feat(auth): keep remember tokens on session timeout

- add timeout-specific logout path without remember-token revocation

- reset session timers after remember-me auto login

- compact session policy wording in admin settings (DE/EN)

- extend auth tests for timeout/manual logout behavior
This commit is contained in:
2026-03-09 20:47:21 +01:00
parent 792af5a532
commit af8ee10930
8 changed files with 106 additions and 11 deletions

View File

@@ -1018,10 +1018,10 @@
"Counters": "Zähler",
"User lifecycle policy": "Benutzer-Lifecycle-Regel",
"Session policy": "Sitzungsrichtlinie",
"Session limits define how long logged-in users stay signed in.": "Sitzungslimits bestimmen, wie lange angemeldete Benutzer eingeloggt bleiben.",
"Idle timeout signs users out after no activity.": "Das Inaktivitäts-Timeout meldet Benutzer bei fehlender Aktivität ab.",
"Session limits define how long logged-in users stay signed in.": "Hier legen Sie Inaktivitäts- und Absolute-Timeout für aktive Sitzungen fest.",
"Idle timeout signs users out after no activity.": "Inaktivitäts-Timeout beendet inaktive Sitzungen, absolutes Timeout beendet lange Sitzungen auch bei Aktivität.",
"Absolute timeout signs users out after total session duration, even with activity.": "Das absolute Timeout meldet Benutzer nach der gesamten Sitzungsdauer ab, auch bei Aktivität.",
"New limits apply on each user's next request.": "Neue Limits gelten mit der nächsten Anfrage jedes Benutzers.",
"New limits apply on each user's next request.": "Änderungen gelten ab der nächsten Anfrage je Benutzer. Angemeldet-bleiben-Tokens bleiben aktiv.",
"Session idle timeout (minutes)": "Sitzungs-Inaktivitäts-Timeout (Minuten)",
"Session absolute timeout (hours)": "Absolutes Sitzungs-Timeout (Stunden)",
"Allowed range: 5-1440 minutes (default: 30)": "Erlaubter Bereich: 5-1440 Minuten (Standard: 30)",
@@ -1218,7 +1218,7 @@
"Top lifecycle reason codes (7d)": "Top-Lifecycle-Fehlercodes (7d)",
"Recent lifecycle risks (7d)": "Aktuelle Lifecycle-Risiken (7d)",
"Login progress": "Anmeldefortschritt",
"Your session has expired. Please log in again.": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"Your session has expired. Please log in again.": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an. Mit „Angemeldet bleiben“ erfolgt die Anmeldung ggf. automatisch.",
"Failed to update role permissions": "Rollenberechtigungen konnten nicht aktualisiert werden",
"Failed to update permission roles": "Berechtigungsrollen konnten nicht aktualisiert werden"
}

View File

@@ -1018,10 +1018,10 @@
"Counters": "Counters",
"User lifecycle policy": "User lifecycle policy",
"Session policy": "Session policy",
"Session limits define how long logged-in users stay signed in.": "Session limits define how long logged-in users stay signed in.",
"Idle timeout signs users out after no activity.": "Idle timeout signs users out after no activity.",
"Session limits define how long logged-in users stay signed in.": "Set idle and absolute limits for active sessions.",
"Idle timeout signs users out after no activity.": "Idle timeout ends inactive sessions; absolute timeout ends long sessions even with activity.",
"Absolute timeout signs users out after total session duration, even with activity.": "Absolute timeout signs users out after total session duration, even with activity.",
"New limits apply on each user's next request.": "New limits apply on each user's next request.",
"New limits apply on each user's next request.": "Changes apply from each user's next request. Remember-me tokens stay active.",
"Session idle timeout (minutes)": "Session idle timeout (minutes)",
"Session absolute timeout (hours)": "Session absolute timeout (hours)",
"Allowed range: 5-1440 minutes (default: 30)": "Allowed range: 5-1440 minutes (default: 30)",
@@ -1218,7 +1218,7 @@
"Top lifecycle reason codes (7d)": "Top lifecycle reason codes (7d)",
"Recent lifecycle risks (7d)": "Recent lifecycle risks (7d)",
"Login progress": "Login progress",
"Your session has expired. Please log in again.": "Your session has expired. Please log in again.",
"Your session has expired. Please log in again.": "Your session timed out. Please sign in again. If you chose Remember me, sign-in can happen automatically.",
"Failed to update role permissions": "Failed to update role permissions",
"Failed to update permission roles": "Failed to update permission roles"
}

View File

