improved login/session handling

Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
Andy Miller
2025-09-15 12:02:55 -06:00
parent 0c593f514f
commit 325764a304
23 changed files with 64282 additions and 68489 deletions

View File

@@ -23,6 +23,7 @@ pages:
show_modular: true
session:
timeout: 1800
keep_alive: true
edit_mode: normal
frontend_preview_target: inline
show_github_msg: true

View File

@@ -224,6 +224,18 @@ form:
type: number
min: 1
session.keep_alive:
type: toggle
label: Keep Alive Ping
help: "Periodically pings to keep your admin session alive. Turn OFF to allow the session to expire while idle (useful for testing timeouts)."
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
hide_page_types:
type: select
size: large

View File

@@ -231,6 +231,34 @@ class LoginController extends AdminController
return $this->createRedirectResponse('/');
}
/**
* Return a fresh login nonce and keep anonymous session alive while on the login screen.
*
* Route: GET /login.json/task:nonce
*
* @return ResponseInterface
*/
public function taskNonce(): ResponseInterface
{
// Touch the anonymous session to prevent immediate expiry on the login page.
$session = $this->getSession();
if (!$session->isStarted()) {
$session->start();
}
$session->__set('admin_login_keepalive', time());
// Generate a fresh nonce for the login form.
$nonce = Admin::getNonce($this->nonce_action);
return $this->createJsonResponse([
'status' => 'success',
'message' => null,
'nonce_name' => $this->nonce_name,
'nonce_action' => $this->nonce_action,
'nonce' => $nonce
]);
}
/**
* Handle 2FA verification.
*

View File

@@ -178,6 +178,11 @@ PLUGIN_ADMIN:
PAGES_FILTERED: "Pages filtered"
NO_PAGE_FOUND: "No Page found"
INVALID_PARAMETERS: "Invalid Parameters"
# Session expiration modal (Admin keep-alive/offline handling)
SESSION_EXPIRED: "Session Expired"
SESSION_EXPIRED_DESC: "Your admin login session has expired. Click OK to log in again."
OK: "OK"
NO_FILES_SENT: "No files sent"
EXCEEDED_FILESIZE_LIMIT: "Exceeded PHP configuration upload_max_filesize"
EXCEEDED_POSTMAX_LIMIT: "Exceeded PHP configuration post_max_size"

View File

@@ -7,6 +7,7 @@ import 'simplebar/dist/simplebar.min.js';
import { UriToMarkdown } from './forms/fields/files.js';
import GPM, { Instance as gpm } from './utils/gpm';
import KeepAlive from './utils/keepalive';
import { config as GravConfig } from 'grav-config';
import Updates, { Instance as updates, Notifications, Feed } from './updates';
import Dashboard from './dashboard';
import Pages from './pages';
@@ -34,9 +35,20 @@ import './utils/changelog';
// Main Sidebar
import Sidebar, { Instance as sidebar } from './utils/sidebar';
import { bindGlobalAjaxTrap, installNavigationGuard } from './utils/session-expired';
// starts the keep alive, auto runs every X seconds
KeepAlive.start();
// starts the keep alive (if enabled), but never on auth views like login/forgot/reset/register
const AUTH_VIEWS = ['login', 'forgot', 'reset', 'register'];
const isAuthView = AUTH_VIEWS.includes(String(GravConfig.route || ''));
if (!isAuthView && String(GravConfig.keep_alive_enabled) !== '0') {
KeepAlive.start();
}
// catch legacy jQuery XHR 401/403 globally
bindGlobalAjaxTrap();
// intercept admin nav clicks to show modal before redirect on timeout
installNavigationGuard();
// global event to catch sidebar_state changes
$(global).on('sidebar_state._grav', () => {

View File

@@ -1,5 +1,6 @@
import { config } from 'grav-config';
import { userFeedbackError } from './response';
import { showSessionExpiredModal } from './session-expired';
const MAX_SAFE_DELAY = 2147483647;
@@ -19,14 +20,27 @@ class KeepAlive {
this.active = false;
}
fetch() {
checkOnce() {
let data = new FormData();
data.append('admin-nonce', config.admin_nonce);
fetch(`${config.base_url_relative}/task${config.param_sep}keepAlive`, {
return fetch(`${config.base_url_relative}/task${config.param_sep}keepAlive`, {
credentials: 'same-origin',
method: 'post',
body: data
})
.then((response) => {
if (response && (response.status === 401 || response.status === 403)) {
return false;
}
return true;
})
.catch(() => false);
}
fetch() {
return this.checkOnce().then((ok) => {
if (!ok) { showSessionExpiredModal(); }
}).catch(userFeedbackError);
}
}

View File

@@ -1,8 +1,9 @@
import $ from 'jquery';
import toastr from './toastr';
import isOnline from './offline';
import { config } from 'grav-config';
// import { config } from 'grav-config';
import trim from 'mout/string/trim';
import { showSessionExpiredModal } from './session-expired';
let UNLOADING = false;
let error = function(response) {
@@ -25,6 +26,12 @@ export function parseStatus(response) {
}
export function parseJSON(response) {
// If the session is no longer valid, surface a blocking modal instead of generic errors
if (response && (response.status === 401 || response.status === 403)) {
showSessionExpiredModal();
throw new Error('Unauthorized');
}
return response.text().then((text) => {
let parsed = text;
try {
@@ -53,7 +60,8 @@ export function userFeedback(response) {
switch (status) {
case 'unauthenticated':
document.location.href = config.base_url_relative;
// Show a blocking modal and stop further processing
showSessionExpiredModal();
throw error('Logged out');
case 'unauthorized':
status = 'error';
@@ -91,6 +99,13 @@ export function userFeedback(response) {
export function userFeedbackError(error) {
if (UNLOADING) { return true; }
// If we can detect an unauthorized state here, show modal
const unauthorized = (error && (error.message === 'Unauthorized' || (error.response && (error.response.status === 401 || error.response.status === 403))));
if (unauthorized) {
showSessionExpiredModal();
return;
}
let stack = error.stack ? `<pre><code>${error.stack}</code></pre>` : '';
toastr.error(`Fetch Failed: <br /> ${error.message} ${stack}`);
console.error(`${error.message} at ${error.stack}`);

View File

@@ -0,0 +1,91 @@
import $ from 'jquery';
import { config } from 'grav-config';
import KeepAlive from './keepalive';
let shown = false;
export function showSessionExpiredModal() {
if (shown) { return; }
shown = true;
try { localStorage.setItem('grav:admin:sessionExpiredShown', '1'); } catch (e) {}
try { KeepAlive.stop(); } catch (e) {}
// Ensure modal exists (in case a custom layout removed it)
let $modal = $('[data-remodal-id="session-expired"]');
if (!$modal.length) {
const html = `
<div class="remodal" data-remodal-id="session-expired" data-remodal-options="hashTracking: false">
<form>
<h1>Session Expired</h1>
<p class="bigger">Your admin login session has expired. Please log in again.</p>
<div class="button-bar">
<a class="button remodal-confirm" data-remodal-action="confirm" href="#">OK</a>
</div>
</form>
</div>`;
$('body').append(html);
$modal = $('[data-remodal-id="session-expired"]');
}
// Harden the modal: no escape/overlay close
const instance = $modal.remodal({ hashTracking: false, closeOnEscape: false, closeOnOutsideClick: false, closeOnCancel: false, closeOnConfirm: true, stack: false });
// Style overlay + blur background
$('html').addClass('session-expired-active');
$('.remodal-overlay').addClass('session-expired');
// On confirm, redirect to login
$modal.off('.session-expired').on('confirmation.session-expired', () => {
// Keep suppression flag for the next page load (login) so we don't double prompt
window.location.href = config.base_url_relative;
});
// Open modal
instance.open();
}
// Bind a jQuery global ajax error trap for legacy XHR paths
export function bindGlobalAjaxTrap() {
$(document).off('ajaxError._grav_session').on('ajaxError._grav_session', (event, xhr) => {
if (!xhr) { return; }
const status = xhr.status || 0;
if (status === 401 || status === 403) {
showSessionExpiredModal();
}
});
}
// Intercept in-admin link clicks to show the modal before any server redirect to login
export function installNavigationGuard() {
$(document).off('click._grav_session_nav').on('click._grav_session_nav', 'a[href]', function(e) {
const $a = $(this);
const href = $a.attr('href');
if (!href || href === '#' || href.indexOf('javascript:') === 0) { return; }
if (e.isDefaultPrevented()) { return; }
if ($a.attr('target') === '_blank' || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return; }
// Only guard admin-relative links
const base = (window.GravAdmin && window.GravAdmin.config && window.GravAdmin.config.base_url_relative) || '';
const isAdminLink = href.indexOf(base + '/') === 0 || href === base || href.indexOf('/') === 0;
if (!isAdminLink) { return; }
e.preventDefault();
// Quick session check, if invalid show modal, else proceed with navigation
try {
KeepAlive.checkOnce().then((ok) => {
if (ok) {
window.location.href = href;
} else {
showSessionExpiredModal();
}
});
} catch (err) {
// On any error, just navigate
window.location.href = href;
}
});
}
export default { showSessionExpiredModal, bindGlobalAjaxTrap };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1 @@
body, h5, h6,
.badge, .note, .grav-mdeditor-preview,
input, select, textarea, button, .selectize-input,
h1, h2, h3, h4,
.fontfamily-sans .CodeMirror pre,
#admin-menu li, .form-tabs > label, .label {
font-family: "Helvetica Neue", "Helvetica", "Tahoma", "Geneva", "Arial", sans-serif; }
.CodeMirror pre,
code, kbd, pre, samp, .mono {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; }
/*# sourceMappingURL=simple-fonts.css.map */
body,h5,h6,.badge,.note,.grav-mdeditor-preview,input,select,textarea,button,.selectize-input,h1,h2,h3,h4,.fontfamily-sans .CodeMirror pre,#admin-menu li,.form-tabs>label,.label{font-family:"Helvetica Neue","Helvetica","Tahoma","Geneva","Arial",sans-serif}.CodeMirror pre,code,kbd,pre,samp,.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,9 @@
"repository": "https://github.com/getgrav/grav-admin",
"main": "app/main.js",
"scripts": {
"watch:sass": "sass --watch scss:css-compiled --style=expanded",
"build:css": "sass scss:css-compiled --style=compressed --no-source-map",
"dev": "sh -c 'npm run watch & npm run watch:sass'",
"watch": "webpack --mode development --watch --progress --color --mode development --config webpack.conf.js",
"prod": "webpack --mode production --config webpack.conf.js"
},
@@ -28,6 +31,7 @@
"popper.js": "^1.14.4",
"rangetouch": "^2.0.1",
"remodal": "^1.1.1",
"sass": "^1.92.1",
"selectize": "^0.12.6",
"simplebar": "^5.3.6",
"sortablejs": "^1.14.0",

