File Coverage

blib/lib/Plack/Middleware/Auth/Complex.pm
Criterion Covered Total %
statement 182 186 97.8
branch 73 88 86.3
condition 8 12 75.0
subroutine 41 41 100.0
pod 21 22 95.4
total 325 349 94.2


line stmt bran cond sub pod time code
1             package Plack::Middleware::Auth::Complex;
2              
3 1     1   15596 use 5.014000;
  1         4  
4 1     1   5 use strict;
  1         3  
  1         18  
5 1     1   5 use warnings;
  1         2  
  1         44  
6              
7             our $VERSION = '0.003001';
8              
9 1     1   395 use parent qw/Plack::Middleware/;
  1         270  
  1         5  
10 1     1   13038 use re '/s';
  1         3  
  1         80  
11              
12 1     1   579 use Authen::Passphrase;
  1         4824  
  1         31  
13 1     1   578 use Authen::Passphrase::BlowfishCrypt;
  1         17891  
  1         51  
14 1     1   14 use Data::Entropy qw/entropy_source/;
  1         4  
  1         59  
15 1     1   718 use Data::Entropy::Source;
  1         1253  
  1         32  
16 1     1   485 use Data::Entropy::RawSource::Local;
  1         2681  
  1         49  
17 1     1   12 use Carp qw/carp croak/;
  1         3  
  1         45  
18 1     1   1315 use DBI;
  1         14519  
  1         82  
19 1     1   661 use Digest::SHA qw/hmac_sha1_base64 sha256/;
  1         2447  
  1         82  
20 1     1   550 use Email::Simple;
  1         3704  
  1         31  
21 1     1   439 use Email::Sender::Simple qw/sendmail/;
  1         121263  
  1         8  
22 1     1   336 use MIME::Base64 qw/decode_base64/;
  1         2  
  1         41  
23 1     1   468 use Plack::Request;
  1         54076  
  1         32  
24 1     1   456 use Tie::Hash::Expire;
  1         9141  
  1         2083  