@@ -268,6 +268,19 @@ class AuthService
]);
}
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');
Auth::logout();
$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();

View File

@@ -92,6 +92,9 @@ class RememberMeService
Session::regenerate();
$this->sessionStore->set('user', $user);
$now = time();
$this->sessionStore->set('session_started_at', $now);
$this->sessionStore->set('session_last_activity', $now);
if (!empty($user['locale'])) {
I18n::$locale = (string) $user['locale'];
}

View File

@@ -334,7 +334,6 @@ $disabledAttr = $isReadOnly ? 'disabled' : '';
<?php e(t('Session limits define how long logged-in users stay signed in.')); ?>
</blockquote>
<small class="muted"><?php e(t('Idle timeout signs users out after no activity.')); ?></small>
<small class="muted"><?php e(t('Absolute timeout signs users out after total session duration, even with activity.')); ?></small>
<small class="muted"><?php e(t('New limits apply on each user\'s next request.')); ?></small>
<div class="grid">
<label class="app-field">

View File

@@ -349,6 +349,80 @@ class AuthServiceTest extends TestCase
$this->assertSame('login_not_verified', $result['flash_key']);
}
// ---------------------------------------------------------------
// logout behavior
// ---------------------------------------------------------------
public function testLogoutDueToSessionTimeoutKeepsRememberTokenAndClearsSessionTimers(): void
{
$_SESSION = [];
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$removedKeys = [];
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], ['id' => 5]],
['current_tenant', [], ['id' => 9]],
]);
$session->method('remove')->willReturnCallback(function (string $key) use (&$removedKeys): void {
$removedKeys[] = $key;
});
$rememberMeService = $this->createMock(RememberMeService::class);
$rememberMeService->expects($this->never())->method('forgetCurrentUser');
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())->method('record')
->with('auth.session.timeout', 'success', $this->callback(function (array $context): bool {
return (int) ($context['actor_user_id'] ?? 0) === 5
&& (int) ($context['actor_tenant_id'] ?? 0) === 9;
}));
$service = $this->newService(
rememberMeService: $rememberMeService,
systemAuditService: $audit,
sessionStore: $session
);
$service->logoutDueToSessionTimeout();
sort($removedKeys);
$this->assertSame(['session_last_activity', 'session_started_at'], $removedKeys);
}
public function testLogoutStillForgetsRememberTokenOnManualLogout(): void
{
$_SESSION = [];
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$rememberMeService = $this->createMock(RememberMeService::class);
$rememberMeService->expects($this->once())->method('forgetCurrentUser');
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], ['id' => 5]],
['current_tenant', [], ['id' => 11]],
]);
$audit = $this->createMock(SystemAuditService::class);
$audit->expects($this->once())->method('record')
->with('auth.logout', 'success', $this->callback(function (array $context): bool {
return (int) ($context['actor_user_id'] ?? 0) === 5
&& (int) ($context['actor_tenant_id'] ?? 0) === 11;
}));
$service = $this->newService(
rememberMeService: $rememberMeService,
systemAuditService: $audit,
sessionStore: $session
);
$service->logout();
}
// ---------------------------------------------------------------
// Helper
// ---------------------------------------------------------------

View File

@@ -273,11 +273,14 @@ class RememberMeServiceTest extends TestCase
$_SESSION = [];
$session = $this->createMock(SessionStoreInterface::class);
$setKeys = [];
$session->method('get')->willReturnMap([
['user', [], []],
['no_active_tenant', false, false],
]);
$session->expects($this->once())->method('set');
$session->method('set')->willReturnCallback(function (string $key, mixed $value) use (&$setKeys): void {
$setKeys[] = $key;
});
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('selector:token');
@@ -318,6 +321,9 @@ class RememberMeServiceTest extends TestCase
authSessionTenantContextService: $tenantCtx
);
$this->assertTrue($service->autoLoginFromCookie());
$this->assertContains('user', $setKeys);
$this->assertContains('session_started_at', $setKeys);
$this->assertContains('session_last_activity', $setKeys);
}
// ---------------------------------------------------------------

View File

@@ -79,7 +79,7 @@ if (!empty($_SESSION['user']['id'])) {
$isAbsolute = ($now - $sessionStartedAt) > $absoluteTimeoutSeconds;
if ($isIdle || $isAbsolute) {
$authService->logout();
$authService->logoutDueToSessionTimeout();
Flash::error(t('Your session has expired. Please log in again.'), 'login', 'session_timeout');
Router::redirect('login');
}