diff options
author | Matthew Somerville <matthew@mysociety.org> | 2019-10-29 17:22:11 +0000 |
---|---|---|
committer | Matthew Somerville <matthew@mysociety.org> | 2019-11-04 17:10:23 +0000 |
commit | 1a4e43acee3614b6f960fed4325a480f41692daa (patch) | |
tree | c3955a55596ddb4418fec6f7d7211733f39fe227 | |
parent | 5220946f4f7f256e692402e747c7beab90a99d2a (diff) |
Add optional enforced password expiry.
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Auth.pm | 23 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Root.pm | 22 | ||||
-rw-r--r-- | t/app/controller/auth.t | 28 | ||||
-rw-r--r-- | templates/web/base/auth/create.html | 31 |
5 files changed, 95 insertions, 10 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 084a819ff..b786b6c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Improve category edit form. #2469 - Allow editing of category name. #1398 - Allow non-superuser staff to use 2FA, and optional enforcement of 2FA. + - Add optional enforced password expiry. - New features: - Categories can be listed under more than one group #2475 - OpenID Connect login support. #2523 diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm index 041a8b76e..96ca8fdbc 100644 --- a/perllib/FixMyStreet/App/Controller/Auth.pm +++ b/perllib/FixMyStreet/App/Controller/Auth.pm @@ -67,6 +67,25 @@ sub forgot : Path('forgot') : Args(0) { $c->detach('code_sign_in'); } +sub expired : Path('expired') : Args(0) { + my ( $self, $c ) = @_; + + $c->detach('/page_error_403_access_denied', []) unless $c->user_exists; + + my $expiry = $c->cobrand->call_hook('password_expiry'); + $c->detach('/page_error_403_access_denied', []) unless $expiry; + + my $last_change = $c->user->get_extra_metadata('last_password_change') || 0; + my $midnight = int(time()/86400)*86400; + my $expired = $last_change + $expiry < $midnight; + $c->detach('/page_error_403_access_denied', []) unless $expired; + + $c->stash->{expired_password} = 1; + $c->stash->{template} = 'auth/create.html'; + return unless $c->req->method eq 'POST'; + $c->detach('code_sign_in', [ $c->user->email ]); +} + sub authenticate : Private { my ($self, $c, $type, $username, $password) = @_; return 1 if $type eq 'email' && $c->authenticate({ email => $username, email_verified => 1, password => $password }); @@ -121,9 +140,9 @@ they come back with a token (which contains the email/phone). =cut sub code_sign_in : Private { - my ( $self, $c ) = @_; + my ( $self, $c, $override_username ) = @_; - my $username = $c->stash->{username} = $c->get_param('username') || ''; + my $username = $c->stash->{username} = $override_username || $c->get_param('username') || ''; my $parsed = FixMyStreet::SMS->parse_username($username); diff --git a/perllib/FixMyStreet/App/Controller/Root.pm b/perllib/FixMyStreet/App/Controller/Root.pm index 2c7e28e5f..fb6d063be 100644 --- a/perllib/FixMyStreet/App/Controller/Root.pm +++ b/perllib/FixMyStreet/App/Controller/Root.pm @@ -39,6 +39,7 @@ sub auto : Private { # decide which cobrand this request should use $c->setup_request(); + $c->forward('check_password_expiry'); $c->detach('/auth/redirect') if $c->cobrand->call_hook('check_login_disallowed'); return 1; @@ -166,6 +167,27 @@ sub check_login_required : Private { $c->detach( '/auth/redirect' ); } +sub check_password_expiry : Private { + my ($self, $c) = @_; + + return unless $c->user_exists; + + return if $c->action eq $c->controller('JS')->action_for('translation_strings'); + return if $c->controller eq $c->controller('Auth'); + + my $expiry = $c->cobrand->call_hook('password_expiry'); + return unless $expiry; + + my $last_change = $c->user->get_extra_metadata('last_password_change') || 0; + my $midnight = int(time()/86400)*86400; + my $expired = $last_change + $expiry < $midnight; + return unless $expired; + + my $uri = $c->uri_for('/auth/expired'); + $c->res->redirect( $uri ); + $c->detach; +} + =head2 end Attempt to render a view, if needed. diff --git a/t/app/controller/auth.t b/t/app/controller/auth.t index 899b64198..b23c1210c 100644 --- a/t/app/controller/auth.t +++ b/t/app/controller/auth.t @@ -3,6 +3,11 @@ use parent 'FixMyStreet::Cobrand::Default'; sub must_have_2fa { 1 } +package FixMyStreet::Cobrand::Expiring; +use parent 'FixMyStreet::Cobrand::Default'; + +sub password_expiry { 86400 } + package main; use Test::MockModule; @@ -286,6 +291,7 @@ subtest 'test forgotten password page' => sub { fields => { username => $test_email, password_register => 'squirblewirble' }, button => 'sign_in_by_code', }); + $mech->clear_emails_ok; }; subtest "Test two-factor authentication login" => sub { @@ -402,4 +408,26 @@ subtest "Check two-factor log in by email works" => sub { $mech->logged_in_ok; }; +FixMyStreet::override_config { + ALLOWED_COBRANDS => 'expiring' +}, sub { + subtest 'Password expiry' => sub { + my $user = FixMyStreet::App->model('DB::User')->find( { email => $test_email } ); + $user->set_extra_metadata('last_password_change', time() - 200000); + $user->unset_extra_metadata('2fa_secret'); + $user->update; + + $mech->get_ok('/'); + $mech->content_contains('Password expired'); + $mech->submit_form_ok( + { with_fields => { password_register => 'new-password' } }, + "fill in reset form" ); + + my $link = $mech->get_link_from_email; + $mech->clear_emails_ok; + $mech->get_ok($link); + $mech->logged_in_ok; + }; +}; + done_testing(); diff --git a/templates/web/base/auth/create.html b/templates/web/base/auth/create.html index 1886da95b..b8830e385 100644 --- a/templates/web/base/auth/create.html +++ b/templates/web/base/auth/create.html @@ -1,6 +1,8 @@ [% IF forgotten; title = loc('Forgot password'); +ELSIF expired_password; + title = loc('Password expired'); ELSE; title = loc('Create an account'); END; @@ -9,9 +11,11 @@ INCLUDE 'header.html', bodyclass='authpage' %] <h1> [% title %] + [% IF NOT expired_password %] <small> [% tprintf(loc('or <a href="%s">sign in</a>'), '/auth') %] </small> + [% END %] </h1> [% IF forgotten %] @@ -19,23 +23,32 @@ INCLUDE 'header.html', bodyclass='authpage' %] [% IF c.config.SMS_AUTHENTICATION %] [% loc('Sign in by email or text, providing a new password. When you click the link in your email or enter the SMS authentication code, your password will be updated.') %]</p> [% ELSE %] - [% loc('Sign in by email instead, providing a new password. When you click the link in your email, your password will be updated.') %]</p> + [% loc('Sign in by email instead, providing a new password. When you click the link in your email, your password will be updated.') %] [% END %] </p> +[% ELSIF expired_password %] +<p> + <a href="/auth/sign_out">[% loc('Sign out') %]</a> +</p> +<p> + [% loc('Your password has expired, please create a new one below. When you click the link in your email, your password will be updated.') %] +</p> [% END %] -<form action="/auth/[% forgotten ? 'forgot' : 'create' %]" method="post" name="general_auth" class="validate"> +<form action="/auth/[% expired_password ? 'expired' : forgotten ? 'forgot' : 'create' %]" method="post" name="general_auth" class="validate"> <fieldset> <input type="hidden" name="r" value="[% c.req.params.r | html %]"> + [% IF NOT expired_password %] + [% loc_username_error = INCLUDE 'auth/_username_error.html' default='email' %] -[% IF c.config.SMS_AUTHENTICATION %] - [% SET username_label = loc('Your email or mobile') %] -[% ELSE %] - [% SET username_label = loc('Your email') %] -[% END %] + [% IF c.config.SMS_AUTHENTICATION %] + [% SET username_label = loc('Your email or mobile') %] + [% ELSE %] + [% SET username_label = loc('Your email') %] + [% END %] <label class="n" for="username">[% username_label %]</label> [% IF loc_username_error %] @@ -43,6 +56,8 @@ INCLUDE 'header.html', bodyclass='authpage' %] [% END %] <input type="text" class="form-control required" id="username" name="username" value="[% username | html %]" autofocus autocomplete="username"> + [% END %] + [% IF field_errors.password_register %] <p class='form-error'>[% field_errors.password_register %]</p> [% END %] @@ -54,7 +69,7 @@ INCLUDE 'header.html', bodyclass='authpage' %] <div class="form-txt-submit-box"> <input class="required form-control js-password-validate" type="password" name="password_register" id="password_register" value="" autocomplete="new-password"> - <input class="green-btn" type="submit" name="sign_in_by_code" value="[% loc('Sign in') %]"> + <input class="green-btn" type="submit" name="sign_in_by_code" value="[% expired_password ? loc('Reset') : loc('Sign in') %]"> </div> </fieldset> |