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