View | Details | Raw Unified | Return to bug 39601
Collapse All | Expand All

(-)a/Koha/Auth/WebAuthn.pm (+78 lines)
Line 0 Link Here
1
# Koha/Auth/WebAuthn.pm
2
package Koha::Auth::WebAuthn;
3
4
# This file is part of Koha.
5
#
6
# Koha is free software; you can redistribute it and/or modify it
7
# under the terms of the GNU General Public License as published by
8
# the Free Software Foundation; either version 3 of the License, or
9
# (at your option) any later version.
10
#
11
# Koha is distributed in the hope that it will be useful, but
12
# WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with Koha; if not, see <http://www.gnu.org/licenses>.
18
19
use Modern::Perl;
20
use Authen::WebAuthn;
21
22
=head1 NAME
23
24
Koha::Auth::WebAuthn - WebAuthn (Passkey) authentication for Koha
25
26
=head1 SYNOPSIS
27
28
  use Koha::Auth::WebAuthn;
29
30
  my $wa = Koha::Auth::WebAuthn->new({ rp_id => 'example.com', origin => 'https://example.com' });
31
  my $challenge = $wa->generate_challenge();
32
  my $result = $wa->verify_assertion($assertion_json, $expected_challenge, $credential_public_key, ...);
33
34
=head1 DESCRIPTION
35
36
This module encapsulates WebAuthn (Passkey) authentication logic for Koha, using Authen::WebAuthn.
37
38
=cut
39
40
sub new {
41
    my ( $class, $params ) = @_;
42
    my $self = {
43
        rp_id    => $params->{rp_id},
44
        origin   => $params->{origin},
45
        webauthn => Authen::WebAuthn->new(
46
            rp_id  => $params->{rp_id},
47
            origin => $params->{origin},
48
        ),
49
    };
50
    bless $self, $class;
51
    return $self;
52
}
53
54
sub generate_challenge {
55
    my ($self) = @_;
56
    return $self->{webauthn}->generate_challenge();
57
}
58
59
sub verify_assertion {
60
    my ( $self, $assertion_json, $expected_challenge, $credential_public_key, $user_handle ) = @_;
61
    return $self->{webauthn}
62
        ->verify_assertion( $assertion_json, $expected_challenge, $credential_public_key, $user_handle );
63
}
64
65
sub verify_registration {
66
    my ( $self, $attestation_json, $expected_challenge ) = @_;
67
    return $self->{webauthn}->verify_attestation( $attestation_json, $expected_challenge );
68
}
69
70
1;
71
72
__END__
73
74
=head1 AUTHOR
75
76
Koha Development Team
77
78
=cut
(-)a/Koha/REST/V1/Auth.pm (-2 / +3 lines)
Lines 83-91 sub under { Link Here
83
        }
83
        }
