From 27a2538ba8829a13410ec8bac866b0b25af654d5 Mon Sep 17 00:00:00 2001 From: Paul Derscheid 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://-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 . + +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. 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 or C. +Throws L 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 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 or +L 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 . + +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 . + +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 %] +
+ + + [% USE Asset %] + [% Asset.js('js/webauthn-login.js') | $raw %] +
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 @@
  • Renew patron
  • + [% IF ( !is_anonymous && loggedinuser.borrowernumber == patron.borrowernumber ) %] +
  • + Register passkey +
  • + [% END %] [% ELSE %]
  • Renew patron @@ -168,6 +173,9 @@ +[% IF ( !is_anonymous && loggedinuser.borrowernumber == patron.borrowernumber ) %] + [% INCLUDE 'passkey-register.inc' %] +[% END %]