harden(logout): enforce POST+CSRF and remove GET logout triggers
This commit is contained in:
@@ -13,6 +13,6 @@ $first_name = $user['first_name'] ?? '';
|
|||||||
Buffer::set('title', t('Admin'));
|
Buffer::set('title', t('Admin'));
|
||||||
|
|
||||||
$breadcrumbs = [
|
$breadcrumbs = [
|
||||||
['label' => t('Login'), 'path' => 'logout'],
|
['label' => t('Login'), 'path' => 'login'],
|
||||||
['label' => t('Home')],
|
['label' => t('Home')],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
if (!actionRequirePost('login')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actionRequireCsrf('login', 'login', 'logout_csrf')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
app(\MintyPHP\Service\Auth\AuthService::class)->logoutAndRedirect('login');
|
app(\MintyPHP\Service\Auth\AuthService::class)->logoutAndRedirect('login');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ $sessionIdleMinutes = (int) (appSetting('session_idle_timeout_minutes') ?? 30);
|
|||||||
$sessionIdleSeconds = max($sessionIdleMinutes, 5) * 60;
|
$sessionIdleSeconds = max($sessionIdleMinutes, 5) * 60;
|
||||||
$sessionPingUrl = lurl('admin/session-ping/data');
|
$sessionPingUrl = lurl('admin/session-ping/data');
|
||||||
$sessionLogoutUrl = lurl('logout');
|
$sessionLogoutUrl = lurl('logout');
|
||||||
|
$sessionLogoutFormId = 'app-logout-form';
|
||||||
$telemetryEnabled = frontendTelemetryEnabled();
|
$telemetryEnabled = frontendTelemetryEnabled();
|
||||||
$telemetrySampleRate = frontendTelemetrySampleRate();
|
$telemetrySampleRate = frontendTelemetrySampleRate();
|
||||||
$telemetryAllowedEvents = implode(',', frontendTelemetryAllowedEvents());
|
$telemetryAllowedEvents = implode(',', frontendTelemetryAllowedEvents());
|
||||||
@@ -224,7 +225,7 @@ $componentPageConfig = [
|
|||||||
'selector' => '[data-app-session-warning-dialog]',
|
'selector' => '[data-app-session-warning-dialog]',
|
||||||
'idleSeconds' => $sessionIdleSeconds,
|
'idleSeconds' => $sessionIdleSeconds,
|
||||||
'pingUrl' => $sessionPingUrl,
|
'pingUrl' => $sessionPingUrl,
|
||||||
'logoutUrl' => $sessionLogoutUrl,
|
'logoutFormId' => $sessionLogoutFormId,
|
||||||
'csrfKey' => $csrfKey,
|
'csrfKey' => $csrfKey,
|
||||||
'csrfToken' => $csrfToken,
|
'csrfToken' => $csrfToken,
|
||||||
],
|
],
|
||||||
@@ -296,7 +297,7 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents;
|
|||||||
<?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($sessionPingUrl); ?>"
|
data-session-ping-url="<?php e($sessionPingUrl); ?>"
|
||||||
data-session-logout-url="<?php e($sessionLogoutUrl); ?>"
|
data-session-logout-form-id="<?php e($sessionLogoutFormId); ?>"
|
||||||
<?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; ?>
|
||||||
@@ -334,6 +335,11 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-drawer-backdrop" aria-hidden="true"></div>
|
<div class="app-drawer-backdrop" aria-hidden="true"></div>
|
||||||
|
<?php if ($isLoggedIn): ?>
|
||||||
|
<form id="<?php e($sessionLogoutFormId); ?>" method="post" action="<?php e($sessionLogoutUrl); ?>" hidden>
|
||||||
|
<input type="hidden" name="<?php e($csrfKey); ?>" value="<?php e($csrfToken); ?>">
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<script src="<?php e(assetVersion('vendor/fslightbox/fslightbox.js')); ?>"></script>
|
<script src="<?php e(assetVersion('vendor/fslightbox/fslightbox.js')); ?>"></script>
|
||||||
<?php require __DIR__ . '/partials/app-confirm-dialog.phtml'; ?>
|
<?php require __DIR__ . '/partials/app-confirm-dialog.phtml'; ?>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ if ($tenantLabel === '') {
|
|||||||
}
|
}
|
||||||
$canSwitchTenant = is_array($currentTenant) && count($availableTenants) > 1;
|
$canSwitchTenant = is_array($currentTenant) && count($availableTenants) > 1;
|
||||||
$allowUserTheme = allowUserTheme();
|
$allowUserTheme = allowUserTheme();
|
||||||
|
$logoutFormId = trim((string) ($sessionLogoutFormId ?? 'app-logout-form'));
|
||||||
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
||||||
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
||||||
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
||||||
@@ -196,9 +197,10 @@ $moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $modu
|
|||||||
|
|
||||||
<li class="app-topbar-menu-divider" role="separator"></li>
|
<li class="app-topbar-menu-divider" role="separator"></li>
|
||||||
<li class="app-topbar-menu-heading" role="presentation"><small><?php e(t('Session')); ?></small></li>
|
<li class="app-topbar-menu-heading" role="presentation"><small><?php e(t('Session')); ?></small></li>
|
||||||
<li><a href="logout">
|
<li>
|
||||||
|
<button type="submit" form="<?php e($logoutFormId); ?>">
|
||||||
<?php e(t('Logout')); ?>
|
<?php e(t('Logout')); ?>
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
39
tests/Architecture/AuthLogoutCsrfContractTest.php
Normal file
39
tests/Architecture/AuthLogoutCsrfContractTest.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Architecture;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class AuthLogoutCsrfContractTest extends TestCase
|
||||||
|
{
|
||||||
|
use ProjectFileAssertionSupport;
|
||||||
|
|
||||||
|
public function testLogoutActionRequiresPostAndCsrf(): void
|
||||||
|
{
|
||||||
|
$content = $this->readProjectFile('pages/auth/logout().php');
|
||||||
|
|
||||||
|
$this->assertStringContainsString("actionRequirePost('login')", $content);
|
||||||
|
$this->assertStringContainsString("actionRequireCsrf('login', 'login', 'logout_csrf')", $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTopbarLogoutUsesPostFormSubmitInsteadOfGetLink(): void
|
||||||
|
{
|
||||||
|
$content = $this->readProjectFile('templates/partials/app-topbar.phtml');
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('href="logout"', $content);
|
||||||
|
$this->assertStringContainsString('type="submit"', $content);
|
||||||
|
$this->assertStringContainsString('form="<?php e($logoutFormId); ?>"', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLayoutAndSessionWarningUseLogoutFormContract(): void
|
||||||
|
{
|
||||||
|
$layoutContent = $this->readProjectFile('templates/default.phtml');
|
||||||
|
$sessionWarningJs = $this->readProjectFile('web/js/components/app-session-warning.js');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('\'logoutFormId\' => $sessionLogoutFormId', $layoutContent);
|
||||||
|
$this->assertStringContainsString('data-session-logout-form-id="<?php e($sessionLogoutFormId); ?>"', $layoutContent);
|
||||||
|
$this->assertStringContainsString('<form id="<?php e($sessionLogoutFormId); ?>" method="post" action="<?php e($sessionLogoutUrl); ?>" hidden>', $layoutContent);
|
||||||
|
$this->assertStringContainsString('requestSubmitWithFallback(form)', $sessionWarningJs);
|
||||||
|
$this->assertStringNotContainsString('window.location.href = config.logoutUrl', $sessionWarningJs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,20 @@ const toPositiveInt = (value, fallback = 0) => {
|
|||||||
|
|
||||||
const toTrimmed = (value) => String(value ?? '').trim();
|
const toTrimmed = (value) => String(value ?? '').trim();
|
||||||
|
|
||||||
|
const requestSubmitWithFallback = (form) => {
|
||||||
|
if (!(form instanceof HTMLFormElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof form.requestSubmit === 'function') {
|
||||||
|
form.requestSubmit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.submit();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const resolveConfig = (runtimeConfig = {}) => {
|
const resolveConfig = (runtimeConfig = {}) => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const config = runtimeConfig && typeof runtimeConfig === 'object' && !Array.isArray(runtimeConfig)
|
const config = runtimeConfig && typeof runtimeConfig === 'object' && !Array.isArray(runtimeConfig)
|
||||||
@@ -34,7 +48,7 @@ const resolveConfig = (runtimeConfig = {}) => {
|
|||||||
|
|
||||||
const idleSeconds = toPositiveInt(config.idleSeconds ?? root.dataset.sessionIdleSeconds, 0);
|
const idleSeconds = toPositiveInt(config.idleSeconds ?? root.dataset.sessionIdleSeconds, 0);
|
||||||
const pingUrl = toTrimmed(config.pingUrl ?? root.dataset.sessionPingUrl);
|
const pingUrl = toTrimmed(config.pingUrl ?? root.dataset.sessionPingUrl);
|
||||||
const logoutUrl = toTrimmed(config.logoutUrl ?? root.dataset.sessionLogoutUrl);
|
const logoutFormId = toTrimmed(config.logoutFormId ?? root.dataset.sessionLogoutFormId);
|
||||||
const csrfKey = toTrimmed(config.csrfKey ?? root.dataset.csrfKey);
|
const csrfKey = toTrimmed(config.csrfKey ?? root.dataset.csrfKey);
|
||||||
const csrfToken = toTrimmed(config.csrfToken ?? root.dataset.csrfToken);
|
const csrfToken = toTrimmed(config.csrfToken ?? root.dataset.csrfToken);
|
||||||
const selector = toTrimmed(config.selector || '[data-app-session-warning-dialog]') || '[data-app-session-warning-dialog]';
|
const selector = toTrimmed(config.selector || '[data-app-session-warning-dialog]') || '[data-app-session-warning-dialog]';
|
||||||
@@ -42,7 +56,7 @@ const resolveConfig = (runtimeConfig = {}) => {
|
|||||||
if (idleSeconds < MIN_IDLE_TIMEOUT) {
|
if (idleSeconds < MIN_IDLE_TIMEOUT) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!pingUrl || !logoutUrl || !csrfKey || !csrfToken) {
|
if (!pingUrl || !logoutFormId || !csrfKey || !csrfToken) {
|
||||||
warnOnce('UI_CONFIG_INVALID', 'Session warning config is incomplete', {
|
warnOnce('UI_CONFIG_INVALID', 'Session warning config is incomplete', {
|
||||||
module: 'session-warning',
|
module: 'session-warning',
|
||||||
});
|
});
|
||||||
@@ -52,7 +66,7 @@ const resolveConfig = (runtimeConfig = {}) => {
|
|||||||
return {
|
return {
|
||||||
idleSeconds,
|
idleSeconds,
|
||||||
pingUrl,
|
pingUrl,
|
||||||
logoutUrl,
|
logoutFormId,
|
||||||
csrfKey,
|
csrfKey,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
selector,
|
selector,
|
||||||
@@ -230,7 +244,12 @@ export function initSessionWarning(root = document, runtimeConfig = {}) {
|
|||||||
|
|
||||||
const doLogout = () => {
|
const doLogout = () => {
|
||||||
closeDialog();
|
closeDialog();
|
||||||
window.location.href = config.logoutUrl;
|
const form = document.getElementById(config.logoutFormId);
|
||||||
|
if (!requestSubmitWithFallback(form)) {
|
||||||
|
warnOnce('UI_EL_MISSING', `Missing logout form: #${config.logoutFormId}`, {
|
||||||
|
module: 'session-warning',
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUserInteraction = () => {
|
const onUserInteraction = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user