mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-05-06 01:21:24 +00:00
Preserve sessions on restart
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
await systemCrypto.initializeJWTSecret();
|
||||
await systemCrypto.initializeDatabaseKey();
|
||||
await systemCrypto.initializeEncryptionKey();
|
||||
await systemCrypto.initializeInternalAuthToken();
|
||||
|
||||
await AutoSSLSetup.initialize();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -58,8 +58,6 @@ async function handleLogout() {
|
||||
await logoutUser();
|
||||
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
const configuredServerUrl = (
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
|
||||
+53
-11
@@ -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
@@ -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));
|
||||
|
||||
@@ -877,8 +877,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user