From ad0a38e5d49c2495773dc876b920fb89bb66ec2d Mon Sep 17 00:00:00 2001 From: Jonathan Druart Date: Mon, 25 Jul 2022 16:53:02 +0200 Subject: [PATCH] Bug 30588: Add the option to require 2FA setup on first staff login Bug 28786 added the ability to turn on a two-factor authentication, using a One Time Password (OTP). Once enabled on the system, librarian had the choice to enable or disable it for themselves. For security reason an administrator could decide to force the librarians to use this second authentication step. This patch adds a third option to the existing syspref, 'Enforced', for that purpose. QA notes: the code we had in the members/two_factor_auth.pl controller has been moved to REST API controller methods (with their tests and swagger specs), for reusability reason. Code from template has been moved to an include file for the same reason. Test plan: A. Regression tests As we modified the code we need first to confirm the existing features are still working as expected. 1. Turn off TwoFactorAuthentication (disabled) and confirm that you are not able to enable and access the second authentication step 2. Turn it on (enabled) and confirm that you are able to enable it in your account 3. Logout and confirm then that you are able to login into Koha B. The new option 1. Set the pref to "enforced" 2. You are not logged out, logged in users stay logged in 3. Pick a user that does not have 2FA setup, login 4. Notice the new screen (UI is a bit ugly, suggestions welcomed) 5. Try to access Koha without enabling 2FA, you shouldn't be able to access any pages 6. Setup 2FA and confirm that you are redirected to the login screen 7. Login, send the correct pin code => You are fully logged in! Note that at 6 we could redirect to the mainpage, without the need to login again, but I think it's preferable to reduce the change to C4::Auth. If it's considered mandatory by QA I could have a look on another bug report. Sponsored-by: Rijksmuseum, Netherlands --- C4/Auth.pm | 17 ++- Koha/Auth/TwoFactorAuth.pm | 2 + Koha/REST/V1/Auth.pm | 22 ++++ Koha/REST/V1/TwoFactorAuth.pm | 103 +++++++++++++++++ api/v1/swagger/paths/auth.yaml | 89 ++++++++++++++ api/v1/swagger/swagger.yaml | 4 + .../intranet-tmpl/prog/en/modules/auth.tt | 51 +++++++- .../en/modules/members/two_factor_auth.tt | 109 ++++++++++-------- members/two_factor_auth.pl | 65 +---------- 9 files changed, 349 insertions(+), 113 deletions(-) diff --git a/C4/Auth.pm b/C4/Auth.pm index a292c36a92f..fed9c5fd5db 100644 --- a/C4/Auth.pm +++ b/C4/Auth.pm @@ -1270,9 +1270,11 @@ sub checkauth { # Auth is completed unless an additional auth is needed if ( $require_2FA ) { my $patron = Koha::Patrons->find({userid => $userid}); - if ( C4::Context->preference('TwoFactorAuthentication') eq "enforced" - || $patron->auth_method eq 'two-factor' ) - { + if ( C4::Context->preference('TwoFactorAuthentication') eq "enforced" && $patron->auth_method eq 'password' ) { + $auth_state = 'setup-additional-auth-needed'; + $session->param('waiting-for-2FA-setup', 1); + %info = ();# We remove the warnings/errors we may have set incorrectly before + } elsif ( $patron->auth_method eq 'two-factor' ) { # Ask for the OTP token $auth_state = 'additional-auth-needed'; $session->param('waiting-for-2FA', 1); @@ -1385,6 +1387,12 @@ sub checkauth { ); } + if ( $auth_state eq 'setup-additional-auth-needed' ) { + $template->param( + TwoFA_setup => 1, + ); + } + if ( $type eq 'opac' ) { require Koha::Virtualshelves; my $some_public_shelves = Koha::Virtualshelves->get_some_shelves( @@ -1778,6 +1786,9 @@ sub check_cookie_auth { return ( "additional-auth-needed", $session ) if $session->param('waiting-for-2FA'); + return ( "setup-additional-auth-needed", $session ) + if $session->param('waiting-for-2FA-setup'); + return ( "ok", $session ); } else { $session->delete(); diff --git a/Koha/Auth/TwoFactorAuth.pm b/Koha/Auth/TwoFactorAuth.pm index 48b2a895ea3..b9c785fa0e7 100644 --- a/Koha/Auth/TwoFactorAuth.pm +++ b/Koha/Auth/TwoFactorAuth.pm @@ -58,6 +58,8 @@ sub new { my $secret32 = $params->{secret32}; my $secret = $params->{secret}; + # FIXME Raise an exception if the syspref is disabled + Koha::Exceptions::MissingParameter->throw("Mandatory patron parameter missing") unless $patron && ref($patron) eq 'Koha::Patron'; diff --git a/Koha/REST/V1/Auth.pm b/Koha/REST/V1/Auth.pm index 621011cff03..8e8a6f675cb 100644 --- a/Koha/REST/V1/Auth.pm +++ b/Koha/REST/V1/Auth.pm @@ -236,6 +236,28 @@ sub authenticate_api_request { Koha::Exceptions::Authentication::Required->throw( error => 'Authentication failure.' ); } + } + elsif ( $c->req->url->to_abs->path eq '/api/v1/auth/two-factor/registration' + || $c->req->url->to_abs->path eq '/api/v1/auth/two-factor/registration/verification' ) { + + if ( $status eq 'setup-additional-auth-needed' ) { + $user = Koha::Patrons->find( $session->param('number') ); + $cookie_auth = 1; + } + elsif ( $status eq 'ok' ) { + $user = Koha::Patrons->find( $session->param('number') ); + if ( $user->auth_method ne 'password' ) { + # If the user already enabled 2FA they don't need to register again + Koha::Exceptions::Authentication->throw( + error => 'Cannot request this route.' ); + } + $cookie_auth = 1; + } + else { + Koha::Exceptions::Authentication::Required->throw( + error => 'Authentication failure.' ); + } + } else { if ($status eq "ok") { $user = Koha::Patrons->find( $session->param('number') ); diff --git a/Koha/REST/V1/TwoFactorAuth.pm b/Koha/REST/V1/TwoFactorAuth.pm index e62136ac26e..f9f199f74be 100644 --- a/Koha/REST/V1/TwoFactorAuth.pm +++ b/Koha/REST/V1/TwoFactorAuth.pm @@ -78,4 +78,107 @@ sub send_otp_token { } +=head3 registration + +Ask for a registration secret. It will return a QR code image and a secret32. + +The secret must be sent back to the server with the pin code for the verification step. + +=cut + +sub registration { + + my $c = shift->openapi->valid_input or return; + + my $patron = Koha::Patrons->find( $c->stash('koha.user')->borrowernumber ); + + return try { + my $secret = Koha::AuthUtils::generate_salt( 'weak', 16 ); + my $auth = Koha::Auth::TwoFactorAuth->new( + { patron => $patron, secret => $secret } ); + + my $response = { + issuer => $auth->issuer, + key_id => $auth->key_id, + qr_code => $auth->qr_code, + secret32 => $auth->secret32, + + # IMPORTANT: get secret32 after qr_code call ! + }; + $auth->clear; + + return $c->render(status => 201, openapi => $response); + } + catch { + $c->unhandled_exception($_); + }; + +} + +=head3 verification + +Verify the registration, get the pin code and the secret retrieved from the registration. + +The 2FA_ENABLE notice will be generated if the pin code is correct, and the patron will have their two-factor authentication setup completed. + +=cut + +sub verification { + + my $c = shift->openapi->valid_input or return; + + my $patron = Koha::Patrons->find( $c->stash('koha.user')->borrowernumber ); + + return try { + + my $pin_code = $c->validation->param('pin_code'); + my $secret32 = $c->validation->param('secret32'); + + my $auth = Koha::Auth::TwoFactorAuth->new( + { patron => $patron, secret32 => $secret32 } ); + + my $verified = $auth->verify( + $pin_code, + 1, # range + $secret32, + undef, # timestamp (defaults to now) + 30, # interval (default 30) + ); + + unless ($verified) { + return $c->render( + status => 400, + openapi => { error => "Invalid pin" } + ); + } + + # FIXME Generate a (new?) secret + $patron->encode_secret($secret32); + $patron->auth_method('two-factor')->store; + if ( $patron->notice_email_address ) { + $patron->queue_notice( + { + letter_params => { + module => 'members', + letter_code => '2FA_ENABLE', + branchcode => $patron->branchcode, + lang => $patron->lang, + tables => { + branches => $patron->branchcode, + borrowers => $patron->id + }, + }, + message_transports => ['email'], + } + ); + } + + return $c->render(status => 204, openapi => {}); + } + catch { + $c->unhandled_exception($_); + }; + +} + 1; diff --git a/api/v1/swagger/paths/auth.yaml b/api/v1/swagger/paths/auth.yaml index c147d0549de..40c886a5ba1 100644 --- a/api/v1/swagger/paths/auth.yaml +++ b/api/v1/swagger/paths/auth.yaml @@ -39,3 +39,92 @@ x-koha-authorization: permissions: catalogue: "1" + +/auth/two-factor/registration: + post: + x-mojo-to: TwoFactorAuth#registration + operationId: Two factor register + tags: + - 2fa + summary: Generate a secret + produces: + - application/json + responses: + "201": + description: OK + schema: + type: object + properties: + secret32: + type: string + qr_code: + type: string + issuer: + type: string + key_id: + type: string + 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" + x-koha-authorization: + permissions: + catalogue: "1" + +/auth/two-factor/registration/verification: + post: + x-mojo-to: TwoFactorAuth#verification + operationId: Two factor register verification + tags: + - 2fa + summary: Verify two-factor registration + parameters: + - name: secret32 + in: formData + description: the secret + required: true + type: string + - name: pin_code + in: formData + description: the pin code + required: true + type: string + produces: + - application/json + responses: + "204": + description: OK + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "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" + x-koha-authorization: + permissions: + catalogue: "1" diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml index 5bdbab2fa8e..e2a4092a4fe 100644 --- a/api/v1/swagger/swagger.yaml +++ b/api/v1/swagger/swagger.yaml @@ -105,6 +105,10 @@ paths: $ref: "./paths/article_requests.yaml#/~1article_requests~1{article_request_id}" /auth/otp/token_delivery: $ref: paths/auth.yaml#/~1auth~1otp~1token_delivery + /auth/two-factor/registration: + $ref: paths/auth.yaml#/~1auth~1two-factor~1registration + /auth/two-factor/registration/verification: + $ref: paths/auth.yaml#/~1auth~1two-factor~1registration~1verification "/biblios/{biblio_id}": $ref: "./paths/biblios.yaml#/~1biblios~1{biblio_id}" "/biblios/{biblio_id}/checkouts": diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt index e2db5e0f30a..50f4d7a0872 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt @@ -17,6 +17,7 @@ [% IF ( nopermission ) %]Access denied[% END %] › Koha [% INCLUDE 'doc-head-close.inc' %] +[% PROCESS 'auth-two-factor.inc' %] @@ -71,7 +72,7 @@

Log in using a Shibboleth account.

[% END %] -[% IF !TwoFA_prompt && !Koha.Preference('staffShibOnly') %] +[% IF !TwoFA_prompt && !TwoFA_setup && !Koha.Preference('staffShibOnly') %]
@@ -168,6 +169,8 @@

+[% ELSIF TwoFA_setup %] + [% PROCESS registration_form %] [% END %] [% IF ( nopermission ) %] @@ -216,6 +219,52 @@ }); [% END %] }); + + if( $("#registration-form").length ) { + $.ajax({ + data: {}, + type: 'POST', + url: '/api/v1/auth/two-factor/registration', + success: function (data) { + $("#qr_code").attr('src', data.qr_code); + $("#secret32").val(data.secret32); + $("#issuer").html(data.issuer); + $("#key_id").html(data.key_id); + $("#registration-form").show(); + }, + error: function (data) { + alert(data); + }, + }); + }; + + $("#register-2FA").on("click", function(e){ + e.preventDefault(); + const data = { + secret32: $("#secret32").val(), + pin_code: $("#pin_code").val(), + }; + if (!data.pin_code) return; + + $.ajax({ + data: data, + type: 'POST', + url: '/api/v1/auth/two-factor/registration/verification', + success: function (data) { + alert(_("Two-factor authentication correctly configured. You will be redirected to the login screen.")); + window.location = "/cgi-bin/koha/mainpage.pl"; + }, + error: function (data) { + const error = data.responseJSON.error; + if ( error == 'Invalid pin' ) { + $("#errors").html(_("Invalid PIN code")).show(); + } else { + alert(error); + } + }, + }); + }); + }); [% END %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt index 98e01a28da2..11eea93c7cd 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt @@ -10,6 +10,7 @@ [% WRAPPER 'header.inc' %] [% INCLUDE 'patron-search-header.inc' %] [% END %] +[% PROCESS 'auth-two-factor.inc' %] [% WRAPPER 'sub-header.inc' %]