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