Bugzilla – Attachment 185297 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), 57.44 KB, created by
Paul Derscheid
on 2025-08-10 16:23:10 UTC
(
hide
)
Description:
Bug 39601: Add staff passkeys (WebAuthn) support
Filename:
MIME Type:
Creator:
Paul Derscheid
Created:
2025-08-10 16:23:10 UTC
Size:
57.44 KB
patch
obsolete
>From 112a06e53339423e836a6edfc2a9dd464025f9a6 Mon Sep 17 00:00:00 2001 >From: Paul Derscheid <paul.derscheid@lmscloud.de> >Date: Sun, 11 May 2025 22:06:42 +0000 >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 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 >- Use Authen::WebAuthn validate_registration/validate_assertion >- Generate cryptographically secure challenges; store challenge and patron_id > in the session >- 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 >- Persist credentials (credential_id, public_key as raw bytes) in the new > webauthn_credentials table; set created_on as DATETIME; update sign_count > and last_used after successful authentication >- Build allowCredentials from stored credential IDs for authentication > challenges >- On successful authentication, issue a staff session and set the CGISESSID > cookie so the user is logged in to the staff interface > >API: >- Add api/v1/swagger/paths/webauthn.yaml defining the WebAuthn endpoints, > request/response schemas (including allowCredentials), and x-koha- > authorization requiring the catalogue permission > >DB: >- Add webauthn_credentials table via installer/data/mysql/atomicupdate/ > bug_39601_add_passkey_support.pl >- Add Koha::Schema::Result::WebauthnCredential and 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 > >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 > >Documentation notes: >- StaffClientBaseURL must match the scheme/host used by the browser (typically > https). Ensure staff is served over the same origin for WebAuthn. >- New routes under /api/v1/webauthn and a new table webauthn_credentials. > >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/WebauthnCredential.t > - prove t/db_dependent/api/v1/webauthn.t >8) Sign off or review and FQA. > >NOTE: I think the controllers still need refactoring, this is more a POC regarding the backend. >--- > Koha/Auth/WebAuthn.pm | 78 ++++ > Koha/REST/V1/Auth.pm | 5 +- > Koha/REST/V1/Webauthn.pm | 408 ++++++++++++++++++ > Koha/WebauthnCredential.pm | 18 + > Koha/WebauthnCredentials.pm | 17 + > api/v1/swagger/paths/webauthn.yaml | 196 +++++++++ > api/v1/swagger/swagger.yaml | 61 +++ > .../prog/en/includes/auth-webauthn.inc | 11 + > .../prog/en/includes/members-toolbar.inc | 8 + > .../prog/en/includes/passkey-register.inc | 34 ++ > .../intranet-tmpl/prog/en/modules/auth.tt | 6 +- > .../intranet-tmpl/prog/js/passkey-register.js | 145 +++++++ > .../intranet-tmpl/prog/js/webauthn-login.js | 135 ++++++ > members/moremember.pl | 7 + > t/db_dependent/Koha/WebauthnCredentials.t | 76 ++++ > t/db_dependent/api/v1/webauthn.t | 93 ++++ > 16 files changed, 1295 insertions(+), 3 deletions(-) > create mode 100644 Koha/Auth/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 100644 t/db_dependent/Koha/WebauthnCredentials.t > create mode 100644 t/db_dependent/api/v1/webauthn.t > >diff --git a/Koha/Auth/WebAuthn.pm b/Koha/Auth/WebAuthn.pm >new file mode 100644 >index 0000000000..ffa432f883 >--- /dev/null >+++ b/Koha/Auth/WebAuthn.pm >@@ -0,0 +1,78 @@ >+# Koha/Auth/WebAuthn.pm >+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 <http://www.gnu.org/licenses>. >+ >+use Modern::Perl; >+use Authen::WebAuthn; >+ >+=head1 NAME >+ >+Koha::Auth::WebAuthn - WebAuthn (Passkey) authentication for Koha >+ >+=head1 SYNOPSIS >+ >+ use Koha::Auth::WebAuthn; >+ >+ my $wa = Koha::Auth::WebAuthn->new({ rp_id => 'example.com', origin => 'https://example.com' }); >+ my $challenge = $wa->generate_challenge(); >+ my $result = $wa->verify_assertion($assertion_json, $expected_challenge, $credential_public_key, ...); >+ >+=head1 DESCRIPTION >+ >+This module encapsulates WebAuthn (Passkey) authentication logic for Koha, using Authen::WebAuthn. >+ >+=cut >+ >+sub new { >+ my ( $class, $params ) = @_; >+ my $self = { >+ rp_id => $params->{rp_id}, >+ origin => $params->{origin}, >+ webauthn => Authen::WebAuthn->new( >+ rp_id => $params->{rp_id}, >+ origin => $params->{origin}, >+ ), >+ }; >+ bless $self, $class; >+ return $self; >+} >+ >+sub generate_challenge { >+ my ($self) = @_; >+ return $self->{webauthn}->generate_challenge(); >+} >+ >+sub verify_assertion { >+ my ( $self, $assertion_json, $expected_challenge, $credential_public_key, $user_handle ) = @_; >+ return $self->{webauthn} >+ ->verify_assertion( $assertion_json, $expected_challenge, $credential_public_key, $user_handle ); >+} >+ >+sub verify_registration { >+ my ( $self, $attestation_json, $expected_challenge ) = @_; >+ return $self->{webauthn}->verify_attestation( $attestation_json, $expected_challenge ); >+} >+ >+1; >+ >+__END__ >+ >+=head1 AUTHOR >+ >+Koha Development Team >+ >+=cut >diff --git a/Koha/REST/V1/Auth.pm b/Koha/REST/V1/Auth.pm >index fd22f1be68..957c21f4c8 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 0000000000..6a502c5a45 >--- /dev/null >+++ b/Koha/REST/V1/Webauthn.pm >@@ -0,0 +1,408 @@ >+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 <http://www.gnu.org/licenses>. >+ >+use Modern::Perl; >+use Mojo::Base 'Mojolicious::Controller'; >+use Try::Tiny; >+use C4::Context; >+use Mojo::URL; >+use Koha::Patrons; >+use Koha::WebauthnCredential; >+use Koha::WebauthnCredentials; >+use Authen::WebAuthn; >+use MIME::Base64 qw(encode_base64 decode_base64); >+use JSON qw(decode_json encode_json); >+use DateTime; >+use Fcntl qw(O_RDONLY); >+ >+=head1 NAME >+ >+Koha::REST::V1::Webauthn >+ >+=head1 API >+ >+=head2 Methods >+ >+=cut >+ >+sub _resolve_patron { >+ my ($c) = @_; >+ my $body = $c->req->json; >+ my $patron_id = $body->{patron_id}; >+ my $userid = $body->{userid}; >+ my $patron; >+ if ($patron_id) { >+ $patron = Koha::Patrons->find($patron_id); >+ } elsif ($userid) { >+ $patron = Koha::Patrons->find( { userid => $userid } ); >+ } >+ return $patron; >+} >+ >+sub _webauthn_origin_and_rp_id { >+ my ($c) = @_; >+ my $pref_base = C4::Context->preference('StaffClientBaseURL'); >+ 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 ); >+ >+ if ($pref_base) { >+ 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 a password manager or proxy upgraded the scheme to https but host matches, >+ # prefer the effective request scheme/port to avoid origin mismatches. >+ 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; # honor request port on upgrade >+ } else { >+ # Keep configured secure scheme when request is http but pref is https >+ $scheme = $pref_scheme; >+ $port = defined $pref_port ? $pref_port : $req_port; >+ } >+ } else { >+ # same scheme; prefer explicit configured port, otherwise request port >+ $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; >+ } >+ } >+ >+ my $origin = $scheme . '://' . $host . ( $port && $port !~ /^(80|443)$/ ? ':' . $port : '' ); >+ my $rp_id = $host; # rp_id must be a registrable domain, no scheme/port >+ return ( $origin, $rp_id ); >+} >+ >+sub _maybe_upgrade_origin_to_client { >+ my ( $origin, $rp_id, $client_origin ) = @_; >+ return $origin unless $client_origin; >+ my $co = Mojo::URL->new($client_origin); >+ my $o = Mojo::URL->new($origin); >+ # Allow only http -> https upgrade for the same host (rp_id) >+ 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; >+} >+ >+sub _generate_random_bytes { >+ my ($length) = @_; >+ $length ||= 32; >+ my $bytes = ''; >+ if ( sysopen( my $fh, '/dev/urandom', O_RDONLY ) ) { >+ read( $fh, $bytes, $length ); >+ close $fh; >+ } >+ return $bytes; >+} >+ >+sub _to_base64url { >+ my ($bytes) = @_; >+ my $b64 = encode_base64( $bytes, '' ); >+ $b64 =~ tr{+/}{-_}; >+ $b64 =~ s/=+$//; >+ return $b64; >+} >+ >+sub _std_b64_to_b64url { >+ my ($b64) = @_; >+ $b64 =~ tr{+/}{-_}; >+ $b64 =~ s/=+$//; >+ return $b64; >+} >+ >+sub _b64url_to_std { >+ my ($b64u) = @_; >+ return unless defined $b64u; >+ my $b64 = $b64u; >+ $b64 =~ tr{-_}{+/}; >+ my $pad = ( 4 - ( length($b64) % 4 ) ) % 4; >+ $b64 .= '=' x $pad; >+ return $b64; >+} >+ >+sub register_challenge { >+ my $c = shift->openapi->valid_input or return; >+ return try { >+ my $patron = _resolve_patron($c); >+ return $c->render( status => 404, openapi => { error => 'Patron not found' } ) unless $patron; >+ my $patron_id = $patron->borrowernumber; >+ >+ my ( $origin, $rp_id ) = _webauthn_origin_and_rp_id($c); >+ my $challenge_bytes = _generate_random_bytes(32); >+ my $challenge_b64u = _to_base64url($challenge_bytes); >+ >+ $c->session( webauthn_challenge => $challenge_b64u ); >+ $c->session( webauthn_patron_id => $patron_id ); >+ >+ $c->render( >+ openapi => { >+ challenge => $challenge_b64u, >+ user => { >+ id => $patron_id, >+ name => $patron->userid, >+ }, >+ } >+ ); >+ } catch { >+ $c->unhandled_exception($_); >+ }; >+} >+ >+sub register { >+ my $c = shift->openapi->valid_input or return; >+ return try { >+ my $body = $c->req->json; >+ my $patron; >+ my $patron_id; >+ if ( $body->{patron_id} ) { >+ $patron = Koha::Patrons->find( $body->{patron_id} ); >+ $patron_id = $body->{patron_id}; >+ } elsif ( $body->{userid} ) { >+ $patron = Koha::Patrons->find( { userid => $body->{userid} } ); >+ $patron_id = $patron ? $patron->borrowernumber : undef; >+ } else { >+ return $c->render( status => 400, openapi => { error => 'Missing patron_id or userid in request' } ); >+ } >+ my $att = $body->{attestation_response} // {}; >+ return $c->render( status => 400, openapi => { error => 'Missing attestation_response' } ) unless %$att; >+ >+ my ( $origin, $rp_id ) = _webauthn_origin_and_rp_id($c); >+ my $client_origin = eval { >+ my $cdj_json = decode_base64( $att->{client_data_json} // '' ); >+ my $cdj = decode_json($cdj_json); >+ $cdj->{origin}; >+ }; >+ $origin = _maybe_upgrade_origin_to_client( $origin, $rp_id, $client_origin ); >+ my $webauthn = Authen::WebAuthn->new( rp_id => $rp_id, origin => $origin ); >+ my $challenge_b64u = $c->session('webauthn_challenge'); >+ >+ my $res = eval { >+ $webauthn->validate_registration( >+ challenge_b64 => $challenge_b64u, >+ requested_uv => 'preferred', >+ client_data_json_b64 => _std_b64_to_b64url( $att->{client_data_json} ), >+ attestation_object_b64 => _std_b64_to_b64url( $att->{attestation_object} ), >+ ); >+ }; >+ if ( $@ || !$res ) { >+ $c->app->log->warn( 'webauthn register failed: ' . ( $@ // 'undef' ) ); >+ return $c->render( status => 401, openapi => { 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_on => DateTime->now->strftime('%F %T'), >+ } >+ )->store; >+ >+ $c->render( status => 201, openapi => { success => 1 } ); >+ } catch { >+ $c->unhandled_exception($_); >+ }; >+} >+ >+sub authenticate_challenge { >+ my $c = shift->openapi->valid_input or return; >+ return try { >+ $c->app->log->debug( 'authenticate_challenge: incoming body: ' . encode_json( $c->req->json // {} ) ); >+ my $patron = _resolve_patron($c); >+ $c->app->log->debug( 'authenticate_challenge: resolved patron: ' >+ . ( defined $patron ? $patron->borrowernumber . ' (' . ( $patron->userid // '' ) . ')' : 'undef' ) ); >+ return $c->render( status => 404, openapi => { error => 'Patron not found' } ) unless $patron; >+ my $patron_id = $patron->borrowernumber; >+ $c->app->log->debug("authenticate_challenge: patron_id: $patron_id"); >+ >+ my $credentials = Koha::WebauthnCredentials->search( { borrowernumber => $patron_id } ); >+ $c->app->log->debug( 'authenticate_challenge: credentials found: ' . $credentials->count ); >+ return $c->render( status => 404, openapi => { error => 'No credentials registered' } ) >+ unless $credentials->count; >+ >+ my ( $origin, $rp_id ) = _webauthn_origin_and_rp_id($c); >+ my $challenge_bytes = _generate_random_bytes(32); >+ my $challenge_b64u = _to_base64url($challenge_bytes); >+ $c->app->log->debug( 'authenticate_challenge: generated challenge: ' . $challenge_b64u ); >+ $c->session( webauthn_challenge => $challenge_b64u ); >+ $c->session( webauthn_patron_id => $patron_id ); >+ >+ my @allow_credentials; >+ while ( my $c = $credentials->next ) { >+ push @allow_credentials, >+ { id => _std_b64_to_b64url( encode_base64( $c->credential_id, '' ) ), type => 'public-key' }; >+ } >+ $c->app->log->debug( 'authenticate_challenge: allow_credentials count: ' . scalar @allow_credentials ); >+ >+ $c->render( >+ openapi => { >+ challenge => $challenge_b64u, >+ allowCredentials => \@allow_credentials, >+ } >+ ); >+ } catch { >+ $c->unhandled_exception($_); >+ }; >+ >+} >+ >+sub authenticate { >+ my $c = shift->openapi->valid_input or return; >+ return try { >+ my $body = $c->req->json; >+ my $patron; >+ my $patron_id; >+ if ( $body->{patron_id} ) { >+ $patron = Koha::Patrons->find( $body->{patron_id} ); >+ $patron_id = $body->{patron_id}; >+ } elsif ( $body->{userid} ) { >+ $patron = Koha::Patrons->find( { userid => $body->{userid} } ); >+ $patron_id = $patron ? $patron->borrowernumber : undef; >+ } else { >+ return $c->render( status => 400, openapi => { error => 'Missing patron_id or userid in request' } ); >+ } >+ my $assertion = $body->{assertion_response}; >+ return $c->render( status => 400, openapi => { error => 'Missing assertion_response' } ) unless $assertion; >+ >+ my $credentials = Koha::WebauthnCredentials->search( { borrowernumber => $patron_id } ); >+ my ( %cred_id_map, %pubkeys ); >+ { >+ my $it = $credentials->search( {}, { columns => [qw/id credential_id public_key/] } ); >+ while ( my $c = $it->next ) { >+ my $id_b64u = _std_b64_to_b64url( encode_base64( $c->credential_id, '' ) ); >+ $cred_id_map{$id_b64u} = $c->id; >+ $pubkeys{$id_b64u} = _std_b64_to_b64url( encode_base64( $c->public_key, '' ) ); >+ } >+ } >+ my $challenge_b64u = $c->session('webauthn_challenge'); >+ my ( $origin, $rp_id ) = _webauthn_origin_and_rp_id($c); >+ my $webauthn = Authen::WebAuthn->new( rp_id => $rp_id, origin => $origin ); >+ >+ # Browser sends base64 (we need base64url) >+ my $client_data_b64u = >+ _std_b64_to_b64url( $assertion->{clientDataJSON} // $assertion->{client_data_json} // '' ); >+ my $client_origin = eval { >+ my $cdj_b64 = $assertion->{clientDataJSON} // $assertion->{client_data_json} // ''; >+ my $cdj_json = decode_base64($cdj_b64); >+ my $cdj = decode_json($cdj_json); >+ $cdj->{origin}; >+ }; >+ $origin = _maybe_upgrade_origin_to_client( $origin, $rp_id, $client_origin ); >+ $webauthn = Authen::WebAuthn->new( rp_id => $rp_id, origin => $origin ); >+ my $auth_data_b64u = >+ _std_b64_to_b64url( $assertion->{authenticatorData} // $assertion->{authenticator_data} // '' ); >+ my $signature_b64u = _std_b64_to_b64url( $assertion->{signature} // '' ); >+ my $credential_id_b64u = _std_b64_to_b64url( $assertion->{id} // $assertion->{raw_id} // '' ); >+ >+ # pubkeys already built above >+ >+ my $stored_sign_count = 0; # optional enforcement; we track after success >+ my $res = eval { >+ $webauthn->validate_assertion( >+ challenge_b64 => $challenge_b64u, >+ credential_pubkey_b64 => $pubkeys{$credential_id_b64u}, >+ 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, >+ ); >+ }; >+ if ( $@ || !$res ) { >+ $c->app->log->warn( 'webauthn authenticate failed: ' . ( $@ // 'undef' ) ); >+ return $c->render( status => 401, openapi => { error => 'Authentication failed' } ); >+ } >+ 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 => DateTime->now->strftime('%F %T') } ) >+ ->store; >+ } >+ } >+ >+ # Issue staff session and cookie for login >+ my $patron_obj = Koha::Patrons->find($patron_id); >+ my $session = C4::Auth::create_basic_session( { patron => $patron_obj, interface => 'intranet' } ); >+ my ( $computed_origin ) = _webauthn_origin_and_rp_id($c); >+ my $is_https = ( $computed_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_obj->library; >+ C4::Context->set_userenv( >+ $patron_obj->borrowernumber, >+ $patron_obj->userid // '', >+ $patron_obj->cardnumber // '', >+ $patron_obj->firstname // '', >+ $patron_obj->surname // '', >+ ( $lib ? $lib->branchcode : '' ), >+ ( $lib ? $lib->branchname : '' ), >+ $patron_obj->flags, >+ $patron_obj->email // '', >+ ); >+ $c->render( status => 200, openapi => { success => 1 } ); >+ } catch { >+ $c->unhandled_exception($_); >+ }; >+} >+ >+1; >diff --git a/Koha/WebauthnCredential.pm b/Koha/WebauthnCredential.pm >new file mode 100644 >index 0000000000..be8bbe8df8 >--- /dev/null >+++ b/Koha/WebauthnCredential.pm >@@ -0,0 +1,18 @@ >+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 0000000000..cd70b43da9 >--- /dev/null >+++ b/Koha/WebauthnCredentials.pm >@@ -0,0 +1,17 @@ >+package Koha::WebauthnCredentials; >+ >+use Modern::Perl; >+use base qw(Koha::Objects); >+ >+sub _type { 'WebauthnCredential' } >+sub object_class { 'Koha::WebauthnCredential' } >+ >+1; >+ >+__END__ >+ >+=head1 NAME >+ >+Koha::WebauthnCredentials - Koha Objects class for webauthn_credentials >+ >+=cut >diff --git a/api/v1/swagger/paths/webauthn.yaml b/api/v1/swagger/paths/webauthn.yaml >new file mode 100644 >index 0000000000..3624933c52 >--- /dev/null >+++ b/api/v1/swagger/paths/webauthn.yaml >@@ -0,0 +1,196 @@ >+--- >+/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" >+ x-koha-authorization: >+ permissions: >+ catalogue: "1" >+ >+/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" >+ x-koha-authorization: >+ permissions: >+ catalogue: "1" >diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml >index fb36579cea..5e27cc4205 100644 >--- a/api/v1/swagger/swagger.yaml >+++ b/api/v1/swagger/swagger.yaml >@@ -2,6 +2,55 @@ > swagger: "2.0" > basePath: /api/v1 > definitions: >+ WebauthnChallenge: >+ type: object >+ properties: >+ challenge: >+ type: string >+ user: >+ type: object >+ properties: >+ id: >+ type: integer >+ name: >+ type: string >+ WebauthnAuthChallenge: >+ type: object >+ properties: >+ challenge: >+ 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 > advancededitormacro: >@@ -585,6 +634,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 >@@ -1309,3 +1367,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 0000000000..1959801594 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/auth-webauthn.inc >@@ -0,0 +1,11 @@ >+<div id="webauthn-login-section" class="mb-0"> >+ <button id="webauthn-login-btn" class="btn btn-secondary btn-lg" type="button" title="[% t('Use a passkey to sign in') | html %]"> >+ <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 c40f34ebce..3c7be53b8d 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 0000000000..1a9fed0533 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/passkey-register.inc >@@ -0,0 +1,34 @@ >+<!-- 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> >+ </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">Create 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 4a0a210904..716453521e 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 0000000000..6965fdade3 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/passkey-register.js >@@ -0,0 +1,145 @@ >+// Passkey registration script for patron page >+ >+(function () { >+ 'use strict'; >+ >+ function qs(id) { return document.getElementById(id); } >+ >+ function text(id) { >+ var el = qs(id); >+ return el ? el.textContent : ''; >+ } >+ >+ function getPatronContext() { >+ var idText = text('patron-borrowernumber'); >+ var patronId = idText ? idText.trim().replace(/[^0-9]/g, '') : null; >+ var usernameText = text('patron-username'); >+ var userid = usernameText ? usernameText.replace('Username:', '').trim() : null; >+ return { patronId: patronId, userid: userid }; >+ } >+ >+ 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; >+ } >+ >+ function toBase64(arrBuf) { >+ return window.btoa(String.fromCharCode.apply(null, new Uint8Array(arrBuf))); >+ } >+ >+ 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(); >+ } >+ >+ function buildPublicKeyOptions(challengeJson, ctx) { >+ const userIdBytes = new TextEncoder().encode(String(ctx.patronId || ctx.userid)); >+ return { >+ challenge: b64uToBytes(challengeJson.challenge), >+ rp: { name: 'Koha' }, >+ 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' >+ }; >+ } >+ >+ async function createCredential(publicKey) { >+ return navigator.credentials.create({ publicKey: publicKey }); >+ } >+ >+ 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(function () { return ''; }); >+ throw new Error(__('Server rejected registration{suffix}').format({ suffix: errTxt ? ': ' + errTxt : '' })); >+ } >+ } >+ >+ function onSuccess() { >+ var successEl = qs('passkey-register-success-modal'); >+ var confirmBtn = qs('passkey-register-confirm'); >+ var doneBtn = qs('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'); >+ } >+ >+ function onError(e) { >+ var errorEl = qs('passkey-register-error-modal'); >+ if (!errorEl) return; >+ var msg = (e && e.name === 'AbortError') ? __('Registration canceled') : (e && e.message ? e.message : String(e)); >+ errorEl.textContent = __('Registration error: {msg}').format({ msg: msg }); >+ errorEl.style.display = ''; >+ } >+ >+ function attachHandlers() { >+ var confirmBtn = qs('passkey-register-confirm'); >+ if (!confirmBtn) return; >+ >+ confirmBtn.addEventListener('click', async function () { >+ // Feature detection >+ if (!window.PublicKeyCredential) { >+ onError(new Error(__('Passkeys are not supported by this browser.'))); >+ return; >+ } >+ var ctx = getPatronContext(); >+ if (!ctx.patronId && !ctx.userid) { >+ onError(new Error(__('Cannot determine patron context'))); >+ return; >+ } >+ // Hide messages >+ var successEl = qs('passkey-register-success-modal'); >+ var errorEl = qs('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 0000000000..74461e7fd0 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/webauthn-login.js >@@ -0,0 +1,135 @@ >+// WebAuthn login script for Koha staff interface >+ >+(function () { >+ 'use strict'; >+ >+ function toBase64(arrBuf) { >+ return window.btoa(String.fromCharCode.apply(null, new Uint8Array(arrBuf))); >+ } >+ >+ 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; >+ } >+ >+ function buildLoginPayload(usernameValue) { >+ if (!usernameValue) return null; >+ const isNumeric = !isNaN(usernameValue); >+ return isNumeric ? { patron_id: usernameValue } : { userid: usernameValue }; >+ } >+ >+ function getUsernameInput() { >+ return document.getElementById('userid'); >+ } >+ >+ function getErrorContainer() { >+ return document.getElementById('webauthn-login-error'); >+ } >+ >+ 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(); >+ } >+ >+ async function performAssertion(opts) { >+ // Convert base64url fields to bytes as required by WebAuthn API >+ const publicKey = Object.assign({}, opts, { >+ challenge: b64uToBytes(opts.challenge), >+ allowCredentials: Array.isArray(opts.allowCredentials) >+ ? opts.allowCredentials.map(function (cred) { >+ return Object.assign({}, cred, { id: b64uToBytes(cred.id) }); >+ }) >+ : undefined >+ }); >+ return navigator.credentials.get({ publicKey: publicKey }); >+ } >+ >+ 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')); >+ } >+ >+ function attachClickHandler(button) { >+ button.addEventListener('click', async function () { >+ const errorEl = getErrorContainer(); >+ if (errorEl) errorEl.style.display = 'none'; >+ >+ // Guard: WebAuthn support >+ if (!window.PublicKeyCredential) 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) { >+ const msg = (e && e.name === 'NotAllowedError') >+ ? __('Passkey request was cancelled or timed out. Please try again.') >+ : __('WebAuthn error: {error}').format({ error: String(e) }); >+ errorEl.textContent = msg; >+ errorEl.style.display = ''; >+ } >+ } >+ }); >+ } >+ >+ function initialize() { >+ var btn = document.getElementById('webauthn-login-btn'); >+ if (!btn) return; // Guard: button not present >+ attachClickHandler(btn); >+ } >+ >+ document.addEventListener('DOMContentLoaded', initialize); >+})(); >+ >+ >diff --git a/members/moremember.pl b/members/moremember.pl >index 49b87e97d6..4550d2f4ee 100755 >--- a/members/moremember.pl >+++ b/members/moremember.pl >@@ -78,6 +78,12 @@ output_and_exit_if_error( > > my $category_type = $patron->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; > } >@@ -111,6 +117,7 @@ if (@guarantors) { > $template->param( > guarantor_relationships => $guarantor_relationships, > guarantees => \@guarantees, >+ loggedinuser => $logged_in_user, > ); > > my $relatives_issues_count = Koha::Checkouts->count( { borrowernumber => \@relatives } ); >diff --git a/t/db_dependent/Koha/WebauthnCredentials.t b/t/db_dependent/Koha/WebauthnCredentials.t >new file mode 100644 >index 0000000000..a6322d022c >--- /dev/null >+++ b/t/db_dependent/Koha/WebauthnCredentials.t >@@ -0,0 +1,76 @@ >+#!/usr/bin/perl >+ >+use Modern::Perl; >+use lib 't/lib'; >+use Test::More; >+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; >+ >+$schema->storage->txn_do( >+ sub { >+ my $borrower = $builder->build_object( { class => 'Koha::Patrons' } ); >+ my $borrowernumber = $borrower->borrowernumber; >+ >+ subtest 'add and retrieve credential' => sub { >+ plan tests => 6; >+ 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_on => '2020-01-01 00:00:00', >+ } >+ )->store; >+ ok( $credential && $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' ); >+ }; >+ >+ subtest 'search by borrowernumber' => sub { >+ plan tests => 1; >+ my $creds = Koha::WebauthnCredentials->search( { borrowernumber => $borrowernumber } ); >+ ok( $creds->count >= 1, 'At least one credential for borrower' ); >+ }; >+ >+ subtest 'update sign_count' => sub { >+ plan tests => 1; >+ my $credential = Koha::WebauthnCredentials->find( { credential_id => 'testcredentialid' } ); >+ $credential->set( { sign_count => 42 } )->store; >+ $credential = Koha::WebauthnCredentials->find( { credential_id => 'testcredentialid' } ); >+ is( $credential->sign_count, 42, 'Sign count updated' ); >+ }; >+ >+ subtest 'delete credential' => sub { >+ plan tests => 1; >+ my $credential = Koha::WebauthnCredentials->find( { credential_id => 'testcredentialid' } ); >+ $credential->delete; >+ $credential = Koha::WebauthnCredentials->find( { credential_id => 'testcredentialid' } ); >+ ok( !$credential, 'Credential deleted' ); >+ }; >+ >+ } >+); >+ >+done_testing(); >+ >+ >diff --git a/t/db_dependent/api/v1/webauthn.t b/t/db_dependent/api/v1/webauthn.t >new file mode 100644 >index 0000000000..a0930a6af3 >--- /dev/null >+++ b/t/db_dependent/api/v1/webauthn.t >@@ -0,0 +1,93 @@ >+#!/usr/bin/perl >+ >+use Modern::Perl; >+use Test::More tests => 4; >+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 => 6; >+ $schema->storage->txn_begin; >+ >+ t::lib::Mocks::mock_preference( 'RESTBasicAuth', 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'); >+ >+ $t->post_ok( "//$userid:$password@/api/v1/webauthn/register/challenge" => json => { userid => $userid } ) >+ ->status_is(200)->json_has('/challenge'); >+ >+ $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 ); >+ 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', 'http://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; >+}; >-- >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