File Coverage

blib/lib/Mojolicious/Plugin/DigestAuth/RequestHandler.pm
Criterion Covered Total %
statement 149 155 96.1
branch 58 66 87.8
condition 36 61 59.0
subroutine 30 30 100.0
pod 0 2 0.0
total 273 314 86.9


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::DigestAuth::RequestHandler;
2              
3 6     6   43 use strict;
  6         15  
  6         186  
4 6     6   31 use warnings;
  6         13  
  6         174  
5              
6 6     6   31 use Carp 'croak';
  6         13  
  6         301  
7 6     6   37 use Scalar::Util 'weaken';
  6         13  
  6         303  
8              
9 6     6   51 use Mojo::Util qw{quote b64_encode b64_decode};
  6         12  
  6         426  
10 6     6   57 use Mojolicious::Plugin::DigestAuth::Util qw{checksum parse_header};
  6         17  
  6         13794  
11              
12             my $QOP_AUTH = 'auth';
13             my $QOP_AUTH_INT = 'auth-int';
14             my %VALID_QOPS = ($QOP_AUTH => 1); #, $QOP_AUTH_INT => 1);
15              
16             my $ALGORITHM_MD5 = 'MD5';
17             my $ALGORITHM_MD5_SESS = 'MD5-sess';
18             my %VALID_ALGORITHMS = ($ALGORITHM_MD5 => 1, $ALGORITHM_MD5_SESS => 1);
19              
20             sub new
21             {
22 70     70 0 202 my ($class, $config) = @_;
23             my $header = {
24             qop => $config->{qop},
25             realm => $config->{realm} || '',
26             domain => $config->{domain} || '/',
27 70   50     814 algorithm => $config->{algorithm} || $ALGORITHM_MD5,
      100        
      66        
28             };
29              
30             # No qop = ''
31 70 100       271 $header->{qop} = $QOP_AUTH unless defined $header->{qop}; # "$QOP_AUTH,$QOP_AUTH_INT"
32 70         237 $header->{opaque} = checksum($header->{domain}, $config->{secret});
33              
34             my $self = {
35             qops => {},
36             opaque => $header->{opaque},
37             secret => $config->{secret},
38             expires => $config->{expires},
39             algorithm => $header->{algorithm},
40             password_db => $config->{password_db},
41             default_header => $header,
42             support_broken_browsers => $config->{support_broken_browsers}
43 70         554 };
44              
45 70 100       250 $self->{support_broken_browsers} = 1 unless defined $self->{support_broken_browsers};
46              
47 70         306 for my $qop (split /\s*,\s*/, $header->{qop}) {
48 68 100       414 croak "unsupported qop: $qop" unless $VALID_QOPS{$qop};
49 67         192 $self->{qops}->{$qop} = 1;
50             }
51              
52 69 100       447 croak "unsupported algorithm: $self->{algorithm}" unless $VALID_ALGORITHMS{$self->{algorithm}};
53 68 100 100     248 croak "algorithm $ALGORITHM_MD5_SESS requires a qop" if $self->{algorithm} eq $ALGORITHM_MD5_SESS and ! %{$self->{qops}};
  2         257  
54              
55 67         229 bless $self, $class;
56             }
57              
58             sub _request
59             {
60 290     290   1530 (shift)->_controller->req;
61             }
62              
63             sub _response
64             {
65 125     125   258 (shift)->_controller->res;
66             }
67              
68             sub _controller
69             {
70 459     459   1404 (shift)->{controller};
71             }
72              
73             sub _nonce_expired
74             {
75 25     25   69 my ($self, $nonce) = @_;
76 25         45 my $t;
77              
78 25         75 $t = ($self->_parse_nonce($nonce))[0];
79 25 50       447 $t && (time() - int($t)) > $self->{expires};
80             }
81              
82             sub _parse_nonce
83             {
84 54     54   120 my ($self, $nonce) = @_;
85 54         371 split ' ', b64_decode($nonce), 2;
86             }
87              
88             sub _valid_nonce
89             {
90 29     29   83 my ($self, $nonce) = @_;
91 29         110 my ($t, $sig) = $self->_parse_nonce($nonce);
92              
93 29 100 66     219 $t && $sig && $sig eq checksum($t, $self->{secret});
94             }
95              
96             sub _create_nonce
97             {
98 37     37   83 my $self = shift;
99 37         78 my $t = time();
100 37         145 my $nonce = b64_encode(sprintf('%s %s', $t, checksum($t, $self->{secret})));
101 37         133 chomp $nonce;
102 37         115 $nonce;
103             }
104              
105             sub authenticate
106             {
107 68     68 0 231 my $self = shift;
108              
109 68         202 $self->{controller} = shift;
110 68         287 weaken $self->{controller};
111              
112 68         128 $self->{response_header} = { %{$self->{default_header}} };
  68         366  
113              
114 68         238 my $auth = $self->_auth_header;
115 68 100       1586 if($auth) {
116 36         256 my $header = parse_header($auth);
117 36 100       165 if(!$self->_valid_header($header)) {
118 7         85 $self->_bad_request;
119 7         25623 return;
120             }
121              
122 29 100       980 if($self->_authorized($header)) {
123 25 100       103 return 1 unless $self->_nonce_expired($header->{nonce});
124 1         7 $self->{response_header}->{stale} = 'true';
125             }
126             }
127              
128 37         144 $self->_unauthorized;
129 37         134341 return;
130             }
131              
132             # TODO: $self->_request->headers->proxy_authorization
133             sub _auth_header
134             {
135 68     68   144 my $self = shift;
136             $self->_request->headers->authorization or
137 68 100       208 $self->_request->env->{'X_HTTP_AUTHORIZATION'} # Mojo does s/-/_/g
138             }
139              
140             sub _unauthorized
141             {
142 37     37   76 my $self = shift;
143 37         154 my $header = $self->_build_auth_header;
144              
145 37         142 $self->_response->headers->www_authenticate($header);
146 37         919 $self->_response->headers->content_type('text/plain');
147 37         695 $self->_response->code(401);
148 37         510 $self->_controller->render(text => 'HTTP 401: Unauthorized');
149             }
150              
151             sub _bad_request
152             {
153 7     7   19 my $self = shift;
154 7         19 $self->_response->code(400);
155 7         115 $self->_response->headers->content_type('text/plain');
156 7         145 $self->_controller->render(text => 'HTTP 400: Bad Request');
157             }
158              
159             sub _valid_header
160             {
161 36     36   116 my ($self, $header) = @_;
162              
163             $self->_header_complete($header) &&
164             $self->_url_matches($header->{uri}) &&
165             $self->_valid_qop($header->{qop}, $header->{nc}) &&
166             $self->_valid_opaque($header->{opaque}) &&
167 36 100 66     154 $self->{algorithm} eq $header->{algorithm};
      100        
      100        
168             }
169              
170             sub _url_matches
171             {
172 36     36   89 my $self = shift;
173              
174 36         82 my $auth_url = shift;
175 36 100       105 return unless $auth_url;
176 35         133 $auth_url = _normalize_url($auth_url);
177              
178 35         7134 my $req_url = $self->_url;
179              
180 35 100       5174 if($self->_support_broken_browser) {
181             # IE 5/6 do not append the querystring on GET requests
182 3         117 my $i = index($req_url, '?');
183 3 100 33     12 if($self->_request->method eq 'GET' && $i != -1 && index($auth_url, '?') == -1) {
      66        
184 1         21 $auth_url .= '?' . substr($req_url, $i+1);
185             }
186             }
187              
188 35         1145 $auth_url eq $req_url;
189             }
190              
191             #
192             # We try to avoid using the URL provided by Mojo because:
193             #
194             # * Depending on the app's config it will not contain the URL requested by the client
195             # it will contain PATH_INFO + QUERY_STRING i.e. /mojo.pl/users/sshaw?x=y will be /users/sshaw?x=y
196             #
197             # * Mojo::URL has/had several bugs and has undergone several changes that have broken backwards
198             # compatibility.
199             #
200             sub _url
201             {
202 35     35   80 my $self = shift;
203 35         103 my $env = $self->_request->env;
204 35         551 my $url;
205              
206 35 100       184 if($env->{REQUEST_URI}) {
    100          
    50          
207 1         4 $url = $env->{REQUEST_URI};
208             }
209             elsif($env->{SCRIPT_NAME}) {
210 3         7 $url = $env->{SCRIPT_NAME};
211 3 100       14 $url .= $env->{PATH_INFO} if $env->{PATH_INFO};
212 3 100       14 $url .= "?$env->{QUERY_STRING}" if $env->{QUERY_STRING};
213             }
214             elsif($self->_request->url) {
215 31         499 $url = $self->_request->url->to_string;
216             }
217             else {
218 0         0 $url = '/';
219             }
220              
221 35         4525 _normalize_url($url);
222             }
223              
224             # We want the URL to be relative to '/'
225             sub _normalize_url
226             {
227 70     70   156 my $s = shift;
228 70         169 $s =~ s|^https?://[^/?#]*||i;
229 70         153 $s =~ s|/{2,}|/|g;
230              
231 70         263 my $url = Mojo::URL->new($s);
232 70         5013 my @parts = @{$url->path->parts};
  70         181  
233 70         3978 my @normalized;
234              
235 70         227 for my $part (@parts) {
236 42 50 33     129 if($part eq '..' && @normalized) {
237 0         0 pop @normalized;
238 0         0 next;
239             }
240              
241 42         145 push @normalized, $part;
242             }
243              
244 70         222 $url->path->parts(\@normalized);
245 70         1259 $url->path->leading_slash(0);
246 70         1226 $url->to_string;
247             }
248              
249             # TODO (maybe): IE 6 sends a new nonce every time when using MD5-sess
250             sub _support_broken_browser
251             {
252 67     67   131 my $self = shift;
253 67 100       264 $self->{support_broken_browsers} && $self->_request->headers->user_agent =~ m|\bMSIE\s+[56]\.|;
254             }
255              
256             sub _valid_qop
257             {
258 33     33   115 my ($self, $qop, $nc) = @_;
259 33         61 my $valid;
260              
261             #
262             # Either there's no QOP from the client and we require one, or the client does not
263             # send a qop because they dont support what we want (e.g., auth-int).
264             #
265             # And, if there's a qop, then there must be a nonce count.
266             #
267 33 50       115 if(defined $qop) {
268 33   66     162 $valid = $self->{qops}->{$qop} && $nc;
269             }
270             else {
271 0   0     0 $valid = !%{$self->{qops}} && !defined $nc;
272             }
273              
274 33         204 $valid;
275             }
276              
277             sub _valid_opaque
278             {
279 32     32   97 my ($self, $opaque) = @_;
280              
281             # IE 5 & 6 only sends opaque with the initial reply but we'll just ignore it regardless
282 32 100 100     92 $self->_support_broken_browser || $opaque && $opaque eq $self->{opaque};
283             }
284              
285             sub _header_complete
286             {
287 36     36   103 my ($self, $header) = @_;
288              
289             $header &&
290             $header->{realm} &&
291             $header->{nonce} &&
292             $header->{response} &&
293             $header->{algorithm} &&
294 36 50 33     603 exists $header->{username};
      33        
      33        
      33        
295             }
296              
297             sub _build_auth_header
298             {
299 37     37   77 my $self = shift;
300 37         104 my $header = $self->{response_header};
301              
302 37 50 66     233 if($header->{stale} || !$header->{nonce}) {
303 37         131 $header->{nonce} = $self->_create_nonce;
304             }
305              
306 37         79 my %no_quote;
307 37         137 @no_quote{qw{algorithm stale}} = ();
308              
309 37         90 my @auth;
310 37         199 while(my ($k, $v) = each %$header) {
311 223 100       471 next unless $v;
312 222 100       653 $v = quote($v) unless exists $no_quote{$k};
313 222         1915 push @auth, "$k=$v";
314             }
315              
316 37         211 'Digest ' . join(', ', @auth);
317             }
318              
319             sub _authorized
320             {
321 29     29   87 my ($self, $header) = @_;
322 29 100       106 return unless $self->_valid_nonce($header->{nonce});
323              
324 28         122 my $a1 = $self->_compute_a1($header);
325 28 100       107 return unless $a1;
326              
327 26         97 my @fields = ($a1, $header->{nonce});
328 26 50       94 if($header->{qop}) {
329             push @fields, $header->{nc},
330             $header->{cnonce},
331             $header->{qop},
332 26         125 $self->_compute_a2($header);
333             }
334             else {
335 0         0 push @fields, $self->_compute_a2($header);
336             }
337              
338 26         94 checksum(@fields) eq $header->{response};
339             }
340              
341             sub _compute_a1
342             {
343 28     28   81 my ($self, $header) = @_;
344 28         184 my $hash = $self->{password_db}->get($header->{realm}, $header->{username});
345              
346 28 50 66     224 if($hash && $header->{algorithm} && $header->{algorithm} eq $ALGORITHM_MD5_SESS) {
      33        
347 0         0 $hash = checksum($hash, $header->{nonce}, $header->{cnonce});
348             }
349              
350 28         78 $hash;
351             }
352              
353             sub _compute_a2
354             {
355 26     26   70 my ($self, $header) = @_;
356 26         69 my @fields = ($self->_request->method, $header->{uri});
357              
358             # Not yet...
359             # if(defined $header->{qop} && $header->{qop} eq $QOP_AUTH_INT) {
360             # # TODO: has body been decoded?
361             # push @fields, checksum($self->_request->content->headers->to_string . "\015\012\015\012" . $self->_request->body);
362             # }
363              
364 26         399 checksum(@fields);
365             }
366              
367             1;