Bugzilla – Attachment 194697 Details for
Bug 39601
Add passkey support to Koha as an authentication mechanism
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 39601: Add staff passkeys (WebAuthn) support
Bug-39601-Add-staff-passkeys-WebAuthn-support.patch (text/plain), 77.48 KB, created by
Paul Derscheid
on 2026-03-06 12:23:44 UTC
(
hide
)
Description:
Bug 39601: Add staff passkeys (WebAuthn) support
Filename:
MIME Type:
Creator:
Paul Derscheid
Created:
2026-03-06 12:23:44 UTC
Size:
77.48 KB
patch
obsolete
>From 27a2538ba8829a13410ec8bac866b0b25af654d5 Mon Sep 17 00:00:00 2001 >From: Paul Derscheid <paul.derscheid@lmscloud.de> >Date: Fri, 6 Mar 2026 11:24:41 +0100 >Subject: [PATCH] Bug 39601: Add staff passkeys (WebAuthn) support > >Introduce staff passkey (WebAuthn) registration and authentication in Koha. >Provides REST endpoints, persistence, UI hooks, and session integration so >staff can register a passkey against a patron record and authenticate on the >staff login screen using platform authenticators. > >Implementation: >- New helper module Koha::Auth::WebAuthn encapsulating origin/RP ID > derivation, challenge generation and validation, patron resolution, > base64url encoding utilities, and session management >- New controller Koha::REST::V1::Webauthn with endpoints: > - POST /api/v1/webauthn/register/challenge > - POST /api/v1/webauthn/register > - POST /api/v1/webauthn/authenticate/challenge > - POST /api/v1/webauthn/authenticate >- New typed exception classes in Koha::Exceptions::WebAuthn >- Use Authen::WebAuthn validate_registration/validate_assertion >- Generate cryptographically secure challenges via Bytes::Random::Secure; > store challenge, patron_id and timestamp in the session with 10-minute TTL > and consume-after-use to prevent replay >- Handle base64url consistently for WebAuthn fields; convert to/from standard > base64 at the API boundary >- Derive origin and rp_id from StaffClientBaseURL (or the request URL) to > enforce correct WebAuthn origins; support reverse proxy headers and > http->https origin upgrade >- Persist credentials (credential_id, public_key as raw bytes) in the new > webauthn_credentials table; update sign_count and last_used_date after > successful authentication >- Build allowCredentials from stored credential IDs for authentication > challenges >- On successful authentication, verify account is not locked, issue a staff > session and set the CGISESSID cookie so the user is logged in to the staff > interface > >Security: >- Challenge TTL (10 min) and single-use consumption prevent replay attacks >- Fail-closed patron match guard rejects if either patron ID is undefined >- Explicit credential pubkey existence check before assertion validation >- account_locked check before issuing session >- $is_https derived from already-upgraded origin (not recomputed) > >API: >- Add api/v1/swagger/paths/webauthn.yaml defining the WebAuthn endpoints, > request/response schemas (including rp_id/rpId, allowCredentials) >- Register endpoints require catalogue permission >- Authenticate endpoints are public (no x-koha-authorization) since they > are login endpoints > >DB: >- Add webauthn_credentials table via installer/data/mysql/atomicupdate/ > bug_39601_add_passkey_support.pl and kohastructure.sql >- PK: webauthn_credential_id (per SQL7), COLLATE=utf8mb4_unicode_ci, > column COMMENTs, ON UPDATE CASCADE, VARBINARY(1024) for credential_id >- Date columns: created_date (timestamp), last_used_date (datetime) > per SQL14 >- Add Koha::WebauthnCredential(s) object classes > >UI: >- Staff login: add JS helper (auth-webauthn.inc) to request a challenge, > convert base64url to bytes, call navigator.credentials.get, send results > with credentials: "same-origin", and redirect to mainpage on success >- Patron page: add JS helper (passkey-register.inc) to request a registration > challenge, include RS256 in pubKeyCredParams for compatibility, convert > base64url to bytes, and submit attestation for storage >- Modern JS throughout (const/let, arrow functions, JSDoc, loop-based > toBase64 to avoid stack overflow on large buffers) >- aria-label on passkey login button for accessibility >- __x() for translatable strings with placeholders > >Tests: >- t/db_dependent/api/v1/webauthn.t: verify challenge endpoints accept > patron_id and userid; return 404 when no credentials; include a mocked > registration negative path; follow Koha testing conventions >- t/db_dependent/Koha/WebauthnCredentials.t: cover ORM add/search/update/ > delete with per-subtest transaction isolation > >Test plan: >1) Apply patches >2) Run database updates to create webauthn_credentials (updatedatabase). >3) Set StaffClientBaseURL to your staff URL (e.g., http://<name>-intra.localhost > when using ktd_proxy, haven't tested with unproxied ktd) > and ensure the staff interface is served over the same origin. >4) As a staff user, open a patron record and click Register Passkey from More. > Complete the OS-native passkey dialog. Verify a row is stored in > webauthn_credentials. > - This worked well in Zen (Firefox under the hood), less so with Chromium. > - Unsure whether ungoogled Chromium supports using the system password > manager, worked with a browser-based password manager, though. > - Best to test with many browsers! >5) Navigate to the staff login page and choose Sign in with passkey. Verify a > challenge is returned, the browser prompts, and you are logged into the > staff interface (redirect to mainpage). >6) Call authenticate_challenge for a patron without credentials and verify a > 404 response. >7) Run: > - prove t/db_dependent/Koha/WebauthnCredentials.t > - prove t/db_dependent/api/v1/webauthn.t >8) Sign off or review and FQA. >--- > Koha/Auth/WebAuthn.pm | 342 +++++++++++++++++ > Koha/Exceptions/WebAuthn.pm | 88 +++++ > Koha/REST/V1/Auth.pm | 5 +- > Koha/REST/V1/Webauthn.pm | 343 ++++++++++++++++++ > Koha/WebauthnCredential.pm | 16 + > Koha/WebauthnCredentials.pm | 26 ++ > api/v1/swagger/paths/webauthn.yaml | 190 ++++++++++ > api/v1/swagger/swagger.yaml | 65 ++++ > .../prog/en/includes/auth-webauthn.inc | 9 + > .../prog/en/includes/members-toolbar.inc | 8 + > .../prog/en/includes/passkey-register.inc | 33 ++ > .../intranet-tmpl/prog/en/modules/auth.tt | 6 +- > .../intranet-tmpl/prog/js/passkey-register.js | 224 ++++++++++++ > .../intranet-tmpl/prog/js/webauthn-login.js | 212 +++++++++++ > members/moremember.pl | 7 + > t/db_dependent/Koha/WebauthnCredentials.t | 132 +++++++ > t/db_dependent/api/v1/webauthn.t | 167 +++++++++ > 17 files changed, 1870 insertions(+), 3 deletions(-) > create mode 100644 Koha/Auth/WebAuthn.pm > create mode 100644 Koha/Exceptions/WebAuthn.pm > create mode 100644 Koha/REST/V1/Webauthn.pm > create mode 100644 Koha/WebauthnCredential.pm > create mode 100644 Koha/WebauthnCredentials.pm > create mode 100644 api/v1/swagger/paths/webauthn.yaml > create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/auth-webauthn.inc > create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/passkey-register.inc > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/passkey-register.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/webauthn-login.js > create mode 100755 t/db_dependent/Koha/WebauthnCredentials.t > create mode 100755 t/db_dependent/api/v1/webauthn.t > >diff --git a/Koha/Auth/WebAuthn.pm b/Koha/Auth/WebAuthn.pm >new file mode 100644 >index 00000000000..456e1adba34 >--- /dev/null >+++ b/Koha/Auth/WebAuthn.pm >@@ -0,0 +1,342 @@ >+package Koha::Auth::WebAuthn; >+ >+# This file is part of Koha. >+# >+# Koha is free software; you can redistribute it and/or modify it >+# under the terms of the GNU General Public License as published by >+# the Free Software Foundation; either version 3 of the License, or >+# (at your option) any later version. >+# >+# Koha is distributed in the hope that it will be useful, but >+# WITHOUT ANY WARRANTY; without even the implied warranty of >+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the >+# GNU General Public License for more details. >+# >+# You should have received a copy of the GNU General Public License >+# along with Koha; if not, see <https://www.gnu.org/licenses>. >+ >+use Modern::Perl; >+use Authen::WebAuthn; >+use Bytes::Random::Secure; >+use C4::Context; >+use JSON qw(decode_json); >+use Koha::Exceptions::WebAuthn; >+use Koha::Patrons; >+use MIME::Base64 qw(encode_base64 decode_base64); >+use Mojo::URL; >+ >+use constant CHALLENGE_TTL_SECONDS => 600; # 10 minutes >+ >+=head1 NAME >+ >+Koha::Auth::WebAuthn - WebAuthn (Passkey) authentication for Koha >+ >+=head1 SYNOPSIS >+ >+ use Koha::Auth::WebAuthn; >+ >+ my ($origin, $rp_id) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c); >+ my $challenge = Koha::Auth::WebAuthn->generate_challenge(); >+ my ($patron, $patron_id) = Koha::Auth::WebAuthn->resolve_patron($body); >+ >+=head1 DESCRIPTION >+ >+This module encapsulates WebAuthn (Passkey) authentication logic for Koha, >+using L<Authen::WebAuthn>. It provides origin/RP ID derivation, challenge >+generation, patron resolution, session management, and base64url encoding >+utilities. >+ >+=head2 Methods >+ >+=head3 resolve_patron >+ >+ my ($patron, $patron_id) = Koha::Auth::WebAuthn->resolve_patron($body); >+ >+Resolves a patron from a request body containing C<patron_id> or C<userid>. >+Throws L<Koha::Exceptions::WebAuthn::PatronNotFound> if not found. >+ >+=cut >+ >+sub resolve_patron { >+ my ( $class, $body ) = @_; >+ my ( $patron, $patron_id ); >+ >+ if ( $body->{patron_id} ) { >+ $patron_id = $body->{patron_id}; >+ $patron = Koha::Patrons->find($patron_id); >+ } elsif ( $body->{userid} ) { >+ $patron = Koha::Patrons->find( { userid => $body->{userid} } ); >+ $patron_id = $patron ? $patron->borrowernumber : undef; >+ } >+ >+ Koha::Exceptions::WebAuthn::PatronNotFound->throw() unless $patron; >+ return ( $patron, $patron_id ); >+} >+ >+=head3 get_origin_and_rp_id >+ >+ my ($origin, $rp_id) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c); >+ >+Derives the WebAuthn origin and relying party ID from the StaffClientBaseURL >+system preference and request headers (including reverse proxy headers). >+ >+Throws L<Koha::Exceptions::WebAuthn::ConfigMissing> if StaffClientBaseURL >+is not configured. >+ >+=cut >+ >+sub get_origin_and_rp_id { >+ my ( $class, $c ) = @_; >+ my $pref_base = C4::Context->preference('StaffClientBaseURL'); >+ >+ Koha::Exceptions::WebAuthn::ConfigMissing->throw( >+ error => 'StaffClientBaseURL system preference must be configured for WebAuthn' ) >+ unless $pref_base; >+ >+ my $req_url = $c->req->url->to_abs; >+ >+ # Respect reverse proxy headers if present >+ my $headers = $c->req->headers; >+ my $xf_proto = $headers->header('X-Forwarded-Proto'); >+ my $xf_host_header = $headers->header('X-Forwarded-Host'); >+ my $xf_port_header = $headers->header('X-Forwarded-Port'); >+ >+ # Effective request values >+ my $req_scheme = $req_url->scheme // 'https'; >+ if ( defined $xf_proto && length $xf_proto ) { >+ $req_scheme = lc $xf_proto; >+ } >+ >+ my $req_host = $req_url->host; >+ my $req_port = $req_url->port; >+ if ( defined $xf_host_header && length $xf_host_header ) { >+ my ($first_host) = split /\s*,\s*/, $xf_host_header, 2; >+ if ($first_host) { >+ if ( $first_host =~ /^(.*?):(\d+)$/ ) { >+ $req_host = $1; >+ $req_port = $2; >+ } else { >+ $req_host = $first_host; >+ } >+ } >+ } >+ if ( defined $xf_port_header && $xf_port_header =~ /^(\d+)$/ ) { >+ $req_port = $1; >+ } >+ >+ my ( $scheme, $host, $port ) = ( $req_scheme, $req_host, $req_port ); >+ >+ my $pref = Mojo::URL->new($pref_base); >+ my $pref_scheme = $pref->scheme // 'https'; >+ my $pref_host = $pref->host; >+ my $pref_port = $pref->port; >+ >+ # Always use the configured host for rp_id (domain requirement) >+ $host = $pref_host if $pref_host; >+ >+ if ( defined $req_scheme && defined $pref_scheme && $req_host && $pref_host && $req_host eq $pref_host ) { >+ if ( $req_scheme ne $pref_scheme ) { >+ >+ # Only allow HTTPS upgrades (http -> https). Prevent downgrades. >+ if ( $pref_scheme eq 'http' && $req_scheme eq 'https' ) { >+ $scheme = 'https'; >+ $port = $req_port; >+ } else { >+ $scheme = $pref_scheme; >+ $port = defined $pref_port ? $pref_port : $req_port; >+ } >+ } else { >+ $scheme = $pref_scheme; >+ $port = defined $pref_port ? $pref_port : $req_port; >+ } >+ } else { >+ >+ # Different host or no request scheme: stick to configured scheme/port >+ $scheme = $pref_scheme; >+ $port = $pref_port; >+ } >+ >+ if ( $host && $pref_host && lc($host) ne lc($pref_host) ) { >+ $c->app->log->warn( "WebAuthn: request host '$host' does not match StaffClientBaseURL host '$pref_host'. " >+ . 'This will likely cause origin validation failures.' ); >+ } >+ >+ my $origin = $scheme . '://' . $host . ( $port && $port !~ /^(80|443)$/ ? ':' . $port : '' ); >+ my $rp_id = $host; >+ return ( $origin, $rp_id ); >+} >+ >+=head3 maybe_upgrade_origin >+ >+ my $origin = Koha::Auth::WebAuthn->maybe_upgrade_origin($origin, $rp_id, $client_origin); >+ >+Allows HTTP to HTTPS origin upgrade when the client (browser) reports an >+HTTPS origin but the server computed HTTP for the same host. >+ >+=cut >+ >+sub maybe_upgrade_origin { >+ my ( $class, $origin, $rp_id, $client_origin ) = @_; >+ return $origin unless $client_origin; >+ my $co = Mojo::URL->new($client_origin); >+ my $o = Mojo::URL->new($origin); >+ >+ if ( lc( $co->host // '' ) eq lc($rp_id) && ( $o->scheme // '' ) eq 'http' && ( $co->scheme // '' ) eq 'https' ) { >+ my $port = $co->port; >+ my $new = 'https://' . $rp_id . ( $port && $port !~ /^(80|443)$/ ? ':' . $port : '' ); >+ return $new; >+ } >+ return $origin; >+} >+ >+=head3 generate_challenge >+ >+ my $challenge_b64u = Koha::Auth::WebAuthn->generate_challenge(); >+ >+Generates a 32-byte cryptographic challenge encoded as base64url. >+ >+=cut >+ >+sub generate_challenge { >+ my ($class) = @_; >+ my $randomizer = Bytes::Random::Secure->new( NonBlocking => 1 ); >+ my $bytes = $randomizer->bytes(32); >+ return _to_base64url($bytes); >+} >+ >+=head3 store_challenge >+ >+ Koha::Auth::WebAuthn->store_challenge($c, $challenge_b64u, $patron_id); >+ >+Stores a WebAuthn challenge in the session along with the patron ID >+and creation timestamp. Validates that the challenge is not empty. >+ >+=cut >+ >+sub store_challenge { >+ my ( $class, $c, $challenge_b64u, $patron_id ) = @_; >+ $c->session( webauthn_challenge => $challenge_b64u ); >+ $c->session( webauthn_patron_id => $patron_id ); >+ $c->session( webauthn_challenge_ts => time ); >+ return; >+} >+ >+=head3 validate_and_consume_challenge >+ >+ my $challenge_b64u = Koha::Auth::WebAuthn->validate_and_consume_challenge($c, $patron_id); >+ >+Retrieves the stored challenge from the session, validates it has not >+expired (10 minute TTL) and that the patron ID matches, then clears >+the session data. Returns the challenge. >+ >+Throws L<Koha::Exceptions::WebAuthn::InvalidChallenge> or >+L<Koha::Exceptions::WebAuthn::ChallengeMismatch> on failure. >+ >+=cut >+ >+sub validate_and_consume_challenge { >+ my ( $class, $c, $patron_id ) = @_; >+ my $challenge_b64u = $c->session('webauthn_challenge'); >+ my $stored_patron = $c->session('webauthn_patron_id'); >+ my $challenge_ts = $c->session('webauthn_challenge_ts'); >+ >+ Koha::Exceptions::WebAuthn::InvalidChallenge->throw( error => 'No pending challenge in session' ) >+ unless $challenge_b64u; >+ >+ # Validate TTL >+ if ( $challenge_ts && ( time - $challenge_ts ) > CHALLENGE_TTL_SECONDS ) { >+ $class->_clear_challenge($c); >+ Koha::Exceptions::WebAuthn::InvalidChallenge->throw( error => 'Challenge has expired' ); >+ } >+ >+ # Validate patron match (fail-closed: reject unless both are defined and match) >+ unless ( defined $stored_patron && defined $patron_id && $stored_patron == $patron_id ) { >+ $class->_clear_challenge($c); >+ Koha::Exceptions::WebAuthn::ChallengeMismatch->throw( error => 'Session patron does not match request patron' ); >+ } >+ >+ # Consume: clear after use to prevent replay >+ $class->_clear_challenge($c); >+ >+ return $challenge_b64u; >+} >+ >+=head3 extract_client_origin >+ >+ my $client_origin = Koha::Auth::WebAuthn->extract_client_origin($client_data_json_b64); >+ >+Decodes the clientDataJSON from base64 and extracts the origin field. >+Returns undef on failure. >+ >+=cut >+ >+sub extract_client_origin { >+ my ( $class, $client_data_json_b64 ) = @_; >+ return unless $client_data_json_b64; >+ my $origin = eval { >+ my $std_b64 = $class->b64url_to_std($client_data_json_b64); >+ my $cdj_json = decode_base64($std_b64); >+ my $cdj = decode_json($cdj_json); >+ $cdj->{origin}; >+ }; >+ return $origin; >+} >+ >+=head3 std_b64_to_b64url >+ >+ my $b64u = Koha::Auth::WebAuthn->std_b64_to_b64url($b64); >+ >+Converts standard base64 to base64url encoding. >+ >+=cut >+ >+sub std_b64_to_b64url { >+ my ( $class, $b64 ) = @_; >+ $b64 =~ tr{+/}{-_}; >+ $b64 =~ s/=+$//; >+ return $b64; >+} >+ >+=head3 b64url_to_std >+ >+ my $b64 = Koha::Auth::WebAuthn->b64url_to_std($b64u); >+ >+Converts base64url to standard base64 encoding. >+ >+=cut >+ >+sub b64url_to_std { >+ my ( $class, $b64u ) = @_; >+ return unless defined $b64u; >+ my $b64 = $b64u; >+ $b64 =~ tr{-_}{+/}; >+ my $pad = ( 4 - ( length($b64) % 4 ) ) % 4; >+ $b64 .= '=' x $pad; >+ return $b64; >+} >+ >+# Private methods >+ >+sub _to_base64url { >+ my ($bytes) = @_; >+ my $b64 = encode_base64( $bytes, '' ); >+ $b64 =~ tr{+/}{-_}; >+ $b64 =~ s/=+$//; >+ return $b64; >+} >+ >+sub _clear_challenge { >+ my ( $class, $c ) = @_; >+ delete $c->session->{webauthn_challenge}; >+ delete $c->session->{webauthn_patron_id}; >+ delete $c->session->{webauthn_challenge_ts}; >+ return; >+} >+ >+=head1 AUTHOR >+ >+Koha Development Team >+ >+=cut >+ >+1; >diff --git a/Koha/Exceptions/WebAuthn.pm b/Koha/Exceptions/WebAuthn.pm >new file mode 100644 >index 00000000000..02977f220ab >--- /dev/null >+++ b/Koha/Exceptions/WebAuthn.pm >@@ -0,0 +1,88 @@ >+package Koha::Exceptions::WebAuthn; >+ >+# This file is part of Koha. >+# >+# Koha is free software; you can redistribute it and/or modify it >+# under the terms of the GNU General Public License as published by >+# the Free Software Foundation; either version 3 of the License, or >+# (at your option) any later version. >+# >+# Koha is distributed in the hope that it will be useful, but >+# WITHOUT ANY WARRANTY; without even the implied warranty of >+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the >+# GNU General Public License for more details. >+# >+# You should have received a copy of the GNU General Public License >+# along with Koha; if not, see <https://www.gnu.org/licenses>. >+ >+use Modern::Perl; >+ >+use Koha::Exception; >+ >+use Exception::Class ( >+ 'Koha::Exceptions::WebAuthn' => { >+ isa => 'Koha::Exception', >+ }, >+ 'Koha::Exceptions::WebAuthn::ConfigMissing' => { >+ isa => 'Koha::Exceptions::WebAuthn', >+ description => 'StaffClientBaseURL system preference not configured', >+ }, >+ 'Koha::Exceptions::WebAuthn::PatronNotFound' => { >+ isa => 'Koha::Exceptions::WebAuthn', >+ description => 'Patron not found', >+ }, >+ 'Koha::Exceptions::WebAuthn::NoCredentials' => { >+ isa => 'Koha::Exceptions::WebAuthn', >+ description => 'No credentials registered for patron', >+ }, >+ 'Koha::Exceptions::WebAuthn::InvalidChallenge' => { >+ isa => 'Koha::Exceptions::WebAuthn', >+ description => 'Invalid or missing challenge', >+ }, >+ 'Koha::Exceptions::WebAuthn::ChallengeMismatch' => { >+ isa => 'Koha::Exceptions::WebAuthn', >+ description => 'Session patron does not match request patron', >+ }, >+ 'Koha::Exceptions::WebAuthn::VerificationFailed' => { >+ isa => 'Koha::Exceptions::WebAuthn', >+ description => 'WebAuthn verification failed', >+ }, >+); >+ >+=head1 NAME >+ >+Koha::Exceptions::WebAuthn - Base class for WebAuthn exceptions >+ >+=head1 Exceptions >+ >+=head2 Koha::Exceptions::WebAuthn >+ >+Generic WebAuthn exception >+ >+=head2 Koha::Exceptions::WebAuthn::ConfigMissing >+ >+Exception for missing StaffClientBaseURL configuration >+ >+=head2 Koha::Exceptions::WebAuthn::PatronNotFound >+ >+Exception when patron cannot be resolved >+ >+=head2 Koha::Exceptions::WebAuthn::NoCredentials >+ >+Exception when patron has no registered credentials >+ >+=head2 Koha::Exceptions::WebAuthn::InvalidChallenge >+ >+Exception for missing or expired challenge >+ >+=head2 Koha::Exceptions::WebAuthn::ChallengeMismatch >+ >+Exception when session patron does not match request >+ >+=head2 Koha::Exceptions::WebAuthn::VerificationFailed >+ >+Exception when attestation or assertion verification fails >+ >+=cut >+ >+1; >diff --git a/Koha/REST/V1/Auth.pm b/Koha/REST/V1/Auth.pm >index 22a4199ce40..506c7d7fb88 100644 >--- a/Koha/REST/V1/Auth.pm >+++ b/Koha/REST/V1/Auth.pm >@@ -83,9 +83,10 @@ sub under { > } > > if ( $c->req->url->to_abs->path =~ m#^/api/v1/oauth/# >- || $c->req->url->to_abs->path =~ m#^/api/v1/public/oauth/# ) >+ || $c->req->url->to_abs->path =~ m#^/api/v1/public/oauth/# >+ || $c->req->url->to_abs->path =~ m#^/api/v1/webauthn/authenticate(?:/challenge)?$# ) > { >- # Requesting OAuth endpoints shouldn't go through the API authentication chain >+ # Requesting OAuth or WebAuthn endpoints shouldn't go through the API authentication chain > $status = 1; > } elsif ( $namespace eq '' or $namespace eq '.html' ) { > $status = 1; >diff --git a/Koha/REST/V1/Webauthn.pm b/Koha/REST/V1/Webauthn.pm >new file mode 100644 >index 00000000000..d3d250650bc >--- /dev/null >+++ b/Koha/REST/V1/Webauthn.pm >@@ -0,0 +1,343 @@ >+package Koha::REST::V1::Webauthn; >+ >+# This file is part of Koha. >+# >+# Koha is free software; you can redistribute it and/or modify it >+# under the terms of the GNU General Public License as published by >+# the Free Software Foundation; either version 3 of the License, or >+# (at your option) any later version. >+# >+# Koha is distributed in the hope that it will be useful, but >+# WITHOUT ANY WARRANTY; without even the implied warranty of >+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the >+# GNU General Public License for more details. >+# >+# You should have received a copy of the GNU General Public License >+# along with Koha; if not, see <https://www.gnu.org/licenses>. >+ >+use Modern::Perl; >+use Mojo::Base 'Mojolicious::Controller'; >+use Try::Tiny; >+use Authen::WebAuthn; >+use C4::Auth qw(create_basic_session); >+use C4::Context; >+use JSON qw(encode_json); >+use Koha::Auth::WebAuthn; >+use Koha::DateUtils qw(dt_from_string); >+use Koha::Exceptions::WebAuthn; >+use Koha::Patrons; >+use Koha::WebauthnCredential; >+use Koha::WebauthnCredentials; >+use MIME::Base64 qw(encode_base64 decode_base64); >+ >+=head1 NAME >+ >+Koha::REST::V1::Webauthn >+ >+=head1 API >+ >+=head2 Methods >+ >+=head3 register_challenge >+ >+Controller for POST /api/v1/webauthn/register/challenge >+ >+=cut >+ >+sub register_challenge { >+ my $c = shift->openapi->valid_input or return; >+ return try { >+ my $body = $c->req->json; >+ my ( $patron, $patron_id ) = Koha::Auth::WebAuthn->resolve_patron($body); >+ >+ my ( $origin, $rp_id ) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c); >+ my $challenge_b64u = Koha::Auth::WebAuthn->generate_challenge(); >+ >+ Koha::Auth::WebAuthn->store_challenge( $c, $challenge_b64u, $patron_id ); >+ >+ $c->render( >+ openapi => { >+ challenge => $challenge_b64u, >+ rp_id => $rp_id, >+ user => { >+ id => $patron_id, >+ name => $patron->userid, >+ }, >+ } >+ ); >+ } catch { >+ return _handle_exception( $c, $_ ); >+ }; >+} >+ >+=head3 register >+ >+Controller for POST /api/v1/webauthn/register >+ >+=cut >+ >+sub register { >+ my $c = shift->openapi->valid_input or return; >+ return try { >+ my $body = $c->req->json; >+ my ( $patron, $patron_id ) = Koha::Auth::WebAuthn->resolve_patron($body); >+ >+ my $att = $body->{attestation_response} // {}; >+ return $c->render( status => 400, openapi => { error => 'Missing attestation_response' } ) >+ unless $att->{client_data_json} && $att->{attestation_object}; >+ >+ my ( $origin, $rp_id ) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c); >+ >+ # Allow http->https upgrade based on client-reported origin >+ my $client_origin = Koha::Auth::WebAuthn->extract_client_origin( $att->{client_data_json} ); >+ $origin = Koha::Auth::WebAuthn->maybe_upgrade_origin( $origin, $rp_id, $client_origin ); >+ >+ my $webauthn = Authen::WebAuthn->new( rp_id => $rp_id, origin => $origin ); >+ my $challenge_b64u = Koha::Auth::WebAuthn->validate_and_consume_challenge( $c, $patron_id ); >+ >+ my $res; >+ try { >+ $res = $webauthn->validate_registration( >+ challenge_b64 => $challenge_b64u, >+ requested_uv => 'preferred', >+ client_data_json_b64 => Koha::Auth::WebAuthn->std_b64_to_b64url( $att->{client_data_json} ), >+ attestation_object_b64 => Koha::Auth::WebAuthn->std_b64_to_b64url( $att->{attestation_object} ), >+ ); >+ } catch { >+ $c->app->log->warn( 'WebAuthn register failed for patron ' . $patron_id . ': ' . $_ ); >+ Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Attestation verification failed' ); >+ }; >+ unless ($res) { >+ $c->app->log->warn( 'WebAuthn register failed for patron ' . $patron_id . ': verification returned false' ); >+ Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Attestation verification failed' ); >+ } >+ >+ Koha::WebauthnCredential->new( >+ { >+ borrowernumber => $patron_id, >+ credential_id => MIME::Base64::decode_base64url( $res->{credential_id} ), >+ public_key => MIME::Base64::decode_base64url( $res->{credential_pubkey} ), >+ sign_count => $res->{signature_count} // 0, >+ transports => undef, >+ created_date => dt_from_string()->strftime('%F %T'), >+ } >+ )->store; >+ >+ $c->app->log->info( 'WebAuthn credential registered for patron ' . $patron_id ); >+ $c->render( status => 201, openapi => { success => 1 } ); >+ } catch { >+ return _handle_exception( $c, $_ ); >+ }; >+} >+ >+=head3 authenticate_challenge >+ >+Controller for POST /api/v1/webauthn/authenticate/challenge >+ >+=cut >+ >+sub authenticate_challenge { >+ my $c = shift->openapi->valid_input or return; >+ return try { >+ my $body = $c->req->json; >+ my ( $patron, $patron_id ) = Koha::Auth::WebAuthn->resolve_patron($body); >+ >+ my $credentials = Koha::WebauthnCredentials->search( { borrowernumber => $patron_id } ); >+ Koha::Exceptions::WebAuthn::NoCredentials->throw() unless $credentials->count; >+ >+ my ( $origin, $rp_id ) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c); >+ my $challenge_b64u = Koha::Auth::WebAuthn->generate_challenge(); >+ >+ Koha::Auth::WebAuthn->store_challenge( $c, $challenge_b64u, $patron_id ); >+ >+ my @allow_credentials; >+ while ( my $cred = $credentials->next ) { >+ push @allow_credentials, >+ { >+ id => Koha::Auth::WebAuthn->std_b64_to_b64url( encode_base64( $cred->credential_id, '' ) ), >+ type => 'public-key', >+ }; >+ } >+ >+ $c->render( >+ openapi => { >+ challenge => $challenge_b64u, >+ rpId => $rp_id, >+ allowCredentials => \@allow_credentials, >+ } >+ ); >+ } catch { >+ return _handle_exception( $c, $_ ); >+ }; >+} >+ >+=head3 authenticate >+ >+Controller for POST /api/v1/webauthn/authenticate >+ >+=cut >+ >+sub authenticate { >+ my $c = shift->openapi->valid_input or return; >+ return try { >+ my $body = $c->req->json; >+ my ( $patron, $patron_id ) = Koha::Auth::WebAuthn->resolve_patron($body); >+ >+ my $assertion = $body->{assertion_response}; >+ return $c->render( status => 400, openapi => { error => 'Missing assertion_response' } ) >+ unless $assertion; >+ >+ # Reject locked or expired accounts >+ if ( $patron->account_locked ) { >+ Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Account is locked' ); >+ } >+ >+ # Build credential lookup maps >+ my $credentials = Koha::WebauthnCredentials->search( { borrowernumber => $patron_id } ); >+ my ( %cred_id_map, %pubkeys, %sign_counts ); >+ while ( my $cred = $credentials->next ) { >+ my $id_b64u = Koha::Auth::WebAuthn->std_b64_to_b64url( encode_base64( $cred->credential_id, '' ) ); >+ $cred_id_map{$id_b64u} = $cred->webauthn_credential_id; >+ $pubkeys{$id_b64u} = Koha::Auth::WebAuthn->std_b64_to_b64url( encode_base64( $cred->public_key, '' ) ); >+ $sign_counts{$id_b64u} = $cred->sign_count // 0; >+ } >+ >+ my $challenge_b64u = Koha::Auth::WebAuthn->validate_and_consume_challenge( $c, $patron_id ); >+ my ( $origin, $rp_id ) = Koha::Auth::WebAuthn->get_origin_and_rp_id($c); >+ >+ # Browser sends base64 (we need base64url) >+ my $client_data_b64u = >+ Koha::Auth::WebAuthn->std_b64_to_b64url( $assertion->{clientDataJSON} // $assertion->{client_data_json} >+ // '' ); >+ >+ # Allow http->https upgrade based on client-reported origin >+ my $client_origin = >+ Koha::Auth::WebAuthn->extract_client_origin( $assertion->{clientDataJSON} >+ // $assertion->{client_data_json} ); >+ $origin = Koha::Auth::WebAuthn->maybe_upgrade_origin( $origin, $rp_id, $client_origin ); >+ >+ my $webauthn = Authen::WebAuthn->new( rp_id => $rp_id, origin => $origin ); >+ my $auth_data_b64u = >+ Koha::Auth::WebAuthn->std_b64_to_b64url( $assertion->{authenticatorData} >+ // $assertion->{authenticator_data} // '' ); >+ my $signature_b64u = Koha::Auth::WebAuthn->std_b64_to_b64url( $assertion->{signature} // '' ); >+ my $credential_id_b64u = >+ Koha::Auth::WebAuthn->std_b64_to_b64url( $assertion->{id} // $assertion->{raw_id} // '' ); >+ >+ # Verify credential exists for this patron >+ my $credential_pubkey = $pubkeys{$credential_id_b64u}; >+ Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Unknown credential' ) >+ unless $credential_pubkey; >+ >+ my $stored_sign_count = $sign_counts{$credential_id_b64u} // 0; >+ my $res; >+ try { >+ $res = $webauthn->validate_assertion( >+ challenge_b64 => $challenge_b64u, >+ credential_pubkey_b64 => $credential_pubkey, >+ stored_sign_count => $stored_sign_count, >+ requested_uv => 'preferred', >+ client_data_json_b64 => $client_data_b64u, >+ authenticator_data_b64 => $auth_data_b64u, >+ signature_b64 => $signature_b64u, >+ ); >+ } catch { >+ $c->app->log->warn( 'WebAuthn authenticate failed for patron ' . $patron_id . ': ' . $_ ); >+ Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Authentication failed' ); >+ }; >+ unless ($res) { >+ $c->app->log->warn( >+ 'WebAuthn authenticate failed for patron ' . $patron_id . ': verification returned false' ); >+ Koha::Exceptions::WebAuthn::VerificationFailed->throw( error => 'Authentication failed' ); >+ } >+ >+ # Update sign count >+ if ( my $cred_pk = $cred_id_map{$credential_id_b64u} ) { >+ if ( my $cred = Koha::WebauthnCredentials->find($cred_pk) ) { >+ $cred->set( >+ { >+ sign_count => $res->{signature_count} // 0, >+ last_used_date => dt_from_string()->strftime('%F %T'), >+ } >+ )->store; >+ } >+ } >+ >+ # Issue staff session and cookie for login >+ my $session = create_basic_session( { patron => $patron, interface => 'intranet' } ); >+ my $is_https = ( $origin =~ m{^https://}i ) ? 1 : 0; >+ $c->cookie( >+ CGISESSID => $session->id, >+ { path => '/', http_only => 1, same_site => 'Lax', secure => $is_https } >+ ); >+ C4::Context->interface('intranet'); >+ my $lib = $patron->library; >+ C4::Context->set_userenv( >+ $patron->borrowernumber, >+ $patron->userid // '', >+ $patron->cardnumber // '', >+ $patron->firstname // '', >+ $patron->surname // '', >+ ( $lib ? $lib->branchcode : '' ), >+ ( $lib ? $lib->branchname : '' ), >+ $patron->flags, >+ $patron->email // '', >+ ); >+ >+ $c->app->log->info( 'WebAuthn authentication successful for patron ' . $patron_id ); >+ $c->render( status => 200, openapi => { success => 1 } ); >+ } catch { >+ return _handle_exception( $c, $_ ); >+ }; >+} >+ >+=head3 _handle_exception >+ >+Maps WebAuthn exceptions to appropriate HTTP responses. >+ >+=cut >+ >+sub _handle_exception { >+ my ( $c, $err ) = @_; >+ >+ if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::ConfigMissing') ) { >+ return $c->render( >+ status => 500, >+ openapi => { error => $err->error // $err->description } >+ ); >+ } >+ if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::PatronNotFound') ) { >+ return $c->render( >+ status => 404, >+ openapi => { error => 'Patron not found' } >+ ); >+ } >+ if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::NoCredentials') ) { >+ return $c->render( >+ status => 404, >+ openapi => { error => 'No credentials registered' } >+ ); >+ } >+ if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::InvalidChallenge') ) { >+ return $c->render( >+ status => 401, >+ openapi => { error => $err->error // 'Invalid or expired challenge' } >+ ); >+ } >+ if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::ChallengeMismatch') ) { >+ return $c->render( >+ status => 401, >+ openapi => { error => 'Challenge patron mismatch' } >+ ); >+ } >+ if ( ref $err && $err->isa('Koha::Exceptions::WebAuthn::VerificationFailed') ) { >+ return $c->render( >+ status => 401, >+ openapi => { error => $err->error // 'Verification failed' } >+ ); >+ } >+ >+ return $c->unhandled_exception($err); >+} >+ >+1; >diff --git a/Koha/WebauthnCredential.pm b/Koha/WebauthnCredential.pm >new file mode 100644 >index 00000000000..11d4d6aaaf6 >--- /dev/null >+++ b/Koha/WebauthnCredential.pm >@@ -0,0 +1,16 @@ >+package Koha::WebauthnCredential; >+ >+use Modern::Perl; >+use base qw(Koha::Object); >+ >+sub _type { 'WebauthnCredential' } >+ >+1; >+ >+__END__ >+ >+=head1 NAME >+ >+Koha::WebauthnCredential - Koha Object class for webauthn_credentials >+ >+=cut >diff --git a/Koha/WebauthnCredentials.pm b/Koha/WebauthnCredentials.pm >new file mode 100644 >index 00000000000..37af916e42f >--- /dev/null >+++ b/Koha/WebauthnCredentials.pm >@@ -0,0 +1,26 @@ >+package Koha::WebauthnCredentials; >+ >+use Modern::Perl; >+use base qw(Koha::Objects); >+ >+=head1 NAME >+ >+Koha::WebauthnCredentials - Koha Objects class for webauthn_credentials >+ >+=head1 API >+ >+=head2 Internal methods >+ >+=head3 _type >+ >+=cut >+ >+sub _type { 'WebauthnCredential' } >+ >+=head3 object_class >+ >+=cut >+ >+sub object_class { 'Koha::WebauthnCredential' } >+ >+1; >diff --git a/api/v1/swagger/paths/webauthn.yaml b/api/v1/swagger/paths/webauthn.yaml >new file mode 100644 >index 00000000000..f4428b157d7 >--- /dev/null >+++ b/api/v1/swagger/paths/webauthn.yaml >@@ -0,0 +1,190 @@ >+--- >+/webauthn/register/challenge: >+ post: >+ x-mojo-to: Webauthn#register_challenge >+ operationId: webauthnRegisterChallenge >+ tags: >+ - webauthn >+ summary: Generate a WebAuthn registration challenge >+ description: Generates a WebAuthn challenge for passkey registration for a given patron. >+ produces: >+ - application/json >+ parameters: >+ - name: body >+ in: body >+ required: true >+ schema: >+ type: object >+ properties: >+ patron_id: >+ type: [integer, "null"] >+ userid: >+ type: [string, "null"] >+ additionalProperties: false >+ responses: >+ "200": >+ description: WebAuthn registration challenge >+ schema: >+ $ref: "../swagger.yaml#/definitions/WebauthnChallenge" >+ "404": >+ description: Patron not found >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "400": >+ description: Bad request >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "401": >+ description: Unauthorized >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Forbidden >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: Internal server error >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ x-koha-authorization: >+ permissions: >+ catalogue: "1" >+ >+/webauthn/register: >+ post: >+ x-mojo-to: Webauthn#register >+ operationId: webauthnRegister >+ tags: >+ - webauthn >+ summary: Complete WebAuthn registration >+ description: Receives and verifies the attestation response, then stores the credential for the patron. >+ produces: >+ - application/json >+ parameters: >+ - name: body >+ in: body >+ required: true >+ schema: >+ $ref: "../swagger.yaml#/definitions/WebauthnRegistrationRequest" >+ responses: >+ "201": >+ description: Credential registered successfully >+ "400": >+ description: "Bad request: Invalid attestation response" >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "401": >+ description: Unauthorized >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Forbidden >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: Internal server error >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ x-koha-authorization: >+ permissions: >+ catalogue: "1" >+ >+/webauthn/authenticate/challenge: >+ post: >+ x-mojo-to: Webauthn#authenticate_challenge >+ operationId: webauthnAuthenticateChallenge >+ tags: >+ - webauthn >+ summary: Generate a WebAuthn authentication challenge >+ description: Generates a challenge for passkey authentication (login) for a given patron. >+ produces: >+ - application/json >+ parameters: >+ - name: body >+ in: body >+ required: true >+ schema: >+ type: object >+ properties: >+ patron_id: >+ type: integer >+ userid: >+ type: string >+ additionalProperties: false >+ responses: >+ "200": >+ description: WebAuthn authentication challenge >+ schema: >+ $ref: "../swagger.yaml#/definitions/WebauthnAuthChallenge" >+ "404": >+ description: No credentials registered >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "400": >+ description: Bad request >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "401": >+ description: Unauthorized >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Forbidden >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: Internal server error >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ >+/webauthn/authenticate: >+ post: >+ x-mojo-to: Webauthn#authenticate >+ operationId: webauthnAuthenticate >+ tags: >+ - webauthn >+ summary: Complete WebAuthn authentication >+ description: Receives and verifies the assertion response, then authenticates the patron. >+ produces: >+ - application/json >+ parameters: >+ - name: body >+ in: body >+ required: true >+ schema: >+ $ref: "../swagger.yaml#/definitions/WebauthnAuthenticationRequest" >+ responses: >+ "200": >+ description: Authentication successful >+ "400": >+ description: "Bad request: Invalid assertion response" >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "401": >+ description: Authentication failed >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Forbidden >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: Internal server error >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml >index 9a9b7af7565..6b79eee49c2 100644 >--- a/api/v1/swagger/swagger.yaml >+++ b/api/v1/swagger/swagger.yaml >@@ -2,6 +2,59 @@ > swagger: "2.0" > basePath: /api/v1 > definitions: >+ WebauthnChallenge: >+ type: object >+ properties: >+ challenge: >+ type: string >+ rp_id: >+ type: string >+ user: >+ type: object >+ properties: >+ id: >+ type: integer >+ name: >+ type: string >+ WebauthnAuthChallenge: >+ type: object >+ properties: >+ challenge: >+ type: string >+ rpId: >+ type: string >+ allowCredentials: >+ type: array >+ items: >+ type: object >+ properties: >+ id: >+ type: string >+ type: >+ type: string >+ WebauthnRegistrationRequest: >+ type: object >+ properties: >+ patron_id: >+ type: integer >+ userid: >+ type: string >+ attestation_response: >+ type: object >+ required: >+ - attestation_response >+ WebauthnAuthenticationRequest: >+ type: object >+ properties: >+ patron_id: >+ type: integer >+ userid: >+ type: string >+ assertion_response: >+ type: object >+ required: >+ - assertion_response >+ > account_line: > $ref: ./definitions/account_line.yaml > additional_content: >@@ -635,6 +688,15 @@ paths: > $ref: ./paths/transfer_limits.yaml#/~1transfer_limits~1batch > "/transfer_limits/{limit_id}": > $ref: "./paths/transfer_limits.yaml#/~1transfer_limits~1{limit_id}" >+ /webauthn/register/challenge: >+ $ref: ./paths/webauthn.yaml#/~1webauthn~1register~1challenge >+ /webauthn/register: >+ $ref: ./paths/webauthn.yaml#/~1webauthn~1register >+ /webauthn/authenticate/challenge: >+ $ref: ./paths/webauthn.yaml#/~1webauthn~1authenticate~1challenge >+ /webauthn/authenticate: >+ $ref: ./paths/webauthn.yaml#/~1webauthn~1authenticate >+ > parameters: > advancededitormacro_id_pp: > description: Advanced editor macro internal identifier >@@ -1419,3 +1481,6 @@ tags: > - description: "Manage vendors configuration\n" > name: vendors_config > x-displayName: Vendors configuration >+ - description: "Handle WebAuthn (passkey) registration and authentication\n" >+ name: webauthn >+ x-displayName: WebAuthn >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/auth-webauthn.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/auth-webauthn.inc >new file mode 100644 >index 00000000000..a13e5c50e83 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/auth-webauthn.inc >@@ -0,0 +1,9 @@ >+[% USE raw %] >+<div id="webauthn-login-section" class="mb-0"> >+ <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> >+ <noscript> >+ <div class="alert alert-warning mt-2">Passkeys require JavaScript.</div> >+ </noscript> >+ [% USE Asset %] >+ [% Asset.js('js/webauthn-login.js') | $raw %] >+</div> >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc >index 51f26cb47a3..8767df3f86c 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc >@@ -79,6 +79,11 @@ > <li> > <a class="dropdown-item" id="renewpatron" href="/cgi-bin/koha/members/setstatus.pl?borrowernumber=[% patron.borrowernumber | html %]&destination=[% destination | html %]&reregistration=y">Renew patron</a> > </li> >+ [% IF ( !is_anonymous && loggedinuser.borrowernumber == patron.borrowernumber ) %] >+ <li> >+ <a class="dropdown-item" id="register-passkey-menu" href="#" data-bs-toggle="modal" data-bs-target="#passkeyRegisterModal">Register passkey</a> >+ </li> >+ [% END %] > [% ELSE %] > <li data-bs-toggle="tooltip" data-bs-placement="left" title="You are not authorized to renew patrons"> > <a class="dropdown-item disabled" aria-disabled="true" id="renewpatron" href="#">Renew patron</a> >@@ -168,6 +173,9 @@ > </div> > > <!-- Modal --> >+[% IF ( !is_anonymous && loggedinuser.borrowernumber == patron.borrowernumber ) %] >+ [% INCLUDE 'passkey-register.inc' %] >+[% END %] > <div id="add_message_form" class="modal" tabindex="-1" role="dialog" aria-labelledby="addnewmessageLabel toolbar_addnewmessageLabel" aria-hidden="true"> > <div class="modal-dialog modal-lg"> > <div class="modal-content modal-lg"> >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/passkey-register.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/passkey-register.inc >new file mode 100644 >index 00000000000..d091778b656 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/passkey-register.inc >@@ -0,0 +1,33 @@ >+[% USE raw %] >+<!-- Passkey registration modal --> >+<div id="passkey-register-section"> >+ <div class="modal fade" id="passkeyRegisterModal" tabindex="-1" aria-labelledby="passkeyRegisterTitle" aria-hidden="true"> >+ <div class="modal-dialog"> >+ <div class="modal-content"> >+ <div class="modal-header"> >+ <h5 class="modal-title" id="passkeyRegisterTitle"><i class="fa fa-key"></i> Register a passkey</h5> >+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> >+ </div> >+ <div class="modal-body"> >+ <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> >+ <ul> >+ <li>We will create a passkey for this patron account.</li> >+ <li>At the staff login screen: enter the username, then click "Passkey".</li> >+ <li>Passkeys can sync via your platform account or password manager if enabled.</li> >+ </ul> >+ <div id="passkey-register-success-modal" class="alert alert-success" style="display:none"></div> >+ <div id="passkey-register-error-modal" class="alert alert-danger" style="display:none"></div> >+ <input type="hidden" id="passkey-patron-id" value="[% patron.borrowernumber | html %]" /> >+ <input type="hidden" id="passkey-patron-userid" value="[% patron.userid | html %]" /> >+ </div> >+ <div class="modal-footer"> >+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> >+ <button type="button" class="btn btn-primary" id="passkey-register-confirm">Register passkey</button> >+ <button type="button" class="btn btn-success d-none" id="passkey-register-done" data-bs-dismiss="modal">Done</button> >+ </div> >+ </div> >+ </div> >+ </div> >+</div> >+[% USE Asset %] >+[% Asset.js("js/passkey-register.js") | $raw %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt >index 142b98fba4e..cb787c980ec 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt >@@ -227,7 +227,11 @@ > [% END %] > [% END %] > >- <p class="submit"><input id="submit-button" type="submit" class="btn btn-primary" value="Log in" tabindex="4" /></p> >+ <div class="d-flex justify-content-end gap-2 align-items-center mb-0"> >+ <button id="submit-button" type="submit" class="btn btn-primary btn-lg">Log in</button> >+ [% INCLUDE 'auth-webauthn.inc' %] >+ </div> >+ <div id="webauthn-login-error" class="alert alert-danger mt-2" style="display:none;"></div> > </form> > > [% IF ( casAuthentication ) %] >diff --git a/koha-tmpl/intranet-tmpl/prog/js/passkey-register.js b/koha-tmpl/intranet-tmpl/prog/js/passkey-register.js >new file mode 100644 >index 00000000000..46595f22c3b >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/passkey-register.js >@@ -0,0 +1,224 @@ >+// Passkey registration script for patron page >+ >+(function () { >+ "use strict"; >+ >+ /** >+ * Read patron_id and userid from hidden form fields. >+ * @returns {{ patronId: string|null, userid: string|null }} >+ */ >+ function getPatronContext() { >+ const patronIdEl = document.getElementById("passkey-patron-id"); >+ const useridEl = document.getElementById("passkey-patron-userid"); >+ return { >+ patronId: patronIdEl ? patronIdEl.value : null, >+ userid: useridEl ? useridEl.value : null, >+ }; >+ } >+ >+ /** >+ * Decode a base64url string to a Uint8Array. >+ * @param {string} b64u - base64url-encoded string >+ * @returns {Uint8Array} >+ */ >+ function b64uToBytes(b64u) { >+ const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/"); >+ const pad = "=".repeat((4 - (b64.length % 4)) % 4); >+ const str = window.atob(b64 + pad); >+ const bytes = new Uint8Array(str.length); >+ for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i); >+ return bytes; >+ } >+ >+ /** >+ * Convert an ArrayBuffer to a standard base64 string. >+ * Uses a loop instead of String.fromCharCode.apply to avoid >+ * stack overflow on large buffers. >+ * @param {ArrayBuffer} arrBuf >+ * @returns {string} >+ */ >+ function toBase64(arrBuf) { >+ const bytes = new Uint8Array(arrBuf); >+ let binary = ""; >+ for (let i = 0; i < bytes.length; i++) { >+ binary += String.fromCharCode(bytes[i]); >+ } >+ return window.btoa(binary); >+ } >+ >+ /** >+ * Request a registration challenge from the server. >+ * @param {{ patronId: string|null, userid: string|null }} ctx >+ * @returns {Promise<Object>} >+ */ >+ async function fetchChallenge(ctx) { >+ const body = ctx.patronId >+ ? { patron_id: Number(ctx.patronId) } >+ : { userid: ctx.userid }; >+ const resp = await fetch("/api/v1/webauthn/register/challenge", { >+ method: "POST", >+ headers: { "Content-Type": "application/json" }, >+ credentials: "same-origin", >+ body: JSON.stringify(body), >+ }); >+ if (!resp.ok) >+ throw new Error(__("Failed to get registration challenge")); >+ return resp.json(); >+ } >+ >+ /** >+ * Build the PublicKeyCredentialCreationOptions for navigator.credentials.create. >+ * @param {Object} challengeJson - Server challenge response >+ * @param {{ patronId: string|null, userid: string|null }} ctx >+ * @returns {PublicKeyCredentialCreationOptions} >+ */ >+ function buildPublicKeyOptions(challengeJson, ctx) { >+ const userIdBytes = new TextEncoder().encode( >+ String(ctx.patronId || ctx.userid) >+ ); >+ return { >+ challenge: b64uToBytes(challengeJson.challenge), >+ rp: { name: "Koha", id: challengeJson.rp_id || undefined }, >+ user: { >+ id: userIdBytes, >+ name: ctx.userid || String(ctx.patronId), >+ displayName: ctx.userid || String(ctx.patronId), >+ }, >+ pubKeyCredParams: [ >+ { type: "public-key", alg: -7 }, >+ { type: "public-key", alg: -257 }, >+ ], >+ timeout: 60000, >+ attestation: "none", >+ }; >+ } >+ >+ /** >+ * @param {PublicKeyCredentialCreationOptions} publicKey >+ * @returns {Promise<PublicKeyCredential>} >+ */ >+ async function createCredential(publicKey) { >+ return navigator.credentials.create({ publicKey }); >+ } >+ >+ /** >+ * Submit the attestation response to the server. >+ * @param {{ patronId: string|null, userid: string|null }} ctx >+ * @param {PublicKeyCredential} credential >+ */ >+ async function submitAttestation(ctx, credential) { >+ const data = { >+ attestation_response: { >+ attestation_object: toBase64( >+ credential.response.attestationObject >+ ), >+ client_data_json: toBase64(credential.response.clientDataJSON), >+ raw_id: toBase64(credential.rawId), >+ }, >+ }; >+ if (ctx.patronId) data.patron_id = Number(ctx.patronId); >+ if (ctx.userid) data.userid = ctx.userid; >+ >+ const resp = await fetch("/api/v1/webauthn/register", { >+ method: "POST", >+ headers: { "Content-Type": "application/json" }, >+ credentials: "same-origin", >+ body: JSON.stringify(data), >+ }); >+ if (!resp.ok) { >+ const errTxt = await resp.text().catch(() => ""); >+ throw new Error( >+ __x("Server rejected registration{suffix}", { >+ suffix: errTxt ? ": " + errTxt : "", >+ }) >+ ); >+ } >+ } >+ >+ /** Show success state in the registration modal. */ >+ function onSuccess() { >+ const successEl = document.getElementById( >+ "passkey-register-success-modal" >+ ); >+ const confirmBtn = document.getElementById("passkey-register-confirm"); >+ const doneBtn = document.getElementById("passkey-register-done"); >+ if (successEl) { >+ successEl.textContent = __("Passkey registered successfully."); >+ successEl.style.display = ""; >+ } >+ if (confirmBtn) confirmBtn.classList.add("d-none"); >+ if (doneBtn) doneBtn.classList.remove("d-none"); >+ } >+ >+ /** >+ * Display a user-friendly error in the registration modal. >+ * @param {Error} e >+ */ >+ function onError(e) { >+ const errorEl = document.getElementById("passkey-register-error-modal"); >+ if (!errorEl) return; >+ let msg; >+ if (e && e.name === "AbortError") { >+ msg = __("Registration canceled"); >+ } else if (e && e.name === "NotAllowedError") { >+ msg = __( >+ "Passkey request was cancelled or timed out. Please try again." >+ ); >+ } else if (e && e.name === "SecurityError") { >+ msg = __( >+ "Security error: the server origin does not match. Check StaffClientBaseURL configuration." >+ ); >+ } else if (e && e.name === "InvalidStateError") { >+ msg = __("This passkey is already registered."); >+ } else { >+ msg = e && e.message ? e.message : String(e); >+ } >+ errorEl.textContent = __x("Registration error: {msg}", { >+ msg: msg, >+ }); >+ errorEl.style.display = ""; >+ } >+ >+ /** Wire up the registration confirm button in the modal. */ >+ function attachHandlers() { >+ const confirmBtn = document.getElementById("passkey-register-confirm"); >+ if (!confirmBtn) return; >+ >+ confirmBtn.addEventListener("click", async () => { >+ // Feature detection >+ if (!window.PublicKeyCredential) { >+ onError( >+ new Error(__("Passkeys are not supported by this browser.")) >+ ); >+ return; >+ } >+ const ctx = getPatronContext(); >+ if (!ctx.patronId && !ctx.userid) { >+ onError(new Error(__("Cannot determine patron context"))); >+ return; >+ } >+ // Hide messages >+ const successEl = document.getElementById( >+ "passkey-register-success-modal" >+ ); >+ const errorEl = document.getElementById( >+ "passkey-register-error-modal" >+ ); >+ if (successEl) successEl.style.display = "none"; >+ if (errorEl) errorEl.style.display = "none"; >+ >+ try { >+ const challenge = await fetchChallenge(ctx); >+ const publicKey = buildPublicKeyOptions(challenge, ctx); >+ const credential = await createCredential(publicKey); >+ if (!credential) throw new Error(__("No credential created")); >+ await submitAttestation(ctx, credential); >+ onSuccess(); >+ } catch (e) { >+ onError(e); >+ } >+ }); >+ } >+ >+ document.addEventListener("DOMContentLoaded", attachHandlers); >+})(); >diff --git a/koha-tmpl/intranet-tmpl/prog/js/webauthn-login.js b/koha-tmpl/intranet-tmpl/prog/js/webauthn-login.js >new file mode 100644 >index 00000000000..22629ddff4e >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/webauthn-login.js >@@ -0,0 +1,212 @@ >+// WebAuthn login script for Koha staff interface >+ >+(function () { >+ "use strict"; >+ >+ /** >+ * Convert an ArrayBuffer to a standard base64 string. >+ * Uses a loop instead of String.fromCharCode.apply to avoid >+ * stack overflow on large buffers. >+ * @param {ArrayBuffer} arrBuf >+ * @returns {string} >+ */ >+ function toBase64(arrBuf) { >+ const bytes = new Uint8Array(arrBuf); >+ let binary = ""; >+ for (let i = 0; i < bytes.length; i++) { >+ binary += String.fromCharCode(bytes[i]); >+ } >+ return window.btoa(binary); >+ } >+ >+ /** >+ * Decode a base64url string to a Uint8Array. >+ * @param {string} b64u - base64url-encoded string >+ * @returns {Uint8Array} >+ */ >+ function b64uToBytes(b64u) { >+ if (!b64u) return new Uint8Array(); >+ const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/"); >+ const pad = "=".repeat((4 - (b64.length % 4)) % 4); >+ const str = window.atob(b64 + pad); >+ const bytes = new Uint8Array(str.length); >+ for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i); >+ return bytes; >+ } >+ >+ /** >+ * Build the JSON payload identifying the patron for a login request. >+ * @param {string} usernameValue - userid or numeric patron_id >+ * @returns {Object|null} >+ */ >+ function buildLoginPayload(usernameValue) { >+ if (!usernameValue) return null; >+ const isNumeric = /^\d+$/.test(usernameValue); >+ return isNumeric >+ ? { patron_id: usernameValue } >+ : { userid: usernameValue }; >+ } >+ >+ /** >+ * @returns {HTMLInputElement|null} >+ */ >+ function getUsernameInput() { >+ return document.getElementById("userid"); >+ } >+ >+ /** >+ * @returns {HTMLElement|null} >+ */ >+ function getErrorContainer() { >+ return document.getElementById("webauthn-login-error"); >+ } >+ >+ /** >+ * Request a WebAuthn authentication challenge from the server. >+ * @param {Object} loginPayload >+ * @returns {Promise<Object>} >+ */ >+ async function requestChallenge(loginPayload) { >+ const resp = await fetch("/api/v1/webauthn/authenticate/challenge", { >+ method: "POST", >+ headers: { "Content-Type": "application/json" }, >+ credentials: "same-origin", >+ body: JSON.stringify(loginPayload), >+ }); >+ if (!resp.ok) throw new Error(__("Failed to fetch WebAuthn challenge")); >+ return resp.json(); >+ } >+ >+ /** >+ * Invoke the browser WebAuthn API to get an assertion. >+ * @param {Object} opts - Server challenge response >+ * @returns {Promise<PublicKeyCredential>} >+ */ >+ async function performAssertion(opts) { >+ const publicKey = Object.assign({}, opts, { >+ challenge: b64uToBytes(opts.challenge), >+ allowCredentials: Array.isArray(opts.allowCredentials) >+ ? opts.allowCredentials.map(cred => >+ Object.assign({}, cred, { >+ id: b64uToBytes(cred.id), >+ }) >+ ) >+ : undefined, >+ }); >+ return navigator.credentials.get({ publicKey }); >+ } >+ >+ /** >+ * Submit the assertion response to the server for verification. >+ * @param {Object} loginPayload >+ * @param {PublicKeyCredential} assertion >+ */ >+ async function submitAssertion(loginPayload, assertion) { >+ const data = { >+ assertion_response: { >+ id: toBase64(assertion.rawId), >+ authenticatorData: toBase64( >+ assertion.response.authenticatorData >+ ), >+ clientDataJSON: toBase64(assertion.response.clientDataJSON), >+ signature: toBase64(assertion.response.signature), >+ userHandle: assertion.response.userHandle >+ ? toBase64(assertion.response.userHandle) >+ : null, >+ }, >+ }; >+ if (loginPayload.userid) data.userid = loginPayload.userid; >+ if (loginPayload.patron_id) data.patron_id = loginPayload.patron_id; >+ >+ const verifyResp = await fetch("/api/v1/webauthn/authenticate", { >+ method: "POST", >+ headers: { "Content-Type": "application/json" }, >+ credentials: "same-origin", >+ body: JSON.stringify(data), >+ }); >+ if (!verifyResp.ok) throw new Error(__("Authentication failed")); >+ } >+ >+ /** >+ * Attach the passkey login click handler to the given button. >+ * @param {HTMLButtonElement} button >+ */ >+ function attachClickHandler(button) { >+ button.addEventListener("click", async () => { >+ const errorEl = getErrorContainer(); >+ if (errorEl) errorEl.style.display = "none"; >+ >+ // Guard: WebAuthn support >+ if (!window.PublicKeyCredential) { >+ if (errorEl) { >+ errorEl.textContent = __( >+ "Passkeys are not supported by this browser." >+ ); >+ errorEl.style.display = ""; >+ } >+ return; >+ } >+ >+ const userInput = getUsernameInput(); >+ if (userInput && !userInput.value) { >+ userInput.focus(); >+ if (errorEl) { >+ errorEl.textContent = __( >+ "Please enter your username or patron ID." >+ ); >+ errorEl.style.display = ""; >+ } >+ return; >+ } >+ >+ const payload = buildLoginPayload(userInput ? userInput.value : ""); >+ if (!payload) { >+ if (errorEl) { >+ errorEl.textContent = __( >+ "Please enter your username or patron ID." >+ ); >+ errorEl.style.display = ""; >+ } >+ return; >+ } >+ >+ try { >+ const opts = await requestChallenge(payload); >+ const assertion = await performAssertion(opts); >+ await submitAssertion(payload, assertion); >+ window.location.assign("/cgi-bin/koha/mainpage.pl"); >+ } catch (e) { >+ if (errorEl) { >+ let msg; >+ if (e && e.name === "NotAllowedError") { >+ msg = __( >+ "Passkey request was cancelled or timed out. Please try again." >+ ); >+ } else if (e && e.name === "SecurityError") { >+ msg = __( >+ "Security error: the server origin does not match. Check StaffClientBaseURL configuration." >+ ); >+ } else if (e && e.name === "InvalidStateError") { >+ msg = __( >+ "This passkey is already registered for another account." >+ ); >+ } else { >+ msg = __x("WebAuthn error: {error}", { >+ error: String(e), >+ }); >+ } >+ errorEl.textContent = msg; >+ errorEl.style.display = ""; >+ } >+ } >+ }); >+ } >+ >+ function initialize() { >+ const btn = document.getElementById("webauthn-login-btn"); >+ if (!btn) return; >+ attachClickHandler(btn); >+ } >+ >+ document.addEventListener("DOMContentLoaded", initialize); >+})(); >diff --git a/members/moremember.pl b/members/moremember.pl >index b64a58e3000..ddc4f6b140d 100755 >--- a/members/moremember.pl >+++ b/members/moremember.pl >@@ -80,6 +80,12 @@ output_and_exit_if_error( > my $category = $patron->category; > my $category_type = $category->category_type; > >+if ( $patron->borrowernumber eq C4::Context->preference("AnonymousPatron") ) { >+ $template->param( is_anonymous => 1 ); >+} else { >+ $template->param( is_anonymous => 0 ); >+} >+ > for (qw(gonenoaddress lost borrowernotes is_debarred)) { > $patron->$_ and $template->param( flagged => 1 ) and last; > } >@@ -113,6 +119,7 @@ if (@guarantors) { > $template->param( > guarantor_relationships => $guarantor_relationships, > guarantees => \@guarantees, >+ loggedinuser => $logged_in_user, > ); > if ( C4::Context->preference('ChildNeedsGuarantor') > and ( $patron->is_child or $category->can_be_guarantee ) >diff --git a/t/db_dependent/Koha/WebauthnCredentials.t b/t/db_dependent/Koha/WebauthnCredentials.t >new file mode 100755 >index 00000000000..4bdbee991c6 >--- /dev/null >+++ b/t/db_dependent/Koha/WebauthnCredentials.t >@@ -0,0 +1,132 @@ >+#!/usr/bin/perl >+ >+# This file is part of Koha. >+# >+# Koha is free software; you can redistribute it and/or modify it >+# under the terms of the GNU General Public License as published by >+# the Free Software Foundation; either version 3 of the License, or >+# (at your option) any later version. >+# >+# Koha is distributed in the hope that it will be useful, but >+# WITHOUT ANY WARRANTY; without even the implied warranty of >+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the >+# GNU General Public License for more details. >+# >+# You should have received a copy of the GNU General Public License >+# along with Koha; if not, see <https://www.gnu.org/licenses>. >+ >+use Modern::Perl; >+use Test::More tests => 5; >+use Test::NoWarnings; >+use C4::Context; >+use Koha::WebauthnCredential; >+use Koha::WebauthnCredentials; >+use Koha::Database; >+use t::lib::TestBuilder; >+ >+my $schema = Koha::Database->new->schema; >+my $builder = t::lib::TestBuilder->new; >+ >+subtest 'add and retrieve credential' => sub { >+ plan tests => 6; >+ $schema->storage->txn_begin; >+ >+ my $borrower = $builder->build_object( { class => 'Koha::Patrons' } ); >+ my $borrowernumber = $borrower->borrowernumber; >+ >+ my $credential_id = 'testcredentialid'; >+ my $public_key = 'testpublickey'; >+ my $sign_count = 1; >+ my $transports = 'usb,nfc'; >+ my $nickname = 'Test Key'; >+ >+ my $credential = Koha::WebauthnCredential->new( >+ { >+ borrowernumber => $borrowernumber, >+ credential_id => $credential_id, >+ public_key => $public_key, >+ sign_count => $sign_count, >+ transports => $transports, >+ nickname => $nickname, >+ created_date => '2020-01-01 00:00:00', >+ } >+ )->store; >+ ok( $credential && $credential->webauthn_credential_id, 'Credential added' ); >+ >+ my $cred = Koha::WebauthnCredentials->find( { credential_id => $credential_id } ); >+ ok( $cred, 'Credential found by credential_id' ); >+ is( $cred->borrowernumber, $borrowernumber, 'Borrowernumber matches' ); >+ is( $cred->public_key, $public_key, 'Public key matches' ); >+ is( $cred->sign_count, $sign_count, 'Sign count matches' ); >+ is( $cred->nickname, $nickname, 'Nickname matches' ); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'search by borrowernumber' => sub { >+ plan tests => 1; >+ $schema->storage->txn_begin; >+ >+ my $borrower = $builder->build_object( { class => 'Koha::Patrons' } ); >+ my $borrowernumber = $borrower->borrowernumber; >+ >+ Koha::WebauthnCredential->new( >+ { >+ borrowernumber => $borrowernumber, >+ credential_id => 'search_test_cred', >+ public_key => 'testpubkey', >+ sign_count => 0, >+ } >+ )->store; >+ >+ my $creds = Koha::WebauthnCredentials->search( { borrowernumber => $borrowernumber } ); >+ ok( $creds->count >= 1, 'At least one credential for borrower' ); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'update sign_count' => sub { >+ plan tests => 1; >+ $schema->storage->txn_begin; >+ >+ my $borrower = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ Koha::WebauthnCredential->new( >+ { >+ borrowernumber => $borrower->borrowernumber, >+ credential_id => 'update_test_cred', >+ public_key => 'testpubkey', >+ sign_count => 0, >+ } >+ )->store; >+ >+ my $credential = Koha::WebauthnCredentials->find( { credential_id => 'update_test_cred' } ); >+ $credential->set( { sign_count => 42 } )->store; >+ $credential = Koha::WebauthnCredentials->find( { credential_id => 'update_test_cred' } ); >+ is( $credential->sign_count, 42, 'Sign count updated' ); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'delete credential' => sub { >+ plan tests => 1; >+ $schema->storage->txn_begin; >+ >+ my $borrower = $builder->build_object( { class => 'Koha::Patrons' } ); >+ >+ Koha::WebauthnCredential->new( >+ { >+ borrowernumber => $borrower->borrowernumber, >+ credential_id => 'delete_test_cred', >+ public_key => 'testpubkey', >+ sign_count => 0, >+ } >+ )->store; >+ >+ my $credential = Koha::WebauthnCredentials->find( { credential_id => 'delete_test_cred' } ); >+ $credential->delete; >+ $credential = Koha::WebauthnCredentials->find( { credential_id => 'delete_test_cred' } ); >+ ok( !$credential, 'Credential deleted' ); >+ >+ $schema->storage->txn_rollback; >+}; >diff --git a/t/db_dependent/api/v1/webauthn.t b/t/db_dependent/api/v1/webauthn.t >new file mode 100755 >index 00000000000..daf2762a46d >--- /dev/null >+++ b/t/db_dependent/api/v1/webauthn.t >@@ -0,0 +1,167 @@ >+#!/usr/bin/perl >+ >+# This file is part of Koha. >+# >+# Koha is free software; you can redistribute it and/or modify it >+# under the terms of the GNU General Public License as published by >+# the Free Software Foundation; either version 3 of the License, or >+# (at your option) any later version. >+# >+# Koha is distributed in the hope that it will be useful, but >+# WITHOUT ANY WARRANTY; without even the implied warranty of >+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the >+# GNU General Public License for more details. >+# >+# You should have received a copy of the GNU General Public License >+# along with Koha; if not, see <https://www.gnu.org/licenses>. >+ >+use Modern::Perl; >+use Test::More tests => 6; >+use Test::NoWarnings; >+ >+use Koha::Database; >+use t::lib::TestBuilder; >+use t::lib::Mocks; >+use Test::Mojo; >+ >+my $schema = Koha::Database->new->schema; >+my $builder = t::lib::TestBuilder->new; >+ >+subtest 'POST /api/v1/webauthn/register/challenge accepts patron_id or userid' => sub { >+ plan tests => 8; >+ $schema->storage->txn_begin; >+ >+ t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); >+ t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'https://127.0.0.1' ); >+ my $password = 'AbcdEFG123'; >+ my $librarian = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { userid => 'testuserapi1', flags => 2**2 } # catalogue >+ } >+ ); >+ $librarian->set_password( { password => $password, skip_validation => 1 } ); >+ my $patron_id = $librarian->borrowernumber; >+ my $userid = $librarian->userid; >+ >+ my $t = Test::Mojo->new('Koha::REST::V1'); >+ >+ $t->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json => { patron_id => $patron_id } ) >+ ->status_is(200) >+ ->json_has('/challenge') >+ ->json_has('/rp_id'); >+ >+ $t->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json => { userid => $userid } ) >+ ->status_is(200) >+ ->json_has('/challenge') >+ ->json_has('/rp_id'); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'POST /api/v1/webauthn/authenticate/challenge returns 404 without credentials' => sub { >+ plan tests => 4; >+ $schema->storage->txn_begin; >+ >+ t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); >+ t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'https://127.0.0.1' ); >+ my $password = 'AbcdEFG123'; >+ my $librarian = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { userid => 'testuserapi2', flags => 2**2 } # catalogue >+ } >+ ); >+ $librarian->set_password( { password => $password, skip_validation => 1 } ); >+ my $patron_id = $librarian->borrowernumber; >+ my $userid = $librarian->userid; >+ >+ my $t = Test::Mojo->new('Koha::REST::V1'); >+ >+ $t->post_ok( "//$userid:$password@/api/v1/webauthn/authenticate/challenge" => json => { patron_id => $patron_id } ) >+ ->status_is(404); >+ >+ $t->post_ok( "//$userid:$password@/api/v1/webauthn/authenticate/challenge" => json => { userid => $userid } ) >+ ->status_is(404); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'POST /api/v1/webauthn/register stores credential (mocked)' => sub { >+ plan tests => 4; >+ $schema->storage->txn_begin; >+ t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); >+ t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'https://127.0.0.1' ); >+ my $password = 'AbcdEFG123'; >+ my $librarian = >+ $builder->build_object( { class => 'Koha::Patrons', value => { userid => 'tuser3', flags => 2**2 } } ); >+ $librarian->set_password( { password => $password, skip_validation => 1 } ); >+ my $userid = $librarian->userid; >+ my $t2 = Test::Mojo->new('Koha::REST::V1'); >+ >+ # Request challenge >+ $t2->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json => >+ { patron_id => $librarian->borrowernumber } )->status_is(200); >+ >+ # Post a dummy attestation that should be unauthorized by validator >+ $t2->post_ok( >+ "//$userid:$password@/api/v1/webauthn/register" => json => { >+ patron_id => $librarian->borrowernumber, >+ attestation_response => { client_data_json => 'AA', attestation_object => 'AA' } >+ } >+ )->status_is(401); >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'POST /api/v1/webauthn/register/challenge returns 404 for non-existent patron' => sub { >+ plan tests => 2; >+ $schema->storage->txn_begin; >+ t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); >+ t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'https://127.0.0.1' ); >+ my $password = 'AbcdEFG123'; >+ my $librarian = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { userid => 'testuserapi4', flags => 2**2 } >+ } >+ ); >+ $librarian->set_password( { password => $password, skip_validation => 1 } ); >+ my $userid = $librarian->userid; >+ my $t = Test::Mojo->new('Koha::REST::V1'); >+ >+ $t->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json => { patron_id => 999999999 } ) >+ ->status_is(404); >+ >+ $schema->storage->txn_rollback; >+}; >+ >+subtest 'POST /api/v1/webauthn/register rejects missing attestation fields' => sub { >+ plan tests => 4; >+ $schema->storage->txn_begin; >+ t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); >+ t::lib::Mocks::mock_preference( 'StaffClientBaseURL', 'https://127.0.0.1' ); >+ my $password = 'AbcdEFG123'; >+ my $librarian = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { userid => 'testuserapi5', flags => 2**2 } >+ } >+ ); >+ $librarian->set_password( { password => $password, skip_validation => 1 } ); >+ my $userid = $librarian->userid; >+ my $t = Test::Mojo->new('Koha::REST::V1'); >+ >+ # First get a challenge so session is populated >+ $t->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json => >+ { patron_id => $librarian->borrowernumber } )->status_is(200); >+ >+ # Missing attestation fields >+ $t->post_ok( >+ "//$userid:$password@/api/v1/webauthn/register" => json => { >+ patron_id => $librarian->borrowernumber, >+ attestation_response => {} >+ } >+ )->status_is(400); >+ >+ $schema->storage->txn_rollback; >+}; >-- >2.39.5
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 39601
:
185295
|
185296
|
185297
|
190988
|
190989
|
194696
| 194697 |
194698