View File

@@ -1435,4 +1435,18 @@ body.sidebar-quickopen #admin-main {
}
}
.simplebar-content-wrapper { overflow: auto; }
/* Session expired blur + overlay */
html.session-expired-active .remodal-bg { filter: blur(4px); pointer-events: none; user-select: none; }
html.session-expired-active #admin-login { filter: blur(4px); pointer-events: none; user-select: none; }
.remodal-overlay.session-expired { background: rgba(0,0,0,0.55); backdrop-filter: blur(3px); }
[data-remodal-id="session-expired"] .button-bar .button { min-width: 120px;text-align: center; }
/* Lightweight modal used on login page when vendor JS is not loaded */
.grav-expired-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); backdrop-filter: blur(3px); z-index: 9999; display: flex; align-items: center; justify-content: center; }
.grav-expired-modal { background: #fff; border-radius: 6px; width: min(640px, 90%); box-shadow: 0 10px 30px rgba(0,0,0,.25); overflow: hidden; font-family: inherit; }
.grav-expired-modal h1 { margin: 0; padding: 24px 28px; font-size: 28px; color: #3D424E; border-bottom: 1px solid #f0f0f0; }
.grav-expired-modal p { margin: 0; padding: 20px 28px; font-size: 16px; color: #6f7b8a; }
.grav-expired-actions { padding: 18px 24px; display: flex; justify-content: flex-end; background: #f7f7f7; }
.grav-expired-actions .button { min-width: 120px;text-align: center; }

View File

@@ -33,12 +33,6 @@
{{ assets.js()|raw }}
{% endblock %}
<style>
.simplebar-content-wrapper {
overflow: auto;
}
</style>
</head>
{% block body %}
{% set sidebarStatus = get_cookie('grav-admin-sidebar') %}
@@ -89,6 +83,16 @@
{% endblock %}
{% block modals %}
{# Session expired blocking modal #}
<div class="remodal" data-remodal-id="session-expired" data-remodal-options="hashTracking: false">
<form>
<h1>{{ 'PLUGIN_ADMIN.SESSION_EXPIRED'|t|default('Session Expired') }}</h1>
<p class="bigger">{{ 'PLUGIN_ADMIN.SESSION_EXPIRED_DESC'|t|default('Your admin login session has expired. Click OK to log in again.') }}</p>
<div class="button-bar">
<a class="button remodal-confirm" data-remodal-action="confirm" href="#">{{ 'PLUGIN_ADMIN.OK'|t|default('OK') }}</a>
</div>
</form>
</div>
<div class="remodal" data-remodal-id="generic" data-remodal-options="hashTracking: false">
<form>
<h1>{{ "PLUGIN_ADMIN.ERROR"|t }}</h1>

View File

@@ -27,6 +27,7 @@
enable_auto_updates_check: '{{ config.plugins.admin.enable_auto_updates_check }}',
{% endif %}
admin_timeout: '{{ config.plugins.admin.session.timeout ?: 1800 }}',
keep_alive_enabled: {{ (config.plugins.admin.session.keep_alive is defined ? config.plugins.admin.session.keep_alive : true) ? 1 : 0 }},
admin_nonce: '{{ admin.getNonce }}',
language: '{{ grav.user.language|default('en') }}',
pro_enabled: '{{ config.plugins["admin-pro"].enabled }}',

View File

@@ -1,11 +1,47 @@
{% do assets.add('jquery',101) %}
{% if authorize(['admin.login', 'admin.super']) %}
{% do assets.addJs(theme_url~'/js/vendor.min.js', { 'loading':'defer' }) %}
{% do assets.addJs(theme_url~'/js/admin.min.js' , { 'loading':'defer' }) %}
{% do assets.addJs(theme_url~'/js/vendor.min.js', { 'loading':'defer' }) %}
{% do assets.addJs(theme_url~'/js/admin.min.js' , { 'loading':'defer' }) %}
{% if browser.getBrowser == 'msie' or browser.getBrowser == 'edge' %}
{% do assets.addJs(theme_url~'/js/form-attr.polyfill.js') %}
{% endif %}
{% if browser.getBrowser == 'msie' or browser.getBrowser == 'edge' %}
{% do assets.addJs(theme_url~'/js/form-attr.polyfill.js') %}
{% endif %}
{% include 'partials/javascripts-extra.html.twig' ignore missing %}
{% include 'partials/javascripts-extra.html.twig' ignore missing %}
{% else %}
{# Not authorized (e.g., login page). Keep session + login nonce fresh. No session-expired overlay here. #}
<script>
(function() {
var base = '{{ base_url_relative }}';
var sep = '{{ config.system.param_sep }}';
// Use Admin's configured timeout if set; fall back to system session timeout
var adminTimeout = {{ (config.plugins.admin.session.timeout is defined ? config.plugins.admin.session.timeout : (config.system.session.timeout|default(1800))) }}; // seconds
// Refresh faster than the admin timeout; aim for ~1/3 of it, but at least 2s and at most 20s
var interval = Math.max(2, Math.min(20, Math.floor(adminTimeout / 3)));
function refreshLoginNonce() {
var url = base + '/task' + sep + 'nonce?ts=' + Date.now();
fetch(url, { credentials: 'same-origin', headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' }})
.then(function(r){ return r.ok ? r.json() : null; })
.then(function(data){
if (!data || !data.nonce) { return; }
var name = (data.nonce_name || 'login-nonce');
var inputs = document.querySelectorAll('input[name="' + name + '"]');
inputs.forEach(function(i){ i.value = data.nonce; });
})
.catch(function(){ /* silent */ });
}
function boot() {
if (document.getElementById('admin-login')) {
refreshLoginNonce();
setInterval(refreshLoginNonce, interval * 1000);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else { boot(); }
})();
</script>
{% endif %}

View File

@@ -1,27 +0,0 @@
#!/bin/sh
#
# Configuration
#
# sass source
SASS_SOURCE_PATH="scss"
# sass options
SASS_OPTIONS="--source-map=true --style=nested"
# css target
CSS_TARGET_PATH="css-compiled"
#
# Check prerequisites
#
wtfile=$(command -v wt) || { echo "install wellington with 'brew install wellington"; exit 1; }
#
# Watch folder for changes
#
cd -P `pwd`
$wtfile compile "$SASS_SOURCE_PATH" -b "$CSS_TARGET_PATH" $SASS_OPTIONS
$wtfile watch "$SASS_SOURCE_PATH" -b "$CSS_TARGET_PATH" $SASS_OPTIONS

File diff suppressed because it is too large Load Diff