- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks - Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists - Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services - Add Microsoft OIDC SSO, API token management, and user lifecycle features - Add swagger-ui vendor integration and OpenAPI spec - Add production Docker setup and bin/ scripts - Update composer dependencies, config, templates, and frontend assets throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
301 lines
9.6 KiB
PHP
301 lines
9.6 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Service\Auth;
|
|
|
|
use MintyPHP\DB;
|
|
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
|
use MintyPHP\Service\Settings\SettingService;
|
|
use MintyPHP\Repository\User\UserRepository;
|
|
use MintyPHP\Service\Tenant\TenantScopeService;
|
|
|
|
class ApiTokenService
|
|
{
|
|
private const MAX_TOKENS_PER_USER = 10;
|
|
|
|
private static 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 static 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 = UserRepository::find($userId);
|
|
if (!$user || !((int) ($user['active'] ?? 0))) {
|
|
return ['ok' => false, 'error' => 'user_inactive'];
|
|
}
|
|
|
|
$activeCount = ApiTokenRepository::countActiveForUser($userId);
|
|
if ($activeCount >= self::MAX_TOKENS_PER_USER) {
|
|
return ['ok' => false, 'error' => 'max_tokens_reached'];
|
|
}
|
|
|
|
if ($tenantId !== null && $tenantId > 0) {
|
|
$userTenantIds = TenantScopeService::getUserTenantIds($userId);
|
|
if (!in_array($tenantId, $userTenantIds, true)) {
|
|
return ['ok' => false, 'error' => 'tenant_not_assigned'];
|
|
}
|
|
}
|
|
|
|
$resolvedExpiresAt = self::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 = ApiTokenRepository::create(
|
|
$userId,
|
|
$name,
|
|
$selector,
|
|
$tokenHash,
|
|
($tenantId !== null && $tenantId > 0) ? $tenantId : null,
|
|
$resolvedExpiresAt,
|
|
$createdBy
|
|
);
|
|
|
|
if (!$id) {
|
|
return ['ok' => false, 'error' => 'create_failed'];
|
|
}
|
|
|
|
$createdToken = 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 static function rotateCurrentToken(int $userId, int $currentTokenId, ?string $expiresAt = null): array
|
|
{
|
|
if ($userId <= 0 || $currentTokenId <= 0) {
|
|
return ['ok' => false, 'error' => 'current_token_not_found'];
|
|
}
|
|
|
|
$currentToken = ApiTokenRepository::find($currentTokenId);
|
|
if (!$currentToken || (int) ($currentToken['user_id'] ?? 0) !== $userId) {
|
|
return ['ok' => false, 'error' => 'current_token_not_found'];
|
|
}
|
|
|
|
if (!empty($currentToken['revoked_at']) || self::isTokenExpired($currentToken['expires_at'] ?? null)) {
|
|
return ['ok' => false, 'error' => 'current_token_invalid'];
|
|
}
|
|
|
|
$activeCountExcludingCurrent = 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;
|
|
}
|
|
|
|
$db = DB::handle();
|
|
$transactionStarted = false;
|
|
try {
|
|
$db->begin_transaction();
|
|
$transactionStarted = true;
|
|
|
|
if (!ApiTokenRepository::revoke($currentTokenId)) {
|
|
$db->rollback();
|
|
return ['ok' => false, 'error' => 'rotate_failed'];
|
|
}
|
|
|
|
$created = self::create($userId, $name, $tenantId, $expiresAt, $userId);
|
|
if (!($created['ok'] ?? false)) {
|
|
$db->rollback();
|
|
$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'];
|
|
}
|
|
|
|
$db->commit();
|
|
$created['tenant_id'] = $tenantId;
|
|
return $created;
|
|
} catch (\Throwable $exception) {
|
|
if ($transactionStarted) {
|
|
try {
|
|
$db->rollback();
|
|
} catch (\Throwable $rollbackException) {
|
|
// Ignore rollback error and return rotate_failed below.
|
|
}
|
|
}
|
|
return ['ok' => false, 'error' => 'rotate_failed'];
|
|
}
|
|
}
|
|
|
|
private static function resolveExpiresAt(?string $expiresAt): ?string
|
|
{
|
|
$nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
|
$maxTtlDays = SettingService::getApiTokenMaxTtlDays();
|
|
$maxExpiryUtc = $nowUtc->modify('+' . $maxTtlDays . ' days');
|
|
$raw = trim((string) ($expiresAt ?? ''));
|
|
|
|
if ($raw === '') {
|
|
$defaultDays = SettingService::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 static 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 = 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 (self::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 = UserRepository::find($userId);
|
|
if (!$user || !((int) ($user['active'] ?? 0))) {
|
|
return null;
|
|
}
|
|
|
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
|
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 static function revoke(int $tokenId): array
|
|
{
|
|
$token = ApiTokenRepository::find($tokenId);
|
|
if (!$token) {
|
|
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
|
}
|
|
ApiTokenRepository::revoke($tokenId);
|
|
return ['ok' => true];
|
|
}
|
|
|
|
/**
|
|
* Revoke all active tokens for a user.
|
|
*/
|
|
public static function revokeAllForUser(int $userId, ?int $tenantId = null): array
|
|
{
|
|
$count = ApiTokenRepository::revokeAllForUser($userId, $tenantId);
|
|
return ['ok' => true, 'count' => $count];
|
|
}
|
|
|
|
public static function revokeAllByAdmin(): int
|
|
{
|
|
return ApiTokenRepository::revokeAllActiveByAdmin();
|
|
}
|
|
|
|
/**
|
|
* List tokens for a user (for admin UI display -- never exposes hashes).
|
|
*/
|
|
public static function listForUser(int $userId): array
|
|
{
|
|
return ApiTokenRepository::listByUserId($userId);
|
|
}
|
|
}
|