From 112a06e53339423e836a6edfc2a9dd464025f9a6 Mon Sep 17 00:00:00 2001 From: Paul Derscheid 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://-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 . + +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 . + +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 @@ +
+ + + [% 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 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 @@
  • 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 %]