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