Bump version and update Changes
[plack-middleware-auth-complex.git] / lib / Plack / Middleware / Auth / Complex.pm
index f69bbe6aa6e1b91ba570d9bc37da6836304d7fdb..ea1b0b34f1eca5b29db24fd7bf864ba4f8be5ca8 100644 (file)
@@ -4,14 +4,17 @@ use 5.014000;
 use strict;
 use warnings;
 
-our $VERSION = '0.000_001';
-$VERSION = eval $VERSION;  # see L<perlmodstyle>
+our $VERSION = '0.003';
 
 use parent qw/Plack::Middleware/;
+use re '/s';
 
 use Authen::Passphrase;
 use Authen::Passphrase::BlowfishCrypt;
-use Bytes::Random::Secure qw/random_bytes/;
+use Data::Entropy qw/entropy_source/;
+use Data::Entropy::Source;
+use Data::Entropy::RawSource::Local;
+use Carp qw/carp croak/;
 use DBI;
 use Digest::SHA qw/hmac_sha1_base64 sha256/;
 use Email::Simple;
@@ -20,6 +23,18 @@ use MIME::Base64 qw/decode_base64/;
 use Plack::Request;
 use Tie::Hash::Expire;
 
+sub make_entropy_source {
+       if (-e '/dev/urandom') {
+               Data::Entropy::Source->new(
+                       Data::Entropy::RawSource::Local->new('/dev/urandom'),
+                       'sysread'
+               )
+       } else {
+               carp "/dev/urandom not found, using insecure random source\n";
+               entropy_source
+       }
+}
+
 sub default_opts {(
        dbi_connect       => ['dbi:Pg:', '', ''],
        select_user       => 'SELECT passphrase, email FROM users WHERE id = ?',
@@ -29,8 +44,9 @@ sub default_opts {(
        realm             => 'restricted area',
        cache_fail        => 0,
        cache_max_age     => 5 * 60,
-       token_max_age     => 60 * 60 * 24,
-       username_regex    => qr/^\w{2,20}$/a,
+       token_max_age     => 60 * 60,
+       username_regex    => qr/^\w{2,20}$/as,
+       invalid_username  => 'Invalid username',
        register_url      => '/action/register',
        passwd_url        => '/action/passwd',
        request_reset_url => '/action/request-reset',
@@ -41,64 +57,89 @@ sub new {
        my ($class, $opts) = @_;
        my %self = $class->default_opts;
        %self = (%self, %$opts);
+       $self{entropy_source} //= make_entropy_source;
+       # If the user did not set [use_scrypt], we set it to 1 if scrypt
+       # is available and to 0 otherwise.
+       # If the user set [use_scrypt] to 1, we try to load scrypt and
+       # croak if we fail to do so.
+       unless (exists $self{use_scrypt}) {
+               my $success = eval 'use Authen::Passphrase::Scrypt';
+               $self{use_scrypt} = !!$success
+       }
+       if ($self{use_scrypt}) {
+               eval 'use Authen::Passphrase::Scrypt; 1' or croak "Failed to load Authen::Passphrase::Scrypt: $@\n";
+       }
        my $self = bless \%self, $class;
-       $self->init;
        $self
 }
 
 sub init {
        my ($self) = @_;
-       $self->{dbh} = DBI->connect(@{$self->{dbi_connect}})              or die $DBI::errstr;
-       $self->{post_connect_cb}->($self) if $self->{post_connect_cb};
-       $self->{insert_sth} = $self->{dbh}->prepare($self->{insert_user}) or die $self->{dbh}->errstr;
-       $self->{select_sth} = $self->{dbh}->prepare($self->{select_user}) or die $self->{dbh}->errstr;
-       $self->{update_sth} = $self->{dbh}->prepare($self->{update_pass}) or die $self->{dbh}->errstr;
+       $self->{dbh} = DBI->connect(@{$self->{dbi_connect}})              or croak $DBI::errstr;
+       $self->{post_connect_cb}->($self) if $self->{post_connect_cb}; # uncoverable branch false
+       $self->{insert_sth} = $self->{dbh}->prepare($self->{insert_user}) or croak $self->{dbh}->errstr;
+       $self->{select_sth} = $self->{dbh}->prepare($self->{select_user}) or croak $self->{dbh}->errstr;
+       $self->{update_sth} = $self->{dbh}->prepare($self->{update_pass}) or croak $self->{dbh}->errstr;
 }
 
 sub create_user {
        my ($self, $parms) = @_;
        my %parms = $parms->flatten;
-       $self->{insert_sth}->execute($parms{username}, $self->hash_passphrase($parms{password}), $parms{email})
+       $self->{insert_sth}->execute($parms{username}, $self->hash_passphrase($parms{password}), $parms{email}) or croak $self->{insert_sth}->errstr;
 }
 
 sub get_user {
        my ($self, $user) = @_;
-       $self->{select_sth}->execute($user) or die $self->{sth}->errstr;
+       $self->{select_sth}->execute($user) or croak $self->{select_sth}->errstr;
        $self->{select_sth}->fetchrow_hashref
 }
 
 sub check_passphrase {
        my ($self, $username, $passphrase) = @_;
        unless ($self->{cache}) {
+               ## no critic (ProhibitTies)
                tie my %cache, 'Tie::Hash::Expire', {expire_seconds => $self->{cache_max_age}};
                $self->{cache} = \%cache;
        }
        my $cachekey = sha256 "$username:$passphrase";
-       return $self->{cache}{$cachekey} if exists $self->{cache}{$cachekey};
+       return $self->{cache}{$cachekey} if exists $self->{cache}{$cachekey}; # uncoverable branch true
        my $user = $self->get_user($username);
        return 0 unless $user;
-       my $ret = Authen::Passphrase->from_rfc2307($user->{passphrase})->match($passphrase);
+       my $ret;
+       if ($user->{passphrase} =~ /^{SCRYPT}/) {
+               croak "$username has a scrypt password but use_scrypt is false\n" unless $self->{use_scrypt};
+               $ret = Authen::Passphrase::Scrypt->from_rfc2307($user->{passphrase})
+       } else {
+               $ret = Authen::Passphrase->from_rfc2307($user->{passphrase});
+       }
+       $ret = $ret->match($passphrase);
        $self->{cache}{$cachekey} = $ret if $ret || $self->{cache_fail};
        $ret
 }
 
 sub hash_passphrase {
        my ($self, $passphrase) = @_;
-       Authen::Passphrase::BlowfishCrypt->new(
-               cost => 10,
-               passphrase => $passphrase,
-               salt_random => 1,
-       )->as_rfc2307
+       if ($self->{use_scrypt}) {
+               Authen::Passphrase::Scrypt->new({
+                       passphrase => $passphrase,
+               })->as_rfc2307
+       } else {
+               Authen::Passphrase::BlowfishCrypt->new(
+                       cost => 10,
+                       passphrase => $passphrase,
+                       salt_random => 1,
+               )->as_rfc2307
+       }
 }
 
 sub set_passphrase {
        my ($self, $username, $passphrase) = @_;
-       $self->{update_sth}->execute($self->hash_passphrase($passphrase), $username)
+       $self->{update_sth}->execute($self->hash_passphrase($passphrase), $username) or croak $self->{update_sth}->errstr;
 }
 
 sub make_reset_hmac {
        my ($self, $username, @data) = @_;
-       $self->{hmackey} //= random_bytes 512;
+       $self->{hmackey} //= $self->{entropy_source}->get_bits(8 * 512); # uncoverable condition false
        my $user = $self->get_user($username);
        my $message = join ' ', $username, $user->{passphrase}, @data;
        hmac_sha1_base64 $message, $self->{hmackey};
@@ -107,8 +148,8 @@ sub make_reset_hmac {
 sub mail_body {
        my ($self, $username, $token) = @_;
        my $hours = $self->{token_max_age} / 60 / 60;
-       $hours .= $hours == 1 ? ' hour' : ' hours';
-       <<EOF;
+       $hours .= $hours == 1 ? ' hour' : ' hours'; # uncoverable branch false
+       <<"EOF";
 Someone has requested a password reset for your account.
 
 To reset your password, please submit the reset password form on the
@@ -131,7 +172,7 @@ sub send_reset_email {
                header => [
                        From    => $self->{mail_from},
                        To      => $user->{email},
-                       Subject => $user->{mail_subject},
+                       Subject => $self->{mail_subject},
                ],
                body => $self->mail_body($username, $token),
        ));
@@ -175,7 +216,7 @@ sub call_register {
                return $self->bad_request("Missing parameter $_") unless $parms{$_};
        }
 
-       return $self->bad_request('Username must match ' . $self->{username_regex}) unless $parms{username} =~ /$self->{username_regex}/;
+       return $self->bad_request($self->{invalid_username}) unless $parms{username} =~ $self->{username_regex};
        return $self->bad_request('Username already in use') if $self->get_user($parms{username});
        return $self->bad_request('The two passwords do not match') unless $parms{password} eq $parms{confirm_password};
 
@@ -203,13 +244,11 @@ sub call_request_reset {
        return $self->internal_server_error('Password resets are disabled') unless $self->{mail_from};
        my $username = $req->param('username');
        my $user = $self->get_user($username) or return $self->bad_request('No such user');
-       my $ok = 0;
        eval {
                $self->send_reset_email($username);
-               $ok = 1;
-       };
-       return $self->reply('Email sent') if $ok;
-       return $self->internal_server_error($@);
+               1
+       } or return $self->internal_server_error($@);
+       $self->reply('Email sent');
 }
 
 sub call_reset {
@@ -222,7 +261,7 @@ sub call_reset {
 
        my $user = $self->get_user($parms{username}) or return $self->bad_request('No such user');
        return $self->bad_request('The two passwords do not match') unless $parms{new_password} eq $parms{confirm_new_password};
-       my ($token, $exp) = split ':', $parms{token};
+       my ($token, $exp) = split /:/, $parms{token};
        my $goodtoken = $self->make_reset_hmac($parms{username}, $exp);
        return $self->bad_request('Bad reset token') unless $token eq $goodtoken;
        return $self->bad_request('Reset token has expired') if time >= $exp;
@@ -232,6 +271,12 @@ sub call_reset {
 
 sub call {
        my ($self, $env) = @_;
+
+       unless ($self->{init_done}) {
+               $self->init;
+               $self->{init_done} = 1;
+       }
+
        my $auth = $env->{HTTP_AUTHORIZATION};
        if ($auth && $auth =~ /^Basic (.*)$/i) {
                my ($user, $pass) = split /:/, decode_base64($1), 2;
@@ -277,7 +322,7 @@ allows user registration, password changing and password reset.
 
 AuthComplex sets REMOTE_USER if the request includes correct basic
 authentication and intercepts POST requests to some configurable URLs.
-It also sets C<$env->{authcomplex}> to itself before passing the
+It also sets C<< $env->{authcomplex} >> to itself before passing the
 request.
 
 Some options can be controlled by passing a hashref to the
@@ -326,6 +371,30 @@ This URL performs a password reset.
 Arrayref of arguments to pass to DBI->connect. Defaults to
 C<['dbi:Pg', '', '']>.
 
+=item entropy_source
+
+C<Data::Entropy::Source> object to get random numbers from. By default
+uses F</dev/urandom> via C<Data::Entropy::RawSource::Local> if
+possible, or the default entropy source otherwise. A warning is
+printed if the default entropy source is used, to supress it set this
+argument to the default entropy source.
+
+=item use_scrypt
+
+Boolean determining whether to use the scrypt algorithm via the
+C<Authen::Passphrase::Scrypt> module.
+
+If true, the default implementation of C<hash_passphrase> uses scrypt
+and C<check_passphrase> accepts scrypt passphrases (in addition to
+passphrases supported by C<Authen::Passphrase>).
+
+If false, the default implementation of C<hash_passphrase> uses bcrypt
+and C<check_passphrase> only accepts passphrases supported by
+C<Authen::Passphrase>.
+
+The default value is true if C<Authen::Passphrase::Scrypt> is
+installed, false otherwise.
+
 =item post_connect_cb
 
 Callback (coderef) that is called just after connecting to the
@@ -379,12 +448,17 @@ to 5 minutes.
 
 =item token_max_age
 
-Password reset token validity, in seconds. Defaults to 24 hours.
+Password reset token validity, in seconds. Defaults to 1 hour.
 
 =item username_regex
 
 Regular expression that matches valid usernames. Defaults to
-C<qr/^\w{2,20}$/a>.
+C<qr/^\w{2,20}$/as>.
+
+=item invalid_username
+
+Error message returned when the username does not match
+username_regex. Defaults to C<'Invalid username'>
 
 =item register_url
 
@@ -420,7 +494,7 @@ Creates a new AuthComplex object.
 
 =item B<init>
 
-Called at the end of the constructor. The default implementation
+Called when the first request is received. The default implementation
 connects to the database, calls C<post_connect_cb> and prepares the
 SQL statements.
 
@@ -438,13 +512,20 @@ address).
 =item B<check_passphrase>(I<$username>, I<$passphrase>)
 
 Returns true if the given plaintext passphrase matches the one
-obtained from database. Default implementation uses L<Authen::Passphrase>.
+obtained from database. Default implementation uses
+L<Authen::Passphrase> (and L<Authen::Passphrase::Scrypt> if
+C<use_scrypt> is true).
 
 =item B<hash_passphrase>(I<$passphrase>)
 
-Returns a RFC2307-formatted hash of the passphrase. Default
-implementation uses L<Authen::Passphrase::BlowfishCrypt> with a cost
-of 10 and a random salt.
+Returns a RFC2307-formatted hash of the passphrase.
+
+If C<use_scrypt> is true, default implementation uses
+L<Authen::Passphrase::Scrypt> with default parameters.
+
+If C<use_scrypt> is false, default implementation uses
+L<Authen::Passphrase::BlowfishCrypt> with a cost of 10 and a random
+salt.
 
 =item B<set_passphrase>(I<$username>, I<$passphrase>)
 
@@ -486,19 +567,19 @@ Returns a 401 Authorization required response.
 
 =item B<call_register>(I<$req>)
 
-Handles the C</register> endpoint. I<$req> is a Plack::Request object.
+Handles the C</action/register> endpoint. I<$req> is a Plack::Request object.
 
 =item B<call_passwd>(I<$req>)
 
-Handles the C</passwd> endpoint. I<$req> is a Plack::Request object.
+Handles the C</action/passwd> endpoint. I<$req> is a Plack::Request object.
 
 =item B<call_request_reset>(I<$req>)
 
-Handles the C</request-reset> endpoint. I<$req> is a Plack::Request object.
+Handles the C</action/request-reset> endpoint. I<$req> is a Plack::Request object.
 
 =item B<call_reset>(I<$req>)
 
-Handles the C</reset> endpoint. I<$req> is a Plack::Request object.
+Handles the C</action/reset> endpoint. I<$req> is a Plack::Request object.
 
 =back
 
@@ -508,7 +589,7 @@ Marius Gavrilescu, E<lt>marius@ieval.roE<gt>
 
 =head1 COPYRIGHT AND LICENSE
 
-Copyright (C) 2015 by Marius Gavrilescu
+Copyright (C) 2015-2017 by Marius Gavrilescu
 
 This library is free software; you can redistribute it and/or modify
 it under the same terms as Perl itself, either Perl version 5.20.1 or,
This page took 0.017399 seconds and 4 git commands to generate.