From 11ca546eaea06e2992a249e81ee6713bcc659545 Mon Sep 17 00:00:00 2001 From: fs Date: Mon, 9 Mar 2026 19:16:26 +0100 Subject: [PATCH] feat(security): add session timeout + transaction wrapping (B1) Session timeout: configurable idle (default 30min) and absolute (default 8h) timeouts via DB settings, enforced in web/index.php on every request. Timestamps set at login in action layer; graceful fallback for pre-existing sessions. Transaction wrapping: UserAccountService.createFromAdmin/register, roles create/edit, and permissions edit now wrap multi-step DB operations in transactions to guarantee atomicity. Co-Authored-By: Claude Opus 4.6 --- i18n/default_de.json | 5 +- i18n/default_en.json | 5 +- .../Registrars/SettingsRegistrar.php | 2 + lib/Service/Auth/AuthService.php | 1 + lib/Service/Settings/SettingKeys.php | 2 + .../Settings/SettingServicesFactory.php | 6 + .../Settings/SettingsSessionGateway.php | 79 +++++++ lib/Service/User/UserAccountService.php | 219 +++++++++++------- lib/Service/User/UserServicesFactory.php | 3 +- pages/admin/permissions/edit($id).php | 21 +- pages/admin/roles/create().php | 12 +- pages/admin/roles/edit($id).php | 19 +- pages/auth/login().php | 2 + pages/auth/microsoft/callback().php | 4 + .../Settings/SettingsSessionGatewayTest.php | 98 ++++++++ tests/Service/User/UserAccountServiceTest.php | 8 +- web/index.php | 32 +++ 17 files changed, 415 insertions(+), 103 deletions(-) create mode 100644 lib/Service/Settings/SettingsSessionGateway.php create mode 100644 tests/Service/Settings/SettingsSessionGatewayTest.php diff --git a/i18n/default_de.json b/i18n/default_de.json index b109928..d88814e 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -1204,5 +1204,8 @@ "Trend vs previous 7d": "Trend vs. vorherige 7d", "Top lifecycle reason codes (7d)": "Top-Lifecycle-Fehlercodes (7d)", "Recent lifecycle risks (7d)": "Aktuelle Lifecycle-Risiken (7d)", - "Login progress": "Anmeldefortschritt" + "Login progress": "Anmeldefortschritt", + "Your session has expired. Please log in again.": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "Failed to update role permissions": "Rollenberechtigungen konnten nicht aktualisiert werden", + "Failed to update permission roles": "Berechtigungsrollen konnten nicht aktualisiert werden" } diff --git a/i18n/default_en.json b/i18n/default_en.json index 772c9af..7131ae0 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -1204,5 +1204,8 @@ "Trend vs previous 7d": "Trend vs previous 7d", "Top lifecycle reason codes (7d)": "Top lifecycle reason codes (7d)", "Recent lifecycle risks (7d)": "Recent lifecycle risks (7d)", - "Login progress": "Login progress" + "Login progress": "Login progress", + "Your session has expired. Please log in again.": "Your session has expired. Please log in again.", + "Failed to update role permissions": "Failed to update role permissions", + "Failed to update permission roles": "Failed to update permission roles" } diff --git a/lib/App/Container/Registrars/SettingsRegistrar.php b/lib/App/Container/Registrars/SettingsRegistrar.php index 1c3e7c9..eac8f74 100644 --- a/lib/App/Container/Registrars/SettingsRegistrar.php +++ b/lib/App/Container/Registrars/SettingsRegistrar.php @@ -21,6 +21,7 @@ use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway; use MintyPHP\Service\Settings\SettingsMetadataGateway; use MintyPHP\Service\Settings\SettingsMicrosoftGateway; +use MintyPHP\Service\Settings\SettingsSessionGateway; use MintyPHP\Service\Settings\SettingsSmtpGateway; use MintyPHP\Service\Settings\SettingsSystemAuditGateway; use MintyPHP\Service\Settings\SettingsUserLifecycleGateway; @@ -36,6 +37,7 @@ final class SettingsRegistrar implements ContainerRegistrar $container->set(SettingsAppGateway::class, static fn (AppContainer $c): SettingsAppGateway => $c->get(SettingServicesFactory::class)->createSettingsAppGateway()); $container->set(SettingsApiPolicyGateway::class, static fn (AppContainer $c): SettingsApiPolicyGateway => $c->get(SettingServicesFactory::class)->createSettingsApiPolicyGateway()); $container->set(SettingsUserLifecycleGateway::class, static fn (AppContainer $c): SettingsUserLifecycleGateway => $c->get(SettingServicesFactory::class)->createSettingsUserLifecycleGateway()); + $container->set(SettingsSessionGateway::class, static fn (AppContainer $c): SettingsSessionGateway => $c->get(SettingServicesFactory::class)->createSettingsSessionGateway()); $container->set(SettingsSystemAuditGateway::class, static fn (AppContainer $c): SettingsSystemAuditGateway => $c->get(SettingServicesFactory::class)->createSettingsSystemAuditGateway()); $container->set(SettingsFrontendTelemetryGateway::class, static fn (AppContainer $c): SettingsFrontendTelemetryGateway => $c->get(SettingServicesFactory::class)->createSettingsFrontendTelemetryGateway()); $container->set(SettingsMicrosoftGateway::class, static fn (AppContainer $c): SettingsMicrosoftGateway => $c->get(SettingServicesFactory::class)->createSettingsMicrosoftGateway()); diff --git a/lib/Service/Auth/AuthService.php b/lib/Service/Auth/AuthService.php index a22ccc7..a82ba28 100644 --- a/lib/Service/Auth/AuthService.php +++ b/lib/Service/Auth/AuthService.php @@ -196,6 +196,7 @@ class AuthService } $this->userWriteRepository->updateLastLogin($userId, $loginProvider); + $this->recordAuthEvent('auth.login.success', 'success', [ 'actor_user_id' => $userId, 'actor_tenant_id' => $this->currentTenantIdFromSession(), diff --git a/lib/Service/Settings/SettingKeys.php b/lib/Service/Settings/SettingKeys.php index 526f4d8..257731c 100644 --- a/lib/Service/Settings/SettingKeys.php +++ b/lib/Service/Settings/SettingKeys.php @@ -33,6 +33,8 @@ final class SettingKeys public const FRONTEND_TELEMETRY_ENABLED_KEY = 'frontend_telemetry_enabled'; public const FRONTEND_TELEMETRY_SAMPLE_RATE_KEY = 'frontend_telemetry_sample_rate'; public const FRONTEND_TELEMETRY_ALLOWED_EVENTS_KEY = 'frontend_telemetry_allowed_events'; + public const SESSION_IDLE_TIMEOUT_MINUTES_KEY = 'session_idle_timeout_minutes'; + public const SESSION_ABSOLUTE_TIMEOUT_HOURS_KEY = 'session_absolute_timeout_hours'; private function __construct() { diff --git a/lib/Service/Settings/SettingServicesFactory.php b/lib/Service/Settings/SettingServicesFactory.php index 9733b0b..dc8f932 100644 --- a/lib/Service/Settings/SettingServicesFactory.php +++ b/lib/Service/Settings/SettingServicesFactory.php @@ -20,6 +20,7 @@ class SettingServicesFactory private ?SettingsSystemAuditGateway $settingsSystemAuditGateway = null; private ?SettingsFrontendTelemetryGateway $settingsFrontendTelemetryGateway = null; private ?SettingsMicrosoftGateway $settingsMicrosoftGateway = null; + private ?SettingsSessionGateway $settingsSessionGateway = null; private ?SettingsSmtpGateway $settingsSmtpGateway = null; public function __construct( @@ -83,6 +84,11 @@ class SettingServicesFactory ); } + public function createSettingsSessionGateway(): SettingsSessionGateway + { + return $this->settingsSessionGateway ??= new SettingsSessionGateway($this->createSettingsMetadataGateway()); + } + public function createSettingsSmtpGateway(): SettingsSmtpGateway { return $this->settingsSmtpGateway ??= new SettingsSmtpGateway($this->createSettingsMetadataGateway()); diff --git a/lib/Service/Settings/SettingsSessionGateway.php b/lib/Service/Settings/SettingsSessionGateway.php new file mode 100644 index 0000000..816ec42 --- /dev/null +++ b/lib/Service/Settings/SettingsSessionGateway.php @@ -0,0 +1,79 @@ +settingsMetadataGateway->getInt(SettingKeys::SESSION_IDLE_TIMEOUT_MINUTES_KEY); + if ($value !== null && $this->isValidIdleTimeout($value)) { + return $value; + } + + return self::IDLE_TIMEOUT_MINUTES_FALLBACK; + } + + public function setSessionIdleTimeoutMinutes(?int $minutes, ?string $description = null): bool + { + $value = $minutes ?? self::IDLE_TIMEOUT_MINUTES_FALLBACK; + if (!$this->isValidIdleTimeout($value)) { + return false; + } + + $desc = $description ?? 'setting.session_idle_timeout_minutes'; + return $this->settingsMetadataGateway->set(SettingKeys::SESSION_IDLE_TIMEOUT_MINUTES_KEY, (string) $value, $desc); + } + + public function getSessionAbsoluteTimeoutHours(): int + { + $value = $this->settingsMetadataGateway->getInt(SettingKeys::SESSION_ABSOLUTE_TIMEOUT_HOURS_KEY); + if ($value !== null && $this->isValidAbsoluteTimeout($value)) { + return $value; + } + + return self::ABSOLUTE_TIMEOUT_HOURS_FALLBACK; + } + + public function setSessionAbsoluteTimeoutHours(?int $hours, ?string $description = null): bool + { + $value = $hours ?? self::ABSOLUTE_TIMEOUT_HOURS_FALLBACK; + if (!$this->isValidAbsoluteTimeout($value)) { + return false; + } + + $desc = $description ?? 'setting.session_absolute_timeout_hours'; + return $this->settingsMetadataGateway->set(SettingKeys::SESSION_ABSOLUTE_TIMEOUT_HOURS_KEY, (string) $value, $desc); + } + + public function getSessionIdleTimeoutSeconds(): int + { + return $this->getSessionIdleTimeoutMinutes() * 60; + } + + public function getSessionAbsoluteTimeoutSeconds(): int + { + return $this->getSessionAbsoluteTimeoutHours() * 3600; + } + + private function isValidIdleTimeout(int $minutes): bool + { + return $minutes >= self::IDLE_TIMEOUT_MINUTES_MIN && $minutes <= self::IDLE_TIMEOUT_MINUTES_MAX; + } + + private function isValidAbsoluteTimeout(int $hours): bool + { + return $hours >= self::ABSOLUTE_TIMEOUT_HOURS_MIN && $hours <= self::ABSOLUTE_TIMEOUT_HOURS_MAX; + } +} diff --git a/lib/Service/User/UserAccountService.php b/lib/Service/User/UserAccountService.php index a307bf1..baec91b 100644 --- a/lib/Service/User/UserAccountService.php +++ b/lib/Service/User/UserAccountService.php @@ -3,6 +3,7 @@ namespace MintyPHP\Service\User; use MintyPHP\I18n; +use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\User\UserListQueryRepositoryInterface; use MintyPHP\Repository\User\UserReadRepositoryInterface; use MintyPHP\Repository\User\UserWriteRepositoryInterface; @@ -19,7 +20,8 @@ class UserAccountService private readonly UserSettingsGateway $settingsGateway, private readonly UserScopeGateway $scopeGateway, private readonly UserDirectoryGateway $directoryGateway, - private readonly SystemAuditService $systemAuditService + private readonly SystemAuditService $systemAuditService, + private readonly DatabaseSessionRepository $databaseSessionRepository ) { } @@ -179,66 +181,82 @@ class UserAccountService } $activeChangedAt = gmdate('Y-m-d H:i:s'); - $created = $this->userWriteRepository->create([ - 'first_name' => $form['first_name'], - 'last_name' => $form['last_name'], - 'email' => $form['email'], - 'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null, - 'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null, - 'phone' => $form['phone'] !== '' ? $form['phone'] : null, - 'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null, - 'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null, - 'address' => $form['address'] !== '' ? $form['address'] : null, - 'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null, - 'city' => $form['city'] !== '' ? $form['city'] : null, - 'country' => $form['country'] !== '' ? $form['country'] : null, - 'region' => $form['region'] !== '' ? $form['region'] : null, - 'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null, - 'password' => $password, - 'locale' => $form['locale'], - 'totp_secret' => $form['totp_secret'], - 'theme' => $form['theme'], - 'primary_tenant_id' => $primaryTenantId > 0 ? $primaryTenantId : null, - 'active' => $form['active'], - 'created_by' => $currentUserId > 0 ? $currentUserId : null, - 'active_changed_at' => $activeChangedAt, - 'active_changed_by' => $currentUserId > 0 ? $currentUserId : null, - ]); - if (!$created) { + $transactionStarted = false; + try { + $this->databaseSessionRepository->beginTransaction(); + $transactionStarted = true; + + $created = $this->userWriteRepository->create([ + 'first_name' => $form['first_name'], + 'last_name' => $form['last_name'], + 'email' => $form['email'], + 'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null, + 'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null, + 'phone' => $form['phone'] !== '' ? $form['phone'] : null, + 'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null, + 'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null, + 'address' => $form['address'] !== '' ? $form['address'] : null, + 'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null, + 'city' => $form['city'] !== '' ? $form['city'] : null, + 'country' => $form['country'] !== '' ? $form['country'] : null, + 'region' => $form['region'] !== '' ? $form['region'] : null, + 'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null, + 'password' => $password, + 'locale' => $form['locale'], + 'totp_secret' => $form['totp_secret'], + 'theme' => $form['theme'], + 'primary_tenant_id' => $primaryTenantId > 0 ? $primaryTenantId : null, + 'active' => $form['active'], + 'created_by' => $currentUserId > 0 ? $currentUserId : null, + 'active_changed_at' => $activeChangedAt, + 'active_changed_by' => $currentUserId > 0 ? $currentUserId : null, + ]); + + if (!$created) { + $this->rollbackQuietly(); + return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form]; + } + + $createdUser = $this->userReadRepository->findByEmail($form['email']); + $uuid = $createdUser['uuid'] ?? null; + $userId = (int) ($createdUser['id'] ?? 0); + + if ($userId > 0 && $tenantIds) { + $this->userAssignmentService->syncTenants($userId, $tenantIds); + } + + $roleIds = $this->userAssignmentService->normalizeIdInput($input['role_ids'] ?? []); + if (!$roleIds) { + $defaultRoleId = $this->settingsGateway->getDefaultRoleId(); + if ($defaultRoleId) { + $roleIds = [$defaultRoleId]; + } + } + if ($userId > 0 && $roleIds) { + $this->userAssignmentService->syncRoles($userId, $roleIds); + } + + $departmentIds = $this->userAssignmentService->normalizeIdInput($input['department_ids'] ?? []); + if (!$departmentIds) { + $defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId(); + if ($defaultDepartmentId) { + $departmentIds = [$defaultDepartmentId]; + } + } + if ($userId > 0 && $departmentIds) { + $this->userAssignmentService->syncDepartments($userId, $departmentIds); + } + + $this->databaseSessionRepository->commitTransaction(); + $transactionStarted = false; + } catch (\Throwable $exception) { + if ($transactionStarted) { + $this->rollbackQuietly(); + } return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form]; } - $createdUser = $this->userReadRepository->findByEmail($form['email']); - $uuid = $createdUser['uuid'] ?? null; - $userId = (int) ($createdUser['id'] ?? 0); - - if ($userId > 0 && $tenantIds) { - $this->userAssignmentService->syncTenants($userId, $tenantIds); - } - - $roleIds = $this->userAssignmentService->normalizeIdInput($input['role_ids'] ?? []); - if (!$roleIds) { - $defaultRoleId = $this->settingsGateway->getDefaultRoleId(); - if ($defaultRoleId) { - $roleIds = [$defaultRoleId]; - } - } - if ($userId > 0 && $roleIds) { - $this->userAssignmentService->syncRoles($userId, $roleIds); - } - - $departmentIds = $this->userAssignmentService->normalizeIdInput($input['department_ids'] ?? []); - if (!$departmentIds) { - $defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId(); - if ($defaultDepartmentId) { - $departmentIds = [$defaultDepartmentId]; - } - } - if ($userId > 0 && $departmentIds) { - $this->userAssignmentService->syncDepartments($userId, $departmentIds); - } - $this->systemAuditService->record('admin.users.create', 'success', [ 'actor_user_id' => $currentUserId > 0 ? $currentUserId : null, 'target_type' => 'user', @@ -476,41 +494,57 @@ class UserAccountService $defaultTheme = $this->settingsGateway->getAppTheme(); $defaultTenantId = $this->settingsGateway->getDefaultTenantId(); $activeChangedAt = gmdate('Y-m-d H:i:s'); - $created = $this->userWriteRepository->create([ - 'first_name' => $form['first_name'], - 'last_name' => $form['last_name'], - 'email' => $form['email'], - 'password' => $password, - 'locale' => I18n::$locale, - 'totp_secret' => '', - 'theme' => $this->normalizeTheme($defaultTheme ?? 'light'), - 'primary_tenant_id' => $defaultTenantId ?: null, - 'active' => 1, - 'created_by' => null, - 'active_changed_at' => $activeChangedAt, - 'active_changed_by' => null, - ]); - if (!$created) { + $transactionStarted = false; + try { + $this->databaseSessionRepository->beginTransaction(); + $transactionStarted = true; + + $created = $this->userWriteRepository->create([ + 'first_name' => $form['first_name'], + 'last_name' => $form['last_name'], + 'email' => $form['email'], + 'password' => $password, + 'locale' => I18n::$locale, + 'totp_secret' => '', + 'theme' => $this->normalizeTheme($defaultTheme ?? 'light'), + 'primary_tenant_id' => $defaultTenantId ?: null, + 'active' => 1, + 'created_by' => null, + 'active_changed_at' => $activeChangedAt, + 'active_changed_by' => null, + ]); + + if (!$created) { + $this->rollbackQuietly(); + return ['ok' => false, 'error' => t('User can not be registered')]; + } + + $createdUser = $this->userReadRepository->findByEmail($form['email']); + $userId = (int) ($createdUser['id'] ?? 0); + if ($userId > 0) { + if ($defaultTenantId) { + $this->userAssignmentService->syncTenants($userId, [$defaultTenantId]); + } + $defaultRoleId = $this->settingsGateway->getDefaultRoleId(); + if ($defaultRoleId) { + $this->userAssignmentService->syncRoles($userId, [$defaultRoleId]); + } + $defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId(); + if ($defaultDepartmentId) { + $this->userAssignmentService->syncDepartments($userId, [$defaultDepartmentId]); + } + } + + $this->databaseSessionRepository->commitTransaction(); + $transactionStarted = false; + } catch (\Throwable $exception) { + if ($transactionStarted) { + $this->rollbackQuietly(); + } return ['ok' => false, 'error' => t('User can not be registered')]; } - $createdUser = $this->userReadRepository->findByEmail($form['email']); - $userId = (int) ($createdUser['id'] ?? 0); - if ($userId > 0) { - if ($defaultTenantId) { - $this->userAssignmentService->syncTenants($userId, [$defaultTenantId]); - } - $defaultRoleId = $this->settingsGateway->getDefaultRoleId(); - if ($defaultRoleId) { - $this->userAssignmentService->syncRoles($userId, [$defaultRoleId]); - } - $defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId(); - if ($defaultDepartmentId) { - $this->userAssignmentService->syncDepartments($userId, [$defaultDepartmentId]); - } - } - return ['ok' => true]; } @@ -651,6 +685,15 @@ class UserAccountService ]; } + private function rollbackQuietly(): void + { + try { + $this->databaseSessionRepository->rollbackTransaction(); + } catch (\Throwable) { + // Swallow — the original exception is more important. + } + } + private function normalizeTheme($value): string { $theme = strtolower(trim((string) $value)); diff --git a/lib/Service/User/UserServicesFactory.php b/lib/Service/User/UserServicesFactory.php index f13f38a..1fdb29b 100644 --- a/lib/Service/User/UserServicesFactory.php +++ b/lib/Service/User/UserServicesFactory.php @@ -45,7 +45,8 @@ class UserServicesFactory $this->createUserSettingsGateway(), $this->createUserScopeGateway(), $this->createUserDirectoryGateway(), - $this->auditServicesFactory->createSystemAuditService() + $this->auditServicesFactory->createSystemAuditService(), + $this->databaseSessionRepository ); } diff --git a/pages/admin/permissions/edit($id).php b/pages/admin/permissions/edit($id).php index 7cc86d3..8585349 100644 --- a/pages/admin/permissions/edit($id).php +++ b/pages/admin/permissions/edit($id).php @@ -68,11 +68,22 @@ if ($request->hasBody('key')) { $roleIds = [$roleIds]; } $roleIds = array_values(array_unique(array_map('intval', $roleIds))); - $rolePermissionRepository->replaceForPermission($id, $roleIds); - $affectedRoleIds = array_values(array_unique(array_merge($previousRoleIds, $roleIds))); - $affectedUserIds = $userRoleRepository->listUserIdsByRoleIds($affectedRoleIds); - if ($affectedUserIds) { - $userWriteRepository->bumpAuthzVersionByUserIds($affectedUserIds); + $dbSession = app(\MintyPHP\Repository\Support\DatabaseSessionRepository::class); + $dbSession->beginTransaction(); + try { + $rolePermissionRepository->replaceForPermission($id, $roleIds); + $affectedRoleIds = array_values(array_unique(array_merge($previousRoleIds, $roleIds))); + $affectedUserIds = $userRoleRepository->listUserIdsByRoleIds($affectedRoleIds); + if ($affectedUserIds) { + $userWriteRepository->bumpAuthzVersionByUserIds($affectedUserIds); + } + $dbSession->commitTransaction(); + } catch (\Throwable) { + try { + $dbSession->rollbackTransaction(); + } catch (\Throwable) { + } + $errorBag->merge([t('Failed to update permission roles')]); } $action = (string) $request->body('action', 'save'); if ($action === 'save_close') { diff --git a/pages/admin/roles/create().php b/pages/admin/roles/create().php index 42b1ac4..ed7f0f8 100644 --- a/pages/admin/roles/create().php +++ b/pages/admin/roles/create().php @@ -50,7 +50,17 @@ if ($request->hasBody('description')) { $permissionIds = array_values(array_unique(array_map('intval', $permissionIds))); $createdId = (int) ($result['id'] ?? 0); if ($createdId > 0) { - $rolePermissionRepository->replaceForRole($createdId, $permissionIds); + $dbSession = app(\MintyPHP\Repository\Support\DatabaseSessionRepository::class); + $dbSession->beginTransaction(); + try { + $rolePermissionRepository->replaceForRole($createdId, $permissionIds); + $dbSession->commitTransaction(); + } catch (\Throwable) { + try { + $dbSession->rollbackTransaction(); + } catch (\Throwable) { + } + } } $action = (string) $request->body('action', 'create'); if ($action === 'create_close') { diff --git a/pages/admin/roles/edit($id).php b/pages/admin/roles/edit($id).php index 221c936..8332337 100644 --- a/pages/admin/roles/edit($id).php +++ b/pages/admin/roles/edit($id).php @@ -86,10 +86,21 @@ if ($request->hasBody('description')) { $permissionIds = [$permissionIds]; } $permissionIds = array_values(array_unique(array_map('intval', $permissionIds))); - $rolePermissionRepository->replaceForRole($roleId, $permissionIds); - $affectedUserIds = $userRoleRepository->listUserIdsByRoleIds([$roleId]); - if ($affectedUserIds) { - $userWriteRepository->bumpAuthzVersionByUserIds($affectedUserIds); + $dbSession = app(\MintyPHP\Repository\Support\DatabaseSessionRepository::class); + $dbSession->beginTransaction(); + try { + $rolePermissionRepository->replaceForRole($roleId, $permissionIds); + $affectedUserIds = $userRoleRepository->listUserIdsByRoleIds([$roleId]); + if ($affectedUserIds) { + $userWriteRepository->bumpAuthzVersionByUserIds($affectedUserIds); + } + $dbSession->commitTransaction(); + } catch (\Throwable) { + try { + $dbSession->rollbackTransaction(); + } catch (\Throwable) { + } + $errorBag->merge([t('Failed to update role permissions')]); } $action = (string) $request->body('action', 'save'); if ($action === 'save_close') { diff --git a/pages/auth/login().php b/pages/auth/login().php index 7f6d545..914901d 100644 --- a/pages/auth/login().php +++ b/pages/auth/login().php @@ -312,6 +312,8 @@ if (requestInput()->method() === 'POST') { if ($remember) { $rememberMeService->rememberUser($userId); } + $sessionStore->set('session_started_at', time()); + $sessionStore->set('session_last_activity', time()); Router::redirect('admin'); return; } diff --git a/pages/auth/microsoft/callback().php b/pages/auth/microsoft/callback().php index 3053a69..dcf9435 100644 --- a/pages/auth/microsoft/callback().php +++ b/pages/auth/microsoft/callback().php @@ -1,5 +1,6 @@ set('session_started_at', time()); +$sessionStore->set('session_last_activity', time()); Router::redirect('admin'); diff --git a/tests/Service/Settings/SettingsSessionGatewayTest.php b/tests/Service/Settings/SettingsSessionGatewayTest.php new file mode 100644 index 0000000..3edc9f1 --- /dev/null +++ b/tests/Service/Settings/SettingsSessionGatewayTest.php @@ -0,0 +1,98 @@ +createGateway(null); + $this->assertSame(30, $gateway->getSessionIdleTimeoutMinutes()); + } + + public function testGetSessionAbsoluteTimeoutHoursReturnsFallbackWhenNotSet(): void + { + $gateway = $this->createGateway(null); + $this->assertSame(8, $gateway->getSessionAbsoluteTimeoutHours()); + } + + public function testGetSessionIdleTimeoutMinutesReturnsStoredValue(): void + { + $gateway = $this->createGateway('15', 'session_idle_timeout_minutes'); + $this->assertSame(15, $gateway->getSessionIdleTimeoutMinutes()); + } + + public function testGetSessionAbsoluteTimeoutHoursReturnsStoredValue(): void + { + $gateway = $this->createGateway('12', 'session_absolute_timeout_hours'); + $this->assertSame(12, $gateway->getSessionAbsoluteTimeoutHours()); + } + + public function testGetSessionIdleTimeoutMinutesReturnsFallbackForOutOfBoundsValue(): void + { + $gateway = $this->createGateway('2', 'session_idle_timeout_minutes'); + $this->assertSame(30, $gateway->getSessionIdleTimeoutMinutes()); + } + + public function testGetSessionAbsoluteTimeoutHoursReturnsFallbackForOutOfBoundsValue(): void + { + $gateway = $this->createGateway('100', 'session_absolute_timeout_hours'); + $this->assertSame(8, $gateway->getSessionAbsoluteTimeoutHours()); + } + + public function testSetSessionIdleTimeoutMinutesRejectsBelowMinimum(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->never())->method('set'); + $gateway = new SettingsSessionGateway(new SettingsMetadataGateway($settings)); + $this->assertFalse($gateway->setSessionIdleTimeoutMinutes(2)); + } + + public function testSetSessionIdleTimeoutMinutesRejectsAboveMaximum(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->never())->method('set'); + $gateway = new SettingsSessionGateway(new SettingsMetadataGateway($settings)); + $this->assertFalse($gateway->setSessionIdleTimeoutMinutes(2000)); + } + + public function testSetSessionAbsoluteTimeoutHoursRejectsZero(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->never())->method('set'); + $gateway = new SettingsSessionGateway(new SettingsMetadataGateway($settings)); + $this->assertFalse($gateway->setSessionAbsoluteTimeoutHours(0)); + } + + public function testGetSessionIdleTimeoutSecondsConvertsCorrectly(): void + { + $gateway = $this->createGateway('15', 'session_idle_timeout_minutes'); + $this->assertSame(900, $gateway->getSessionIdleTimeoutSeconds()); + } + + public function testGetSessionAbsoluteTimeoutSecondsConvertsCorrectly(): void + { + $gateway = $this->createGateway('4', 'session_absolute_timeout_hours'); + $this->assertSame(14400, $gateway->getSessionAbsoluteTimeoutSeconds()); + } + + private function createGateway(?string $returnValue, string $forKey = ''): SettingsSessionGateway + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturnCallback( + static function (string $key) use ($returnValue, $forKey): ?string { + if ($forKey !== '' && $key === $forKey) { + return $returnValue; + } + return $returnValue !== null && $forKey === '' ? $returnValue : null; + } + ); + + return new SettingsSessionGateway(new SettingsMetadataGateway($settings)); + } +} diff --git a/tests/Service/User/UserAccountServiceTest.php b/tests/Service/User/UserAccountServiceTest.php index 4bbe8c3..5700557 100644 --- a/tests/Service/User/UserAccountServiceTest.php +++ b/tests/Service/User/UserAccountServiceTest.php @@ -2,6 +2,7 @@ namespace MintyPHP\Tests\Service\User; +use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\User\UserListQueryRepositoryInterface; use MintyPHP\Repository\User\UserReadRepositoryInterface; use MintyPHP\Repository\User\UserWriteRepositoryInterface; @@ -418,7 +419,8 @@ class UserAccountServiceTest extends TestCase ?UserSettingsGateway $settingsGateway = null, ?UserScopeGateway $scopeGateway = null, ?UserDirectoryGateway $directoryGateway = null, - ?SystemAuditService $auditService = null + ?SystemAuditService $auditService = null, + ?DatabaseSessionRepository $databaseSessionRepository = null ): UserAccountService { $readRepository = $readRepository ?? $this->createMock(UserReadRepositoryInterface::class); $writeRepository = $writeRepository ?? $this->createMock(UserWriteRepositoryInterface::class); @@ -463,6 +465,7 @@ class UserAccountServiceTest extends TestCase } $auditService = $auditService ?? $this->createMock(SystemAuditService::class); + $databaseSessionRepository = $databaseSessionRepository ?? $this->createMock(DatabaseSessionRepository::class); return new UserAccountService( $readRepository, @@ -473,7 +476,8 @@ class UserAccountServiceTest extends TestCase $settingsGateway, $scopeGateway, $directoryGateway, - $auditService + $auditService, + $databaseSessionRepository ); } } diff --git a/web/index.php b/web/index.php index 2b7a727..3e5a693 100644 --- a/web/index.php +++ b/web/index.php @@ -55,6 +55,38 @@ if (empty($_SESSION['user']['id'])) { $rememberMeService->autoLoginFromCookie(); } +// Session timeout enforcement (idle + absolute) +if (!empty($_SESSION['user']['id'])) { + $now = time(); + $sessionStartedAt = (int) ($_SESSION['session_started_at'] ?? 0); + $sessionLastActivity = (int) ($_SESSION['session_last_activity'] ?? 0); + + // Gracefully handle pre-existing sessions that lack timestamps. + if ($sessionStartedAt === 0) { + $_SESSION['session_started_at'] = $now; + $sessionStartedAt = $now; + } + if ($sessionLastActivity === 0) { + $_SESSION['session_last_activity'] = $now; + $sessionLastActivity = $now; + } + + $sessionGateway = app(\MintyPHP\Service\Settings\SettingsSessionGateway::class); + $idleTimeoutSeconds = $sessionGateway->getSessionIdleTimeoutSeconds(); + $absoluteTimeoutSeconds = $sessionGateway->getSessionAbsoluteTimeoutSeconds(); + + $isIdle = ($now - $sessionLastActivity) > $idleTimeoutSeconds; + $isAbsolute = ($now - $sessionStartedAt) > $absoluteTimeoutSeconds; + + if ($isIdle || $isAbsolute) { + $authService->logout(); + Flash::error(t('Your session has expired. Please log in again.'), 'login', 'session_timeout'); + Router::redirect('login'); + } + + $_SESSION['session_last_activity'] = $now; +} + // Parse request URI and resolve locale $originalUri = $_SERVER['REQUEST_URI'] ?? ''; $_SERVER['MINTY_ORIGINAL_URI'] = $originalUri;