diff --git a/.github/workflows/dev-build.yaml b/.github/workflows/dev-build.yaml index 6189f8663..09d167c33 100644 --- a/.github/workflows/dev-build.yaml +++ b/.github/workflows/dev-build.yaml @@ -6,7 +6,7 @@ concurrency: on: push: - branches: ['migrate-to-bcryptjs'] # put your current branch to create a build. Core team only. + branches: ['bug-user-session-stale'] # put your current branch to create a build. Core team only. paths-ignore: - '**.md' - 'cloud-deployments/*' diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx index 672241f96..9c8310b47 100644 --- a/frontend/src/AuthContext.jsx +++ b/frontend/src/AuthContext.jsx @@ -1,20 +1,31 @@ -import React, { useState, createContext } from "react"; +import React, { useState, createContext, useEffect } from "react"; import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER, USER_PROMPT_INPUT_MAP, } from "@/utils/constants"; +import System from "./models/system"; +import { useNavigate } from "react-router-dom"; +import { safeJsonParse } from "@/utils/request"; export const AuthContext = createContext(null); export function AuthProvider(props) { const localUser = localStorage.getItem(AUTH_USER); const localAuthToken = localStorage.getItem(AUTH_TOKEN); const [store, setStore] = useState({ - user: localUser ? JSON.parse(localUser) : null, + user: localUser ? safeJsonParse(localUser, null) : null, authToken: localAuthToken ? localAuthToken : null, }); + const navigate = useNavigate(); + + /* NOTE: + * 1. There's no reason for these helper functions to be stateful. They could + * just be regular funcs or methods on a basic object. + * 2. These actions are not being invoked anywhere in the + * codebase, dead code. + */ const [actions] = useState({ updateUser: (user, authToken = "") => { localStorage.setItem(AUTH_USER, JSON.stringify(user)); @@ -30,6 +41,37 @@ export function AuthProvider(props) { }, }); + /* + * On initial mount and whenever the token changes, fetch a new user object + * If the user is suspended, (success === false and data === null) logout the user and redirect to the login page + * If success is true and data is not null, update the user object in the store (multi-user mode only) + * If success is true and data is null, do nothing (single-user mode only) with or without password protection + */ + useEffect(() => { + async function refreshUser() { + const { success, user: refreshedUser } = await System.refreshUser(); + if (success && refreshedUser === null) return; + + if (!success) { + localStorage.removeItem(AUTH_USER); + localStorage.removeItem(AUTH_TOKEN); + localStorage.removeItem(AUTH_TIMESTAMP); + localStorage.removeItem(USER_PROMPT_INPUT_MAP); + setStore({ user: null, authToken: null }); + navigate("/login"); + return; + } + + localStorage.setItem(AUTH_USER, JSON.stringify(refreshedUser)); + setStore((prev) => ({ + ...prev, + user: refreshedUser, + })); + } + if (store.authToken) refreshUser(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [store.authToken]); + return ( {props.children} diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index c0c14f98c..e58764b8e 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -83,6 +83,22 @@ const System = { return { valid: false, message: e.message }; }); }, + /** + * Refreshes the user object from the session. + * @returns {Promise<{success: boolean, user: Object | null, message: string | null}>} + */ + refreshUser: () => { + return fetch(`${API_BASE}/system/refresh-user`, { + headers: baseHeaders(), + }) + .then((res) => { + if (!res.ok) throw new Error("Could not refresh user."); + return res.json(); + }) + .catch((e) => { + return { success: false, user: null, message: e.message }; + }); + }, recoverAccount: async function (username, recoveryCodes) { return await fetch(`${API_BASE}/system/recover-account`, { method: "POST", diff --git a/server/endpoints/system.js b/server/endpoints/system.js index 55fbcc418..868b635db 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -114,6 +114,52 @@ function systemEndpoints(app) { } ); + /** + * Refreshes the user object from the session from a provided token. + * This does not refresh the token itself - if that is expired or invalid, the user will be logged out. + * This simply keeps the user object in sync with the database over the course of the session. + * @returns {Promise<{success: boolean, user: Object | null, message: string | null}>} + */ + app.get( + "/system/refresh-user", + [validatedRequest], + async (request, response) => { + try { + if (!multiUserMode(response)) + return response + .status(200) + .json({ success: true, user: null, message: null }); + + const user = await userFromSession(request, response); + if (!user) + return response.status(200).json({ + success: false, + user: null, + message: "Session expired or invalid.", + }); + + if (user.suspended) + return response.status(200).json({ + success: false, + user: null, + message: "User is suspended.", + }); + + return response.status(200).json({ + success: true, + user: User.filterFields(user), + message: null, + }); + } catch (e) { + return response.status(500).json({ + success: false, + user: null, + message: e.message, + }); + } + } + ); + app.post("/request-token", async (request, response) => { try { const bcrypt = require("bcryptjs");