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

(-)a/Koha/Auth/WebAuthn.pm (+342 lines)
Line 0 Link Here
1
package Koha::Auth::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 <https://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
use Authen::WebAuthn;
20
use Bytes::Random::Secure;
21
use C4::Context;
22
use JSON qw(decode_json);
23
use Koha::Exceptions::WebAuthn;
24
use Koha::Patrons;
25
use MIME::Base64 qw(encode_base64 decode_base64);
26
use Mojo::URL;
27
28
use constant CHALLENGE_TTL_SECONDS => 600;    # 10 minutes
29
30
=head1 NAME
31
32
Koha::Auth::WebAuthn - WebAuthn (Passkey) authentication for Koha
33
34
=head1 SYNOPSIS
35
36
  use Koha::Auth::WebAuthn;
37
38
  my ($origin, $rp_id) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c);
39
  my $challenge = Koha::Auth::WebAuthn->generate_challenge();
40
  my ($patron, $patron_id) = Koha::Auth::WebAuthn->resolve_patron($body);
41
42
=head1 DESCRIPTION
43
44
This module encapsulates WebAuthn (Passkey) authentication logic for Koha,
45
using L<Authen::WebAuthn>. It provides origin/RP ID derivation, challenge
46
generation, patron resolution, session management, and base64url encoding
47
utilities.
48
49
=head2 Methods
50
51
=head3 resolve_patron
52
53
  my ($patron, $patron_id) = Koha::Auth::WebAuthn->resolve_patron($body);
54
55
Resolves a patron from a request body containing C<patron_id> or C<userid>.
56
Throws L<Koha::Exceptions::WebAuthn::PatronNotFound> if not found.
57
58
=cut
59
60
sub resolve_patron {
61
    my ( $class, $body ) = @_;
62
    my ( $patron, $patron_id );
63
64
    if ( $body->{patron_id} ) {
65
        $patron_id = $body->{patron_id};
66
        $patron    = Koha::Patrons->find($patron_id);
67
    } elsif ( $body->{userid} ) {
68
        $patron    = Koha::Patrons->find( { userid => $body->{userid} } );
69
        $patron_id = $patron ? $patron->borrowernumber : undef;
70
    }
71
72
    Koha::Exceptions::WebAuthn::PatronNotFound->throw() unless $patron;
73
    return ( $patron, $patron_id );
74
}
75
76
=head3 get_origin_and_rp_id
77
78
  my ($origin, $rp_id) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c);
79
80
Derives the WebAuthn origin and relying party ID from the StaffClientBaseURL
81
system preference and request headers (including reverse proxy headers).
82
83
Throws L<Koha::Exceptions::WebAuthn::ConfigMissing> if StaffClientBaseURL
84
is not configured.
85
86
=cut
87
88
sub get_origin_and_rp_id {
89
    my ( $class, $c ) = @_;
90
    my $pref_base = C4::Context->preference('StaffClientBaseURL');
91
92
    Koha::Exceptions::WebAuthn::ConfigMissing->throw(
93
        error => 'StaffClientBaseURL system preference must be configured for WebAuthn' )
94
        unless $pref_base;
95
96
    my $req_url = $c->req->url->to_abs;
97
98
    # Respect reverse proxy headers if present
99
    my $headers        = $c->req->headers;
100
    my $xf_proto       = $headers->header('X-Forwarded-Proto');
101
    my $xf_host_header = $headers->header('X-Forwarded-Host');
102
    my $xf_port_header = $headers->header('X-Forwarded-Port');
103
104
    # Effective request values
105
    my $req_scheme = $req_url->scheme // 'https';
106
    if ( defined $xf_proto && length $xf_proto ) {
107
        $req_scheme = lc $xf_proto;
108
    }
109
110
    my $req_host = $req_url->host;
111
    my $req_port = $req_url->port;
112
    if ( defined $xf_host_header && length $xf_host_header ) {
113
        my ($first_host) = split /\s*,\s*/, $xf_host_header, 2;
114
        if ($first_host) {
115
            if ( $first_host =~ /^(.*?):(\d+)$/ ) {
116
                $req_host = $1;
117
                $req_port = $2;
118
            } else {
119
                $req_host = $first_host;
120
            }
121
        }
122
    }
123
    if ( defined $xf_port_header && $xf_port_header =~ /^(\d+)$/ ) {
124
        $req_port = $1;
125
    }
126
127
    my ( $scheme, $host, $port ) = ( $req_scheme, $req_host, $req_port );
128
129
    my $pref        = Mojo::URL->new($pref_base);
130
    my $pref_scheme = $pref->scheme // 'https';
131
    my $pref_host   = $pref->host;
132
    my $pref_port   = $pref->port;
133
134
    # Always use the configured host for rp_id (domain requirement)
135
    $host = $pref_host if $pref_host;
136
137
    if ( defined $req_scheme && defined $pref_scheme && $req_host && $pref_host && $req_host eq $pref_host ) {
138
        if ( $req_scheme ne $pref_scheme ) {
139
140
            # Only allow HTTPS upgrades (http -> https). Prevent downgrades.
141
            if ( $pref_scheme eq 'http' && $req_scheme eq 'https' ) {
142
                $scheme = 'https';
143
                $port   = $req_port;
144
            } else {
145
                $scheme = $pref_scheme;
146
                $port   = defined $pref_port ? $pref_port : $req_port;
147
            }
148
        } else {
149
            $scheme = $pref_scheme;
150
            $port   = defined $pref_port ? $pref_port : $req_port;
151
        }
152
    } else {
153
154
        # Different host or no request scheme: stick to configured scheme/port
155
        $scheme = $pref_scheme;
156
        $port   = $pref_port;
157
    }
158
159
    if ( $host && $pref_host && lc($host) ne lc($pref_host) ) {
160
        $c->app->log->warn( "WebAuthn: request host '$host' does not match StaffClientBaseURL host '$pref_host'. "
161
                . 'This will likely cause origin validation failures.' );
162
    }
163
164
    my $origin = $scheme . '://' . $host . ( $port && $port !~ /^(80|443)$/ ? ':' . $port : '' );
165
    my $rp_id  = $host;
166
    return ( $origin, $rp_id );
167
}
168
169
=head3 maybe_upgrade_origin
170
171
  my $origin = Koha::Auth::WebAuthn->maybe_upgrade_origin($origin, $rp_id, $client_origin);
