File Coverage

blib/lib/Plack/Middleware/Auth/Complex.pm
Criterion Covered Total %
statement 160 160 100.0
branch 63 74 89.1
condition 7 9 88.8
subroutine 37 37 100.0
pod 21 21 100.0
total 288 301 97.0


line stmt bran cond sub pod time code
1             package Plack::Middleware::Auth::Complex;
2              
3 1     1   13702 use 5.014000;
  1         2  
4 1     1   3 use strict;
  1         1  
  1         34  
5 1     1   8 use warnings;
  1         2  
  1         40  
6              
7             our $VERSION = '0.002';
8              
9 1     1   385 use parent qw/Plack::Middleware/;
  1         224  
  1         4  
10 1     1   10875 use re '/s';
  1         1  
  1         65  
11              
12 1     1   450 use Authen::Passphrase;
  1         4146  
  1         24  
13 1     1   419 use Authen::Passphrase::BlowfishCrypt;
  1         13053  
  1         29  
14 1     1   521 use Bytes::Random::Secure qw//;
  1         6452  
  1         22  
15 1     1   5 use Carp qw/croak/;
  1         1  
  1         37  
16 1     1   1255 use DBI;
  1         13005  
  1         72  
17 1     1   557 use Digest::SHA qw/hmac_sha1_base64 sha256/;
  1         2259  
  1         70  
18 1     1   492 use Email::Simple;
  1         3414  
  1         24  
19 1     1   414 use Email::Sender::Simple qw/sendmail/;
  1         111527  
  1         6  
20 1     1   242 use MIME::Base64 qw/decode_base64/;
  1         1  
  1         40  
21 1     1   460 use Plack::Request;
  1         50422  
  1         30  
22 1     1   510 use Tie::Hash::Expire;
  1         8454  
  1         1461  
