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', ]); } }