Bug 30588: Add the option to require 2FA setup on first staff login
authorJonathan Druart <jonathan.druart@bugs.koha-community.org>
Mon, 25 Jul 2022 14:53:02 +0000 (16:53 +0200)
committerTomas Cohen Arazi <tomascohen@theke.io>
Fri, 21 Oct 2022 14:36:57 +0000 (11:36 -0300)
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
Signed-off-by: Nick Clemens <nick@bywatersolutions.com>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
C4/Auth.pm
Koha/Auth/TwoFactorAuth.pm
Koha/REST/V1/Auth.pm
Koha/REST/V1/TwoFactorAuth.pm
api/v1/swagger/paths/auth.yaml
api/v1/swagger/swagger.yaml
koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt
koha-tmpl/intranet-tmpl/prog/en/modules/members/two_factor_auth.tt
members/two_factor_auth.pl

index a292c36..fed9c5f 100644 (file)
@@ -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();
index 48b2a89..b9c785f 100644 (file)
@@ -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';
 
index 621011c..8e8a6f6 100644 (file)
@@ -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') );
index e62136a..f9f199f 100644 (file)
@@ -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;
index c147d05..40c886a 100644 (file)
     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"
index 50a35a1..1bc55f7 100644 (file)
@@ -111,6 +111,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":
index e2db5e0..50f4d7a 100644 (file)
@@ -17,6 +17,7 @@
     [% IF ( nopermission ) %]Access denied[% END %] &rsaquo; Koha
 </title>
 [% INCLUDE 'doc-head-close.inc' %]
+[% PROCESS 'auth-two-factor.inc' %]
 </head>
 <body id="main_auth" class="main_main-auth">
 
@@ -71,7 +72,7 @@
 <p><a href="[% shibbolethLoginUrl | $raw %]">Log in using a Shibboleth account</a>.</p>
 [% END %]
 
-[% IF !TwoFA_prompt && !Koha.Preference('staffShibOnly') %]
+[% IF !TwoFA_prompt && !TwoFA_setup && !Koha.Preference('staffShibOnly') %]
     <!-- login prompt time-->
     <form action="[% script_name | html %]" method="post" name="loginform" id="loginform">
         <input type="hidden" name="koha_login_context" value="intranet" />
         </p>
 
     </form>
+[% ELSIF TwoFA_setup %]
+    [% PROCESS registration_form %]
 [% END %]
 
 [% IF ( nopermission ) %]
                     });
                 [% 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);
+                        }
+                    },
+                });
+            });
+
         });
     </script>
 [% END %]
index 98e01a2..11eea93 100644 (file)
@@ -10,6 +10,7 @@
 [% WRAPPER 'header.inc' %]
     [% INCLUDE 'patron-search-header.inc' %]
 [% END %]
+[% PROCESS 'auth-two-factor.inc' %]
 
 [% WRAPPER 'sub-header.inc' %]
 <nav id="breadcrumbs" aria-label="Breadcrumb" class="breadcrumb">
                         </p>
                     </div>
                 [% ELSE %]
+                    [% PROCESS registration_form %]
 
-                    [% IF op == 'register' %]
-                        <div class="dialog message">
-                            <p>We recommend cloud-based mobile authenticator apps such as Authy, Duo Mobile, and LastPass. They can restore access if you lose your hardware device.</p>
-                            <p>Can't scan the code?</p>
-                            <p>To add the entry manually, provide the following details to the application on your phone.</p>
-                            <p>Account: [% issuer | html %]</p>
-                            <p>Key: [% key_id | html %]</p>
-                            <p>Time based: Yes</p>
-                        </div>
-
-                        [% IF invalid_pin %]
-                            <div class="dialog error">Invalid PIN code</div>
-                        [% END %]
-                        <form id="two-factor-auth" action="/cgi-bin/koha/members/two_factor_auth.pl" method="post">
-                            <fieldset class="rows">
-                                <input type="hidden" name="csrf_token" value="[% csrf_token | html %]" />
-                                <input type="hidden" name="op" value="register-2FA" />
-                                <input type="hidden" name="secret32" value="[% secret32 | html %]" />
-                                <ol>
-                                    <li>
-                                        <label for="qr_code">QR code: </label>
-                                        <img id="qr_code" src="[% qr_code | $raw %]" />
-                                    </li>
-                                    <li>
-                                        <label for="pin_code">PIN code: </label>
-                                        <input type="text" id="pin_code" name="pin_code" value="" />
-                                    </li>
-                                </ol>
-                            </fieldset>
-                            <fieldset class="action">
-                                <input type="submit" value="Register with two-factor app" />
-                                <a class="cancel" href="/cgi-bin/koha/members/two_factor_auth.pl">Cancel</a>
-                            </fieldset>
-                        </form>
-                    [% ELSE %]
+                    <div id="registration-status">
                         [% IF patron.auth_method == "two-factor" %]
