fix(session): preserve intended URL across session timeout with remember-me

Session timeout redirected to /de/login?return_to=... but the remember-me
cookie was not cleared by logoutDueToSessionTimeout(). On the next request,
autoLoginFromCookie() silently re-logged the user in, causing the MintyPHP
router to resolve the login route to pages/index().php (dashboard) instead
of the login action — the intended URL redirect logic never executed.

Fix: intercept ?return_to= immediately after successful auto-login in
index.php and redirect to the sanitized route path. Also harden the login
flow with hidden return_to form fields and POST body fallback for cases
without remember-me.

Additional improvements in this commit:
- Convert stored REQUEST_URI to router-compatible path (strip leading
  slash and locale prefix) via IntendedUrlService::toRoutePath()
- Consolidate duplicate CSRF data attributes into shared data-csrf-key
  and data-csrf-token on <html> element
- Add tenant branding (logo) to auth pages via appAuthLogoUrl() helper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 18:28:13 +01:00
parent add5cca222
commit e1c211069e
10 changed files with 115 additions and 14 deletions

View File

@@ -47,12 +47,22 @@ class IntendedUrlService
/** /**
* Retrieve the intended URL and remove it from the session. * Retrieve the intended URL and remove it from the session.
* Returns the fallback ('admin') if no valid URL is stored. * Returns the fallback ('admin') if no valid URL is stored.
*
* The stored URL is a full REQUEST_URI (e.g. /de/admin/users?page=2)
* but Router::redirect() expects a route path without leading slash
* and without locale prefix (e.g. admin/users?page=2), because it
* prepends getBaseUrl() which already includes the base path.
*/ */
public function retrieveAndForget(SessionStoreInterface $session): string public function retrieveAndForget(SessionStoreInterface $session): string
{ {
$value = $this->retrieve($session); $value = $this->retrieve($session);
$session->remove(self::SESSION_KEY); $session->remove(self::SESSION_KEY);
return $value ?? self::FALLBACK;
if ($value === null) {
return self::FALLBACK;
}
return $this->toRoutePath($value);
} }
/** /**
@@ -103,6 +113,25 @@ class IntendedUrlService
return $url; return $url;
} }
/**
* Convert a full REQUEST_URI into a Router::redirect()-compatible route path.
*
* /de/admin/users?page=2 → admin/users?page=2
* /admin/users → admin/users
*/
public function toRoutePath(string $url): string
{
// Remove leading slash
$path = ltrim($url, '/');
// Remove locale prefix (2-letter code followed by /)
if (preg_match('#^[a-z]{2}/#', $path)) {
$path = substr($path, 3);
}
return $path !== '' ? $path : self::FALLBACK;
}
/** /**
* Extract the path portion without query string and without locale prefix. * Extract the path portion without query string and without locale prefix.
*/ */

View File

@@ -450,6 +450,24 @@ function appLogoUrl(?int $size = null): string
return asset('brand/logo.svg'); return asset('brand/logo.svg');
} }
/**
* Resolve logo for auth pages: tenant avatar → global logo → default SVG.
*
* After logout the tenant context (`$_SESSION['current_tenant']`) is preserved,
* so the login page can show the tenant avatar instead of the generic app logo.
*/
function appAuthLogoUrl(?int $size = null): string
{
$tenantUuid = $_SESSION['current_tenant']['uuid'] ?? '';
if ($tenantUuid !== '' && class_exists('MintyPHP\\Service\\Tenant\\TenantAvatarService')) {
if (app(\MintyPHP\Service\Tenant\TenantAvatarService::class)->hasAvatar($tenantUuid)) {
$query = $size ? '&size=' . (int) $size : '';
return lurl('auth/tenant-avatar-file?uuid=' . rawurlencode($tenantUuid) . $query);
}
}
return appLogoUrl($size);
}
/** /**
* Resolve favicon path (tenant favicon with global fallback). * Resolve favicon path (tenant favicon with global fallback).
*/ */

View File

