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..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/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..3aee69cf 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,47 @@ 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"); + } }; 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..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(); 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;