mirror of
https://github.com/getgrav/grav-plugin-admin.git
synced 2025-10-26 00:36:31 +02:00
improved login/session handling
Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
91
themes/grav/app/utils/session-expired.js
Normal file
91
themes/grav/app/utils/session-expired.js
Normal 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 };
|
||||
626
themes/grav/css-compiled/nucleus.css
vendored
626
themes/grav/css-compiled/nucleus.css
vendored
File diff suppressed because one or more lines are too long
1521
themes/grav/css-compiled/preset.css
vendored
1521
themes/grav/css-compiled/preset.css
vendored
File diff suppressed because one or more lines are too long
14
themes/grav/css-compiled/simple-fonts.css
vendored
14
themes/grav/css-compiled/simple-fonts.css
vendored
@@ -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}
|
||||
|
||||
8745
themes/grav/css-compiled/template.css
vendored
8745
themes/grav/css-compiled/template.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1390
themes/grav/js/admin.min.js
vendored
1390
themes/grav/js/admin.min.js
vendored
File diff suppressed because it is too large
Load Diff
107399
themes/grav/js/vendor.min.js
vendored
107399
themes/grav/js/vendor.min.js
vendored
File diff suppressed because one or more lines are too long
9445
themes/grav/package-lock.json
generated
9445
themes/grav/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}',
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user