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 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 19:16:26 +01:00
parent 8d6cfa2fbc
commit 11ca546eae
17 changed files with 415 additions and 103 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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());

View File

@@ -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(),

View File

@@ -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()
{

View File

@@ -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());

View File

@@ -0,0 +1,79 @@
<?php
namespace MintyPHP\Service\Settings;
class SettingsSessionGateway
{
private const IDLE_TIMEOUT_MINUTES_FALLBACK = 30;
private const ABSOLUTE_TIMEOUT_HOURS_FALLBACK = 8;
private const IDLE_TIMEOUT_MINUTES_MIN = 5;
private const IDLE_TIMEOUT_MINUTES_MAX = 1440;
private const ABSOLUTE_TIMEOUT_HOURS_MIN = 1;
private const ABSOLUTE_TIMEOUT_HOURS_MAX = 72;
public function __construct(private readonly SettingsMetadataGateway $settingsMetadataGateway)
{
}
public function getSessionIdleTimeoutMinutes(): int
{
$value = $this->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;
}
}

View File

@@ -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));

View File

@@ -45,7 +45,8 @@ class UserServicesFactory
$this->createUserSettingsGateway(),
$this->createUserScopeGateway(),
$this->createUserDirectoryGateway(),
$this->auditServicesFactory->createSystemAuditService()
$this->auditServicesFactory->createSystemAuditService(),
$this->databaseSessionRepository
);
}

View File

@@ -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') {

View File

@@ -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') {

View File

@@ -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') {

View File

@@ -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;
}

View File

@@ -1,5 +1,6 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Support\Flash;
@@ -57,4 +58,7 @@ if (!($loginResult['ok'] ?? false)) {
return;
}
$sessionStore = app(SessionStoreInterface::class);
$sessionStore->set('session_started_at', time());
$sessionStore->set('session_last_activity', time());
Router::redirect('admin');

View File

@@ -0,0 +1,98 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use MintyPHP\Service\Settings\SettingsSessionGateway;
use PHPUnit\Framework\TestCase;
class SettingsSessionGatewayTest extends TestCase
{
public function testGetSessionIdleTimeoutMinutesReturnsFallbackWhenNotSet(): void
{
$gateway = $this->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));
}
}

View File

@@ -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
);
}
}

View File

@@ -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;