aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md1
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth.pm23
-rw-r--r--perllib/FixMyStreet/App/Controller/Root.pm22
-rw-r--r--t/app/controller/auth.t28
-rw-r--r--templates/web/base/auth/create.html31
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>