@@ -, +, @@ login --- 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(-) --- a/C4/Auth.pm +++ a/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(); --- a/Koha/Auth/TwoFactorAuth.pm +++ a/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'; --- a/Koha/REST/V1/Auth.pm +++ a/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') ); --- a/Koha/REST/V1/TwoFactorAuth.pm +++ a/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; --- a/api/v1/swagger/paths/auth.yaml +++ a/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" --- a/api/v1/swagger/swagger.yaml +++ a/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": --- a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt +++ a/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 %] --- a/koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt +++ a/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' %]