1
0
Files
breadcrumb-the-shire/lib/Service/Auth/ApiTokenService.php

313 lines
11 KiB
PHP

<?php
namespace MintyPHP\Service\Auth;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Repository\Auth\ApiTokenRepositoryInterface;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Tenant\TenantScopeService;
class ApiTokenService
{
private const MAX_TOKENS_PER_USER = 10;
public function __construct(
private readonly UserReadRepositoryInterface $userReadRepository,
private readonly ApiTokenRepositoryInterface $apiTokenRepository,
private readonly AuthSettingsGateway $settingsGateway,
private readonly TenantScopeService $scopeGateway,
private readonly DatabaseSessionRepository $databaseSessionRepository,
private readonly RequestRuntimeInterface $requestRuntime
) {
}
private function isTokenExpired(?string $expiresAt): bool
{
$expiresAt = trim((string) ($expiresAt ?? ''));
if ($expiresAt === '') {
return false;
}
try {
$expiry = new \DateTimeImmutable($expiresAt, new \DateTimeZone('UTC'));
return $expiry <= new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
} catch (\Exception $e) {
return true;
}
}
/**
* Generate a new API token for a user.
*
* Returns ['ok' => 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 is public (used for lookup); verifier is the secret (only its hash is stored).
$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;
}
// Wrap revoke+create in a transaction so rotation is atomic — no gap where neither token is valid.
$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 = $this->requestRuntime->ip();
$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);
}
}