- Improve user verification in `authentication.php` by adding a check for user existence and throwing meaningful exceptions for missing users. - Refactor `webauthn.php` to handle Base64URL decoding and COSE key normalization for consistent WebAuthn library compatibility. - Extend error logging with additional context for debugging (e.g., public key hex representation). - Add utility functions for Base64URL decoding and checking PEM/DER format. - Update `passkeysRoute.php` to normalize public keys and handle errors gracefully during WebAuthn workflows.
169 lines
7.8 KiB
PHP
169 lines
7.8 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\authentication;
|
|
use objects\logs_o;
|
|
use objects\passkeys_o;
|
|
use traits\route_t;
|
|
|
|
class passkeysRoute
|
|
{
|
|
use route_t;
|
|
|
|
public function run(): void
|
|
{
|
|
// List passkeys for current authenticated user
|
|
$this->get('/account/security/passkeys', function () {
|
|
global $response;
|
|
self::requirePermission('user_security_passkeys_list');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_LIST', 'User not logged in');
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
|
|
$passkeys = new passkeys_o();
|
|
// Restrict to current user (customer) and non-subuser records
|
|
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user->id . ' AND `is_subuser` = 0');
|
|
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
|
// $o is an associative array from the database
|
|
$transports = null;
|
|
if (isset($o['transports'])) {
|
|
$decoded = json_decode($o['transports'], true);
|
|
$transports = is_array($decoded) ? $decoded : null;
|
|
}
|
|
return [
|
|
'id' => isset($o['id']) ? (int)$o['id'] : null,
|
|
'credential_id' => $o['credential_id'] ?? null,
|
|
'name' => $o['name'] ?? null,
|
|
'algorithm' => $o['algorithm'] ?? null,
|
|
'transports' => $transports,
|
|
'sign_count' => isset($o['sign_count']) ? (int)$o['sign_count'] : null,
|
|
'created_at' => $o['created_at'] ?? null,
|
|
'updated_at' => $o['updated_at'] ?? null,
|
|
];
|
|
});
|
|
|
|
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_LIST', 'Listed passkeys');
|
|
$response->success($list);
|
|
}, [
|
|
'user_security_passkeys_list' => 'List passkeys for the authenticated user',
|
|
]);
|
|
|
|
// Create/add a passkey (store after client-side WebAuthn attestation)
|
|
$this->post('/account/security/passkeys', function () {
|
|
global $response;
|
|
self::requirePermission('user_security_passkeys_create');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_CREATE', 'User not logged in');
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
|
|
self::requireParameters(['credential_id', 'public_key', 'algorithm', 'transports']);
|
|
|
|
$credential_id = (string)self::getParameter('credential_id');
|
|
self::requireType($credential_id, self::type_string());
|
|
self::requireMinLength('credential_id', 16);
|
|
self::requireMaxLength('credential_id', 4096);
|
|
|
|
$algorithm = (string)self::getParameter('algorithm');
|
|
self::requireType($algorithm, self::type_string());
|
|
self::requireMinLength('algorithm', 3);
|
|
self::requireMaxLength('algorithm', 32);
|
|
|
|
$public_key = (string)self::getParameter('public_key');
|
|
self::requireType($public_key, self::type_string());
|
|
self::requireMinLength('public_key', 32);
|
|
self::requireMaxLength('public_key', 16384);
|
|
|
|
// Normalize public key to COSE (some clients send full attestation object instead of just the key)
|
|
try {
|
|
$wa = new \classes\webauthn();
|
|
$decoded_pk = $wa->b64urlDecode($public_key);
|
|
$normalized_pk = $wa->ensureCosePublicKey($decoded_pk, $algorithm);
|
|
$public_key = rtrim(strtr(base64_encode($normalized_pk), '+/', '-_'), '=');
|
|
} catch (\Exception $e) {
|
|
// If normalization fails, we continue with original public_key and let verification handle it later if possible
|
|
}
|
|
|
|
$transports = self::getParameter('transports');
|
|
self::requireType($transports, self::TYPE_ARRAY());
|
|
|
|
$name = self::getParameter('name');
|
|
if ($name !== null) {
|
|
$name = (string)$name;
|
|
self::requireType($name, self::type_string());
|
|
self::requireMinLength('name', 1);
|
|
self::requireMaxLength('name', 255);
|
|
}
|
|
|
|
$obj = new passkeys_o();
|
|
$obj->add((int)$user->id, false, $credential_id, $public_key, $algorithm, (array)$transports, $name);
|
|
|
|
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_CREATE', 'Created passkey: ' . $obj->id);
|
|
$response->success(['id' => $obj->id]);
|
|
}, [
|
|
'user_security_passkeys_create' => 'Create/add a new passkey for the authenticated user',
|
|
]);
|
|
|
|
// Rename a passkey
|
|
$this->patch('/account/security/passkeys/{id}', function () {
|
|
global $response;
|
|
self::requirePermission('user_security_passkeys_rename');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_RENAME', 'User not logged in');
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
|
|
$id = (int)self::fromRoute('id');
|
|
self::requireParameterIntPositive($id, 'id');
|
|
self::requireParameters(['name']);
|
|
$name = (string)self::getParameter('name');
|
|
self::requireType($name, self::type_string());
|
|
self::requireMinLength('name', 1);
|
|
self::requireMaxLength('name', 255);
|
|
|
|
$obj = (new passkeys_o())->select($id);
|
|
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
|
|
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Passkey not found or not owned');
|
|
$response->error('Not found', 404);
|
|
}
|
|
|
|
$obj->update(['name' => $name]);
|
|
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Renamed passkey ' . $id);
|
|
$response->success(['message' => 'Renamed', 'id' => $id]);
|
|
}, [
|
|
'user_security_passkeys_rename' => 'Rename a passkey that belongs to the authenticated user',
|
|
]);
|
|
|
|
// Delete a passkey (soft delete)
|
|
$this->delete('/account/security/passkeys/{id}', function () {
|
|
global $response;
|
|
self::requirePermission('user_security_passkeys_delete');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_DELETE', 'User not logged in');
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
|
|
$id = (int)self::fromRoute('id');
|
|
self::requireParameterIntPositive($id, 'id');
|
|
|
|
$obj = (new passkeys_o())->select($id);
|
|
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
|
|
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Passkey not found or not owned');
|
|
$response->error('Not found', 404);
|
|
}
|
|
|
|
$obj->delete();
|
|
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Deleted passkey ' . $id);
|
|
$response->success(['message' => 'Deleted', 'id' => $id]);
|
|
}, [
|
|
'user_security_passkeys_delete' => 'Delete a passkey that belongs to the authenticated user',
|
|
]);
|
|
}
|
|
}
|