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:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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; ?> />
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
$authLogoUrl = appLogoUrl();
|
||||
$authLogoUrl = appAuthLogoUrl();
|
||||
if ($authLogoUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user