@@ -15,12 +15,21 @@ $requestRuntime = app(RequestRuntimeInterface::class);
$sessionStore = app(SessionStoreInterface::class); $sessionStore = app(SessionStoreInterface::class);
$intendedUrlService = app(IntendedUrlService::class); $intendedUrlService = app(IntendedUrlService::class);
// Store return_to from query parameter (set by session timeout or auth guard redirect). // Store return_to from query parameter (set by session timeout or auth guard redirect)
// or from hidden form field (preserved through multi-stage login POST flow).
$returnTo = trim((string) (requestInput()->queryAll()['return_to'] ?? '')); $returnTo = trim((string) (requestInput()->queryAll()['return_to'] ?? ''));
if ($returnTo === '') {
$returnTo = trim((string) (requestInput()->bodyAll()['return_to'] ?? ''));
}
if ($returnTo !== '') { if ($returnTo !== '') {
$intendedUrlService->store($returnTo, $sessionStore); $intendedUrlService->store($returnTo, $sessionStore);
} }
// Expose sanitized return_to for hidden form fields so it survives across
// POST stages even if the query string or session is lost.
$returnToSanitized = $intendedUrlService->sanitize($returnTo !== '' ? $returnTo : ($intendedUrlService->retrieve($sessionStore) ?? ''));
$session = $sessionStore->all(); $session = $sessionStore->all();
if (!empty($session['user']['id'])) { if (!empty($session['user']['id'])) {

View File

@@ -58,6 +58,7 @@ $authLogoHref = lurl('login');
if ($tenantHint !== '') { if ($tenantHint !== '') {
$authLogoHref .= '?tenant=' . rawurlencode($tenantHint); $authLogoHref .= '?tenant=' . rawurlencode($tenantHint);
} }
$returnToSanitized = (string) ($returnToSanitized ?? '');
?> ?>
@@ -85,6 +86,7 @@ if ($tenantHint !== '') {
<form method="post" data-login-submit-lock="1"> <form method="post" data-login-submit-lock="1">
<input type="hidden" name="stage" value="resolve_email"> <input type="hidden" name="stage" value="resolve_email">
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>"> <input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<label for="email"> <label for="email">
<span><?php e(t('Email')); ?></span> <span><?php e(t('Email')); ?></span>
<input required type="email" name="email" id="email" value="<?php e($email); ?>" autocomplete="email" autofocus <?php if ($errors): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> /> <input required type="email" name="email" id="email" value="<?php e($email); ?>" autocomplete="email" autofocus <?php if ($errors): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
@@ -99,6 +101,7 @@ if ($tenantHint !== '') {
<input type="hidden" name="stage" value="choose_tenant"> <input type="hidden" name="stage" value="choose_tenant">
<input type="hidden" name="email" value="<?php e($email); ?>"> <input type="hidden" name="email" value="<?php e($email); ?>">
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>"> <input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<fieldset class="login-tenant-fieldset"> <fieldset class="login-tenant-fieldset">
<legend class="login-tenant-select-label"><?php e(t('Select tenant')); ?></legend> <legend class="login-tenant-select-label"><?php e(t('Select tenant')); ?></legend>
<div class="login-tenant-choice-grid"> <div class="login-tenant-choice-grid">
@@ -139,6 +142,7 @@ if ($tenantHint !== '') {
</form> </form>
<form method="post"> <form method="post">
<input type="hidden" name="stage" value="reset"> <input type="hidden" name="stage" value="reset">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<p> <p>
<button type="submit" class="secondary outline"><?php e(t('Use different email')); ?></button> <button type="submit" class="secondary outline"><?php e(t('Use different email')); ?></button>
</p> </p>
@@ -164,6 +168,7 @@ if ($tenantHint !== '') {
<input type="hidden" name="stage" value="resolve_email"> <input type="hidden" name="stage" value="resolve_email">
<input type="hidden" name="email" value="<?php e($email); ?>"> <input type="hidden" name="email" value="<?php e($email); ?>">
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>"> <input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<button type="submit" class="secondary outline small"><?php e(t('Switch tenant')); ?></button> <button type="submit" class="secondary outline small"><?php e(t('Switch tenant')); ?></button>
<?php Session::getCsrfInput(); ?> <?php Session::getCsrfInput(); ?>
</form> </form>
@@ -190,6 +195,7 @@ if ($tenantHint !== '') {
<input type="hidden" name="email" value="<?php e($email); ?>"> <input type="hidden" name="email" value="<?php e($email); ?>">
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>"> <input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<input type="hidden" name="tenant_id" value="<?php e((string) $selectedTenantId); ?>"> <input type="hidden" name="tenant_id" value="<?php e((string) $selectedTenantId); ?>">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<label for="password"> <label for="password">
<span><?php e(t('Password')); ?></span> <span><?php e(t('Password')); ?></span>
<input required type="password" name="password" id="password" autocomplete="current-password" <?php if ($errors): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> /> <input required type="password" name="password" id="password" autocomplete="current-password" <?php if ($errors): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />

View File

@@ -39,14 +39,12 @@ if ($bufferTitle !== '') {
data-telemetry-enabled="<?php e($telemetryEnabled ? '1' : '0'); ?>" data-telemetry-enabled="<?php e($telemetryEnabled ? '1' : '0'); ?>"
data-telemetry-sample-rate="<?php e((string) $telemetrySampleRate); ?>" data-telemetry-sample-rate="<?php e((string) $telemetrySampleRate); ?>"
data-telemetry-allowed-events="<?php e($telemetryAllowedEvents); ?>" data-telemetry-allowed-events="<?php e($telemetryAllowedEvents); ?>"
data-telemetry-csrf-key="<?php e($csrfKey); ?>" data-csrf-key="<?php e($csrfKey); ?>"
data-telemetry-csrf-token="<?php e($csrfToken); ?>" data-csrf-token="<?php e($csrfToken); ?>"
<?php if ($isLoggedIn): ?> <?php if ($isLoggedIn): ?>
data-session-idle-seconds="<?php e((string) $sessionIdleSeconds); ?>" data-session-idle-seconds="<?php e((string) $sessionIdleSeconds); ?>"
data-session-ping-url="<?php e(lurl('admin/session-ping/data')); ?>" data-session-ping-url="<?php e(lurl('admin/session-ping/data')); ?>"
data-session-logout-url="<?php e(lurl('logout')); ?>" data-session-logout-url="<?php e(lurl('logout')); ?>"
data-session-csrf-key="<?php e($csrfKey); ?>"
data-session-csrf-token="<?php e($csrfToken); ?>"
<?php endif; ?> <?php endif; ?>
class="no-js" class="no-js"
<?php if ($primaryVars !== ''): ?>style="<?php e($primaryVars); ?>" <?php endif; ?> <?php if ($primaryVars !== ''): ?>style="<?php e($primaryVars); ?>" <?php endif; ?>

View File

@@ -1,6 +1,6 @@
<?php <?php
$authLogoUrl = appLogoUrl(); $authLogoUrl = appAuthLogoUrl();
if ($authLogoUrl === '') { if ($authLogoUrl === '') {
return; return;
} }

View File

@@ -186,7 +186,7 @@ class IntendedUrlServiceTest extends TestCase
// ── retrieveAndForget ───────────────────────────────────── // ── retrieveAndForget ─────────────────────────────────────
public function testRetrieveAndForgetReturnsStoredUrlAndRemovesIt(): void public function testRetrieveAndForgetReturnsRoutePathAndRemovesIt(): void
{ {
$this->session->expects($this->any()) $this->session->expects($this->any())
->method('get') ->method('get')
@@ -197,7 +197,35 @@ class IntendedUrlServiceTest extends TestCase
->method('remove') ->method('remove')
->with('intended_url'); ->with('intended_url');
$this->assertSame('/admin/users', $this->service->retrieveAndForget($this->session)); $this->assertSame('admin/users', $this->service->retrieveAndForget($this->session));
}
public function testRetrieveAndForgetStripsLocalePrefix(): void
{
$this->session->expects($this->any())
->method('get')
->with('intended_url')
->willReturn('/de/admin/users/edit/5');
$this->session->expects($this->once())
->method('remove')
->with('intended_url');
$this->assertSame('admin/users/edit/5', $this->service->retrieveAndForget($this->session));
}
public function testRetrieveAndForgetPreservesQueryString(): void
{
$this->session->expects($this->any())
->method('get')
->with('intended_url')
->willReturn('/de/admin/users?page=2&sort=name');
$this->session->expects($this->once())
->method('remove')
->with('intended_url');
$this->assertSame('admin/users?page=2&sort=name', $this->service->retrieveAndForget($this->session));
} }
public function testRetrieveAndForgetReturnsFallbackWhenNoUrlStored(): void public function testRetrieveAndForgetReturnsFallbackWhenNoUrlStored(): void

View File

@@ -53,7 +53,20 @@ if ($defaultLocale !== null) {
// Auto-login from remember-me cookie // Auto-login from remember-me cookie
if (empty($_SESSION['user']['id'])) { if (empty($_SESSION['user']['id'])) {
$rememberMeService->autoLoginFromCookie(); $autoLoginResult = $rememberMeService->autoLoginFromCookie();
if ($autoLoginResult) {
// After auto-login (e.g. session timeout with active remember-me cookie),
// honour ?return_to= so the user lands on the page they were on.
$returnToParam = trim((string) ($_GET['return_to'] ?? ''));
if ($returnToParam !== '') {
$intendedUrlService = app(IntendedUrlService::class);
$sanitized = $intendedUrlService->sanitize($returnToParam);
if ($sanitized !== '') {
$routePath = $intendedUrlService->toRoutePath($sanitized);
Router::redirect($routePath);
}
}
}
} }
// Session timeout enforcement (idle + absolute) // Session timeout enforcement (idle + absolute)

View File

@@ -29,8 +29,8 @@ const readConfig = () => {
const pingUrl = root.dataset.sessionPingUrl || ''; const pingUrl = root.dataset.sessionPingUrl || '';
const logoutUrl = root.dataset.sessionLogoutUrl || ''; const logoutUrl = root.dataset.sessionLogoutUrl || '';
const csrfKey = root.dataset.sessionCsrfKey || ''; const csrfKey = root.dataset.csrfKey || '';
const csrfToken = root.dataset.sessionCsrfToken || ''; const csrfToken = root.dataset.csrfToken || '';
if (!pingUrl || !logoutUrl || !csrfKey || !csrfToken) { if (!pingUrl || !logoutUrl || !csrfKey || !csrfToken) {
return null; return null;

View File

@@ -244,8 +244,8 @@ const resolveConfig = () => {
return { return {
enabled, enabled,
endpoint: normalizeWhitespace(root.dataset.telemetryUrl || ''), endpoint: normalizeWhitespace(root.dataset.telemetryUrl || ''),
csrfKey: normalizeWhitespace(root.dataset.telemetryCsrfKey || ''), csrfKey: normalizeWhitespace(root.dataset.csrfKey || ''),
csrfToken: normalizeWhitespace(root.dataset.telemetryCsrfToken || ''), csrfToken: normalizeWhitespace(root.dataset.csrfToken || ''),
sampleRate: clampRate(root.dataset.telemetrySampleRate || DEFAULT_SAMPLE_RATE, DEFAULT_SAMPLE_RATE), sampleRate: clampRate(root.dataset.telemetrySampleRate || DEFAULT_SAMPLE_RATE, DEFAULT_SAMPLE_RATE),
maxEventsPerMinute: clampPositiveInt( maxEventsPerMinute: clampPositiveInt(
root.dataset.telemetryMaxEventsPerMinute || DEFAULT_MAX_EVENTS_PER_MINUTE, root.dataset.telemetryMaxEventsPerMinute || DEFAULT_MAX_EVENTS_PER_MINUTE,