aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMatthew Somerville <matthew-github@dracos.co.uk>2018-02-07 13:09:45 +0000
committerMatthew Somerville <matthew-github@dracos.co.uk>2018-02-07 13:09:45 +0000
commit7361782de3d072f8d09442e33aa9c42a7c181c4c (patch)
tree5b8f49a13eb6f3aeb152262cbe66d55a48c4924d
parent6879af98d0246b6973affff08a4e078206bb5dfc (diff)
parent3e721ddf5d9809c9f44d7dedcf2083a544e6e148 (diff)
Merge branch '2fa-superuser'
-rw-r--r--.travis.yml2
-rw-r--r--CHANGELOG.md1
-rw-r--r--cpanfile2
-rw-r--r--cpanfile.snapshot53
-rw-r--r--perllib/Catalyst/Authentication/Credential/2FA.pm141
-rw-r--r--perllib/FixMyStreet/App.pm17
-rw-r--r--perllib/FixMyStreet/App/Controller/Alert.pm9
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth.pm8
-rw-r--r--perllib/FixMyStreet/App/Controller/Auth/Profile.pm26
-rw-r--r--perllib/FixMyStreet/App/Controller/Report/New.pm32
-rw-r--r--perllib/FixMyStreet/App/Controller/Report/Update.pm16
-rw-r--r--perllib/FixMyStreet/DB/Result/User.pm5
-rw-r--r--t/app/controller/auth.t23
-rw-r--r--t/app/controller/auth_profile.t36
-rw-r--r--t/app/controller/report_new.t34
-rw-r--r--templates/web/base/auth/2faform.html26
-rw-r--r--templates/web/base/auth/generate_token.html26
-rw-r--r--templates/web/base/my/my.html2
18 files changed, 423 insertions, 36 deletions
diff --git a/.travis.yml b/.travis.yml
index 13bbee380..479595cad 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -40,7 +40,7 @@ install:
- 'if [ "$TRAVIS_PERL_VERSION" = "5.24" ]; then cpanm --quiet --notest https://github.com/mysociety/codecov-perl/tarball/cover-uncoverables; fi'
before_script:
- commonlib/bin/gettext-makemo FixMyStreet
- - 'if [ "$TRAVIS_PERL_VERSION" = "5.24" ]; then export HARNESS_PERL_SWITCHES="-MDevel::Cover=+ignore,local/lib/perl5,commonlib,perllib/Catalyst,perllib/DBIx,perllib/Email,perllib/Template,^t"; fi'
+ - 'if [ "$TRAVIS_PERL_VERSION" = "5.24" ]; then export HARNESS_PERL_SWITCHES="-MDevel::Cover=+ignore,local/lib/perl5,commonlib,perllib/Catalyst/[^A],perllib/DBIx,perllib/Email,perllib/Template,^t"; fi'
script: "script/test --jobs 3 t"
after_success:
- .travis/after_script
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 118b93e3d..065ad72e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@
- Admin can anonymize/hide all a user's reports. #1942 #1943
- Admin can log a user out. #1975
- Admin can remove a user's account details. #1944
+ - Superusers can have optional two-factor authentication. #1973
- UK:
- Lazy load images in the footer.
diff --git a/cpanfile b/cpanfile
index 1f68df9db..707dcb006 100644
--- a/cpanfile
+++ b/cpanfile
@@ -18,6 +18,7 @@ requires 'CGI', '4.38';
# Catalyst itself, and modules/plugins used
requires 'Catalyst', '5.80031';
requires 'Catalyst::Action::RenderView';
+requires 'Catalyst::Authentication::Credential::MultiFactor';
requires 'Catalyst::Authentication::Store::DBIx::Class';
requires 'Catalyst::Devel';
requires 'Catalyst::Model::Adaptor';
@@ -30,6 +31,7 @@ requires 'Catalyst::Plugin::Unicode::Encoding';
requires 'Catalyst::View::TT';
# Modules used by FixMyStreet
+requires 'Auth::GoogleAuth';
requires 'Authen::SASL';
requires 'Cache::Memcached';
requires 'Carp';
diff --git a/cpanfile.snapshot b/cpanfile.snapshot
index 5fa44e318..83d855c98 100644
--- a/cpanfile.snapshot
+++ b/cpanfile.snapshot
@@ -28,6 +28,22 @@ DISTRIBUTIONS
requirements:
ExtUtils::MakeMaker 0
Test::More 0
+ Auth-GoogleAuth-1.02
+ pathname: G/GR/GRYPHON/Auth-GoogleAuth-1.02.tar.gz
+ provides:
+ Auth::GoogleAuth 1.02
+ requirements:
+ Carp 0
+ Class::Accessor 0
+ Convert::Base32 0
+ Digest::HMAC_SHA1 0
+ ExtUtils::MakeMaker 0
+ Math::Random::MT 0
+ URI::Escape 0
+ base 0
+ perl 5.008
+ strict 0
+ warnings 0
Authen-SASL-2.16
pathname: G/GB/GBARR/Authen-SASL-2.16.tar.gz
provides:
@@ -292,6 +308,14 @@ DISTRIBUTIONS
HTTP::Request::AsCGI 0
MRO::Compat 0
Test::More 0.88
+ Catalyst-Authentication-Credential-MultiFactor-1.2
+ pathname: T/TE/TENGU/Catalyst-Authentication-Credential-MultiFactor-1.2.tar.gz
+ provides:
+ Catalyst::Authentication::Credential::MultiFactor 1.2
+ requirements:
+ ExtUtils::MakeMaker 0
+ Moose 0
+ namespace::autoclean 0
Catalyst-Authentication-Store-DBIx-Class-0.1503
pathname: B/BO/BOBTFISH/Catalyst-Authentication-Store-DBIx-Class-0.1503.tar.gz
provides:
@@ -909,6 +933,14 @@ DISTRIBUTIONS
Test::Exception 0
Test::More 0
ok 0
+ Convert-Base32-0.06
+ pathname: I/IK/IKEGAMI/Convert-Base32-0.06.tar.gz
+ provides:
+ Convert::Base32 0.06
+ requirements:
+ ExtUtils::MakeMaker 0
+ Test::Exception 0
+ Test::More 0
Cpanel-JSON-XS-3.0210
pathname: R/RU/RURBAN/Cpanel-JSON-XS-3.0210.tar.gz
provides:
@@ -3685,6 +3717,14 @@ DISTRIBUTIONS
Net::Domain 1.05
Net::SMTP 1.03
Test::More 0
+ Math-Random-MT-1.17
+ pathname: F/FA/FANGLY/Math-Random-MT-1.17.tar.gz
+ provides:
+ Math::Random::MT 1.17
+ requirements:
+ ExtUtils::MakeMaker 0
+ Test::More 0
+ Test::Number::Delta 0
Memoize-ExpireLRU-0.55
pathname: B/BP/BPOWERS/Memoize-ExpireLRU-0.55.tar.gz
provides:
@@ -6099,6 +6139,19 @@ DISTRIBUTIONS
Test::More 0.47
Test::Tester 0.107
perl 5.006
+ Test-Number-Delta-1.06
+ pathname: D/DA/DAGOLDEN/Test-Number-Delta-1.06.tar.gz
+ provides:
+ Test::Number::Delta 1.06
+ requirements:
+ Carp 0
+ Exporter 0
+ ExtUtils::MakeMaker 6.17
+ Test::Builder 0
+ perl 5.006
+ strict 0
+ vars 0
+ warnings 0
Test-Output-1.01
pathname: B/BD/BDFOY/Test-Output-1.01.tar.gz
provides:
diff --git a/perllib/Catalyst/Authentication/Credential/2FA.pm b/perllib/Catalyst/Authentication/Credential/2FA.pm
new file mode 100644
index 000000000..6cb1dd297
--- /dev/null
+++ b/perllib/Catalyst/Authentication/Credential/2FA.pm
@@ -0,0 +1,141 @@
+package Catalyst::Authentication::Credential::2FA;
+
+use strict;
+use warnings;
+use Auth::GoogleAuth;
+
+our $VERSION = "0.01";
+
+sub new {
+ my ($class, $config, $c, $realm) = @_;
+ my $self = { %$config };
+ bless $self, $class;
+ return $self;
+}
+
+sub authenticate {
+ my ( $self, $c, $realm, $authinfo ) = @_;
+
+ my $userfindauthinfo = {%{$authinfo}};
+ delete($userfindauthinfo->{password});
+
+ my $user_obj = $realm->find_user($userfindauthinfo, $c);
+ if (ref($user_obj)) {
+ # We don't care unless user is a superuser and has a 2FA secret
+ return $user_obj unless $user_obj->is_superuser;
+ return $user_obj unless $user_obj->get_extra_metadata('2fa_secret');
+
+ $c->stash->{token} = $c->get_param('token');
+
+ if ($self->check_2fa($c, $user_obj)) {
+ if ($c->stash->{token}) {
+ my $token = $c->forward('/tokens/load_auth_token', [ $c->stash->{token}, '2fa' ]);
+ # Will contain a detach_to and report/update data
+ $c->stash($token->data);
+ }
+ return $user_obj;
+ }
+
+ if ($c->stash->{tfa_data}) {
+ my $token = $c->model("DB::Token")->create( {
+ scope => '2fa',
+ data => $c->stash->{tfa_data},
+ });
+ $c->stash->{token} = $token->token;
+ }
+
+ $c->stash->{template} = 'auth/2faform.html';
+ $c->detach;
+ }
+}
+
+sub check_2fa {
+ my ($self, $c, $user) = @_;
+
+ if (my $code = $c->get_param('2fa_code')) {
+ my $auth = Auth::GoogleAuth->new;
+ my $secret32 = $user->get_extra_metadata('2fa_secret');
+ return 1 if $auth->verify($code, 1, $secret32);
+ $c->stash->{incorrect_code} = 1;
+ }
+ return 0;
+}
+
+__PACKAGE__;
+
+__END__
+
+=pod
+
+=head1 NAME
+
+Catalyst::Authentication::Credential::2FA - Authenticate a user
+with a two-factor authentication code.
+
+=head1 SYNOPSIS
+
+ use Catalyst qw/
+ Authentication
+ /;
+
+ package MyApp::Controller::Auth;
+
+ sub login : Local {
+ my ( $self, $c ) = @_;
+
+ $c->authenticate( { username => $c->req->param('username'),
+ password => $c->req->param('password') });
+ }
+
+=head1 DESCRIPTION
+
+This authentication credential checker takes authentication information
+(most often a username), and only passes if a valid 2FA code is then
+entered. It only works for Users that have an is_superuser flag set,
+plus store the 2FA secret in a FixMyStreet::Role::Extra metadata key.
+
+=head1 CONFIGURATION
+
+ # example
+ 'Plugin::Authentication' => {
+ default => {
+ credential => {
+ class => 'MultiFactor',
+ factors => [
+ {
+ class => 'Password',
+ password_field => 'password',
+ password_type => 'self_check',
+ },
+ {
+ class => '2FA',
+ },
+ ],
+ },
+ store => {
+ class => 'DBIx::Class',
+ user_model => 'DB::User',
+ },
+ },
+
+
+=over 4
+
+=item class
+
+The classname used for Credential. This is part of
+L<Catalyst::Plugin::Authentication> and is the method by which
+Catalyst::Authentication::Credential::2FA is loaded as the
+credential validator. For this module to be used, this must be set to
+'2FA'.
+
+=back
+
+=head1 USAGE
+
+Once configured as indicated above, authenticating using this module is a
+matter of calling $c->authenticate() as normal. If you wish to use it in
+combination with e.g. password authentication as well (so it actually is
+two-factor!), check out Catalyst::Authentication::Credential::MultiFactor.
+
+=cut
diff --git a/perllib/FixMyStreet/App.pm b/perllib/FixMyStreet/App.pm
index a3331d32a..008aea595 100644
--- a/perllib/FixMyStreet/App.pm
+++ b/perllib/FixMyStreet/App.pm
@@ -62,10 +62,19 @@ __PACKAGE__->config(
'Plugin::Authentication' => {
default_realm => 'default',
default => {
- credential => { # Catalyst::Authentication::Credential::Password
- class => 'Password',
- password_field => 'password',
- password_type => 'self_check',
+ credential => {
+ class => 'MultiFactor',
+ factors => [
+ # Catalyst::Authentication::Credential::Password
+ {
+ class => 'Password',
+ password_field => 'password',
+ password_type => 'self_check',
+ },
+ {
+ class => '2FA',
+ },
+ ],
},
store => { # Catalyst::Authentication::Store::DBIx::Class
class => 'DBIx::Class',
diff --git a/perllib/FixMyStreet/App/Controller/Alert.pm b/perllib/FixMyStreet/App/Controller/Alert.pm
index 5c9fbad1b..9d522dbc9 100644
--- a/perllib/FixMyStreet/App/Controller/Alert.pm
+++ b/perllib/FixMyStreet/App/Controller/Alert.pm
@@ -281,20 +281,25 @@ then display confirmation page.
sub send_confirmation_email : Private {
my ( $self, $c ) = @_;
+ my $user = $c->stash->{alert}->user;
+
+ # Superusers using 2FA can not log in by code
+ $c->detach( '/page_error_403_access_denied', [] ) if $user->has_2fa;
+
my $token = $c->model("DB::Token")->create(
{
scope => 'alert',
data => {
id => $c->stash->{alert}->id,
type => 'subscribe',
- email => $c->stash->{alert}->user->email
+ email => $user->email
}
}
);
$c->stash->{token_url} = $c->uri_for_email( '/A', $token->token );
- $c->send_email( 'alert-confirm.txt', { to => $c->stash->{alert}->user->email } );
+ $c->send_email( 'alert-confirm.txt', { to => $user->email } );
$c->stash->{email_type} = 'alert';
$c->stash->{template} = 'email_sent.html';
diff --git a/perllib/FixMyStreet/App/Controller/Auth.pm b/perllib/FixMyStreet/App/Controller/Auth.pm
index 95f8bb9a2..533e6a9be 100644
--- a/perllib/FixMyStreet/App/Controller/Auth.pm
+++ b/perllib/FixMyStreet/App/Controller/Auth.pm
@@ -243,6 +243,9 @@ sub process_login : Private {
$c->detach( '/page_error_403_access_denied', [] )
if FixMyStreet->config('SIGNUPS_DISABLED') && !$user->in_storage && !$data->{old_user_id};
+ # Superusers using 2FA can not log in by code
+ $c->detach( '/page_error_403_access_denied', [] ) if $user->has_2fa;
+
if ($data->{old_user_id}) {
# Were logged in as old_user_id, want to switch to $user
if ($user->in_storage) {
@@ -281,6 +284,11 @@ Used after signing in to take the person back to where they were.
sub redirect_on_signin : Private {
my ( $self, $c, $redirect, $params ) = @_;
+
+ if ($c->stash->{detach_to}) {
+ $c->detach($c->stash->{detach_to}, $c->stash->{detach_args});
+ }
+
unless ( $redirect ) {
$c->detach('redirect_to_categories') if $c->user->from_body && scalar @{ $c->user->categories };
$redirect = 'my';
diff --git a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
index 2d8ae081e..87aff2261 100644
--- a/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
+++ b/perllib/FixMyStreet/App/Controller/Auth/Profile.pm
@@ -180,14 +180,34 @@ sub generate_token : Path('/auth/generate_token') {
$c->stash->{template} = 'auth/generate_token.html';
$c->forward('/auth/get_csrf_token');
+ my $has_2fa = $c->user->get_extra_metadata('2fa_secret');
+
if ($c->req->method eq 'POST') {
$c->forward('/auth/check_csrf_token');
- my $token = mySociety::AuthToken::random_token();
- $c->user->set_extra_metadata('access_token', $token);
+
+ if ($c->get_param('generate_token')) {
+ my $token = mySociety::AuthToken::random_token();
+ $c->user->set_extra_metadata('access_token', $token);
+ $c->stash->{token_generated} = 1;
+ }
+
+ if ($c->get_param('toggle_2fa') && $c->user->is_superuser) {
+ if ($has_2fa) {
+ $c->user->unset_extra_metadata('2fa_secret');
+ $c->stash->{toggle_2fa_off} = 1;
+ } else {
+ my $auth = Auth::GoogleAuth->new;
+ $c->stash->{qr_code} = $auth->qr_code(undef, $c->user->email, 'FixMyStreet');
+ $c->stash->{secret32} = $auth->secret32;
+ $c->user->set_extra_metadata('2fa_secret', $auth->secret32);
+ $c->stash->{toggle_2fa_on} = 1;
+ }
+ }
+
$c->user->update();
- $c->stash->{token_generated} = 1;
}
+ $c->stash->{has_2fa} = $has_2fa ? 1 : 0;
$c->stash->{existing_token} = $c->user->get_extra_metadata('access_token');
}
diff --git a/perllib/FixMyStreet/App/Controller/Report/New.pm b/perllib/FixMyStreet/App/Controller/Report/New.pm
index 05d082e45..85652450e 100644
--- a/perllib/FixMyStreet/App/Controller/Report/New.pm
+++ b/perllib/FixMyStreet/App/Controller/Report/New.pm
@@ -109,8 +109,8 @@ sub report_new : Path : Args(0) {
return unless $c->forward('check_form_submitted');
$c->forward('/auth/check_csrf_token');
- $c->forward('process_user');
$c->forward('process_report');
+ $c->forward('process_user');
$c->forward('/photo/process_photo');
return unless $c->forward('check_for_errors');
$c->forward('save_user_and_report');
@@ -147,8 +147,8 @@ sub report_new_ajax : Path('mobile') : Args(0) {
$c->forward('setup_categories_and_bodies');
$c->forward('setup_report_extra_fields');
- $c->forward('process_user');
$c->forward('process_report');
+ $c->forward('process_user');
$c->forward('/photo/process_photo');
unless ($c->forward('check_for_errors')) {
@@ -418,7 +418,9 @@ sub report_import : Path('/import') {
sub oauth_callback : Private {
my ( $self, $c, $token_code ) = @_;
- $c->stash->{oauth_report} = $token_code;
+ my $auth_token = $c->forward(
+ '/tokens/load_auth_token', [ $token_code, 'problem/social' ]);
+ $c->stash->{oauth_report} = $auth_token->data;
$c->detach('report_new');
}
@@ -475,9 +477,7 @@ sub initialize_report : Private {
}
if (!$report && $c->stash->{oauth_report}) {
- my $auth_token = $c->forward( '/tokens/load_auth_token',
- [ $c->stash->{oauth_report}, 'problem/social' ] );
- $report = $c->model("DB::Problem")->new($auth_token->data);
+ $report = $c->model("DB::Problem")->new($c->stash->{oauth_report});
}
if ($report) {
@@ -772,7 +772,7 @@ sub process_user : Private {
if ( $c->user_exists ) { {
my $user = $c->user->obj;
- if ($c->stash->{contributing_as_another_user} = $user->contributing_as('another_user', $c, $c->stash->{bodies})) {
+ if ($c->stash->{contributing_as_another_user}) {
# Act as if not logged in (and it will be auto-confirmed later on)
$report->user(undef);
last;
@@ -781,8 +781,7 @@ sub process_user : Private {
$report->user( $user );
$c->forward('update_user', [ \%params ]);
- if ($c->stash->{contributing_as_body} = $user->contributing_as('body', $c, $c->stash->{bodies}) or
- $c->stash->{contributing_as_anonymous_user} = $user->contributing_as('anonymous_user', $c, $c->stash->{bodies})) {
+ if ($c->stash->{contributing_as_body} or $c->stash->{contributing_as_anonymous_user}) {
$report->name($user->from_body->name);
$user->name($user->from_body->name) unless $user->name;
$c->stash->{no_reporter_alert} = 1;
@@ -799,6 +798,11 @@ sub process_user : Private {
# The user is trying to sign in. We only care about username from the params.
if ( $c->get_param('submit_sign_in') || $c->get_param('password_sign_in') ) {
+ $c->stash->{tfa_data} = {
+ detach_to => '/report/new/report_new',
+ login_success => 1,
+ oauth_report => { $report->get_inflated_columns }
+ };
unless ( $c->forward( '/auth/sign_in', [ $params{username} ] ) ) {
$c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the &lsquo;No&rsquo; section of the form.');
return 1;
@@ -869,6 +873,13 @@ sub process_report : Private {
$report->longitude( $c->stash->{longitude} );
$report->send_questionnaire( $c->cobrand->send_questionnaires() );
+ if ( $c->user_exists ) {
+ my $user = $c->user->obj;
+ $c->stash->{contributing_as_another_user} = $user->contributing_as('another_user', $c, $c->stash->{bodies});
+ $c->stash->{contributing_as_body} = $user->contributing_as('body', $c, $c->stash->{bodies});
+ $c->stash->{contributing_as_anonymous_user} = $user->contributing_as('anonymous_user', $c, $c->stash->{bodies});
+ }
+
# set some simple bool values (note they get inverted)
if ($c->stash->{contributing_as_body}) {
$report->anonymous(0);
@@ -1393,6 +1404,9 @@ sub redirect_or_confirm_creation : Private {
return 1;
}
+ # Superusers using 2FA can not log in by code
+ $c->detach( '/page_error_403_access_denied', [] ) if $report->user->has_2fa;
+
# otherwise email or text a confirm token to them.
my $thing = 'email';
if ($report->user->email_verified) {
diff --git a/perllib/FixMyStreet/App/Controller/Report/Update.pm b/perllib/FixMyStreet/App/Controller/Report/Update.pm
index c684e46ad..24b54d7b5 100644
--- a/perllib/FixMyStreet/App/Controller/Report/Update.pm
+++ b/perllib/FixMyStreet/App/Controller/Report/Update.pm
@@ -131,6 +131,11 @@ sub process_user : Private {
# The user is trying to sign in. We only care about username from the params.
if ( $c->get_param('submit_sign_in') || $c->get_param('password_sign_in') ) {
+ $c->stash->{tfa_data} = {
+ detach_to => '/report/update/report_update',
+ login_success => 1,
+ oauth_update => { $update->get_inflated_columns }
+ };
unless ( $c->forward( '/auth/sign_in', [ $params{username} ] ) ) {
$c->stash->{field_errors}->{password} = _('There was a problem with your login information. If you cannot remember your password, or do not have one, please fill in the &lsquo;No&rsquo; section of the form.');
return 1;
@@ -164,7 +169,9 @@ what we have so far.
sub oauth_callback : Private {
my ( $self, $c, $token_code ) = @_;
- $c->stash->{oauth_update} = $token_code;
+ my $auth_token = $c->forward('/tokens/load_auth_token',
+ [ $token_code, 'update/social' ]);
+ $c->stash->{oauth_update} = $auth_token->data;
$c->detach('report_update');
}
@@ -179,9 +186,7 @@ sub initialize_update : Private {
my $update;
if ($c->stash->{oauth_update}) {
- my $auth_token = $c->forward( '/tokens/load_auth_token',
- [ $c->stash->{oauth_update}, 'update/social' ] );
- $update = $c->model("DB::Comment")->new($auth_token->data);
+ $update = $c->model("DB::Comment")->new($c->stash->{oauth_update});
}
if ($update) {
@@ -481,6 +486,9 @@ sub redirect_or_confirm_creation : Private {
return 1;
}
+ # Superusers using 2FA can not log in by code
+ $c->detach( '/page_error_403_access_denied', [] ) if $update->user->has_2fa;
+
my $data = $c->stash->{token_data};
$data->{id} = $update->id;
$data->{add_alert} = $c->get_param('add_alert') ? 1 : 0;
diff --git a/perllib/FixMyStreet/DB/Result/User.pm b/perllib/FixMyStreet/DB/Result/User.pm
index 97f2132b1..a6b927ad1 100644
--- a/perllib/FixMyStreet/DB/Result/User.pm
+++ b/perllib/FixMyStreet/DB/Result/User.pm
@@ -400,6 +400,11 @@ sub admin_user_body_permissions {
});
}
+sub has_2fa {
+ my $self = shift;
+ return $self->is_superuser && $self->get_extra_metadata('2fa_secret');
+}
+
sub contributing_as {
my ($self, $other, $c, $bodies) = @_;
$bodies = [ keys %$bodies ] if ref $bodies eq 'HASH';
diff --git a/t/app/controller/auth.t b/t/app/controller/auth.t
index bec8698d5..8cc7e4154 100644
--- a/t/app/controller/auth.t
+++ b/t/app/controller/auth.t
@@ -299,3 +299,26 @@ subtest 'check common password AJAX call' => sub {
$mech->post_ok('/auth/common_password', { password_register => 'squirblewirble' });
$mech->content_contains("true");
};
+
+subtest "Test two-factor authentication login" => sub {
+ use Auth::GoogleAuth;
+ my $auth = Auth::GoogleAuth->new;
+ my $code = $auth->code;
+ my $wrong_code = $auth->code(undef, time() - 120);
+
+ my $user = FixMyStreet::App->model('DB::User')->find( { email => $test_email } );
+ $user->is_superuser(1);
+ $user->password('password');
+ $user->set_extra_metadata('2fa_secret', $auth->secret32);
+ $user->update;
+
+ $mech->get_ok('/auth');
+ $mech->submit_form_ok(
+ { with_fields => { username => $test_email, password_sign_in => 'password' } },
+ "sign in using form" );
+ $mech->content_contains('Please generate a two-factor code');
+ $mech->submit_form_ok({ with_fields => { '2fa_code' => $wrong_code } }, "provide wrong 2FA code" );
+ $mech->content_contains('Try again');
+ $mech->submit_form_ok({ with_fields => { '2fa_code' => $code } }, "provide correct 2FA code" );
+ $mech->logged_in_ok;
+};
diff --git a/t/app/controller/auth_profile.t b/t/app/controller/auth_profile.t
index de2ad6534..4be1be12c 100644
--- a/t/app/controller/auth_profile.t
+++ b/t/app/controller/auth_profile.t
@@ -347,7 +347,7 @@ subtest "Test superuser can access generate token page" => sub {
},
});
- $mech->content_lacks('Generate token');
+ $mech->content_lacks('Security');
$mech->get('/auth/generate_token');
is $mech->res->code, 403, "access denied";
@@ -355,7 +355,7 @@ subtest "Test superuser can access generate token page" => sub {
ok $user->update({ is_superuser => 1 }), 'user is superuser';
$mech->get_ok('/my');
- $mech->content_contains('Generate token');
+ $mech->content_contains('Security');
$mech->get_ok('/auth/generate_token');
};
@@ -372,7 +372,7 @@ subtest "Test staff user can access generate token page" => sub {
},
});
- $mech->content_lacks('Generate token');
+ $mech->content_lacks('Security');
my $body = $mech->create_body_ok(2237, 'Oxfordshire');
@@ -382,7 +382,7 @@ subtest "Test staff user can access generate token page" => sub {
ok $user->update({ from_body => $body }), 'user is staff user';
$mech->get_ok('/my');
- $mech->content_contains('Generate token');
+ $mech->content_contains('Security');
$mech->get_ok('/auth/generate_token');
};
@@ -406,7 +406,7 @@ subtest "Test generate token page" => sub {
$mech->follow_link_ok({url => '/auth/generate_token'});
$mech->content_lacks('Token:');
$mech->submit_form_ok(
- { with_fields => { generate_token => 'Generate token' } },
+ { button => 'generate_token' },
"submit generate token form"
);
$mech->content_contains( 'Your token has been generated', "token generated" );
@@ -425,4 +425,28 @@ subtest "Test generate token page" => sub {
$mech->log_out_ok;
$mech->add_header('Authorization', "Bearer $token");
$mech->logged_in_ok;
-}
+};
+
+subtest "Test two-factor authentication admin" => sub {
+ my $user = FixMyStreet::App->model('DB::User')->find( { email => $test_email } );
+ ok $user->update({ is_superuser => 1 }), 'user set to superuser';
+
+ $mech->log_in_ok($test_email);
+ $mech->get_ok('/auth/generate_token');
+ ok !$user->get_extra_metadata('2fa_secret');
+
+ $mech->submit_form_ok({ button => 'toggle_2fa' }, "submit 2FA activation");
+ $mech->content_contains('has been activated', "2FA activated");
+
+ $user->discard_changes();
+ my $token = $user->get_extra_metadata('2fa_secret');
+ ok $token, '2FA secret set';
+
+ $mech->content_contains($token, 'secret displayed');
+
+ $mech->get_ok('/auth/generate_token');
+ $mech->content_lacks($token, 'secret no longer displayed');
+
+ $mech->submit_form_ok({ button => 'toggle_2fa' }, "submit 2FA deactivation");
+ $mech->content_contains('has been deactivated', "2FA deactivated");
+};
diff --git a/t/app/controller/report_new.t b/t/app/controller/report_new.t
index 95461fa8f..3c120b0b0 100644
--- a/t/app/controller/report_new.t
+++ b/t/app/controller/report_new.t
@@ -705,7 +705,11 @@ subtest "test password errors for a user who is signing in as they report" => su
], "check there were errors";
};
-subtest "test report creation for a user who is signing in as they report" => sub {
+foreach my $test (
+ { two_factor => 0, desc => '', },
+ { two_factor => 1, desc => ' with two-factor', },
+) {
+ subtest "test report creation for a user who is signing in as they report$test->{desc}" => sub {
$mech->log_out_ok;
$mech->cookie_jar({});
$mech->clear_emails_ok;
@@ -722,6 +726,15 @@ subtest "test report creation for a user who is signing in as they report" => su
password => 'secret2',
} ), "set user details";
+ my $auth;
+ if ($test->{two_factor}) {
+ use Auth::GoogleAuth;
+ $auth = Auth::GoogleAuth->new;
+ $user->is_superuser(1);
+ $user->set_extra_metadata('2fa_secret', $auth->generate_secret32);
+ $user->update;
+ }
+
# submit initial pc form
$mech->get_ok('/around');
FixMyStreet::override_config {
@@ -742,7 +755,7 @@ subtest "test report creation for a user who is signing in as they report" => su
title => 'Test Report',
detail => 'Test report details.',
photo1 => '',
- username => 'test-2@example.com',
+ username => $test_email,
password_sign_in => 'secret2',
category => 'Street lighting',
}
@@ -750,6 +763,15 @@ subtest "test report creation for a user who is signing in as they report" => su
"submit good details"
);
+ if ($test->{two_factor}) {
+ my $code = $auth->code;
+ my $wrong_code = $auth->code(undef, time() - 120);
+ $mech->content_contains('Please generate a two-factor code');
+ $mech->submit_form_ok({ with_fields => { '2fa_code' => $wrong_code } }, "provide wrong 2FA code" );
+ $mech->content_contains('Try again');
+ $mech->submit_form_ok({ with_fields => { '2fa_code' => $code } }, "provide correct 2FA code" );
+ }
+
# check that we got the message expected
$mech->content_contains( 'You have successfully signed in; please check and confirm your details are accurate:' );
@@ -768,7 +790,10 @@ subtest "test report creation for a user who is signing in as they report" => su
my $report = $user->problems->first;
ok $report, "Found the report";
- $mech->content_contains('Thank you for reporting this issue');
+ if (!$test->{two_factor}) {
+ # The superuser account will be immediately redirected
+ $mech->content_contains('Thank you for reporting this issue');
+ }
# Check the report has been assigned appropriately
is $report->bodies_str, $body_ids{2651};
@@ -793,7 +818,8 @@ subtest "test report creation for a user who is signing in as they report" => su
# cleanup
$mech->delete_user($user)
-};
+ };
+}
#### test report creation for user with account and logged in
my ($saved_lat, $saved_lon);
diff --git a/templates/web/base/auth/2faform.html b/templates/web/base/auth/2faform.html
new file mode 100644
index 000000000..bd8d60cdb
--- /dev/null
+++ b/templates/web/base/auth/2faform.html
@@ -0,0 +1,26 @@
+[% INCLUDE 'header.html', bodyclass = 'fullwidthpage', title = loc('Confirm account') %]
+
+ <div class="confirmation-header confirmation-header--phone">
+ [% IF incorrect_code %]
+ <h1>[% loc('Sorry, that wasn&rsquo;t the correct code') %]</h1>
+ <p>[% loc('Try again') %]:</p>
+ [% ELSE %]
+ <h1>[% loc("Nearly done! Now check your phone&hellip;") %]</h1>
+ <p>[% loc("Please generate a two-factor code and enter it below:") %]</p>
+ [% END %]
+ <form action="/auth" method="post">
+ <input type="hidden" name="username" value="[% c.get_param('username') | html %]">
+ <input type="hidden" name="password_sign_in" value="[% c.get_param('password_sign_in') | html %]">
+ <input type="hidden" name="r" value="[% c.get_param('r') | html %]">
+ <input type="hidden" name="remember_me" value="[% c.get_param('remember_me') | html %]">
+ <input type="hidden" name="token" value="[% token | html %]">
+
+ <label for="2fa_code">[% loc('Code') %]</label>
+ <div class="form-txt-submit-box">
+ <input class="form-control" type="number" id="2fa_code" name="2fa_code" value="" required>
+ <input type="submit" value="[% loc('Submit') %]" class="btn-primary">
+ </div>
+ </form>
+ </div>
+
+[% INCLUDE 'footer.html' %]
diff --git a/templates/web/base/auth/generate_token.html b/templates/web/base/auth/generate_token.html
index 157335047..f7061be45 100644
--- a/templates/web/base/auth/generate_token.html
+++ b/templates/web/base/auth/generate_token.html
@@ -1,5 +1,5 @@
[%
-INCLUDE 'header.html', title = loc('Generate token'), bodyclass = 'fullwidthpage'
+INCLUDE 'header.html', title = loc('Security'), bodyclass = 'fullwidthpage'
%]
[% IF token_generated %]
@@ -15,9 +15,28 @@ INCLUDE 'header.html', title = loc('Generate token'), bodyclass = 'fullwidthpage
<p><a href="/my">[% loc('Your account') %]</a></p>
</div>
+[% ELSIF toggle_2fa_on %]
+
+ <div class="confirmation-header">
+ <h1>[% loc('Two-factor authentication has been activated') %]</h1>
+
+ <p align="center"><img src="[% qr_code %]"></p>
+ <p align="center">[% secret32.replace('(....)', '$1 ') %]</p>
+
+ <p><a href="/my">[% loc('Your account') %]</a></p>
+ </div>
+
+[% ELSIF toggle_2fa_off %]
+
+ <div class="confirmation-header">
+ <h1>[% loc('Two-factor authentication has been deactivated') %]</h1>
+
+ <p><a href="/my">[% loc('Your account') %]</a></p>
+ </div>
+
[% ELSE %]
-<h1>[% loc('Generate token') %]</h1>
+<h1>[% loc('Security') %]</h1>
<form action="[% c.uri_for_action('/auth/profile/generate_token') %]" method="post" name="generate_token">
<input type="hidden" name="token" value="[% csrf_token %]">
@@ -31,6 +50,9 @@ INCLUDE 'header.html', title = loc('Generate token'), bodyclass = 'fullwidthpage
<p>
<input name="generate_token" type="submit" class="btn" value="[% existing_token ? loc('Replace token') : loc('Generate token') %]">
+ [% IF c.user.is_superuser %]
+ <input name="toggle_2fa" type="submit" class="btn" value="[% has_2fa ? loc('Deactivate two-factor authentication') : loc('Activate two-factor authentication') %]">
+ [% END %]
</p>
</form>
diff --git a/templates/web/base/my/my.html b/templates/web/base/my/my.html
index e10dd96c8..459fa5266 100644
--- a/templates/web/base/my/my.html
+++ b/templates/web/base/my/my.html
@@ -62,7 +62,7 @@ li .my-account-buttons a {
<p class="my-account-buttons">
<a href="/auth/change_password">[% loc('Change password') %]</a>
[% IF c.user AND (c.user.from_body OR c.user.is_superuser) %]
- <a href="/auth/generate_token">[% loc('Generate token') %]</a>
+ <a href="/auth/generate_token">[% loc('Security') %]</a>
[% END %]
<a href="/auth/sign_out">[% loc('Sign out') %]</a>
</p>