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:
parent
244c649250
commit
6e099d0bbd
10 changed files with 346 additions and 3 deletions
|
@ -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;
|
||||
|
|
|
@ -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 );
|
||||
|
|
79
Koha/REST/V1/TwoFactorAuth.pm
Normal file
79
Koha/REST/V1/TwoFactorAuth.pm
Normal 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;
|
38
api/v1/swagger/paths/auth.yaml
Normal file
38
api/v1/swagger/paths/auth.yaml
Normal 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"
|
|
@ -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":
|
||||
|
|
15
installer/data/mysql/atomicupdate/bug_28787.pl
Executable file
15
installer/data/mysql/atomicupdate/bug_28787.pl
Executable 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')
|
||||
});
|
||||
|
||||
},
|
||||
};
|
|
@ -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: ""
|
||||
|
|
|
@ -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 %]
|
||||
|
|
106
t/db_dependent/api/v1/two_factor_auth.t
Executable file
106
t/db_dependent/api/v1/two_factor_auth.t
Executable 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;
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Reference in a new issue