@@ -, +, @@ - Setup the two-factor authentication (you need the config entry and the - 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 --- 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 --- a/C4/Letters.pm +++ a/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; @@ -1602,6 +1603,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(); --- a/Koha/REST/V1/Auth.pm +++ a/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 ); --- a/Koha/REST/V1/TwoFactorAuth.pm +++ a/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; --- a/api/v1/swagger/paths/auth.yaml +++ a/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" --- a/api/v1/swagger/swagger.yaml +++ a/api/v1/swagger/swagger.yaml @@ -93,6 +93,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": --- a/installer/data/mysql/atomicupdate/bug_28787.pl +++ a/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') + }); + + }, +}; --- a/installer/data/mysql/en/mandatory/sample_notices.yml +++ a/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: "" --- a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt +++ a/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 %] --- a/t/db_dependent/api/v1/two_factor_auth.t +++ a/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; --- a/t/db_dependent/selenium/authentication_2fa.t +++ a/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; --