172
173
Allows HTTP to HTTPS origin upgrade when the client (browser) reports an
174
HTTPS origin but the server computed HTTP for the same host.
175
176
=cut
177
178
sub maybe_upgrade_origin {
179
    my ( $class, $origin, $rp_id, $client_origin ) = @_;
180
    return $origin unless $client_origin;
181
    my $co = Mojo::URL->new($client_origin);
182
    my $o  = Mojo::URL->new($origin);
183
184
    if ( lc( $co->host // '' ) eq lc($rp_id) && ( $o->scheme // '' ) eq 'http' && ( $co->scheme // '' ) eq 'https' ) {
185
        my $port = $co->port;
186
        my $new  = 'https://' . $rp_id . ( $port && $port !~ /^(80|443)$/ ? ':' . $port : '' );
187
        return $new;
188
    }
189
    return $origin;
190
}
191
192
=head3 generate_challenge
193
194
  my $challenge_b64u = Koha::Auth::WebAuthn->generate_challenge();
195
196
Generates a 32-byte cryptographic challenge encoded as base64url.
197
198
=cut
199
200
sub generate_challenge {
201
    my ($class)    = @_;
202
    my $randomizer = Bytes::Random::Secure->new( NonBlocking => 1 );
203
    my $bytes      = $randomizer->bytes(32);
204
    return _to_base64url($bytes);
205
}
206
207
=head3 store_challenge
208
209
  Koha::Auth::WebAuthn->store_challenge($c, $challenge_b64u, $patron_id);
210
211
Stores a WebAuthn challenge in the session along with the patron ID
212
and creation timestamp. Validates that the challenge is not empty.
213
214
=cut
215
216
sub store_challenge {
217
    my ( $class, $c, $challenge_b64u, $patron_id ) = @_;
218
    $c->session( webauthn_challenge    => $challenge_b64u );
219
    $c->session( webauthn_patron_id    => $patron_id );
220
    $c->session( webauthn_challenge_ts => time );
221
    return;
222
}
223
224
=head3 validate_and_consume_challenge
225
226
  my $challenge_b64u = Koha::Auth::WebAuthn->validate_and_consume_challenge($c, $patron_id);
227
228
Retrieves the stored challenge from the session, validates it has not
229
expired (10 minute TTL) and that the patron ID matches, then clears
230
the session data. Returns the challenge.
231
232
Throws L<Koha::Exceptions::WebAuthn::InvalidChallenge> or
233
L<Koha::Exceptions::WebAuthn::ChallengeMismatch> on failure.
234
235
=cut
236
237
sub validate_and_consume_challenge {
238
    my ( $class, $c, $patron_id ) = @_;
239
    my $challenge_b64u = $c->session('webauthn_challenge');
240
    my $stored_patron  = $c->session('webauthn_patron_id');
241
    my $challenge_ts   = $c->session('webauthn_challenge_ts');
242
243
    Koha::Exceptions::WebAuthn::InvalidChallenge->throw( error => 'No pending challenge in session' )
244
        unless $challenge_b64u;
245
246
    # Validate TTL
247
    if ( $challenge_ts && ( time - $challenge_ts ) > CHALLENGE_TTL_SECONDS ) {
248
        $class->_clear_challenge($c);
249
        Koha::Exceptions::WebAuthn::InvalidChallenge->throw( error => 'Challenge has expired' );
250
    }
251
252
    # Validate patron match (fail-closed: reject unless both are defined and match)
253
    unless ( defined $stored_patron && defined $patron_id && $stored_patron == $patron_id ) {
254
        $class->_clear_challenge($c);
255
        Koha::Exceptions::WebAuthn::ChallengeMismatch->throw( error => 'Session patron does not match request patron' );
256
    }
257
258
    # Consume: clear after use to prevent replay
259
    $class->_clear_challenge($c);
260
261
    return $challenge_b64u;
262
}
263
264
=head3 extract_client_origin
265
266
  my $client_origin = Koha::Auth::WebAuthn->extract_client_origin($client_data_json_b64);
267
268
Decodes the clientDataJSON from base64 and extracts the origin field.
269
Returns undef on failure.
270
271
=cut
272
273
sub extract_client_origin {
274
    my ( $class, $client_data_json_b64 ) = @_;
275
    return unless $client_data_json_b64;
276
    my $origin = eval {
277
        my $std_b64  = $class->b64url_to_std($client_data_json_b64);
278
        my $cdj_json = decode_base64($std_b64);
279
        my $cdj      = decode_json($cdj_json);
280
        $cdj->{origin};
281
    };
282
    return $origin;
283
}
284
285
=head3 std_b64_to_b64url
286
287
  my $b64u = Koha::Auth::WebAuthn->std_b64_to_b64url($b64);
288
289
Converts standard base64 to base64url encoding.
290
291
=cut
292
293
sub std_b64_to_b64url {
294
    my ( $class, $b64 ) = @_;
295
    $b64 =~ tr{+/}{-_};
296
    $b64 =~ s/=+$//;
297
    return $b64;
298
}
299
300
=head3 b64url_to_std
301
302
  my $b64 = Koha::Auth::WebAuthn->b64url_to_std($b64u);
303
304
Converts base64url to standard base64 encoding.
305
306
=cut
307
308
sub b64url_to_std {
309
    my ( $class, $b64u ) = @_;
310
    return unless defined $b64u;
311
    my $b64 = $b64u;
312
    $b64 =~ tr{-_}{+/};
313
    my $pad = ( 4 - ( length($b64) % 4 ) ) % 4;
314
    $b64 .= '=' x $pad;
315
    return $b64;
316
}
317
318
# Private methods
319
320
sub _to_base64url {
321
    my ($bytes) = @_;
322
    my $b64 = encode_base64( $bytes, '' );
323
    $b64 =~ tr{+/}{-_};
324
    $b64 =~ s/=+$//;
325
    return $b64;
326
}
327
328
sub _clear_challenge {
329
    my ( $class, $c ) = @_;
330
    delete $c->session->{webauthn_challenge};
331
    delete $c->session->{webauthn_patron_id};
332
    delete $c->session->{webauthn_challenge_ts};
333
    return;
334
}
335
336
=head1 AUTHOR
337
338
Koha Development Team
339
340
=cut
341
342
1;
(-)a/Koha/Exceptions/WebAuthn.pm (+88 lines)
Line 0 Link Here
1
package Koha::Exceptions::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 <https://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
20
use Koha::Exception;
21
22
use Exception::Class (
23
    'Koha::Exceptions::WebAuthn' => {
24
        isa => 'Koha::Exception',
25
    },
26
    'Koha::Exceptions::WebAuthn::ConfigMissing' => {
27
        isa         => 'Koha::Exceptions::WebAuthn',
28
        description => 'StaffClientBaseURL system preference not configured',
29
    },
30
    'Koha::Exceptions::WebAuthn::PatronNotFound' => {
31
        isa         => 'Koha::Exceptions::WebAuthn',
32
        description => 'Patron not found',
33
    },
34
    'Koha::Exceptions::WebAuthn::NoCredentials' => {
35
        isa         => 'Koha::Exceptions::WebAuthn',
36
        description => 'No credentials registered for patron',
37
    },
38
    'Koha::Exceptions::WebAuthn::InvalidChallenge' => {
39
        isa         => 'Koha::Exceptions::WebAuthn',
40
        description => 'Invalid or missing challenge',
41
    },
42
    'Koha::Exceptions::WebAuthn::ChallengeMismatch' => {
43
        isa         => 'Koha::Exceptions::WebAuthn',
44
        description => 'Session patron does not match request patron',
45
    },
46
    'Koha::Exceptions::WebAuthn::VerificationFailed' => {
47
        isa         => 'Koha::Exceptions::WebAuthn',
48
        description => 'WebAuthn verification failed',
49
    },
50
);
51
52
=head1 NAME
53
54
Koha::Exceptions::WebAuthn - Base class for WebAuthn exceptions
55
56
=head1 Exceptions
57
58
=head2 Koha::Exceptions::WebAuthn
59
60
Generic WebAuthn exception
61
62
=head2 Koha::Exceptions::WebAuthn::ConfigMissing
63
64
Exception for missing StaffClientBaseURL configuration
65
66
=head2 Koha::Exceptions::WebAuthn::PatronNotFound
67
68
Exception when patron cannot be resolved
69
70
=head2 Koha::Exceptions::WebAuthn::NoCredentials
71
72
Exception when patron has no registered credentials
73
74
=head2 Koha::Exceptions::WebAuthn::InvalidChallenge
75
76
Exception for missing or expired challenge
77
78
=head2 Koha::Exceptions::WebAuthn::ChallengeMismatch
79
80
Exception when session patron does not match request
81
82
=head2 Koha::Exceptions::WebAuthn::VerificationFailed
83
84
Exception when attestation or assertion verification fails
85
86
=cut
87
88
1;
(-)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 (+343 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 <https://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
use Mojo::Base 'Mojolicious::Controller';
20
use Try::Tiny;
21
use Authen::WebAuthn;
22
use C4::Auth qw(create_basic_session);
23
use C4::Context;
24
use JSON qw(encode_json);
25
use Koha::Auth::WebAuthn;
26
use Koha::DateUtils qw(dt_from_string);
27
use Koha::Exceptions::WebAuthn;
28
use Koha::Patrons;
29
use Koha::WebauthnCredential;
30
use Koha::WebauthnCredentials;
31
use MIME::Base64 qw(encode_base64 decode_base64);
32
33
=head1 NAME
34
35
Koha::REST::V1::Webauthn
36
37
=head1 API
38
39
=head2 Methods
40
41
=head3 register_challenge
42
43
Controller for POST /api/v1/webauthn/register/challenge
44
45
=cut
46
47
sub register_challenge {
48
    my $c = shift->openapi->valid_input or return;
49
    return try {
50
        my $body = $c->req->json;
51
        my ( $patron, $patron_id ) = Koha::Auth::WebAuthn->resolve_patron($body);
52
53
        my ( $origin, $rp_id ) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c);
54
        my $challenge_b64u = Koha::Auth::WebAuthn->generate_challenge();
55
56
        Koha::Auth::WebAuthn->store_challenge( $c, $challenge_b64u, $patron_id );
57
58
        $c->render(
59
            openapi => {
60
                challenge => $challenge_b64u,
61
                rp_id     => $rp_id,
62
                user      => {
63
                    id   => $patron_id,
64
                    name => $patron->userid,
65
                },
66
            }
67
        );
68
    } catch {
69
        return _handle_exception( $c, $_ );
70
    };
71
}
72
73
=head3 register
74
75
Controller for POST /api/v1/webauthn/register
76
77
=cut
78
79
sub register {
80
    my $c = shift->openapi->valid_input or return;
81
    return try {
82
        my $body = $c->req->json;
83
        my ( $patron, $patron_id ) = Koha::Auth::WebAuthn->resolve_patron($body);
84
85
        my $att = $body->{attestation_response} // {};
86
        return $c->render( status => 400, openapi => { error => 'Missing attestation_response' } )
87
            unless $att->{client_data_json} && $att->{attestation_object};
88
89
        my ( $origin, $rp_id ) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c);
90
91
        # Allow http->https upgrade based on client-reported origin
92
        my $client_origin = Koha::Auth::WebAuthn->extract_client_origin( $att->{client_data_json} );
93
        $origin = Koha::Auth::WebAuthn->maybe_upgrade_origin( $origin, $rp_id, $client_origin );
94
95
        my $webauthn       = Authen::WebAuthn->new( rp_id => $rp_id, origin => $origin );
96
        my $challenge_b64u = Koha::Auth::WebAuthn->validate_and_consume_challenge( $c, $patron_id );
97
98
        my $res;
99
        try {
100
            $res = $webauthn->validate_registration(
101
                challenge_b64          => $challenge_b64u,
102
                requested_uv           => 'preferred',
103
                client_data_json_b64   => Koha::Auth::WebAuthn->std_b64_to_b64url( $att->{client_data_json} ),
104
                attestation_object_b64 => Koha::Auth::WebAuthn->std_b64_to_b64url( $att->{attestation_object} ),
105
            );
106
        } catch {
107
            $c->app->log->warn( 'WebAuthn register failed for patron ' . $patron_id . ': ' . $_ );
108
            Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Attestation verification failed' );
109
        };
110
        unless ($res) {
111
            $c->app->log->warn( 'WebAuthn register failed for patron ' . $patron_id . ': verification returned false' );
112
            Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Attestation verification failed' );
113
        }
114
115
        Koha::WebauthnCredential->new(
116
            {
117
                borrowernumber => $patron_id,
118
                credential_id  => MIME::Base64::decode_base64url( $res->{credential_id} ),
119
                public_key     => MIME::Base64::decode_base64url( $res->{credential_pubkey} ),
120
                sign_count     => $res->{signature_count} // 0,
121
                transports     => undef,
122
                created_date   => dt_from_string()->strftime('%F %T'),
123
            }
124
        )->store;
125
126
        $c->app->log->info( 'WebAuthn credential registered for patron ' . $patron_id );
127
        $c->render( status => 201, openapi => { success => 1 } );
128
    } catch {
129
        return _handle_exception( $c, $_ );
130
    };
131
}
132
133
=head3 authenticate_challenge
134
135
Controller for POST /api/v1/webauthn/authenticate/challenge
136
137
=cut
138
139
sub authenticate_challenge {
140
    my $c = shift->openapi->valid_input or return;
141
    return try {
142
        my $body = $c->req->json;
143
        my ( $patron, $patron_id ) = Koha::Auth::WebAuthn->resolve_patron($body);
144
145
        my $credentials = Koha::WebauthnCredentials->search( { borrowernumber => $patron_id } );
146
        Koha::Exceptions::WebAuthn::NoCredentials->throw() unless $credentials->count;
147
148
        my ( $origin, $rp_id ) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c);
149
        my $challenge_b64u = Koha::Auth::WebAuthn->generate_challenge();
150
151
        Koha::Auth::WebAuthn->store_challenge( $c, $challenge_b64u, $patron_id );
152
153
        my @allow_credentials;
154
        while ( my $cred = $credentials->next ) {
155
            push @allow_credentials,
156
                {
157
                id   => Koha::Auth::WebAuthn->std_b64_to_b64url( encode_base64( $cred->credential_id, '' ) ),
158
                type => 'public-key',
159
                };
160
        }
161
162
        $c->render(
163
            openapi => {
164
                challenge        => $challenge_b64u,
165
                rpId             => $rp_id,
166
                allowCredentials => \@allow_credentials,
167
            }
168
        );
169
    } catch {
170
        return _handle_exception( $c, $_ );
171
    };
