Files
api/services/nginx/app/routes/passkeysRoute.php
T
OpenClaw 51a87655d6 feat(auth): add scope-based access control to all existing routes (TRU-149)
Adds a scope-based access control layer to all 81 existing API routes.
Sits alongside existing session-cookie auth (does not replace it).

What this PR does:
- Audits every existing route and documents required scope per route
  (see documentation/auth/route-scope-audit.md)
- Adds classes/auth/scope.php with 10 scope constants and role→scope defaults
- Adds classes/auth/scope_middleware.php with requireScope/requireAnyScope/requireRole
- Applies require*() calls to all 81 existing routes
- Adds ScopeMiddlewareTest (unit, 178 lines) and RouteScopeTest (integration, 212 lines)

Coexistence note:
This branch's classes/auth/scope.php is a stub that will be replaced
by classes/auth/scope_registry.php (from TRU-145 / PR #396) when that
PR merges first. The two have compatible APIs.

Refs: TRU-149
2026-08-17 11:43:13 +00:00

196 lines
8.4 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use objects\logs_o;
use objects\passkeys_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class passkeysRoute
{
use route_t;
/**
* @return array{principal:object,user_id:int,is_subuser:bool}
*/
private function resolvePasskeyPrincipal(string $classicUserPermission): array
{
global $response;
$auth = new authentication();
$subuser = $auth->get_subuser();
if ($subuser !== false) {
return [
'principal' => $subuser,
'user_id' => (int)$subuser->id,
'is_subuser' => true,
];
}
$this->requirePermission($classicUserPermission);
$user = $auth->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_AUTH', 'User not logged in');
$response->error('Invalid session', 400);
}
return [
'principal' => $user,
'user_id' => (int)$user->id,
'is_subuser' => false,
];
}
public function run(): void
{
// List passkeys for current authenticated user
$this->get('/account/security/passkeys', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/security/passkeys');
global $response;
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_list');
$passkeys = new passkeys_o();
$passkeys->setAdditionalWhereClause(
'`user_id` = ' . (int)$principal['user_id'] . ' AND `is_subuser` = ' . ($principal['is_subuser'] ? '1' : '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, $principal['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 () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/security/passkeys');
global $response;
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_create');
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)$principal['user_id'], (bool)$principal['is_subuser'], $credential_id, $public_key, $algorithm, (array)$transports, $name);
(new logs_o())->add('user_security', 'global', 1, $principal['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 () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/security/passkeys/{id}');
global $response;
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_rename');
$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)$principal['user_id']
|| (bool)$obj->is_subuser->value() !== (bool)$principal['is_subuser']
) {
(new logs_o())->add('user_security', 'global', 0, $principal['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, $principal['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 () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/security/passkeys/{id}');
global $response;
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_delete');
$id = (int)self::fromRoute('id');
self::requireParameterIntPositive($id, 'id');
$obj = (new passkeys_o())->select($id);
if (
!$obj->exists()
|| (int)$obj->user_id->value() !== (int)$principal['user_id']
|| (bool)$obj->is_subuser->value() !== (bool)$principal['is_subuser']
) {
(new logs_o())->add('user_security', 'global', 0, $principal['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, $principal['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',
]);
}
}