diff --git a/ProcessMaker/Http/Controllers/Auth/LoginController.php b/ProcessMaker/Http/Controllers/Auth/LoginController.php index f50eff5596..ccdffe7a09 100644 --- a/ProcessMaker/Http/Controllers/Auth/LoginController.php +++ b/ProcessMaker/Http/Controllers/Auth/LoginController.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cookie; use Illuminate\Validation\ValidationException; +use Laravel\Passport\ApiTokenCookieFactory; use Laravel\Passport\Passport; use ProcessMaker\Events\Logout; use ProcessMaker\Http\Controllers\Controller; @@ -243,9 +244,29 @@ public function username() return 'username'; } - public function keepAlive() + public function keepAlive(Request $request) { - return response('', 204); + // Touch the Laravel session to extend its server-side expiration. + // Return the session CSRF token and refresh Passport's API cookie. + $token = ''; + try { + $request->session()->put('_pm_keep_alive', time()); + $token = (string) $request->session()->token(); + } catch (\Throwable $e) { + // If session is unavailable, return an empty token and let the frontend handle auth failure. + } + + $response = response()->json(['token' => $token]); + + if (Auth::check() && $token !== '') { + // Passport's CreateFreshApiToken middleware only refreshes this + // cookie on GET requests. keep-alive is POST, so refresh it here. + $response->withCookie( + app(ApiTokenCookieFactory::class)->make(Auth::id(), $token) + ); + } + + return $response; } protected function authenticated(Request $request, $user) diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 8df87873b1..6f10bfad4a 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -1,8 +1,6 @@ import "bootstrap-vue/dist/bootstrap-vue.css"; import { BootstrapVue, BootstrapVueIcons } from "bootstrap-vue"; import * as bootstrap from "bootstrap"; -import TenantAwareEcho from "./common/TenantAwareEcho"; -import { initSessionSync } from "./common/sessionSync"; import Router from "vue-router"; import ScreenBuilder, { initializeScreenCache } from "@processmaker/screen-builder"; import * as VueDeepSet from "vue-deepset"; @@ -20,6 +18,14 @@ import MonacoEditor from "vue-monaco"; import Vue from "vue"; import * as vue from "vue"; import VueCookies from "vue-cookies"; +import { initSessionSync } from "./common/sessionSync"; +import { + applyCsrfToken, + attachCsrfRequestInterceptor, + attachSessionRenewalInterceptor, + getCsrfToken, +} from "./common/csrfToken"; +import TenantAwareEcho from "./common/TenantAwareEcho"; import GlobalStore from "./globalStore"; import Pagination from "./components/common/Pagination"; import ScreenSelect from "./processes/modeler/components/inspector/ScreenSelect.vue"; @@ -242,19 +248,26 @@ window.ProcessMaker.i18nPromise.then(() => { translationsLoaded = true; }); */ window.ProcessMaker.apiClient = require("axios"); +window.ProcessMaker.apiClient.defaults.withCredentials = true; + window.ProcessMaker.apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; +const token = document.head.querySelector("meta[name=\"csrf-token\"]"); +const isProd = document.head.querySelector("meta[name=\"is-prod\"]")?.content === "true"; + +window.ProcessMaker.applyCsrfToken = applyCsrfToken; +window.ProcessMaker.getCsrfToken = getCsrfToken; +// Attach CSRF interceptor before other interceptors so it runs last in the axios chain. +attachCsrfRequestInterceptor(window.ProcessMaker.apiClient); + /** * Next we will register the CSRF Token as a common header with Axios so that * all outgoing HTTP requests automatically have it attached. This is just * a simple convenience so we don't have to attach every token manually. */ -const token = document.head.querySelector("meta[name=\"csrf-token\"]"); -const isProd = document.head.querySelector("meta[name=\"is-prod\"]")?.content === "true"; - if (token) { - window.ProcessMaker.apiClient.defaults.headers.common["X-CSRF-TOKEN"] = token.content; + applyCsrfToken(token.content, "page-load"); } else { console.error("CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token"); } @@ -284,6 +297,8 @@ window.ProcessMaker.apiClient.interceptors.request.use((config) => { return config; }); +attachSessionRenewalInterceptor(window.ProcessMaker.apiClient); + // Set the default API timeout let apiTimeout = 5000; if (window.Processmaker && window.Processmaker.apiTimeout !== undefined) { @@ -353,10 +368,11 @@ if (window.Processmaker && window.Processmaker.broadcasting) { if (userID) { const timeoutScript = document.head.querySelector("meta[name=\"timeout-worker\"]")?.content; - const accountTimeoutLength = parseInt(eval(document.head.querySelector("meta[name=\"timeout-length\"]")?.content)); - const warnSeconds = parseInt(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content); + const timeoutEnabledMeta = document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content; + const accountTimeoutLength = Number(document.head.querySelector("meta[name=\"timeout-length\"]")?.content); + const warnSeconds = Number(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content); const accountTimeoutWarnSeconds = Number.isNaN(warnSeconds) ? 0 : warnSeconds; - const accountTimeoutEnabled = document.head.querySelector("meta[name=\"timeout-enabled\"]") ? parseInt(document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content) : 1; + const accountTimeoutEnabled = timeoutEnabledMeta ? Number(timeoutEnabledMeta) : 1; const sessionSyncState = initSessionSync({ userId: userID.content, diff --git a/resources/js/common/csrfToken.js b/resources/js/common/csrfToken.js new file mode 100644 index 0000000000..55c1fdc89d --- /dev/null +++ b/resources/js/common/csrfToken.js @@ -0,0 +1,289 @@ +/** + * Read the current CSRF token from memory or the meta tag. + */ +export const getCsrfToken = () => { + if (globalThis.ProcessMaker?.__pmXsrfToken) { + return globalThis.ProcessMaker.__pmXsrfToken; + } + + const meta = globalThis.document?.head?.querySelector('meta[name="csrf-token"]'); + return meta?.content || null; +}; + +const setRequestCsrfHeader = (config, token) => { + if (!token) { + return; + } + + // Per-request header overrides axios defaults (even when defaults are frozen). + // eslint-disable-next-line no-param-reassign + config.headers = config.headers || {}; + // eslint-disable-next-line no-param-reassign + config.headers["X-CSRF-TOKEN"] = token; + + if (config.headers.common) { + // eslint-disable-next-line no-param-reassign + config.headers.common["X-CSRF-TOKEN"] = token; + } +}; + +const safeStorageGetItem = (key) => { + try { + return globalThis.localStorage?.getItem(key); + } catch (error) { + return null; + } +}; + +const isSessionRenewalDebugEnabled = () => safeStorageGetItem("pm:session:debug") === "1"; + +const getRequestDebugData = (config) => { + const requestUrl = getRequestUrl(config); + + return { + method: config?.method || "get", + url: requestUrl ? `${requestUrl.pathname}${requestUrl.search}` : config?.url, + }; +}; + +const sessionRenewalDebugLog = (...args) => { + if (isSessionRenewalDebugEnabled()) { + console.info("[SessionRenewal]", ...args); + } +}; + +/** + * Apply CSRF token to all client-side sources axios may read from. + */ +export const applyCsrfToken = (token) => { + if (!token) { + return false; + } + + globalThis.ProcessMaker = globalThis.ProcessMaker || {}; + + globalThis.ProcessMaker.__pmXsrfToken = token; + + const meta = globalThis.document?.head?.querySelector('meta[name="csrf-token"]'); + if (meta) { + meta.setAttribute("content", token); + } + + try { + if (globalThis.ProcessMaker?.apiClient?.defaults?.headers?.common) { + globalThis.ProcessMaker.apiClient.defaults.headers.common["X-CSRF-TOKEN"] = token; + } + } catch (e) { + // If defaults are readonly, the request interceptor will still set the header. + } + + sessionRenewalDebugLog("csrf:applied", { + hasApiClientDefaults: !!globalThis.ProcessMaker?.apiClient?.defaults?.headers?.common, + hasMeta: !!meta, + }); + + return true; +}; + +const getRequestUrl = (config) => { + if (typeof config?.url !== "string" || !config.url) { + return null; + } + + try { + const currentOrigin = globalThis.location?.origin; + const baseURL = config.baseURL + ? new URL(config.baseURL, currentOrigin).href + : currentOrigin; + + return new URL(config.url, baseURL); + } catch (error) { + return null; + } +}; + +const getSessionRenewalSkipReason = (config) => { + if (config?.skipSessionRenewal || config?.__skipSessionRenewal) { + return "request-marked-skip"; + } + + const requestUrl = getRequestUrl(config); + if (!requestUrl) { + return "invalid-url"; + } + + const currentOrigin = globalThis.location?.origin; + if (currentOrigin && requestUrl.origin !== currentOrigin) { + return "external-origin"; + } + + const excludedPath = [ + "/keep-alive", + "/logout", + "/login", + "/debug", + ].find((path) => requestUrl.pathname === path || requestUrl.pathname.startsWith(`${path}/`)); + + return excludedPath ? `excluded-path:${excludedPath}` : null; +}; + +const getSessionRenewalStatus = (sessionSync) => { + if (typeof sessionSync?.getRequestRenewalStatus === "function") { + return sessionSync.getRequestRenewalStatus(); + } + + return { + remainingSeconds: typeof sessionSync?.getRemainingSeconds === "function" + ? sessionSync.getRemainingSeconds() + : null, + renewalThresholdSeconds: null, + shouldRenew: typeof sessionSync?.shouldRenewBeforeRequest === "function" + && sessionSync.shouldRenewBeforeRequest(), + }; +}; + +const getRemainingSeconds = (sessionSync) => getSessionRenewalStatus(sessionSync).remainingSeconds; + +const logSessionRenewalSkipped = (config, reason) => { + sessionRenewalDebugLog("request:skip-renewal", { + ...getRequestDebugData(config), + reason, + }); +}; + +const handleExpiredSession = () => { + sessionRenewalDebugLog("session:expired"); + + if (globalThis.ProcessMaker?.sessionSync?.clearWarningState) { + globalThis.ProcessMaker.sessionSync.clearWarningState(); + } + + if (globalThis.ProcessMaker?.sessionSync?.broadcast) { + globalThis.ProcessMaker.sessionSync.broadcast("expired"); + } + + if (globalThis.location) { + globalThis.location.href = "/logout"; + } +}; + +let pendingSessionRenewal = null; + +const renewSessionBeforeRequest = async (apiClient) => { + if (pendingSessionRenewal) { + sessionRenewalDebugLog("keep-alive:join-pending"); + return pendingSessionRenewal; + } + + const sessionSync = globalThis.ProcessMaker?.sessionSync; + const renewalStatus = getSessionRenewalStatus(sessionSync); + sessionRenewalDebugLog("keep-alive:start", { + remainingSeconds: renewalStatus.remainingSeconds, + renewalThresholdSeconds: renewalStatus.renewalThresholdSeconds, + }); + + pendingSessionRenewal = apiClient + .post("/keep-alive", {}, { + baseURL: "", + skipSessionRenewal: true, + __skipSessionRenewal: true, + }) + .then((response) => { + const { token } = response.data || {}; + + if (token) { + applyCsrfToken(token); + } + + const timeout = globalThis.ProcessMaker?.AccountTimeoutLength; + if (sessionSync?.renewSession && timeout) { + sessionSync.renewSession(timeout); + } + + sessionRenewalDebugLog("keep-alive:success", { + hasToken: !!token, + timeout, + }); + + return response; + }) + .catch((error) => { + const status = error?.response?.status; + if (status === 401 || status === 419) { + sessionRenewalDebugLog("keep-alive:auth-failed", { status }); + handleExpiredSession(); + throw error; + } + + sessionRenewalDebugLog("keep-alive:failed-continue", { + status, + message: error?.message, + }); + return null; + }) + .finally(() => { + sessionRenewalDebugLog("keep-alive:finished"); + pendingSessionRenewal = null; + }); + + return pendingSessionRenewal; +}; + +/** + * Renew the Laravel session and CSRF token right before an API request when the + * known session lifetime is close to expiring. + */ +export const attachSessionRenewalInterceptor = (apiClient) => { + if (!apiClient?.interceptors?.request) { + return; + } + + return apiClient.interceptors.request.use(async (config) => { + const sessionSync = globalThis.ProcessMaker?.sessionSync; + const skipReason = getSessionRenewalSkipReason(config); + const renewalStatus = getSessionRenewalStatus(sessionSync); + + if (skipReason) { + logSessionRenewalSkipped(config, skipReason); + return config; + } + + if (!renewalStatus.shouldRenew) { + sessionRenewalDebugLog("request:continue-with-current-session", { + ...getRequestDebugData(config), + remainingSeconds: renewalStatus.remainingSeconds, + renewalThresholdSeconds: renewalStatus.renewalThresholdSeconds, + }); + return config; + } + + sessionRenewalDebugLog("request:renew-before-send", { + ...getRequestDebugData(config), + remainingSeconds: renewalStatus.remainingSeconds, + renewalThresholdSeconds: renewalStatus.renewalThresholdSeconds, + }); + + await renewSessionBeforeRequest(apiClient); + + return config; + }); +}; + +/** + * Attach a request interceptor so every request uses the latest CSRF token. + * Register this BEFORE other interceptors so it runs LAST in the axios chain. + */ +export const attachCsrfRequestInterceptor = (apiClient) => { + if (!apiClient?.interceptors?.request) { + return; + } + + apiClient.interceptors.request.use((config) => { + const token = getCsrfToken(); + if (token) { + setRequestCsrfHeader(config, token); + } + + return config; + }); +}; diff --git a/resources/js/common/sessionSync.js b/resources/js/common/sessionSync.js index 648a6f257b..67e717bfc3 100644 --- a/resources/js/common/sessionSync.js +++ b/resources/js/common/sessionSync.js @@ -17,18 +17,39 @@ export const initSessionSync = ({ return null; } - const sessionChannelName = "pm-session-sync"; - const sessionLeaderKey = "pm:session:leader"; - const sessionStateKey = "pm:session:state"; - const sessionWarningKey = "pm:session:warning"; + const safeStorageGetItem = (key) => { + try { + return localStorage.getItem(key); + } catch (error) { + return null; + } + }; + + const safeTimeoutLength = Number(accountTimeoutLength); + const safeTimeoutWarnSeconds = Number(accountTimeoutWarnSeconds); + const safeTimeoutEnabled = Number(accountTimeoutEnabled); + const normalizedTimeoutLength = Number.isFinite(safeTimeoutLength) && safeTimeoutLength > 0 + ? safeTimeoutLength + : 0; + const normalizedTimeoutWarnSeconds = Number.isFinite(safeTimeoutWarnSeconds) && safeTimeoutWarnSeconds > 0 + ? safeTimeoutWarnSeconds + : 0; + const normalizedTimeoutEnabled = Number.isFinite(safeTimeoutEnabled) ? safeTimeoutEnabled : 1; + const sessionOrigin = window.location?.origin || "unknown-origin"; + const sessionScope = encodeURIComponent(`${sessionOrigin}:${userId}`); + const sessionKeyPrefix = `pm:session:${sessionScope}`; + const sessionChannelName = `${sessionKeyPrefix}:sync`; + const sessionLeaderKey = `${sessionKeyPrefix}:leader`; + const sessionStateKey = `${sessionKeyPrefix}:state`; + const sessionWarningKey = `${sessionKeyPrefix}:warning`; // Track keep-alive progress across tabs. - const sessionRenewingKey = "pm:session:renewing"; - const sessionSuppressKey = "pm:session:suppress-warning"; - const sessionMessageKey = "pm:session:message"; + const sessionRenewingKey = `${sessionKeyPrefix}:renewing`; + const sessionSuppressKey = `${sessionKeyPrefix}:suppress-warning`; + const sessionMessageKey = `${sessionKeyPrefix}:message`; const sessionTabId = `${Date.now()}-${Math.random().toString(16).slice(2)}`; const leaderHeartbeatMs = 4000; const leaderTtlMs = 8000; - const sessionDebugEnabled = localStorage.getItem("pm:session:debug") === "1"; + const sessionDebugEnabled = safeStorageGetItem("pm:session:debug") === "1"; const sessionDebugLog = (...args) => { if (sessionDebugEnabled && !isProd) { console.info("[SessionSync]", `[tab:${sessionTabId}]`, ...args); @@ -36,15 +57,24 @@ export const initSessionSync = ({ }; sessionDebugLog("worker:init", { timeoutScript }); - const AccountTimeoutWorker = new Worker(timeoutScript); - sessionDebugLog("worker:created"); + let AccountTimeoutWorker = null; + if (timeoutScript && normalizedTimeoutEnabled && normalizedTimeoutLength > 0) { + try { + AccountTimeoutWorker = new Worker(timeoutScript); + sessionDebugLog("worker:created"); + } catch (error) { + sessionDebugLog("worker:create-failed", error); + } + } else { + sessionDebugLog("worker:disabled", { timeoutScript, normalizedTimeoutEnabled, normalizedTimeoutLength }); + } const resolveSessionModal = () => (typeof getSessionModal === "function" ? getSessionModal() : null); const resolveCloseSessionModal = () => (typeof getCloseSessionModal === "function" ? getCloseSessionModal() : null); const readStorageJson = (key) => { try { - const raw = localStorage.getItem(key); + const raw = safeStorageGetItem(key); return raw ? JSON.parse(raw) : null; } catch (error) { return null; @@ -68,7 +98,7 @@ export const initSessionSync = ({ }; let sessionState = { - timeout: accountTimeoutLength, + timeout: normalizedTimeoutLength, startedAt: Date.now(), }; @@ -94,8 +124,13 @@ export const initSessionSync = ({ refreshSessionStateFromStorage(); const setSessionState = (timeoutMinutes) => { + const normalizedTimeout = Number(timeoutMinutes); + if (!Number.isFinite(normalizedTimeout) || normalizedTimeout <= 0) { + sessionDebugLog("session-state:invalid-timeout", { timeoutMinutes }); + return; + } sessionState = { - timeout: timeoutMinutes, + timeout: normalizedTimeout, startedAt: Date.now(), }; writeStorageJson(sessionStateKey, sessionState); @@ -180,7 +215,14 @@ export const initSessionSync = ({ writeStorageJson(sessionSuppressKey, suppressWarningState); }; - const sessionChannel = "BroadcastChannel" in window ? new BroadcastChannel(sessionChannelName) : null; + let sessionChannel = null; + if ("BroadcastChannel" in window) { + try { + sessionChannel = new BroadcastChannel(sessionChannelName); + } catch (error) { + sessionDebugLog("broadcast-channel:create-failed", error); + } + } const recentMessageIds = new Map(); const recentMessageTtlMs = 5000; const maxRecentMessageIds = 100; @@ -258,11 +300,11 @@ export const initSessionSync = ({ }; const markActivity = (source) => { - setSessionState(accountTimeoutLength); + setSessionState(normalizedTimeoutLength); clearWarningState(); setSuppressWarning(2000); - broadcastSessionEvent("activity", { timeout: accountTimeoutLength, source }); - sessionDebugLog("activity", { source, timeout: accountTimeoutLength }); + broadcastSessionEvent("activity", { timeout: normalizedTimeoutLength, source }); + sessionDebugLog("activity", { source, timeout: normalizedTimeoutLength }); const closeSessionModal = resolveCloseSessionModal(); if (closeSessionModal) { closeSessionModal(); @@ -273,12 +315,70 @@ export const initSessionSync = ({ } }; + const renewSession = (timeoutMinutes = normalizedTimeoutLength) => { + const timeout = Number(timeoutMinutes) || normalizedTimeoutLength; + clearWarningState(); + setRenewingState(false); + setSuppressWarning(1000); + setSessionState(timeout); + broadcastSessionEvent("renewed", { timeout }); + const closeSessionModal = resolveCloseSessionModal(); + if (closeSessionModal) { + closeSessionModal(); + } + if (isLeader()) { + if (AccountTimeoutWorker) { + AccountTimeoutWorker.postMessage({ method: "stop" }); + } + startTimeoutWorker(timeout); + } + }; + const getRemainingTimeout = (timeoutMinutes) => { const elapsedMinutes = (Date.now() - sessionState.startedAt) / 60000; const remaining = timeoutMinutes - elapsedMinutes; return Math.max(0, remaining); }; + const getRemainingSeconds = () => { + refreshSessionStateFromStorage(); + return Math.ceil(getRemainingTimeout(sessionState.timeout) * 60); + }; + + const getRequestRenewalThresholdSeconds = (thresholdSeconds) => { + const configuredThreshold = Number(thresholdSeconds); + const timeoutSeconds = normalizedTimeoutLength * 60; + const defaultThreshold = Math.floor(timeoutSeconds * 0.8); + const baseThreshold = Number.isFinite(configuredThreshold) && configuredThreshold > 0 + ? configuredThreshold + : defaultThreshold; + const maxThreshold = Math.max(0, timeoutSeconds - 5); + + return Math.min(baseThreshold, maxThreshold); + }; + + const shouldRenewBeforeRequest = (thresholdSeconds) => { + if (!AccountTimeoutWorker || !normalizedTimeoutEnabled || normalizedTimeoutLength <= 0) { + return false; + } + + const remainingSeconds = getRemainingSeconds(); + const renewalThresholdSeconds = getRequestRenewalThresholdSeconds(thresholdSeconds); + + return remainingSeconds > 0 && remainingSeconds <= renewalThresholdSeconds; + }; + + const getRequestRenewalStatus = (thresholdSeconds) => { + const remainingSeconds = getRemainingSeconds(); + const renewalThresholdSeconds = getRequestRenewalThresholdSeconds(thresholdSeconds); + + return { + remainingSeconds, + renewalThresholdSeconds, + shouldRenew: remainingSeconds > 0 && remainingSeconds <= renewalThresholdSeconds, + }; + }; + const getRemainingWarningTime = () => { if (!warningState?.time || !warningState?.ts) { return 0; @@ -288,6 +388,10 @@ export const initSessionSync = ({ }; const startTimeoutWorker = (timeoutMinutes) => { + if (!AccountTimeoutWorker || !normalizedTimeoutEnabled || normalizedTimeoutLength <= 0) { + sessionDebugLog("worker:start:skip", { hasWorker: !!AccountTimeoutWorker, normalizedTimeoutEnabled, normalizedTimeoutLength }); + return; + } const remaining = getRemainingTimeout(timeoutMinutes); sessionDebugLog("worker:start", { timeoutMinutes, remaining }); if (remaining <= 0) { @@ -300,8 +404,8 @@ export const initSessionSync = ({ method: "start", data: { timeout: remaining, - warnSeconds: accountTimeoutWarnSeconds, - enabled: accountTimeoutEnabled, + warnSeconds: normalizedTimeoutWarnSeconds, + enabled: normalizedTimeoutEnabled, }, }); }; @@ -330,11 +434,15 @@ export const initSessionSync = ({ const sessionModal = resolveSessionModal(); // Guard for layouts that don't include the session modal. if (typeof sessionModal === "function") { + const warningMessage = [ + "
Your user session is expiring. If your session expires, all of your unsaved data will be lost.
", + "Would you like to stay connected?
", + ].join(""); sessionModal( "Session Warning", - "Your user session is expiring. If your session expires, all of your unsaved data will be lost.
Would you like to stay connected?
", + warningMessage, remainingTime, - accountTimeoutWarnSeconds, + normalizedTimeoutWarnSeconds, ); } }; @@ -373,7 +481,7 @@ export const initSessionSync = ({ } if (message.type === "renewed" || message.type === "started" || message.type === "activity") { - const timeout = Number(message.data?.timeout) || accountTimeoutLength; + const timeout = Number(message.data?.timeout) || normalizedTimeoutLength; clearWarningState(); setRenewingState(false); setSuppressWarning(1000); @@ -401,25 +509,45 @@ export const initSessionSync = ({ } }; + const handleBroadcastMessage = (event) => handleSessionMessage(event.data); if (sessionChannel) { - sessionChannel.onmessage = (event) => handleSessionMessage(event.data); + sessionChannel.onmessage = handleBroadcastMessage; } - window.addEventListener("storage", (event) => { + const handleStorageMessage = (event) => { if (event.key !== sessionMessageKey || !event.newValue) { return; } handleSessionMessage(readStorageJson(sessionMessageKey)); - }); + }; + window.addEventListener("storage", handleStorageMessage); + + const handleStoredTerminalSessionMessage = () => { + const message = readStorageJson(sessionMessageKey); + if (message?.type === "logout" || message?.type === "expired") { + handleSessionMessage(message); + } + }; - AccountTimeoutWorker.onmessage = (e) => { + const handleWorkerMessage = (e) => { if (!isLeader()) { return; } if (e.data.method === "countdown") { sessionDebugLog("worker:countdown", e.data.data); + refreshSessionStateFromStorage(); + const remainingSeconds = getRemainingTimeout(sessionState.timeout) * 60; + if (remainingSeconds > normalizedTimeoutWarnSeconds) { + sessionDebugLog("worker:countdown:stale", { remainingSeconds, sessionState }); + clearWarningState(); + const closeSessionModal = resolveCloseSessionModal(); + if (closeSessionModal) { + closeSessionModal(); + } + return; + } setWarningState(e.data.data.time); showWarningIfActive(); broadcastSessionEvent("warning", { time: e.data.data.time }); @@ -438,6 +566,9 @@ export const initSessionSync = ({ window.location = "/logout?timeout=true"; } }; + if (AccountTimeoutWorker) { + AccountTimeoutWorker.onmessage = handleWorkerMessage; + } let wasLeader = false; const updateLeadership = () => { @@ -468,22 +599,28 @@ export const initSessionSync = ({ if (leaderNow) { ensureWorkerRunning("leadership-change"); } else { + workerStarted = false; + if (AccountTimeoutWorker) { + AccountTimeoutWorker.postMessage({ method: "stop" }); + } const closeSessionModal = resolveCloseSessionModal(); if (closeSessionModal) { - workerStarted = false; closeSessionModal(); } } } }; + if (document.visibilityState === "visible") { + markActivity("load"); + } updateLeadership(); if (isLeader()) { - markActivity("load"); ensureWorkerRunning("load"); } - setInterval(updateLeadership, leaderHeartbeatMs); - window.addEventListener("visibilitychange", () => { + const leadershipInterval = setInterval(updateLeadership, leaderHeartbeatMs); + const handleVisibilityChange = () => { + handleStoredTerminalSessionMessage(); updateLeadership(); // Keep warning state in sync when switching tabs. refreshWarningStateFromStorage(); @@ -493,17 +630,20 @@ export const initSessionSync = ({ refreshSessionStateFromStorage(); startTimeoutWorker(sessionState.timeout); } - }); + }; + window.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", handleStoredTerminalSessionMessage); // Broadcast manual logout so all tabs close warning and redirect. - document.addEventListener("click", (event) => { - const logoutLink = event.target.closest('a[href="/logout"], a[href^="/logout?"]'); + const handleDocumentClick = (event) => { + const logoutLink = event.target.closest("a[href=\"/logout\"], a[href^=\"/logout?\"]"); if (!logoutLink) { return; } clearWarningState(); broadcastSessionEvent("logout"); - }); + }; + document.addEventListener("click", handleDocumentClick); const isSameDevice = (e) => { const localDeviceId = Vue.$cookies.get(e.device_variable); @@ -532,10 +672,14 @@ export const initSessionSync = ({ } }) .listen(".SessionStarted", (e) => { - const lifetime = parseInt(eval(e.lifetime)); + const lifetime = Number(e.lifetime); if (!isSameDevice(e)) { return; } + if (!Number.isFinite(lifetime) || lifetime <= 0) { + sessionDebugLog("event:session-started:invalid-lifetime", { lifetime: e.lifetime }); + return; + } sessionDebugLog("event:session-started", { lifetime }); setSessionState(lifetime); @@ -557,7 +701,8 @@ export const initSessionSync = ({ const newDeviceId = Vue.$cookies.get(e.device_variable); if (localDeviceId !== newDeviceId) { clearInterval(redirectLogoutinterval); - window.location.href = "/logout"; + // eslint-disable-next-line no-undef + globalThis.location.href = "/logout"; } }, 100); } @@ -574,17 +719,37 @@ export const initSessionSync = ({ } return { - AccountTimeoutLength: accountTimeoutLength, - AccountTimeoutWarnSeconds: accountTimeoutWarnSeconds, - AccountTimeoutWarnMinutes: accountTimeoutWarnSeconds / 60, - AccountTimeoutEnabled: accountTimeoutEnabled, + AccountTimeoutLength: normalizedTimeoutLength, + AccountTimeoutWarnSeconds: normalizedTimeoutWarnSeconds, + AccountTimeoutWarnMinutes: normalizedTimeoutWarnSeconds / 60, + AccountTimeoutEnabled: normalizedTimeoutEnabled, AccountTimeoutWorker, sessionSync: { broadcast: broadcastSessionEvent, + destroy() { + clearInterval(leadershipInterval); + // eslint-disable-next-line no-undef + globalThis.removeEventListener("storage", handleStorageMessage); + // eslint-disable-next-line no-undef + globalThis.removeEventListener("visibilitychange", handleVisibilityChange); + // eslint-disable-next-line no-undef + globalThis.removeEventListener("focus", handleStoredTerminalSessionMessage); + document.removeEventListener("click", handleDocumentClick); + if (sessionChannel) { + sessionChannel.close(); + } + if (AccountTimeoutWorker) { + AccountTimeoutWorker.terminate(); + } + }, isLeader, + renewSession, setSessionState, clearWarningState, setRenewingState, + getRemainingSeconds, + getRequestRenewalStatus, + shouldRenewBeforeRequest, }, }; }; diff --git a/resources/js/components/Session.vue b/resources/js/components/Session.vue index 5f53629e44..d1b169b17d 100644 --- a/resources/js/components/Session.vue +++ b/resources/js/components/Session.vue @@ -1,62 +1,70 @@ -