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 <m.de.rooy@rijksmuseum.nl>

Sponsored-by: Rijksmuseum, Netherlands

Signed-off-by: Kyle M Hall <kyle@bywatersolutions.com>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
This commit is contained in:
Jonathan Druart 2022-05-23 22:51:26 +02:00 committed by Tomas Cohen Arazi
parent 244c649250
commit 6e099d0bbd
Signed by: tomascohen
GPG key ID: 0A272EA1B2F3C15F
10 changed files with 346 additions and 3 deletions

View file

@ -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;

View file

@ -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 );

View file

@ -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 <http://www.gnu.org/licenses>.
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;

View file

@ -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"

View file

@ -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":

View file

@ -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')
});
},
};

View file

@ -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: ""

View file

@ -155,12 +155,15 @@
<div id="login_error">Invalid two-factor code</div>
[% END %]
<div id="email_error" class="dialog alert" style="display: none;"></div>
<div id="email_success" class="dialog message" style="display: none;"></div>
<p>
<label for="otp_token">Two-factor authentication code:</label>
<input type="text" name="otp_token" id="otp_token" class="input focus" value="" size="20" tabindex="1" />
</p>
<p>
<input id="submit-button" type="submit" value="Verify code" />
<a class="send_otp" id="send_otp" href="#">Send the code by email</a>
<a class="cancel" id="logout" href="/cgi-bin/koha/mainpage.pl?logout.x=1">Cancel</a>
</p>
@ -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);
}
});
});
});
</script>
[% END %]

View file

@ -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 <http://www.gnu.org/licenses>.
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;

View file

@ -16,7 +16,7 @@
# along with Koha; if not, see <http://www.gnu.org/licenses>.
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;