23              
24             sub default_opts {(
25 1     1 1 22 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',
31             cache_fail => 0,
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'
40             )}
41              
42             sub new {
43 1     1 1 22 my ($class, $opts) = @_;
44 1         4 my %self = $class->default_opts;
45 1         11 %self = (%self, %$opts);
46 1         5 my $self = bless \%self, $class;
47 1         2 $self
48             }
49              
50             sub init {
51 1     1 1 19 my ($self) = @_;
52 1 50       2 $self->{dbh} = DBI->connect(@{$self->{dbi_connect}}) or croak $DBI::errstr;
  1         10  
53 1 50       7785 $self->{post_connect_cb}->($self) if $self->{post_connect_cb}; # uncoverable branch false
54 1 50       387 $self->{insert_sth} = $self->{dbh}->prepare($self->{insert_user}) or croak $self->{dbh}->errstr;
55 1 50       62 $self->{select_sth} = $self->{dbh}->prepare($self->{select_user}) or croak $self->{dbh}->errstr;
56 1 50       45 $self->{update_sth} = $self->{dbh}->prepare($self->{update_pass}) or croak $self->{dbh}->errstr;
57             }
58              
59             sub create_user {
60 1     1 1 6 my ($self, $parms) = @_;
61 1         5 my %parms = $parms->flatten;
62 1 50       18 $self->{insert_sth}->execute($parms{username}, $self->hash_passphrase($parms{password}), $parms{email}) or croak $self->{insert_sth}->errstr;
63             }
64              
65             sub get_user {
66 30     30 1 42 my ($self, $user) = @_;
67 30 50       894 $self->{select_sth}->execute($user) or croak $self->{select_sth}->errstr;
68             $self->{select_sth}->fetchrow_hashref
69 30         623 }
70              
71             sub check_passphrase {
72 13     13 1 32 my ($self, $username, $passphrase) = @_;
73 13 100       38 unless ($self->{cache}) {
74             ## no critic (ProhibitTies)
75 1         12 tie my %cache, 'Tie::Hash::Expire', {expire_seconds => $self->{cache_max_age}};
76 1         20 $self->{cache} = \%cache;
77             }
78 13         157 my $cachekey = sha256 "$username:$passphrase";
79 13 50       88 return $self->{cache}{$cachekey} if exists $self->{cache}{$cachekey}; # uncoverable branch true
80 13         371 my $user = $self->get_user($username);
81 13 100       42 return 0 unless $user;
82 12         105 my $ret = Authen::Passphrase->from_rfc2307($user->{passphrase})->match($passphrase);
83 12 100 66     1089955 $self->{cache}{$cachekey} = $ret if $ret || $self->{cache_fail};
84 12         477 $ret
85             }
86              
87             sub hash_passphrase {
88 3     3 1 4 my ($self, $passphrase) = @_;
89 3         31 Authen::Passphrase::BlowfishCrypt->new(
90             cost => 10,
91             passphrase => $passphrase,
92             salt_random => 1,
93             )->as_rfc2307
94             }
95              
96             sub set_passphrase {
97 2     2 1 16 my ($self, $username, $passphrase) = @_;
98 2 50       8 $self->{update_sth}->execute($self->hash_passphrase($passphrase), $username) or croak $self->{update_sth}->errstr;
99             }
100              
101             sub make_reset_hmac {
102 5     5 1 871 my ($self, $username, @data) = @_;
103 5   66     23 $self->{hmackey} //= Bytes::Random::Secure->new(NonBlocking => 1)->bytes(512); # uncoverable condition false
104 5         881 my $user = $self->get_user($username);
105 5         17 my $message = join ' ', $username, $user->{passphrase}, @data;
106 5         78 hmac_sha1_base64 $message, $self->{hmackey};
107             }
108              
109             sub mail_body {
110 1     1 1 2 my ($self, $username, $token) = @_;
111 1         4 my $hours = $self->{token_max_age} / 60 / 60;
112 1 50       4 $hours .= $hours == 1 ? ' hour' : ' hours'; # uncoverable branch false
113 1         12 <<"EOF";
114             Someone has requested a password reset for your account.
115              
116             To reset your password, please submit the reset password form on the
117             website using the following information:
118              
119             Username: $username
120             Password:
121             Reset token: $token
122              
123             The token is valid for $hours.
124             EOF
125             }
126              
127             sub send_reset_email {
128 1     1 1 1 my ($self, $username) = @_;
129 1         12 my $expire = time + $self->{token_max_age};
130 1         3 my $token = $self->make_reset_hmac($username, $expire) . ":$expire";
131 1         2 my $user = $self->get_user($username);
132             sendmail (Email::Simple->create(
133             header => [
134             From => $self->{mail_from},
135             To => $user->{email},
136             Subject => $self->{mail_subject},
137 1         7 ],
138             body => $self->mail_body($username, $token),
139             ));
140             }
141              
142             ##################################################
143              
144             sub response {
145 19     19 1 31 my ($self, $code, $body) = @_;
146             return [
147 19         316 $code,
148             ['Content-Type' => 'text/plain',
149             'Content-Length' => length $body],
150             [ $body ],
151             ];
152             }
153              
154 4     4 1 21 sub reply { shift->response(200, $_[0]) }
155 14     14 1 36 sub bad_request { shift->response(400, $_[0]) }
156 1     1 1 5 sub internal_server_error { shift->response(500, $_[0]) }
157              
158             sub unauthorized {
159 1     1 1 8 my ($self) = @_;
160 1         2 my $body = 'Authorization required';
161             return [
162             401,
163             ['Content-Type' => 'text/plain',
164             'Content-Length' => length $body,
165 1         10 'WWW-Authenticate' => 'Basic realm="' . $self->{realm} . '"' ],
166             [ $body ],
167             ];
168             }
169              
170             ##################################################
171              
172             sub call_register {
173 5     5 1 35 my ($self, $req) = @_;
174 5         6 my %parms;
175 5         8 for (qw/username password confirm_password email/) {
176 17         32 $parms{$_} = $req->param($_);
177 17 100       1485 return $self->bad_request("Missing parameter $_") unless $parms{$_};
178             }
179              
180 4 100       32 return $self->bad_request($self->{invalid_username}) unless $parms{username} =~ $self->{username_regex};
181 3 100       10 return $self->bad_request('Username already in use') if $self->get_user($parms{username});
182 2 100       8 return $self->bad_request('The two passwords do not match') unless $parms{password} eq $parms{confirm_password};
183              
184 1         4 $self->create_user($req->parameters);
185 1         96362 return $self->reply('Registered successfully')
186             }
187              
188             sub call_passwd {
189 5     5 1 50 my ($self, $req) = @_;
190 5 100       22 return $self->unauthorized unless $req->user;
191 4         26 my %parms;
192 4         15 for (qw/password new_password confirm_new_password/) {
193 10         25 $parms{$_} = $req->param($_);
194 10 100       1520 return $self->bad_request("Missing parameter $_") unless $parms{$_};
195             }
196              
197 3 100       10 return $self->bad_request('Incorrect password') unless $self->check_passphrase($req->user, $parms{password});
198 2 100       15 return $self->bad_request('The two passwords do not match') unless $parms{new_password} eq $parms{confirm_new_password};
199 1         10 $self->set_passphrase($req->user, $parms{new_password});
200 1         91561 return $self->reply('Password changed successfully');
201             }
202              
203             sub call_request_reset {
204 4     4 1 21 my ($self, $req) = @_;
205 4 100       11 return $self->internal_server_error('Password resets are disabled') unless $self->{mail_from};
206 3         8 my $username = $req->param('username');
207 3 100       602 my $user = $self->get_user($username) or return $self->bad_request('No such user');
208 1 50       2 eval {
209 1         4 $self->send_reset_email($username);
210 1         11982 1
211             } or return $self->internal_server_error($@);
212 1         4 $self->reply('Email sent');
213             }
214              
215             sub call_reset {
216 6     6 1 31 my ($self, $req) = @_;
217 6         6 my %parms;
218 6         10 for (qw/username new_password confirm_new_password token/) {
219 21         35 $parms{$_} = $req->param($_);
220 21 100       1511 return $self->bad_request("Missing parameter $_") unless $parms{$_};
221             }
222              
223 5 100       12 my $user = $self->get_user($parms{username}) or return $self->bad_request('No such user');
224 4 100       15 return $self->bad_request('The two passwords do not match') unless $parms{new_password} eq $parms{confirm_new_password};
225 3         8 my ($token, $exp) = split /:/, $parms{token};
226 3         9 my $goodtoken = $self->make_reset_hmac($parms{username}, $exp);
227 3 100       10 return $self->bad_request('Bad reset token') unless $token eq $goodtoken;
228 2 100       9 return $self->bad_request('Reset token has expired') if time >= $exp;
229 1         5 $self->set_passphrase($parms{username}, $parms{new_password});
230 1         91067 return $self->reply('Password reset successfully');
231             }
232              
233             sub call {
234 30     30 1 62566 my ($self, $env) = @_;
235              
236 30 100       104 unless ($self->{init_done}) {
237 1         4 $self->init;
238 1         39 $self->{init_done} = 1;
239             }
240              
241 30         37 my $auth = $env->{HTTP_AUTHORIZATION};
242 30 100 100     137 if ($auth && $auth =~ /^Basic (.*)$/i) {
243 10         80 my ($user, $pass) = split /:/, decode_base64($1), 2;
244 10 100       32 $env->{REMOTE_USER} = $user if $self->check_passphrase($user, $pass);
245             }
246              
247 30         220 my $req = Plack::Request->new($env);
248              
249 30 100       291 if ($req->method eq 'POST') {
250 21 100       171 return $self->call_register($req) if $req->path eq $self->{register_url};
251 16 100       127 return $self->call_passwd($req) if $req->path eq $self->{passwd_url};
252 11 100       74 return $self->call_request_reset($req) if $req->path eq $self->{request_reset_url};
253 7 100       29 return $self->call_reset($req) if $req->path eq $self->{reset_url};
254             }
255              
256 10         102 $env->{authcomplex} = $self;
257 10         48 $self->app->($env);
258             }
259              
260             1;
261             __END__