v1.8.0 #429

Merged
LukeGus merged 198 commits from dev-1.8.0 into main 2025-11-05 16:36:16 +00:00
7 changed files with 177 additions and 113 deletions
Showing only changes of commit 5c7cc078ad - Show all commits
+32 -22
View File
@@ -1012,15 +1012,31 @@ router.post("/login", async (req, res) => {
// Route: Logout user // Route: Logout user
// POST /users/logout // POST /users/logout
router.post("/logout", async (req, res) => { router.post("/logout", authenticateJWT, async (req, res) => {
try { try {
const userId = (req as AuthenticatedRequest).userId; const authReq = req as AuthenticatedRequest;
const userId = authReq.userId;
if (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", { authLogger.info("User logged out", {
operation: "user_logout", operation: "user_logout",
userId, userId,
sessionId,
}); });
} }
@@ -1036,29 +1052,35 @@ router.post("/logout", async (req, res) => {
// Route: Get current user's info using JWT // Route: Get current user's info using JWT
// GET /users/me // GET /users/me
router.get("/me", authenticateJWT, async (req: Request, res: Response) => { router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
console.log("=== /users/me CALLED ===");
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
console.log("User ID from JWT:", userId);
if (!isNonEmptyString(userId)) { if (!isNonEmptyString(userId)) {
console.log("ERROR: Invalid userId");
authLogger.warn("Invalid userId in JWT for /users/me"); authLogger.warn("Invalid userId in JWT for /users/me");
return res.status(401).json({ error: "Invalid userId" }); return res.status(401).json({ error: "Invalid userId" });
} }
try { try {
const user = await db.select().from(users).where(eq(users.id, userId)); 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) { if (!user || user.length === 0) {
console.log("ERROR: User not found in database");
authLogger.warn(`User not found for /users/me: ${userId}`); authLogger.warn(`User not found for /users/me: ${userId}`);
return res.status(401).json({ error: "User not found" }); return res.status(401).json({ error: "User not found" });
} }
const isDataUnlocked = authManager.isUserUnlocked(userId); console.log("SUCCESS: Returning user info");
res.json({ res.json({
userId: user[0].id, userId: user[0].id,
username: user[0].username, username: user[0].username,
is_admin: !!user[0].is_admin, is_admin: !!user[0].is_admin,
is_oidc: !!user[0].is_oidc, is_oidc: !!user[0].is_oidc,
totp_enabled: !!user[0].totp_enabled, totp_enabled: !!user[0].totp_enabled,
data_unlocked: isDataUnlocked,
}); });
} catch (err) { } catch (err) {
console.log("ERROR: Exception thrown:", err);
authLogger.error("Failed to get username", err); authLogger.error("Failed to get username", err);
res.status(500).json({ error: "Failed to get username" }); 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 // Logged-in user: preserve data
try { try {
const success = await authManager.resetUserPasswordWithPreservedDEK( 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" ||
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", { authLogger.success("TOTP verification successful", {
operation: "totp_verify_success", operation: "totp_verify_success",
userId: userRecord.id, userId: userRecord.id,
@@ -1848,7 +1861,6 @@ router.post("/totp/verify-login", async (req, res) => {
userId: userRecord.id, userId: userRecord.id,
is_oidc: !!userRecord.is_oidc, is_oidc: !!userRecord.is_oidc,
totp_enabled: !!userRecord.totp_enabled, totp_enabled: !!userRecord.totp_enabled,
data_unlocked: isDataUnlocked,
}; };
if (isElectron) { if (isElectron) {
@@ -2218,12 +2230,10 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
try { try {
const isUnlocked = authManager.isUserUnlocked(userId); // Data lock functionality has been removed - always return unlocked for authenticated users
res.json({ res.json({
unlocked: isUnlocked, unlocked: true,
message: isUnlocked message: "Data is unlocked",
? "Data is unlocked"
: "Data is locked - re-authenticate with password",
}); });
} catch (err) { } catch (err) {
authLogger.error("Failed to check data status", err, { authLogger.error("Failed to check data status", err, {
+141 -53
View File
@@ -45,7 +45,6 @@ class AuthManager {
private static instance: AuthManager; private static instance: AuthManager;
private systemCrypto: SystemCrypto; private systemCrypto: SystemCrypto;
private userCrypto: UserCrypto; private userCrypto: UserCrypto;
private invalidatedTokens: Set<string> = new Set();
private constructor() { private constructor() {
this.systemCrypto = SystemCrypto.getInstance(); this.systemCrypto = SystemCrypto.getInstance();
@@ -54,6 +53,22 @@ class AuthManager {
this.userCrypto.setSessionExpiredCallback((userId: string) => { this.userCrypto.setSessionExpiredCallback((userId: string) => {
this.invalidateUserTokens(userId); 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 { static getInstance(): AuthManager {
@@ -237,48 +252,81 @@ class AuthManager {
async verifyJWTToken(token: string): Promise<JWTPayload | null> { async verifyJWTToken(token: string): Promise<JWTPayload | null> {
try { try {
if (this.invalidatedTokens.has(token)) {
return null;
}
const jwtSecret = await this.systemCrypto.getJWTSecret(); const jwtSecret = await this.systemCrypto.getJWTSecret();
const payload = jwt.verify(token, jwtSecret) as JWTPayload; 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; return payload;
} catch (error) { } catch (error) {
databaseLogger.warn("JWT verification failed", { databaseLogger.warn("JWT verification failed", {
operation: "jwt_verify_failed", operation: "jwt_verify_failed",
error: error instanceof Error ? error.message : "Unknown error", error: error instanceof Error ? error.message : "Unknown error",
errorName: error instanceof Error ? error.name : "Unknown",
}); });
return null; return null;
} }
} }
invalidateJWTToken(token: string): void { 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 { 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", operation: "user_tokens_invalidate",
userId, userId,
}); });
// Session cleanup will happen through revokeAllUserSessions if needed
} }
async revokeSession(sessionId: string): Promise<boolean> { async revokeSession(sessionId: string): Promise<boolean> {
try { try {
// Get the session to blacklist the token // Delete the session from database
const sessionRecords = await db // The JWT will be invalidated because verifyJWTToken checks for session existence
.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
await db.delete(sessions).where(eq(sessions.id, sessionId)); await db.delete(sessions).where(eq(sessions.id, sessionId));
databaseLogger.info("Session deleted", { databaseLogger.info("Session deleted", {
@@ -301,19 +349,18 @@ class AuthManager {
exceptSessionId?: string, exceptSessionId?: string,
): Promise<number> { ): Promise<number> {
try { try {
// Get all user sessions to blacklist tokens // Get session count before deletion
let query = db.select().from(sessions).where(eq(sessions.userId, userId)); 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) // Delete sessions from database
for (const session of userSessions) { // JWTs will be invalidated because verifyJWTToken checks for session existence
if (!exceptSessionId || session.id !== exceptSessionId) {
this.invalidatedTokens.add(session.jwtToken);
}
}
// Delete sessions instead of marking as revoked
if (exceptSessionId) { if (exceptSessionId) {
await db await db
.delete(sessions) .delete(sessions)
@@ -327,10 +374,6 @@ class AuthManager {
await db.delete(sessions).where(eq(sessions.userId, userId)); await db.delete(sessions).where(eq(sessions.userId, userId));
} }
const deletedCount = userSessions.filter(
(s) => !exceptSessionId || s.id !== exceptSessionId,
).length;
databaseLogger.info("User sessions deleted", { databaseLogger.info("User sessions deleted", {
operation: "user_sessions_delete", operation: "user_sessions_delete",
userId, userId,
@@ -350,30 +393,28 @@ class AuthManager {
async cleanupExpiredSessions(): Promise<number> { async cleanupExpiredSessions(): Promise<number> {
try { try {
// Get expired sessions to blacklist their tokens // Get expired sessions count
const expiredSessions = await db const expiredSessions = await db
.select() .select()
.from(sessions) .from(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`); .where(sql`${sessions.expiresAt} < datetime('now')`);
// Add expired tokens to blacklist const expiredCount = expiredSessions.length;
for (const session of expiredSessions) {
this.invalidatedTokens.add(session.jwtToken);
}
// Delete expired sessions // Delete expired sessions
// JWTs will be invalidated because verifyJWTToken checks for session existence
await db await db
.delete(sessions) .delete(sessions)
.where(sql`${sessions.expiresAt} < datetime('now')`); .where(sql`${sessions.expiresAt} < datetime('now')`);
if (expiredSessions.length > 0) { if (expiredCount > 0) {
databaseLogger.info("Expired sessions cleaned up", { databaseLogger.info("Expired sessions cleaned up", {
operation: "sessions_cleanup", operation: "sessions_cleanup",
count: expiredSessions.length, count: expiredCount,
}); });
} }
return expiredSessions.length; return expiredCount;
} catch (error) { } catch (error) {
databaseLogger.error("Failed to cleanup expired sessions", error, { databaseLogger.error("Failed to cleanup expired sessions", error, {
operation: "sessions_cleanup_failed", operation: "sessions_cleanup_failed",
@@ -465,8 +506,20 @@ class AuthManager {
// Session exists, no need to check isRevoked since we delete sessions instead // Session exists, no need to check isRevoked since we delete sessions instead
// Check if session has expired // Check if session has expired by comparing timestamps
if (new Date(session.expiresAt) < new Date()) { 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({ return res.status(401).json({
error: "Session has expired", error: "Session has expired",
code: "SESSION_EXPIRED", code: "SESSION_EXPIRED",
@@ -508,15 +561,14 @@ class AuthManager {
return res.status(401).json({ error: "Authentication required" }); 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); const dataKey = this.userCrypto.getUserDataKey(userId);
if (!dataKey) { authReq.dataKey = dataKey || undefined;
return res.status(401).json({
error: "Session expired - please log in again", // Note: Data key will be null after backend restart until user performs
code: "SESSION_EXPIRED", // an operation that requires decryption. This is expected behavior.
}); // Individual routes that need encryption should check dataKey explicitly.
}
authReq.dataKey = dataKey;
next(); next();
}; };
} }
@@ -580,8 +632,44 @@ class AuthManager {
}; };
} }
logoutUser(userId: string): void { async logoutUser(userId: string, sessionId?: string): Promise<void> {
this.userCrypto.logoutUser(userId); 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 { getUserDataKey(userId: string): Buffer | null {
-12
View File
@@ -91,18 +91,6 @@ export function Auth({
setInternalLoggedIn(loggedIn); setInternalLoggedIn(loggedIn);
}, [loggedIn]); }, [loggedIn]);
useEffect(() => {
const clearJWTOnLoad = async () => {
try {
await logoutUser();
} catch {
// Ignore logout errors on initial load
}
};
clearJWTOnLoad();
}, []);
useEffect(() => { useEffect(() => {
getRegistrationAllowed().then((res) => { getRegistrationAllowed().then((res) => {
setRegistrationAllowed(res.allowed); setRegistrationAllowed(res.allowed);
-7
View File
@@ -34,13 +34,6 @@ function AppContent() {
setIsAuthenticated(true); setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null); 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) => { .catch((err) => {
setIsAuthenticated(false); setIsAuthenticated(false);
-12
View File
@@ -133,18 +133,6 @@ export function Auth({
setInternalLoggedIn(loggedIn); setInternalLoggedIn(loggedIn);
}, [loggedIn]); }, [loggedIn]);
useEffect(() => {
const clearJWTOnLoad = async () => {
try {
await logoutUser();
} catch (error) {
console.log("JWT cleanup on HomepageAuth load:", error);
}
};
clearJWTOnLoad();
}, []);
useEffect(() => { useEffect(() => {
getRegistrationAllowed().then((res) => { getRegistrationAllowed().then((res) => {
setRegistrationAllowed(res.allowed); setRegistrationAllowed(res.allowed);
-7
View File
@@ -31,13 +31,6 @@ const AppContent: FC = () => {
setIsAuthenticated(true); setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null); 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) => { .catch((err) => {
setIsAuthenticated(false); setIsAuthenticated(false);
+4
View File
@@ -310,6 +310,10 @@ function createApiInstance(
if (isSessionExpired && typeof window !== "undefined") { if (isSessionExpired && typeof window !== "undefined") {
console.warn("Session expired - please log in again"); 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 }) => { import("sonner").then(({ toast }) => {
toast.warning("Session expired - please log in again"); toast.warning("Session expired - please log in again");
}); });