diff options
author | Matthew Somerville <matthew-github@dracos.co.uk> | 2018-02-07 13:09:45 +0000 |
---|---|---|
committer | Matthew Somerville <matthew-github@dracos.co.uk> | 2018-02-07 13:09:45 +0000 |
commit | 7361782de3d072f8d09442e33aa9c42a7c181c4c (patch) | |
tree | 5b8f49a13eb6f3aeb152262cbe66d55a48c4924d | |
parent | 6879af98d0246b6973affff08a4e078206bb5dfc (diff) | |
parent | 3e721ddf5d9809c9f44d7dedcf2083a544e6e148 (diff) |
Merge branch '2fa-superuser'
-rw-r--r-- | .travis.yml | 2 | ||||
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | cpanfile | 2 | ||||
-rw-r--r-- | cpanfile.snapshot | 53 | ||||
-rw-r--r-- | perllib/Catalyst/Authentication/Credential/2FA.pm | 141 | ||||
-rw-r--r-- | perllib/FixMyStreet/App.pm | 17 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Alert.pm | 9 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Auth.pm | 8 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Auth/Profile.pm | 26 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Report/New.pm | 32 | ||||
-rw-r--r-- | perllib/FixMyStreet/App/Controller/Report/Update.pm | 16 | ||||
-rw-r--r-- | perllib/FixMyStreet/DB/Result/User.pm | 5 | ||||
-rw-r--r-- | t/app/controller/auth.t | 23 | ||||
-rw-r--r-- | t/app/controller/auth_profile.t | 36 | ||||
-rw-r--r-- | t/app/controller/report_new.t | 34 | ||||
-rw-r--r-- | templates/web/base/auth/2faform.html | 26 | ||||
-rw-r--r-- | templates/web/base/auth/generate_token.html | 26 | ||||
-rw-r--r-- | templates/web/base/my/my.html | 2 |
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. @@ -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 ‘No’ 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 ‘No’ 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’t the correct code') %]</h1> + <p>[% loc('Try again') %]:</p> + [% ELSE %] + <h1>[% loc("Nearly done! Now check your phone…") %]</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> |