Refactor subuser permissions and enhance artifact management

This commit is contained in:
Jeppe Bundgaard
2026-07-01 13:18:27 +02:00
parent 11d39af934
commit 866a5be126
19 changed files with 756 additions and 167 deletions
+125 -28
View File
@@ -24,6 +24,72 @@ class authRoute
{
use route_t;
private function passkeyChallengePrincipalCacheKey(string $challengeToken): string
{
return 'passkey_challenge_principal:' . $challengeToken;
}
private function setPasskeyChallengePrincipal(string $challengeToken, string $principalType): void
{
if (!defined('redis')) {
return;
}
constant('redis')->setEx($this->passkeyChallengePrincipalCacheKey($challengeToken), $principalType, 5 * 60);
}
private function getPasskeyChallengePrincipal(string $challengeToken): string
{
if (!defined('redis')) {
return 'discoverable';
}
$principalType = constant('redis')->get($this->passkeyChallengePrincipalCacheKey($challengeToken));
return is_string($principalType) && in_array($principalType, ['user', 'subuser'], true)
? $principalType
: 'discoverable';
}
private function clearPasskeyChallengePrincipal(string $challengeToken): void
{
if (!defined('redis')) {
return;
}
constant('redis')->delete($this->passkeyChallengePrincipalCacheKey($challengeToken));
}
private function passkeyAllowCredentials(int $userId, bool $isSubuser): array
{
if ($userId <= 0) {
return [];
}
$passkeys = new passkeys_o();
$passkeys->setAdditionalWhereClause(
'`user_id` = ' . (int)$userId . ' AND `is_subuser` = ' . ($isSubuser ? '1' : '0')
);
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
$transports = null;
if (isset($o['transports'])) {
$decoded = json_decode($o['transports'], true);
$transports = is_array($decoded) ? $decoded : null;
}
return [
'type' => 'public-key',
'id' => $o['credential_id'] ?? null,
'transports' => $transports,
];
});
if (isset($list['items']) && is_array($list['items'])) {
$list = $list['items'];
}
return is_array($list)
? array_values(array_filter($list, function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}))
: [];
}
public function run(): void
{
$this->post('/auth/login', function () {
@@ -625,11 +691,53 @@ class authRoute
global $response;
$this->requireRecaptcha();
$principal_type = strtolower(trim((string)(self::getParameter('principal_type') ?? self::getParameter('user_type') ?? '')));
if ($principal_type === '') {
$principal_type = self::getParameter('customer_number') !== null ? 'user' : 'discoverable';
}
if (!in_array($principal_type, ['user', 'subuser', 'discoverable'], true)) {
$response->error('Invalid principal_type', 400);
}
$customer_number = self::getParameter('customer_number');
$user_id = 0;
$allowCredentials = [];
if ($customer_number !== null) {
if ($principal_type === 'subuser') {
$subuser = null;
if (self::isParametersSet(['subuser_id'])) {
$subuser_id = (int)self::getParameter('subuser_id');
self::requireType($subuser_id, $this->type_int());
self::requireMinValue($subuser_id, 1);
$candidate = (new subusers_o())->select($subuser_id);
if ($candidate->exists()) {
$candidate->getObjectProperties();
$subuser = $candidate;
}
} elseif (self::isParametersSet(['username'])) {
$username = (string)self::getParameter('username');
self::requireType($username, $this->type_string());
self::requireMinLength('username', 3);
self::requireMaxLength('username', 255);
$subuser = (new subusers_o())->getSubuserByUsername($username);
} elseif (self::isParametersSet(['phone_country_code', 'phone'])) {
$phone_country_code = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
self::requireType($phone_country_code, $this->type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
self::requireType($phone, $this->type_int());
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
}
if ($subuser !== null) {
$user_id = (int)$subuser->id;
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
}
} elseif ($customer_number !== null) {
$principal_type = 'user';
$customer_number = (int)$customer_number;
self::requireType($customer_number, $this->type_int());
self::requireMinValue($customer_number, 1);
@@ -639,33 +747,7 @@ class authRoute
if ($user->exists()) {
$user_id = (int)$user->id;
// Load passkeys for this user (non-subuser)
$passkeys = new passkeys_o();
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user_id . ' AND `is_subuser` = 0');
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
$transports = null;
if (isset($o['transports'])) {
$decoded = json_decode($o['transports'], true);
$transports = is_array($decoded) ? $decoded : null;
}
return [
'type' => 'public-key',
'id' => $o['credential_id'] ?? null,
'transports' => $transports,
];
});
// Ensure we return a simple array of credentials (without pagination wrapper)
if (isset($list['items']) && is_array($list['items'])) {
$allowCredentials = array_values(array_filter($list['items'], function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}));
} elseif (is_array($list)) {
$allowCredentials = array_values(array_filter($list, function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}));
}
$allowCredentials = $this->passkeyAllowCredentials($user_id, false);
}
}
@@ -682,6 +764,10 @@ class authRoute
// Create an ephemeral token to bind the challenge to the (potential) user
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
$this->setPasskeyChallengePrincipal(
$challenge_token,
$principal_type === 'subuser' ? 'subuser' : ($principal_type === 'user' ? 'user' : 'discoverable')
);
$logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)';
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails);
@@ -733,6 +819,7 @@ class authRoute
if ($token_type !== 'PASSKEY_CHALLENGE') {
$response->error('Invalid token type', 401);
}
$challengePrincipalType = $this->getPasskeyChallengePrincipal($challenge_token);
// Determine rpId/host
$host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
@@ -768,7 +855,17 @@ class authRoute
// 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();
if (
($challengePrincipalType === 'subuser' && !$is_subuser)
|| ($challengePrincipalType === 'user' && $is_subuser)
) {
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Credential principal mismatch');
$token_o->delete($challenge_token);
$this->clearPasskeyChallengePrincipal($challenge_token);
$response->error('Invalid credential', 401);
}
$token_o->delete($challenge_token);
$this->clearPasskeyChallengePrincipal($challenge_token);
(new logs_o())->add('auth', 'global', 1, $issued_to_user_id, 'AUTH_PASSKEY_VERIFY_SUCCESS', 'Passkey assertion accepted');