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