172
}
173
174
=head3 authenticate
175
176
Controller for POST /api/v1/webauthn/authenticate
177
178
=cut
179
180
sub authenticate {
181
    my $c = shift->openapi->valid_input or return;
182
    return try {
183
        my $body = $c->req->json;
184
        my ( $patron, $patron_id ) = Koha::Auth::WebAuthn->resolve_patron($body);
185
186
        my $assertion = $body->{assertion_response};
187
        return $c->render( status => 400, openapi => { error => 'Missing assertion_response' } )
188
            unless $assertion;
189
190
        # Reject locked or expired accounts
191
        if ( $patron->account_locked ) {
192
            Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Account is locked' );
193
        }
194
195
        # Build credential lookup maps
196
        my $credentials = Koha::WebauthnCredentials->search( { borrowernumber => $patron_id } );
197
        my ( %cred_id_map, %pubkeys, %sign_counts );
198
        while ( my $cred = $credentials->next ) {
199
            my $id_b64u = Koha::Auth::WebAuthn->std_b64_to_b64url( encode_base64( $cred->credential_id, '' ) );
200
            $cred_id_map{$id_b64u} = $cred->webauthn_credential_id;
201
            $pubkeys{$id_b64u}     = Koha::Auth::WebAuthn->std_b64_to_b64url( encode_base64( $cred->public_key, '' ) );
202
            $sign_counts{$id_b64u} = $cred->sign_count // 0;
203
        }
204
205
        my $challenge_b64u = Koha::Auth::WebAuthn->validate_and_consume_challenge( $c, $patron_id );
206
        my ( $origin, $rp_id ) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c);
207
208
        # Browser sends base64 (we need base64url)
209
        my $client_data_b64u =
210
            Koha::Auth::WebAuthn->std_b64_to_b64url( $assertion->{clientDataJSON} // $assertion->{client_data_json}
211
                // '' );
212
213
        # Allow http->https upgrade based on client-reported origin
214
        my $client_origin =
215
            Koha::Auth::WebAuthn->extract_client_origin( $assertion->{clientDataJSON}
216
                // $assertion->{client_data_json} );
217
        $origin = Koha::Auth::WebAuthn->maybe_upgrade_origin( $origin, $rp_id, $client_origin );
218
219
        my $webauthn = Authen::WebAuthn->new( rp_id => $rp_id, origin => $origin );
220
        my $auth_data_b64u =
221
            Koha::Auth::WebAuthn->std_b64_to_b64url( $assertion->{authenticatorData}
222
                // $assertion->{authenticator_data} // '' );
223
        my $signature_b64u = Koha::Auth::WebAuthn->std_b64_to_b64url( $assertion->{signature} // '' );
224
        my $credential_id_b64u =
225
            Koha::Auth::WebAuthn->std_b64_to_b64url( $assertion->{id} // $assertion->{raw_id} // '' );
226
227
        # Verify credential exists for this patron
228
        my $credential_pubkey = $pubkeys{$credential_id_b64u};
229
        Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Unknown credential' )
230
            unless $credential_pubkey;
231
232
        my $stored_sign_count = $sign_counts{$credential_id_b64u} // 0;
233
        my $res;
234
        try {
235
            $res = $webauthn->validate_assertion(
236
                challenge_b64          => $challenge_b64u,
237
                credential_pubkey_b64  => $credential_pubkey,
238
                stored_sign_count      => $stored_sign_count,
239
                requested_uv           => 'preferred',
240
                client_data_json_b64   => $client_data_b64u,
241
                authenticator_data_b64 => $auth_data_b64u,
242
                signature_b64          => $signature_b64u,
243
            );
244
        } catch {
245
            $c->app->log->warn( 'WebAuthn authenticate failed for patron ' . $patron_id . ': ' . $_ );
246
            Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Authentication failed' );
247
        };
248
        unless ($res) {
249
            $c->app->log->warn(
250
                'WebAuthn authenticate failed for patron ' . $patron_id . ': verification returned false' );
251
            Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Authentication failed' );
252
        }
253
254
        # Update sign count
255
        if ( my $cred_pk = $cred_id_map{$credential_id_b64u} ) {
256
            if ( my $cred = Koha::WebauthnCredentials->find($cred_pk) ) {
257
                $cred->set(
258
                    {
259
                        sign_count     => $res->{signature_count} // 0,
260
                        last_used_date => dt_from_string()->strftime('%F %T'),
261
                    }
262
                )->store;
263
            }
264
        }
265
266
        # Issue staff session and cookie for login
267
        my $session  = create_basic_session( { patron => $patron, interface => 'intranet' } );
268
        my $is_https = ( $origin =~ m{^https://}i ) ? 1 : 0;
269
        $c->cookie(
270
            CGISESSID => $session->id,
271
            { path => '/', http_only => 1, same_site => 'Lax', secure => $is_https }
272
        );
273
        C4::Context->interface('intranet');
274
        my $lib = $patron->library;
275
        C4::Context->set_userenv(
276
            $patron->borrowernumber,
277
            $patron->userid     // '',
278
            $patron->cardnumber // '',
279
            $patron->firstname  // '',
280
            $patron->surname    // '',
281
            ( $lib ? $lib->branchcode : '' ),
282
            ( $lib ? $lib->branchname : '' ),
283
            $patron->flags,
284
            $patron->email // '',
285
        );
286
287
        $c->app->log->info( 'WebAuthn authentication successful for patron ' . $patron_id );
288
        $c->render( status => 200, openapi => { success => 1 } );
289
    } catch {
290
        return _handle_exception( $c, $_ );
291
    };
292
}
293
294
=head3 _handle_exception
295
296
Maps WebAuthn exceptions to appropriate HTTP responses.
297
298
=cut
299
300
sub _handle_exception {
301
    my ( $c, $err ) = @_;
302
303
    if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::ConfigMissing') ) {
304
        return $c->render(
305
            status  => 500,
306
            openapi => { error => $err->error // $err->description }
307
        );
308
    }
309
    if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::PatronNotFound') ) {
310
        return $c->render(
311
            status  => 404,
312
            openapi => { error => 'Patron not found' }
313
        );
314
    }
315
    if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::NoCredentials') ) {
316
        return $c->render(
317
            status  => 404,
318
            openapi => { error => 'No credentials registered' }
319
        );
320
    }
321
    if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::InvalidChallenge') ) {
322
        return $c->render(
323
            status  => 401,
324
            openapi => { error => $err->error // 'Invalid or expired challenge' }
325
        );
326
    }
327
    if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::ChallengeMismatch') ) {
328
        return $c->render(
329
            status  => 401,
330
            openapi => { error => 'Challenge patron mismatch' }
331
        );
332
    }
333
    if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::VerificationFailed') ) {
334
        return $c->render(
335
            status  => 401,
336
            openapi => { error => $err->error // 'Verification failed' }
337
        );
338
    }
339
340
    return $c->unhandled_exception($err);
341
}
342
343
1;
(-)a/Koha/WebauthnCredential.pm (+16 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
(-)a/Koha/WebauthnCredentials.pm (+26 lines)
Line 0 Link Here
1
package Koha::WebauthnCredentials;
2
3
use Modern::Perl;
4
use base qw(Koha::Objects);
5
6
=head1 NAME
7
8
Koha::WebauthnCredentials - Koha Objects class for webauthn_credentials
9
10
=head1 API
11
12
=head2 Internal methods
13
14
=head3 _type
15
16
=cut
17
18
sub _type { 'WebauthnCredential' }
19
20
=head3 object_class
21
22
=cut
23
24
sub object_class { 'Koha::WebauthnCredential' }
25
26
1;
(-)a/api/v1/swagger/paths/webauthn.yaml (+190 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
152
/webauthn/authenticate:
153
  post:
154
    x-mojo-to: Webauthn#authenticate
155
    operationId: webauthnAuthenticate
156
    tags:
157
      - webauthn
158
    summary: Complete WebAuthn authentication
159
    description: Receives and verifies the assertion response, then authenticates the patron.
160
    produces:
161
      - application/json
162
    parameters:
163
      - name: body
164
        in: body
165
        required: true
166
        schema:
167
          $ref: "../swagger.yaml#/definitions/WebauthnAuthenticationRequest"
168
    responses:
169
      "200":
170
        description: Authentication successful
171
      "400":
172
        description: "Bad request: Invalid assertion response"
173
        schema:
174
          $ref: "../swagger.yaml#/definitions/error"
175
      "401":
176
        description: Authentication failed
177
        schema:
178
          $ref: "../swagger.yaml#/definitions/error"
179
      "403":
180
        description: Forbidden
181
        schema:
182
          $ref: "../swagger.yaml#/definitions/error"
183
      "500":
184
        description: Internal server error
185
        schema:
186
          $ref: "../swagger.yaml#/definitions/error"
187
      "503":
188
        description: Under maintenance
189
        schema:
190
          $ref: "../swagger.yaml#/definitions/error"
(-)a/api/v1/swagger/swagger.yaml (+65 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
      rp_id:
11
        type: string
12
      user:
13
        type: object
14
        properties:
15
          id:
16
            type: integer
17
          name:
18
            type: string
19
  WebauthnAuthChallenge:
20
    type: object
21
    properties:
22
      challenge:
23
        type: string
24
      rpId:
25
        type: string
26
      allowCredentials:
27
        type: array
28
        items:
29
          type: object
30
          properties:
31
            id:
32
              type: string
33
            type:
34
              type: string
35
  WebauthnRegistrationRequest:
36
    type: object
37
    properties:
38
      patron_id:
39
        type: integer
40
      userid:
41
        type: string
42
      attestation_response:
43
        type: object
44
    required:
45
      - attestation_response
46
  WebauthnAuthenticationRequest:
47
    type: object
48
    properties:
49
      patron_id:
50
        type: integer
51
      userid:
52
        type: string
53
      assertion_response:
54
        type: object
55
    required:
56
      - assertion_response
57
5
  account_line:
58
  account_line:
6
    $ref: ./definitions/account_line.yaml
59
    $ref: ./definitions/account_line.yaml
7
  additional_content:
60
  additional_content:
Lines 635-640 paths: Link Here
635
    $ref: ./paths/transfer_limits.yaml#/~1transfer_limits~1batch
688
    $ref: ./paths/transfer_limits.yaml#/~1transfer_limits~1batch
636
  "/transfer_limits/{limit_id}":
689
  "/transfer_limits/{limit_id}":
637
    $ref: "./paths/transfer_limits.yaml#/~1transfer_limits~1{limit_id}"
690
    $ref: "./paths/transfer_limits.yaml#/~1transfer_limits~1{limit_id}"
691
  /webauthn/register/challenge:
692
    $ref: ./paths/webauthn.yaml#/~1webauthn~1register~1challenge
693
  /webauthn/register:
694
    $ref: ./paths/webauthn.yaml#/~1webauthn~1register
695
  /webauthn/authenticate/challenge:
696
    $ref: ./paths/webauthn.yaml#/~1webauthn~1authenticate~1challenge
697
  /webauthn/authenticate:
698
    $ref: ./paths/webauthn.yaml#/~1webauthn~1authenticate
699
638
parameters:
700
parameters:
639
  advancededitormacro_id_pp:
701
  advancededitormacro_id_pp:
640
    description: Advanced editor macro internal identifier
702
    description: Advanced editor macro internal identifier
Lines 1419-1421 tags: Link Here
1419
  - description: "Manage vendors configuration\n"
1481
  - description: "Manage vendors configuration\n"
1420
    name: vendors_config
1482
    name: vendors_config
1421
    x-displayName: Vendors configuration
1483
    x-displayName: Vendors configuration
1484
  - description: "Handle WebAuthn (passkey) registration and authentication\n"
1485
    name: webauthn
1486
    x-displayName: WebAuthn
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/auth-webauthn.inc (+9 lines)
Line 0 Link Here
1
[% USE raw %]
2
<div id="webauthn-login-section" class="mb-0">
3
    <button id="webauthn-login-btn" class="btn btn-secondary btn-lg" type="button" title="Use a passkey to sign in" aria-label="Sign in with a passkey"> <i class="fa fa-key"></i> <span class="d-none d-sm-inline">Passkey</span> </button>
4
    <noscript>
5
        <div class="alert alert-warning mt-2">Passkeys require JavaScript.</div>
6
    </noscript>
7
    [% USE Asset %]
8
    [% Asset.js('js/webauthn-login.js') | $raw %]
9
</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 (+33 lines)
Line 0 Link Here
1
[% USE raw %]
2
<!-- Passkey registration modal -->
3
<div id="passkey-register-section">
4
    <div class="modal fade" id="passkeyRegisterModal" tabindex="-1" aria-labelledby="passkeyRegisterTitle" aria-hidden="true">
5
        <div class="modal-dialog">
6
            <div class="modal-content">
7
                <div class="modal-header">
8
                    <h5 class="modal-title" id="passkeyRegisterTitle"><i class="fa fa-key"></i> Register a passkey</h5>
9
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
10
                </div>
11
                <div class="modal-body">
12
                    <p> Passkeys let staff sign in using the device (for example Touch ID, Face ID, Windows Hello, or a device PIN). They are more secure and phishing-resistant compared to passwords. </p>
13
                    <ul>
14
                        <li>We will create a passkey for this patron account.</li>
15
                        <li>At the staff login screen: enter the username, then click "Passkey".</li>
16
                        <li>Passkeys can sync via your platform account or password manager if enabled.</li>
17
                    </ul>
18
                    <div id="passkey-register-success-modal" class="alert alert-success" style="display:none"></div>
19
                    <div id="passkey-register-error-modal" class="alert alert-danger" style="display:none"></div>
20
                    <input type="hidden" id="passkey-patron-id" value="[% patron.borrowernumber | html %]" />
21
                    <input type="hidden" id="passkey-patron-userid" value="[% patron.userid | html %]" />
22
                </div>
23
                <div class="modal-footer">
24
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
25
                    <button type="button" class="btn btn-primary" id="passkey-register-confirm">Register passkey</button>
26
                    <button type="button" class="btn btn-success d-none" id="passkey-register-done" data-bs-dismiss="modal">Done</button>
27
                </div>
28
            </div>
29
        </div>
30
    </div>
31
</div>
32
[% USE Asset %]
33
[% 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 (+224 lines)
Line 0 Link Here
1
// Passkey registration script for patron page
2
3
(function () {
4
    "use strict";
5
6
    /**
7
     * Read patron_id and userid from hidden form fields.
8
     * @returns {{ patronId: string|null, userid: string|null }}
9
     */
10
    function getPatronContext() {
11
        const patronIdEl = document.getElementById("passkey-patron-id");
12
        const useridEl = document.getElementById("passkey-patron-userid");
13
        return {
14
            patronId: patronIdEl ? patronIdEl.value : null,
15
            userid: useridEl ? useridEl.value : null,
16
        };
17
    }
18
19
    /**
20
     * Decode a base64url string to a Uint8Array.
21
     * @param {string} b64u - base64url-encoded string
22
     * @returns {Uint8Array}
23
     */
24
    function b64uToBytes(b64u) {
25
        const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/");
26
        const pad = "=".repeat((4 - (b64.length % 4)) % 4);
27
        const str = window.atob(b64 + pad);
28
        const bytes = new Uint8Array(str.length);
29
        for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i);
30
        return bytes;
31
    }
32
33
    /**
34
     * Convert an ArrayBuffer to a standard base64 string.
35
     * Uses a loop instead of String.fromCharCode.apply to avoid
36
     * stack overflow on large buffers.
37
     * @param {ArrayBuffer} arrBuf
38
     * @returns {string}
39
     */
40
    function toBase64(arrBuf) {
41
        const bytes = new Uint8Array(arrBuf);
42
        let binary = "";
43
        for (let i = 0; i < bytes.length; i++) {
44
            binary += String.fromCharCode(bytes[i]);
45
        }
46
        return window.btoa(binary);
47
    }
48
49
    /**
50
     * Request a registration challenge from the server.
51
     * @param {{ patronId: string|null, userid: string|null }} ctx
52
     * @returns {Promise<Object>}
53
     */
54
    async function fetchChallenge(ctx) {
55
        const body = ctx.patronId
56
            ? { patron_id: Number(ctx.patronId) }
57
            : { userid: ctx.userid };
58
        const resp = await fetch("/api/v1/webauthn/register/challenge", {
59
            method: "POST",
60
            headers: { "Content-Type": "application/json" },
61
            credentials: "same-origin",
62
            body: JSON.stringify(body),
63
        });
64
        if (!resp.ok)
65
            throw new Error(__("Failed to get registration challenge"));
66
        return resp.json();
67
    }
68
69
    /**
70
     * Build the PublicKeyCredentialCreationOptions for navigator.credentials.create.
71
     * @param {Object} challengeJson - Server challenge response
72
     * @param {{ patronId: string|null, userid: string|null }} ctx
73
     * @returns {PublicKeyCredentialCreationOptions}
74
     */
75
    function buildPublicKeyOptions(challengeJson, ctx) {
76
        const userIdBytes = new TextEncoder().encode(
77
            String(ctx.patronId || ctx.userid)
78
        );
79
        return {
80
            challenge: b64uToBytes(challengeJson.challenge),
81
            rp: { name: "Koha", id: challengeJson.rp_id || undefined },
82
            user: {
83
                id: userIdBytes,
84
                name: ctx.userid || String(ctx.patronId),
85
                displayName: ctx.userid || String(ctx.patronId),
86
            },
87
            pubKeyCredParams: [
88
                { type: "public-key", alg: -7 },
89
                { type: "public-key", alg: -257 },
90
            ],
91
            timeout: 60000,
92
            attestation: "none",
93
        };
94
    }
95
96
    /**
97
     * @param {PublicKeyCredentialCreationOptions} publicKey
98
     * @returns {Promise<PublicKeyCredential>}
99
     */
100
    async function createCredential(publicKey) {
101
        return navigator.credentials.create({ publicKey });
102
    }
103
104
    /**
105
     * Submit the attestation response to the server.
106
     * @param {{ patronId: string|null, userid: string|null }} ctx
107
     * @param {PublicKeyCredential} credential
108
     */
109
    async function submitAttestation(ctx, credential) {
110
        const data = {
111
            attestation_response: {
112
                attestation_object: toBase64(
113
                    credential.response.attestationObject
114
                ),
115
                client_data_json: toBase64(credential.response.clientDataJSON),
116
                raw_id: toBase64(credential.rawId),
117
            },
118
        };
119
        if (ctx.patronId) data.patron_id = Number(ctx.patronId);
120
        if (ctx.userid) data.userid = ctx.userid;
121
122
        const resp = await fetch("/api/v1/webauthn/register", {
123
            method: "POST",
124
            headers: { "Content-Type": "application/json" },
125
            credentials: "same-origin",
126
            body: JSON.stringify(data),
127
        });
128
        if (!resp.ok) {
129
            const errTxt = await resp.text().catch(() => "");
130
            throw new Error(
131
                __x("Server rejected registration{suffix}", {
132
                    suffix: errTxt ? ": " + errTxt : "",
133
                })
134
            );
135
        }
136
    }
137
138
    /** Show success state in the registration modal. */
139
    function onSuccess() {
140
        const successEl = document.getElementById(
141
            "passkey-register-success-modal"
142
        );
143
        const confirmBtn = document.getElementById("passkey-register-confirm");
144
        const doneBtn = document.getElementById("passkey-register-done");
145
        if (successEl) {
146
            successEl.textContent = __("Passkey registered successfully.");
147
            successEl.style.display = "";
148
        }
149
        if (confirmBtn) confirmBtn.classList.add("d-none");
150
        if (doneBtn) doneBtn.classList.remove("d-none");
151
    }
152
153
    /**
154
     * Display a user-friendly error in the registration modal.
155
     * @param {Error} e
156
     */
157
    function onError(e) {
158
        const errorEl = document.getElementById("passkey-register-error-modal");
159
        if (!errorEl) return;
160
        let msg;
161
        if (e && e.name === "AbortError") {
162
            msg = __("Registration canceled");
163
        } else if (e && e.name === "NotAllowedError") {
164
            msg = __(
165
                "Passkey request was cancelled or timed out. Please try again."
166
            );
167
        } else if (e && e.name === "SecurityError") {
168
            msg = __(
169
                "Security error: the server origin does not match. Check StaffClientBaseURL configuration."
170
            );
171
        } else if (e && e.name === "InvalidStateError") {
172
            msg = __("This passkey is already registered.");
173
        } else {
174
            msg = e && e.message ? e.message : String(e);
175
        }
176
        errorEl.textContent = __x("Registration error: {msg}", {
177
            msg: msg,
178
        });
179
        errorEl.style.display = "";
180
    }
181
182
    /** Wire up the registration confirm button in the modal. */
183
    function attachHandlers() {
184
        const confirmBtn = document.getElementById("passkey-register-confirm");
185
        if (!confirmBtn) return;
186
187
        confirmBtn.addEventListener("click", async () => {
188
            // Feature detection
189
            if (!window.PublicKeyCredential) {
190
                onError(
191
                    new Error(__("Passkeys are not supported by this browser."))
192
                );
193
                return;
194
            }
195
            const ctx = getPatronContext();
196
            if (!ctx.patronId && !ctx.userid) {
197
                onError(new Error(__("Cannot determine patron context")));
198
                return;
199
            }
200
            // Hide messages
201
            const successEl = document.getElementById(
202
                "passkey-register-success-modal"
203
            );
204
            const errorEl = document.getElementById(
205
                "passkey-register-error-modal"
206
            );
207
            if (successEl) successEl.style.display = "none";
208
            if (errorEl) errorEl.style.display = "none";
209
210
            try {
211
                const challenge = await fetchChallenge(ctx);
212
                const publicKey = buildPublicKeyOptions(challenge, ctx);
213
                const credential = await createCredential(publicKey);
214
                if (!credential) throw new Error(__("No credential created"));
215
                await submitAttestation(ctx, credential);
216
                onSuccess();
217
            } catch (e) {
218
                onError(e);
219
            }
220
        });
221
    }
222
223
    document.addEventListener("DOMContentLoaded", attachHandlers);
224
})();
(-)a/koha-tmpl/intranet-tmpl/prog/js/webauthn-login.js (+212 lines)
Line 0 Link Here
1
// WebAuthn login script for Koha staff interface
2
3
(function () {
4
    "use strict";
5
6
    /**
7
     * Convert an ArrayBuffer to a standard base64 string.
8
     * Uses a loop instead of String.fromCharCode.apply to avoid
9
     * stack overflow on large buffers.
10
     * @param {ArrayBuffer} arrBuf
11
     * @returns {string}
12
     */
13
    function toBase64(arrBuf) {
14
        const bytes = new Uint8Array(arrBuf);
15
        let binary = "";
16
        for (let i = 0; i < bytes.length; i++) {
17
            binary += String.fromCharCode(bytes[i]);
18
        }
19
        return window.btoa(binary);
20
    }
21
22
    /**
23
     * Decode a base64url string to a Uint8Array.
24
     * @param {string} b64u - base64url-encoded string
25
     * @returns {Uint8Array}
26
     */
27
    function b64uToBytes(b64u) {
28
        if (!b64u) return new Uint8Array();
29
        const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/");
30
        const pad = "=".repeat((4 - (b64.length % 4)) % 4);
31
        const str = window.atob(b64 + pad);
32
        const bytes = new Uint8Array(str.length);
33
        for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i);
34
        return bytes;
35
    }
36
37
    /**
38
     * Build the JSON payload identifying the patron for a login request.
39
     * @param {string} usernameValue - userid or numeric patron_id
40
     * @returns {Object|null}
41
     */
42
    function buildLoginPayload(usernameValue) {
43
        if (!usernameValue) return null;
44
        const isNumeric = /^\d+$/.test(usernameValue);
45
        return isNumeric
46
            ? { patron_id: usernameValue }
47
            : { userid: usernameValue };
48
    }
49
50
    /**
51
     * @returns {HTMLInputElement|null}
52
     */
53
    function getUsernameInput() {
54
        return document.getElementById("userid");
55
    }
56
57
    /**
58
     * @returns {HTMLElement|null}
59
     */
60
    function getErrorContainer() {
61
        return document.getElementById("webauthn-login-error");
62
    }
63
64
    /**
65
     * Request a WebAuthn authentication challenge from the server.
66
     * @param {Object} loginPayload
67
     * @returns {Promise<Object>}
68
     */
69
    async function requestChallenge(loginPayload) {
70
        const resp = await fetch("/api/v1/webauthn/authenticate/challenge", {
71
            method: "POST",
72
            headers: { "Content-Type": "application/json" },
73
            credentials: "same-origin",
74
            body: JSON.stringify(loginPayload),
75
        });
76
        if (!resp.ok) throw new Error(__("Failed to fetch WebAuthn challenge"));
77
        return resp.json();
78
    }
79
80
    /**
81
     * Invoke the browser WebAuthn API to get an assertion.
82
     * @param {Object} opts - Server challenge response
83
     * @returns {Promise<PublicKeyCredential>}
84
     */
85
    async function performAssertion(opts) {
86
        const publicKey = Object.assign({}, opts, {
87
            challenge: b64uToBytes(opts.challenge),
88
            allowCredentials: Array.isArray(opts.allowCredentials)
89
                ? opts.allowCredentials.map(cred =>
90
                      Object.assign({}, cred, {
91
                          id: b64uToBytes(cred.id),
92
                      })
93
                  )
94
                : undefined,
95
        });
96
        return navigator.credentials.get({ publicKey });
97
    }
98
99
    /**
100
     * Submit the assertion response to the server for verification.
101
     * @param {Object} loginPayload
102
     * @param {PublicKeyCredential} assertion
103
     */
104
    async function submitAssertion(loginPayload, assertion) {
105
        const data = {
106
            assertion_response: {
107
                id: toBase64(assertion.rawId),
108
                authenticatorData: toBase64(
109
                    assertion.response.authenticatorData
110
                ),
111
                clientDataJSON: toBase64(assertion.response.clientDataJSON),
112
                signature: toBase64(assertion.response.signature),
113
                userHandle: assertion.response.userHandle
114
                    ? toBase64(assertion.response.userHandle)
115
                    : null,
116
            },
117
        };
118
        if (loginPayload.userid) data.userid = loginPayload.userid;
119
        if (loginPayload.patron_id) data.patron_id = loginPayload.patron_id;
120
121
        const verifyResp = await fetch("/api/v1/webauthn/authenticate", {
122
            method: "POST",
123
            headers: { "Content-Type": "application/json" },
124
            credentials: "same-origin",
125
            body: JSON.stringify(data),
126
        });
127
        if (!verifyResp.ok) throw new Error(__("Authentication failed"));
128
    }
129
130
    /**
131
     * Attach the passkey login click handler to the given button.
132
     * @param {HTMLButtonElement} button
133
     */
134
    function attachClickHandler(button) {
135
        button.addEventListener("click", async () => {
136
            const errorEl = getErrorContainer();
137
            if (errorEl) errorEl.style.display = "none";
138
139
            // Guard: WebAuthn support
140
            if (!window.PublicKeyCredential) {
141
                if (errorEl) {
142
                    errorEl.textContent = __(
143
                        "Passkeys are not supported by this browser."
144
                    );
145
                    errorEl.style.display = "";
146
                }
147
                return;
148
            }
149
150
            const userInput = getUsernameInput();
151
            if (userInput && !userInput.value) {
152
                userInput.focus();
153
                if (errorEl) {
154
                    errorEl.textContent = __(
155
                        "Please enter your username or patron ID."
156
                    );
157
                    errorEl.style.display = "";
158
                }
159
                return;
160
            }
161
162
            const payload = buildLoginPayload(userInput ? userInput.value : "");
163
            if (!payload) {
164
                if (errorEl) {
165
                    errorEl.textContent = __(
166
                        "Please enter your username or patron ID."
167
                    );
168
                    errorEl.style.display = "";
169
                }
170
                return;
171
            }
172
173
            try {
174
                const opts = await requestChallenge(payload);
175
                const assertion = await performAssertion(opts);
176
                await submitAssertion(payload, assertion);
177
                window.location.assign("/cgi-bin/koha/mainpage.pl");
178
            } catch (e) {
179
                if (errorEl) {
180
                    let msg;
181
                    if (e && e.name === "NotAllowedError") {
182
                        msg = __(
183
                            "Passkey request was cancelled or timed out. Please try again."
184
                        );
185
                    } else if (e && e.name === "SecurityError") {
186
                        msg = __(
187
                            "Security error: the server origin does not match. Check StaffClientBaseURL configuration."
188
                        );
189
                    } else if (e && e.name === "InvalidStateError") {
190
                        msg = __(
191
                            "This passkey is already registered for another account."
192
                        );
193
                    } else {
194
                        msg = __x("WebAuthn error: {error}", {
195
                            error: String(e),
196
                        });
197
                    }
198
                    errorEl.textContent = msg;
199
                    errorEl.style.display = "";
200
                }
201
            }
202
        });
203
    }
204
205
    function initialize() {
206
        const btn = document.getElementById("webauthn-login-btn");
207
        if (!btn) return;
208
        attachClickHandler(btn);
209
    }
210
211
    document.addEventListener("DOMContentLoaded", initialize);
212
})();
(-)a/members/moremember.pl (+7 lines)
Lines 80-85 output_and_exit_if_error( Link Here
80
my $category      = $patron->category;
80
my $category      = $patron->category;
81
my $category_type = $category->category_type;
81
my $category_type = $category->category_type;
82
82
83
if ( $patron->borrowernumber eq C4::Context->preference("AnonymousPatron") ) {
84
    $template->param( is_anonymous => 1 );
85
} else {
86
    $template->param( is_anonymous => 0 );
87
}
88
83
for (qw(gonenoaddress lost borrowernotes is_debarred)) {
89
for (qw(gonenoaddress lost borrowernotes is_debarred)) {
84
    $patron->$_ and $template->param( flagged => 1 ) and last;
90
    $patron->$_ and $template->param( flagged => 1 ) and last;
85
}
91
}
Lines 113-118 if (@guarantors) { Link Here
113
$template->param(
119
$template->param(
114
    guarantor_relationships => $guarantor_relationships,
120
    guarantor_relationships => $guarantor_relationships,
115
    guarantees              => \@guarantees,
121
    guarantees              => \@guarantees,
122
    loggedinuser            => $logged_in_user,
116
);
123
);
117
if (    C4::Context->preference('ChildNeedsGuarantor')
124
if (    C4::Context->preference('ChildNeedsGuarantor')
118
    and ( $patron->is_child or $category->can_be_guarantee )
125
    and ( $patron->is_child or $category->can_be_guarantee )
(-)a/t/db_dependent/Koha/WebauthnCredentials.t (+132 lines)
Line 0 Link Here
1
#!/usr/bin/perl
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 <https://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
use Test::More tests => 5;
20
use Test::NoWarnings;
21
use C4::Context;
22
use Koha::WebauthnCredential;
23
use Koha::WebauthnCredentials;
24
use Koha::Database;
25
use t::lib::TestBuilder;
26
27
my $schema  = Koha::Database->new->schema;
28
my $builder = t::lib::TestBuilder->new;
29
30
subtest 'add and retrieve credential' => sub {
31
    plan tests => 6;
32
    $schema->storage->txn_begin;
33
34
    my $borrower       = $builder->build_object( { class => 'Koha::Patrons' } );
35
    my $borrowernumber = $borrower->borrowernumber;
36
37
    my $credential_id = 'testcredentialid';
38
    my $public_key    = 'testpublickey';
39
    my $sign_count    = 1;
40
    my $transports    = 'usb,nfc';
41
    my $nickname      = 'Test Key';
42
43
    my $credential = Koha::WebauthnCredential->new(
44
        {
45
            borrowernumber => $borrowernumber,
46
            credential_id  => $credential_id,
47
            public_key     => $public_key,
48
            sign_count     => $sign_count,
49
            transports     => $transports,
50
            nickname       => $nickname,
51
            created_date   => '2020-01-01 00:00:00',
52
        }
53
    )->store;
54
    ok( $credential && $credential->webauthn_credential_id, 'Credential added' );
55
56
    my $cred = Koha::WebauthnCredentials->find( { credential_id => $credential_id } );
57
    ok( $cred, 'Credential found by credential_id' );
58
    is( $cred->borrowernumber, $borrowernumber, 'Borrowernumber matches' );
59
    is( $cred->public_key,     $public_key,     'Public key matches' );
60
    is( $cred->sign_count,     $sign_count,     'Sign count matches' );
61
    is( $cred->nickname,       $nickname,       'Nickname matches' );
62
63
    $schema->storage->txn_rollback;
64
};
65
66
subtest 'search by borrowernumber' => sub {
67
    plan tests => 1;
68
    $schema->storage->txn_begin;
69
70
    my $borrower       = $builder->build_object( { class => 'Koha::Patrons' } );
71
    my $borrowernumber = $borrower->borrowernumber;
72
73
    Koha::WebauthnCredential->new(
74
        {
75
            borrowernumber => $borrowernumber,
76
            credential_id  => 'search_test_cred',
77
            public_key     => 'testpubkey',
78
            sign_count     => 0,
79
        }
80
    )->store;
81
82
    my $creds = Koha::WebauthnCredentials->search( { borrowernumber => $borrowernumber } );
83
    ok( $creds->count >= 1, 'At least one credential for borrower' );
84
85
    $schema->storage->txn_rollback;
86
};
87
88
subtest 'update sign_count' => sub {
89
    plan tests => 1;
90
    $schema->storage->txn_begin;
91
92
    my $borrower = $builder->build_object( { class => 'Koha::Patrons' } );
93
94
    Koha::WebauthnCredential->new(
95
        {
96
            borrowernumber => $borrower->borrowernumber,
97
            credential_id  => 'update_test_cred',
98
            public_key     => 'testpubkey',
99
            sign_count     => 0,
100
        }
101
    )->store;
102
103
    my $credential = Koha::WebauthnCredentials->find( { credential_id => 'update_test_cred' } );
104
    $credential->set( { sign_count => 42 } )->store;
105
    $credential = Koha::WebauthnCredentials->find( { credential_id => 'update_test_cred' } );
106
    is( $credential->sign_count, 42, 'Sign count updated' );
107
108
    $schema->storage->txn_rollback;
109
};
110
111
subtest 'delete credential' => sub {
112
    plan tests => 1;
113
    $schema->storage->txn_begin;
114
115
    my $borrower = $builder->build_object( { class => 'Koha::Patrons' } );
116
117
    Koha::WebauthnCredential->new(
118
        {
119
            borrowernumber => $borrower->borrowernumber,
120
            credential_id  => 'delete_test_cred',
121
            public_key     => 'testpubkey',
122
            sign_count     => 0,
123
        }
124
    )->store;
125
126
    my $credential = Koha::WebauthnCredentials->find( { credential_id => 'delete_test_cred' } );
127
    $credential->delete;
128
    $credential = Koha::WebauthnCredentials->find( { credential_id => 'delete_test_cred' } );
129
    ok( !$credential, 'Credential deleted' );
130
131
    $schema->storage->txn_rollback;
132
};
(-)a/t/db_dependent/api/v1/webauthn.t (-1 / +167 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/perl
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 <https://www.gnu.org/licenses>.
17
18
use Modern::Perl;
19
use Test::More tests => 6;
20
use Test::NoWarnings;
21
22
use Koha::Database;
23
use t::lib::TestBuilder;
24
use t::lib::Mocks;
25
use Test::Mojo;
26
27
my $schema  = Koha::Database->new->schema;
28
my $builder = t::lib::TestBuilder->new;
29
30
subtest 'POST /api/v1/webauthn/register/challenge accepts patron_id or userid' => sub {
31
    plan tests => 8;
32
    $schema->storage->txn_begin;
33
34
    t::lib::Mocks::mock_preference( 'RESTBasicAuth',      1 );
35
    t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'https://127.0.0.1' );
36
    my $password  = 'AbcdEFG123';
37
    my $librarian = $builder->build_object(
38
        {
39
            class => 'Koha::Patrons',
40
            value => { userid => 'testuserapi1', flags => 2**2 }    # catalogue
41
        }
42
    );
43
    $librarian->set_password( { password => $password, skip_validation => 1 } );
44
    my $patron_id = $librarian->borrowernumber;
45
    my $userid    = $librarian->userid;
46
47
    my $t = Test::Mojo->new('Koha::REST::V1');
48
49
    $t->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json => { patron_id => $patron_id } )
50
        ->status_is(200)
51
        ->json_has('/challenge')
52
        ->json_has('/rp_id');
53
54
    $t->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json => { userid => $userid } )
55
        ->status_is(200)
56
        ->json_has('/challenge')
57
        ->json_has('/rp_id');
58
59
    $schema->storage->txn_rollback;
60
};
61
62
subtest 'POST /api/v1/webauthn/authenticate/challenge returns 404 without credentials' => sub {
63
    plan tests => 4;
64
    $schema->storage->txn_begin;
65
66
    t::lib::Mocks::mock_preference( 'RESTBasicAuth',      1 );
67
    t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'https://127.0.0.1' );
68
    my $password  = 'AbcdEFG123';
69
    my $librarian = $builder->build_object(
70
        {
71
            class => 'Koha::Patrons',
72
            value => { userid => 'testuserapi2', flags => 2**2 }    # catalogue
73
        }
74
    );
75
    $librarian->set_password( { password => $password, skip_validation => 1 } );
76
    my $patron_id = $librarian->borrowernumber;
77
    my $userid    = $librarian->userid;
78
79
    my $t = Test::Mojo->new('Koha::REST::V1');
80
81
    $t->post_ok( "//$userid:$password@/api/v1/webauthn/authenticate/challenge" => json => { patron_id => $patron_id } )
82
        ->status_is(404);
83
84
    $t->post_ok( "//$userid:$password@/api/v1/webauthn/authenticate/challenge" => json => { userid => $userid } )
85
        ->status_is(404);
86
87
    $schema->storage->txn_rollback;
88
};
89
90
subtest 'POST /api/v1/webauthn/register stores credential (mocked)' => sub {
91
    plan tests => 4;
92
    $schema->storage->txn_begin;
93
    t::lib::Mocks::mock_preference( 'RESTBasicAuth',      1 );
94
    t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'https://127.0.0.1' );
95
    my $password = 'AbcdEFG123';
96
    my $librarian =
97
        $builder->build_object( { class => 'Koha::Patrons', value => { userid => 'tuser3', flags => 2**2 } } );
98
    $librarian->set_password( { password => $password, skip_validation => 1 } );
99
    my $userid = $librarian->userid;
100
    my $t2     = Test::Mojo->new('Koha::REST::V1');
101
102
    # Request challenge
103
    $t2->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json =>
104
            { patron_id => $librarian->borrowernumber } )->status_is(200);
105
106
    # Post a dummy attestation that should be unauthorized by validator
107
    $t2->post_ok(
108
        "//$userid:$password@/api/v1/webauthn/register" => json => {
109
            patron_id            => $librarian->borrowernumber,
110
            attestation_response => { client_data_json => 'AA', attestation_object => 'AA' }
111
        }
112
    )->status_is(401);
113
    $schema->storage->txn_rollback;
114
};
115
116
subtest 'POST /api/v1/webauthn/register/challenge returns 404 for non-existent patron' => sub {
117
    plan tests => 2;
118
    $schema->storage->txn_begin;
119
    t::lib::Mocks::mock_preference( 'RESTBasicAuth',      1 );
120
    t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'https://127.0.0.1' );
121
    my $password  = 'AbcdEFG123';
122
    my $librarian = $builder->build_object(
123
        {
124
            class => 'Koha::Patrons',
125
            value => { userid => 'testuserapi4', flags => 2**2 }
126
        }
127
    );
128
    $librarian->set_password( { password => $password, skip_validation => 1 } );
129
    my $userid = $librarian->userid;
130
    my $t      = Test::Mojo->new('Koha::REST::V1');
131
132
    $t->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json => { patron_id => 999999999 } )
133
        ->status_is(404);
134
135
    $schema->storage->txn_rollback;
136
};
137
138
subtest 'POST /api/v1/webauthn/register rejects missing attestation fields' => sub {
139
    plan tests => 4;
140
    $schema->storage->txn_begin;
141
    t::lib::Mocks::mock_preference( 'RESTBasicAuth',      1 );
142
    t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'https://127.0.0.1' );
143
    my $password  = 'AbcdEFG123';
144
    my $librarian = $builder->build_object(
145
        {
146
            class => 'Koha::Patrons',
147
            value => { userid => 'testuserapi5', flags => 2**2 }
148
        }
149
    );
150
    $librarian->set_password( { password => $password, skip_validation => 1 } );
151
    my $userid = $librarian->userid;
152
    my $t      = Test::Mojo->new('Koha::REST::V1');
153
154
    # First get a challenge so session is populated
155
    $t->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json =>
156
            { patron_id => $librarian->borrowernumber } )->status_is(200);
157
158
    # Missing attestation fields
159
    $t->post_ok(
160
        "//$userid:$password@/api/v1/webauthn/register" => json => {
161
            patron_id            => $librarian->borrowernumber,
162
            attestation_response => {}
163
        }
164
    )->status_is(400);
165
166
    $schema->storage->txn_rollback;
167
};

Return to bug 39601