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:
@@ -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]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user