Preserve sessions on restart

This commit is contained in:
Xenthys
2026-04-24 23:17:26 +02:00
parent 9a7bfc2fa3
commit 06727eeede
14 changed files with 401 additions and 59 deletions
+9 -1
View File
@@ -439,7 +439,15 @@ async function initializeCompleteDatabase(): Promise<void> {
`);
try {
sqlite.prepare("DELETE FROM sessions").run();
const result = sqlite
.prepare("DELETE FROM sessions WHERE expires_at <= ?")
.run(new Date().toISOString());
if (result.changes > 0) {
databaseLogger.info("Expired sessions cleaned up on startup", {
operation: "db_init_session_cleanup",
deletedSessions: result.changes,
});
}
} catch (e) {
databaseLogger.warn("Could not clear expired sessions on startup", {
operation: "db_init_session_cleanup_failed",
+23 -3
View File
@@ -1670,6 +1670,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
is_oidc: !!user[0].isOidc,
is_dual_auth: isDualAuth,
totp_enabled: !!user[0].totpEnabled,
data_unlocked: authManager.isUserUnlocked(userId),
});
} catch (err) {
authLogger.error("Failed to get username", err);
@@ -3529,9 +3530,14 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
* description: Failed to unlock data.
*/
router.post("/unlock-data", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const authReq = req as AuthenticatedRequest;
const userId = authReq.userId;
const { password } = req.body;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (!password) {
return res.status(400).json({ error: "Password is required" });
}
@@ -3539,6 +3545,19 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => {
try {
const unlocked = await authManager.authenticateUser(userId, password);
if (unlocked) {
const refreshedSession =
userId && authReq.sessionId
? await authManager.refreshSessionToken(userId, authReq.sessionId)
: null;
if (refreshedSession) {
res.cookie(
"jwt",
refreshedSession.token,
authManager.getSecureCookieOptions(req, refreshedSession.maxAge),
);
}
res.json({
success: true,
message: "Data unlocked successfully",
@@ -3577,9 +3596,10 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const unlocked = authManager.isUserUnlocked(userId);
res.json({
unlocked: true,
message: "Data is unlocked",
unlocked,
message: unlocked ? "Data is unlocked" : "Data is locked",
});
} catch (err) {
authLogger.error("Failed to check data status", err, {
+1
View File
@@ -98,6 +98,7 @@ import {
const systemCrypto = SystemCrypto.getInstance();
await systemCrypto.initializeJWTSecret();
await systemCrypto.initializeDatabaseKey();
await systemCrypto.initializeEncryptionKey();
await systemCrypto.initializeInternalAuthToken();
await AutoSSLSetup.initialize();
+195 -10
View File
@@ -1,4 +1,5 @@
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { UserCrypto } from "./user-crypto.js";
import { SystemCrypto } from "./system-crypto.js";
import { DataCrypto } from "./data-crypto.js";
@@ -29,10 +30,18 @@ interface JWTPayload {
userId: string;
sessionId?: string;
pendingTOTP?: boolean;
dataKeyWrap?: WrappedDataKey;
iat?: number;
exp?: number;
}
interface WrappedDataKey {
version: "v1";
iv: string;
tag: string;
data: string;
}
interface AuthenticatedRequest extends Request {
userId?: string;
sessionId?: string;
@@ -199,6 +208,108 @@ class AuthManager {
}
}
private getDataKeyAAD(userId: string, sessionId?: string): Buffer {
return Buffer.from(`${userId}:${sessionId || ""}`, "utf8");
}
private async wrapUserDataKey(
userId: string,
sessionId: string | undefined,
dataKey: Buffer,
): Promise<WrappedDataKey> {
const encryptionKey = await this.systemCrypto.getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv);
cipher.setAAD(this.getDataKeyAAD(userId, sessionId));
const encrypted = Buffer.concat([cipher.update(dataKey), cipher.final()]);
const tag = cipher.getAuthTag();
return {
version: "v1",
iv: iv.toString("base64url"),
tag: tag.toString("base64url"),
data: encrypted.toString("base64url"),
};
}
private async unwrapUserDataKey(
userId: string,
sessionId: string | undefined,
wrapped: WrappedDataKey,
): Promise<Buffer> {
if (wrapped.version !== "v1") {
throw new Error(`Unsupported wrapped data key version: ${wrapped.version}`);
}
const encryptionKey = await this.systemCrypto.getEncryptionKey();
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
encryptionKey,
Buffer.from(wrapped.iv, "base64url"),
);
decipher.setAAD(this.getDataKeyAAD(userId, sessionId));
decipher.setAuthTag(Buffer.from(wrapped.tag, "base64url"));
return Buffer.concat([
decipher.update(Buffer.from(wrapped.data, "base64url")),
decipher.final(),
]);
}
private async addWrappedDataKey(payload: JWTPayload): Promise<void> {
if (payload.pendingTOTP) {
return;
}
const dataKey = this.userCrypto.getUserDataKey(payload.userId);
if (!dataKey) {
return;
}
payload.dataKeyWrap = await this.wrapUserDataKey(
payload.userId,
payload.sessionId,
dataKey,
);
}
private async restoreDataKeyFromPayload(
payload: JWTPayload,
sessionExpiresAt?: string,
): Promise<void> {
if (!payload.dataKeyWrap || this.userCrypto.getUserDataKey(payload.userId)) {
return;
}
const expiresAt = sessionExpiresAt
? new Date(sessionExpiresAt).getTime()
: payload.exp
? payload.exp * 1000
: Date.now();
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
return;
}
try {
const dataKey = await this.unwrapUserDataKey(
payload.userId,
payload.sessionId,
payload.dataKeyWrap,
);
this.userCrypto.restoreUserDataKey(payload.userId, dataKey, expiresAt);
dataKey.fill(0);
} catch (error) {
databaseLogger.warn("Failed to restore data key from session token", {
operation: "session_data_key_restore_failed",
userId: payload.userId,
sessionId: payload.sessionId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
async generateJWTToken(
userId: string,
options: {
@@ -235,6 +346,7 @@ class AuthManager {
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
const sessionId = nanoid();
payload.sessionId = sessionId;
await this.addWrappedDataKey(payload);
const token = jwt.sign(payload, jwtSecret, {
expiresIn,
@@ -282,6 +394,7 @@ class AuthManager {
return token;
}
await this.addWrappedDataKey(payload);
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
}
@@ -328,6 +441,11 @@ class AuthManager {
});
return null;
}
await this.restoreDataKeyFromPayload(
payload,
sessionRecords[0].expiresAt,
);
} catch (dbError) {
databaseLogger.error(
"Failed to check session in database during JWT verification",
@@ -339,6 +457,8 @@ class AuthManager {
);
return null;
}
} else {
await this.restoreDataKeyFromPayload(payload);
}
return payload;
} catch (error) {
@@ -351,6 +471,59 @@ class AuthManager {
}
}
async refreshSessionToken(
userId: string,
sessionId: string,
): Promise<{ token: string; maxAge: number } | null> {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
if (sessionRecords.length === 0 || sessionRecords[0].userId !== userId) {
return null;
}
const expiresAt = new Date(sessionRecords[0].expiresAt).getTime();
const maxAge = expiresAt - Date.now();
if (!Number.isFinite(maxAge) || maxAge <= 0) {
return null;
}
const payload: JWTPayload = { userId, sessionId };
await this.addWrappedDataKey(payload);
const token = jwt.sign(payload, await this.systemCrypto.getJWTSecret(), {
expiresIn: Math.ceil(maxAge / 1000),
} as jwt.SignOptions);
await db
.update(sessions)
.set({
jwtToken: token,
lastActiveAt: new Date().toISOString(),
})
.where(eq(sessions.id, sessionId));
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after session token refresh",
saveError,
{
operation: "session_token_refresh_db_save_failed",
sessionId,
},
);
}
return { token, maxAge };
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
invalidateJWTToken(_token: string): void {
// expected - no-op, JWT tokens are stateless
@@ -574,7 +747,10 @@ class AuthManager {
const payload = await this.verifyJWTToken(token);
if (!payload) {
return res.status(401).json({ error: "Invalid token" });
return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({ error: "Invalid token" });
}
if (payload.pendingTOTP) {
@@ -598,10 +774,13 @@ class AuthManager {
sessionId: payload.sessionId,
userId: payload.userId,
});
return res.status(401).json({
error: "Session not found",
code: "SESSION_NOT_FOUND",
});
return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({
error: "Session not found",
code: "SESSION_NOT_FOUND",
});
}
const session = sessionRecords[0];
@@ -658,10 +837,13 @@ class AuthManager {
);
});
return res.status(401).json({
error: "Session has expired",
code: "SESSION_EXPIRED",
});
return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({
error: "Session has expired",
code: "SESSION_EXPIRED",
});
}
db.update(sessions)
@@ -723,7 +905,10 @@ class AuthManager {
const payload = await this.verifyJWTToken(token);
if (!payload) {
return res.status(401).json({ error: "Invalid token" });
return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({ error: "Invalid token" });
}
if (payload.pendingTOTP) {
+58
View File
@@ -7,6 +7,7 @@ class SystemCrypto {
private static instance: SystemCrypto;
private jwtSecret: string | null = null;
private databaseKey: Buffer | null = null;
private encryptionKey: Buffer | null = null;
private internalAuthToken: string | null = null;
private credentialSharingKey: Buffer | null = null;
@@ -114,6 +115,46 @@ class SystemCrypto {
return this.databaseKey!;
}
async initializeEncryptionKey(): Promise<void> {
try {
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
const envKey = process.env.ENCRYPTION_KEY;
if (envKey && envKey.length >= 64) {
this.encryptionKey = Buffer.from(envKey, "hex");
return;
}
try {
const envContent = await fs.readFile(envPath, "utf8");
const keyMatch = envContent.match(/^ENCRYPTION_KEY=(.+)$/m);
if (keyMatch && keyMatch[1] && keyMatch[1].length >= 64) {
this.encryptionKey = Buffer.from(keyMatch[1], "hex");
process.env.ENCRYPTION_KEY = keyMatch[1];
return;
}
} catch {
// expected - env file may not exist
}
await this.generateAndGuideEncryptionKey();
} catch (error) {
databaseLogger.error("Failed to initialize encryption key", error, {
operation: "encryption_key_init_failed",
dataDir: process.env.DATA_DIR || "./db/data",
});
throw new Error("Encryption key initialization failed");
}
}
async getEncryptionKey(): Promise<Buffer> {
if (!this.encryptionKey) {
await this.initializeEncryptionKey();
}
return this.encryptionKey!;
}
async initializeInternalAuthToken(): Promise<void> {
try {
const envToken = process.env.INTERNAL_AUTH_TOKEN;
@@ -230,6 +271,23 @@ class SystemCrypto {
});
}
private async generateAndGuideEncryptionKey(): Promise<void> {
const newKey = crypto.randomBytes(32);
const newKeyHex = newKey.toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
this.encryptionKey = newKey;
await this.updateEnvFile("ENCRYPTION_KEY", newKeyHex);
databaseLogger.success("Encryption key auto-generated and saved to .env", {
operation: "encryption_key_auto_generated",
instanceId,
envVarName: "ENCRYPTION_KEY",
note: "Used to wrap session data keys - no restart required",
});
}
private async generateAndGuideInternalAuthToken(): Promise<void> {
const newToken = crypto.randomBytes(32).toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
+17
View File
@@ -265,6 +265,23 @@ class UserCrypto {
return session.dataKey;
}
restoreUserDataKey(
userId: string,
dataKey: Buffer,
expiresAt: number,
): void {
const oldSession = this.userSessions.get(userId);
if (oldSession) {
oldSession.dataKey.fill(0);
}
this.userSessions.set(userId, {
dataKey: Buffer.from(dataKey),
expiresAt,
lastActivity: Date.now(),
});
}
logoutUser(userId: string): void {
const session = this.userSessions.get(userId);
if (session) {
+19 -7
View File
@@ -20,7 +20,12 @@ import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/comm
import { ServerStatusProvider } from "@/ui/contexts/ServerStatusContext";
import { Toaster } from "@/components/ui/sonner.tsx";
import { toast } from "sonner";
import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts";
import {
getUserInfo,
logoutUser,
isElectron,
isCurrentAuthInvalidationError,
} from "@/ui/main-axios.ts";
import { useTheme } from "@/components/theme-provider";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
import { useTranslation } from "react-i18next";
@@ -78,6 +83,7 @@ function AppContent({
const { theme, setTheme } = useTheme();
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
const isAuthenticatedRef = useRef(false);
const isDarkMode =
theme === "dark" ||
@@ -273,13 +279,18 @@ function AppContent({
}
})
.catch((err) => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
if (isCurrentAuthInvalidationError(err)) {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
console.warn("Session expired - please log in again");
return;
}
if (!isAuthenticatedRef.current) {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
}
})
.finally(() => {
@@ -302,6 +313,7 @@ function AppContent({
useEffect(() => {
onAuthStateChange?.(isAuthenticated);
isAuthenticatedRef.current = isAuthenticated;
}, [isAuthenticated, onAuthStateChange]);
const handleAuthSuccess = useCallback(
+5 -6
View File
@@ -17,6 +17,7 @@ import {
sendMetricsHeartbeat,
getGuacamoleDpi,
getGuacamoleTokenFromHost,
isCurrentAuthInvalidationError,
type RecentActivityItem,
} from "@/ui/main-axios.ts";
import { useSidebar } from "@/components/ui/sidebar.tsx";
@@ -127,12 +128,10 @@ export function Dashboard({
setDbError(null);
})
.catch((err) => {
setIsAdmin(false);
setUsername(null);
setUserId(null);
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
if (isCurrentAuthInvalidationError(err)) {
setIsAdmin(false);
setUsername(null);
setUserId(null);
console.warn("Session expired - please log in again");
setDbError("Session expired - please log in again");
} else {
@@ -1652,8 +1652,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsConnecting(false);
shouldNotReconnectRef.current = true;
localStorage.removeItem("jwt");
return;
}
+1 -5
View File
@@ -8,7 +8,7 @@ import {
RotateCcw,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { isElectron, logoutUser } from "@/ui/main-axios.ts";
import { logoutUser } from "@/ui/main-axios.ts";
import {
Sidebar,
@@ -50,10 +50,6 @@ async function handleLogout() {
try {
await logoutUser();
if (isElectron()) {
localStorage.removeItem("jwt");
}
window.location.reload();
} catch (error) {
console.error("Logout failed:", error);
-2
View File
@@ -58,8 +58,6 @@ async function handleLogout() {
await logoutUser();
if (isElectron()) {
localStorage.removeItem("jwt");
const configuredServerUrl = (
window as Window &
typeof globalThis & {
+53 -11
View File
@@ -182,8 +182,6 @@ function getLoggerForService(serviceName: string) {
const electronSettingsCache = new Map<string, string>();
if (isElectron()) {
localStorage.removeItem("jwt");
(async () => {
try {
const electronAPI = (window as any).electronAPI;
@@ -279,6 +277,40 @@ export function getCookie(name: string): string | undefined {
}
let userWasAuthenticated = false;
let latestAuthSuccessAt = 0;
function markUserAuthenticated(): void {
userWasAuthenticated = true;
latestAuthSuccessAt =
typeof performance !== "undefined" ? performance.now() : Date.now();
}
export function isCurrentAuthInvalidationError(error: unknown): boolean {
const authError = error as {
__staleAuthInvalidation?: boolean;
};
if (authError.__staleAuthInvalidation) {
return false;
}
const axiosError = error as AxiosError;
const responseData = axiosError.response?.data as
| Record<string, unknown>
| undefined;
const errorCode = responseData?.code;
const errorMessage = responseData?.error;
return (
axiosError.response?.status === 401 &&
(errorCode === "SESSION_EXPIRED" ||
errorCode === "SESSION_NOT_FOUND" ||
errorCode === "AUTH_REQUIRED" ||
errorMessage === "Invalid token" ||
errorMessage === "Authentication required" ||
errorMessage === "Missing authentication token")
);
}
function createApiInstance(
baseURL: string,
@@ -456,12 +488,25 @@ function createApiInstance(
errorMessage === "Missing authentication token";
if (isSessionExpired || isSessionNotFound || isInvalidToken) {
const requestStartedAt =
typeof error.config?.startTime === "number"
? error.config.startTime
: 0;
const isStaleAuthInvalidation =
latestAuthSuccessAt > 0 &&
requestStartedAt > 0 &&
requestStartedAt < latestAuthSuccessAt;
if (isStaleAuthInvalidation) {
(
error as { __staleAuthInvalidation?: boolean }
).__staleAuthInvalidation = true;
return Promise.reject(error);
}
const wasAuthenticated = userWasAuthenticated;
localStorage.removeItem("jwt");
if (isElectron()) {
electronSettingsCache.delete("jwt");
const electronAPI = (
window as unknown as {
electronAPI?: { clearSessionCookies?: () => Promise<void> };
@@ -2785,7 +2830,7 @@ export async function loginUser(
}
if (response.data.success && !response.data.requires_totp) {
userWasAuthenticated = true;
markUserAuthenticated();
}
return {
@@ -2814,8 +2859,6 @@ export async function logoutUser(): Promise<{
clearTermixSessionStorage();
if (isElectron()) {
localStorage.removeItem("jwt");
electronSettingsCache.delete("jwt");
const electronAPI = (
window as unknown as {
electronAPI?: { clearSessionCookies?: () => Promise<void> };
@@ -2835,8 +2878,6 @@ export async function logoutUser(): Promise<{
clearTermixSessionStorage();
if (isElectron()) {
localStorage.removeItem("jwt");
electronSettingsCache.delete("jwt");
const electronAPI = (
window as unknown as {
electronAPI?: { clearSessionCookies?: () => Promise<void> };
@@ -2857,6 +2898,7 @@ export async function logoutUser(): Promise<{
export async function getUserInfo(): Promise<UserInfo> {
try {
const response = await authApi.get("/users/me");
markUserAuthenticated();
return response.data;
} catch (error) {
handleApiError(error, "fetch user info");
@@ -3253,7 +3295,7 @@ export async function verifyTOTPLogin(
}
if (response.data.success) {
userWasAuthenticated = true;
markUserAuthenticated();
}
return response.data;
+20 -10
View File
@@ -14,7 +14,10 @@ import {
TabProvider,
useTabs,
} from "@/ui/mobile/navigation/tabs/TabContext.tsx";
import { getUserInfo } from "@/ui/main-axios.ts";
import {
getUserInfo,
isCurrentAuthInvalidationError,
} from "@/ui/main-axios.ts";
import { Auth } from "@/ui/mobile/authentication/Auth.tsx";
import { useTranslation } from "react-i18next";
import { Toaster } from "@/components/ui/sonner.tsx";
@@ -33,6 +36,11 @@ const AppContent: FC = () => {
const [username, setUsername] = useState<string | null>(null);
const [, setIsAdmin] = useState(false);
const [authLoading, setAuthLoading] = useState(true);
const isAuthenticatedRef = React.useRef(false);
useEffect(() => {
isAuthenticatedRef.current = isAuthenticated;
}, [isAuthenticated]);
useEffect(() => {
const checkAuth = () => {
@@ -43,7 +51,6 @@ const AppContent: FC = () => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
localStorage.removeItem("jwt");
} else {
setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin);
@@ -51,15 +58,18 @@ const AppContent: FC = () => {
}
})
.catch((err) => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
localStorage.removeItem("jwt");
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
if (isCurrentAuthInvalidationError(err)) {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
console.warn(t("errors.sessionExpired"));
return;
}
if (!isAuthenticatedRef.current) {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
}
})
.finally(() => setAuthLoading(false));
-2
View File
@@ -877,8 +877,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsConnecting(false);
shouldNotReconnectRef.current = true;
localStorage.removeItem("jwt");
return;
}