userReadRepository->findByEmail($email); if ($existingUser && empty($existingUser['email_verified_at'])) { $this->recordAuthEvent('auth.login.failed', 'failed', [ 'error_code' => 'email_not_verified', 'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))], ]); 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) { $this->recordAuthEvent('auth.login.failed', 'failed', [ 'error_code' => 'invalid_credentials', 'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))], ]); return [ 'ok' => false, 'message' => 'Email/password not valid', 'flash_key' => 'login_invalid', ]; } $sessionUser = $this->sessionUser(); if (!($sessionUser['active'] ?? 1)) { $this->logoutWithModuleCleanup(); $this->recordAuthEvent('auth.login.failed', 'failed', [ 'error_code' => 'account_inactive', 'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))], ]); return [ 'ok' => false, 'message' => 'Account is inactive', 'flash_key' => 'login_inactive', ]; } $userId = (int) ($sessionUser['id'] ?? 0); if ($userId > 0 && !$this->isLocalPasswordLoginAllowedForUser($userId)) { $this->logoutWithModuleCleanup(); $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', ]; } if ($userId > 0) { $this->permissionService->getUserPermissions($userId, true); $this->loadTenantDataIntoSession($userId); if ($this->noActiveTenant()) { $this->logoutWithModuleCleanup(); $this->recordAuthEvent('auth.login.failed', 'failed', [ 'error_code' => 'no_active_tenant', 'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))], ]); return [ 'ok' => false, 'message' => 'No active tenant assigned', 'flash_key' => 'login_no_active_tenant', ]; } $this->userWriteRepository->updateLastLogin($userId, 'local'); } $this->recordAuthEvent('auth.login.success', 'success', [ 'actor_user_id' => $userId > 0 ? $userId : null, 'actor_tenant_id' => $this->currentTenantIdFromSession(), ]); $this->eventDispatcher?->dispatch('user.login', [ 'user_id' => $userId, 'provider' => 'local', ]); return ['ok' => true]; } // Returns true if at least ONE assigned tenant permits local login. // A user is only blocked if ALL tenants enforce Microsoft-only login. private function isLocalPasswordLoginAllowedForUser(int $userId): bool { if ($userId <= 0) { return false; } $availableTenants = $this->userTenantContextService->getAvailableTenants($userId); if (!$availableTenants) { return true; } foreach ($availableTenants as $tenant) { $tenantId = (int) ($tenant['id'] ?? 0); if ($tenantId <= 0) { continue; } if ($this->tenantSsoService->isLocalPasswordLoginAllowed($tenantId)) { return true; } } return false; } public function canLoginToTenant(int $userId, int $tenantId): bool { if ($userId <= 0 || $tenantId <= 0) { return false; } foreach ($this->userTenantContextService->getAvailableTenants($userId) as $tenant) { if ((int) ($tenant['id'] ?? 0) === $tenantId) { return true; } } return false; } public function loginUserById(int $userId, ?int $preferredTenantId = null, string $loginProvider = 'local'): array { if ($userId <= 0) { return ['ok' => false, 'error' => 'user_not_found']; } $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(); $this->sessionStore->set('user', $user); if ($preferredTenantId !== null && $preferredTenantId > 0) { $setTenant = $this->userTenantContextService->setCurrentTenant($userId, $preferredTenantId); if ($setTenant['ok'] ?? false) { $sessionUser = $this->sessionUser(); $sessionUser['current_tenant_id'] = $preferredTenantId; $this->sessionStore->set('user', $sessionUser); } } $this->permissionService->getUserPermissions($userId, true); $this->loadTenantDataIntoSession($userId); if ($this->noActiveTenant()) { $this->logoutWithModuleCleanup(); $this->recordAuthEvent('auth.login.failed', 'failed', [ 'error_code' => 'login_no_active_tenant', 'actor_user_id' => $userId, ]); return ['ok' => false, 'error' => 'login_no_active_tenant']; } $this->userWriteRepository->updateLastLogin($userId, $loginProvider); $this->recordAuthEvent('auth.login.success', 'success', [ 'actor_user_id' => $userId, 'actor_tenant_id' => $this->currentTenantIdFromSession(), 'metadata' => ['provider' => $loginProvider], ]); $this->eventDispatcher?->dispatch('user.login', [ 'user_id' => $userId, 'provider' => $loginProvider, ]); return ['ok' => true, 'user' => $user]; } public function loginAndRedirect( string $email, string $password, string $successTarget = 'admin', string $failTarget = 'login', bool $remember = false ): void { $result = $this->login($email, $password); if (!($result['ok'] ?? false)) { // Check if user needs to verify email if (!empty($result['needs_verification'])) { $this->sessionStore->set('email_verification_email', $result['email'] ?? $email); 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) { $userId = (int) ($this->sessionUser()['id'] ?? 0); if ($userId > 0) { $this->rememberMeService->rememberUser($userId); } } Router::redirect($successTarget); } public function register(array $data): array { $email = trim((string) ($data['email'] ?? '')); $result = $this->userAccountService->register($data); if (!($result['ok'] ?? false)) { return $result; } // Get the created user to send verification email $createdUser = $this->userReadRepository->findByEmail($email); if ($createdUser && isset($createdUser['id'])) { $userId = (int) $createdUser['id']; $this->emailVerificationService->sendVerification($userId); } // Don't login - user needs to verify email first return ['ok' => true, 'email' => $email, 'needs_verification' => true]; } public function logout(): void { $actorUserId = (int) ($this->sessionUser()['id'] ?? 0); $actorTenantId = $this->currentTenantIdFromSession(); $this->eventDispatcher?->dispatch('user.logout', [ 'user_id' => $actorUserId, ]); $this->rememberMeService->forgetCurrentUser(); $this->logoutWithModuleCleanup(); $this->recordAuthEvent('auth.logout', 'success', [ 'actor_user_id' => $actorUserId > 0 ? $actorUserId : null, 'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null, ]); } public function logoutDueToSessionTimeout(): void { $actorUserId = (int) ($this->sessionUser()['id'] ?? 0); $actorTenantId = $this->currentTenantIdFromSession(); $this->sessionStore->remove('session_started_at'); $this->sessionStore->remove('session_last_activity'); $this->logoutWithModuleCleanup(); $this->recordAuthEvent('auth.session.timeout', 'success', [ 'actor_user_id' => $actorUserId > 0 ? $actorUserId : null, 'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null, ]); } public function logoutAndRedirect(string $target = 'login'): void { $this->logout(); Router::redirect($target); } private function recordAuthEvent(string $eventType, string $outcome, array $context = []): void { $this->systemAuditService->record($eventType, $outcome, $context); } /** * Re-validates the session against the current DB state. * * Uses an authz_version counter: the DB version is bumped whenever permissions or * tenant assignments change. A mismatch with the session version triggers a full * reload of user data, permissions, and tenant context. This is the mechanism that * makes permission changes take effect without requiring re-login. */ public function refreshSessionAuthState(int $userId): array { if ($userId <= 0) { return [ 'ok' => false, 'logout_required' => true, 'reason' => 'user_missing', ]; } $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', ]; } // 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) { $user = $this->userReadRepository->find($userId); if (!$user || (int) ($user['active'] ?? 0) !== 1) { return [ 'ok' => false, 'logout_required' => true, 'reason' => 'inactive', ]; } $this->sessionStore->set('user', $user); $this->permissionService->getUserPermissions($userId, true); $this->loadTenantDataIntoSession($userId); $refreshed = true; } if ($this->noActiveTenant()) { return [ 'ok' => false, 'logout_required' => true, 'reason' => 'no_active_tenant', 'refreshed' => $refreshed, ]; } return [ 'ok' => true, 'logout_required' => false, 'refreshed' => $refreshed, ]; } public function loadTenantDataIntoSession(int $userId): void { $this->authSessionTenantContextService->hydrateForUser($userId); } /** * @return array */ private function sessionUser(): array { $user = $this->sessionStore->get('user', []); return is_array($user) ? $user : []; } /** * @return array */ private function sessionCurrentTenant(): array { $tenant = $this->sessionStore->get('current_tenant', []); return is_array($tenant) ? $tenant : []; } private function currentTenantIdFromSession(): int { return (int) ($this->sessionCurrentTenant()['id'] ?? 0); } private function noActiveTenant(): bool { return (bool) $this->sessionStore->get('no_active_tenant', false); } private function logoutWithModuleCleanup(): void { $this->authSessionTenantContextService->clearModuleSessionData(); Auth::logout(); } }