From 31d83a9da2ca0652f57d73240b9307c026c01c44 Mon Sep 17 00:00:00 2001 From: Erik Johansen Date: Tue, 29 Sep 2026 09:00:33 +0200 Subject: [PATCH 1/8] fix tests for removed controller and file plugin Yancy::Controller::Yancy was renamed to Yancy::Controller::Model, and Yancy::Plugin::File was removed in favor of Yancy::Storage. Update the controller test and give the default editor a Yancy::Storage rooted at public/uploads instead of loading the missing file plugin. Co-Authored-By: Claude Opus 5.5 --- lib/Mojolicious/Plugin/Yancy.pm | 15 +++++++-------- t/controller/model.t | 4 ++-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/Mojolicious/Plugin/Yancy.pm b/lib/Mojolicious/Plugin/Yancy.pm index 3cb46c67..0f5e37d9 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,13 +216,11 @@ 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' => { + storage => Yancy::Storage->new( $app->home->child( 'public/uploads' ) ), %{ $config->{editor} // {} }, } ); } 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; From ada96595c15d1a82582c20bb3a25bf79479481df Mon Sep 17 00:00:00 2001 From: Erik Johansen Date: Tue, 29 Sep 2026 09:00:33 +0200 Subject: [PATCH 2/8] port i18n test to new password auth The new password auth template now uses the `l` helper for its labels, and Yancy::Auth loads the I18N plugin if the app does not have it yet. This lets t/i18n.t use Yancy::Auth::Password instead of the deleted auth plugin roles. Co-Authored-By: Claude Opus 5.5 --- lib/Yancy/Auth.pm | 5 ++ .../Auth/templates/auth/password.html.ep | 6 +-- t/i18n.t | 51 +++++++++++-------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/lib/Yancy/Auth.pm b/lib/Yancy/Auth.pm index 02f5636d..88403b0b 100644 --- a/lib/Yancy/Auth.pm +++ b/lib/Yancy/Auth.pm @@ -25,6 +25,11 @@ 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 $self->model->add_auth( $self ); 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/t/i18n.t b/t/i18n.t index e84d1037..663884cc 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,50 @@ 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 ), + digest => { type => 'SHA-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(); From 026040041b79bb259244c10396d3da96e8d9d664 Mon Sep 17 00:00:00 2001 From: Erik Johansen Date: Tue, 29 Sep 2026 09:15:17 +0200 Subject: [PATCH 3/8] start user sessions when logging in with new auth Yancy::Auth->start_session only redirected, so logging in did nothing. Add start_session, current_user, and end_session to Yancy::User, using the sessions table with the session ID stored in the Mojolicious session. Yancy::Auth now starts a session for the user after a successful login, and Yancy::User keeps the auths registered with it. Also fix two bugs in Yancy::Auth::Password's login check: logging in as an unknown user no longer looks up attributes for an undefined user ID, and upgrading a password digest calls set_password instead of the missing _set_password. Co-Authored-By: Claude Opus 5.5 --- lib/Yancy/Auth.pm | 5 ++- lib/Yancy/Auth/Password.pm | 4 +- lib/Yancy/User.pm | 77 ++++++++++++++++++++++++++++++++++++++ t/auth/password.t | 20 ++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/lib/Yancy/Auth.pm b/lib/Yancy/Auth.pm index 88403b0b..6ac0b085 100644 --- a/lib/Yancy/Auth.pm +++ b/lib/Yancy/Auth.pm @@ -76,7 +76,6 @@ sub return_to( $self, $c ) { 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 @@ -91,6 +90,10 @@ sub start_session( $self, $c, $login ) { ); } + 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..e1adc2ae 100644 --- a/lib/Yancy/Auth/Password.pm +++ b/lib/Yancy/Auth/Password.pm @@ -207,7 +207,7 @@ sub _build_digest_config_string($config) { } sub _check_pass($self, $c, $login, $input_password) { - my $user = $self->get_user( $login ); + my $user = $self->get_user( $login ) // return undef; my $stored_password = $self->attribute( $user->{user_id}, 'password' ); if ( !$stored_password ) { @@ -233,7 +233,7 @@ sub _check_pass($self, $c, $login, $input_password) { 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 ); + $self->set_password( $login, $input_password ); } return $success; 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/t/auth/password.t b/t/auth/password.t index 1a99a87c..b9ff8b35 100644 --- a/t/auth/password.t +++ b/t/auth/password.t @@ -40,6 +40,16 @@ $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 ); @@ -69,7 +79,17 @@ 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'); }; done_testing; From b3940cf61d2fb7f645966f4d03e0b1ddb5bac09c Mon Sep 17 00:00:00 2001 From: Erik Johansen Date: Tue, 29 Sep 2026 09:15:17 +0200 Subject: [PATCH 4/8] require login for the editor by default The editor API and storage routes were open to anyone, giving full read/write access to every schema and to file storage. Yancy::Plugin::Editor now requires either a `users` model (a Yancy::User) or an explicit `allow_anonymous => 1`. With a users model, requests from anyone who is not logged in get a 401 from the API and storage routes, and the editor page redirects to the first registered auth's login page. Mojolicious::Plugin::Yancy only adds its default editor when one of those options is given in `editor`, and logs a warning otherwise. The development apps set `allow_anonymous => 1`. Co-Authored-By: Claude Opus 5.5 --- eg/meet-space/myapp.pl | 2 + lib/Mojolicious/Plugin/Yancy.pm | 18 +++++-- lib/Yancy/Plugin/Editor.pm | 38 +++++++++++++-- myapp.pl | 2 + t/plugin/editor.t | 86 ++++++++++++++++++++++++++++++++- 5 files changed, 137 insertions(+), 9 deletions(-) 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 0f5e37d9..7ae20662 100644 --- a/lib/Mojolicious/Plugin/Yancy.pm +++ b/lib/Mojolicious/Plugin/Yancy.pm @@ -219,10 +219,20 @@ sub register { # Add the default editor unless the user explicitly disables it if ( !exists $config->{editor} || defined $config->{editor} ) { - $app->plugin( 'Yancy::Plugin::Editor' => { - storage => Yancy::Storage->new( $app->home->child( 'public/uploads' ) ), - %{ $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/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/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/plugin/editor.t b/t/plugin/editor.t index fa417672..0f4c47ea 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,82 @@ 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, + digest => { type => 'SHA-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; From bfb88a42dd1ec7766663e82f38de14378cb66910 Mon Sep 17 00:00:00 2001 From: Erik Johansen Date: Tue, 29 Sep 2026 09:21:36 +0200 Subject: [PATCH 5/8] only redirect to local paths after login and logout The return_to check only rejected URLs starting with a scheme or `//`, so `/\evil.example` (which browsers treat as `//evil.example`) could redirect users to another site after logging in. An unsafe return_to also caused a 500 error instead of being ignored. Add Yancy::Auth->is_local_path, which only allows paths starting with a single `/` without backslashes or control characters. return_to and start_session skip unsafe values and fall back to a safe default. Also add a `/auth/logout` route that ends the session and redirects to a local return_to path, or `/`. Co-Authored-By: Claude Opus 5.5 --- lib/Yancy/Auth.pm | 61 ++++++++++++++++++++++++++++++----------------- t/auth/password.t | 30 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/lib/Yancy/Auth.pm b/lib/Yancy/Auth.pm index 6ac0b085..0f117d5c 100644 --- a/lib/Yancy/Auth.pm +++ b/lib/Yancy/Auth.pm @@ -32,6 +32,18 @@ sub new($class, $app, @args) { # 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; } @@ -53,25 +65,36 @@ 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 ) { @@ -80,15 +103,9 @@ sub start_session( $self, $c, $login ) { # 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}; diff --git a/t/auth/password.t b/t/auth/password.t index b9ff8b35..3aee69cf 100644 --- a/t/auth/password.t +++ b/t/auth/password.t @@ -92,4 +92,34 @@ subtest 'login' => sub { $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"); + } +}; + done_testing; From 5b17e7543b6d5b428b7216349f32194741f49be8 Mon Sep 17 00:00:00 2001 From: Erik Johansen Date: Tue, 29 Sep 2026 09:31:59 +0200 Subject: [PATCH 6/8] hash passwords with Crypt::Passphrase and Argon2id Yancy::Auth::Password hashed passwords with a single unsalted Digest pass (the docs and tests used SHA-1), and compared hashes with `eq`. Passwords are now hashed with Crypt::Passphrase, using Argon2id with the `interactive` profile by default. Each hash gets its own random salt, and verification is constant-time. The encoder can be changed with the new `passphrase` option. Existing Digest hashes (`base64$Type`) still verify, using a constant-time compare, and are re-hashed with the current settings the next time the user logs in. The `digest` option is no longer used, and logs a warning if given. Logins with an unknown user also check a dummy hash, so failed logins take about the same time whether or not the user exists. Co-Authored-By: Claude Opus 5.5 --- cpanfile | 2 + dist.ini | 2 + lib/Yancy/Auth/Password.pm | 158 ++++++++++++++++--------------------- t/auth/password.t | 33 +++++++- t/i18n.t | 3 +- t/plugin/editor.t | 3 +- 6 files changed, 106 insertions(+), 95 deletions(-) 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/lib/Yancy/Auth/Password.pm b/lib/Yancy/Auth/Password.pm index e1adc2ae..cc4a0bf0 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,44 @@ 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 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 +111,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 +179,24 @@ 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 +sub _check_digest_password( $password, $stored_password ) { + my ( $user_password, $user_digest_config_string ) = split /\$/, $stored_password, 2; + my $digest = eval { _get_digest_by_config_string( $user_digest_config_string ) } + or return 0; + my $check_password = $digest->add( $password )->b64digest; + return Crypt::Passphrase::Validator->secure_compare( $check_password, $user_password ); } sub _check_pass($self, $c, $login, $input_password) { - my $user = $self->get_user( $login ) // return undef; - my $stored_password = $self->attribute( $user->{user_id}, 'password' ); + my $passphrase = $self->_passphrase; + my $user = $self->get_user( $login ); + 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 +205,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 + 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/t/auth/password.t b/t/auth/password.t index 3aee69cf..661fec22 100644 --- a/t/auth/password.t +++ b/t/auth/password.t @@ -55,7 +55,8 @@ 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 { @@ -122,4 +123,34 @@ subtest 'logout' => sub { } }; +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'); +}; + done_testing; diff --git a/t/i18n.t b/t/i18n.t index 663884cc..95a0d5ab 100644 --- a/t/i18n.t +++ b/t/i18n.t @@ -45,7 +45,8 @@ $t->app->plugin( 'Yancy', { } ); my $auth = Yancy::Auth::Password->new( $t->app, model => Yancy::User->new( backend => $backend ), - digest => { type => 'SHA-1' }, + # Fast settings for tests + passphrase => { module => 'Argon2', memory_cost => '8M', time_cost => 1 }, ); subtest 'Yancy && Mojolicious::Plugin::I18N integration' => sub { diff --git a/t/plugin/editor.t b/t/plugin/editor.t index 0f4c47ea..a97dcb25 100644 --- a/t/plugin/editor.t +++ b/t/plugin/editor.t @@ -163,7 +163,8 @@ subtest 'editor requires login' => sub { my $app = Mojolicious->new; my $auth = Yancy::Auth::Password->new( $app, model => $users, - digest => { type => 'SHA-1' }, + # 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' ); From e795a9a9df415704c8eb024e4a92458fb0c614cb Mon Sep 17 00:00:00 2001 From: Erik Johansen Date: Tue, 29 Sep 2026 09:33:03 +0200 Subject: [PATCH 7/8] accept old Digest hashes of non-ASCII passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crypt::Passphrase gives validators the password as UTF-8 bytes, but the old Digest hashes were made by adding the password characters, which Digest hashes as Latin-1 bytes. Users with characters like "æøå" in their password could not log in with their old hash. Try both the UTF-8 and Latin-1 bytes when checking an old hash. Co-Authored-By: Claude Opus 5.5 --- lib/Yancy/Auth/Password.pm | 21 ++++++++++++++++----- t/auth/password.t | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/lib/Yancy/Auth/Password.pm b/lib/Yancy/Auth/Password.pm index cc4a0bf0..6002a1b2 100644 --- a/lib/Yancy/Auth/Password.pm +++ b/lib/Yancy/Auth/Password.pm @@ -72,6 +72,7 @@ L, L, L 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; @@ -179,13 +180,23 @@ sub _get_digest_by_config_string($config_string) { return _get_digest( @digest_parts ); } -# Verify a password against a hash from the old Digest configuration +# 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 $digest = eval { _get_digest_by_config_string( $user_digest_config_string ) } - or return 0; - my $check_password = $digest->add( $password )->b64digest; - return Crypt::Passphrase::Validator->secure_compare( $check_password, $user_password ); + 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) { diff --git a/t/auth/password.t b/t/auth/password.t index 661fec22..3e43be8f 100644 --- a/t/auth/password.t +++ b/t/auth/password.t @@ -153,4 +153,20 @@ subtest 'old Digest passwords are upgraded' => sub { ->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'); +}; + done_testing; From 19f5182bc84b62a05e80537183201e1d4603cc05 Mon Sep 17 00:00:00 2001 From: Erik Johansen Date: Tue, 29 Sep 2026 09:36:54 +0200 Subject: [PATCH 8/8] test Unicode passwords Add tests for passwords with emoji (which the old Digest hashing could not handle), for composed and decomposed characters matching after normalization, and for old Digest hashes made from UTF-8 bytes. Co-Authored-By: Claude Opus 5.5 --- t/auth/password.t | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/t/auth/password.t b/t/auth/password.t index 3e43be8f..f27d64ef 100644 --- a/t/auth/password.t +++ b/t/auth/password.t @@ -169,4 +169,35 @@ subtest 'old Digest passwords with non-ASCII characters' => sub { ->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;