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