File Coverage

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


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::DigestAuth::RequestHandler;
2              
3 6     6   21 use strict;
  6         8  
  6         150  
4 6     6   21 use warnings;
  6         7  
  6         117  
5              
6 6     6   21 use Carp 'croak';
  6         92  
  6         216  
7 6     6   28 use Scalar::Util 'weaken';
  6         5  
  6         206  
8              
9 6     6   21 use Mojo::Util qw{quote b64_encode b64_decode};
  6         6  
  6         254  
10 6     6   21 use Mojolicious::Plugin::DigestAuth::Util qw{checksum parse_header};
  6         7  
  6         7956  
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 111 my ($class, $config) = @_;
23 70   50     811 my $header = {
      100        
      66        
24             qop => $config->{qop},
25             realm => $config->{realm} || '',
26             domain => $config->{domain} || '/',
27             algorithm => $config->{algorithm} || $ALGORITHM_MD5,
28             };
29              
30             # No qop = ''
31 70 100       231 $header->{qop} = $QOP_AUTH unless defined $header->{qop}; # "$QOP_AUTH,$QOP_AUTH_INT"
32 70         190 $header->{opaque} = checksum($header->{domain}, $config->{secret});
33              
34 70         750 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             };
44              
45 70 100       228 $self->{support_broken_browsers} = 1 unless defined $self->{support_broken_browsers};
46              
47 70         262 for my $qop (split /\s*,\s*/, $header->{qop}) {
48 68 100       413 croak "unsupported qop: $qop" unless $VALID_QOPS{$qop};
49 67         202 $self->{qops}->{$qop} = 1;
50             }
51              
52 69 100       439 croak "unsupported algorithm: $self->{algorithm}" unless $VALID_ALGORITHMS{$self->{algorithm}};
53 68 100 100     245 croak "algorithm $ALGORITHM_MD5_SESS requires a qop" if $self->{algorithm} eq $ALGORITHM_MD5_SESS and ! %{$self->{qops}};
  2         238  