+                        <div id="registration-status-enabled">
+                        [% ELSE %]
+                        <div id="registration-status-enabled" style="display: none;">
+                        [% END %]
                             <div class="two-factor-status">Status: Enabled</div>
 
                             <form id="two-factor-auth" action="/cgi-bin/koha/members/two_factor_auth.pl" method="post">
                                 <input type="hidden" name="op" value="disable-2FA" />
                                 <input type="submit" value="Disable two-factor authentication" />
                             </form>
+                        </div>
+
+                        [% IF patron.auth_method == "password" %]
+                        <div id="registration-status-disabled">
                         [% ELSE %]
+                        <div id="registration-status-disabled" style="display: none;">
+                        [% END %]
                             <div class="two-factor-status">Status: Disabled</div>
+                            [% IF Koha.Preference('TwoFactorAuthentication') == 'enforced' %]
+                                <div>Two-factor authentication is mandatory to login. If you do not enable now it will be asked at your next login.</div>
+                            [% END %]
 
-                            <form id="two-factor-auth" action="/cgi-bin/koha/members/two_factor_auth.pl" method="post">
-                                <input type="hidden" name="csrf_token" value="[% csrf_token | html %]" />
-                                <input type="hidden" name="op" value="enable-2FA" />
-                                <input type="submit" value="Enable two-factor authentication" />
-                            </form>
-
-                        [% END %]
-                    [% END %]
+                            <input id="enable-2FA" type="submit" value="Enable two-factor authentication" />
+                        </div>
+                    </div>
                 [% END %]
             </main>
         </div> <!-- /.col-sm-10.col-sm-push-2 -->
     [% Asset.js("js/members-menu.js") | $raw %]
     <script>
         $(document).ready(function(){
-            $(".delete").on("click", function(e){
-                return confirmDelete(_("Are you sure you want to delete this key?"));
+            $("#enable-2FA").on("click", function(e){
+                e.preventDefault();
+                $.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();
+                        $("#registration-status").hide();
+                    },
+                    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) {
+                        window.location = "/cgi-bin/koha/members/two_factor_auth.pl";
+                    },
+                    error: function (data) {
+                        const error = data.responseJSON.error;
+                        if ( error == 'Invalid pin' ) {
+                            $("#errors").html(_("Invalid PIN code")).show();
+                        } else {
+                            alert(error);
+                        }
+                    },
+                });
+            });
+
         });
     </script>
 [% END %]
index 3a93732..ccbafe3 100755 (executable)
@@ -56,70 +56,7 @@ else {
         token      => scalar $cgi->param('csrf_token'),
     };
 
-    if ( $op eq 'register-2FA' ) {
-        output_and_exit( $cgi, $cookie, $template, 'wrong_csrf_token' )
-          unless Koha::Token->new->check_csrf($csrf_pars);
-
-        my $pin_code = $cgi->param('pin_code');
-        my $secret32 = $cgi->param('secret32');
-        my $auth     = Koha::Auth::TwoFactorAuth->new(
-            { patron => $logged_in_user, secret32 => $secret32 } );
-
-        my $verified = $auth->verify(
-            $pin_code,
-            1,        # range
-            $secret32,
-            undef,    # timestamp (defaults to now)
-            30,       # interval (default 30)
-        );
-
-        if ($verified) {
-
-            # FIXME Generate a (new?) secret
-            $logged_in_user->encode_secret($secret32);
-            $logged_in_user->auth_method('two-factor')->store;
-            $op = 'registered';
-            if ( $logged_in_user->notice_email_address ) {
-                $logged_in_user->queue_notice(
-                    {
-                        letter_params => {
-                            module      => 'members',
-                            letter_code => '2FA_ENABLE',
-                            branchcode  => $logged_in_user->branchcode,
-                            lang        => $logged_in_user->lang,
-                            tables      => {
-                                branches  => $logged_in_user->branchcode,
-                                borrowers => $logged_in_user->id
-                            },
-                        },
-                        message_transports => ['email'],
-                    }
-                );
-            }
-        }
-        else {
-            $template->param( invalid_pin => 1, );
-            $op = 'enable-2FA';
-        }
-    }
-
-    if ( $op eq 'enable-2FA' ) {
-        my $secret = Koha::AuthUtils::generate_salt( 'weak', 16 );
-        my $auth   = Koha::Auth::TwoFactorAuth->new(
-            { patron => $logged_in_user, secret => $secret } );
-
-        $template->param(
-            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;
-        $op = 'register';
-    }
-    elsif ( $op eq 'disable-2FA' ) {
+    if ( $op eq 'disable-2FA' ) {
         output_and_exit( $cgi, $cookie, $template, 'wrong_csrf_token' )
           unless Koha::Token->new->check_csrf($csrf_pars);
         my $auth =