diff --git a/cpanfile b/cpanfile index 4f5f6f9a..5b7adf32 100644 --- a/cpanfile +++ b/cpanfile @@ -1,4 +1,6 @@ requires "Class::Method::Modifiers" => "0"; +requires "Crypt::Passphrase" => "0.023"; +requires "Crypt::Passphrase::Argon2" => "0.010"; requires "Digest" => "0"; requires "Exporter" => "0"; requires "File::Spec::Functions" => "0"; diff --git a/dist.ini b/dist.ini index 3897fc4e..1a5fa2e7 100644 --- a/dist.ini +++ b/dist.ini @@ -155,6 +155,8 @@ Mojolicious::Plugin::I18N = 1.6 ; Must be all-caps I18N! Role::Tiny = 2.000001 ; Required for Mojolicious roles Class::Method::Modifiers = 0 ; Required for around/before/after JSON::Validator = 5.00 +Crypt::Passphrase = 0.023 ; Password hashing +Crypt::Passphrase::Argon2 = 0.010 ; Default password hash Sys::Hostname = 0 File::Spec::Functions = 0 FindBin = 0 diff --git a/eg/meet-space/myapp.pl b/eg/meet-space/myapp.pl index 0d028d3f..ffe06d62 100644 --- a/eg/meet-space/myapp.pl +++ b/eg/meet-space/myapp.pl @@ -99,6 +99,8 @@ plugin 'Yancy::Plugin::Editor', { model => $model, storage => $storage, + # Development app: Allow editing without logging in + allow_anonymous => 1, }; ### /Mojolicious::Plugin::Yancy diff --git a/lib/Mojolicious/Plugin/Yancy.pm b/lib/Mojolicious/Plugin/Yancy.pm index 3cb46c67..7ae20662 100644 --- a/lib/Mojolicious/Plugin/Yancy.pm +++ b/lib/Mojolicious/Plugin/Yancy.pm @@ -83,12 +83,12 @@ By default, the L form plugin is loaded. You can override this with your own form plugin. See L for more information. -=head2 yancy.file +=head2 File storage -By default, the L plugin is loaded to handle file -uploading and file management. The default path for file uploads is -C<$MOJO_HOME/public/uploads>. You can override this with your own file -plugin. See L for more information. +By default, the editor stores uploaded files in a L +rooted at C<$MOJO_HOME/public/uploads>. You can override this with +C<< editor => { storage => ... } >>. See L for more +information. =head2 yancy.plugin @@ -165,6 +165,7 @@ use Mojo::File qw( path ); use Mojo::Loader qw( load_class ); use Yancy::Util qw( load_backend curry copy_inline_refs is_type json_validator ); use Yancy::Model; +use Yancy::Storage; use Storable qw( dclone ); use Scalar::Util qw( blessed ); @@ -215,15 +216,23 @@ sub register { # override this one # XXX: Form and File should both be part of the content plugin $app->plugin( 'Yancy::Plugin::Form::Bootstrap4' ); - $app->plugin( 'Yancy::Plugin::File' => { - path => $app->home->child( 'public/uploads' ), - } ); # Add the default editor unless the user explicitly disables it if ( !exists $config->{editor} || defined $config->{editor} ) { - $app->plugin( 'Yancy::Plugin::Editor' => { - %{ $config->{editor} // {} }, - } ); + my $editor = $config->{editor} // {}; + # The editor must be told how to check logins before it is added + if ( !$editor->{users} && !$editor->{allow_anonymous} ) { + $app->log->warn( + 'Yancy editor not loaded: Set `editor => { users => $user_model }` to require logins,' + . ' or `editor => { allow_anonymous => 1 }` to allow anyone to edit', + ); + } + else { + $app->plugin( 'Yancy::Plugin::Editor' => { + storage => Yancy::Storage->new( $app->home->child( 'public/uploads' ) ), + %$editor, + } ); + } } } diff --git a/lib/Yancy/Auth.pm b/lib/Yancy/Auth.pm index 02f5636d..0f117d5c 100644 --- a/lib/Yancy/Auth.pm +++ b/lib/Yancy/Auth.pm @@ -25,8 +25,25 @@ sub new($class, $app, @args) { push @{$app->renderer->paths}, "$templates"; } + # Auth templates use the `l` helper for translated strings + if (!$app->renderer->get_helper('l')) { + $app->plugin('I18N', { namespace => 'Yancy::I18N' }); + } + # TODO: Auth sets up routes in $app + # Add a logout route for the user model, unless another auth already did + if ( !$app->routes->find('auth.logout') ) { + my $model = $self->model; + $app->routes->any( [qw( GET POST )] => '/auth/logout' )->to( cb => sub ($c) { + $model->end_session( $c ); + my $to = $c->req->param( 'return_to' ); + $to = '/' unless $self->is_local_path( $to ); + $c->res->headers->location( $to ); + return $c->rendered( 303 ); + } )->name('auth.logout'); + } + $self->model->add_auth( $self ); return $self; } @@ -48,43 +65,51 @@ sub form_for( $self, $c, $login ) { # If $login is provided, do not make the user type it in again. } +=method is_local_path + + my $ok = $auth->is_local_path( $url ); + +Returns true if the given URL is a path on this site that is safe to redirect +to. It must start with a single C, and must not contain backslashes or +control characters, which browsers may treat as a C URL. +L + +=cut + +sub is_local_path( $self, $url ) { + return defined $url + && $url =~ m{^/(?![/\\])} + && $url !~ m{[\\\x00-\x1f\x7f]}; +} + sub return_to( $self, $c ) { - my $return_to + my $referrer = $c->req->headers->referrer; + my ( $return_to ) = grep { $self->is_local_path( $_ ) } ( # If we've specified one, go there directly - = $c->req->param( 'return_to' ) - ? $c->req->param( 'return_to' ) + $c->req->param( 'return_to' ), # Check flash storage, perhaps from a redirect to the login form - : $c->flash('return_to') ? $c->flash('return_to') + $c->flash('return_to'), # If this is the login page, go back to referer - : $c->current_route =~ /^auth\./ - && $c->req->headers->referrer - && $c->req->headers->referrer !~ m{^(?:\w+:|//)} - ? $c->req->headers->referrer + ( ( $c->current_route // '' ) =~ /^auth\./ ? $referrer : () ), # Otherwise, return the user here - : ( $c->req->url->path || '/' ) - ; - if ( $return_to =~ m{^(?:\w+:|//)} ) { - die q{`return_to` can not contain URL scheme or host}, - } - return $return_to; + $c->req->url->path->to_string, + ); + return $return_to // '/'; } sub start_session( $self, $c, $login ) { # Start a session for the user - # XXX: Set "current user" in session # XXX: Set last login datetime # XXX: Set last used provider # XXX: Set cookie to make auth easier next time - my $to = $c->req->param( 'return_to' ) || '/'; - # Do not allow return_to to redirect the user to another site. - # http://cwe.mitre.org/data/definitions/601.html - if ( $to =~ m{^(?:\w+:|//)} ) { - return $c->reply->exception( - q{`return_to` can not contain URL scheme or host}, - ); - } + my $to = $c->req->param( 'return_to' ); + $to = '/' unless $self->is_local_path( $to ); + + my $user = $self->get_user( $login ) + // die qq{Can not start session for unknown user "$login"\n}; + $self->model->start_session( $c, $user->{user_id} ); $c->res->headers->location( $to ); return $c->rendered( 303 ); diff --git a/lib/Yancy/Auth/Password.pm b/lib/Yancy/Auth/Password.pm index dccd618d..6002a1b2 100644 --- a/lib/Yancy/Auth/Password.pm +++ b/lib/Yancy/Auth/Password.pm @@ -7,87 +7,52 @@ our $VERSION = '1.089'; =head1 SYNOPSIS use Yancy::Auth::Password; - my $auth = Yancy::Auth::Password->new( + my $auth = Yancy::Auth::Password->new( $app, model => $model, # A Yancy::User object - ) + ); =head1 DESCRIPTION This plugin provides a basic password-based authentication scheme for a site. +Passwords are hashed with L, using Argon2id by default. +Each hash has its own random salt, and the hash settings are stored with the +hash, so they can be changed later. + =head2 Adding Users XXX -=head2 Verifying Yancy Passwords in SQL (or other languages) - -Passwords are stored as base64. The Perl L module's removes the -trailing padding from a base64 string. This means that if you try to use -another method to verify passwords, you must also remove any trailing -C<=> from the base64 hash you generate. - -Here are some examples for how to generate the same password hash in -different databases/languages: - -* Perl: - - $ perl -MDigest -E'say Digest->new( "SHA-1" )->add( "password" )->b64digest' - W6ph5Mm5Pz8GgiULbPgzG37mj9g - -* MySQL: - - mysql> SELECT TRIM( TRAILING "=" FROM TO_BASE64( UNHEX( SHA1( "password" ) ) ) ); - +--------------------------------------------------------------------+ - | TRIM( TRAILING "=" FROM TO_BASE64( UNHEX( SHA1( "password" ) ) ) ) | - +--------------------------------------------------------------------+ - | W6ph5Mm5Pz8GgiULbPgzG37mj9g | - +--------------------------------------------------------------------+ - -* Postgres: +=head2 Upgrading Passwords - yancy=# CREATE EXTENSION pgcrypto; - CREATE EXTENSION - yancy=# SELECT TRIM( TRAILING '=' FROM encode( digest( 'password', 'sha1' ), 'base64' ) ); - rtrim - ----------------------------- - W6ph5Mm5Pz8GgiULbPgzG37mj9g - (1 row) +Passwords hashed with an older L configuration (C, as +created by earlier versions of Yancy) can still be used to log in. When +a user logs in with a password whose hash does not match the current +L settings, the password is hashed again with the current +settings. =head1 CONFIGURATION This plugin has the following configuration options. -=head2 digest - -This is the hashing mechanism that should be used for hashing passwords. -This value should be a hash of digest configuration. The one required -field is C, and should be a type supported by the L module: - -=over - -=item * MD5 (part of core Perl) +=head2 passphrase -=item * SHA-1 (part of core Perl) +The L configuration used to hash new passwords, given as +the C to L. Defaults to Argon2id using the +C profile: -=item * SHA-256 (part of core Perl) + passphrase => { module => 'Argon2', profile => 'interactive' }, -=item * SHA-512 (part of core Perl) +Any L encoder can be used, like +L: -=item * Bcrypt (recommended) + passphrase => { module => 'Bcrypt', cost => 12 }, -=back - -Additional fields are given as configuration to the L module. -Not all Digest types require additional configuration. - -There is no default: Perl core provides SHA-1 hashes, but those aren't good -enough. We recommend installing L for password hashing. +=head2 digest -Digest information is stored with the password so that password digests -can be updated transparently when necessary. Changing the digest -configuration will result in a user's password being upgraded the next -time they log in. +B Passwords are now hashed with L. Existing +L hashes are still accepted and upgraded when the user logs in. =head1 TEMPLATES @@ -100,21 +65,45 @@ The form to log in. =head1 SEE ALSO -L, L +L, L, L =cut use Mojo::Base 'Yancy::Auth', -signatures; use Yancy::Util qw( currym routify ); use Digest; +use Encode (); +use Crypt::Passphrase; +use Crypt::Passphrase::Fallback; +use Crypt::Passphrase::Validator; has provider_name => 'password'; has template => 'auth/password'; has layout => 'auth'; -has digest => sub { die 'digest is required' }; +has passphrase => sub { { module => 'Argon2', profile => 'interactive' } }; +has _passphrase => sub ($self) { + return Crypt::Passphrase->new( + encoder => $self->passphrase, + validators => [ + # Hashes from the old Digest configuration: "base64$Type[$key$value...]" + Crypt::Passphrase::Fallback->new( + acceptor => sub ($hash) { $hash =~ m{^[A-Za-z0-9+/]+\$[A-Za-z]} }, + callback => \&_check_digest_password, + ), + ], + ); +}; +# Checked when the login does not exist, so a failed login takes about the +# same time whether or not the login exists +has _dummy_hash => sub ($self) { $self->_passphrase->hash_password( 'yancy' ) }; sub new($class, $app, @args) { my $self = $class->SUPER::new($app, @args); + if ( $self->{digest} ) { + $self->log->warn( + 'Yancy::Auth::Password `digest` is no longer used. Passwords are hashed with `passphrase`', + ); + } my $route = routify( $app, $self->route ); $route->get( '' )->to( cb => currym( $self, '_get_login' ) )->name( $self->route_name('entrypoint') ); @@ -123,17 +112,9 @@ sub new($class, $app, @args) { return $self; } -sub _digest_password( $self, $password ) { - my $config = $self->digest; - my $digest_config_string = _build_digest_config_string( $config ); - my $digest = _get_digest_by_config_string( $digest_config_string ); - my $password_string = join '$', $digest->add( $password )->b64digest, $digest_config_string; - return $password_string; -} - sub set_password( $self, $login, $password ) { my $user = $self->get_user( $login ); - my $password_string = eval { $self->_digest_password( $password ) }; + my $password_string = eval { $self->_passphrase->hash_password( $password ) }; if ( $@ ) { $self->log->error( sprintf 'Error setting password for user "%s": %s', $login, $@, @@ -199,17 +180,34 @@ sub _get_digest_by_config_string($config_string) { return _get_digest( @digest_parts ); } -sub _build_digest_config_string($config) { - my @config_parts = ( - map { $_, $config->{$_} } grep !/^type$/, keys %$config - ); - return join '$', $config->{type}, @config_parts; +# Verify a password against a hash from the old Digest configuration. +# Crypt::Passphrase gives us the password as UTF-8 bytes, but Digest hashed +# characters below 256 (like "æøå") as single Latin-1 bytes, so try both. +sub _check_digest_password( $password, $stored_password ) { + my ( $user_password, $user_digest_config_string ) = split /\$/, $stored_password, 2; + my @candidates = ( $password ); + my $chars = eval { Encode::decode( 'UTF-8', $password, Encode::FB_CROAK ) }; + push @candidates, $chars if defined $chars && $chars ne $password && $chars !~ /[^\x00-\xff]/; + + my $ok = 0; + for my $candidate ( @candidates ) { + my $digest = eval { _get_digest_by_config_string( $user_digest_config_string ) } + or return 0; + my $check_password = $digest->add( $candidate )->b64digest; + $ok ||= Crypt::Passphrase::Validator->secure_compare( $check_password, $user_password ); + } + return $ok; } sub _check_pass($self, $c, $login, $input_password) { + my $passphrase = $self->_passphrase; my $user = $self->get_user( $login ); - my $stored_password = $self->attribute( $user->{user_id}, 'password' ); + if ( !$user ) { + $passphrase->verify_password( $input_password // '', $self->_dummy_hash ); + return undef; + } + my $stored_password = $self->attribute( $user->{user_id}, 'password' ); if ( !$stored_password ) { $self->log->error( sprintf 'Error checking password for user "%s": No stored password', @@ -218,25 +216,12 @@ sub _check_pass($self, $c, $login, $input_password) { return undef; } - my ( $user_password, $user_digest_config_string ) - = split /\$/, $stored_password, 2; - - my $digest = eval { _get_digest_by_config_string( $user_digest_config_string ) }; - if ( $@ ) { - die sprintf 'Error checking password for user "%s": %s', $login, $@; - } - my $check_password = $digest->add( $input_password )->b64digest; - - my $success = $check_password eq $user_password; - - my $default_config_string = _build_digest_config_string( $self->digest ); - if ( $success && ( $user_digest_config_string ne $default_config_string ) ) { - # We need to re-create the user's password field using the new - # settings - $self->_set_password( $login, $input_password ); + my $success = $passphrase->verify_password( $input_password // '', $stored_password ); + if ( $success && $passphrase->needs_rehash( $stored_password ) ) { + # Re-hash the user's password using the current settings + $self->set_password( $login, $input_password ); } return $success; } - 1; diff --git a/lib/Yancy/Auth/templates/auth/password.html.ep b/lib/Yancy/Auth/templates/auth/password.html.ep index 9e1539b8..0b3beb51 100644 --- a/lib/Yancy/Auth/templates/auth/password.html.ep +++ b/lib/Yancy/Auth/templates/auth/password.html.ep @@ -1,14 +1,14 @@
- +
- +
- +
diff --git a/lib/Yancy/Plugin/Editor.pm b/lib/Yancy/Plugin/Editor.pm index 04e00bb7..2146fa5f 100644 --- a/lib/Yancy/Plugin/Editor.pm +++ b/lib/Yancy/Plugin/Editor.pm @@ -29,11 +29,25 @@ The L to use for this editor. The L to use for this editor. +=head2 users + +The L model to check logins with. Only logged-in users can use +the editor. Requests to the editor API and storage from anyone else get a +C<401> response, and the editor page redirects to the first +L registered with the model (or responds with +C<401> if there is none). + +Either this or L is required. + +=head2 allow_anonymous + +Set to true to allow anyone to use the editor without logging in. B that already requires a login>. + =head2 route A base route to add the editor to. This allows you to customize the URL -and add authentication or authorization. Defaults to C with no -authentication. +and add your own authentication or authorization. Defaults to C. This can be a string or a L object. @@ -63,9 +77,28 @@ sub register($self, $app, $config) { my $model = $config->{model}; my $storage = $config->{storage}; + my $users = $config->{users}; + if ( !$users && !$config->{allow_anonymous} ) { + die "Yancy::Plugin::Editor requires a `users` model to check logins, or `allow_anonymous => 1`\n"; + } my $route = blessed $config->{route} ? $config->{route} : $app->routes->any( $config->{route} // '/yancy' ); $route->to( return_to => $config->{return_to} // '/' ); + my $base = $route->render({}); + + if ( $users ) { + $route = $route->under( sub ($c) { + return 1 if $users->current_user( $c ); + my $path = $c->req->url->path->to_string; + my ( $auth ) = @{ $users->auths }; + if ( !$auth || $path =~ m{^\Q$base\E/(?:api|storage)(?:/|$)} ) { + $c->render( status => 401, json => { errors => [ { message => 'Login required' } ] } ); + return undef; + } + $c->redirect_to( $auth->url_for( $c )->query( return_to => $c->req->url->path_query ) ); + return undef; + } ); + } push @{$app->renderer->paths}, path(__FILE__)->dirname->sibling('Editor', 'templates'); push @{$app->static->paths}, path(__FILE__)->dirname->sibling('Editor', 'dist'); @@ -89,7 +122,6 @@ sub register($self, $app, $config) { $route->any( ['DELETE'] => '/storage/*id', { hidden => 1, storage => $storage } )->to( 'storage#delete' ); # Route(s) to view the editor - my $base = $route->render({}); $route->get( '/*rest', { rest => '', hidden => 1, base => $base, template => 'yancy/editor' }, sub($c) { if (!$c->stash('rest') && !$c->req->url->path->trailing_slash) { $c->res->code(308); diff --git a/lib/Yancy/User.pm b/lib/Yancy/User.pm index 687d0963..05291f53 100644 --- a/lib/Yancy/User.pm +++ b/lib/Yancy/User.pm @@ -34,9 +34,86 @@ sub new($class, @args) { return $self; } +=method add_auth + +Register a L with this model. This is called by L +when it is created. + +=cut + sub add_auth( $self, $auth ) { # XXX: Make sure auth provider_name is unique! + push @{ $self->_auths }, $auth; +} + +=method auths + +Returns an arrayref of the L objects registered with this model. + +=cut + +sub auths( $self ) { + return $self->_auths; +} + +=method start_session + + my $session_id = $users->start_session( $c, $user_id ); + +Start a new session for the given user. Creates a row in the C +table and stores its ID in the L. + +=cut + +sub start_session( $self, $c, $user_id ) { + my $now = _now(); + my $session_id = $self->schema('sessions')->create({ + user_id => $user_id, + created_datetime => $now, + last_activity_datetime => $now, + }); + delete $c->stash->{'yancy.current_user'}; + $c->session( yancy_session_id => $session_id ); + return $session_id; +} + +=method current_user + + my $user = $users->current_user( $c ); + +Returns the user for the current session, or C if there is no +active session. + +=cut + +sub current_user( $self, $c ) { + return $c->stash->{'yancy.current_user'} //= do { + my $session_id = $c->session('yancy_session_id'); + my $session = defined $session_id ? $self->schema('sessions')->get($session_id) : undef; + $session && !$session->{expired_datetime} + ? $self->schema('users')->get($session->{user_id}) + : undef; + }; +} + +=method end_session + + $users->end_session( $c ); + +End the current session, if any. + +=cut + +sub end_session( $self, $c ) { + delete $c->stash->{'yancy.current_user'}; + my $session_id = delete $c->session->{yancy_session_id} // return; + $self->schema('sessions')->set($session_id, { expired_datetime => _now() }); + return; +} +sub _now { + my @t = gmtime; + return sprintf '%04d-%02d-%02d %02d:%02d:%02d', $t[5] + 1900, $t[4] + 1, @t[3, 2, 1, 0]; } 1; diff --git a/myapp.pl b/myapp.pl index ba91a9a4..d073dfdc 100755 --- a/myapp.pl +++ b/myapp.pl @@ -25,6 +25,8 @@ plugin 'Yancy::Plugin::Editor', { model => $model, storage => $storage, + # Development app: Allow editing without logging in + allow_anonymous => 1, }; # TODO: 'Mojolicious::Plugin::Yancy' is combination of above things diff --git a/t/auth/password.t b/t/auth/password.t index 1a99a87c..f27d64ef 100644 --- a/t/auth/password.t +++ b/t/auth/password.t @@ -40,12 +40,23 @@ $backend->{schema}{auth_attributes} = { value => { type => 'string' }, }, }; +$backend->{schema}{sessions} = { + 'x-id-field' => 'session_id', + properties => { + session_id => { type => 'integer' }, + user_id => { type => 'integer' }, + created_datetime => { type => 'string' }, + last_activity_datetime => { type => 'string' }, + expired_datetime => { type => [qw( string null )] }, + }, +}; my $t = Test::Mojo->new( 'Mojolicious' ); my $model = Yancy::User->new( backend => $backend ); my $auth = Yancy::Auth::Password->new( $t->app, model => $model, - digest => { type => 'SHA-1' }, + # Fast settings for tests + passphrase => { module => 'Argon2', memory_cost => '8M', time_cost => 1 }, ); subtest 'form_for' => sub { @@ -69,7 +80,124 @@ subtest 'login' => sub { login => 'doug@example.com', }); $auth->set_password('doug@example.com', 'fake_password'); + $t->app->routes->get('/whoami', sub { + my ( $c ) = @_; + my $user = $model->current_user($c); + $c->render(json => { login => $user ? $user->{login} : undef }); + }); + $t->get_ok('/whoami')->json_is('/login', undef, 'not logged in yet'); + $t->post_ok($auth->url_for($c), form => { login => 'doug@example.com', password => 'wrong' })->status_is(400); + $t->post_ok($auth->url_for($c), form => { login => 'nobody@example.com', password => 'fake_password' })->status_is(400); + $t->get_ok('/whoami')->json_is('/login', undef, 'failed logins do not start a session'); $t->post_ok($auth->url_for($c), form => { login => 'doug@example.com', password => 'fake_password' })->status_is(303); + $t->get_ok('/whoami')->json_is('/login', 'doug@example.com', 'login starts a session'); +}; + +subtest 'return_to must be a local path' => sub { + my $c = $t->app->build_controller; + my @unsafe = ( 'https://evil.example', '//evil.example', '/\\evil.example', "/\t/evil.example", 'evil.example' ); + + for my $to ( @unsafe ) { + $t->post_ok($auth->url_for($c), form => { login => 'doug@example.com', password => 'fake_password', return_to => $to }) + ->status_is(303)->header_is(Location => '/', "login does not redirect to $to"); + } + $t->post_ok($auth->url_for($c), form => { login => 'doug@example.com', password => 'fake_password', return_to => '/yancy/?page=2' }) + ->status_is(303)->header_is(Location => '/yancy/?page=2', 'login redirects to local path'); + + for my $to ( @unsafe ) { + $t->get_ok($auth->url_for($c)->query(return_to => $to))->status_is(200) + ->element_exists('input[name=return_to][value=/auth/password]', "login form does not use $to"); + } +}; + +subtest 'logout' => sub { + my $c = $t->app->build_controller; + $t->post_ok($auth->url_for($c), form => { login => 'doug@example.com', password => 'fake_password' })->status_is(303); + $t->get_ok('/whoami')->json_is('/login', 'doug@example.com', 'logged in'); + $t->get_ok('/auth/logout?return_to=/bye')->status_is(303)->header_is(Location => '/bye'); + $t->get_ok('/whoami')->json_is('/login', undef, 'logout ends the session'); + + for my $to ( 'https://evil.example', '//evil.example', '/\\evil.example' ) { + $t->get_ok(Mojo::URL->new('/auth/logout')->query(return_to => $to)) + ->status_is(303)->header_is(Location => '/', "logout does not redirect to $to"); + } +}; + +subtest 'password hashing' => sub { + my $users = $model->schema('users'); + $users->create({ user_id => 2, login => 'alice@example.com' }); + $users->create({ user_id => 3, login => 'bob@example.com' }); + $auth->set_password('alice@example.com', 'same_password'); + $auth->set_password('bob@example.com', 'same_password'); + my $alice_hash = $auth->attribute(2, 'password'); + my $bob_hash = $auth->attribute(3, 'password'); + like $alice_hash, qr{^\$argon2id\$}, 'password is hashed with Argon2id'; + isnt $alice_hash, $bob_hash, 'same password gets a different salt'; + unlike $alice_hash, qr{same_password}, 'password is not stored'; +}; + +subtest 'old Digest passwords are upgraded' => sub { + my $c = $t->app->build_controller; + $model->schema('users')->create({ user_id => 4, login => 'legacy@example.com' }); + my $legacy_hash = Digest->new('SHA-1')->add('legacy_password')->b64digest . '$SHA-1'; + $auth->attribute(4, password => $legacy_hash); + + $t->post_ok($auth->url_for($c), form => { login => 'legacy@example.com', password => 'wrong' })->status_is(400); + is $auth->attribute(4, 'password'), $legacy_hash, 'failed login does not change hash'; + + $t->post_ok($auth->url_for($c), form => { login => 'legacy@example.com', password => 'legacy_password' })->status_is(303); + like $auth->attribute(4, 'password'), qr{^\$argon2id\$}, 'hash upgraded to Argon2id'; + + $t->get_ok('/auth/logout')->status_is(303); + $t->post_ok($auth->url_for($c), form => { login => 'legacy@example.com', password => 'legacy_password' }) + ->status_is(303, 'can log in with upgraded hash'); +}; + +subtest 'old Digest passwords with non-ASCII characters' => sub { + my $c = $t->app->build_controller; + $model->schema('users')->create({ user_id => 5, login => 'soren@example.com' }); + # Old Digest hashes were made from Latin-1 bytes + my $password = "bl\x{e5}b\x{e6}rgr\x{f8}d"; + my $legacy_hash = Digest->new('SHA-1')->add($password)->b64digest . '$SHA-1'; + $auth->attribute(5, password => $legacy_hash); + + $t->post_ok($auth->url_for($c), form => { login => 'soren@example.com', password => $password }) + ->status_is(303, 'can log in with non-ASCII legacy password'); + like $auth->attribute(5, 'password'), qr{^\$argon2id\$}, 'hash upgraded to Argon2id'; + $t->get_ok('/auth/logout')->status_is(303); + $t->post_ok($auth->url_for($c), form => { login => 'soren@example.com', password => $password }) + ->status_is(303, 'can log in with upgraded non-ASCII password'); +}; + +subtest 'Unicode passwords' => sub { + my $c = $t->app->build_controller; + my $users = $model->schema('users'); + + # Old Digest hashes died on characters above U+00FF, so these could not be set + $users->create({ user_id => 6, login => 'emoji@example.com' }); + my $password = "pass\x{1F600}\x{1F510}"; + $auth->set_password('emoji@example.com', $password); + like $auth->attribute(6, 'password'), qr{^\$argon2id\$}, 'emoji password is hashed'; + $t->post_ok($auth->url_for($c), form => { login => 'emoji@example.com', password => $password }) + ->status_is(303, 'can log in with emoji password'); + $t->get_ok('/auth/logout')->status_is(303); + $t->post_ok($auth->url_for($c), form => { login => 'emoji@example.com', password => "pass\x{1F600}" }) + ->status_is(400, 'missing emoji does not log in'); + + # Passwords are normalized, so "å" as U+00E5 matches "a" + U+030A + $users->create({ user_id => 7, login => 'nfc@example.com' }); + $auth->set_password('nfc@example.com', "bl\x{e5}b\x{e6}r"); + $t->post_ok($auth->url_for($c), form => { login => 'nfc@example.com', password => "bla\x{30a}b\x{e6}r" }) + ->status_is(303, 'decomposed characters match composed characters'); + $t->get_ok('/auth/logout')->status_is(303); + + # Old Digest hashes made from UTF-8 bytes, like SQL SHA1() on a UTF-8 string + $users->create({ user_id => 8, login => 'utf8@example.com' }); + my $legacy_hash = Digest->new('SHA-1')->add("pass\xf0\x9f\x98\x80")->b64digest . '$SHA-1'; + $auth->attribute(8, password => $legacy_hash); + $t->post_ok($auth->url_for($c), form => { login => 'utf8@example.com', password => "pass\x{1F600}" }) + ->status_is(303, 'can log in with old UTF-8 hash of emoji password'); + like $auth->attribute(8, 'password'), qr{^\$argon2id\$}, 'old UTF-8 hash upgraded to Argon2id'; }; done_testing; diff --git a/t/controller/model.t b/t/controller/model.t index dc6cdb99..8c9d09ff 100644 --- a/t/controller/model.t +++ b/t/controller/model.t @@ -6,7 +6,7 @@ API =head1 SEE ALSO -L +L =cut @@ -19,7 +19,7 @@ use Mojo::File qw( path ); use Scalar::Util qw( blessed ); use lib "".path( $Bin, '..', 'lib' ); use Local::Test qw( init_backend load_fixtures ); -use Yancy::Controller::Yancy; +use Yancy::Controller::Model; my $schema = \%Local::Test::SCHEMA; diff --git a/t/i18n.t b/t/i18n.t index e84d1037..95a0d5ab 100644 --- a/t/i18n.t +++ b/t/i18n.t @@ -6,8 +6,8 @@ use FindBin qw( $Bin ); use Mojo::File qw( path ); use lib "".path( $Bin, 'lib' ); use Local::Test qw( init_backend ); -use Digest; -use Data::Dumper; +use Yancy::User; +use Yancy::Auth::Password; package MyApp::I18N; use base 'Locale::Maketext'; @@ -18,43 +18,51 @@ our %Lexicon = ( 'Username' => 'Логин', 'Password' => 'Пароль', 'Logi package main; -my ( $backend_url, $backend, %items ) = init_backend( - \%Local::Test::SCHEMA, - user => [ - { - username => 'pavelsr', - password => Digest->new( 'SHA-1' )->add( '123qwe' )->b64digest . '$SHA-1', - email => 'pavel@example.com', - } - ], -); +my ( $backend_url, $backend, %items ) = init_backend( \%Local::Test::SCHEMA ); + +$backend->{schema}{users} = { + 'x-id-field' => 'user_id', + properties => { + user_id => { type => 'integer' }, + login => { type => 'string' }, + }, +}; +$backend->{schema}{auth_attributes} = { + 'x-id-field' => 'auth_attribute_id', + properties => { + auth_attribute_id => { type => 'integer' }, + user_id => { type => 'integer' }, + provider_name => { type => 'string' }, + attribute => { type => 'string' }, + value => { type => 'string' }, + }, +}; my $t = Test::Mojo->new( 'Mojolicious' ); $t->app->plugin( 'Yancy', { backend => $backend_url, schema => \%Local::Test::SCHEMA, } ); -$t->app->plugin( 'Yancy::Plugin::Auth::Password', { - schema => 'user', - username_field => 'username', - password_field => 'password', - password_digest => { type => 'SHA-1' }, - allow_register => 1, -} ); +my $auth = Yancy::Auth::Password->new( $t->app, + model => Yancy::User->new( backend => $backend ), + # Fast settings for tests + passphrase => { module => 'Argon2', memory_cost => '8M', time_cost => 1 }, +); subtest 'Yancy && Mojolicious::Plugin::I18N integration' => sub { my $c = $t->app->build_controller; is 'Hello', $c->helpers->l('Hello'), 'Helper l is available'; }; -subtest 'login_form with russian language' => sub { +subtest 'login form with russian language' => sub { $t->app->plugin('Mojolicious::Plugin::I18N', { namespace => 'MyApp::I18N' } ); my $c = $t->app->build_controller; $c->languages('ru'); - ok my $html = $c->yancy->auth->login_form, 'login form is returned'; + ok my $html = $auth->form_for( $c ), 'login form is returned'; my $dom = Mojo::DOM->new( $html ); - is $dom->at( 'label[for=yancy-username]' )->text, 'Логин', 'username ru ok'; + is $dom->at( 'label[for=yancy-login]' )->text, 'Вход', 'login ru ok'; is $dom->at( 'label[for=yancy-password]' )->text, 'Пароль', 'password ru ok'; + is $dom->at( 'button' )->text, 'Вход', 'button ru ok'; }; done_testing(); diff --git a/t/plugin/editor.t b/t/plugin/editor.t index fa417672..a97dcb25 100644 --- a/t/plugin/editor.t +++ b/t/plugin/editor.t @@ -13,6 +13,9 @@ use Test::Mojo; use FindBin qw( $Bin ); use Mojo::File qw( path ); use Yancy::Model; +use Yancy::User; +use Yancy::Auth::Password; +use Yancy::Backend::Memory; use lib "".path( $Bin, '..', 'lib' ); use Local::Test qw( init_backend ); use Mojo::JSON qw( true false ); @@ -25,7 +28,7 @@ my $model = Yancy::Model->new(backend => $backend); subtest 'editor handles all routes under given root' => sub { my $app = Mojolicious->new; - $app->plugin( 'Yancy::Plugin::Editor' => { model => $model } ); + $app->plugin( 'Yancy::Plugin::Editor' => { model => $model, allow_anonymous => 1 } ); my $t = Test::Mojo->new( $app ); subtest 'editor app routes' => sub { @@ -96,11 +99,12 @@ subtest 'non-default backend' => sub { my $app = Mojolicious->new; # The "original" editor - $app->plugin( 'Yancy::Plugin::Editor' => { model => $model } ); + $app->plugin( 'Yancy::Plugin::Editor' => { model => $model, allow_anonymous => 1 } ); # The "custom" editor $app->plugin( 'Yancy::Plugin::Editor' => { model => $new_model, route => '/pets/editor', + allow_anonymous => 1, } ); my $t = Test::Mojo->new( $app ); @@ -118,4 +122,83 @@ subtest 'non-default backend' => sub { ; }; +subtest 'editor requires users or allow_anonymous' => sub { + my $app = Mojolicious->new; + eval { $app->plugin( 'Yancy::Plugin::Editor' => { model => $model } ) }; + like $@, qr{requires a `users` model}, 'dies without users or allow_anonymous'; +}; + +subtest 'editor requires login' => sub { + my $user_backend = Yancy::Backend::Memory->new( undef, { + users => { + 'x-id-field' => 'user_id', + properties => { + user_id => { type => 'integer' }, + login => { type => 'string' }, + }, + }, + auth_attributes => { + 'x-id-field' => 'auth_attribute_id', + properties => { + auth_attribute_id => { type => 'integer' }, + user_id => { type => 'integer' }, + provider_name => { type => 'string' }, + attribute => { type => 'string' }, + value => { type => 'string' }, + }, + }, + sessions => { + 'x-id-field' => 'session_id', + properties => { + session_id => { type => 'integer' }, + user_id => { type => 'integer' }, + created_datetime => { type => 'string' }, + last_activity_datetime => { type => 'string' }, + expired_datetime => { type => [qw( string null )] }, + }, + }, + } ); + my $users = Yancy::User->new( backend => $user_backend ); + + my $app = Mojolicious->new; + my $auth = Yancy::Auth::Password->new( $app, + model => $users, + # Fast settings for tests + passphrase => { module => 'Argon2', memory_cost => '8M', time_cost => 1 }, + ); + $users->schema('users')->create({ user_id => 1, login => 'doug@example.com' }); + $auth->set_password( 'doug@example.com', 'fake_password' ); + + $app->plugin( 'Yancy::Plugin::Editor' => { model => $model, users => $users } ); + $app->routes->get( '/logout', sub { my ( $c ) = @_; $users->end_session( $c ); $c->rendered( 204 ) } ); + + my $t = Test::Mojo->new( $app ); + $t->get_ok( '/yancy/api' )->status_is( 401 ) + ->json_is( '/errors/0/message', 'Login required' ); + $t->get_ok( '/yancy/api/people' )->status_is( 401 ); + $t->post_ok( '/yancy/api/people', json => { name => 'Anonymous' } )->status_is( 401 ); + $t->get_ok( '/yancy/storage' )->status_is( 401 ); + $t->get_ok( '/yancy/' )->status_is( 302 ) + ->header_is( Location => '/auth/password?return_to=%2Fyancy%2F' ); + + $t->post_ok( '/auth/password', form => { login => 'doug@example.com', password => 'wrong' } ) + ->status_is( 400 ); + $t->get_ok( '/yancy/api' )->status_is( 401, 'wrong password does not log in' ); + + $t->post_ok( '/auth/password', form => { + login => 'doug@example.com', + password => 'fake_password', + return_to => '/yancy/', + } ) + ->status_is( 303 ) + ->header_is( Location => '/yancy/' ); + $t->get_ok( '/yancy/api' )->status_is( 200 ) + ->json_has( '/people', 'logged in user can use the editor API' ); + $t->get_ok( '/yancy/' )->status_is( 200 ) + ->text_is( title => 'Yancy Editor' ); + + $t->get_ok( '/logout' )->status_is( 204 ); + $t->get_ok( '/yancy/api' )->status_is( 401, 'logged out user can not use the editor API' ); +}; + done_testing;