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.
* 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
{
$value = $this->retrieve($session);
$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;
}
/**
* 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.
*/

View File

@@ -450,6 +450,24 @@ function appLogoUrl(?int $size = null): string
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).
*/

View File

@@ -15,12 +15,21 @@ $requestRuntime = app(RequestRuntimeInterface::class);
$sessionStore = app(SessionStoreInterface::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'] ?? ''));
if ($returnTo === '') {
$returnTo = trim((string) (requestInput()->bodyAll()['return_to'] ?? ''));
}
if ($returnTo !== '') {
$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();
if (!empty($session['user']['id'])) {

View File

@@ -58,6 +58,7 @@ $authLogoHref = lurl('login');
if ($tenantHint !== '') {
$authLogoHref .= '?tenant=' . rawurlencode($tenantHint);
}
$returnToSanitized = (string) ($returnToSanitized ?? '');
?>
@@ -85,6 +86,7 @@ if ($tenantHint !== '') {
<form method="post" data-login-submit-lock="1">
<input type="hidden" name="stage" value="resolve_email">
<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">
<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; ?> />
@@ -99,6 +101,7 @@ if ($tenantHint !== '') {
<input type="hidden" name="stage" value="choose_tenant">
<input type="hidden" name="email" value="<?php e($email); ?>">
<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">
<legend class="login-tenant-select-label"><?php e(t('Select tenant')); ?></legend>
<div class="login-tenant-choice-grid">
@@ -139,6 +142,7 @@ if ($tenantHint !== '') {
</form>
<form method="post">
<input type="hidden" name="stage" value="reset">
<?php if ($returnToSanitized !== ''): ?><input type="hidden" name="return_to" value="<?php e($returnToSanitized); ?>"><?php endif; ?>
<p>
<button type="submit" class="secondary outline"><?php e(t('Use different email')); ?></button>
</p>
@@ -164,6 +168,7 @@ if ($tenantHint !== '') {
<input type="hidden" name="stage" value="resolve_email">
<input type="hidden" name="email" value="<?php e($email); ?>">
<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>
<?php Session::getCsrfInput(); ?>
</form>
@@ -190,6 +195,7 @@ if ($tenantHint !== '') {
<input type="hidden" name="email" value="<?php e($email); ?>">
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<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">
<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; ?> />

View File

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

View File

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

View File

@@ -186,7 +186,7 @@ class IntendedUrlServiceTest extends TestCase
// ── retrieveAndForget ─────────────────────────────────────
public function testRetrieveAndForgetReturnsStoredUrlAndRemovesIt(): void
public function testRetrieveAndForgetReturnsRoutePathAndRemovesIt(): void
{
$this->session->expects($this->any())
->method('get')
@@ -197,7 +197,35 @@ class IntendedUrlServiceTest extends TestCase
->method('remove')
->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

View File

@@ -53,7 +53,20 @@ if ($defaultLocale !== null) {
// Auto-login from remember-me cookie
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)

View File

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

View File

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