true, 'token' => 'selector:plaintext', 'id' => int] on success. * The plain-text token is shown ONCE and never stored. */ public function create( int $userId, string $name, ?int $tenantId = null, ?string $expiresAt = null, ?int $createdBy = null ): array { $name = trim($name); if ($name === '') { return ['ok' => false, 'error' => 'name_required']; } if ($userId <= 0) { return ['ok' => false, 'error' => 'invalid_user']; } $user = $this->userReadRepository->find($userId); if (!$user || !((int) ($user['active'] ?? 0))) { return ['ok' => false, 'error' => 'user_inactive']; } $activeCount = $this->apiTokenRepository->countActiveForUser($userId); if ($activeCount >= self::MAX_TOKENS_PER_USER) { return ['ok' => false, 'error' => 'max_tokens_reached']; } if ($tenantId !== null && $tenantId > 0) { $userTenantIds = $this->scopeGateway->getUserTenantIds($userId); if (!in_array($tenantId, $userTenantIds, true)) { return ['ok' => false, 'error' => 'tenant_not_assigned']; } } $resolvedExpiresAt = $this->resolveExpiresAt($expiresAt); if ($resolvedExpiresAt === null) { return ['ok' => false, 'error' => 'invalid_expires_at']; } $selector = bin2hex(random_bytes(12)); $plainToken = bin2hex(random_bytes(32)); $tokenHash = hash('sha256', $plainToken); $id = $this->apiTokenRepository->create( $userId, $name, $selector, $tokenHash, ($tenantId !== null && $tenantId > 0) ? $tenantId : null, $resolvedExpiresAt, $createdBy ); if (!$id) { return ['ok' => false, 'error' => 'create_failed']; } $createdToken = $this->apiTokenRepository->find($id); if (!$createdToken) { return ['ok' => false, 'error' => 'create_failed']; } $fullToken = $selector . ':' . $plainToken; return [ 'ok' => true, 'token' => $fullToken, 'id' => $id, 'uuid' => (string) ($createdToken['uuid'] ?? ''), 'selector' => $selector, 'expires_at' => $resolvedExpiresAt, ]; } public function rotateCurrentToken(int $userId, int $currentTokenId, ?string $expiresAt = null): array { if ($userId <= 0 || $currentTokenId <= 0) { return ['ok' => false, 'error' => 'current_token_not_found']; } $currentToken = $this->apiTokenRepository->find($currentTokenId); if (!$currentToken || (int) ($currentToken['user_id'] ?? 0) !== $userId) { return ['ok' => false, 'error' => 'current_token_not_found']; } if (!empty($currentToken['revoked_at']) || $this->isTokenExpired($currentToken['expires_at'] ?? null)) { return ['ok' => false, 'error' => 'current_token_invalid']; } $activeCountExcludingCurrent = $this->apiTokenRepository->countActiveForUserExcludingId($userId, $currentTokenId); if ($activeCountExcludingCurrent >= self::MAX_TOKENS_PER_USER) { return ['ok' => false, 'error' => 'max_tokens_reached']; } $name = trim((string) ($currentToken['name'] ?? '')); if ($name === '') { $name = 'API token'; } $tenantId = isset($currentToken['tenant_id']) ? (int) ($currentToken['tenant_id']) : 0; if ($tenantId <= 0) { $tenantId = null; } $transactionStarted = false; try { $this->databaseSessionRepository->beginTransaction(); $transactionStarted = true; if (!$this->apiTokenRepository->revoke($currentTokenId)) { $this->databaseSessionRepository->rollbackTransaction(); return ['ok' => false, 'error' => 'rotate_failed']; } $created = $this->create($userId, $name, $tenantId, $expiresAt, $userId); if (!($created['ok'] ?? false)) { $this->databaseSessionRepository->rollbackTransaction(); $error = (string) ($created['error'] ?? 'rotate_failed'); if ($error === 'invalid_expires_at') { return ['ok' => false, 'error' => 'invalid_expires_at']; } if ($error === 'max_tokens_reached') { return ['ok' => false, 'error' => 'max_tokens_reached']; } return ['ok' => false, 'error' => 'rotate_failed']; } $this->databaseSessionRepository->commitTransaction(); $transactionStarted = false; $created['tenant_id'] = $tenantId; return $created; } catch (\Throwable $exception) { if ($transactionStarted) { try { $this->databaseSessionRepository->rollbackTransaction(); } catch (\Throwable $rollbackException) { // Ignore rollback error and return rotate_failed below. } } return ['ok' => false, 'error' => 'rotate_failed']; } } private function resolveExpiresAt(?string $expiresAt): ?string { $nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $maxTtlDays = $this->settingsGateway->getApiTokenMaxTtlDays(); $maxExpiryUtc = $nowUtc->modify('+' . $maxTtlDays . ' days'); $raw = trim((string) ($expiresAt ?? '')); if ($raw === '') { $defaultDays = $this->settingsGateway->getApiTokenDefaultTtlDays(); $defaultExpiry = $nowUtc->modify('+' . $defaultDays . ' days'); if ($defaultExpiry > $maxExpiryUtc) { $defaultExpiry = $maxExpiryUtc; } return $defaultExpiry->format('Y-m-d H:i:s'); } try { $parsed = new \DateTimeImmutable($raw); } catch (\Exception $exception) { return null; } $expiryUtc = $parsed->setTimezone(new \DateTimeZone('UTC')); if ($expiryUtc <= $nowUtc) { return null; } if ($expiryUtc > $maxExpiryUtc) { return null; } return $expiryUtc->format('Y-m-d H:i:s'); } /** * Validate a Bearer token string. * * @return array{user: array, token_record: array, tenant_id: ?int}|null */ public function validate(string $bearerToken): ?array { if ($bearerToken === '' || strpos($bearerToken, ':') === false) { return null; } [$selector, $plainToken] = explode(':', $bearerToken, 2); $selector = trim($selector); $plainToken = trim($plainToken); if ($selector === '' || $plainToken === '') { return null; } $record = $this->apiTokenRepository->findBySelector($selector); if (!$record) { // Perform a dummy hash to prevent timing-based selector enumeration. $_ = hash('sha256', $plainToken); return null; } if (!empty($record['revoked_at'])) { return null; } if ($this->isTokenExpired($record['expires_at'] ?? null)) { return null; } $storedHash = (string) ($record['token_hash'] ?? ''); if ($storedHash === '' || !hash_equals($storedHash, hash('sha256', $plainToken))) { return null; } $userId = (int) ($record['user_id'] ?? 0); if ($userId <= 0) { return null; } $user = $this->userReadRepository->find($userId); if (!$user || !((int) ($user['active'] ?? 0))) { return null; } $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $this->apiTokenRepository->updateLastUsed((int) $record['id'], $ip); return [ 'user' => $user, 'token_record' => $record, 'tenant_id' => !empty($record['tenant_id']) ? (int) $record['tenant_id'] : null, ]; } /** * Revoke a specific token by ID. */ public function revoke(int $tokenId): array { $token = $this->apiTokenRepository->find($tokenId); if (!$token) { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } $this->apiTokenRepository->revoke($tokenId); return ['ok' => true]; } /** * Revoke all active tokens for a user. */ public function revokeAllForUser(int $userId, ?int $tenantId = null): array { $count = $this->apiTokenRepository->revokeAllForUser($userId, $tenantId); return ['ok' => true, 'count' => $count]; } public function revokeAllByAdmin(): int { return $this->apiTokenRepository->revokeAllActiveByAdmin(); } /** * List tokens for a user (for admin UI display -- never exposes hashes). */ public function listForUser(int $userId): array { return $this->apiTokenRepository->listByUserId($userId); } }