]>
iEval git - plack-middleware-auth-complex.git/blob - Complex.pm
53ff827fafbe76ea5ce1a9d4c325ccbd3f9e099b
1 package Plack
::Middleware
::Auth
::Complex
;
7 our $VERSION = '0.001001';
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 invalid_username
=> 'Invalid username',
36 register_url
=> '/action/register',
37 passwd_url
=> '/action/passwd',
38 request_reset_url
=> '/action/request-reset',
39 reset_url
=> '/action/reset'
43 my ($class, $opts) = @_;
44 my %self = $class->default_opts;
45 %self = (%self, %$opts);
46 my $self = bless \
%self, $class;
52 $self->{dbh
} = DBI
->connect(@
{$self->{dbi_connect
}}) or croak
$DBI::errstr
;
53 $self->{post_connect_cb
}->($self) if $self->{post_connect_cb
}; # uncoverable branch false
54 $self->{insert_sth
} = $self->{dbh
}->prepare($self->{insert_user
}) or croak
$self->{dbh
}->errstr;
55 $self->{select_sth
} = $self->{dbh
}->prepare($self->{select_user
}) or croak
$self->{dbh
}->errstr;
56 $self->{update_sth
} = $self->{dbh
}->prepare($self->{update_pass
}) or croak
$self->{dbh
}->errstr;
60 my ($self, $parms) = @_;
61 my %parms = $parms->flatten;
62 $self->{insert_sth
}->execute($parms{username
}, $self->hash_passphrase($parms{password
}), $parms{email
}) or croak
$self->{insert_sth
}->errstr;
66 my ($self, $user) = @_;
67 $self->{select_sth
}->execute($user) or croak
$self->{select_sth
}->errstr;
68 $self->{select_sth
}->fetchrow_hashref
71 sub check_passphrase
{
72 my ($self, $username, $passphrase) = @_;
73 unless ($self->{cache
}) {
74 ## no critic (ProhibitTies)
75 tie
my %cache, 'Tie::Hash::Expire', {expire_seconds
=> $self->{cache_max_age
}};
76 $self->{cache
} = \
%cache;
78 my $cachekey = sha256
"$username:$passphrase";
79 return $self->{cache
}{$cachekey} if exists $self->{cache
}{$cachekey}; # uncoverable branch true
80 my $user = $self->get_user($username);
81 return 0 unless $user;
82 my $ret = Authen
::Passphrase
->from_rfc2307($user->{passphrase
})->match($passphrase);
83 $self->{cache
}{$cachekey} = $ret if $ret || $self->{cache_fail
};
88 my ($self, $passphrase) = @_;
89 Authen
::Passphrase
::BlowfishCrypt
->new(
91 passphrase
=> $passphrase,
97 my ($self, $username, $passphrase) = @_;
98 $self->{update_sth
}->execute($self->hash_passphrase($passphrase), $username) or croak
$self->{update_sth
}->errstr;
101 sub make_reset_hmac
{
102 my ($self, $username, @data) = @_;
103 $self->{hmackey
} //= random_bytes
512; # uncoverable condition false
104 my $user = $self->get_user($username);
105 my $message = join ' ', $username, $user->{passphrase
}, @data;
106 hmac_sha1_base64
$message, $self->{hmackey
};
110 my ($self, $username, $token) = @_;
111 my $hours = $self->{token_max_age
} / 60 / 60;
112 $hours .= $hours == 1 ?
' hour' : ' hours'; # uncoverable branch false
114 Someone has requested a password reset for your account.
116 To reset your password, please submit the reset password form on the
117 website using the following information:
120 Password: <your new password>
123 The token is valid for $hours.
127 sub send_reset_email
{
128 my ($self, $username) = @_;
129 my $expire = time + $self->{token_max_age
};
130 my $token = $self->make_reset_hmac($username, $expire) . ":$expire";
131 my $user = $self->get_user($username);
132 sendmail
(Email
::Simple
->create(
134 From
=> $self->{mail_from
},
135 To
=> $user->{email
},
136 Subject
=> $self->{mail_subject
},
138 body
=> $self->mail_body($username, $token),
142 ##################################################
145 my ($self, $code, $body) = @_;
148 ['Content-Type' => 'text/plain',
149 'Content-Length' => length $body],
154 sub reply
{ shift->response(200, $_[0]) }
155 sub bad_request
{ shift->response(400, $_[0]) }
156 sub internal_server_error
{ shift->response(500, $_[0]) }
160 my $body = 'Authorization required';
163 ['Content-Type' => 'text/plain',
164 'Content-Length' => length $body,
165 'WWW-Authenticate' => 'Basic realm="' . $self->{realm
} . '"' ],
170 ##################################################
173 my ($self, $req) = @_;
175 for (qw
/username password confirm_password email/) {
176 $parms{$_} = $req->param($_);
177 return $self->bad_request("Missing parameter $_") unless $parms{$_};
180 return $self->bad_request($self->{invalid_username
}) unless $parms{username
} =~ $self->{username_regex
};
181 return $self->bad_request('Username already in use') if $self->get_user($parms{username
});
182 return $self->bad_request('The two passwords do not match') unless $parms{password
} eq $parms{confirm_password
};
184 $self->create_user($req->parameters);
185 return $self->reply('Registered successfully')
189 my ($self, $req) = @_;
190 return $self->unauthorized unless $req->user;
192 for (qw
/password new_password confirm_new_password/) {
193 $parms{$_} = $req->param($_);
194 return $self->bad_request("Missing parameter $_") unless $parms{$_};
197 return $self->bad_request('Incorrect password') unless $self->check_passphrase($req->user, $parms{password
});
198 return $self->bad_request('The two passwords do not match') unless $parms{new_password
} eq $parms{confirm_new_password
};
199 $self->set_passphrase($req->user, $parms{new_password
});
200 return $self->reply('Password changed successfully');
203 sub call_request_reset
{
204 my ($self, $req) = @_;
205 return $self->internal_server_error('Password resets are disabled') unless $self->{mail_from
};
206 my $username = $req->param('username');
207 my $user = $self->get_user($username) or return $self->bad_request('No such user');
209 $self->send_reset_email($username);
211 } or return $self->internal_server_error($@
);
212 $self->reply('Email sent');
216 my ($self, $req) = @_;
218 for (qw
/username new_password confirm_new_password token/) {
219 $parms{$_} = $req->param($_);
220 return $self->bad_request("Missing parameter $_") unless $parms{$_};
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');
234 my ($self, $env) = @_;
236 unless ($self->{init_done
}) {
238 $self->{init_done
} = 1;
241 my $auth = $env->{HTTP_AUTHORIZATION
};
242 if ($auth && $auth =~ /^Basic (.*)$/i) {
243 my ($user, $pass) = split /:/, decode_base64
($1), 2;
244 $env->{REMOTE_USER
} = $user if $self->check_passphrase($user, $pass);
247 my $req = Plack
::Request
->new($env);
249 if ($req->method eq 'POST') {
250 return $self->call_register($req) if $req->path eq $self->{register_url
};
251 return $self->call_passwd($req) if $req->path eq $self->{passwd_url
};
252 return $self->call_request_reset($req) if $req->path eq $self->{request_reset_url
};
253 return $self->call_reset($req) if $req->path eq $self->{reset_url
};
256 $env->{authcomplex
} = $self;
265 Plack::Middleware::Auth::Complex - Feature-rich authentication system
272 enable 'Auth::Complex', dbi_connect => ['dbi:Pg:dbname=mydb', '', ''], mail_from => 'nobody@example.org';
275 [200, [], ['Hello ' . ($env->{REMOTE_USER} // 'unregistered user')]]
281 AuthComplex is an authentication system for Plack applications that
282 allows user registration, password changing and password reset.
284 AuthComplex sets REMOTE_USER if the request includes correct basic
285 authentication and intercepts POST requests to some configurable URLs.
286 It also sets C<< $env->{authcomplex} >> to itself before passing the
289 Some options can be controlled by passing a hashref to the
290 constructor. More customization can be achieved by subclassing this
293 =head2 Intercepted URLs
295 Only POST requests are intercepted. Parameters can be either query
296 parameters or body parameters. Using query parameters is not
297 recommended. These endpoints return 200 for success, 400 for client
298 error and 500 for server errors. All parameters are mandatory.
302 =item B<POST> /action/register?username=user&password=pw&confirm_password=pw&email=user@example.org
304 This URL creates a new user with the given username, password and
305 email. The two passwords must match, the user must match
306 C<username_regex> and the user must not already exist.
308 =item B<POST> /action/passwd?password=oldpw&new_password=newpw&confirm_new_password=newpw
310 This URL changes the password of a user. The user must be
311 authenticated (otherwise the endpoint will return 401).
313 =item B<POST> /action/request-reset?username=user
315 This URL requests a password reset token for the given user. The token
316 will be sent to the user's email address.
318 A reset token in the default implementation is C<< base64(HMAC-SHA1("$username $passphrase $expiration_unix_time")) . ":$expiration_user_time" >>.
320 =item B<POST> /action/reset?username=user&new_password=pw&confirm_new_password=pw&token=token
322 This URL performs a password reset.
326 =head2 Constructor arguments
332 Arrayref of arguments to pass to DBI->connect. Defaults to
333 C<['dbi:Pg', '', '']>.
335 =item post_connect_cb
337 Callback (coderef) that is called just after connecting to the
338 database. Used by the testsuite to create the users table.
342 SQL statement that selects a user by username. Defaults to
343 C<'SELECT id, passphrase, email FROM users WHERE id = ?'>.
347 SQL statement that updates a user's password. Defaults to
348 C<'UPDATE users SET passphrase = ? WHERE id = ?'>.
352 SQL statement that inserts a user. Defaults to
353 C<'INSERT INTO users (id, passphrase, email) VALUES (?,?,?)'>.
357 HMAC key used for password reset tokens. If not provided it is
358 generated randomly, in which case reset tokens do not persist across
359 application restarts.
363 From: header of password reset emails. If not provided, password reset
368 The subject of password reset emails. Defaults to
369 C<'Password reset token'>.
373 Authentication realm. Defaults to C<'restricted area'>.
377 If true, all authentication results are cached. If false, only
378 successful logins are cached. Defaults to false.
382 Authentication cache timeout, in seconds. Authentication results are
383 cached for this number of seconds to avoid expensive hashing. Defaults
388 Password reset token validity, in seconds. Defaults to 1 hour.
392 Regular expression that matches valid usernames. Defaults to
395 =item invalid_username
397 Error message returned when the username does not match
398 username_regex. Defaults to C<'Invalid username'>
402 URL for registering. Defaults to C<'/action/register'>.
406 URL for changing your password. Defaults to C<'/action/passwd'>.
408 =item request_reset_url
410 URL for requesting a password reset token by email. Defaults to
411 C<'/action/request-reset'>.
415 URL for resetting your password with a reset token. Defaults to
424 =item B<default_opts>
426 Returns a list of default options for the constructor.
428 =item B<new>(I<\%opts>)
430 Creates a new AuthComplex object.
434 Called when the first request is received. The default implementation
435 connects to the database, calls C<post_connect_cb> and prepares the
438 =item B<create_user>(I<$parms>)
440 Inserts a new user into the database. I<$parms> is a
441 L<Hash::MultiValue> object containing the request parameters.
443 =item B<get_user>(I<$username>)
445 Returns a hashref with (at least) the following keys: passphrase (the
446 RFC2307-formatted passphrase of the user), email (the user's email
449 =item B<check_passphrase>(I<$username>, I<$passphrase>)
451 Returns true if the given plaintext passphrase matches the one
452 obtained from database. Default implementation uses L<Authen::Passphrase>.
454 =item B<hash_passphrase>(I<$passphrase>)
456 Returns a RFC2307-formatted hash of the passphrase. Default
457 implementation uses L<Authen::Passphrase::BlowfishCrypt> with a cost
458 of 10 and a random salt.
460 =item B<set_passphrase>(I<$username>, I<$passphrase>)
462 Changes a user's passphrase to the given value.
464 =item B<make_reset_hmac>(I<$username>, [I<@data>])
466 Returns the HMAC part of the reset token.
468 =item B<mail_body>(I<$username>, I<$token>)
470 Returns the body of the password reset email for the given username
471 and password reset token.
473 =item B<send_reset_email>(I<$username>)
475 Generates a new reset token and sends it to the user via email.
477 =item B<response>(I<$code>, I<$body>)
479 Helper method. Returns a PSGI response with the given response code
482 =item B<reply>(I<$message>)
484 Shorthand for C<response(200, $message)>.
486 =item B<bad_request>(I<$message>)
488 Shorthand for C<response(400, $message)>.
490 =item B<internal_server_error>(I<$message>)
492 Shorthand for C<response(500, $message)>.
494 =item B<unauthorized>
496 Returns a 401 Authorization required response.
498 =item B<call_register>(I<$req>)
500 Handles the C</action/register> endpoint. I<$req> is a Plack::Request object.
502 =item B<call_passwd>(I<$req>)
504 Handles the C</action/passwd> endpoint. I<$req> is a Plack::Request object.
506 =item B<call_request_reset>(I<$req>)
508 Handles the C</action/request-reset> endpoint. I<$req> is a Plack::Request object.
510 =item B<call_reset>(I<$req>)
512 Handles the C</action/reset> endpoint. I<$req> is a Plack::Request object.
518 Marius Gavrilescu, E<lt>marius@ieval.roE<gt>
520 =head1 COPYRIGHT AND LICENSE
522 Copyright (C) 2015 by Marius Gavrilescu
524 This library is free software; you can redistribute it and/or modify
525 it under the same terms as Perl itself, either Perl version 5.20.1 or,
526 at your option, any later version of Perl 5 you may have available.
This page took 0.088346 seconds and 3 git commands to generate.