1 package Plack
::Middleware
::Auth
::Complex
;
7 our $VERSION = '0.001';
9 use parent qw
/Plack::Middleware/;
12 use Authen
::Passphrase
;
13 use Authen
::Passphrase
::BlowfishCrypt
;
14 use Bytes
::Random
::Secure qw
/random_bytes/;
17 use Digest
::SHA qw
/hmac_sha1_base64 sha256/;
19 use Email
::Sender
::Simple qw
/sendmail/;
20 use MIME
::Base64 qw
/decode_base64/;
22 use Tie
::Hash
::Expire
;
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',
32 cache_max_age
=> 5 * 60,
33 token_max_age
=> 60 * 60,
34 username_regex
=> qr/^\w{2,20}$/as,
35 register_url
=> '/action/register',
36 passwd_url
=> '/action/passwd',
37 request_reset_url
=> '/action/request-reset',
38 reset_url
=> '/action/reset'
42 my ($class, $opts) = @_;
43 my %self = $class->default_opts;
44 %self = (%self, %$opts);
45 my $self = bless \
%self, $class;
51 $self->{dbh
} = DBI
->connect(@
{$self->{dbi_connect
}}) or croak
$DBI::errstr
;
52 $self->{post_connect_cb
}->($self) if $self->{post_connect_cb
}; # uncoverable branch false
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;
59 my ($self, $parms) = @_;
60 my %parms = $parms->flatten;
61 $self->{insert_sth
}->execute($parms{username
}, $self->hash_passphrase($parms{password
}), $parms{email
}) or croak
$self->{insert_sth
}->errstr;
65 my ($self, $user) = @_;
66 $self->{select_sth
}->execute($user) or croak
$self->{select_sth
}->errstr;
67 $self->{select_sth
}->fetchrow_hashref
70 sub check_passphrase
{
71 my ($self, $username, $passphrase) = @_;
72 unless ($self->{cache
}) {
73 ## no critic (ProhibitTies)
74 tie
my %cache, 'Tie::Hash::Expire', {expire_seconds
=> $self->{cache_max_age
}};
75 $self->{cache
} = \
%cache;
77 my $cachekey = sha256
"$username:$passphrase";
78 return $self->{cache
}{$cachekey} if exists $self->{cache
}{$cachekey}; # uncoverable branch true
79 my $user = $self->get_user($username);
80 return 0 unless $user;
81 my $ret = Authen
::Passphrase
->from_rfc2307($user->{passphrase
})->match($passphrase);
82 $self->{cache
}{$cachekey} = $ret if $ret || $self->{cache_fail
};
87 my ($self, $passphrase) = @_;
88 Authen
::Passphrase
::BlowfishCrypt
->new(
90 passphrase
=> $passphrase,
96 my ($self, $username, $passphrase) = @_;
97 $self->{update_sth
}->execute($self->hash_passphrase($passphrase), $username) or croak
$self->{update_sth
}->errstr;
100 sub make_reset_hmac
{
101 my ($self, $username, @data) = @_;
102 $self->{hmackey
} //= random_bytes
512; # uncoverable condition false
103 my $user = $self->get_user($username);
104 my $message = join ' ', $username, $user->{passphrase
}, @data;
105 hmac_sha1_base64
$message, $self->{hmackey
};
109 my ($self, $username, $token) = @_;
110 my $hours = $self->{token_max_age
} / 60 / 60;
111 $hours .= $hours == 1 ?
' hour' : ' hours'; # uncoverable branch false
113 Someone has requested a password reset for your account.
115 To reset your password, please submit the reset password form on the
116 website using the following information:
119 Password: <your new password>
122 The token is valid for $hours.
126 sub 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(
133 From
=> $self->{mail_from
},
134 To
=> $user->{email
},
135 Subject
=> $self->{mail_subject
},
137 body
=> $self->mail_body($username, $token),
141 ##################################################
144 my ($self, $code, $body) = @_;
147 ['Content-Type' => 'text/plain',
148 'Content-Length' => length $body],
153 sub reply
{ shift->response(200, $_[0]) }
154 sub bad_request
{ shift->response(400, $_[0]) }
155 sub internal_server_error
{ shift->response(500, $_[0]) }
159 my $body = 'Authorization required';
162 ['Content-Type' => 'text/plain',
163 'Content-Length' => length $body,
164 'WWW-Authenticate' => 'Basic realm="' . $self->{realm
} . '"' ],
169 ##################################################
172 my ($self, $req) = @_;
174 for (qw
/username password confirm_password email/) {
175 $parms{$_} = $req->param($_);
176 return $self->bad_request("Missing parameter $_") unless $parms{$_};
179 return $self->bad_request('Username must match ' . $self->{username_regex
}) unless $parms{username
} =~ $self->{username_regex
};
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
};
183 $self->create_user($req->parameters);
184 return $self->reply('Registered successfully')
188 my ($self, $req) = @_;
189 return $self->unauthorized unless $req->user;
191 for (qw
/password new_password confirm_new_password/) {
192 $parms{$_} = $req->param($_);
193 return $self->bad_request("Missing parameter $_") unless $parms{$_};
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');
202 sub 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');
208 $self->send_reset_email($username);
210 } or return $self->internal_server_error($@
);
211 $self->reply('Email sent');
215 my ($self, $req) = @_;
217 for (qw
/username new_password confirm_new_password token/) {
218 $parms{$_} = $req->param($_);
219 return $self->bad_request("Missing parameter $_") unless $parms{$_};
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
};
224 my ($token, $exp) = split /:/, $parms{token
};
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');
233 my ($self, $env) = @_;
235 unless ($self->{init_done
}) {
237 $self->{init_done
} = 1;
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);
246 my $req = Plack
::Request
->new($env);
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
};
255 $env->{authcomplex
} = $self;
264 Plack::Middleware::Auth::Complex - Feature-rich authentication system
271 enable 'Auth::Complex', dbi_connect => ['dbi:Pg:dbname=mydb', '', ''], mail_from => 'nobody@example.org';
274 [200, [], ['Hello ' . ($env->{REMOTE_USER} // 'unregistered user')]]
280 AuthComplex is an authentication system for Plack applications that
281 allows user registration, password changing and password reset.
283 AuthComplex sets REMOTE_USER if the request includes correct basic
284 authentication and intercepts POST requests to some configurable URLs.
285 It also sets C<< $env->{authcomplex} >> to itself before passing the
288 Some options can be controlled by passing a hashref to the
289 constructor. More customization can be achieved by subclassing this
292 =head2 Intercepted URLs
294 Only POST requests are intercepted. Parameters can be either query
295 parameters or body parameters. Using query parameters is not
296 recommended. These endpoints return 200 for success, 400 for client
297 error and 500 for server errors. All parameters are mandatory.
301 =item B<POST> /action/register?username=user&password=pw&confirm_password=pw&email=user@example.org
303 This URL creates a new user with the given username, password and
304 email. The two passwords must match, the user must match
305 C<username_regex> and the user must not already exist.
307 =item B<POST> /action/passwd?password=oldpw&new_password=newpw&confirm_new_password=newpw
309 This URL changes the password of a user. The user must be
310 authenticated (otherwise the endpoint will return 401).
312 =item B<POST> /action/request-reset?username=user
314 This URL requests a password reset token for the given user. The token
315 will be sent to the user's email address.
317 A reset token in the default implementation is C<< base64(HMAC-SHA1("$username $passphrase $expiration_unix_time")) . ":$expiration_user_time" >>.
319 =item B<POST> /action/reset?username=user&new_password=pw&confirm_new_password=pw&token=token
321 This URL performs a password reset.
325 =head2 Constructor arguments
331 Arrayref of arguments to pass to DBI->connect. Defaults to
332 C<['dbi:Pg', '', '']>.
334 =item post_connect_cb
336 Callback (coderef) that is called just after connecting to the
337 database. Used by the testsuite to create the users table.
341 SQL statement that selects a user by username. Defaults to
342 C<'SELECT id, passphrase, email FROM users WHERE id = ?'>.
346 SQL statement that updates a user's password. Defaults to
347 C<'UPDATE users SET passphrase = ? WHERE id = ?'>.
351 SQL statement that inserts a user. Defaults to
352 C<'INSERT INTO users (id, passphrase, email) VALUES (?,?,?)'>.
356 HMAC key used for password reset tokens. If not provided it is
357 generated randomly, in which case reset tokens do not persist across
358 application restarts.
362 From: header of password reset emails. If not provided, password reset
367 The subject of password reset emails. Defaults to
368 C<'Password reset token'>.
372 Authentication realm. Defaults to C<'restricted area'>.
376 If true, all authentication results are cached. If false, only
377 successful logins are cached. Defaults to false.
381 Authentication cache timeout, in seconds. Authentication results are
382 cached for this number of seconds to avoid expensive hashing. Defaults
387 Password reset token validity, in seconds. Defaults to 1 hour.
391 Regular expression that matches valid usernames. Defaults to
396 URL for registering. Defaults to C<'/action/register'>.
400 URL for changing your password. Defaults to C<'/action/passwd'>.
402 =item request_reset_url
404 URL for requesting a password reset token by email. Defaults to
405 C<'/action/request-reset'>.
409 URL for resetting your password with a reset token. Defaults to
418 =item B<default_opts>
420 Returns a list of default options for the constructor.
422 =item B<new>(I<\%opts>)
424 Creates a new AuthComplex object.
428 Called when the first request is received. The default implementation
429 connects to the database, calls C<post_connect_cb> and prepares the
432 =item B<create_user>(I<$parms>)
434 Inserts a new user into the database. I<$parms> is a
435 L<Hash::MultiValue> object containing the request parameters.
437 =item B<get_user>(I<$username>)
439 Returns a hashref with (at least) the following keys: passphrase (the
440 RFC2307-formatted passphrase of the user), email (the user's email
443 =item B<check_passphrase>(I<$username>, I<$passphrase>)
445 Returns true if the given plaintext passphrase matches the one
446 obtained from database. Default implementation uses L<Authen::Passphrase>.
448 =item B<hash_passphrase>(I<$passphrase>)
450 Returns a RFC2307-formatted hash of the passphrase. Default
451 implementation uses L<Authen::Passphrase::BlowfishCrypt> with a cost
452 of 10 and a random salt.
454 =item B<set_passphrase>(I<$username>, I<$passphrase>)
456 Changes a user's passphrase to the given value.
458 =item B<make_reset_hmac>(I<$username>, [I<@data>])
460 Returns the HMAC part of the reset token.
462 =item B<mail_body>(I<$username>, I<$token>)
464 Returns the body of the password reset email for the given username
465 and password reset token.
467 =item B<send_reset_email>(I<$username>)
469 Generates a new reset token and sends it to the user via email.
471 =item B<response>(I<$code>, I<$body>)
473 Helper method. Returns a PSGI response with the given response code
476 =item B<reply>(I<$message>)
478 Shorthand for C<response(200, $message)>.
480 =item B<bad_request>(I<$message>)
482 Shorthand for C<response(400, $message)>.
484 =item B<internal_server_error>(I<$message>)
486 Shorthand for C<response(500, $message)>.
488 =item B<unauthorized>
490 Returns a 401 Authorization required response.
492 =item B<call_register>(I<$req>)
494 Handles the C</action/register> endpoint. I<$req> is a Plack::Request object.
496 =item B<call_passwd>(I<$req>)
498 Handles the C</action/passwd> endpoint. I<$req> is a Plack::Request object.
500 =item B<call_request_reset>(I<$req>)
502 Handles the C</action/request-reset> endpoint. I<$req> is a Plack::Request object.
504 =item B<call_reset>(I<$req>)
506 Handles the C</action/reset> endpoint. I<$req> is a Plack::Request object.
512 Marius Gavrilescu, E<lt>marius@ieval.roE<gt>
514 =head1 COPYRIGHT AND LICENSE
516 Copyright (C) 2015 by Marius Gavrilescu
518 This library is free software; you can redistribute it and/or modify
519 it under the same terms as Perl itself, either Perl version 5.20.1 or,
520 at your option, any later version of Perl 5 you may have available.
This page took 0.052188 seconds and 4 git commands to generate.