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

379 lines
13 KiB
PHP
Raw Normal View History

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
use MintyPHP\Auth;
2026-03-06 00:44:52 +01:00
use MintyPHP\Http\SessionStoreInterface;
2026-03-05 08:26:51 +01:00
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
2026-02-04 23:31:53 +01:00
use MintyPHP\Router;
2026-03-05 11:17:42 +01:00
use MintyPHP\Service\Audit\SystemAuditService;
2026-02-23 12:58:19 +01:00
use MintyPHP\Service\User\UserAccountService;
use MintyPHP\Service\User\UserTenantContextService;
use MintyPHP\Session;
2026-02-04 23:31:53 +01:00
use MintyPHP\Support\Flash;
class AuthService
{
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,
2026-02-23 12:58:19 +01:00
private readonly UserAccountService $userAccountService,
private readonly UserTenantContextService $userTenantContextService,
private readonly RememberMeService $rememberMeService,
private readonly EmailVerificationService $emailVerificationService,
private readonly AuthPermissionGateway $permissionGateway,
private readonly AuthTenantSsoGateway $tenantSsoGateway,
2026-03-06 00:44:52 +01:00
private readonly AuthSessionTenantContextService $authSessionTenantContextService,
private readonly SystemAuditService $systemAuditService,
private readonly SessionStoreInterface $sessionStore
2026-02-23 12:58:19 +01:00
) {
}
public function login(string $email, string $password): array
2026-02-04 23:31:53 +01:00
{
$email = trim($email);
// Check if email is verified before attempting login
2026-02-23 12:58:19 +01:00
$existingUser = $this->userReadRepository->findByEmail($email);
2026-02-04 23:31:53 +01:00
if ($existingUser && empty($existingUser['email_verified_at'])) {
2026-03-04 15:56:58 +01:00
$this->recordAuthEvent('auth.login.failed', 'failed', [
'error_code' => 'email_not_verified',
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
]);
2026-02-04 23:31:53 +01:00
return [
'ok' => false,
'message' => 'Please verify your email first',
'flash_key' => 'login_not_verified',
'needs_verification' => true,
'email' => $email,
];
}
$user = Auth::login($email, $password);
if (!$user) {
2026-03-04 15:56:58 +01:00
$this->recordAuthEvent('auth.login.failed', 'failed', [
'error_code' => 'invalid_credentials',
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
]);
2026-02-04 23:31:53 +01:00
return [
'ok' => false,
'message' => 'Email/password not valid',
'flash_key' => 'login_invalid',
];
}
2026-03-06 00:44:52 +01:00
$sessionUser = $this->sessionUser();
if (!($sessionUser['active'] ?? 1)) {
2026-02-04 23:31:53 +01:00
Auth::logout();
2026-03-04 15:56:58 +01:00
$this->recordAuthEvent('auth.login.failed', 'failed', [
'error_code' => 'account_inactive',
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
]);
2026-02-04 23:31:53 +01:00
return [
'ok' => false,
'message' => 'Account is inactive',
'flash_key' => 'login_inactive',
];
}
2026-03-06 00:44:52 +01:00
$userId = (int) ($sessionUser['id'] ?? 0);
2026-02-23 12:58:19 +01:00
if ($userId > 0 && !$this->isLocalPasswordLoginAllowedForUser($userId)) {
Auth::logout();
2026-03-04 15:56:58 +01:00
$this->recordAuthEvent('auth.login.failed', 'failed', [
'error_code' => 'password_login_disabled',
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
]);
return [
'ok' => false,
'message' => 'Password login is disabled for all assigned tenants',
'flash_key' => 'login_password_disabled_all_tenants',
];
}
2026-02-04 23:31:53 +01:00
if ($userId > 0) {
2026-02-23 12:58:19 +01:00
$this->permissionGateway->warmUserPermissions($userId, true);
$this->loadTenantDataIntoSession($userId);
2026-03-06 00:44:52 +01:00
if ($this->noActiveTenant()) {
2026-02-04 23:31:53 +01:00
Auth::logout();
2026-03-04 15:56:58 +01:00
$this->recordAuthEvent('auth.login.failed', 'failed', [
'error_code' => 'no_active_tenant',
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
]);
2026-02-04 23:31:53 +01:00
return [
'ok' => false,
'message' => 'No active tenant assigned',
'flash_key' => 'login_no_active_tenant',
];
}
2026-02-23 12:58:19 +01:00
$this->userWriteRepository->updateLastLogin($userId, 'local');
2026-02-04 23:31:53 +01:00
}
2026-03-04 15:56:58 +01:00
$this->recordAuthEvent('auth.login.success', 'success', [
'actor_user_id' => $userId > 0 ? $userId : null,
2026-03-06 00:44:52 +01:00
'actor_tenant_id' => $this->currentTenantIdFromSession(),
2026-03-04 15:56:58 +01:00
]);
2026-02-04 23:31:53 +01:00
return ['ok' => true];
}
2026-03-06 00:44:52 +01:00
// Returns true if at least ONE assigned tenant permits local login.
// A user is only blocked if ALL tenants enforce Microsoft-only login.
2026-02-23 12:58:19 +01:00
private function isLocalPasswordLoginAllowedForUser(int $userId): bool
{
if ($userId <= 0) {
return false;
}
2026-02-23 12:58:19 +01:00
$availableTenants = $this->userTenantContextService->getAvailableTenants($userId);
if (!$availableTenants) {
return true;
}
foreach ($availableTenants as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
2026-02-23 12:58:19 +01:00
if ($this->tenantSsoGateway->isLocalPasswordLoginAllowed($tenantId)) {
return true;
}
}
return false;
}
2026-02-23 12:58:19 +01:00
public function canLoginToTenant(int $userId, int $tenantId): bool
{
if ($userId <= 0 || $tenantId <= 0) {
return false;
}
2026-02-23 12:58:19 +01:00
foreach ($this->userTenantContextService->getAvailableTenants($userId) as $tenant) {
if ((int) ($tenant['id'] ?? 0) === $tenantId) {
return true;
}
}
return false;
}
2026-02-23 12:58:19 +01:00
public function loginUserById(int $userId, ?int $preferredTenantId = null, string $loginProvider = 'local'): array
{
if ($userId <= 0) {
return ['ok' => false, 'error' => 'user_not_found'];
}
2026-02-23 12:58:19 +01:00
$user = $this->userReadRepository->find($userId);
if (!$user) {
return ['ok' => false, 'error' => 'user_not_found'];
}
if (!((int) ($user['active'] ?? 1) === 1)) {
return ['ok' => false, 'error' => 'user_inactive'];
}
Session::regenerate();
2026-03-06 00:44:52 +01:00
$this->sessionStore->set('user', $user);
if ($preferredTenantId !== null && $preferredTenantId > 0) {
2026-02-23 12:58:19 +01:00
$setTenant = $this->userTenantContextService->setCurrentTenant($userId, $preferredTenantId);
if ($setTenant['ok'] ?? false) {
2026-03-06 00:44:52 +01:00
$sessionUser = $this->sessionUser();
$sessionUser['current_tenant_id'] = $preferredTenantId;
$this->sessionStore->set('user', $sessionUser);
}
}
2026-02-23 12:58:19 +01:00
$this->permissionGateway->warmUserPermissions($userId, true);
$this->loadTenantDataIntoSession($userId);
2026-03-06 00:44:52 +01:00
if ($this->noActiveTenant()) {
Auth::logout();
2026-03-04 15:56:58 +01:00
$this->recordAuthEvent('auth.login.failed', 'failed', [
'error_code' => 'login_no_active_tenant',
'actor_user_id' => $userId,
]);
return ['ok' => false, 'error' => 'login_no_active_tenant'];
}
2026-02-23 12:58:19 +01:00
$this->userWriteRepository->updateLastLogin($userId, $loginProvider);
2026-03-04 15:56:58 +01:00
$this->recordAuthEvent('auth.login.success', 'success', [
'actor_user_id' => $userId,
2026-03-06 00:44:52 +01:00
'actor_tenant_id' => $this->currentTenantIdFromSession(),
2026-03-04 15:56:58 +01:00
'metadata' => ['provider' => $loginProvider],
]);
return ['ok' => true, 'user' => $user];
}
2026-02-23 12:58:19 +01:00
public function loginAndRedirect(
2026-02-04 23:31:53 +01:00
string $email,
string $password,
string $successTarget = 'admin',
string $failTarget = 'login',
bool $remember = false
): void {
2026-02-23 12:58:19 +01:00
$result = $this->login($email, $password);
2026-02-04 23:31:53 +01:00
if (!($result['ok'] ?? false)) {
// Check if user needs to verify email
if (!empty($result['needs_verification'])) {
2026-03-06 00:44:52 +01:00
$this->sessionStore->set('email_verification_email', $result['email'] ?? $email);
2026-02-04 23:31:53 +01:00
Flash::error(t('Please verify your email first'), 'verify-email', 'login_not_verified');
Router::redirect('verify-email');
}
$message = $result['message'] ?? 'Email/password not valid';
$key = $result['flash_key'] ?? 'login_invalid';
Flash::error($message, $failTarget, $key);
Router::redirect($failTarget);
}
if ($remember) {
2026-03-06 00:44:52 +01:00
$userId = (int) ($this->sessionUser()['id'] ?? 0);
2026-02-04 23:31:53 +01:00
if ($userId > 0) {
2026-02-23 12:58:19 +01:00
$this->rememberMeService->rememberUser($userId);
2026-02-04 23:31:53 +01:00
}
}
Router::redirect($successTarget);
}
2026-02-23 12:58:19 +01:00
public function register(array $data): array
2026-02-04 23:31:53 +01:00
{
$email = trim((string) ($data['email'] ?? ''));
2026-02-23 12:58:19 +01:00
$result = $this->userAccountService->register($data);
2026-02-04 23:31:53 +01:00
if (!($result['ok'] ?? false)) {
return $result;
}
// Get the created user to send verification email
2026-02-23 12:58:19 +01:00
$createdUser = $this->userReadRepository->findByEmail($email);
2026-02-04 23:31:53 +01:00
if ($createdUser && isset($createdUser['id'])) {
$userId = (int) $createdUser['id'];
2026-02-23 12:58:19 +01:00
$this->emailVerificationService->sendVerification($userId);
2026-02-04 23:31:53 +01:00
}
// Don't login - user needs to verify email first
return ['ok' => true, 'email' => $email, 'needs_verification' => true];
}
2026-02-23 12:58:19 +01:00
public function logout(): void
2026-02-04 23:31:53 +01:00
{
2026-03-06 00:44:52 +01:00
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
$actorTenantId = $this->currentTenantIdFromSession();
2026-02-23 12:58:19 +01:00
$this->rememberMeService->forgetCurrentUser();
2026-02-04 23:31:53 +01:00
Auth::logout();
2026-03-04 15:56:58 +01:00
$this->recordAuthEvent('auth.logout', 'success', [
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
]);
2026-02-04 23:31:53 +01:00
}
2026-02-23 12:58:19 +01:00
public function logoutAndRedirect(string $target = 'login'): void
2026-02-04 23:31:53 +01:00
{
2026-03-04 15:56:58 +01:00
$this->logout();
2026-02-04 23:31:53 +01:00
Router::redirect($target);
}
2026-03-04 15:56:58 +01:00
private function recordAuthEvent(string $eventType, string $outcome, array $context = []): void
{
$this->systemAuditService->record($eventType, $outcome, $context);
}
2026-02-04 23:31:53 +01:00
2026-02-23 12:58:19 +01:00
public function refreshSessionAuthState(int $userId): array
{
if ($userId <= 0) {
return [
'ok' => false,
'logout_required' => true,
'reason' => 'user_missing',
];
}
2026-02-23 12:58:19 +01:00
$snapshot = $this->userReadRepository->findAuthzSnapshot($userId);
if (!$snapshot) {
return [
'ok' => false,
'logout_required' => true,
'reason' => 'user_not_found',
];
}
if ((int) ($snapshot['active'] ?? 0) !== 1) {
return [
'ok' => false,
'logout_required' => true,
'reason' => 'inactive',
];
}
2026-03-06 00:44:52 +01:00
// authz_version is bumped in DB whenever permissions/tenants change.
// A mismatch means the session is stale and needs to be refreshed.
$sessionVersion = (int) ($this->sessionUser()['authz_version'] ?? 0);
$dbVersion = max(1, (int) ($snapshot['authz_version'] ?? 1));
$refreshed = false;
if ($sessionVersion !== $dbVersion) {
2026-02-23 12:58:19 +01:00
$user = $this->userReadRepository->find($userId);
if (!$user || (int) ($user['active'] ?? 0) !== 1) {
return [
'ok' => false,
'logout_required' => true,
'reason' => 'inactive',
];
}
2026-03-06 00:44:52 +01:00
$this->sessionStore->set('user', $user);
2026-02-23 12:58:19 +01:00
$this->permissionGateway->warmUserPermissions($userId, true);
$this->loadTenantDataIntoSession($userId);
$refreshed = true;
}
2026-03-06 00:44:52 +01:00
if ($this->noActiveTenant()) {
return [
'ok' => false,
'logout_required' => true,
'reason' => 'no_active_tenant',
'refreshed' => $refreshed,
];
}
return [
'ok' => true,
'logout_required' => false,
'refreshed' => $refreshed,
];
}
2026-02-23 12:58:19 +01:00
public function loadTenantDataIntoSession(int $userId): void
2026-02-04 23:31:53 +01:00
{
2026-03-06 00:44:52 +01:00
$this->authSessionTenantContextService->hydrateForUser($userId);
}
2026-02-04 23:31:53 +01:00
2026-03-06 00:44:52 +01:00
/**
* @return array<string, mixed>
*/
private function sessionUser(): array
{
$user = $this->sessionStore->get('user', []);
return is_array($user) ? $user : [];
}
2026-02-04 23:31:53 +01:00
2026-03-06 00:44:52 +01:00
/**
* @return array<string, mixed>
*/
private function sessionCurrentTenant(): array
{
$tenant = $this->sessionStore->get('current_tenant', []);
return is_array($tenant) ? $tenant : [];
}
2026-02-04 23:31:53 +01:00
2026-03-06 00:44:52 +01:00
private function currentTenantIdFromSession(): int
{
return (int) ($this->sessionCurrentTenant()['id'] ?? 0);
}
private function noActiveTenant(): bool
{
return (bool) $this->sessionStore->get('no_active_tenant', false);
2026-02-04 23:31:53 +01:00
}
}