Add WebAuthn passkey challenge and verification endpoints
- Introduced endpoints for WebAuthn-based authentication flow (`/auth/passkey/challenge` and `/auth/passkey/verify`). - Added support for generating and verifying WebAuthn PublicKeyCredentialRequestOptions and challenge tokens. - Extended routing logic to expose matched route templates for improved parameter handling. - Updated OpenAPI specifications to document passkey challenge and verification workflows. - Included unit tests for validating both existing and non-existing user scenarios during passkey challenges.
This commit is contained in:
+132
@@ -1131,6 +1131,138 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
/auth/passkey/challenge:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Initiate passkey authentication challenge
|
||||
description: Generates a WebAuthn PublicKeyCredentialRequestOptions payload. If customer_number is provided, allowCredentials will be populated with existing passkeys for that account. Otherwise, a challenge is issued for discoverable credentials.
|
||||
operationId: passkeyChallenge
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
customer_number:
|
||||
type: integer
|
||||
description: Optional customer's e-conomic customer number
|
||||
example: 12345
|
||||
responses:
|
||||
'200':
|
||||
description: Challenge generated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
challenge_token:
|
||||
type: string
|
||||
description: Temporary token binding the challenge to the login attempt
|
||||
publicKey:
|
||||
type: object
|
||||
properties:
|
||||
challenge:
|
||||
type: string
|
||||
description: Base64URL-encoded challenge
|
||||
rpId:
|
||||
type: string
|
||||
description: Relying party ID (domain)
|
||||
timeout:
|
||||
type: integer
|
||||
description: Timeout in milliseconds
|
||||
userVerification:
|
||||
type: string
|
||||
enum: [required, preferred, discouraged]
|
||||
allowCredentials:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
example: public-key
|
||||
id:
|
||||
type: string
|
||||
description: Base64URL-encoded credential ID
|
||||
transports:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
|
||||
/auth/passkey/verify:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Verify passkey authentication and start session
|
||||
description: Verifies the WebAuthn assertion and challenge token. Returns a session token on success.
|
||||
operationId: passkeyVerify
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- challenge_token
|
||||
- id
|
||||
- response
|
||||
properties:
|
||||
challenge_token:
|
||||
type: string
|
||||
description: The token returned by the challenge endpoint
|
||||
id:
|
||||
type: string
|
||||
description: The credential ID (base64url)
|
||||
response:
|
||||
type: object
|
||||
required:
|
||||
- clientDataJSON
|
||||
- authenticatorData
|
||||
- signature
|
||||
properties:
|
||||
clientDataJSON:
|
||||
type: string
|
||||
description: Base64URL-encoded client data
|
||||
authenticatorData:
|
||||
type: string
|
||||
description: Base64URL-encoded authenticator data
|
||||
signature:
|
||||
type: string
|
||||
description: Base64URL-encoded signature
|
||||
userHandle:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Base64URL-encoded user handle
|
||||
responses:
|
||||
'200':
|
||||
description: Verification successful, session started
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- type: object
|
||||
required: [token]
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: Bearer token for customer
|
||||
- type: object
|
||||
required: [session]
|
||||
properties:
|
||||
session:
|
||||
type: string
|
||||
description: Session token for subuser
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
/auth/logout:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -14,6 +14,7 @@ use objects\logs_o;
|
||||
use objects\tokens_o;
|
||||
use objects\users_o;
|
||||
use objects\subusers_o;
|
||||
use objects\passkeys_o;
|
||||
use traits\route_t;
|
||||
|
||||
class authRoute
|
||||
@@ -493,5 +494,186 @@ class authRoute
|
||||
$response->error($e->getMessage(), 400);
|
||||
}
|
||||
});
|
||||
|
||||
$this->post('/auth/passkey/challenge', function () {
|
||||
global $response;
|
||||
$this->requireRecaptcha();
|
||||
|
||||
$customer_number = self::getParameter('customer_number');
|
||||
$user_id = 0;
|
||||
$allowCredentials = [];
|
||||
|
||||
if ($customer_number !== null) {
|
||||
$customer_number = (int)$customer_number;
|
||||
self::requireType($customer_number, $this->type_int());
|
||||
self::requireMinValue($customer_number, 1);
|
||||
|
||||
// Attempt to locate user; do not reveal existence in response
|
||||
$user = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
|
||||
if ($user->exists()) {
|
||||
$user_id = (int)$user->id;
|
||||
|
||||
// Load passkeys for this user (non-subuser)
|
||||
$passkeys = new passkeys_o();
|
||||
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user_id . ' AND `is_subuser` = 0');
|
||||
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
||||
$transports = null;
|
||||
if (isset($o['transports'])) {
|
||||
$decoded = json_decode($o['transports'], true);
|
||||
$transports = is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
return [
|
||||
'type' => 'public-key',
|
||||
'id' => $o['credential_id'] ?? null,
|
||||
'transports' => $transports,
|
||||
];
|
||||
});
|
||||
|
||||
// Ensure we return a simple array of credentials (without pagination wrapper)
|
||||
if (isset($list['items']) && is_array($list['items'])) {
|
||||
$allowCredentials = array_values(array_filter($list['items'], function ($item) {
|
||||
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
||||
}));
|
||||
} elseif (is_array($list)) {
|
||||
$allowCredentials = array_values(array_filter($list, function ($item) {
|
||||
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a challenge (Base64URL without padding)
|
||||
$challenge_token = bin2hex(random_bytes(32));
|
||||
$rawChallenge = hex2bin($challenge_token);
|
||||
$challenge = rtrim(strtr(base64_encode($rawChallenge), '+/', '-_'), '=');
|
||||
|
||||
// rpId from current host (strip port)
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$rpId = explode(':', $host)[0];
|
||||
|
||||
// Create an ephemeral token to bind the challenge to the (potential) user
|
||||
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
|
||||
|
||||
$logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)';
|
||||
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails);
|
||||
|
||||
$response->success([
|
||||
'challenge_token' => $challenge_token,
|
||||
'publicKey' => [
|
||||
'challenge' => $challenge,
|
||||
'rpId' => $rpId,
|
||||
'timeout' => 60000,
|
||||
'userVerification' => 'preferred',
|
||||
'allowCredentials' => $allowCredentials
|
||||
]
|
||||
]);
|
||||
});
|
||||
|
||||
$this->post('/auth/passkey/verify', function () {
|
||||
global $response, $db;
|
||||
$this->requireRecaptcha();
|
||||
self::requireParameters(['challenge_token', 'id', 'response']);
|
||||
|
||||
$challenge_token_str = (string)self::getParameter('challenge_token');
|
||||
$credential_id = (string)self::getParameter('id');
|
||||
$webauthn_response = self::getParameter('response');
|
||||
|
||||
if (!is_array($webauthn_response)) {
|
||||
$response->error('Invalid response format', 400);
|
||||
}
|
||||
|
||||
$token_o = new tokens_o();
|
||||
try {
|
||||
$token = $token_o->getToken($challenge_token_str);
|
||||
} catch (Exception $e) {
|
||||
$response->error('Invalid or expired challenge token', 401);
|
||||
}
|
||||
|
||||
if ($token->type->value() !== 'PASSKEY_CHALLENGE') {
|
||||
$response->error('Invalid token type', 401);
|
||||
}
|
||||
|
||||
// Find the passkey
|
||||
$passkeys = new passkeys_o();
|
||||
$fields = ['id', 'user_id', 'is_subuser', 'public_key', 'sign_count', 'algorithm'];
|
||||
$found = $passkeys->getFieldsWhere(['credential_id' => $credential_id], $fields);
|
||||
|
||||
if (empty($found)) {
|
||||
$response->error('Passkey not found', 401);
|
||||
}
|
||||
|
||||
$passkey_data = $found[0];
|
||||
$user_id = (int)$passkey_data['user_id'];
|
||||
$is_subuser = (bool)$passkey_data['is_subuser'];
|
||||
|
||||
// If the challenge was bound to a specific user, verify it matches
|
||||
if ((int)$token->user_id->value() > 0 && (int)$token->user_id->value() !== $user_id) {
|
||||
$response->error('Passkey does not match requested user', 401);
|
||||
}
|
||||
|
||||
// WebAuthn signature verification
|
||||
$clientDataJSON = $webauthn_response['clientDataJSON'] ?? null;
|
||||
$authenticatorData = $webauthn_response['authenticatorData'] ?? null;
|
||||
$signature = $webauthn_response['signature'] ?? null;
|
||||
|
||||
if (!$clientDataJSON || !$authenticatorData || !$signature) {
|
||||
$response->error('Missing WebAuthn response fields', 400);
|
||||
}
|
||||
|
||||
$decode = function ($data) {
|
||||
return base64_decode(strtr($data, '-_', '+/'));
|
||||
};
|
||||
|
||||
$rawClientDataJSON = $decode($clientDataJSON);
|
||||
$rawAuthenticatorData = $decode($authenticatorData);
|
||||
$rawSignature = $decode($signature);
|
||||
|
||||
$clientData = json_decode($rawClientDataJSON, true);
|
||||
if (!$clientData || !isset($clientData['challenge'])) {
|
||||
$response->error('Invalid clientDataJSON', 400);
|
||||
}
|
||||
|
||||
// Reconstruct and verify challenge
|
||||
$expectedChallenge = rtrim(strtr(base64_encode(hex2bin($challenge_token_str)), '+/', '-_'), '=');
|
||||
if ($clientData['challenge'] !== $expectedChallenge) {
|
||||
$response->error('Challenge mismatch', 401);
|
||||
}
|
||||
|
||||
// Verify signature: S = ES256(authenticatorData || hash(clientDataJSON))
|
||||
$clientDataHash = hash('sha256', $rawClientDataJSON, true);
|
||||
$dataToVerify = $rawAuthenticatorData . $clientDataHash;
|
||||
$publicKey = $passkey_data['public_key'];
|
||||
|
||||
// WebAuthn signatures are DER encoded, which openssl_verify accepts.
|
||||
$algo = OPENSSL_ALGO_SHA256;
|
||||
$verifyResult = openssl_verify($dataToVerify, $rawSignature, $publicKey, $algo);
|
||||
|
||||
if ($verifyResult !== 1) {
|
||||
(new logs_o())->add('auth', 'global', 0, $user_id, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Signature verification failed for credential ' . $credential_id);
|
||||
$response->error('Invalid signature', 401);
|
||||
}
|
||||
|
||||
// Success! Update sign_count
|
||||
$p_obj = (new passkeys_o())->select((int)$passkey_data['id']);
|
||||
$p_obj->update(['sign_count' => (int)$passkey_data['sign_count'] + 1]);
|
||||
|
||||
$token_o->delete($challenge_token_str);
|
||||
$auth = new authentication();
|
||||
|
||||
if ($is_subuser) {
|
||||
$subuser = (new subusers_o())->select($user_id);
|
||||
$session = $subuser->generateSession();
|
||||
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_SUCCESS', 'Subuser login via passkey');
|
||||
$response->success(['session' => $session]);
|
||||
} else {
|
||||
$user = (new users_o())->getUserById($user_id);
|
||||
$customer_number = (int)$user->customer_number->value();
|
||||
$new_token = $auth->create_token($customer_number);
|
||||
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_SUCCESS', 'Customer login via passkey');
|
||||
$response->success(['token' => $new_token]);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
namespace {
|
||||
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
|
||||
|
||||
// Setup request context
|
||||
global $response, $router, $DEBUG;
|
||||
$DEBUG = true;
|
||||
$_SERVER['REQUEST_URI'] = '/auth/passkey/challenge';
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
}
|
||||
|
||||
// Mock response class in classes namespace
|
||||
namespace classes {
|
||||
class MockExitException extends \Error {}
|
||||
|
||||
class response {
|
||||
public static $last_success = null;
|
||||
public static $last_error = null;
|
||||
public static $last_status = null;
|
||||
public static $request_parameters = [];
|
||||
|
||||
public static function reset() {
|
||||
self::$last_success = null;
|
||||
self::$last_error = null;
|
||||
self::$last_status = null;
|
||||
self::$request_parameters = [];
|
||||
}
|
||||
|
||||
public function success($data, $status = 200) {
|
||||
self::$last_success = $data;
|
||||
self::$last_status = $status;
|
||||
throw new MockExitException("SUCCESS_EXIT");
|
||||
}
|
||||
|
||||
public function error($data, $status = 400) {
|
||||
self::$last_error = $data;
|
||||
self::$last_status = $status;
|
||||
throw new MockExitException("ERROR_EXIT");
|
||||
}
|
||||
|
||||
public function getRequestParameter($key) {
|
||||
return self::$request_parameters[$key] ?? null;
|
||||
}
|
||||
|
||||
public function isRequestParameterSet($key) {
|
||||
return array_key_exists($key, self::$request_parameters);
|
||||
}
|
||||
}
|
||||
|
||||
class recaptcha {
|
||||
public static $mock_valid = true;
|
||||
public function validate($resp) { return self::$mock_valid; }
|
||||
}
|
||||
}
|
||||
|
||||
// Mock objects needed by the route
|
||||
namespace objects {
|
||||
class logs_o { public function add($a,$b,$c,$d,$e,$f) {} }
|
||||
class tokens_o {
|
||||
public static $created = [];
|
||||
public function create($user_id, $token, $type = 'AUTH_TOKEN') { self::$created[] = compact('user_id','token','type'); }
|
||||
}
|
||||
|
||||
class MockUserObj {
|
||||
public $id;
|
||||
public function __construct($id) { $this->id = $id; }
|
||||
public function exists() { return $this->id > 0; }
|
||||
}
|
||||
|
||||
class users_o {
|
||||
public static $existing_numbers = [];
|
||||
public function getUserByCustomerNumber($num) {
|
||||
if (in_array((int)$num, self::$existing_numbers, true)) { return new MockUserObj(42); }
|
||||
return new MockUserObj(0);
|
||||
}
|
||||
}
|
||||
|
||||
class passkeys_o {
|
||||
public $where;
|
||||
public function setAdditionalWhereClause($w) { $this->where = $w; }
|
||||
public function listObjectsWithPaginationIfSet($parser) {
|
||||
$items = [
|
||||
['credential_id' => 'cred_abcd', 'transports' => json_encode(['internal'])],
|
||||
['credential_id' => 'cred_efgh', 'transports' => json_encode(['hybrid','usb'])],
|
||||
];
|
||||
$out = [];
|
||||
foreach ($items as $o) { $out[] = $parser($o); }
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provide a minimal router mock and run the route
|
||||
namespace {
|
||||
class MockRouter {
|
||||
public $routes = [];
|
||||
public function add($route, $method, $callback, $permissions) { $this->routes[$method][$route] = $callback; }
|
||||
}
|
||||
|
||||
$router = new MockRouter();
|
||||
$response = new \classes\response();
|
||||
|
||||
require_once WD . '/traits/route_t.php';
|
||||
require_once WD . '/routes/authRoute.php';
|
||||
|
||||
use routes\authRoute;
|
||||
|
||||
function ok($m){ echo "\033[32m✔ $m\033[0m\n"; }
|
||||
function fail($m){ echo "\033[31m✖ $m\033[0m\n"; }
|
||||
|
||||
$authRoute = new authRoute();
|
||||
$authRoute->run();
|
||||
|
||||
if (!isset($router->routes['POST']['/auth/passkey/challenge'])) {
|
||||
die("Route /auth/passkey/challenge not found\n");
|
||||
}
|
||||
|
||||
$callback = $router->routes['POST']['/auth/passkey/challenge'];
|
||||
|
||||
// Test case: valid request for existing user
|
||||
\classes\response::reset();
|
||||
\classes\recaptcha::$mock_valid = true;
|
||||
\objects\users_o::$existing_numbers = [12345];
|
||||
\classes\response::$request_parameters = [
|
||||
'customer_number' => 12345,
|
||||
'g_recaptcha_response' => 'valid'
|
||||
];
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} catch (\classes\MockExitException $e) {
|
||||
if ($e->getMessage() !== 'SUCCESS_EXIT') { fail('Expected SUCCESS_EXIT'); exit(1); }
|
||||
}
|
||||
|
||||
$data = \classes\response::$last_success;
|
||||
if (!is_array($data)) { fail('Response should be array'); exit(1); }
|
||||
if (!isset($data['challenge_token']) || !is_string($data['challenge_token'])) { fail('Missing challenge_token'); exit(1); }
|
||||
if (!isset($data['publicKey']) || !is_array($data['publicKey'])) { fail('Missing publicKey'); exit(1); }
|
||||
$pk = $data['publicKey'];
|
||||
if (!isset($pk['challenge']) || !is_string($pk['challenge'])) { fail('Missing publicKey.challenge'); exit(1); }
|
||||
if (!isset($pk['rpId']) || !is_string($pk['rpId'])) { fail('Missing publicKey.rpId'); exit(1); }
|
||||
if (!isset($pk['allowCredentials']) || !is_array($pk['allowCredentials'])) { fail('Missing publicKey.allowCredentials'); exit(1); }
|
||||
if (count($pk['allowCredentials']) !== 2) { fail('Expected 2 allowCredentials'); exit(1); }
|
||||
ok('Passkey challenge returns expected structure for existing user');
|
||||
|
||||
// Test case: valid request for non-existing user → allowCredentials may be empty but still returns challenge
|
||||
\classes\response::reset();
|
||||
\objects\users_o::$existing_numbers = [];
|
||||
\classes\response::$request_parameters = [
|
||||
'customer_number' => 99999,
|
||||
'g_recaptcha_response' => 'valid'
|
||||
];
|
||||
|
||||
try {
|
||||
$callback();
|
||||
} catch (\classes\MockExitException $e) {
|
||||
if ($e->getMessage() !== 'SUCCESS_EXIT') { fail('Expected SUCCESS_EXIT for non-existing user'); exit(1); }
|
||||
}
|
||||
|
||||
$data = \classes\response::$last_success;
|
||||
if (!isset($data['publicKey']['allowCredentials'])) { fail('Missing allowCredentials for non-existing'); exit(1); }
|
||||
ok('Passkey challenge works for non-existing user (empty allowCredentials)');
|
||||
|
||||
echo "PasskeyChallengeTest completed.\n";
|
||||
}
|
||||
@@ -15,6 +15,11 @@ trait route_t
|
||||
{
|
||||
protected array $permissions = [];
|
||||
private string $route;
|
||||
/**
|
||||
* The route template (e.g., "/account/security/passkeys/{id}") of the currently matched route.
|
||||
* This is populated just before the route callback is executed.
|
||||
*/
|
||||
private ?string $__current_route_template = null;
|
||||
/**
|
||||
* Lightweight per-request caches to avoid repeated DB/auth checks during a single request lifecycle.
|
||||
*/
|
||||
@@ -566,7 +571,15 @@ trait route_t
|
||||
private function registerRoute($route, $method, $callback, $permissions = []): void
|
||||
{
|
||||
global $router;
|
||||
$router->add($route, $method, $callback, self::registerPermissions($permissions, $route, $method));
|
||||
// Wrap the original callback so we can expose the matched route template to fromRoute()
|
||||
$self = $this;
|
||||
$wrapped = function () use ($callback, $route, $self) {
|
||||
// Set the current route template for parameter extraction
|
||||
$self->__current_route_template = $route;
|
||||
// Execute the original callback
|
||||
$callback();
|
||||
};
|
||||
$router->add($route, $method, $wrapped, self::registerPermissions($permissions, $route, $method));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -643,15 +656,42 @@ trait route_t
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parameter from the route URL by index
|
||||
* @param string $index
|
||||
* @return string|null
|
||||
* Get the value of a route parameter by name from the currently matched route.
|
||||
* Example: for template "/users/{id}", current URL "/users/123" → fromRoute('id') === "123".
|
||||
* @param string $index The parameter name (without braces), e.g., 'id'
|
||||
* @return string|null The extracted value or null if not present
|
||||
*/
|
||||
public function fromRoute(string $index): ?string
|
||||
{
|
||||
$params = explode('/', $this->route);
|
||||
$index = array_search($index, $params);
|
||||
return $params[$index] ?? null;
|
||||
// Resolve current path without query string
|
||||
$currentPath = explode('?', $this->route)[0] ?? '';
|
||||
$currentPath = trim($currentPath, '/');
|
||||
|
||||
// We need the route template used to register this callback
|
||||
$template = $this->__current_route_template;
|
||||
if ($template === null) {
|
||||
// Fallback: no template context — cannot reliably parse; return null
|
||||
return null;
|
||||
}
|
||||
$template = trim($template, '/');
|
||||
|
||||
$pathSegments = $currentPath === '' ? [] : explode('/', $currentPath);
|
||||
$tplSegments = $template === '' ? [] : explode('/', $template);
|
||||
|
||||
// Quick length guard: router allows alnum-only for params; still, differing counts means no match
|
||||
if (count($pathSegments) !== count($tplSegments)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($tplSegments as $i => $seg) {
|
||||
if (preg_match('/^{([a-zA-Z0-9_]+)}$/', $seg, $m)) {
|
||||
$name = $m[1];
|
||||
if ($name === $index) {
|
||||
return $pathSegments[$i] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user