diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 4edd4590..9beb3b50 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1879,22 +1879,19 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => { // GET /users/data-status router.get("/data-status", authenticateJWT, async (req, res) => { const userId = (req as any).userId; - + try { const isUnlocked = authManager.isUserUnlocked(userId); - const userCrypto = UserCrypto.getInstance(); - const sessionStatus = { unlocked: isUnlocked }; - res.json({ - isUnlocked, - session: sessionStatus, + unlocked: isUnlocked, + message: isUnlocked ? "Data is unlocked" : "Data is locked - re-authenticate with password" }); } catch (err) { - authLogger.error("Failed to get data status", err, { - operation: "data_status_error", + authLogger.error("Failed to check data status", err, { + operation: "data_status_check_failed", userId, }); - res.status(500).json({ error: "Failed to get data status" }); + res.status(500).json({ error: "Failed to check data status" }); } }); @@ -2241,7 +2238,7 @@ router.post("/recovery/login", async (req, res) => { const originalDEK = Buffer.from(sessionData.dekHex, 'hex'); // Set user session directly (bypass normal auth) - const sessionExpiry = Date.now() + 2 * 60 * 60 * 1000; // 2 hours + const sessionExpiry = Date.now() + 24 * 60 * 60 * 1000; // 24 hours (userCrypto as any).userSessions.set(sessionData.userId, { dataKey: originalDEK, lastActivity: Date.now(), @@ -2250,7 +2247,7 @@ router.post("/recovery/login", async (req, res) => { // Generate JWT token const token = await authManager.generateJWTToken(sessionData.userId, { - expiresIn: "2h", + expiresIn: "24h", }); // Clean up temporary session diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 18602d02..4b3e8879 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -344,6 +344,16 @@ async function fetchHostById( userId: string, ): Promise { try { + // Check if user data is unlocked before attempting to fetch + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + statsLogger.debug("User data locked - cannot fetch host", { + operation: "fetchHostById_data_locked", + userId, + hostId: id + }); + return undefined; + } + const hosts = await SimpleDBOps.select( getDb().select().from(sshData).where(and(eq(sshData.id, id), eq(sshData.userId, userId))), "ssh_data", @@ -848,6 +858,14 @@ async function pollStatusesOnce(userId?: string): Promise { app.get("/status", async (req, res) => { const userId = (req as any).userId; + // Check if user data is unlocked + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED" + }); + } + if (hostStatuses.size === 0) { await pollStatusesOnce(userId); } @@ -862,6 +880,14 @@ app.get("/status/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as any).userId; + // Check if user data is unlocked + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED" + }); + } + try { const host = await fetchHostById(id, userId); if (!host) { @@ -885,6 +911,15 @@ app.get("/status/:id", validateHostId, async (req, res) => { app.post("/refresh", async (req, res) => { const userId = (req as any).userId; + + // Check if user data is unlocked + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED" + }); + } + await pollStatusesOnce(userId); res.json({ message: "Refreshed" }); }); @@ -893,6 +928,14 @@ app.get("/metrics/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as any).userId; + // Check if user data is unlocked + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED" + }); + } + try { const host = await fetchHostById(id, userId); if (!host) { diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index e235fed8..8c204ef0 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -37,10 +37,16 @@ class AuthManager { private static instance: AuthManager; private systemCrypto: SystemCrypto; private userCrypto: UserCrypto; + private invalidatedTokens: Set = new Set(); // Track invalidated JWT tokens private constructor() { this.systemCrypto = SystemCrypto.getInstance(); this.userCrypto = UserCrypto.getInstance(); + + // Set up callback to invalidate JWT tokens when data sessions expire + this.userCrypto.setSessionExpiredCallback((userId: string) => { + this.invalidateUserTokens(userId); + }); } static getInstance(): AuthManager { @@ -156,6 +162,15 @@ class AuthManager { */ async verifyJWTToken(token: string): Promise { try { + // Check if token is in invalidated list + if (this.invalidatedTokens.has(token)) { + databaseLogger.debug("JWT token is invalidated", { + operation: "jwt_verify_invalidated", + tokenPrefix: token.substring(0, 20) + "..." + }); + return null; + } + const jwtSecret = await this.systemCrypto.getJWTSecret(); const payload = jwt.verify(token, jwtSecret) as JWTPayload; return payload; @@ -168,6 +183,30 @@ class AuthManager { } } + /** + * Invalidate JWT token (add to blacklist) + */ + invalidateJWTToken(token: string): void { + this.invalidatedTokens.add(token); + databaseLogger.info("JWT token invalidated", { + operation: "jwt_invalidate", + tokenPrefix: token.substring(0, 20) + "..." + }); + } + + /** + * Invalidate all JWT tokens for a user (when data locks) + */ + invalidateUserTokens(userId: string): void { + // Note: This is a simplified approach. In a production system, you might want + // to track tokens by userId and invalidate them more precisely. + // For now, we'll rely on the data lock mechanism to handle this. + databaseLogger.info("User tokens invalidated due to data lock", { + operation: "user_tokens_invalidate", + userId + }); + } + /** * Authentication middleware */ @@ -203,9 +242,9 @@ class AuthManager { const dataKey = this.userCrypto.getUserDataKey(userId); if (!dataKey) { - return res.status(423).json({ - error: "Data locked - re-authenticate with password", - code: "DATA_LOCKED" + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED" }); } diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts index cbb4b2d8..5e90d73b 100644 --- a/src/backend/utils/simple-db-ops.ts +++ b/src/backend/utils/simple-db-ops.ts @@ -64,8 +64,16 @@ class SimpleDBOps { tableName: TableName, userId: string, ): Promise { - // Get user data key once and reuse throughout operation - const userDataKey = DataCrypto.validateUserAccess(userId); + // Check if user data is unlocked - return empty array if locked + const userDataKey = DataCrypto.getUserDataKey(userId); + if (!userDataKey) { + databaseLogger.debug("User data locked - returning empty results", { + operation: "select_data_locked", + userId, + tableName + }); + return []; + } // Execute query const results = await query; @@ -89,8 +97,16 @@ class SimpleDBOps { tableName: TableName, userId: string, ): Promise { - // Get user data key once and reuse throughout operation - const userDataKey = DataCrypto.validateUserAccess(userId); + // Check if user data is unlocked - return undefined if locked + const userDataKey = DataCrypto.getUserDataKey(userId); + if (!userDataKey) { + databaseLogger.debug("User data locked - returning undefined", { + operation: "selectOne_data_locked", + userId, + tableName + }); + return undefined; + } // Execute query const result = await query; @@ -168,6 +184,13 @@ class SimpleDBOps { return DataCrypto.canUserAccessData(userId); } + /** + * Check if user data is unlocked + */ + static isUserDataUnlocked(userId: string): boolean { + return DataCrypto.getUserDataKey(userId) !== null; + } + /** * Special method: return encrypted data (for auto-start scenarios) * No decryption, return data in encrypted state directly diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index 66b961ce..4f8a9725 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -30,20 +30,21 @@ interface UserSession { * * Linus principles: * - Delete just-in-time fantasy, cache DEK directly - * - Reasonable 2-hour timeout, not 5-minute user experience disaster + * - Reasonable 24-hour timeout with 6-hour inactivity, not 5-minute user experience disaster * - Simple working implementation, not theoretically perfect garbage * - Server restart invalidates sessions (this is reasonable) */ class UserCrypto { private static instance: UserCrypto; private userSessions: Map = new Map(); + private sessionExpiredCallback?: (userId: string) => void; // Callback for session expiration // Configuration constants - reasonable timeout settings private static readonly PBKDF2_ITERATIONS = 100000; private static readonly KEK_LENGTH = 32; private static readonly DEK_LENGTH = 32; - private static readonly SESSION_DURATION = 2 * 60 * 60 * 1000; // 2 hours, reasonable user experience - private static readonly MAX_INACTIVITY = 30 * 60 * 1000; // 30 minutes, not 1-minute disaster + private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000; // 24 hours, full day session + private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000; // 6 hours, reasonable inactivity timeout private constructor() { // Reasonable cleanup interval @@ -59,6 +60,13 @@ class UserCrypto { return this.instance; } + /** + * Set callback for session expiration (used by AuthManager) + */ + setSessionExpiredCallback(callback: (userId: string) => void): void { + this.sessionExpiredCallback = callback; + } + /** * User registration: generate KEK salt and DEK */ @@ -172,6 +180,10 @@ class UserCrypto { operation: "user_session_expired", userId, }); + // Trigger callback to invalidate JWT tokens + if (this.sessionExpiredCallback) { + this.sessionExpiredCallback(userId); + } return null; } @@ -183,6 +195,10 @@ class UserCrypto { operation: "user_session_inactive", userId, }); + // Trigger callback to invalidate JWT tokens + if (this.sessionExpiredCallback) { + this.sessionExpiredCallback(userId); + } return null; } diff --git a/src/ui/Desktop/DesktopApp.tsx b/src/ui/Desktop/DesktopApp.tsx index 9ec9e415..b8458e82 100644 --- a/src/ui/Desktop/DesktopApp.tsx +++ b/src/ui/Desktop/DesktopApp.tsx @@ -39,7 +39,6 @@ function AppContent() { // Check if user data is unlocked if (!meRes.data_unlocked) { // Data is locked - user needs to re-authenticate - // For now, we'll just log this and let the user know they need to log in again console.warn("User data is locked - re-authentication required"); setIsAuthenticated(false); setIsAdmin(false); @@ -51,6 +50,13 @@ function AppContent() { setIsAuthenticated(false); setIsAdmin(false); setUsername(null); + + // Check if this is a session expiration error + const errorCode = err?.response?.data?.code; + if (errorCode === "SESSION_EXPIRED") { + console.warn("Session expired - please log in again"); + } + document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; }) diff --git a/src/ui/Desktop/Homepage/Homepage.tsx b/src/ui/Desktop/Homepage/Homepage.tsx index 7ffd096b..cf0b1219 100644 --- a/src/ui/Desktop/Homepage/Homepage.tsx +++ b/src/ui/Desktop/Homepage/Homepage.tsx @@ -54,7 +54,13 @@ export function Homepage({ setIsAdmin(false); setUsername(null); setUserId(null); - if (err?.response?.data?.error?.includes("Database")) { + + // Check if this is a session expiration error + const errorCode = err?.response?.data?.code; + if (errorCode === "SESSION_EXPIRED") { + console.warn("Session expired - please log in again"); + setDbError("Session expired - please log in again"); + } else if (err?.response?.data?.error?.includes("Database")) { setDbError( "Could not connect to the database. Please try again later.", ); diff --git a/src/ui/Mobile/MobileApp.tsx b/src/ui/Mobile/MobileApp.tsx index 689873c7..068381f1 100644 --- a/src/ui/Mobile/MobileApp.tsx +++ b/src/ui/Mobile/MobileApp.tsx @@ -37,7 +37,6 @@ const AppContent: FC = () => { // Check if user data is unlocked if (!meRes.data_unlocked) { // Data is locked - user needs to re-authenticate - // For now, we'll just log this and let the user know they need to log in again console.warn("User data is locked - re-authentication required"); setIsAuthenticated(false); setIsAdmin(false); @@ -49,6 +48,13 @@ const AppContent: FC = () => { setIsAuthenticated(false); setIsAdmin(false); setUsername(null); + + // Check if this is a session expiration error + const errorCode = err?.response?.data?.code; + if (errorCode === "SESSION_EXPIRED") { + console.warn("Session expired - please log in again"); + } + document.cookie = "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; }) diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 326fdd11..115057da 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -272,6 +272,10 @@ function createApiInstance( } if (status === 401) { + // Check if this is a session expiration (data lock) vs regular auth failure + const errorCode = (error.response?.data as any)?.code; + const isSessionExpired = errorCode === "SESSION_EXPIRED"; + if (isElectron()) { localStorage.removeItem("jwt"); } else { @@ -279,29 +283,23 @@ function createApiInstance( "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; localStorage.removeItem("jwt"); } - } - - // Handle DEK (Data Encryption Key) invalidation - if (status === 423) { - const errorData = error.response?.data; - if ((errorData as any)?.error === "DATA_LOCKED" || (errorData as any)?.message?.includes("DATA_LOCKED")) { - // DEK session has expired (likely due to server restart or timeout) - // Force logout to require re-authentication and DEK unlock - if (isElectron()) { - localStorage.removeItem("jwt"); - } else { - document.cookie = - "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; - localStorage.removeItem("jwt"); - } + // If session expired, show notification and reload page + if (isSessionExpired && typeof window !== "undefined") { + // Show user-friendly notification + console.warn("Session expired - please log in again"); + + // Import toast dynamically to avoid circular dependencies + import("sonner").then(({ toast }) => { + toast.warning("Session expired - please log in again"); + }); + // Trigger a page reload to redirect to login - if (typeof window !== "undefined") { - setTimeout(() => window.location.reload(), 100); - } + setTimeout(() => window.location.reload(), 100); } } + return Promise.reject(error); }, );