diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 6b25ebbe..a30adacd 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1012,15 +1012,31 @@ router.post("/login", async (req, res) => { // Route: Logout user // POST /users/logout -router.post("/logout", async (req, res) => { +router.post("/logout", authenticateJWT, async (req, res) => { try { - const userId = (req as AuthenticatedRequest).userId; + const authReq = req as AuthenticatedRequest; + const userId = authReq.userId; if (userId) { - authManager.logoutUser(userId); + // Get sessionId from JWT if available + const token = + req.cookies?.jwt || req.headers["authorization"]?.split(" ")[1]; + let sessionId: string | undefined; + + if (token) { + try { + const payload = await authManager.verifyJWTToken(token); + sessionId = payload?.sessionId; + } catch (error) { + // Ignore token verification errors during logout + } + } + + await authManager.logoutUser(userId, sessionId); authLogger.info("User logged out", { operation: "user_logout", userId, + sessionId, }); } @@ -1036,29 +1052,35 @@ router.post("/logout", async (req, res) => { // Route: Get current user's info using JWT // GET /users/me router.get("/me", authenticateJWT, async (req: Request, res: Response) => { + console.log("=== /users/me CALLED ==="); const userId = (req as AuthenticatedRequest).userId; + console.log("User ID from JWT:", userId); + if (!isNonEmptyString(userId)) { + console.log("ERROR: Invalid userId"); authLogger.warn("Invalid userId in JWT for /users/me"); return res.status(401).json({ error: "Invalid userId" }); } try { const user = await db.select().from(users).where(eq(users.id, userId)); + console.log("User found:", user.length > 0 ? "YES" : "NO"); + if (!user || user.length === 0) { + console.log("ERROR: User not found in database"); authLogger.warn(`User not found for /users/me: ${userId}`); return res.status(401).json({ error: "User not found" }); } - const isDataUnlocked = authManager.isUserUnlocked(userId); - + console.log("SUCCESS: Returning user info"); res.json({ userId: user[0].id, username: user[0].username, is_admin: !!user[0].is_admin, is_oidc: !!user[0].is_oidc, totp_enabled: !!user[0].totp_enabled, - data_unlocked: isDataUnlocked, }); } catch (err) { + console.log("ERROR: Exception thrown:", err); authLogger.error("Failed to get username", err); res.status(500).json({ error: "Failed to get username" }); } @@ -1429,7 +1451,7 @@ router.post("/complete-reset", async (req, res) => { } } - if (userIdFromJwt === userId && authManager.isUserUnlocked(userId)) { + if (userIdFromJwt === userId) { // Logged-in user: preserve data try { const success = await authManager.resetUserPasswordWithPreservedDEK( @@ -1825,15 +1847,6 @@ router.post("/totp/verify-login", async (req, res) => { req.headers["x-electron-app"] === "true" || req.headers["X-Electron-App"] === "true"; - const isDataUnlocked = authManager.isUserUnlocked(userRecord.id); - - if (!isDataUnlocked) { - return res.status(401).json({ - error: "Session expired - please log in again", - code: "SESSION_EXPIRED", - }); - } - authLogger.success("TOTP verification successful", { operation: "totp_verify_success", userId: userRecord.id, @@ -1848,7 +1861,6 @@ router.post("/totp/verify-login", async (req, res) => { userId: userRecord.id, is_oidc: !!userRecord.is_oidc, totp_enabled: !!userRecord.totp_enabled, - data_unlocked: isDataUnlocked, }; if (isElectron) { @@ -2218,12 +2230,10 @@ router.get("/data-status", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { - const isUnlocked = authManager.isUserUnlocked(userId); + // Data lock functionality has been removed - always return unlocked for authenticated users res.json({ - unlocked: isUnlocked, - message: isUnlocked - ? "Data is unlocked" - : "Data is locked - re-authenticate with password", + unlocked: true, + message: "Data is unlocked", }); } catch (err) { authLogger.error("Failed to check data status", err, { diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 62babdb7..f701ce3a 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -45,7 +45,6 @@ class AuthManager { private static instance: AuthManager; private systemCrypto: SystemCrypto; private userCrypto: UserCrypto; - private invalidatedTokens: Set = new Set(); private constructor() { this.systemCrypto = SystemCrypto.getInstance(); @@ -54,6 +53,22 @@ class AuthManager { this.userCrypto.setSessionExpiredCallback((userId: string) => { this.invalidateUserTokens(userId); }); + + // Run session cleanup every 5 minutes + setInterval( + () => { + this.cleanupExpiredSessions().catch((error) => { + databaseLogger.error( + "Failed to run periodic session cleanup", + error, + { + operation: "session_cleanup_periodic", + }, + ); + }); + }, + 5 * 60 * 1000, + ); } static getInstance(): AuthManager { @@ -237,48 +252,81 @@ class AuthManager { async verifyJWTToken(token: string): Promise { try { - if (this.invalidatedTokens.has(token)) { - return null; - } - const jwtSecret = await this.systemCrypto.getJWTSecret(); const payload = jwt.verify(token, jwtSecret) as JWTPayload; + + // For tokens with sessionId, verify the session exists in database + // This ensures revoked sessions are rejected even after backend restart + if (payload.sessionId) { + try { + const sessionRecords = await db + .select() + .from(sessions) + .where(eq(sessions.id, payload.sessionId)) + .limit(1); + + if (sessionRecords.length === 0) { + databaseLogger.warn( + "JWT token has no matching session in database", + { + operation: "jwt_verify_failed", + reason: "session_not_found", + sessionId: payload.sessionId, + }, + ); + return null; + } + } catch (dbError) { + databaseLogger.error( + "Failed to check session in database during JWT verification", + dbError, + { + operation: "jwt_verify_session_check_failed", + sessionId: payload.sessionId, + }, + ); + // Continue anyway - database errors shouldn't block valid JWTs + } + } + + databaseLogger.info("JWT verification successful", { + operation: "jwt_verify_success", + userId: payload.userId, + sessionId: payload.sessionId, + }); return payload; } catch (error) { databaseLogger.warn("JWT verification failed", { operation: "jwt_verify_failed", error: error instanceof Error ? error.message : "Unknown error", + errorName: error instanceof Error ? error.name : "Unknown", }); return null; } } invalidateJWTToken(token: string): void { - this.invalidatedTokens.add(token); + // No-op: Token invalidation is now handled through database session deletion + databaseLogger.info( + "Token invalidation requested (handled via session deletion)", + { + operation: "token_invalidate", + }, + ); } invalidateUserTokens(userId: string): void { - databaseLogger.info("User tokens invalidated due to data lock", { + databaseLogger.info("User tokens invalidation requested due to data lock", { operation: "user_tokens_invalidate", userId, }); + // Session cleanup will happen through revokeAllUserSessions if needed } async revokeSession(sessionId: string): Promise { try { - // Get the session to blacklist the token - const sessionRecords = await db - .select() - .from(sessions) - .where(eq(sessions.id, sessionId)) - .limit(1); - - if (sessionRecords.length > 0) { - const session = sessionRecords[0]; - this.invalidatedTokens.add(session.jwtToken); - } - - // Delete the session instead of marking as revoked + // Delete the session from database + // The JWT will be invalidated because verifyJWTToken checks for session existence await db.delete(sessions).where(eq(sessions.id, sessionId)); databaseLogger.info("Session deleted", { @@ -301,19 +349,18 @@ class AuthManager { exceptSessionId?: string, ): Promise { try { - // Get all user sessions to blacklist tokens - let query = db.select().from(sessions).where(eq(sessions.userId, userId)); + // Get session count before deletion + const userSessions = await db + .select() + .from(sessions) + .where(eq(sessions.userId, userId)); - const userSessions = await query; + const deletedCount = userSessions.filter( + (s) => !exceptSessionId || s.id !== exceptSessionId, + ).length; - // Add all tokens to blacklist (except the excepted one) - for (const session of userSessions) { - if (!exceptSessionId || session.id !== exceptSessionId) { - this.invalidatedTokens.add(session.jwtToken); - } - } - - // Delete sessions instead of marking as revoked + // Delete sessions from database + // JWTs will be invalidated because verifyJWTToken checks for session existence if (exceptSessionId) { await db .delete(sessions) @@ -327,10 +374,6 @@ class AuthManager { await db.delete(sessions).where(eq(sessions.userId, userId)); } - const deletedCount = userSessions.filter( - (s) => !exceptSessionId || s.id !== exceptSessionId, - ).length; - databaseLogger.info("User sessions deleted", { operation: "user_sessions_delete", userId, @@ -350,30 +393,28 @@ class AuthManager { async cleanupExpiredSessions(): Promise { try { - // Get expired sessions to blacklist their tokens + // Get expired sessions count const expiredSessions = await db .select() .from(sessions) .where(sql`${sessions.expiresAt} < datetime('now')`); - // Add expired tokens to blacklist - for (const session of expiredSessions) { - this.invalidatedTokens.add(session.jwtToken); - } + const expiredCount = expiredSessions.length; // Delete expired sessions + // JWTs will be invalidated because verifyJWTToken checks for session existence await db .delete(sessions) .where(sql`${sessions.expiresAt} < datetime('now')`); - if (expiredSessions.length > 0) { + if (expiredCount > 0) { databaseLogger.info("Expired sessions cleaned up", { operation: "sessions_cleanup", - count: expiredSessions.length, + count: expiredCount, }); } - return expiredSessions.length; + return expiredCount; } catch (error) { databaseLogger.error("Failed to cleanup expired sessions", error, { operation: "sessions_cleanup_failed", @@ -465,8 +506,20 @@ class AuthManager { // Session exists, no need to check isRevoked since we delete sessions instead - // Check if session has expired - if (new Date(session.expiresAt) < new Date()) { + // Check if session has expired by comparing timestamps + const sessionExpiryTime = new Date(session.expiresAt).getTime(); + const currentTime = Date.now(); + const isExpired = sessionExpiryTime < currentTime; + + if (isExpired) { + databaseLogger.warn("Session has expired", { + operation: "session_expired", + sessionId: payload.sessionId, + expiresAt: session.expiresAt, + expiryTime: sessionExpiryTime, + currentTime: currentTime, + difference: currentTime - sessionExpiryTime, + }); return res.status(401).json({ error: "Session has expired", code: "SESSION_EXPIRED", @@ -508,15 +561,14 @@ class AuthManager { return res.status(401).json({ error: "Authentication required" }); } + // Try to get data key if available (may be null after restart) const dataKey = this.userCrypto.getUserDataKey(userId); - if (!dataKey) { - return res.status(401).json({ - error: "Session expired - please log in again", - code: "SESSION_EXPIRED", - }); - } + authReq.dataKey = dataKey || undefined; + + // Note: Data key will be null after backend restart until user performs + // an operation that requires decryption. This is expected behavior. + // Individual routes that need encryption should check dataKey explicitly. - authReq.dataKey = dataKey; next(); }; } @@ -580,8 +632,44 @@ class AuthManager { }; } - logoutUser(userId: string): void { + async logoutUser(userId: string, sessionId?: string): Promise { this.userCrypto.logoutUser(userId); + + // Delete the specific session from database if sessionId provided + if (sessionId) { + try { + await db.delete(sessions).where(eq(sessions.id, sessionId)); + databaseLogger.info("Session deleted on logout", { + operation: "session_delete_logout", + userId, + sessionId, + }); + } catch (error) { + databaseLogger.error("Failed to delete session on logout", error, { + operation: "session_delete_logout_failed", + userId, + sessionId, + }); + } + } else { + // If no sessionId, delete all sessions for this user + try { + await db.delete(sessions).where(eq(sessions.userId, userId)); + databaseLogger.info("All user sessions deleted on logout", { + operation: "sessions_delete_logout", + userId, + }); + } catch (error) { + databaseLogger.error( + "Failed to delete user sessions on logout", + error, + { + operation: "sessions_delete_logout_failed", + userId, + }, + ); + } + } } getUserDataKey(userId: string): Buffer | null { diff --git a/src/ui/Desktop/Authentication/Auth.tsx b/src/ui/Desktop/Authentication/Auth.tsx index bd4dc06a..49aa78d3 100644 --- a/src/ui/Desktop/Authentication/Auth.tsx +++ b/src/ui/Desktop/Authentication/Auth.tsx @@ -91,18 +91,6 @@ export function Auth({ setInternalLoggedIn(loggedIn); }, [loggedIn]); - useEffect(() => { - const clearJWTOnLoad = async () => { - try { - await logoutUser(); - } catch { - // Ignore logout errors on initial load - } - }; - - clearJWTOnLoad(); - }, []); - useEffect(() => { getRegistrationAllowed().then((res) => { setRegistrationAllowed(res.allowed); diff --git a/src/ui/Desktop/DesktopApp.tsx b/src/ui/Desktop/DesktopApp.tsx index 0dae9584..db2625cb 100644 --- a/src/ui/Desktop/DesktopApp.tsx +++ b/src/ui/Desktop/DesktopApp.tsx @@ -34,13 +34,6 @@ function AppContent() { setIsAuthenticated(true); setIsAdmin(!!meRes.is_admin); setUsername(meRes.username || null); - - if (!meRes.data_unlocked) { - console.warn("User data is locked - re-authentication required"); - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - } }) .catch((err) => { setIsAuthenticated(false); diff --git a/src/ui/Mobile/Authentication/Auth.tsx b/src/ui/Mobile/Authentication/Auth.tsx index 8a212f65..afe4b806 100644 --- a/src/ui/Mobile/Authentication/Auth.tsx +++ b/src/ui/Mobile/Authentication/Auth.tsx @@ -133,18 +133,6 @@ export function Auth({ setInternalLoggedIn(loggedIn); }, [loggedIn]); - useEffect(() => { - const clearJWTOnLoad = async () => { - try { - await logoutUser(); - } catch (error) { - console.log("JWT cleanup on HomepageAuth load:", error); - } - }; - - clearJWTOnLoad(); - }, []); - useEffect(() => { getRegistrationAllowed().then((res) => { setRegistrationAllowed(res.allowed); diff --git a/src/ui/Mobile/MobileApp.tsx b/src/ui/Mobile/MobileApp.tsx index 2d687e2d..553e2f65 100644 --- a/src/ui/Mobile/MobileApp.tsx +++ b/src/ui/Mobile/MobileApp.tsx @@ -31,13 +31,6 @@ const AppContent: FC = () => { setIsAuthenticated(true); setIsAdmin(!!meRes.is_admin); setUsername(meRes.username || null); - - if (!meRes.data_unlocked) { - console.warn("User data is locked - re-authentication required"); - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - } }) .catch((err) => { setIsAuthenticated(false); diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 78b5a75b..23d6023b 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -310,6 +310,10 @@ function createApiInstance( if (isSessionExpired && typeof window !== "undefined") { console.warn("Session expired - please log in again"); + // Clear the JWT cookie to prevent reload loop + document.cookie = + "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + import("sonner").then(({ toast }) => { toast.warning("Session expired - please log in again"); });