Fix RT#31036
[catagits/Catalyst-Authentication-Credential-HTTP.git] / lib / Catalyst / Plugin / Authentication / Credential / HTTP.pm
CommitLineData
d99b7693 1#!/usr/bin/perl
2
a14203f8 3package Catalyst::Plugin::Authentication::Credential::HTTP;
d99b7693 4use base qw/Catalyst::Plugin::Authentication::Credential::Password/;
5
6use strict;
7use warnings;
8
9use String::Escape ();
10use URI::Escape ();
11use Catalyst ();
12use Digest::MD5 ();
13
6afc3665 14our $VERSION = "0.11";
d99b7693 15
16sub authenticate_http {
17 my ( $c, @args ) = @_;
18
19 return 1 if $c->_is_http_auth_type('digest') && $c->authenticate_digest(@args);
20 return 1 if $c->_is_http_auth_type('basic') && $c->authenticate_basic(@args);
21}
22
23sub get_http_auth_store {
24 my ( $c, %opts ) = @_;
25
26 my $store = $opts{store} || $c->config->{authentication}{http}{store} || return;
27
28 return ref $store
29 ? $store
30 : $c->get_auth_store($store);
31}
32
33sub authenticate_basic {
34 my ( $c, %opts ) = @_;
35
36 $c->log->debug('Checking http basic authentication.') if $c->debug;
37
38 my $headers = $c->req->headers;
39
40 if ( my ( $username, $password ) = $headers->authorization_basic ) {
41
42 my $user;
43
44 unless ( $user = $opts{user} ) {
45 if ( my $store = $c->get_http_auth_store(%opts) ) {
46 $user = $store->get_user($username);
47 } else {
48 $user = $username;
49 }
50 }
51
52 return $c->login( $user, $password );
53 }
54
55 return 0;
56}
57
58sub authenticate_digest {
59 my ( $c, %opts ) = @_;
60
61 $c->log->debug('Checking http digest authentication.') if $c->debug;
62
63 my $headers = $c->req->headers;
64 my @authorization = $headers->header('Authorization');
65 foreach my $authorization (@authorization) {
66 next unless $authorization =~ m{^Digest};
67
68 my %res = map {
69 my @key_val = split /=/, $_, 2;
70 $key_val[0] = lc $key_val[0];
71 $key_val[1] =~ s{"}{}g; # remove the quotes
72 @key_val;
73 } split /,\s?/, substr( $authorization, 7 ); #7 == length "Digest "
74
75 my $opaque = $res{opaque};
76 my $nonce = $c->get_digest_authorization_nonce( __PACKAGE__ . '::opaque:' . $opaque );
77 next unless $nonce;
78
79 $c->log->debug('Checking authentication parameters.')
80 if $c->debug;
81
82 my $uri = '/' . $c->request->path;
83 my $algorithm = $res{algorithm} || 'MD5';
84 my $nonce_count = '0x' . $res{nc};
85
86 my $check = $uri eq $res{uri}
87 && ( exists $res{username} )
88 && ( exists $res{qop} )
89 && ( exists $res{cnonce} )
90 && ( exists $res{nc} )
91 && $algorithm eq $nonce->algorithm
92 && hex($nonce_count) > hex( $nonce->nonce_count )
93 && $res{nonce} eq $nonce->nonce; # TODO: set Stale instead
94
95 unless ($check) {
96 $c->log->debug('Digest authentication failed. Bad request.')
97 if $c->debug;
98 $c->res->status(400); # bad request
99 die $Catalyst::DETACH;
100 }
101
102 $c->log->debug('Checking authentication response.')
103 if $c->debug;
104
105 my $username = $res{username};
106 my $realm = $res{realm};
107
108 my $user;
109
110 unless ( $user = $opts{user} ) {
111 if ( my $store = $c->get_http_auth_store(%opts) || $c->default_auth_store ) {
112 $user = $store->get_user($username);
113 }
114 }
115
116 unless ($user) { # no user, no authentication
117 $c->log->debug('Unknown user: $user.') if $c->debug;
118 return 0;
119 }
120
121 # everything looks good, let's check the response
122
123 # calculate H(A2) as per spec
124 my $ctx = Digest::MD5->new;
125 $ctx->add( join( ':', $c->request->method, $res{uri} ) );
126 if ( $res{qop} eq 'auth-int' ) {
127 my $digest =
128 Digest::MD5::md5_hex( $c->request->body ); # not sure here
129 $ctx->add( ':', $digest );
130 }
131 my $A2_digest = $ctx->hexdigest;
132
133 # the idea of the for loop:
134 # if we do not want to store the plain password in our user store,
135 # we can store md5_hex("$username:$realm:$password") instead
136 for my $r ( 0 .. 1 ) {
137
138 # calculate H(A1) as per spec
139 my $A1_digest = $r ? $user->password : do {
140 $ctx = Digest::MD5->new;
141 $ctx->add( join( ':', $username, $realm, $user->password ) );
142 $ctx->hexdigest;
143 };
144 if ( $nonce->algorithm eq 'MD5-sess' ) {
145 $ctx = Digest::MD5->new;
146 $ctx->add( join( ':', $A1_digest, $res{nonce}, $res{cnonce} ) );
147 $A1_digest = $ctx->hexdigest;
148 }
149
150 my $rq_digest = Digest::MD5::md5_hex(
151 join( ':',
152 $A1_digest, $res{nonce},
153 $res{qop} ? ( $res{nc}, $res{cnonce}, $res{qop} ) : (),
154 $A2_digest )
155 );
156
157 $nonce->nonce_count($nonce_count);
158 $c->cache->set( __PACKAGE__ . '::opaque:' . $nonce->opaque,
159 $nonce );
160
161 return $c->login( $user, $user->password )
162 if $rq_digest eq $res{response};
163 }
164 }
165
166 return 0;
167}
168
169sub _check_cache {
170 my $c = shift;
171
172 die "A cache is needed for http digest authentication."
173 unless $c->can('cache');
174}
175
176sub _is_http_auth_type {
177 my ( $c, $type ) = @_;
178
179 my $cfgtype = lc( $c->config->{authentication}{http}{type} || 'any' );
180 return 1 if $cfgtype eq 'any' || $cfgtype eq lc $type;
181 return 0;
182}
183
184sub authorization_required {
185 my ( $c, @args ) = @_;
186
187 return 1 if $c->authenticate_http(@args);
188
189 $c->authorization_required_response(@args);
190
191 die $Catalyst::DETACH;
192}
193
194sub authorization_required_response {
195 my ( $c, %opts ) = @_;
196
197 $c->res->status(401);
198 $c->res->content_type('text/plain');
199 $c->res->body($c->config->{authentication}{http}{authorization_required_message} ||
200 $opts{authorization_required_message} ||
201 'Authorization required.');
202
203 # *DONT* short circuit
204 my $ok;
205 $ok++ if $c->_create_digest_auth_response(\%opts);
206 $ok++ if $c->_create_basic_auth_response(\%opts);
207
208 unless ( $ok ) {
209 die 'Could not build authorization required response. '
210 . 'Did you configure a valid authentication http type: '
211 . 'basic, digest, any';
212 }
213}
214
215sub _add_authentication_header {
216 my ( $c, $header ) = @_;
217 $c->res->headers->push_header( 'WWW-Authenticate' => $header );
218}
219
220sub _create_digest_auth_response {
221 my ( $c, $opts ) = @_;
222
223 return unless $c->_is_http_auth_type('digest');
224
225 if ( my $digest = $c->_build_digest_auth_header( $opts ) ) {
226 $c->_add_authentication_header( $digest );
227 return 1;
228 }
229
230 return;
231}
232
233sub _create_basic_auth_response {
234 my ( $c, $opts ) = @_;
235
236 return unless $c->_is_http_auth_type('basic');
237
238 if ( my $basic = $c->_build_basic_auth_header( $opts ) ) {
239 $c->_add_authentication_header( $basic );
240 return 1;
241 }
242
243 return;
244}
245
246sub _build_auth_header_realm {
247 my ( $c, $opts ) = @_;
248
249 if ( my $realm = $opts->{realm} ) {
6afc3665 250 my $realm_name = String::Escape::qprintable($realm);
251 $realm_name =~ s/"/\\"/g;
252 return 'realm="' . $realm_name . '"';
d99b7693 253 } else {
254 return;
255 }
256}
257
258sub _build_auth_header_domain {
259 my ( $c, $opts ) = @_;
260
261 if ( my $domain = $opts->{domain} ) {
262 Catalyst::Exception->throw("domain must be an array reference")
263 unless ref($domain) && ref($domain) eq "ARRAY";
264
265 my @uris =
266 $c->config->{authentication}{http}{use_uri_for}
267 ? ( map { $c->uri_for($_) } @$domain )
268 : ( map { URI::Escape::uri_escape($_) } @$domain );
269
270 return qq{domain="@uris"};
271 } else {
272 return;
273 }
274}
275
276sub _build_auth_header_common {
277 my ( $c, $opts ) = @_;
278
279 return (
280 $c->_build_auth_header_realm($opts),
281 $c->_build_auth_header_domain($opts),
282 );
283}
284
285sub _build_basic_auth_header {
286 my ( $c, $opts ) = @_;
287 return $c->_join_auth_header_parts( Basic => $c->_build_auth_header_common( $opts ) );
288}
289
290sub _build_digest_auth_header {
291 my ( $c, $opts ) = @_;
292
293 my $nonce = $c->_digest_auth_nonce($opts);
294
295 my $key = __PACKAGE__ . '::opaque:' . $nonce->opaque;
296
297 $c->store_digest_authorization_nonce( $key, $nonce );
a14203f8 298
d99b7693 299 return $c->_join_auth_header_parts( Digest =>
300 $c->_build_auth_header_common($opts),
301 map { sprintf '%s="%s"', $_, $nonce->$_ } qw(
302 qop
303 nonce
304 opaque
305 algorithm
306 ),
307 );
308}
a14203f8 309
d99b7693 310sub _digest_auth_nonce {
311 my ( $c, $opts ) = @_;
312
313 my $package = __PACKAGE__ . '::Nonce';
314
315 my $nonce = $package->new;
316
317 if ( my $algorithm = $opts->{algorithm} || $c->config->{authentication}{http}{algorithm}) {
318 $nonce->algorithm( $algorithm );
319 }
320
321 return $nonce;
322}
323
324sub _join_auth_header_parts {
325 my ( $c, $type, @parts ) = @_;
326 return "$type " . join(", ", @parts );
327}
328
329sub get_digest_authorization_nonce {
330 my ( $c, $key ) = @_;
331
332 $c->_check_cache;
333 $c->cache->get( $key );
334}
335
336sub store_digest_authorization_nonce {
337 my ( $c, $key, $nonce ) = @_;
338
339 $c->_check_cache;
340 $c->cache->set( $key, $nonce );
341}
342
343package Catalyst::Plugin::Authentication::Credential::HTTP::Nonce;
344
345use strict;
346use base qw[ Class::Accessor::Fast ];
347use Data::UUID ();
348
349our $VERSION = "0.01";
350
351__PACKAGE__->mk_accessors(qw[ nonce nonce_count qop opaque algorithm ]);
352
353sub new {
354 my $class = shift;
355 my $self = $class->SUPER::new(@_);
356
357 $self->nonce( Data::UUID->new->create_b64 );
358 $self->opaque( Data::UUID->new->create_b64 );
359 $self->qop('auth,auth-int');
360 $self->nonce_count('0x0');
361 $self->algorithm('MD5');
362
363 return $self;
364}
a14203f8 365
a14203f8 3661;
367
a14203f8 368__END__
369
a14203f8 370=pod
371
a14203f8 372=head1 NAME
373
a14203f8 374Catalyst::Plugin::Authentication::Credential::HTTP - HTTP Basic and Digest authentication
53306b93 375for Catalyst.
a14203f8 376
a14203f8 377=head1 SYNOPSIS
378
a14203f8 379 use Catalyst qw/
a14203f8 380 Authentication
c7b3e379 381 Authentication::Store::Minimal
a14203f8 382 Authentication::Credential::HTTP
a14203f8 383 /;
384
d99b7693 385 __PACKAGE__->config->{authentication}{http}{type} = 'any'; # or 'digest' or 'basic'
386 __PACKAGE__->config->{authentication}{users} = {
387 Mufasa => { password => "Circle Of Life", },
388 };
389
390 sub foo : Local {
391 my ( $self, $c ) = @_;
392
393 $c->authorization_required( realm => "foo" ); # named after the status code ;-)
394
395 # either user gets authenticated or 401 is sent
396
397 do_stuff();
398 }
399
400 # with ACL plugin
401 __PACKAGE__->deny_access_unless("/path", sub { $_[0]->authenticate_http });
402
403 sub end : Private {
404 my ( $self, $c ) = @_;
405
406 $c->authorization_required_response( realm => "foo" );
407 $c->error(0);
408 }
409
a14203f8 410=head1 DESCRIPTION
411
d99b7693 412This moduule lets you use HTTP authentication with
413L<Catalyst::Plugin::Authentication>. Both basic and digest authentication
414are currently supported.
415
416When authentication is required, this module sets a status of 401, and
417the body of the response to 'Authorization required.'. To override
418this and set your own content, check for the C<< $c->res->status ==
419401 >> in your C<end> action, and change the body accordingly.
420
421=head2 TERMS
422
423=over 4
424
425=item Nonce
426
427A nonce is a one-time value sent with each digest authentication
428request header. The value must always be unique, so per default the
429last value of the nonce is kept using L<Catalyst::Plugin::Cache>. To
430change this behaviour, override the
431C<store_digest_authorization_nonce> and
432C<get_digest_authorization_nonce> methods as shown below.
433
434=back
435
436=head1 METHODS
437
438=over 4
439
440=item authorization_required %opts
441
442Tries to C<authenticate_http>, and if that fails calls
443C<authorization_required_response> and detaches the current action call stack.
444
445This method just passes the options through untouched.
446
447=item authenticate_http %opts
448
449Looks inside C<< $c->request->headers >> and processes the digest and basic
450(badly named) authorization header.
451
452This will only try the methods set in the configuration. First digest, then basic.
453
454See the next two methods for what %opts can contain.
455
456=item authenticate_basic %opts
457
458=item authenticate_digest %opts
459
460Try to authenticate one of the methods without checking if the method is
461allowed in the configuration.
462
463%opts can contain C<store> (either an object or a name), C<user> (to disregard
464%the username from the header altogether, overriding it with a username or user
465%object).
466
467=item authorization_required_response %opts
468
469Sets C<< $c->response >> to the correct status code, and adds the correct
470header to demand authentication data from the user agent.
471
472Typically used by C<authorization_required>, but may be invoked manually.
473
474%opts can contain C<realm>, C<domain> and C<algorithm>, which are used to build
475%the digest header.
476
477=item store_digest_authorization_nonce $key, $nonce
478
479=item get_digest_authorization_nonce $key
480
481Set or get the C<$nonce> object used by the digest auth mode.
482
483You may override these methods. By default they will call C<get> and C<set> on
484C<< $c->cache >>.
485
486=item get_http_auth_store %opts
487
488=back
489
490=head1 CONFIGURATION
491
492All configuration is stored in C<< YourApp->config->{authentication}{http} >>.
493
494This should be a hash, and it can contain the following entries:
495
496=over 4
497
498=item store
499
500Either a name or an object -- the default store to use for HTTP authentication.
501
502=item type
503
504Can be either C<any> (the default), C<basic> or C<digest>.
505
506This controls C<authorization_required_response> and C<authenticate_http>, but
507not the "manual" methods.
508
509=item authorization_required_message
510
511Set this to a string to override the default body content "Authorization required."
512
513=back
514
515=head1 RESTRICTIONS
516
517When using digest authentication, this module will only work together
518with authentication stores whose User objects have a C<password>
519method that returns the plain-text password. It will not work together
520with L<Catalyst::Authentication::Store::Htpasswd>, or
521L<Catalyst::Plugin::Authentication::Store::DBIC> stores whose
522C<password> methods return a hashed or salted version of the password.
c7b3e379 523
a14203f8 524=head1 AUTHORS
525
a14203f8 526Yuval Kogman, C<nothingmuch@woobling.org>
527
a14203f8 528Jess Robinson
529
a14203f8 530Sascha Kiefer C<esskar@cpan.org>
531
c7b3e379 532=head1 SEE ALSO
533
d99b7693 534RFC 2617 (or its successors), L<Catalyst::Plugin::Cache>, L<Catalyst::Plugin::Authentication>
c7b3e379 535
a14203f8 536=head1 COPYRIGHT & LICENSE
537
a14203f8 538 Copyright (c) 2005-2006 the aforementioned authors. All rights
a14203f8 539 reserved. This program is free software; you can redistribute
a14203f8 540 it and/or modify it under the same terms as Perl itself.
541
a14203f8 542=cut