2026-02-04 23:31:53 +01:00
|
|
|
<?php
|
|
|
|
|
|
2026-02-11 19:28:12 +01:00
|
|
|
namespace MintyPHP\Service\Auth;
|
2026-02-04 23:31:53 +01:00
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
use MintyPHP\Auth;
|
2026-03-06 00:44:52 +01:00
|
|
|
use MintyPHP\Http\CookieStoreInterface;
|
|
|
|
|
use MintyPHP\Http\RequestRuntimeInterface;
|
|
|
|
|
use MintyPHP\Http\SessionStoreInterface;
|
test(auth): add 33 unit tests for AuthService and RememberMeService
AuthServiceTest (17 tests): canLoginToTenant, refreshSessionAuthState,
loginUserById, login email-not-verified branch.
RememberMeServiceTest (16 tests): rememberUser, autoLoginFromCookie
(all 11 branches), forgetCurrentUser, forgetAllForUser,
expireAllTokensByAdmin.
Also fixes: 3 PHPStan errors in FrontendTelemetryIngestService,
8 CS-Fixer violations in out-of-scope files (ordered_imports,
braces_position).
All quality gates green: QG-001 (429 tests/0 failures),
QG-002 (0 errors), QG-003 (11 arch tests pass), QG-006 (0 violations).
Task: AUTH-LOGIN-REVIEW-001
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:43:31 +01:00
|
|
|
use MintyPHP\I18n;
|
2026-03-05 08:26:51 +01:00
|
|
|
use MintyPHP\Repository\Auth\RememberTokenRepositoryInterface;
|
|
|
|
|
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
|
|
|
|
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
2026-03-13 11:31:33 +01:00
|
|
|
use MintyPHP\Service\Access\PermissionService;
|
2026-02-23 12:58:19 +01:00
|
|
|
use MintyPHP\Session;
|
2026-02-04 23:31:53 +01:00
|
|
|
|
|
|
|
|
class RememberMeService
|
|
|
|
|
{
|
|
|
|
|
private const COOKIE_NAME = 'remember';
|
|
|
|
|
private const LIFETIME_DAYS = 30;
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
public function __construct(
|
2026-03-05 08:26:51 +01:00
|
|
|
private readonly UserReadRepositoryInterface $userReadRepository,
|
|
|
|
|
private readonly UserWriteRepositoryInterface $userWriteRepository,
|
|
|
|
|
private readonly RememberTokenRepositoryInterface $rememberTokenRepository,
|
2026-03-13 11:31:33 +01:00
|
|
|
private readonly PermissionService $permissionService,
|
2026-03-06 00:44:52 +01:00
|
|
|
private readonly AuthSessionTenantContextService $authSessionTenantContextService,
|
|
|
|
|
private readonly SessionStoreInterface $sessionStore,
|
|
|
|
|
private readonly CookieStoreInterface $cookieStore,
|
2026-03-10 22:48:10 +01:00
|
|
|
private readonly RequestRuntimeInterface $requestRuntime,
|
|
|
|
|
private readonly ?AuthSettingsGateway $authSettingsGateway = null
|
2026-02-23 12:58:19 +01:00
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function rememberUser(int $userId): void
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
|
|
|
|
$selector = bin2hex(random_bytes(12));
|
|
|
|
|
$token = bin2hex(random_bytes(32));
|
|
|
|
|
$tokenHash = hash('sha256', $token);
|
2026-02-23 12:58:19 +01:00
|
|
|
$expiresAt = gmdate('Y-m-d H:i:s', time() + $this->lifetimeSeconds());
|
|
|
|
|
$this->rememberTokenRepository->create($userId, $selector, $tokenHash, $expiresAt);
|
|
|
|
|
$this->setCookie($selector, $token);
|
2026-02-04 23:31:53 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
public function autoLoginFromCookie(): bool
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
2026-03-06 00:44:52 +01:00
|
|
|
$sessionUser = $this->sessionStore->get('user', []);
|
|
|
|
|
if (is_array($sessionUser) && !empty($sessionUser['id'])) {
|
2026-02-04 23:31:53 +01:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-03-06 00:44:52 +01:00
|
|
|
$value = $this->cookieStore->get($this->cookieName());
|
2026-02-04 23:31:53 +01:00
|
|
|
if ($value === '' || strpos($value, ':') === false) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
[$selector, $token] = explode(':', $value, 2);
|
|
|
|
|
$selector = trim($selector);
|
|
|
|
|
$token = trim($token);
|
|
|
|
|
if ($selector === '' || $token === '') {
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->clearCookie();
|
2026-02-04 23:31:53 +01:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
$record = $this->rememberTokenRepository->findBySelector($selector);
|
2026-02-04 23:31:53 +01:00
|
|
|
if (!$record) {
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->clearCookie();
|
2026-02-04 23:31:53 +01:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$expiresAt = (string) ($record['expires_at'] ?? '');
|
|
|
|
|
if ($expiresAt !== '' && strtotime($expiresAt . ' UTC') <= time()) {
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->rememberTokenRepository->deleteById((int) $record['id']);
|
|
|
|
|
$this->clearCookie();
|
2026-02-04 23:31:53 +01:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$hash = (string) ($record['token_hash'] ?? '');
|
|
|
|
|
if ($hash === '' || !hash_equals($hash, hash('sha256', $token))) {
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->rememberTokenRepository->deleteById((int) $record['id']);
|
|
|
|
|
$this->clearCookie();
|
2026-02-04 23:31:53 +01:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$userId = (int) ($record['user_id'] ?? 0);
|
|
|
|
|
if ($userId <= 0) {
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->clearCookie();
|
2026-02-04 23:31:53 +01:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
$user = $this->userReadRepository->find($userId);
|
2026-02-04 23:31:53 +01:00
|
|
|
if (!$user || empty($user['id']) || !($user['active'] ?? 1)) {
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->rememberTokenRepository->deleteById((int) $record['id']);
|
|
|
|
|
$this->clearCookie();
|
2026-02-04 23:31:53 +01:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Session::regenerate();
|
2026-03-06 00:44:52 +01:00
|
|
|
$this->sessionStore->set('user', $user);
|
2026-03-09 20:47:21 +01:00
|
|
|
$now = time();
|
|
|
|
|
$this->sessionStore->set('session_started_at', $now);
|
|
|
|
|
$this->sessionStore->set('session_last_activity', $now);
|
2026-02-04 23:31:53 +01:00
|
|
|
if (!empty($user['locale'])) {
|
|
|
|
|
I18n::$locale = (string) $user['locale'];
|
|
|
|
|
}
|
add composer-unused, comprehensive docs, and project restructure
- 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>
2026-02-22 15:27:35 +01:00
|
|
|
$userId = (int) $user['id'];
|
2026-02-04 23:31:53 +01:00
|
|
|
if ($userId > 0) {
|
2026-03-13 11:31:33 +01:00
|
|
|
$this->permissionService->getUserPermissions($userId, true);
|
2026-03-06 00:44:52 +01:00
|
|
|
$this->authSessionTenantContextService->hydrateForUser($userId);
|
|
|
|
|
if ((bool) $this->sessionStore->get('no_active_tenant', false)) {
|
add composer-unused, comprehensive docs, and project restructure
- 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>
2026-02-22 15:27:35 +01:00
|
|
|
// Enforce the same tenant policy as interactive login.
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->forgetCurrentUser();
|
|
|
|
|
Auth::logout();
|
add composer-unused, comprehensive docs, and project restructure
- 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>
2026-02-22 15:27:35 +01:00
|
|
|
return false;
|
2026-02-11 19:28:12 +01:00
|
|
|
}
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->userWriteRepository->updateLastLogin($userId, 'local');
|
2026-02-04 23:31:53 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
// Rotate token on each auto-login — limits the window if a cookie is stolen.
|
2026-02-04 23:31:53 +01:00
|
|
|
$newToken = bin2hex(random_bytes(32));
|
|
|
|
|
$newHash = hash('sha256', $newToken);
|
2026-02-23 12:58:19 +01:00
|
|
|
$newExpires = gmdate('Y-m-d H:i:s', time() + $this->lifetimeSeconds());
|
|
|
|
|
$this->rememberTokenRepository->updateToken((int) $record['id'], $newHash, $newExpires);
|
|
|
|
|
$this->setCookie($selector, $newToken);
|
2026-02-04 23:31:53 +01:00
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
public function forgetCurrentUser(): void
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
2026-03-06 00:44:52 +01:00
|
|
|
$value = $this->cookieStore->get($this->cookieName());
|
2026-02-04 23:31:53 +01:00
|
|
|
if ($value !== '' && strpos($value, ':') !== false) {
|
|
|
|
|
[$selector] = explode(':', $value, 2);
|
|
|
|
|
$selector = trim($selector);
|
|
|
|
|
if ($selector !== '') {
|
2026-02-23 12:58:19 +01:00
|
|
|
$record = $this->rememberTokenRepository->findBySelector($selector);
|
2026-02-04 23:31:53 +01:00
|
|
|
if ($record && isset($record['id'])) {
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->rememberTokenRepository->deleteById((int) $record['id']);
|
2026-02-04 23:31:53 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->clearCookie();
|
2026-02-04 23:31:53 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
public function forgetAllForUser(int $userId): void
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
2026-02-23 12:58:19 +01:00
|
|
|
$this->rememberTokenRepository->deleteByUserId($userId);
|
2026-02-04 23:31:53 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
public function expireAllTokensByAdmin(): int
|
2026-02-11 19:28:12 +01:00
|
|
|
{
|
2026-02-23 12:58:19 +01:00
|
|
|
return $this->rememberTokenRepository->expireAllByAdmin();
|
2026-02-11 19:28:12 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
private function setCookie(string $selector, string $token): void
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
|
|
|
|
$value = $selector . ':' . $token;
|
2026-02-23 12:58:19 +01:00
|
|
|
$expires = time() + $this->lifetimeSeconds();
|
2026-03-06 00:44:52 +01:00
|
|
|
$secure = $this->requestRuntime->isSecure();
|
2026-02-04 23:31:53 +01:00
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
$this->cookieStore->set($this->cookieName(), $value, [
|
2026-02-04 23:31:53 +01:00
|
|
|
'expires' => $expires,
|
|
|
|
|
'path' => '/',
|
|
|
|
|
'secure' => $secure,
|
|
|
|
|
'httponly' => true,
|
|
|
|
|
'samesite' => 'Lax',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
private function clearCookie(): void
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
2026-03-06 00:44:52 +01:00
|
|
|
$secure = $this->requestRuntime->isSecure();
|
|
|
|
|
$this->cookieStore->remove($this->cookieName(), [
|
2026-02-04 23:31:53 +01:00
|
|
|
'path' => '/',
|
|
|
|
|
'secure' => $secure,
|
|
|
|
|
'httponly' => true,
|
|
|
|
|
'samesite' => 'Lax',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
private function lifetimeSeconds(): int
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
2026-03-10 22:48:10 +01:00
|
|
|
if ($this->authSettingsGateway !== null) {
|
|
|
|
|
return $this->authSettingsGateway->getRememberTokenLifetimeDays() * 86400;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return self::LIFETIME_DAYS * 86400;
|
2026-02-04 23:31:53 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-23 12:58:19 +01:00
|
|
|
private function cookieName(): string
|
2026-02-04 23:31:53 +01:00
|
|
|
{
|
|
|
|
|
$name = getenv('REMEMBER_COOKIE_NAME') ?: self::COOKIE_NAME;
|
|
|
|
|
return trim($name) !== '' ? $name : self::COOKIE_NAME;
|
|
|
|
|
}
|
2026-02-23 12:58:19 +01:00
|
|
|
|
2026-02-04 23:31:53 +01:00
|
|
|
}
|