Bump version and update Changes
[plack-middleware-auth-complex.git] / lib / Plack / Middleware / Auth / Complex.pm
CommitLineData
12aa0bc6
MG
1package Plack::Middleware::Auth::Complex;
2
3use 5.014000;
4use strict;
5use warnings;
6
9e0352f7 7our $VERSION = '0.003001';
12aa0bc6
MG
8
9use parent qw/Plack::Middleware/;
1f7bfc27 10use re '/s';
12aa0bc6
MG
11
12use Authen::Passphrase;
13use Authen::Passphrase::BlowfishCrypt;
61456017
MG
14use Data::Entropy qw/entropy_source/;
15use Data::Entropy::Source;
16use Data::Entropy::RawSource::Local;
17use Carp qw/carp croak/;
12aa0bc6 18use DBI;
4c1b8033 19use Digest::SHA qw/hmac_sha1_base64 sha256/;
12aa0bc6
MG
20use Email::Simple;
21use Email::Sender::Simple qw/sendmail/;
22use MIME::Base64 qw/decode_base64/;
23use Plack::Request;
4c1b8033 24use Tie::Hash::Expire;
12aa0bc6 25
61456017
MG
26sub make_entropy_source {
27 if (-e '/dev/urandom') {
28 Data::Entropy::Source->new(
29 Data::Entropy::RawSource::Local->new('/dev/urandom'),
30 'sysread'
31 )
32 } else {
33 carp "/dev/urandom not found, using insecure random source\n";
34 entropy_source
35 }
36}
37
12aa0bc6
MG
38sub default_opts {(
39 dbi_connect => ['dbi:Pg:', '', ''],
40 select_user => 'SELECT passphrase, email FROM users WHERE id = ?',
41 update_pass => 'UPDATE users SET passphrase = ? WHERE id = ?',
42 insert_user => 'INSERT INTO users (id, passphrase, email) VALUES (?,?,?)',
43 mail_subject => 'Password reset token',
44 realm => 'restricted area',
4c1b8033
MG
45 cache_fail => 0,
46 cache_max_age => 5 * 60,
fe1dfcf1 47 token_max_age => 60 * 60,
1f7bfc27 48 username_regex => qr/^\w{2,20}$/as,
cd19b1b4 49 invalid_username => 'Invalid username',
388ed281
MG
50 register_url => '/action/register',
51 passwd_url => '/action/passwd',
52 request_reset_url => '/action/request-reset',
53 reset_url => '/action/reset'
12aa0bc6
MG
54)}
55
56sub new {
57 my ($class, $opts) = @_;
58 my %self = $class->default_opts;
59 %self = (%self, %$opts);
61456017 60 $self{entropy_source} //= make_entropy_source;
58b36d0a
MG
61 # If the user did not set [use_scrypt], we set it to 1 if scrypt
62 # is available and to 0 otherwise.
63 # If the user set [use_scrypt] to 1, we try to load scrypt and
64 # croak if we fail to do so.
65 unless (exists $self{use_scrypt}) {
66 my $success = eval 'use Authen::Passphrase::Scrypt';
67 $self{use_scrypt} = !!$success
68 }
69 if ($self{use_scrypt}) {
70 eval 'use Authen::Passphrase::Scrypt; 1' or croak "Failed to load Authen::Passphrase::Scrypt: $@\n";
71 }
12aa0bc6 72 my $self = bless \%self, $class;
12aa0bc6
MG
73 $self
74}
75
76sub init {
77 my ($self) = @_;
1f7bfc27 78 $self->{dbh} = DBI->connect(@{$self->{dbi_connect}}) or croak $DBI::errstr;
9d9f4067 79 $self->{post_connect_cb}->($self) if $self->{post_connect_cb}; # uncoverable branch false
1f7bfc27
MG
80 $self->{insert_sth} = $self->{dbh}->prepare($self->{insert_user}) or croak $self->{dbh}->errstr;
81 $self->{select_sth} = $self->{dbh}->prepare($self->{select_user}) or croak $self->{dbh}->errstr;
82 $self->{update_sth} = $self->{dbh}->prepare($self->{update_pass}) or croak $self->{dbh}->errstr;
12aa0bc6
MG
83}
84
8637972b
MG
85sub create_user {
86 my ($self, $parms) = @_;
87 my %parms = $parms->flatten;
6af8d6cc 88 $self->{insert_sth}->execute($parms{username}, $self->hash_passphrase($parms{password}), $parms{email}) or croak $self->{insert_sth}->errstr;
8637972b
MG
89}
90
12aa0bc6
MG
91sub get_user {
92 my ($self, $user) = @_;
6af8d6cc 93 $self->{select_sth}->execute($user) or croak $self->{select_sth}->errstr;
12aa0bc6
MG
94 $self->{select_sth}->fetchrow_hashref
95}
96
97sub check_passphrase {
98 my ($self, $username, $passphrase) = @_;
4c1b8033 99 unless ($self->{cache}) {
1f7bfc27 100 ## no critic (ProhibitTies)
4c1b8033
MG
101 tie my %cache, 'Tie::Hash::Expire', {expire_seconds => $self->{cache_max_age}};
102 $self->{cache} = \%cache;
103 }
104 my $cachekey = sha256 "$username:$passphrase";
9d9f4067 105 return $self->{cache}{$cachekey} if exists $self->{cache}{$cachekey}; # uncoverable branch true
12aa0bc6
MG
106 my $user = $self->get_user($username);
107 return 0 unless $user;
58b36d0a
MG
108 my $ret;
109 if ($user->{passphrase} =~ /^{SCRYPT}/) {
110 croak "$username has a scrypt password but use_scrypt is false\n" unless $self->{use_scrypt};
111 $ret = Authen::Passphrase::Scrypt->from_rfc2307($user->{passphrase})
112 } else {
113 $ret = Authen::Passphrase->from_rfc2307($user->{passphrase});
114 }
115 $ret = $ret->match($passphrase);
4c1b8033
MG
116 $self->{cache}{$cachekey} = $ret if $ret || $self->{cache_fail};
117 $ret
12aa0bc6
MG
118}
119
120sub hash_passphrase {
121 my ($self, $passphrase) = @_;
58b36d0a
MG
122 if ($self->{use_scrypt}) {
123 Authen::Passphrase::Scrypt->new({
124 passphrase => $passphrase,
125 })->as_rfc2307
126 } else {
127 Authen::Passphrase::BlowfishCrypt->new(
128 cost => 10,
129 passphrase => $passphrase,
130 salt_random => 1,
131 )->as_rfc2307
132 }
12aa0bc6
MG
133}
134
135sub set_passphrase {
136 my ($self, $username, $passphrase) = @_;
6af8d6cc 137 $self->{update_sth}->execute($self->hash_passphrase($passphrase), $username) or croak $self->{update_sth}->errstr;
12aa0bc6
MG
138}
139
140sub make_reset_hmac {
141 my ($self, $username, @data) = @_;
61456017 142 $self->{hmackey} //= $self->{entropy_source}->get_bits(8 * 512); # uncoverable condition false
12aa0bc6
MG
143 my $user = $self->get_user($username);
144 my $message = join ' ', $username, $user->{passphrase}, @data;
145 hmac_sha1_base64 $message, $self->{hmackey};
146}
147
148sub mail_body {
149 my ($self, $username, $token) = @_;
150 my $hours = $self->{token_max_age} / 60 / 60;
9d9f4067 151 $hours .= $hours == 1 ? ' hour' : ' hours'; # uncoverable branch false
1f7bfc27 152 <<"EOF";
12aa0bc6
MG
153Someone has requested a password reset for your account.
154
155To reset your password, please submit the reset password form on the
156website using the following information:
157
158Username: $username
159Password: <your new password>
160Reset token: $token
161
162The token is valid for $hours.
163EOF
164}
165
166sub send_reset_email {
167 my ($self, $username) = @_;
168 my $expire = time + $self->{token_max_age};
169 my $token = $self->make_reset_hmac($username, $expire) . ":$expire";
170 my $user = $self->get_user($username);
171 sendmail (Email::Simple->create(
172 header => [
173 From => $self->{mail_from},
174 To => $user->{email},
876ab8b5 175 Subject => $self->{mail_subject},
12aa0bc6
MG
176 ],
177 body => $self->mail_body($username, $token),
178 ));
179}
180
181##################################################
182
183sub response {
184 my ($self, $code, $body) = @_;
185 return [
186 $code,
187 ['Content-Type' => 'text/plain',
188 'Content-Length' => length $body],
189 [ $body ],
190 ];
191}
192
193sub reply { shift->response(200, $_[0]) }
194sub bad_request { shift->response(400, $_[0]) }
195sub internal_server_error { shift->response(500, $_[0]) }
196
197sub unauthorized {
198 my ($self) = @_;
199 my $body = 'Authorization required';
200 return [
201 401,
202 ['Content-Type' => 'text/plain',
203 'Content-Length' => length $body,
204 'WWW-Authenticate' => 'Basic realm="' . $self->{realm} . '"' ],
205 [ $body ],
206 ];
207}
208
209##################################################
210
211sub call_register {
212 my ($self, $req) = @_;
213 my %parms;
214 for (qw/username password confirm_password email/) {
215 $parms{$_} = $req->param($_);
216 return $self->bad_request("Missing parameter $_") unless $parms{$_};
217 }
218
cd19b1b4 219 return $self->bad_request($self->{invalid_username}) unless $parms{username} =~ $self->{username_regex};
12aa0bc6
MG
220 return $self->bad_request('Username already in use') if $self->get_user($parms{username});
221 return $self->bad_request('The two passwords do not match') unless $parms{password} eq $parms{confirm_password};
8637972b
MG
222
223 $self->create_user($req->parameters);
12aa0bc6
MG
224 return $self->reply('Registered successfully')
225}
226
227sub call_passwd {
228 my ($self, $req) = @_;
229 return $self->unauthorized unless $req->user;
230 my %parms;
231 for (qw/password new_password confirm_new_password/) {
232 $parms{$_} = $req->param($_);
233 return $self->bad_request("Missing parameter $_") unless $parms{$_};
234 }
235
236 return $self->bad_request('Incorrect password') unless $self->check_passphrase($req->user, $parms{password});
237 return $self->bad_request('The two passwords do not match') unless $parms{new_password} eq $parms{confirm_new_password};
238 $self->set_passphrase($req->user, $parms{new_password});
239 return $self->reply('Password changed successfully');
240}
241
242sub call_request_reset {
243 my ($self, $req) = @_;
244 return $self->internal_server_error('Password resets are disabled') unless $self->{mail_from};
245 my $username = $req->param('username');
246 my $user = $self->get_user($username) or return $self->bad_request('No such user');
12aa0bc6
MG
247 eval {
248 $self->send_reset_email($username);
1f7bfc27
MG
249 1
250 } or return $self->internal_server_error($@);
251 $self->reply('Email sent');
12aa0bc6
MG
252}
253
254sub call_reset {
255 my ($self, $req) = @_;
256 my %parms;
257 for (qw/username new_password confirm_new_password token/) {
258 $parms{$_} = $req->param($_);
259 return $self->bad_request("Missing parameter $_") unless $parms{$_};
260 }
261
262 my $user = $self->get_user($parms{username}) or return $self->bad_request('No such user');
263 return $self->bad_request('The two passwords do not match') unless $parms{new_password} eq $parms{confirm_new_password};
1f7bfc27 264 my ($token, $exp) = split /:/, $parms{token};
12aa0bc6
MG
265 my $goodtoken = $self->make_reset_hmac($parms{username}, $exp);
266 return $self->bad_request('Bad reset token') unless $token eq $goodtoken;
267 return $self->bad_request('Reset token has expired') if time >= $exp;
268 $self->set_passphrase($parms{username}, $parms{new_password});
269 return $self->reply('Password reset successfully');
270}
271
272sub call {
273 my ($self, $env) = @_;
d332726d
MG
274
275 unless ($self->{init_done}) {
276 $self->init;
277 $self->{init_done} = 1;
278 }
279
12aa0bc6
MG
280 my $auth = $env->{HTTP_AUTHORIZATION};
281 if ($auth && $auth =~ /^Basic (.*)$/i) {
282 my ($user, $pass) = split /:/, decode_base64($1), 2;
283 $env->{REMOTE_USER} = $user if $self->check_passphrase($user, $pass);
284 }
285
286 my $req = Plack::Request->new($env);
287
288 if ($req->method eq 'POST') {
289 return $self->call_register($req) if $req->path eq $self->{register_url};
290 return $self->call_passwd($req) if $req->path eq $self->{passwd_url};
291 return $self->call_request_reset($req) if $req->path eq $self->{request_reset_url};
292 return $self->call_reset($req) if $req->path eq $self->{reset_url};
293 }
294
295 $env->{authcomplex} = $self;
296 $self->app->($env);
297}
298
2991;
300__END__
301
302=head1 NAME
303
304Plack::Middleware::Auth::Complex - Feature-rich authentication system
305
306=head1 SYNOPSIS
307
308 use Plack::Builder;
309
310 builder {
311 enable 'Auth::Complex', dbi_connect => ['dbi:Pg:dbname=mydb', '', ''], mail_from => 'nobody@example.org';
312 sub {
313 my ($env) = @_;
314 [200, [], ['Hello ' . ($env->{REMOTE_USER} // 'unregistered user')]]
315 }
316 }
317
318=head1 DESCRIPTION
319
320AuthComplex is an authentication system for Plack applications that
321allows user registration, password changing and password reset.
322
323AuthComplex sets REMOTE_USER if the request includes correct basic
324authentication and intercepts POST requests to some configurable URLs.
a27e5239 325It also sets C<< $env->{authcomplex} >> to itself before passing the
12aa0bc6
MG
326request.
327
328Some options can be controlled by passing a hashref to the
329constructor. More customization can be achieved by subclassing this
330module.
331
332=head2 Intercepted URLs
333
334Only POST requests are intercepted. Parameters can be either query
335parameters or body parameters. Using query parameters is not
336recommended. These endpoints return 200 for success, 400 for client
337error and 500 for server errors. All parameters are mandatory.
338
339=over
340
388ed281 341=item B<POST> /action/register?username=user&password=pw&confirm_password=pw&email=user@example.org
12aa0bc6
MG
342
343This URL creates a new user with the given username, password and
344email. The two passwords must match, the user must match
345C<username_regex> and the user must not already exist.
346
388ed281 347=item B<POST> /action/passwd?password=oldpw&new_password=newpw&confirm_new_password=newpw
12aa0bc6
MG
348
349This URL changes the password of a user. The user must be
350authenticated (otherwise the endpoint will return 401).
351
388ed281 352=item B<POST> /action/request-reset?username=user
12aa0bc6
MG
353
354This URL requests a password reset token for the given user. The token
355will be sent to the user's email address.
356
357A reset token in the default implementation is C<< base64(HMAC-SHA1("$username $passphrase $expiration_unix_time")) . ":$expiration_user_time" >>.
358
388ed281 359=item B<POST> /action/reset?username=user&new_password=pw&confirm_new_password=pw&token=token
12aa0bc6
MG
360
361This URL performs a password reset.
362
363=back
364
365=head2 Constructor arguments
366
367=over
368
369=item dbi_connect
370
371Arrayref of arguments to pass to DBI->connect. Defaults to
372C<['dbi:Pg', '', '']>.
373
61456017
MG
374=item entropy_source
375
376C<Data::Entropy::Source> object to get random numbers from. By default
377uses F</dev/urandom> via C<Data::Entropy::RawSource::Local> if
378possible, or the default entropy source otherwise. A warning is
379printed if the default entropy source is used, to supress it set this
380argument to the default entropy source.
381
58b36d0a
MG
382=item use_scrypt
383
384Boolean determining whether to use the scrypt algorithm via the
385C<Authen::Passphrase::Scrypt> module.
386
387If true, the default implementation of C<hash_passphrase> uses scrypt
388and C<check_passphrase> accepts scrypt passphrases (in addition to
389passphrases supported by C<Authen::Passphrase>).
390
391If false, the default implementation of C<hash_passphrase> uses bcrypt
392and C<check_passphrase> only accepts passphrases supported by
393C<Authen::Passphrase>.
394
395The default value is true if C<Authen::Passphrase::Scrypt> is
396installed, false otherwise.
397
12aa0bc6
MG
398=item post_connect_cb
399
400Callback (coderef) that is called just after connecting to the
401database. Used by the testsuite to create the users table.
402
403=item select_user
404
405SQL statement that selects a user by username. Defaults to
406C<'SELECT id, passphrase, email FROM users WHERE id = ?'>.
407
408=item update_pass
409
410SQL statement that updates a user's password. Defaults to
411C<'UPDATE users SET passphrase = ? WHERE id = ?'>.
412
413=item insert_user
414
415SQL statement that inserts a user. Defaults to
416C<'INSERT INTO users (id, passphrase, email) VALUES (?,?,?)'>.
417
418=item hmackey
419
420HMAC key used for password reset tokens. If not provided it is
421generated randomly, in which case reset tokens do not persist across
422application restarts.
423
424=item mail_from
425
426From: header of password reset emails. If not provided, password reset
427is disabled.
428
429=item mail_subject
430
431The subject of password reset emails. Defaults to
432C<'Password reset token'>.
433
434=item realm
435
436Authentication realm. Defaults to C<'restricted area'>.
437
4c1b8033
MG
438=item cache_fail
439
440If true, all authentication results are cached. If false, only
441successful logins are cached. Defaults to false.
442
443=item cache_max_age
444
445Authentication cache timeout, in seconds. Authentication results are
446cached for this number of seconds to avoid expensive hashing. Defaults
447to 5 minutes.
448
12aa0bc6
MG
449=item token_max_age
450
fe1dfcf1 451Password reset token validity, in seconds. Defaults to 1 hour.
12aa0bc6
MG
452
453=item username_regex
454
455Regular expression that matches valid usernames. Defaults to
1f7bfc27 456C<qr/^\w{2,20}$/as>.
12aa0bc6 457
cd19b1b4
MG
458=item invalid_username
459
460Error message returned when the username does not match
461username_regex. Defaults to C<'Invalid username'>
462
12aa0bc6
MG
463=item register_url
464
388ed281 465URL for registering. Defaults to C<'/action/register'>.
12aa0bc6
MG
466
467=item passwd_url
468
388ed281 469URL for changing your password. Defaults to C<'/action/passwd'>.
12aa0bc6
MG
470
471=item request_reset_url
472
473URL for requesting a password reset token by email. Defaults to
388ed281 474C<'/action/request-reset'>.
12aa0bc6
MG
475
476=item reset_url
477
478URL for resetting your password with a reset token. Defaults to
388ed281 479C<'/action/reset'>.
12aa0bc6
MG
480
481=back
482
483=head2 Methods
484
485=over
486
487=item B<default_opts>
488
489Returns a list of default options for the constructor.
490
491=item B<new>(I<\%opts>)
492
493Creates a new AuthComplex object.
494
495=item B<init>
496
d332726d 497Called when the first request is received. The default implementation
12aa0bc6
MG
498connects to the database, calls C<post_connect_cb> and prepares the
499SQL statements.
500
8637972b
MG
501=item B<create_user>(I<$parms>)
502
503Inserts a new user into the database. I<$parms> is a
504L<Hash::MultiValue> object containing the request parameters.
505
12aa0bc6
MG
506=item B<get_user>(I<$username>)
507
508Returns a hashref with (at least) the following keys: passphrase (the
509RFC2307-formatted passphrase of the user), email (the user's email
510address).
511
512=item B<check_passphrase>(I<$username>, I<$passphrase>)
513
514Returns true if the given plaintext passphrase matches the one
58b36d0a
MG
515obtained from database. Default implementation uses
516L<Authen::Passphrase> (and L<Authen::Passphrase::Scrypt> if
517C<use_scrypt> is true).
12aa0bc6
MG
518
519=item B<hash_passphrase>(I<$passphrase>)
520
58b36d0a
MG
521Returns a RFC2307-formatted hash of the passphrase.
522
523If C<use_scrypt> is true, default implementation uses
524L<Authen::Passphrase::Scrypt> with default parameters.
525
526If C<use_scrypt> is false, default implementation uses
527L<Authen::Passphrase::BlowfishCrypt> with a cost of 10 and a random
528salt.
12aa0bc6
MG
529
530=item B<set_passphrase>(I<$username>, I<$passphrase>)
531
532Changes a user's passphrase to the given value.
533
534=item B<make_reset_hmac>(I<$username>, [I<@data>])
535
536Returns the HMAC part of the reset token.
537
538=item B<mail_body>(I<$username>, I<$token>)
539
540Returns the body of the password reset email for the given username
541and password reset token.
542
543=item B<send_reset_email>(I<$username>)
544
545Generates a new reset token and sends it to the user via email.
546
547=item B<response>(I<$code>, I<$body>)
548
549Helper method. Returns a PSGI response with the given response code
550and string body.
551
552=item B<reply>(I<$message>)
553
554Shorthand for C<response(200, $message)>.
555
556=item B<bad_request>(I<$message>)
557
558Shorthand for C<response(400, $message)>.
559
560=item B<internal_server_error>(I<$message>)
561
562Shorthand for C<response(500, $message)>.
563
564=item B<unauthorized>
565
566Returns a 401 Authorization required response.
567
568=item B<call_register>(I<$req>)
569
f8d502f0 570Handles the C</action/register> endpoint. I<$req> is a Plack::Request object.
12aa0bc6
MG
571
572=item B<call_passwd>(I<$req>)
573
f8d502f0 574Handles the C</action/passwd> endpoint. I<$req> is a Plack::Request object.
12aa0bc6
MG
575
576=item B<call_request_reset>(I<$req>)
577
f8d502f0 578Handles the C</action/request-reset> endpoint. I<$req> is a Plack::Request object.
12aa0bc6
MG
579
580=item B<call_reset>(I<$req>)
581
f8d502f0 582Handles the C</action/reset> endpoint. I<$req> is a Plack::Request object.
12aa0bc6
MG
583
584=back
585
586=head1 AUTHOR
587
588Marius Gavrilescu, E<lt>marius@ieval.roE<gt>
589
590=head1 COPYRIGHT AND LICENSE
591
cc5b1b68 592Copyright (C) 2015-2017 by Marius Gavrilescu
12aa0bc6
MG
593
594This library is free software; you can redistribute it and/or modify
595it under the same terms as Perl itself, either Perl version 5.20.1 or,
596at your option, any later version of Perl 5 you may have available.
597
598
599=cut
This page took 0.049167 seconds and 4 git commands to generate.