forked from fa/breadcrumb-the-shire
313 lines
11 KiB
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);
|
|
}
|
|
}
|