84
84
85
        if (   $c->req->url->to_abs->path =~ m#^/api/v1/oauth/#
85
        if (   $c->req->url->to_abs->path =~ m#^/api/v1/oauth/#
86
            || $c->req->url->to_abs->path =~ m#^/api/v1/public/oauth/# )
86
            || $c->req->url->to_abs->path =~ m#^/api/v1/public/oauth/#
87
            || $c->req->url->to_abs->path =~ m#^/api/v1/webauthn/authenticate(?:/challenge)?$# )
87
        {
88
        {
88
            # Requesting OAuth endpoints shouldn't go through the API authentication chain
89
            # Requesting OAuth or WebAuthn endpoints shouldn't go through the API authentication chain
89
            $status = 1;
90
            $status = 1;
90
        } elsif ( $namespace eq '' or $namespace eq '.html' ) {
91
        } elsif ( $namespace eq '' or $namespace eq '.html' ) {
91
            $status = 1;
92
            $status = 1;
(-)a/Koha/REST/V1/Webauthn.pm (+408 lines)
Line 0 Link Here
1
package Koha::REST::V1::Webauthn;
2
3
# This file is part of Koha.
4
#
5
# Koha is free software; you can redistribute it and/or modify it
6
# under the terms of the GNU General Public License as published by
7
# the Free Software Foundation; either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# Koha is distributed in the hope that it will be useful, but
11
# WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
# GNU General Public License for more details.
14
#
15
# You should have received a copy of the GNU General Public License
16
# along with Koha; if not, see <http://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
use Mojo::Base 'Mojolicious::Controller';
20
use Try::Tiny;
21
use C4::Context;
22
use Mojo::URL;
23
use Koha::Patrons;
24
use Koha::WebauthnCredential;
25
use Koha::WebauthnCredentials;
26
use Authen::WebAuthn;
27
use MIME::Base64 qw(encode_base64 decode_base64);
28
use JSON         qw(decode_json encode_json);
29
use DateTime;
30
use Fcntl       qw(O_RDONLY);
31
32
=head1 NAME
33
34
Koha::REST::V1::Webauthn
35
36
=head1 API
37
38
=head2 Methods
39
40
=cut
41
42
sub _resolve_patron {
43
    my ($c)       = @_;
44
    my $body      = $c->req->json;
45
    my $patron_id = $body->{patron_id};
46
    my $userid    = $body->{userid};
47
    my $patron;
48
    if ($patron_id) {
49
        $patron = Koha::Patrons->find($patron_id);
50
    } elsif ($userid) {
51
        $patron = Koha::Patrons->find( { userid => $userid } );
52
    }
53
    return $patron;
54
}
55
56
sub _webauthn_origin_and_rp_id {
57
    my ($c) = @_;
58
    my $pref_base = C4::Context->preference('StaffClientBaseURL');
59
    my $req_url   = $c->req->url->to_abs;
60
61
    # Respect reverse proxy headers if present
62
    my $headers           = $c->req->headers;
63
    my $xf_proto          = $headers->header('X-Forwarded-Proto');
64
    my $xf_host_header    = $headers->header('X-Forwarded-Host');
65
    my $xf_port_header    = $headers->header('X-Forwarded-Port');
66
67
    # Effective request values
68
    my $req_scheme = $req_url->scheme // 'https';
69
    if ( defined $xf_proto && length $xf_proto ) {
70
        $req_scheme = lc $xf_proto;
71
    }
72
73
    my $req_host = $req_url->host;
74
    my $req_port = $req_url->port;
75
    if ( defined $xf_host_header && length $xf_host_header ) {
76
        my ($first_host) = split /\s*,\s*/, $xf_host_header, 2;
77
        if ( $first_host ) {
78
            if ( $first_host =~ /^(.*?):(\d+)$/ ) {
79
                $req_host = $1;
80
                $req_port = $2;
81
            } else {
82
                $req_host = $first_host;
83
            }
84
        }
85
    }
86
    if ( defined $xf_port_header && $xf_port_header =~ /^(\d+)$/ ) {
87
        $req_port = $1;
88
    }
89
90
    my ( $scheme, $host, $port ) = ( $req_scheme, $req_host, $req_port );
91
92
    if ($pref_base) {
93
        my $pref = Mojo::URL->new($pref_base);
94
        my $pref_scheme = $pref->scheme // 'https';
95
        my $pref_host   = $pref->host;
96
        my $pref_port   = $pref->port;
97
98
        # Always use the configured host for rp_id (domain requirement)
99
        $host = $pref_host if $pref_host;
100
101
        # If a password manager or proxy upgraded the scheme to https but host matches,
102
        # prefer the effective request scheme/port to avoid origin mismatches.
103
        if ( defined $req_scheme && defined $pref_scheme && $req_host && $pref_host && $req_host eq $pref_host ) {
104
            if ( $req_scheme ne $pref_scheme ) {
105
                # Only allow HTTPS upgrades (http -> https). Prevent downgrades.
106
                if ( $pref_scheme eq 'http' && $req_scheme eq 'https' ) {
107
                    $scheme = 'https';
108
                    $port   = $req_port;    # honor request port on upgrade
109
                } else {
110
                    # Keep configured secure scheme when request is http but pref is https
111
                    $scheme = $pref_scheme;
112
                    $port   = defined $pref_port ? $pref_port : $req_port;
113
                }
114
            } else {
115
                # same scheme; prefer explicit configured port, otherwise request port
116
                $scheme = $pref_scheme;
117
                $port   = defined $pref_port ? $pref_port : $req_port;
118
            }
119
        } else {
120
            # Different host or no request scheme: stick to configured scheme/port
121
            $scheme = $pref_scheme;
122
            $port   = $pref_port;
123
        }
124
    }
125
126
    my $origin = $scheme . '://' . $host . ( $port && $port !~ /^(80|443)$/ ? ':' . $port : '' );
127
    my $rp_id  = $host;    # rp_id must be a registrable domain, no scheme/port
128
    return ( $origin, $rp_id );
129
}
130
131
sub _maybe_upgrade_origin_to_client {
132
    my ( $origin, $rp_id, $client_origin ) = @_;
133
    return $origin unless $client_origin;
134
    my $co = Mojo::URL->new($client_origin);
135
    my $o  = Mojo::URL->new($origin);
136
    # Allow only http -> https upgrade for the same host (rp_id)
137
    if ( lc( $co->host // '' ) eq lc($rp_id) && ( $o->scheme // '' ) eq 'http' && ( $co->scheme // '' ) eq 'https' ) {
138
        my $port = $co->port;
139
        my $new  = 'https://' . $rp_id . ( $port && $port !~ /^(80|443)$/ ? ':' . $port : '' );
140
        return $new;
141
    }
142
    return $origin;
143
}
144
145
sub _generate_random_bytes {
146
    my ($length) = @_;
147
    $length ||= 32;
148
    my $bytes = '';
149
    if ( sysopen( my $fh, '/dev/urandom', O_RDONLY ) ) {
150
        read( $fh, $bytes, $length );
151
        close $fh;
152
    }
153
    return $bytes;
154
}
155
156
sub _to_base64url {
157
    my ($bytes) = @_;
158
    my $b64 = encode_base64( $bytes, '' );
159
    $b64 =~ tr{+/}{-_};
160
    $b64 =~ s/=+$//;
161
    return $b64;
162
}
163
164
sub _std_b64_to_b64url {
165
    my ($b64) = @_;
166
    $b64 =~ tr{+/}{-_};
167
    $b64 =~ s/=+$//;
168
    return $b64;
169
}
170
171
sub _b64url_to_std {
172
    my ($b64u) = @_;
173
    return unless defined $b64u;
174
    my $b64 = $b64u;
175
    $b64 =~ tr{-_}{+/};
176
    my $pad = ( 4 - ( length($b64) % 4 ) ) % 4;
177
    $b64 .= '=' x $pad;
178
    return $b64;
179
}
180
181
sub register_challenge {
182
    my $c = shift->openapi->valid_input or return;
183
    return try {
184
        my $patron = _resolve_patron($c);
185
        return $c->render( status => 404, openapi => { error => 'Patron not found' } ) unless $patron;
186
        my $patron_id = $patron->borrowernumber;
187
188
        my ( $origin, $rp_id ) = _webauthn_origin_and_rp_id($c);
189
        my $challenge_bytes = _generate_random_bytes(32);
190
        my $challenge_b64u  = _to_base64url($challenge_bytes);
191
192
        $c->session( webauthn_challenge => $challenge_b64u );
193
        $c->session( webauthn_patron_id => $patron_id );
194
195
        $c->render(
196
            openapi => {
197
                challenge => $challenge_b64u,
198
                user      => {
199
                    id   => $patron_id,
200
                    name => $patron->userid,
201
                },
202
            }
203
        );
204
    } catch {
205
        $c->unhandled_exception($_);
206
    };
207
}
208
209
sub register {
210
    my $c = shift->openapi->valid_input or return;
211
    return try {
212
        my $body = $c->req->json;
213
        my $patron;
214
        my $patron_id;
215
        if ( $body->{patron_id} ) {
216
            $patron    = Koha::Patrons->find( $body->{patron_id} );
217
            $patron_id = $body->{patron_id};
218
        } elsif ( $body->{userid} ) {
219
            $patron    = Koha::Patrons->find( { userid => $body->{userid} } );
220
            $patron_id = $patron ? $patron->borrowernumber : undef;
221
        } else {
222
            return $c->render( status => 400, openapi => { error => 'Missing patron_id or userid in request' } );
223
        }
224
        my $att = $body->{attestation_response} // {};
225
        return $c->render( status => 400, openapi => { error => 'Missing attestation_response' } ) unless %$att;
226
227
        my ( $origin, $rp_id ) = _webauthn_origin_and_rp_id($c);
228
        my $client_origin = eval {
229
            my $cdj_json = decode_base64( $att->{client_data_json} // '' );
230
            my $cdj      = decode_json($cdj_json);
231
            $cdj->{origin};
232
        };
233
        $origin = _maybe_upgrade_origin_to_client( $origin, $rp_id, $client_origin );
234
        my $webauthn       = Authen::WebAuthn->new( rp_id => $rp_id, origin => $origin );
235
        my $challenge_b64u = $c->session('webauthn_challenge');
236
237
        my $res = eval {
238
            $webauthn->validate_registration(
239
                challenge_b64          => $challenge_b64u,
240
                requested_uv           => 'preferred',
241
                client_data_json_b64   => _std_b64_to_b64url( $att->{client_data_json} ),
242
                attestation_object_b64 => _std_b64_to_b64url( $att->{attestation_object} ),
243
            );
244
        };
245
        if ( $@ || !$res ) {
246
            $c->app->log->warn( 'webauthn register failed: ' . ( $@ // 'undef' ) );
247
            return $c->render( status => 401, openapi => { error => 'Attestation verification failed' } );
248
        }
249
250
        Koha::WebauthnCredential->new(
251
            {
252
                borrowernumber => $patron_id,
253
                credential_id  => MIME::Base64::decode_base64url( $res->{credential_id} ),
254
                public_key     => MIME::Base64::decode_base64url( $res->{credential_pubkey} ),
255
                sign_count     => $res->{signature_count} // 0,
256
                transports     => undef,
257
                created_on     => DateTime->now->strftime('%F %T'),
258
            }
259
        )->store;
260
261
        $c->render( status => 201, openapi => { success => 1 } );
262
    } catch {
263
        $c->unhandled_exception($_);
264
    };
265
}
266
267
sub authenticate_challenge {
268
    my $c = shift->openapi->valid_input or return;
269
    return try {
270
        $c->app->log->debug( 'authenticate_challenge: incoming body: ' . encode_json( $c->req->json // {} ) );
271
        my $patron = _resolve_patron($c);
272
        $c->app->log->debug( 'authenticate_challenge: resolved patron: '
273
                . ( defined $patron ? $patron->borrowernumber . ' (' . ( $patron->userid // '' ) . ')' : 'undef' ) );
274
        return $c->render( status => 404, openapi => { error => 'Patron not found' } ) unless $patron;
275
        my $patron_id = $patron->borrowernumber;
276
        $c->app->log->debug("authenticate_challenge: patron_id: $patron_id");
277
278
        my $credentials = Koha::WebauthnCredentials->search( { borrowernumber => $patron_id } );
279
        $c->app->log->debug( 'authenticate_challenge: credentials found: ' . $credentials->count );
280
        return $c->render( status => 404, openapi => { error => 'No credentials registered' } )
281
            unless $credentials->count;
282
283
        my ( $origin, $rp_id ) = _webauthn_origin_and_rp_id($c);
284
        my $challenge_bytes = _generate_random_bytes(32);
285
        my $challenge_b64u  = _to_base64url($challenge_bytes);
286
        $c->app->log->debug( 'authenticate_challenge: generated challenge: ' . $challenge_b64u );
287
        $c->session( webauthn_challenge => $challenge_b64u );
288
        $c->session( webauthn_patron_id => $patron_id );
289
290
        my @allow_credentials;
291
        while ( my $c = $credentials->next ) {
292
            push @allow_credentials,
293
                { id => _std_b64_to_b64url( encode_base64( $c->credential_id, '' ) ), type => 'public-key' };
294
        }
295
        $c->app->log->debug( 'authenticate_challenge: allow_credentials count: ' . scalar @allow_credentials );
296
297
        $c->render(
298
            openapi => {
299
                challenge        => $challenge_b64u,
300
                allowCredentials => \@allow_credentials,
301
            }
302
        );
303
    } catch {
304
        $c->unhandled_exception($_);
305
    };
306
307
}
308
309
sub authenticate {
310
    my $c = shift->openapi->valid_input or return;
311
    return try {
312
        my $body = $c->req->json;
313
        my $patron;
314
        my $patron_id;
315
        if ( $body->{patron_id} ) {
316
            $patron    = Koha::Patrons->find( $body->{patron_id} );
317
            $patron_id = $body->{patron_id};
318
        } elsif ( $body->{userid} ) {
319
            $patron    = Koha::Patrons->find( { userid => $body->{userid} } );
320
            $patron_id = $patron ? $patron->borrowernumber : undef;
321
        } else {
322
            return $c->render( status => 400, openapi => { error => 'Missing patron_id or userid in request' } );
323
        }
324
        my $assertion = $body->{assertion_response};
325
        return $c->render( status => 400, openapi => { error => 'Missing assertion_response' } ) unless $assertion;
326
327
        my $credentials = Koha::WebauthnCredentials->search( { borrowernumber => $patron_id } );
328
        my ( %cred_id_map, %pubkeys );
329
        {
330
            my $it = $credentials->search( {}, { columns => [qw/id credential_id public_key/] } );
331
            while ( my $c = $it->next ) {
332
                my $id_b64u = _std_b64_to_b64url( encode_base64( $c->credential_id, '' ) );
333
                $cred_id_map{$id_b64u} = $c->id;
334
                $pubkeys{$id_b64u}     = _std_b64_to_b64url( encode_base64( $c->public_key, '' ) );
335
            }
336
        }
337
        my $challenge_b64u = $c->session('webauthn_challenge');
338
        my ( $origin, $rp_id ) = _webauthn_origin_and_rp_id($c);
339
        my $webauthn = Authen::WebAuthn->new( rp_id => $rp_id, origin => $origin );
340
341
        # Browser sends base64 (we need base64url)
342
        my $client_data_b64u =
343
            _std_b64_to_b64url( $assertion->{clientDataJSON} // $assertion->{client_data_json} // '' );
344
        my $client_origin = eval {
345
            my $cdj_b64 = $assertion->{clientDataJSON} // $assertion->{client_data_json} // '';
346
            my $cdj_json = decode_base64($cdj_b64);
347
            my $cdj      = decode_json($cdj_json);
348
            $cdj->{origin};
349
        };
350
        $origin   = _maybe_upgrade_origin_to_client( $origin, $rp_id, $client_origin );
351
        $webauthn = Authen::WebAuthn->new( rp_id => $rp_id, origin => $origin );
352
        my $auth_data_b64u =
353
            _std_b64_to_b64url( $assertion->{authenticatorData} // $assertion->{authenticator_data} // '' );
354
        my $signature_b64u     = _std_b64_to_b64url( $assertion->{signature} // '' );
355
        my $credential_id_b64u = _std_b64_to_b64url( $assertion->{id} // $assertion->{raw_id} // '' );
356
357
        # pubkeys already built above
358
359
        my $stored_sign_count = 0;       # optional enforcement; we track after success
360
        my $res               = eval {
361
            $webauthn->validate_assertion(
362
                challenge_b64          => $challenge_b64u,
363
                credential_pubkey_b64  => $pubkeys{$credential_id_b64u},
364
                stored_sign_count      => $stored_sign_count,
365
                requested_uv           => 'preferred',
366
                client_data_json_b64   => $client_data_b64u,
367
                authenticator_data_b64 => $auth_data_b64u,
368
                signature_b64          => $signature_b64u,
369
            );
370
        };
371
        if ( $@ || !$res ) {
372
            $c->app->log->warn( 'webauthn authenticate failed: ' . ( $@ // 'undef' ) );
373
            return $c->render( status => 401, openapi => { error => 'Authentication failed' } );
374
        }
375
        if ( my $cred_pk = $cred_id_map{$credential_id_b64u} ) {
376
            if ( my $cred = Koha::WebauthnCredentials->find($cred_pk) ) {
377
                $cred->set(
378
                    { sign_count => $res->{signature_count} // 0, last_used => DateTime->now->strftime('%F %T') } )
379
                    ->store;
380
            }
381
        }
382
383
        # Issue staff session and cookie for login
384
        my $patron_obj = Koha::Patrons->find($patron_id);
385
        my $session    = C4::Auth::create_basic_session( { patron => $patron_obj, interface => 'intranet' } );
386
        my ( $computed_origin ) = _webauthn_origin_and_rp_id($c);
387
        my $is_https = ( $computed_origin =~ m{^https://}i ) ? 1 : 0;
388
        $c->cookie( CGISESSID => $session->id, { path => '/', http_only => 1, same_site => 'Lax', secure => $is_https } );
389
        C4::Context->interface('intranet');
390
        my $lib = $patron_obj->library;
391
        C4::Context->set_userenv(
392
            $patron_obj->borrowernumber,
393
            $patron_obj->userid     // '',
394
            $patron_obj->cardnumber // '',
395
            $patron_obj->firstname  // '',
396
            $patron_obj->surname    // '',
397
            ( $lib ? $lib->branchcode : '' ),
398
            ( $lib ? $lib->branchname : '' ),
399
            $patron_obj->flags,
400
            $patron_obj->email // '',
401
        );
402
        $c->render( status => 200, openapi => { success => 1 } );
403
    } catch {
404
        $c->unhandled_exception($_);
405
    };
406
}
407
408
1;
(-)a/Koha/WebauthnCredential.pm (+18 lines)
Line 0 Link Here
1
package Koha::WebauthnCredential;
2
3
use Modern::Perl;
4
use base qw(Koha::Object);
5
6
sub _type { 'WebauthnCredential' }
7
8
1;
9
10
__END__
11
12
=head1 NAME
13
14
Koha::WebauthnCredential - Koha Object class for webauthn_credentials
15
16
=cut
17
18
(-)a/Koha/WebauthnCredentials.pm (+17 lines)
Line 0 Link Here
1
package Koha::WebauthnCredentials;
2
3
use Modern::Perl;
4
use base qw(Koha::Objects);
5
6
sub _type        { 'WebauthnCredential' }
7
sub object_class { 'Koha::WebauthnCredential' }
8
9
1;
10
11
__END__
12
13
=head1 NAME
14
15
Koha::WebauthnCredentials - Koha Objects class for webauthn_credentials
16
17
=cut
(-)a/api/v1/swagger/paths/webauthn.yaml (+196 lines)
Line 0 Link Here
1
---
2
/webauthn/register/challenge:
3
  post:
4
    x-mojo-to: Webauthn#register_challenge
5
    operationId: webauthnRegisterChallenge
6
    tags:
7
      - webauthn
8
    summary: Generate a WebAuthn registration challenge
9
    description: Generates a WebAuthn challenge for passkey registration for a given patron.
10
    produces:
11
      - application/json
12
    parameters:
13
      - name: body
14
        in: body
15
        required: true
16
        schema:
17
          type: object
18
          properties:
19
            patron_id:
20
              type: [integer, "null"]
21
            userid:
22
              type: [string, "null"]
23
          additionalProperties: false
24
    responses:
25
      "200":
26
        description: WebAuthn registration challenge
27
        schema:
28
          $ref: "../swagger.yaml#/definitions/WebauthnChallenge"
29
      "404":
30
        description: Patron not found
31
        schema:
32
          $ref: "../swagger.yaml#/definitions/error"
33
      "400":
34
        description: Bad request
35
        schema:
36
          $ref: "../swagger.yaml#/definitions/error"
37
      "401":
38
        description: Unauthorized
39
        schema:
40
          $ref: "../swagger.yaml#/definitions/error"
41
      "403":
42
        description: Forbidden
43
        schema:
44
          $ref: "../swagger.yaml#/definitions/error"
45
      "500":
46
        description: Internal server error
47
        schema:
48
          $ref: "../swagger.yaml#/definitions/error"
49
      "503":
50
        description: Under maintenance
51
        schema:
52
          $ref: "../swagger.yaml#/definitions/error"
53
    x-koha-authorization:
54
      permissions:
55
        catalogue: "1"
56
57
/webauthn/register:
58
  post:
59
    x-mojo-to: Webauthn#register
60
    operationId: webauthnRegister
61
    tags:
62
      - webauthn
63
    summary: Complete WebAuthn registration
64
    description: Receives and verifies the attestation response, then stores the credential for the patron.
65
    produces:
66
      - application/json
67
    parameters:
68
      - name: body
69
        in: body
70
        required: true
71
        schema:
72
          $ref: "../swagger.yaml#/definitions/WebauthnRegistrationRequest"
73
    responses:
74
      "201":
75
        description: Credential registered successfully
76
      "400":
77
        description: "Bad request: Invalid attestation response"
78
        schema:
79
          $ref: "../swagger.yaml#/definitions/error"
80
      "401":
81
        description: Unauthorized
82
        schema:
83
          $ref: "../swagger.yaml#/definitions/error"
84
      "403":
85
        description: Forbidden
86
        schema:
87
          $ref: "../swagger.yaml#/definitions/error"
88
      "500":
89
        description: Internal server error
90
        schema:
91
          $ref: "../swagger.yaml#/definitions/error"
92
      "503":
93
        description: Under maintenance
94
        schema:
95
          $ref: "../swagger.yaml#/definitions/error"
96
    x-koha-authorization:
97
      permissions:
98
        catalogue: "1"
99
100
/webauthn/authenticate/challenge:
101
  post:
102
    x-mojo-to: Webauthn#authenticate_challenge
103
    operationId: webauthnAuthenticateChallenge
104
    tags:
105
      - webauthn
106
    summary: Generate a WebAuthn authentication challenge
107
    description: Generates a challenge for passkey authentication (login) for a given patron.
108
    produces:
109
      - application/json
110
    parameters:
111
      - name: body
112
        in: body
113
        required: true
114
        schema:
115
          type: object
116
          properties:
117
            patron_id:
118
              type: integer
119
            userid:
120
              type: string
121
          additionalProperties: false
122
    responses:
123
      "200":
124
        description: WebAuthn authentication challenge
125
        schema:
126
          $ref: "../swagger.yaml#/definitions/WebauthnAuthChallenge"
127
      "404":
128
        description: No credentials registered
129
        schema:
130
          $ref: "../swagger.yaml#/definitions/error"
131
      "400":
132
        description: Bad request
133
        schema:
134
          $ref: "../swagger.yaml#/definitions/error"
135
      "401":
136
        description: Unauthorized
137
        schema:
138
          $ref: "../swagger.yaml#/definitions/error"
139
      "403":
140
        description: Forbidden
141
        schema:
142
          $ref: "../swagger.yaml#/definitions/error"
143
      "500":
144
        description: Internal server error
145
        schema:
146
          $ref: "../swagger.yaml#/definitions/error"
147
      "503":
148
        description: Under maintenance
149
        schema:
150
          $ref: "../swagger.yaml#/definitions/error"
151
    x-koha-authorization:
152
      permissions:
153
        catalogue: "1"
154
155
/webauthn/authenticate:
156
  post:
157
    x-mojo-to: Webauthn#authenticate
158
    operationId: webauthnAuthenticate
159
    tags:
160
      - webauthn
161
    summary: Complete WebAuthn authentication
162
    description: Receives and verifies the assertion response, then authenticates the patron.
163
    produces:
164
      - application/json
165
    parameters:
166
      - name: body
167
        in: body
168
        required: true
169
        schema:
170
          $ref: "../swagger.yaml#/definitions/WebauthnAuthenticationRequest"
171
    responses:
172
      "200":
173
        description: Authentication successful
174
      "400":
175
        description: "Bad request: Invalid assertion response"
176
        schema:
177
          $ref: "../swagger.yaml#/definitions/error"
178
      "401":
179
        description: Authentication failed
180
        schema:
181
          $ref: "../swagger.yaml#/definitions/error"
182
      "403":
183
        description: Forbidden
184
        schema:
185
          $ref: "../swagger.yaml#/definitions/error"
186
      "500":
187
        description: Internal server error
188
        schema:
189
          $ref: "../swagger.yaml#/definitions/error"
190
      "503":
191
        description: Under maintenance
192
        schema:
193
          $ref: "../swagger.yaml#/definitions/error"
194
    x-koha-authorization:
195
      permissions:
196
        catalogue: "1"
(-)a/api/v1/swagger/swagger.yaml (+61 lines)
Lines 2-7 Link Here
2
swagger: "2.0"
2
swagger: "2.0"
3
basePath: /api/v1
3
basePath: /api/v1
4
definitions:
4
definitions:
5
  WebauthnChallenge:
6
    type: object
7
    properties:
8
      challenge:
9
        type: string
10
      user:
11
        type: object
12
        properties:
13
          id:
14
            type: integer
15
          name:
16
            type: string
17
  WebauthnAuthChallenge:
18
    type: object
19
    properties:
20
      challenge:
21
        type: string
22
      allowCredentials:
23
        type: array
24
        items:
25
          type: object
26
          properties:
27
            id:
28
              type: string
29
            type:
30
              type: string
31
  WebauthnRegistrationRequest:
32
    type: object
33
    properties:
34
      patron_id:
35
        type: integer
36
      userid:
37
        type: string
38
      attestation_response:
39
        type: object
40
    required:
41
      - attestation_response
42
  WebauthnAuthenticationRequest:
43
    type: object
44
    properties:
45
      patron_id:
46
        type: integer
47
      userid:
48
        type: string
49
      assertion_response:
50
        type: object
51
    required:
52
      - assertion_response
53
5
  account_line:
54
  account_line:
6
    $ref: ./definitions/account_line.yaml
55
    $ref: ./definitions/account_line.yaml
7
  advancededitormacro:
56
  advancededitormacro:
Lines 585-590 paths: Link Here
585
    $ref: ./paths/transfer_limits.yaml#/~1transfer_limits~1batch
634
    $ref: ./paths/transfer_limits.yaml#/~1transfer_limits~1batch
586
  "/transfer_limits/{limit_id}":
635
  "/transfer_limits/{limit_id}":
587
    $ref: "./paths/transfer_limits.yaml#/~1transfer_limits~1{limit_id}"
636
    $ref: "./paths/transfer_limits.yaml#/~1transfer_limits~1{limit_id}"
637
  /webauthn/register/challenge:
638
    $ref: ./paths/webauthn.yaml#/~1webauthn~1register~1challenge
639
  /webauthn/register:
640
    $ref: ./paths/webauthn.yaml#/~1webauthn~1register
641
  /webauthn/authenticate/challenge:
642
    $ref: ./paths/webauthn.yaml#/~1webauthn~1authenticate~1challenge
643
  /webauthn/authenticate:
644
    $ref: ./paths/webauthn.yaml#/~1webauthn~1authenticate
645
588
parameters:
646
parameters:
589
  advancededitormacro_id_pp:
647
  advancededitormacro_id_pp:
590
    description: Advanced editor macro internal identifier
648
    description: Advanced editor macro internal identifier
Lines 1309-1311 tags: Link Here
1309
  - description: "Manage vendors configuration\n"
1367
  - description: "Manage vendors configuration\n"
1310
    name: vendors_config
1368
    name: vendors_config
1311
    x-displayName: Vendors configuration
1369
    x-displayName: Vendors configuration
1370
  - description: "Handle WebAuthn (passkey) registration and authentication\n"
1371
    name: webauthn
1372
    x-displayName: WebAuthn
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/auth-webauthn.inc (+11 lines)
Line 0 Link Here
1
<div id="webauthn-login-section" class="mb-0">
2
    <button id="webauthn-login-btn" class="btn btn-secondary btn-lg" type="button" title="[% t('Use a passkey to sign in') | html %]">
3
        <i class="fa fa-key"></i>
4
        <span class="d-none d-sm-inline"> Passkey</span>
5
    </button>
6
    <noscript>
7
        <div class="alert alert-warning mt-2">Passkeys require JavaScript.</div>
8
    </noscript>
9
    [% USE Asset %]
10
    [% Asset.js('js/webauthn-login.js') | $raw %]
11
</div>
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc (+8 lines)
Lines 79-84 Link Here
79
                    <li>
79
                    <li>
80
                        <a class="dropdown-item" id="renewpatron" href="/cgi-bin/koha/members/setstatus.pl?borrowernumber=[% patron.borrowernumber | html %]&amp;destination=[% destination | html %]&amp;reregistration=y">Renew patron</a>
80
                        <a class="dropdown-item" id="renewpatron" href="/cgi-bin/koha/members/setstatus.pl?borrowernumber=[% patron.borrowernumber | html %]&amp;destination=[% destination | html %]&amp;reregistration=y">Renew patron</a>
81
                    </li>
81
                    </li>
82
                    [% IF ( !is_anonymous && loggedinuser.borrowernumber == patron.borrowernumber ) %]
83
                        <li>
84
                            <a class="dropdown-item" id="register-passkey-menu" href="#" data-bs-toggle="modal" data-bs-target="#passkeyRegisterModal">Register Passkey</a>
85
                        </li>
86
                    [% END %]
82
                [% ELSE %]
87
                [% ELSE %]
83
                    <li data-bs-toggle="tooltip" data-bs-placement="left" title="You are not authorized to renew patrons">
88
                    <li data-bs-toggle="tooltip" data-bs-placement="left" title="You are not authorized to renew patrons">
84
                        <a class="dropdown-item disabled" aria-disabled="true" id="renewpatron" href="#">Renew patron</a>
89
                        <a class="dropdown-item disabled" aria-disabled="true" id="renewpatron" href="#">Renew patron</a>
Lines 168-173 Link Here
168
</div>
173
</div>
169
174
170
<!-- Modal -->
175
<!-- Modal -->
176
[% IF ( !is_anonymous && loggedinuser.borrowernumber == patron.borrowernumber ) %]
177
[% INCLUDE 'passkey-register.inc' %]
178
[% END %]
171
<div id="add_message_form" class="modal" tabindex="-1" role="dialog" aria-labelledby="addnewmessageLabel toolbar_addnewmessageLabel" aria-hidden="true">
179
<div id="add_message_form" class="modal" tabindex="-1" role="dialog" aria-labelledby="addnewmessageLabel toolbar_addnewmessageLabel" aria-hidden="true">
172
    <div class="modal-dialog modal-lg">
180
    <div class="modal-dialog modal-lg">
173
        <div class="modal-content modal-lg">
181
        <div class="modal-content modal-lg">
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/passkey-register.inc (+34 lines)
Line 0 Link Here
1
<!-- Passkey registration modal -->
2
<div id="passkey-register-section">
3
    <div class="modal fade" id="passkeyRegisterModal" tabindex="-1" aria-labelledby="passkeyRegisterTitle" aria-hidden="true">
4
        <div class="modal-dialog">
5
            <div class="modal-content">
6
                <div class="modal-header">
7
                    <h5 class="modal-title" id="passkeyRegisterTitle"><i class="fa fa-key"></i> Register a passkey</h5>
8
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
9
                </div>
10
                <div class="modal-body">
11
                    <p>
12
                        Passkeys let staff sign in using the device (for example Touch ID,
13
                        Face ID, Windows Hello, or a device PIN). They are more secure and
14
                        phishing‑resistant compared to passwords.
15
                    </p>
16
                    <ul>
17
                        <li>We will create a passkey for this patron account.</li>
18
                        <li>At the staff login screen: enter the username, then click “Passkey”.</li>
19
                        <li>Passkeys can sync via your platform account or password manager if enabled.</li>
20
                    </ul>
21
                    <div id="passkey-register-success-modal" class="alert alert-success" style="display:none"></div>
22
                    <div id="passkey-register-error-modal" class="alert alert-danger" style="display:none"></div>
23
                </div>
24
                <div class="modal-footer">
25
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
26
                    <button type="button" class="btn btn-primary" id="passkey-register-confirm">Create passkey</button>
27
                    <button type="button" class="btn btn-success d-none" id="passkey-register-done" data-bs-dismiss="modal">Done</button>
28
                </div>
29
            </div>
30
        </div>
31
    </div>
32
</div>
33
[% USE Asset %]
34
[% Asset.js("js/passkey-register.js") | $raw %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt (-1 / +5 lines)
Lines 227-233 Link Here
227
                    [% END %]
227
                    [% END %]
228
                [% END %]
228
                [% END %]
229
229
230
                <p class="submit"><input id="submit-button" type="submit" class="btn btn-primary" value="Log in" tabindex="4" /></p>
230
                <div class="d-flex justify-content-end gap-2 align-items-center mb-0">
231
                    <button id="submit-button" type="submit" class="btn btn-primary btn-lg">Log in</button>
232
                    [% INCLUDE 'auth-webauthn.inc' %]
233
                </div>
234
                <div id="webauthn-login-error" class="alert alert-danger mt-2" style="display:none;"></div>
231
            </form>
235
            </form>
232
236
233
            [% IF ( casAuthentication ) %]
237
            [% IF ( casAuthentication ) %]
(-)a/koha-tmpl/intranet-tmpl/prog/js/passkey-register.js (+145 lines)
Line 0 Link Here
1
// Passkey registration script for patron page
2
3
(function () {
4
  'use strict';
5
6
  function qs(id) { return document.getElementById(id); }
7
8
  function text(id) {
9
    var el = qs(id);
10
    return el ? el.textContent : '';
11
  }
12
13
  function getPatronContext() {
14
    var idText = text('patron-borrowernumber');
15
    var patronId = idText ? idText.trim().replace(/[^0-9]/g, '') : null;
16
    var usernameText = text('patron-username');
17
    var userid = usernameText ? usernameText.replace('Username:', '').trim() : null;
18
    return { patronId: patronId, userid: userid };
19
  }
20
21
  function b64uToBytes(b64u) {
22
    const b64 = b64u.replace(/-/g, '+').replace(/_/g, '/');
23
    const pad = '='.repeat((4 - (b64.length % 4)) % 4);
24
    const str = window.atob(b64 + pad);
25
    const bytes = new Uint8Array(str.length);
26
    for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i);
27
    return bytes;
28
  }
29
30
  function toBase64(arrBuf) {
31
    return window.btoa(String.fromCharCode.apply(null, new Uint8Array(arrBuf)));
32
  }
33
34
  async function fetchChallenge(ctx) {
35
    const body = ctx.patronId ? { patron_id: Number(ctx.patronId) } : { userid: ctx.userid };
36
    const resp = await fetch('/api/v1/webauthn/register/challenge', {
37
      method: 'POST',
38
      headers: { 'Content-Type': 'application/json' },
39
      credentials: 'same-origin',
40
      body: JSON.stringify(body)
41
    });
42
    if (!resp.ok) throw new Error(__('Failed to get registration challenge'));
43
    return resp.json();
44
  }
45
46
  function buildPublicKeyOptions(challengeJson, ctx) {
47
    const userIdBytes = new TextEncoder().encode(String(ctx.patronId || ctx.userid));
48
    return {
49
      challenge: b64uToBytes(challengeJson.challenge),
50
      rp: { name: 'Koha' },
51
      user: { id: userIdBytes, name: ctx.userid || String(ctx.patronId), displayName: ctx.userid || String(ctx.patronId) },
52
      pubKeyCredParams: [
53
        { type: 'public-key', alg: -7 },
54
        { type: 'public-key', alg: -257 }
55
      ],
56
      timeout: 60000,
57
      attestation: 'none'
58
    };
59
  }
60
61
  async function createCredential(publicKey) {
62
    return navigator.credentials.create({ publicKey: publicKey });
63
  }
64
65
  async function submitAttestation(ctx, credential) {
66
    const data = {
67
      attestation_response: {
68
        attestation_object: toBase64(credential.response.attestationObject),
69
        client_data_json: toBase64(credential.response.clientDataJSON),
70
        raw_id: toBase64(credential.rawId)
71
      }
72
    };
73
    if (ctx.patronId) data.patron_id = Number(ctx.patronId);
74
    if (ctx.userid) data.userid = ctx.userid;
75
76
    const resp = await fetch('/api/v1/webauthn/register', {
77
      method: 'POST',
78
      headers: { 'Content-Type': 'application/json' },
79
      credentials: 'same-origin',
80
      body: JSON.stringify(data)
81
    });
82
    if (!resp.ok) {
83
      const errTxt = await resp.text().catch(function () { return ''; });
84
      throw new Error(__('Server rejected registration{suffix}').format({ suffix: errTxt ? ': ' + errTxt : '' }));
85
    }
86
  }
87
88
  function onSuccess() {
89
    var successEl = qs('passkey-register-success-modal');
90
    var confirmBtn = qs('passkey-register-confirm');
91
    var doneBtn = qs('passkey-register-done');
92
    if (successEl) {
93
      successEl.textContent = __('Passkey registered successfully.');
94
      successEl.style.display = '';
95
    }
96
    if (confirmBtn) confirmBtn.classList.add('d-none');
97
    if (doneBtn) doneBtn.classList.remove('d-none');
98
  }
99
100
  function onError(e) {
101
    var errorEl = qs('passkey-register-error-modal');
102
    if (!errorEl) return;
103
    var msg = (e && e.name === 'AbortError') ? __('Registration canceled') : (e && e.message ? e.message : String(e));
104
    errorEl.textContent = __('Registration error: {msg}').format({ msg: msg });
105
    errorEl.style.display = '';
106
  }
107
108
  function attachHandlers() {
109
    var confirmBtn = qs('passkey-register-confirm');
110
    if (!confirmBtn) return;
111
112
    confirmBtn.addEventListener('click', async function () {
113
      // Feature detection
114
      if (!window.PublicKeyCredential) {
115
        onError(new Error(__('Passkeys are not supported by this browser.')));
116
        return;
117
      }
118
      var ctx = getPatronContext();
119
      if (!ctx.patronId && !ctx.userid) {
120
        onError(new Error(__('Cannot determine patron context')));
121
        return;
122
      }
123
      // Hide messages
124
      var successEl = qs('passkey-register-success-modal');
125
      var errorEl = qs('passkey-register-error-modal');
126
      if (successEl) successEl.style.display = 'none';
127
      if (errorEl) errorEl.style.display = 'none';
128
129
      try {
130
        const challenge = await fetchChallenge(ctx);
131
        const publicKey = buildPublicKeyOptions(challenge, ctx);
132
        const credential = await createCredential(publicKey);
133
        if (!credential) throw new Error(__('No credential created'));
134
        await submitAttestation(ctx, credential);
135
        onSuccess();
136
      } catch (e) {
137
        onError(e);
138
      }
139
    });
140
  }
141
142
  document.addEventListener('DOMContentLoaded', attachHandlers);
143
})();
144
145
(-)a/koha-tmpl/intranet-tmpl/prog/js/webauthn-login.js (+135 lines)
Line 0 Link Here
1
// WebAuthn login script for Koha staff interface
2
3
(function () {
4
  'use strict';
5
6
  function toBase64(arrBuf) {
7
    return window.btoa(String.fromCharCode.apply(null, new Uint8Array(arrBuf)));
8
  }
9
10
  function b64uToBytes(b64u) {
11
    if (!b64u) return new Uint8Array();
12
    const b64 = b64u.replace(/-/g, '+').replace(/_/g, '/');
13
    const pad = '='.repeat((4 - (b64.length % 4)) % 4);
14
    const str = window.atob(b64 + pad);
15
    const bytes = new Uint8Array(str.length);
16
    for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i);
17
    return bytes;
18
  }
19
20
  function buildLoginPayload(usernameValue) {
21
    if (!usernameValue) return null;
22
    const isNumeric = !isNaN(usernameValue);
23
    return isNumeric ? { patron_id: usernameValue } : { userid: usernameValue };
24
  }
25
26
  function getUsernameInput() {
27
    return document.getElementById('userid');
28
  }
29
30
  function getErrorContainer() {
31
    return document.getElementById('webauthn-login-error');
32
  }
33
34
  async function requestChallenge(loginPayload) {
35
    const resp = await fetch('/api/v1/webauthn/authenticate/challenge', {
36
      method: 'POST',
37
      headers: { 'Content-Type': 'application/json' },
38
      credentials: 'same-origin',
39
      body: JSON.stringify(loginPayload)
40
    });
41
    if (!resp.ok) throw new Error(__('Failed to fetch WebAuthn challenge'));
42
    return resp.json();
43
  }
44
45
  async function performAssertion(opts) {
46
    // Convert base64url fields to bytes as required by WebAuthn API
47
    const publicKey = Object.assign({}, opts, {
48
      challenge: b64uToBytes(opts.challenge),
49
      allowCredentials: Array.isArray(opts.allowCredentials)
50
        ? opts.allowCredentials.map(function (cred) {
51
            return Object.assign({}, cred, { id: b64uToBytes(cred.id) });
52
          })
53
        : undefined
54
    });
55
    return navigator.credentials.get({ publicKey: publicKey });
56
  }
57
58
  async function submitAssertion(loginPayload, assertion) {
59
    const data = {
60
      assertion_response: {
61
        id: toBase64(assertion.rawId),
62
        authenticatorData: toBase64(assertion.response.authenticatorData),
63
        clientDataJSON: toBase64(assertion.response.clientDataJSON),
64
        signature: toBase64(assertion.response.signature),
65
        userHandle: assertion.response.userHandle
66
          ? toBase64(assertion.response.userHandle)
67
          : null
68
      }
69
    };
70
    if (loginPayload.userid) data.userid = loginPayload.userid;
71
    if (loginPayload.patron_id) data.patron_id = loginPayload.patron_id;
72
73
    const verifyResp = await fetch('/api/v1/webauthn/authenticate', {
74
      method: 'POST',
75
      headers: { 'Content-Type': 'application/json' },
76
      credentials: 'same-origin',
77
      body: JSON.stringify(data)
78
    });
79
    if (!verifyResp.ok) throw new Error(__('Authentication failed'));
80
  }
81
82
  function attachClickHandler(button) {
83
    button.addEventListener('click', async function () {
84
      const errorEl = getErrorContainer();
85
      if (errorEl) errorEl.style.display = 'none';
86
87
      // Guard: WebAuthn support
88
      if (!window.PublicKeyCredential) return;
89
90
      const userInput = getUsernameInput();
91
      if (userInput && !userInput.value) {
92
        userInput.focus();
93
        if (errorEl) {
94
          errorEl.textContent = __('Please enter your username or patron ID.');
95
          errorEl.style.display = '';
96
        }
97
        return;
98
      }
99
100
      const payload = buildLoginPayload(userInput ? userInput.value : '');
101
      if (!payload) {
102
        if (errorEl) {
103
          errorEl.textContent = __('Please enter your username or patron ID.');
104
          errorEl.style.display = '';
105
        }
106
        return;
107
      }
108
109
      try {
110
        const opts = await requestChallenge(payload);
111
        const assertion = await performAssertion(opts);
112
        await submitAssertion(payload, assertion);
113
        window.location.assign('/cgi-bin/koha/mainpage.pl');
114
      } catch (e) {
115
        if (errorEl) {
116
          const msg = (e && e.name === 'NotAllowedError')
117
            ? __('Passkey request was cancelled or timed out. Please try again.')
118
            : __('WebAuthn error: {error}').format({ error: String(e) });
119
          errorEl.textContent = msg;
120
          errorEl.style.display = '';
121
        }
122
      }
123
    });
124
  }
125
126
  function initialize() {
127
    var btn = document.getElementById('webauthn-login-btn');
128
    if (!btn) return; // Guard: button not present
129
    attachClickHandler(btn);
130
  }
131
132
  document.addEventListener('DOMContentLoaded', initialize);
133
})();
134
135
(-)a/members/moremember.pl (+7 lines)
Lines 78-83 output_and_exit_if_error( Link Here
78
78
79
my $category_type = $patron->category->category_type;
79
my $category_type = $patron->category->category_type;
80
80
81
if ( $patron->borrowernumber eq C4::Context->preference("AnonymousPatron") ) {
82
    $template->param( is_anonymous => 1 );
83
} else {
84
    $template->param( is_anonymous => 0 );
85
}
86
81
for (qw(gonenoaddress lost borrowernotes is_debarred)) {
87
for (qw(gonenoaddress lost borrowernotes is_debarred)) {
82
    $patron->$_ and $template->param( flagged => 1 ) and last;
88
    $patron->$_ and $template->param( flagged => 1 ) and last;
83
}
89
}
Lines 111-116 if (@guarantors) { Link Here
111
$template->param(
117
$template->param(
112
    guarantor_relationships => $guarantor_relationships,
118
    guarantor_relationships => $guarantor_relationships,
113
    guarantees              => \@guarantees,
119
    guarantees              => \@guarantees,
120
    loggedinuser            => $logged_in_user,
114
);
121
);
115
122
116
my $relatives_issues_count = Koha::Checkouts->count( { borrowernumber => \@relatives } );
123
my $relatives_issues_count = Koha::Checkouts->count( { borrowernumber => \@relatives } );
(-)a/t/db_dependent/Koha/WebauthnCredentials.t (+76 lines)
Line 0 Link Here
1
#!/usr/bin/perl
2
3
use Modern::Perl;
4
use lib 't/lib';
5
use Test::More;
6
use C4::Context;
7
use Koha::WebauthnCredential;
8
use Koha::WebauthnCredentials;
9
use Koha::Database;
10
use t::lib::TestBuilder;
11
12
my $schema  = Koha::Database->new->schema;
13
my $builder = t::lib::TestBuilder->new;
14
15
$schema->storage->txn_do(
16
    sub {
17
        my $borrower       = $builder->build_object( { class => 'Koha::Patrons' } );
18
        my $borrowernumber = $borrower->borrowernumber;
19
20
        subtest 'add and retrieve credential' => sub {
21
            plan tests => 6;
22
            my $credential_id = 'testcredentialid';
23
            my $public_key    = 'testpublickey';
24
            my $sign_count    = 1;
25
            my $transports    = 'usb,nfc';
26
            my $nickname      = 'Test Key';
27
28
            my $credential = Koha::WebauthnCredential->new(
29
                {
30
                    borrowernumber => $borrowernumber,
31
                    credential_id  => $credential_id,
32
                    public_key     => $public_key,
33
                    sign_count     => $sign_count,
34
                    transports     => $transports,
35
                    nickname       => $nickname,
36
                    created_on     => '2020-01-01 00:00:00',
37
                }
38
            )->store;
39
            ok( $credential && $credential->id, 'Credential added' );
40
41
            my $cred = Koha::WebauthnCredentials->find( { credential_id => $credential_id } );
42
            ok( $cred, 'Credential found by credential_id' );
43
            is( $cred->borrowernumber, $borrowernumber, 'Borrowernumber matches' );
44
            is( $cred->public_key,     $public_key,     'Public key matches' );
45
            is( $cred->sign_count,     $sign_count,     'Sign count matches' );
46
            is( $cred->nickname,       $nickname,       'Nickname matches' );
47
        };
48
49
        subtest 'search by borrowernumber' => sub {
50
            plan tests => 1;
51
            my $creds = Koha::WebauthnCredentials->search( { borrowernumber => $borrowernumber } );
52
            ok( $creds->count >= 1, 'At least one credential for borrower' );
53
        };
54
55
        subtest 'update sign_count' => sub {
56
            plan tests => 1;
57
            my $credential = Koha::WebauthnCredentials->find( { credential_id => 'testcredentialid' } );
58
            $credential->set( { sign_count => 42 } )->store;
59
            $credential = Koha::WebauthnCredentials->find( { credential_id => 'testcredentialid' } );
60
            is( $credential->sign_count, 42, 'Sign count updated' );
61
        };
62
63
        subtest 'delete credential' => sub {
64
            plan tests => 1;
65
            my $credential = Koha::WebauthnCredentials->find( { credential_id => 'testcredentialid' } );
66
            $credential->delete;
67
            $credential = Koha::WebauthnCredentials->find( { credential_id => 'testcredentialid' } );
68
            ok( !$credential, 'Credential deleted' );
69
        };
70
71
    }
72
);
73
74
done_testing();
75
76
(-)a/t/db_dependent/api/v1/webauthn.t (-1 / +93 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/perl
2
3
use Modern::Perl;
4
use Test::More tests => 4;
5
use Test::NoWarnings;
6
7
use Koha::Database;
8
use t::lib::TestBuilder;
9
use t::lib::Mocks;
10
use Test::Mojo;
11
12
my $schema  = Koha::Database->new->schema;
13
my $builder = t::lib::TestBuilder->new;
14
15
subtest 'POST /api/v1/webauthn/register/challenge accepts patron_id or userid' => sub {
16
    plan tests => 6;
17
    $schema->storage->txn_begin;
18
19
    t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
20
    my $password  = 'AbcdEFG123';
21
    my $librarian = $builder->build_object(
22
        {
23
            class => 'Koha::Patrons',
24
            value => { userid => 'testuserapi1', flags => 2**2 }    # catalogue
25
        }
26
    );
27
    $librarian->set_password( { password => $password, skip_validation => 1 } );
28
    my $patron_id = $librarian->borrowernumber;
29
    my $userid    = $librarian->userid;
30
31
    my $t = Test::Mojo->new('Koha::REST::V1');
32
33
    $t->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json => { patron_id => $patron_id } )
34
        ->status_is(200)->json_has('/challenge');
35
36
    $t->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json => { userid => $userid } )
37
        ->status_is(200)->json_has('/challenge');
38
39
    $schema->storage->txn_rollback;
40
};
41
42
subtest 'POST /api/v1/webauthn/authenticate/challenge returns 404 without credentials' => sub {
43
    plan tests => 4;
44
    $schema->storage->txn_begin;
45
46
    t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
47
    my $password  = 'AbcdEFG123';
48
    my $librarian = $builder->build_object(
49
        {
50
            class => 'Koha::Patrons',
51
            value => { userid => 'testuserapi2', flags => 2**2 }    # catalogue
52
        }
53
    );
54
    $librarian->set_password( { password => $password, skip_validation => 1 } );
55
    my $patron_id = $librarian->borrowernumber;
56
    my $userid    = $librarian->userid;
57
58
    my $t = Test::Mojo->new('Koha::REST::V1');
59
60
    $t->post_ok( "//$userid:$password@/api/v1/webauthn/authenticate/challenge" => json => { patron_id => $patron_id } )
61
        ->status_is(404);
62
63
    $t->post_ok( "//$userid:$password@/api/v1/webauthn/authenticate/challenge" => json => { userid => $userid } )
64
        ->status_is(404);
65
66
    $schema->storage->txn_rollback;
67
};
68
69
subtest 'POST /api/v1/webauthn/register stores credential (mocked)' => sub {
70
    plan tests => 4;
71
    $schema->storage->txn_begin;
72
    t::lib::Mocks::mock_preference( 'RESTBasicAuth',      1 );
73
    t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'http://127.0.0.1' );
74
    my $password = 'AbcdEFG123';
75
    my $librarian =
76
        $builder->build_object( { class => 'Koha::Patrons', value => { userid => 'tuser3', flags => 2**2 } } );
77
    $librarian->set_password( { password => $password, skip_validation => 1 } );
78
    my $userid = $librarian->userid;
79
    my $t2     = Test::Mojo->new('Koha::REST::V1');
80
81
    # Request challenge
82
    $t2->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json =>
83
            { patron_id => $librarian->borrowernumber } )->status_is(200);
84
85
    # Post a dummy attestation that should be unauthorized by validator
86
    $t2->post_ok(
87
        "//$userid:$password@/api/v1/webauthn/register" => json => {
88
            patron_id            => $librarian->borrowernumber,
89
            attestation_response => { client_data_json => 'AA', attestation_object => 'AA' }
90
        }
91
    )->status_is(401);
92
    $schema->storage->txn_rollback;
93
};

Return to bug 39601