25              
26             sub make_entropy_source {
27 2 50   2 0 60 if (-e '/dev/urandom') {
28 2         37 Data::Entropy::Source->new(
29             Data::Entropy::RawSource::Local->new('/dev/urandom'),
30             'sysread'
31             )
32             } else {
33 0         0 carp "/dev/urandom not found, using insecure random source\n";
34 0         0 entropy_source
35             }
36             }
37              
38             sub default_opts {(
39 2     2 1 44 dbi_connect => ['dbi:Pg:', '', ''],
40             select_user => 'SELECT passphrase, email FROM users WHERE id = ?',
41             update_pass => 'UPDATE users SET passphrase = ? WHERE id = ?',
42             insert_user => 'INSERT INTO users (id, passphrase, email) VALUES (?,?,?)',
43             mail_subject => 'Password reset token',
44             realm => 'restricted area',
45             cache_fail => 0,
46             cache_max_age => 5 * 60,
47             token_max_age => 60 * 60,
48             username_regex => qr/^\w{2,20}$/as,
49             invalid_username => 'Invalid username',
50             register_url => '/action/register',
51             passwd_url => '/action/passwd',
52             request_reset_url => '/action/request-reset',
53             reset_url => '/action/reset'
54             )}
55              
56             sub new {
57 2     2 1 7388 my ($class, $opts) = @_;
58 2         10 my %self = $class->default_opts;
59 2         27 %self = (%self, %$opts);
60 2   33     52 $self{entropy_source} //= make_entropy_source;
61             # If the user did not set [use_scrypt], we set it to 1 if scrypt
62             # is available and to 0 otherwise.
63             # If the user set [use_scrypt] to 1, we try to load scrypt and
64             # croak if we fail to do so.
65 2 50       326 unless (exists $self{use_scrypt}) {
66 0         0 my $success = eval 'use Authen::Passphrase::Scrypt';
67 0         0 $self{use_scrypt} = !!$success
68             }
69 2 100       9 if ($self{use_scrypt}) {
70 1 50   1   35 eval 'use Authen::Passphrase::Scrypt; 1' or croak "Failed to load Authen::Passphrase::Scrypt: $@\n";
  1         3  
  1         54  
  1         88  
71             }
72 2         10 my $self = bless \%self, $class;
73 2         7 $self
74             }
75              
76             sub init {
77 2     2 1 5 my ($self) = @_;
78 2 50       5 $self->{dbh} = DBI->connect(@{$self->{dbi_connect}}) or croak $DBI::errstr;
  2         20  
79 2 50       8620 $self->{post_connect_cb}->($self) if $self->{post_connect_cb}; # uncoverable branch false
80 2 50       672 $self->{insert_sth} = $self->{dbh}->prepare($self->{insert_user}) or croak $self->{dbh}->errstr;
81 2 50       151 $self->{select_sth} = $self->{dbh}->prepare($self->{select_user}) or croak $self->{dbh}->errstr;
82 2 50       113 $self->{update_sth} = $self->{dbh}->prepare($self->{update_pass}) or croak $self->{dbh}->errstr;
83             }
84              
85             sub create_user {
86 2     2 1 20 my ($self, $parms) = @_;
87 2         12 my %parms = $parms->flatten;
88 2 50       57 $self->{insert_sth}->execute($parms{username}, $self->hash_passphrase($parms{password}), $parms{email}) or croak $self->{insert_sth}->errstr;
89             }
90              
91             sub get_user {
92 60     60 1 181 my ($self, $user) = @_;
93 60 50       2339 $self->{select_sth}->execute($user) or croak $self->{select_sth}->errstr;
94             $self->{select_sth}->fetchrow_hashref
95 60         1566 }
96              
97             sub check_passphrase {
98 26     26 1 130 my ($self, $username, $passphrase) = @_;
99 26 100       110 unless ($self->{cache}) {
100             ## no critic (ProhibitTies)
101 2         28 tie my %cache, 'Tie::Hash::Expire', {expire_seconds => $self->{cache_max_age}};
102 2         61 $self->{cache} = \%cache;
103             }
104 26         345 my $cachekey = sha256 "$username:$passphrase";
105 26 50       252 return $self->{cache}{$cachekey} if exists $self->{cache}{$cachekey}; # uncoverable branch true
106 26         1141 my $user = $self->get_user($username);
107 26 100       127 return 0 unless $user;
108 24         59 my $ret;
109 24 100       158 if ($user->{passphrase} =~ /^{SCRYPT}/) {
110 12 50       63 croak "$username has a scrypt password but use_scrypt is false\n" unless $self->{use_scrypt};
111             $ret = Authen::Passphrase::Scrypt->from_rfc2307($user->{passphrase})
112 12         126 } else {
113 12         123 $ret = Authen::Passphrase->from_rfc2307($user->{passphrase});
114             }
115 24         4014 $ret = $ret->match($passphrase);
116 24 100 66     9735118 $self->{cache}{$cachekey} = $ret if $ret || $self->{cache_fail};
117 24         1445 $ret
118             }
119              
120             sub hash_passphrase {
121 6     6 1 21 my ($self, $passphrase) = @_;
122 6 100       27 if ($self->{use_scrypt}) {
123 3         33 Authen::Passphrase::Scrypt->new({
124             passphrase => $passphrase,
125             })->as_rfc2307
126             } else {
127 3         47 Authen::Passphrase::BlowfishCrypt->new(
128             cost => 10,
129             passphrase => $passphrase,
130             salt_random => 1,
131             )->as_rfc2307
132             }
133             }
134              
135             sub set_passphrase {
136 4     4 1 42 my ($self, $username, $passphrase) = @_;
137 4 50       22 $self->{update_sth}->execute($self->hash_passphrase($passphrase), $username) or croak $self->{update_sth}->errstr;
138             }
139              
140             sub make_reset_hmac {
141 10     10 1 2628 my ($self, $username, @data) = @_;
142 10   66     59 $self->{hmackey} //= $self->{entropy_source}->get_bits(8 * 512); # uncoverable condition false
143 10         17787 my $user = $self->get_user($username);
144 10         58 my $message = join ' ', $username, $user->{passphrase}, @data;
145 10         183 hmac_sha1_base64 $message, $self->{hmackey};
146             }
147              
148             sub mail_body {
149 2     2 1 11 my ($self, $username, $token) = @_;
150 2         12 my $hours = $self->{token_max_age} / 60 / 60;
151 2 50       16 $hours .= $hours == 1 ? ' hour' : ' hours'; # uncoverable branch false
152 2         36 <<"EOF";
153             Someone has requested a password reset for your account.
154              
155             To reset your password, please submit the reset password form on the
156             website using the following information:
157              
158             Username: $username
159             Password:
160             Reset token: $token
161              
162             The token is valid for $hours.
163             EOF
164             }
165              
166             sub send_reset_email {
167 2     2 1 9 my ($self, $username) = @_;
168 2         18 my $expire = time + $self->{token_max_age};
169 2         11 my $token = $self->make_reset_hmac($username, $expire) . ":$expire";
170 2         12 my $user = $self->get_user($username);
171             sendmail (Email::Simple->create(
172             header => [
173             From => $self->{mail_from},
174             To => $user->{email},
175             Subject => $self->{mail_subject},
176 2         24 ],
177             body => $self->mail_body($username, $token),
178             ));
179             }
180              
181             ##################################################
182              
183             sub response {
184 38     38 1 133 my ($self, $code, $body) = @_;
185             return [
186 38         734 $code,
187             ['Content-Type' => 'text/plain',
188             'Content-Length' => length $body],
189             [ $body ],
190             ];
191             }
192              
193 8     8 1 54 sub reply { shift->response(200, $_[0]) }
194 28     28 1 114 sub bad_request { shift->response(400, $_[0]) }
195 2     2 1 10 sub internal_server_error { shift->response(500, $_[0]) }
196              
197             sub unauthorized {
198 2     2 1 26 my ($self) = @_;
199 2         5 my $body = 'Authorization required';
200             return [
201             401,
202             ['Content-Type' => 'text/plain',
203             'Content-Length' => length $body,
204 2         26 'WWW-Authenticate' => 'Basic realm="' . $self->{realm} . '"' ],
205             [ $body ],
206             ];
207             }
208              
209             ##################################################
210              
211             sub call_register {
212 10     10 1 103 my ($self, $req) = @_;
213 10         20 my %parms;
214 10         29 for (qw/username password confirm_password email/) {
215 34         93 $parms{$_} = $req->param($_);
216 34 100       4836 return $self->bad_request("Missing parameter $_") unless $parms{$_};
217             }
218              
219 8 100       75 return $self->bad_request($self->{invalid_username}) unless $parms{username} =~ $self->{username_regex};
220 6 100       29 return $self->bad_request('Username already in use') if $self->get_user($parms{username});
221 4 100       24 return $self->bad_request('The two passwords do not match') unless $parms{password} eq $parms{confirm_password};
222              
223 2         9 $self->create_user($req->parameters);
224 2         812670 return $self->reply('Registered successfully')
225             }
226              
227             sub call_passwd {
228 10     10 1 121 my ($self, $req) = @_;
229 10 100       49 return $self->unauthorized unless $req->user;
230 8         74 my %parms;
231 8         39 for (qw/password new_password confirm_new_password/) {
232 20         84 $parms{$_} = $req->param($_);
233 20 100       4843 return $self->bad_request("Missing parameter $_") unless $parms{$_};
234             }
235              
236 6 100       24 return $self->bad_request('Incorrect password') unless $self->check_passphrase($req->user, $parms{password});
237 4 100       38 return $self->bad_request('The two passwords do not match') unless $parms{new_password} eq $parms{confirm_new_password};
238 2         22 $self->set_passphrase($req->user, $parms{new_password});
239 2         803518 return $self->reply('Password changed successfully');
240             }
241              
242             sub call_request_reset {
243 8     8 1 66 my ($self, $req) = @_;
244 8 100       33 return $self->internal_server_error('Password resets are disabled') unless $self->{mail_from};
245 6         23 my $username = $req->param('username');
246 6 100       2258 my $user = $self->get_user($username) or return $self->bad_request('No such user');
247 2 50       9 eval {
248 2         15 $self->send_reset_email($username);
249 2         17671 1
250             } or return $self->internal_server_error($@);
251 2         11 $self->reply('Email sent');
252             }
253              
254             sub call_reset {
255 12     12 1 91 my ($self, $req) = @_;
256 12         25 my %parms;
257 12         27 for (qw/username new_password confirm_new_password token/) {
258 42         110 $parms{$_} = $req->param($_);
259 42 100       5266 return $self->bad_request("Missing parameter $_") unless $parms{$_};
260             }
261              
262 10 100       30 my $user = $self->get_user($parms{username}) or return $self->bad_request('No such user');
263 8 100       38 return $self->bad_request('The two passwords do not match') unless $parms{new_password} eq $parms{confirm_new_password};
264 6         29 my ($token, $exp) = split /:/, $parms{token};
265 6         22 my $goodtoken = $self->make_reset_hmac($parms{username}, $exp);
266 6 100       24 return $self->bad_request('Bad reset token') unless $token eq $goodtoken;
267 4 100       23 return $self->bad_request('Reset token has expired') if time >= $exp;
268 2         12 $self->set_passphrase($parms{username}, $parms{new_password});
269 2         804727 return $self->reply('Password reset successfully');
270             }
271              
272             sub call {
273 60     60 1 172900 my ($self, $env) = @_;
274              
275 60 100       272 unless ($self->{init_done}) {
276 2         14 $self->init;
277 2         100 $self->{init_done} = 1;
278             }
279              
280 60         169 my $auth = $env->{HTTP_AUTHORIZATION};
281 60 100 100     379 if ($auth && $auth =~ /^Basic (.*)$/i) {
282 20         181 my ($user, $pass) = split /:/, decode_base64($1), 2;
283 20 100       95 $env->{REMOTE_USER} = $user if $self->check_passphrase($user, $pass);
284             }
285              
286 60         560 my $req = Plack::Request->new($env);
287              
288 60 100       879 if ($req->method eq 'POST') {
289 42 100       485 return $self->call_register($req) if $req->path eq $self->{register_url};
290 32 100       332 return $self->call_passwd($req) if $req->path eq $self->{passwd_url};
291 22 100       162 return $self->call_request_reset($req) if $req->path eq $self->{request_reset_url};
292 14 100       92 return $self->call_reset($req) if $req->path eq $self->{reset_url};
293             }
294              
295 20         236 $env->{authcomplex} = $self;
296 20         132 $self->app->($env);
297             }
298              
299             1;
300             __END__