2026-03-04 15:56:58 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Service\Auth;
|
|
|
|
|
|
|
|
|
|
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
|
|
|
|
use MintyPHP\Repository\Tenant\TenantRepository;
|
2026-03-13 11:31:33 +01:00
|
|
|
use MintyPHP\Service\Tenant\TenantScopeService;
|
2026-03-04 15:56:58 +01:00
|
|
|
|
|
|
|
|
class ApiTokenEndpointService
|
|
|
|
|
{
|
|
|
|
|
public function __construct(
|
|
|
|
|
private readonly ApiTokenRepository $apiTokenRepository,
|
|
|
|
|
private readonly TenantRepository $tenantRepository,
|
2026-03-13 11:31:33 +01:00
|
|
|
private readonly TenantScopeService $authScopeGateway
|
2026-03-04 15:56:58 +01:00
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function normalizeLimit(mixed $rawLimit, int $default = 50, int $min = 1, int $max = 100): int
|
|
|
|
|
{
|
|
|
|
|
$limit = (int) $rawLimit;
|
|
|
|
|
if ($limit < $min) {
|
|
|
|
|
return $default;
|
|
|
|
|
}
|
|
|
|
|
if ($limit > $max) {
|
|
|
|
|
return $max;
|
|
|
|
|
}
|
|
|
|
|
return $limit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array<int, array<string, mixed>>
|
|
|
|
|
*/
|
|
|
|
|
public function listSelfTokens(int $userId, int $limit, ?int $scopedTenantId, ?int $currentTokenId): array
|
|
|
|
|
{
|
|
|
|
|
if ($userId <= 0) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$tokens = $this->apiTokenRepository->listByUserId($userId, $limit);
|
|
|
|
|
$tenantUuidById = $this->buildTenantUuidByIdMap($tokens);
|
|
|
|
|
$rows = [];
|
|
|
|
|
|
|
|
|
|
foreach ($tokens as $token) {
|
|
|
|
|
$tokenTenantId = $this->normalizeTenantId($token['tenant_id'] ?? null);
|
|
|
|
|
if ($scopedTenantId !== null && $scopedTenantId > 0 && $tokenTenantId !== $scopedTenantId) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$rows[] = [
|
|
|
|
|
'uuid' => (string) ($token['uuid'] ?? ''),
|
|
|
|
|
'name' => (string) ($token['name'] ?? ''),
|
|
|
|
|
'selector_prefix' => substr((string) ($token['selector'] ?? ''), 0, 8),
|
|
|
|
|
'tenant_uuid' => $tokenTenantId !== null ? ($tenantUuidById[$tokenTenantId] ?? null) : null,
|
|
|
|
|
'last_used_at' => $token['last_used_at'] ?? null,
|
|
|
|
|
'last_ip' => (string) ($token['last_ip'] ?? ''),
|
|
|
|
|
'expires_at' => $token['expires_at'] ?? null,
|
|
|
|
|
'revoked_at' => $token['revoked_at'] ?? null,
|
|
|
|
|
'created' => $token['created'] ?? null,
|
|
|
|
|
'is_current_token' => (int) ($token['id'] ?? 0) === (int) ($currentTokenId ?? 0),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $rows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array<string, mixed>|null
|
|
|
|
|
*/
|
|
|
|
|
public function findSelfToken(string $tokenUuid, int $userId, ?int $scopedTenantId, ?int $currentTokenId): ?array
|
|
|
|
|
{
|
|
|
|
|
$tokenUuid = trim($tokenUuid);
|
|
|
|
|
if ($userId <= 0 || !$this->apiTokenRepository->isUuid($tokenUuid)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$token = $this->apiTokenRepository->findByUuidForUser($tokenUuid, $userId);
|
|
|
|
|
if (!$token) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$tokenTenantId = $this->normalizeTenantId($token['tenant_id'] ?? null);
|
|
|
|
|
if ($scopedTenantId !== null && $scopedTenantId > 0 && $tokenTenantId !== $scopedTenantId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'uuid' => (string) ($token['uuid'] ?? ''),
|
|
|
|
|
'name' => (string) ($token['name'] ?? ''),
|
|
|
|
|
'selector_prefix' => substr((string) ($token['selector'] ?? ''), 0, 8),
|
|
|
|
|
'tenant_uuid' => $this->resolveTenantUuidById($tokenTenantId),
|
|
|
|
|
'last_used_at' => $token['last_used_at'] ?? null,
|
|
|
|
|
'last_ip' => (string) ($token['last_ip'] ?? ''),
|
|
|
|
|
'expires_at' => $token['expires_at'] ?? null,
|
|
|
|
|
'revoked_at' => $token['revoked_at'] ?? null,
|
|
|
|
|
'created' => $token['created'] ?? null,
|
|
|
|
|
'is_current_token' => (int) ($token['id'] ?? 0) === (int) ($currentTokenId ?? 0),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function revokeSelfToken(string $tokenUuid, int $userId): bool
|
|
|
|
|
{
|
|
|
|
|
$tokenUuid = trim($tokenUuid);
|
|
|
|
|
if ($userId <= 0 || !$this->apiTokenRepository->isUuid($tokenUuid)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->apiTokenRepository->revokeByUuidForUser($tokenUuid, $userId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array{ok: bool, tenant_id?: int|null, tenant_uuid?: string|null, status?: int, error?: string, field?: string}
|
|
|
|
|
*/
|
|
|
|
|
public function resolveSelfCreateTenant(int $userId, ?int $scopedTenantId, string $tenantUuidInput): array
|
|
|
|
|
{
|
|
|
|
|
$tenantUuidInput = trim($tenantUuidInput);
|
|
|
|
|
$tenantId = null;
|
|
|
|
|
$tenantUuid = null;
|
|
|
|
|
|
|
|
|
|
if ($tenantUuidInput !== '') {
|
|
|
|
|
$tenant = $this->tenantRepository->findByUuid($tenantUuidInput);
|
|
|
|
|
$tenantId = (int) ($tenant['id'] ?? 0);
|
|
|
|
|
$tenantUuid = trim((string) ($tenant['uuid'] ?? ''));
|
|
|
|
|
if ($tenantId <= 0 || $tenantUuid === '') {
|
|
|
|
|
return [
|
|
|
|
|
'ok' => false,
|
|
|
|
|
'status' => 422,
|
|
|
|
|
'error' => 'invalid_tenant_uuid',
|
|
|
|
|
'field' => 'tenant_uuid',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($scopedTenantId !== null && $scopedTenantId > 0) {
|
|
|
|
|
$scopedTenantUuid = $this->resolveTenantUuidById($scopedTenantId);
|
|
|
|
|
if ($scopedTenantUuid === null) {
|
|
|
|
|
return ['ok' => false, 'status' => 403, 'error' => 'tenant_scoped_token_forbidden'];
|
|
|
|
|
}
|
|
|
|
|
if ($tenantId !== null && $tenantId !== $scopedTenantId) {
|
|
|
|
|
return ['ok' => false, 'status' => 403, 'error' => 'tenant_scoped_token_forbidden'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$tenantId = $scopedTenantId;
|
|
|
|
|
$tenantUuid = $scopedTenantUuid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($tenantId !== null) {
|
|
|
|
|
$tenantIds = $this->authScopeGateway->getUserTenantIds($userId);
|
|
|
|
|
if (!in_array($tenantId, $tenantIds, true)) {
|
|
|
|
|
return ['ok' => false, 'status' => 403, 'error' => 'tenant_not_assigned'];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'ok' => true,
|
|
|
|
|
'tenant_id' => $tenantId,
|
|
|
|
|
'tenant_uuid' => $tenantUuid,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array{ok: bool, expires_at?: string|null, error?: string}
|
|
|
|
|
*/
|
|
|
|
|
public function parseExpiresAt(string $expiresInput): array
|
|
|
|
|
{
|
|
|
|
|
$expiresInput = trim($expiresInput);
|
|
|
|
|
if ($expiresInput === '') {
|
|
|
|
|
return ['ok' => true, 'expires_at' => null];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$expiresDate = new \DateTimeImmutable($expiresInput);
|
|
|
|
|
$expiresDateUtc = $expiresDate->setTimezone(new \DateTimeZone('UTC'));
|
|
|
|
|
$nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
|
|
|
|
if ($expiresDateUtc <= $nowUtc) {
|
|
|
|
|
return ['ok' => false, 'error' => 'must_be_in_future'];
|
|
|
|
|
}
|
|
|
|
|
return ['ok' => true, 'expires_at' => $expiresDateUtc->format('Y-m-d H:i:s')];
|
|
|
|
|
} catch (\Throwable $exception) {
|
|
|
|
|
return ['ok' => false, 'error' => 'invalid_datetime'];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function capExpiryToParent(?string $requestedExpiresAt, ?string $parentExpiresAt): ?string
|
|
|
|
|
{
|
|
|
|
|
$parentExpiresAt = trim((string) ($parentExpiresAt ?? ''));
|
|
|
|
|
if ($parentExpiresAt === '') {
|
|
|
|
|
return $requestedExpiresAt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$parentExpiry = new \DateTimeImmutable($parentExpiresAt, new \DateTimeZone('UTC'));
|
|
|
|
|
if ($requestedExpiresAt === null || trim($requestedExpiresAt) === '') {
|
|
|
|
|
return $parentExpiresAt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$requestedExpiry = new \DateTimeImmutable($requestedExpiresAt, new \DateTimeZone('UTC'));
|
|
|
|
|
if ($requestedExpiry > $parentExpiry) {
|
|
|
|
|
return $parentExpiresAt;
|
|
|
|
|
}
|
|
|
|
|
} catch (\Throwable $exception) {
|
|
|
|
|
return $requestedExpiresAt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $requestedExpiresAt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array{ok: bool, tenant_id?: int|null, error?: string, status?: int}
|
|
|
|
|
*/
|
|
|
|
|
public function resolveLoginTenant(int $userId, string $tenantUuidInput): array
|
|
|
|
|
{
|
|
|
|
|
$tenantUuidInput = trim($tenantUuidInput);
|
|
|
|
|
if ($tenantUuidInput === '') {
|
|
|
|
|
return ['ok' => true, 'tenant_id' => null];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$tenant = $this->tenantRepository->findByUuid($tenantUuidInput);
|
|
|
|
|
$tenantId = (int) ($tenant['id'] ?? 0);
|
|
|
|
|
if ($tenantId <= 0) {
|
|
|
|
|
return ['ok' => false, 'error' => 'invalid_tenant_uuid', 'status' => 422];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$userTenantIds = $this->authScopeGateway->getUserTenantIds($userId);
|
|
|
|
|
if (!in_array($tenantId, $userTenantIds, true)) {
|
|
|
|
|
return ['ok' => false, 'error' => 'tenant_not_assigned', 'status' => 403];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ['ok' => true, 'tenant_id' => $tenantId];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function resolveTenantUuidById(?int $tenantId): ?string
|
|
|
|
|
{
|
|
|
|
|
$tenantId = (int) ($tenantId ?? 0);
|
|
|
|
|
if ($tenantId <= 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$tenant = $this->tenantRepository->find($tenantId);
|
|
|
|
|
$tenantUuid = trim((string) ($tenant['uuid'] ?? ''));
|
|
|
|
|
|
|
|
|
|
return $tenantUuid !== '' ? $tenantUuid : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<int, array<string, mixed>> $tokens
|
|
|
|
|
* @return array<int, string>
|
|
|
|
|
*/
|
|
|
|
|
private function buildTenantUuidByIdMap(array $tokens): array
|
|
|
|
|
{
|
|
|
|
|
$tenantIds = [];
|
|
|
|
|
foreach ($tokens as $token) {
|
|
|
|
|
$tenantId = $this->normalizeTenantId($token['tenant_id'] ?? null);
|
|
|
|
|
if ($tenantId !== null) {
|
|
|
|
|
$tenantIds[] = $tenantId;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
$tenantIds = array_values(array_unique($tenantIds));
|
|
|
|
|
if (!$tenantIds) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$map = [];
|
|
|
|
|
foreach ($this->tenantRepository->listByIds($tenantIds) as $tenant) {
|
|
|
|
|
$tenantId = (int) ($tenant['id'] ?? 0);
|
|
|
|
|
$tenantUuid = trim((string) ($tenant['uuid'] ?? ''));
|
|
|
|
|
if ($tenantId > 0 && $tenantUuid !== '') {
|
|
|
|
|
$map[$tenantId] = $tenantUuid;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return $map;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function normalizeTenantId(mixed $tenantId): ?int
|
|
|
|
|
{
|
|
|
|
|
$tenantId = (int) ($tenantId ?? 0);
|
|
|
|
|
return $tenantId > 0 ? $tenantId : null;
|
|
|
|
|
}
|
|
|
|
|
}
|