From ab027e86346f7659daeff0c3cbdc95d894851d12 Mon Sep 17 00:00:00 2001 From: Jonathan Druart Date: Mon, 23 May 2022 22:51:26 +0200 Subject: [PATCH] Bug 28787: Send a notice with the TOTP token Content-Type: text/plain; charset=utf-8 Bug 28786 let librarians enable a Two-factor authentication but force them to use an application to generate the TOTP token. This new enhancement add the ability to send an email containing the token to the patron once it's authenticaed The new notice template has the code '2FA_OTP_TOKEN' Test plan: - Setup the two-factor authentication (you need the config entry and the syspref ON) - Enable it for your logged in patron - Logout - Login and notice the new link "Send the code by email" - Click on it and confirm that you received an email with the code - Use the code to be fully logged in QA question: Is 400 the correct error code to tell the email has not been sent? Signed-off-by: Marcel de Rooy --- C4/Letters.pm | 6 + Koha/REST/V1/Auth.pm | 13 ++- Koha/REST/V1/TwoFactorAuth.pm | 79 +++++++++++++ api/v1/swagger/paths/auth.yaml | 38 +++++++ api/v1/swagger/swagger.yaml | 2 + .../data/mysql/atomicupdate/bug_28787.pl | 15 +++ .../mysql/en/mandatory/sample_notices.yml | 14 +++ .../intranet-tmpl/prog/en/modules/auth.tt | 23 ++++ t/db_dependent/api/v1/two_factor_auth.t | 106 ++++++++++++++++++ t/db_dependent/selenium/authentication_2fa.t | 53 ++++++++- 10 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 Koha/REST/V1/TwoFactorAuth.pm create mode 100644 api/v1/swagger/paths/auth.yaml create mode 100755 installer/data/mysql/atomicupdate/bug_28787.pl create mode 100755 t/db_dependent/api/v1/two_factor_auth.t diff --git a/C4/Letters.pm b/C4/Letters.pm index 8ab0bb727e..4eef7cd2c0 100644 --- a/C4/Letters.pm +++ b/C4/Letters.pm @@ -36,6 +36,7 @@ use Koha::Email; use Koha::Notice::Messages; use Koha::Notice::Templates; use Koha::DateUtils qw( dt_from_string output_pref ); +use Koha::Auth::TwoFactorAuth; use Koha::Patrons; use Koha::SMTP::Servers; use Koha::Subscriptions; @@ -1603,6 +1604,11 @@ sub _process_tt { $content = add_tt_filters( $content ); $content = qq|[% USE KohaDates %][% USE Remove_MARC_punctuation %]$content|; + if ( $content =~ m|\[% otp_token %\]| ) { + my $patron = Koha::Patrons->find(C4::Context->userenv->{number}); + $tt_params->{otp_token} = Koha::Auth::TwoFactorAuth->new({patron => $patron})->code; + } + my $output; $template->process( \$content, $tt_params, \$output ) || croak "ERROR PROCESSING TEMPLATE: " . $template->error(); diff --git a/Koha/REST/V1/Auth.pm b/Koha/REST/V1/Auth.pm index 182db518f6..4fac9237c6 100644 --- a/Koha/REST/V1/Auth.pm +++ b/Koha/REST/V1/Auth.pm @@ -82,6 +82,7 @@ sub under { } if ( $c->req->url->to_abs->path eq '/api/v1/oauth/token' ) { + #|| $c->req->url->to_abs->path eq '/api/v1/auth/send_otp_token' ) { # Requesting a token shouldn't go through the API authenticaction chain $status = 1; } @@ -161,6 +162,7 @@ sub authenticate_api_request { $c->stash_overrides(); my $cookie_auth = 0; + my $pending_auth; my $authorization = $spec->{'x-koha-authorization'}; @@ -229,6 +231,13 @@ sub authenticate_api_request { elsif ($status eq "anon") { $cookie_auth = 1; } + elsif ($status eq "additional-auth-needed") { + if ( $c->req->url->to_abs->path eq '/api/v1/auth/send_otp_token' ) { + $user = Koha::Patrons->find( $session->param('number') ); + $cookie_auth = 1; + $pending_auth = 1; + } + } elsif ($status eq "maintenance") { Koha::Exceptions::UnderMaintenance->throw( error => 'System is under maintenance.' @@ -261,7 +270,9 @@ sub authenticate_api_request { if ( !$authorization and ( $params->{is_public} and ( C4::Context->preference('RESTPublicAnonymousRequests') or - $user) or $params->{is_plugin} ) ) { + $user) or $params->{is_plugin} ) + or $pending_auth + ) { # We do not need any authorization # Check the parameters validate_query_parameters( $c, $spec ); diff --git a/Koha/REST/V1/TwoFactorAuth.pm b/Koha/REST/V1/TwoFactorAuth.pm new file mode 100644 index 0000000000..35479228e5 --- /dev/null +++ b/Koha/REST/V1/TwoFactorAuth.pm @@ -0,0 +1,79 @@ +package Koha::REST::V1::TwoFactorAuth; + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Mojo::Base 'Mojolicious::Controller'; +use Try::Tiny; + +use C4::Letters qw( GetPreparedLetter ); + +=head1 NAME + +Koha::REST::V1::TwoFactorAuth + +=head1 API + +=head2 Methods + +=head3 send_otp_token + +Will send an email with the OTP token needed to complete the second authentication step. + +=cut + + +sub send_otp_token { + + my $c = shift->openapi->valid_input or return; + + my $patron = Koha::Patrons->find( $c->stash('koha.user')->borrowernumber ); + + return try { + + my $letter = C4::Letters::GetPreparedLetter( + module => 'members', + letter_code => '2FA_OTP_TOKEN', + branchcode => $patron->branchcode, + tables => { + borrowers => $patron->unblessed, + } + ); + my $message_id = C4::Letters::EnqueueLetter( + { + letter => $letter, + borrowernumber => $patron->borrowernumber, + message_transport_type => 'email' + } + ); + C4::Letters::SendQueuedMessages({message_id => $message_id}); + + my $message = C4::Letters::GetMessage($message_id); + + if ( $message->{status} eq 'sent' ) { + return $c->render(status => 200, openapi => {}); + } elsif ( $message->{status} eq 'failed' ) { + return $c->render(status => 400, openapi => { error => 'email_not_sent'}); + } + } + catch { + $c->unhandled_exception($_); + }; + +} + +1; diff --git a/api/v1/swagger/paths/auth.yaml b/api/v1/swagger/paths/auth.yaml new file mode 100644 index 0000000000..3e0ac45083 --- /dev/null +++ b/api/v1/swagger/paths/auth.yaml @@ -0,0 +1,38 @@ +--- +/auth/send_otp_token: + post: + x-mojo-to: TwoFactorAuth#send_otp_token + operationId: send_otp_token + tags: + - 2fa + summary: Send OTP token for second step authentication + produces: + - application/json + responses: + "200": + description: OK + schema: + type: object + properties: + access_token: + type: string + token_type: + type: string + expires_in: + type: integer + additionalProperties: false + "400": + description: Bad Request + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml index f020ee427a..1b16b96003 100644 --- a/api/v1/swagger/swagger.yaml +++ b/api/v1/swagger/swagger.yaml @@ -97,6 +97,8 @@ paths: $ref: "./paths/advancededitormacros.yaml#/~1advanced_editor~1macros~1{advancededitormacro_id}" "/article_requests/{article_request_id}": $ref: "./paths/article_requests.yaml#/~1article_requests~1{article_request_id}" + /auth/send_otp_token: + $ref: paths/auth.yaml#/~1auth~1send_otp_token "/biblios/{biblio_id}": $ref: "./paths/biblios.yaml#/~1biblios~1{biblio_id}" "/biblios/{biblio_id}/checkouts": diff --git a/installer/data/mysql/atomicupdate/bug_28787.pl b/installer/data/mysql/atomicupdate/bug_28787.pl new file mode 100755 index 0000000000..bce0671836 --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug_28787.pl @@ -0,0 +1,15 @@ +use Modern::Perl; + +return { + bug_number => "28787", + description => "Add new letter 2FA_OTP_TOKEN", + up => sub { + my ($args) = @_; + my ($dbh, $out) = @$args{qw(dbh out)}; + $dbh->do(q{ + INSERT IGNORE INTO `letter` (`module`, `code`, `branchcode`, `name`, `is_html`, `title`, `content`, `message_transport_type`) VALUES + ('members', '2FA_OTP_TOKEN', '', 'two-authentication step token', 0, 'Two-authentication step token', 'Dear [% borrower.firstname %] [% borrower.surname %] ([% borrower.cardnumber %])\r\n\r\nYour authentication token is [% otp_token %]. \r\nIt is valid one minute.', 'email') + }); + + }, +}; diff --git a/installer/data/mysql/en/mandatory/sample_notices.yml b/installer/data/mysql/en/mandatory/sample_notices.yml index 34ffab80df..16d84b9533 100644 --- a/installer/data/mysql/en/mandatory/sample_notices.yml +++ b/installer/data/mysql/en/mandatory/sample_notices.yml @@ -1110,6 +1110,20 @@ tables: - "" - "If you have any problems or questions regarding your account, please contact the library." + - module: members + code: 2FA_OTP_TOKEN + branchcode: "" + name: "two-authentication step token" + is_html: 1 + title: "Two-authentication token" + message_transport_type: email + lang: default + content: + - "Dear [% borrower.firstname %] [% borrower.surname %] ([% borrower.cardnumber %])" + - "" + - "Your authentication token is [% otp_token %]." + - "It is valid one minute." + - module: orderacquisition code: ACQORDER branchcode: "" diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt index 47e135312c..e188d5f268 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt @@ -155,12 +155,15 @@
Invalid two-factor code
[% END %] + +

+ Send the code by email Cancel

@@ -189,6 +192,26 @@ } // Clear last borrowers, rememberd sql reports, carts, etc. logOut(); + + $("#send_otp").on("click", function(e){ + e.preventDefault(); + $("#email_success").hide(); + $("#email_error").hide(); + $.ajax({ + url: '/api/v1/auth/send_otp_token', + type: 'POST', + success: function(data){ + let message = _("The code has been sent by email, please check your inbox.") + $("#email_success").show().html(message); + }, + error: function(data){ + let error = data.responseJSON && data.responseJSON.error == "email_not_sent" + ? _("Email not sent, maybe you don't have an email address defined?") + : _("Email not sent"); + $("#email_error").show().html(error); + } + }); + }); }); [% END %] diff --git a/t/db_dependent/api/v1/two_factor_auth.t b/t/db_dependent/api/v1/two_factor_auth.t new file mode 100755 index 0000000000..c22fe819f8 --- /dev/null +++ b/t/db_dependent/api/v1/two_factor_auth.t @@ -0,0 +1,106 @@ +#!/usr/bin/env perl + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Test::More tests => 1; +use Test::Mojo; +use Test::MockModule; + +use t::lib::TestBuilder; +use t::lib::Mocks; + +use Koha::Database; + +my $schema = Koha::Database->new->schema; +my $builder = t::lib::TestBuilder->new; + +# FIXME: sessionStorage defaults to mysql, but it seems to break transaction handling +# this affects the other REST api tests +t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' ); + +my $remote_address = '127.0.0.1'; +my $t = Test::Mojo->new('Koha::REST::V1'); + +subtest 'send_otp_token' => sub { + + plan tests => 7; + + $schema->storage->txn_begin; + + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { + flags => 16 + } + } + ); + + my $session = C4::Auth::get_session(''); + $session->param( 'number', $patron->borrowernumber ); + $session->param( 'id', $patron->userid ); + $session->param( 'ip', '127.0.0.1' ); + $session->param( 'lasttime', time() ); + $session->flush; + + my $tx = $t->ua->build_tx( POST => "/api/v1/auth/send_otp_token" ); + $tx->req->cookies( { name => 'CGISESSID', value => $session->id } ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + + # Patron is not authenticated yet + $t->request_ok($tx)->status_is(403); + + $session->param('waiting-for-2FA', 1); + $session->flush; + + $session = C4::Auth::get_session($session->id); + + my $auth = Test::MockModule->new("C4::Auth"); + $auth->mock('check_cookie_auth', sub { return 'additional-auth-needed'}); + + $patron->library->set( + { + branchemail => 'from@example.org', + branchreturnpath => undef, + branchreplyto => undef, + } + )->store; + $patron->auth_method('two-factor'); + $patron->encode_secret("nv4v65dpobpxgzldojsxiii"); + $patron->email(undef); + $patron->store; + + $tx = $t->ua->build_tx( POST => "/api/v1/auth/send_otp_token" ); + $tx->req->cookies( { name => 'CGISESSID', value => $session->id } ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + + # Invalid email + $t->request_ok($tx)->status_is(400)->json_is({ error => 'email_not_sent' }); + + $patron->email('to@example.org')->store; + $tx = $t->ua->build_tx( POST => "/api/v1/auth/send_otp_token" ); + $tx->req->cookies( { name => 'CGISESSID', value => $session->id } ); + $tx->req->env( { REMOTE_ADDR => $remote_address } ); + + # Everything is ok, the email will be sent + $t->request_ok($tx)->status_is(200); + + $schema->storage->txn_rollback; +}; + +1; diff --git a/t/db_dependent/selenium/authentication_2fa.t b/t/db_dependent/selenium/authentication_2fa.t index 2b8f888219..d00cdfda53 100755 --- a/t/db_dependent/selenium/authentication_2fa.t +++ b/t/db_dependent/selenium/authentication_2fa.t @@ -16,7 +16,7 @@ # along with Koha; if not, see . use Modern::Perl; -use Test::More tests => 3; +use Test::More tests => 4; use C4::Context; use Koha::AuthUtils; @@ -94,7 +94,6 @@ SKIP: { $driver->get($mainpage . q|?logout.x=1|); $driver->get($s->base_url . q|circ/circulation.pl?borrowernumber=|.$patron->borrowernumber); like( $driver->get_title, qr(Log in to Koha), 'Must be on the first auth screen' ); - $driver->capture_screenshot('selenium_failure_2.png'); fill_login_form($s); like( $driver->get_title, qr(Two-factor authentication), 'Must be on the second auth screen' ); is( login_error($s), undef ); @@ -150,6 +149,56 @@ SKIP: { } }; + subtest "Send OTP code" => sub { + plan tests => 4; + + # Make sure the send won't fail because of invalid email addresses + $patron->library->set( + { + branchemail => 'from@example.org', + branchreturnpath => undef, + branchreplyto => undef, + } + )->store; + $patron->auth_method('two-factor'); + $patron->email(undef); + $patron->store; + + my $mainpage = $s->base_url . q|mainpage.pl|; + $driver->get( $mainpage . q|?logout.x=1| ); + like( + $driver->get_title, + qr(Log in to Koha), + 'Must be on the first auth screen' + ); + fill_login_form($s); + like( + $driver->get_title, + qr(Two-factor authentication), + 'Must be on the second auth screen' + ); + $driver->find_element('//a[@id="send_otp"]')->click; + $s->wait_for_ajax; + my $error = $driver->find_element('//div[@id="email_error"]')->get_text; + like( + $error, + qr{Email not sent}, + 'Email not sent will display an error' + ); + + $patron->email('test@example.org'); + $patron->store; + $driver->find_element('//a[@id="send_otp"]')->click; + $s->wait_for_ajax; + my $message = + $driver->find_element('//div[@id="email_success"]')->get_text; + is( + $message, + "The code has been sent by email, please check your inbox.", + 'The email must have been sent correctly' + ); + }; + subtest "Disable" => sub { plan tests => 4; -- 2.20.1