From f681c3e6c0443a17d12bb0748496bdab67100f0e 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 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 Sponsored-by: Rijksmuseum, Netherlands --- 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 d2f0b469ca0..134658ff968 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; @@ -1609,6 +1610,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; my $schema = Koha::Database->new->schema; $schema->txn_begin; diff --git a/Koha/REST/V1/Auth.pm b/Koha/REST/V1/Auth.pm index 182db518f6f..4fac9237c61 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 00000000000..35479228e58 --- /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 00000000000..3e0ac45083f --- /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 7e3a8d8afca..a14d2f2c5cb 100644 --- a/api/v1/swagger/swagger.yaml +++ b/api/v1/swagger/swagger.yaml @@ -101,6 +101,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 00000000000..bce06718366 --- /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 34ffab80dfb..16d84b95338 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 47e135312cb..e188d5f268b 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 00000000000..c22fe819f8a --- /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 2b8f8882193..d00cdfda53e 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.25.1