Add WebAuthn integration, reCAPTCHA support, and new tests

- Integrate WebAuthn library for passkey authentication workflows, including assertion verification and improved error handling.
- Add support for reCAPTCHA token validation across multiple endpoints for enhanced security.
- Extend OpenAPI schema to document new fields and restructured payloads.
- Add unit tests for WebAuthn flows, permission initialization, and route validation to ensure robustness and accuracy.
This commit is contained in:
Jeppe Bundgaard
2026-02-24 09:27:51 +01:00
parent 2e88ed7bbd
commit b291e959e0
11 changed files with 2487 additions and 115 deletions
+60 -90
View File
@@ -8,6 +8,7 @@ use classes\email;
use classes\recaptcha;
use classes\totp;
use classes\virkdata;
use classes\webauthn;
use Exception;
use objects\customer_password_reset_keys_o;
use objects\logs_o;
@@ -549,11 +550,7 @@ class authRoute
$challenge = rtrim(strtr(base64_encode($rawChallenge), '+/', '-_'), '=');
// rpId for passkeys
$rpId = 'truckwash.io';
$host = $_SERVER['HTTP_HOST'] ?? '';
if (str_starts_with($host, 'localhost')) {
$rpId = 'localhost';
}
$rpId = parse_url((string)$_SERVER['HTTP_ORIGIN'], PHP_URL_HOST) ?: $_SERVER['SERVER_NAME'];
// Create an ephemeral token to bind the challenge to the (potential) user
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
@@ -574,115 +571,88 @@ class authRoute
});
$this->post('/auth/passkey/verify', function () {
global $response, $db;
global $response;
$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);
// Parse JSON body
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
$data = [];
}
if (!isset($data['challenge_token']) || !is_string($data['challenge_token']) || strlen($data['challenge_token']) < 10) {
$response->error('Invalid or missing challenge_token', 400);
}
if (!isset($data['credential'])) {
$response->error('Missing credential', 400);
}
$challenge_token = (string)$data['challenge_token'];
$credentialPayload = $data['credential'];
$credentialJson = is_string($credentialPayload) ? $credentialPayload : json_encode($credentialPayload, JSON_UNESCAPED_SLASHES);
if (!is_string($credentialJson) || $credentialJson === false) {
$response->error('Invalid credential payload', 400);
}
// Load and validate the ephemeral challenge token
$token_o = new tokens_o();
try {
$token = $token_o->getToken($challenge_token_str);
$token = $token_o->getToken($challenge_token);
} catch (Exception $e) {
$response->error('Invalid or expired challenge token', 401);
$response->error('Invalid or expired challenge', 401);
}
if ($token->type->value() !== 'PASSKEY_CHALLENGE') {
$token_type = $token->type->value();
if ($token_type !== '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);
// Determine rpId/host
$host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
if (empty($found)) {
$response->error('Passkey not found', 401);
// Extract credential id
$credentialArr = is_array($credentialPayload) ? $credentialPayload : json_decode($credentialJson, true);
$credentialId = $credentialArr['id'] ?? $credentialArr['rawId'] ?? null;
if (!is_string($credentialId) || $credentialId === '') {
$response->error('Invalid credential id', 400);
}
$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);
// Find corresponding stored passkey
$user_id_hint = (int)$token->user_id->value();
$passkeyRepo = new passkeys_o();
$passkey = $passkeyRepo->findByCredentialId($credentialId, $user_id_hint > 0 ? $user_id_hint : null);
if ($passkey === null) {
// As a fallback for discoverable credentials, try without user hint
$passkey = (new passkeys_o())->findByCredentialId($credentialId, null);
}
if ($passkey === null) {
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Unknown credential');
$response->error('Invalid credential', 404);
}
// 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);
// Verify assertion using the WebAuthn library
$wa = new webauthn();
$ok = $wa->verifyAssertion($credentialJson, $challenge_token, $passkey, $host);
if (!$ok) {
(new logs_o())->add('auth', 'global', 1, (int)$passkey->user_id->value(), 'AUTH_PASSKEY_VERIFY_FAILURE', 'Assertion verification failed');
$response->error('Invalid passkey assertion', 401);
}
$decode = function ($data) {
return base64_decode(strtr($data, '-_', '+/'));
};
// Success → issue session token accordingly and delete the challenge token
$issued_to_user_id = (int)$passkey->user_id->value();
$is_subuser = (bool)$passkey->is_subuser->value();
$token_o->delete($challenge_token);
$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'];
// If the public key is not in PEM format, assume it's base64url encoded DER
if (strpos($publicKey, '-----BEGIN PUBLIC KEY-----') === false) {
$rawPublicKey = $decode($publicKey);
$publicKey = "-----BEGIN PUBLIC KEY-----\n" .
chunk_split(base64_encode($rawPublicKey), 64, "\n") .
"-----END 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();
(new logs_o())->add('auth', 'global', 1, $issued_to_user_id, 'AUTH_PASSKEY_VERIFY_SUCCESS', 'Passkey assertion accepted');
if ($is_subuser) {
$subuser = (new subusers_o())->select($user_id);
$subuser = (new subusers_o())->select($issued_to_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]);
// For customers, user_id stores the customer number
$auth = new authentication();
$jwt = $auth->create_token($issued_to_user_id);
$response->success(['token' => $jwt]);
}
});