54              
55 67         248 bless $self, $class;
56             }
57              
58             sub _request
59             {
60 290     290   1130 (shift)->_controller->req;
61             }
62              
63             sub _response
64             {
65 125     125   161 (shift)->_controller->res;
66             }
67              
68             sub _controller
69             {
70 459     459   1081 (shift)->{controller};
71             }
72              
73             sub _nonce_expired
74             {
75 25     25   33 my ($self, $nonce) = @_;
76 25         27 my $t;
77              
78 25         52 $t = ($self->_parse_nonce($nonce))[0];
79 25 50       437 $t && (time() - int($t)) > $self->{expires};
80             }
81              
82             sub _parse_nonce
83             {
84 54     54   67 my ($self, $nonce) = @_;
85 54         119 split ' ', b64_decode($nonce), 2;
86             }
87              
88             sub _valid_nonce
89             {
90 29     29   37 my ($self, $nonce) = @_;
91 29         77 my ($t, $sig) = $self->_parse_nonce($nonce);
92              
93 29 100 66     377 $t && $sig && $sig eq checksum($t, $self->{secret});
94             }
95              
96             sub _create_nonce
97             {
98 37     37   43 my $self = shift;
99 37         55 my $t = time();
100 37         95 my $nonce = b64_encode(sprintf('%s %s', $t, checksum($t, $self->{secret})));
101 37         606 chomp $nonce;
102 37         84 $nonce;
103             }
104              
105             sub authenticate
106             {
107 68     68 0 135 my $self = shift;
108              
109 68         154 $self->{controller} = shift;
110 68         190 weaken $self->{controller};
111              
112 68         65 $self->{response_header} = { %{$self->{default_header}} };
  68         317  
113              
114 68         180 my $auth = $self->_auth_header;
115 68 100       1142 if($auth) {
116 36         104 my $header = parse_header($auth);
117 36 100       111 if(!$self->_valid_header($header)) {
118 7         58 $self->_bad_request;
119 7         2237 return;
120             }
121              
122 29 100       806 if($self->_authorized($header)) {
123 25 100       183 return 1 unless $self->_nonce_expired($header->{nonce});
124 1         4 $self->{response_header}->{stale} = 'true';
125             }
126             }
127              
128 37         90 $self->_unauthorized;
129 37         11871 return;
130             }
131              
132             # TODO: $self->_request->headers->proxy_authorization
133             sub _auth_header
134             {
135 68     68   85 my $self = shift;
136 68 100       166 $self->_request->headers->authorization or
137             $self->_request->env->{'X_HTTP_AUTHORIZATION'} # Mojo does s/-/_/g
138             }
139              
140             sub _unauthorized
141             {
142 37     37   41 my $self = shift;
143 37         105 my $header = $self->_build_auth_header;
144              
145 37         103 $self->_response->headers->www_authenticate($header);
146 37         1118 $self->_response->headers->content_type('text/plain');
147 37         696 $self->_response->code(401);
148 37         326 $self->_controller->render(text => 'HTTP 401: Unauthorized');
149             }
150              
151             sub _bad_request
152             {
153 7     7   11 my $self = shift;
154 7         18 $self->_response->code(400);
155 7         73 $self->_response->headers->content_type('text/plain');
156 7         151 $self->_controller->render(text => 'HTTP 400: Bad Request');
157             }
158              
159             sub _valid_header
160             {
161 36     36   45 my ($self, $header) = @_;
162              
163 36 100 66     113 $self->_header_complete($header) &&
      100        
      100        
164             $self->_url_matches($header->{uri}) &&
165             $self->_valid_qop($header->{qop}, $header->{nc}) &&
166             $self->_valid_opaque($header->{opaque}) &&
167             $self->{algorithm} eq $header->{algorithm};
168             }
169              
170             sub _url_matches
171             {
172 36     36   50 my $self = shift;
173              
174 36         54 my $auth_url = shift;
175 36 100       80 return unless $auth_url;
176 35         250 $auth_url = _normalize_url($auth_url);
177              
178 35         3878 my $req_url = $self->_url;
179              
180 35 100       2733 if($self->_support_broken_browser) {
181             # IE 5/6 do not append the querystring on GET requests
182 3         76 my $i = index($req_url, '?');
183 3 100 33     8 if($self->_request->method eq 'GET' && $i != -1 && index($auth_url, '?') == -1) {
      66        
184 1         28 $auth_url .= '?' . substr($req_url, $i+1);
185             }
186             }
187              
188 35         913 $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   60 my $self = shift;
203 35         66 my $env = $self->_request->env;
204 35         301 my $url;
205              
206 35 100       163 if($env->{REQUEST_URI}) {
    100          
    50          
207 1         3 $url = $env->{REQUEST_URI};
208             }
209             elsif($env->{SCRIPT_NAME}) {
210 3         5 $url = $env->{SCRIPT_NAME};
211 3 100       11 $url .= $env->{PATH_INFO} if $env->{PATH_INFO};
212 3 100       9 $url .= "?$env->{QUERY_STRING}" if $env->{QUERY_STRING};
213             }
214             elsif($self->_request->url) {
215 31         343 $url = $self->_request->url->to_string;
216             }
217             else {
218 0         0 $url = '/';
219             }
220              
221 35         2191 _normalize_url($url);
222             }
223              
224             # We want the URL to be relative to '/'
225             sub _normalize_url
226             {
227 70     70   81 my $s = shift;
228 70         121 $s =~ s|^https?://[^/?#]*||i;
229 70         81 $s =~ s|/{2,}|/|g;
230              
231 70         212 my $url = Mojo::URL->new($s);
232 70         2954 my @parts = @{$url->path->parts};
  70         116  
233 70         2360 my @normalized;
234              
235 70         113 for my $part (@parts) {
236 42 50 33     98 if($part eq '..' && @normalized) {
237 0         0 pop @normalized;
238 0         0 next;
239             }
240              
241 42         95 push @normalized, $part;
242             }
243              
244 70         157 $url->path->parts(\@normalized);
245 70         730 $url->path->leading_slash(0);
246 70         705 $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   68 my $self = shift;
253 67 100       219 $self->{support_broken_browsers} && $self->_request->headers->user_agent =~ m|\bMSIE\s+[56]\.|;
254             }
255              
256             sub _valid_qop
257             {
258 33     33   58 my ($self, $qop, $nc) = @_;
259 33         34 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       82 if(defined $qop) {
268 33   66     131 $valid = $self->{qops}->{$qop} && $nc;
269             }
270             else {
271 0   0     0 $valid = !%{$self->{qops}} && !defined $nc;
272             }
273              
274 33         159 $valid;
275             }
276              
277             sub _valid_opaque
278             {
279 32     32   41 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     61 $self->_support_broken_browser || $opaque && $opaque eq $self->{opaque};
283             }
284              
285             sub _header_complete
286             {
287 36     36   47 my ($self, $header) = @_;
288              
289 36 50 33     616 $header &&
      33        
      33        
      33        
290             $header->{realm} &&
291             $header->{nonce} &&
292             $header->{response} &&
293             $header->{algorithm} &&
294             exists $header->{username};
295             }
296              
297             sub _build_auth_header
298             {
299 37     37   54 my $self = shift;
300 37         61 my $header = $self->{response_header};
301              
302 37 50 66     176 if($header->{stale} || !$header->{nonce}) {
303 37         108 $header->{nonce} = $self->_create_nonce;
304             }
305              
306 37         41 my %no_quote;
307 37         82 @no_quote{qw{algorithm stale}} = ();
308              
309 37         41 my @auth;
310 37         146 while(my ($k, $v) = each %$header) {
311 223 100       258 next unless $v;
312 222 100       451 $v = quote($v) unless exists $no_quote{$k};
313 222         1113 push @auth, "$k=$v";
314             }
315              
316 37         165 'Digest ' . join(', ', @auth);
317             }
318              
319             sub _authorized
320             {
321 29     29   35 my ($self, $header) = @_;
322 29 100       90 return unless $self->_valid_nonce($header->{nonce});
323              
324 28         232 my $a1 = $self->_compute_a1($header);
325 28 100       70 return unless $a1;
326              
327 26         51 my @fields = ($a1, $header->{nonce});
328 26 50       69 if($header->{qop}) {
329 26         96 push @fields, $header->{nc},
330             $header->{cnonce},
331             $header->{qop},
332             $self->_compute_a2($header);
333             }
334             else {
335 0         0 push @fields, $self->_compute_a2($header);
336             }
337              
338 26         142 checksum(@fields) eq $header->{response};
339             }
340              
341             sub _compute_a1
342             {
343 28     28   38 my ($self, $header) = @_;
344 28         185 my $hash = $self->{password_db}->get($header->{realm}, $header->{username});
345              
346 28 50 66     180 if($hash && $header->{algorithm} && $header->{algorithm} eq $ALGORITHM_MD5_SESS) {
      66        
347 0         0 $hash = checksum($hash, $header->{nonce}, $header->{cnonce});
348             }
349              
350 28         43 $hash;
351             }
352              
353             sub _compute_a2
354             {
355 26     26   38 my ($self, $header) = @_;
356 26         73 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         299 checksum(@fields);
365             }
366              
367             1;