Add session lock notifications and change timeouts

This commit is contained in:
LukeGus
2025-09-26 23:05:33 -05:00
parent 5afe225470
commit 2cd1cb64a3
9 changed files with 176 additions and 42 deletions

View File

@@ -1882,19 +1882,16 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
try { try {
const isUnlocked = authManager.isUserUnlocked(userId); const isUnlocked = authManager.isUserUnlocked(userId);
const userCrypto = UserCrypto.getInstance();
const sessionStatus = { unlocked: isUnlocked };
res.json({ res.json({
isUnlocked, unlocked: isUnlocked,
session: sessionStatus, message: isUnlocked ? "Data is unlocked" : "Data is locked - re-authenticate with password"
}); });
} catch (err) { } catch (err) {
authLogger.error("Failed to get data status", err, { authLogger.error("Failed to check data status", err, {
operation: "data_status_error", operation: "data_status_check_failed",
userId, 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'); const originalDEK = Buffer.from(sessionData.dekHex, 'hex');
// Set user session directly (bypass normal auth) // 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, { (userCrypto as any).userSessions.set(sessionData.userId, {
dataKey: originalDEK, dataKey: originalDEK,
lastActivity: Date.now(), lastActivity: Date.now(),
@@ -2250,7 +2247,7 @@ router.post("/recovery/login", async (req, res) => {
// Generate JWT token // Generate JWT token
const token = await authManager.generateJWTToken(sessionData.userId, { const token = await authManager.generateJWTToken(sessionData.userId, {
expiresIn: "2h", expiresIn: "24h",
}); });
// Clean up temporary session // Clean up temporary session

View File

@@ -344,6 +344,16 @@ async function fetchHostById(
userId: string, userId: string,
): Promise<SSHHostWithCredentials | undefined> { ): Promise<SSHHostWithCredentials | undefined> {
try { 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( const hosts = await SimpleDBOps.select(
getDb().select().from(sshData).where(and(eq(sshData.id, id), eq(sshData.userId, userId))), getDb().select().from(sshData).where(and(eq(sshData.id, id), eq(sshData.userId, userId))),
"ssh_data", "ssh_data",
@@ -848,6 +858,14 @@ async function pollStatusesOnce(userId?: string): Promise<void> {
app.get("/status", async (req, res) => { app.get("/status", async (req, res) => {
const userId = (req as any).userId; 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) { if (hostStatuses.size === 0) {
await pollStatusesOnce(userId); await pollStatusesOnce(userId);
} }
@@ -862,6 +880,14 @@ app.get("/status/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const userId = (req as any).userId; 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 { try {
const host = await fetchHostById(id, userId); const host = await fetchHostById(id, userId);
if (!host) { if (!host) {
@@ -885,6 +911,15 @@ app.get("/status/:id", validateHostId, async (req, res) => {
app.post("/refresh", async (req, res) => { app.post("/refresh", async (req, res) => {
const userId = (req as any).userId; 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); await pollStatusesOnce(userId);
res.json({ message: "Refreshed" }); res.json({ message: "Refreshed" });
}); });
@@ -893,6 +928,14 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const userId = (req as any).userId; 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 { try {
const host = await fetchHostById(id, userId); const host = await fetchHostById(id, userId);
if (!host) { if (!host) {

View File

@@ -37,10 +37,16 @@ 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(); // Track invalidated JWT tokens
private constructor() { private constructor() {
this.systemCrypto = SystemCrypto.getInstance(); this.systemCrypto = SystemCrypto.getInstance();
this.userCrypto = UserCrypto.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 { static getInstance(): AuthManager {
@@ -156,6 +162,15 @@ class AuthManager {
*/ */
async verifyJWTToken(token: string): Promise<JWTPayload | null> { async verifyJWTToken(token: string): Promise<JWTPayload | null> {
try { 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 jwtSecret = await this.systemCrypto.getJWTSecret();
const payload = jwt.verify(token, jwtSecret) as JWTPayload; const payload = jwt.verify(token, jwtSecret) as JWTPayload;
return payload; 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 * Authentication middleware
*/ */
@@ -203,9 +242,9 @@ class AuthManager {
const dataKey = this.userCrypto.getUserDataKey(userId); const dataKey = this.userCrypto.getUserDataKey(userId);
if (!dataKey) { if (!dataKey) {
return res.status(423).json({ return res.status(401).json({
error: "Data locked - re-authenticate with password", error: "Session expired - please log in again",
code: "DATA_LOCKED" code: "SESSION_EXPIRED"
}); });
} }

View File

@@ -64,8 +64,16 @@ class SimpleDBOps {
tableName: TableName, tableName: TableName,
userId: string, userId: string,
): Promise<T[]> { ): Promise<T[]> {
// Get user data key once and reuse throughout operation // Check if user data is unlocked - return empty array if locked
const userDataKey = DataCrypto.validateUserAccess(userId); const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
databaseLogger.debug("User data locked - returning empty results", {
operation: "select_data_locked",
userId,
tableName
});
return [];
}
// Execute query // Execute query
const results = await query; const results = await query;
@@ -89,8 +97,16 @@ class SimpleDBOps {
tableName: TableName, tableName: TableName,
userId: string, userId: string,
): Promise<T | undefined> { ): Promise<T | undefined> {
// Get user data key once and reuse throughout operation // Check if user data is unlocked - return undefined if locked
const userDataKey = DataCrypto.validateUserAccess(userId); const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
databaseLogger.debug("User data locked - returning undefined", {
operation: "selectOne_data_locked",
userId,
tableName
});
return undefined;
}
// Execute query // Execute query
const result = await query; const result = await query;
@@ -168,6 +184,13 @@ class SimpleDBOps {
return DataCrypto.canUserAccessData(userId); 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) * Special method: return encrypted data (for auto-start scenarios)
* No decryption, return data in encrypted state directly * No decryption, return data in encrypted state directly

View File

@@ -30,20 +30,21 @@ interface UserSession {
* *
* Linus principles: * Linus principles:
* - Delete just-in-time fantasy, cache DEK directly * - 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 * - Simple working implementation, not theoretically perfect garbage
* - Server restart invalidates sessions (this is reasonable) * - Server restart invalidates sessions (this is reasonable)
*/ */
class UserCrypto { class UserCrypto {
private static instance: UserCrypto; private static instance: UserCrypto;
private userSessions: Map<string, UserSession> = new Map(); private userSessions: Map<string, UserSession> = new Map();
private sessionExpiredCallback?: (userId: string) => void; // Callback for session expiration
// Configuration constants - reasonable timeout settings // Configuration constants - reasonable timeout settings
private static readonly PBKDF2_ITERATIONS = 100000; private static readonly PBKDF2_ITERATIONS = 100000;
private static readonly KEK_LENGTH = 32; private static readonly KEK_LENGTH = 32;
private static readonly DEK_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 SESSION_DURATION = 24 * 60 * 60 * 1000; // 24 hours, full day session
private static readonly MAX_INACTIVITY = 30 * 60 * 1000; // 30 minutes, not 1-minute disaster private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000; // 6 hours, reasonable inactivity timeout
private constructor() { private constructor() {
// Reasonable cleanup interval // Reasonable cleanup interval
@@ -59,6 +60,13 @@ class UserCrypto {
return this.instance; 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 * User registration: generate KEK salt and DEK
*/ */
@@ -172,6 +180,10 @@ class UserCrypto {
operation: "user_session_expired", operation: "user_session_expired",
userId, userId,
}); });
// Trigger callback to invalidate JWT tokens
if (this.sessionExpiredCallback) {
this.sessionExpiredCallback(userId);
}
return null; return null;
} }
@@ -183,6 +195,10 @@ class UserCrypto {
operation: "user_session_inactive", operation: "user_session_inactive",
userId, userId,
}); });
// Trigger callback to invalidate JWT tokens
if (this.sessionExpiredCallback) {
this.sessionExpiredCallback(userId);
}
return null; return null;
} }

View File

@@ -39,7 +39,6 @@ function AppContent() {
// Check if user data is unlocked // Check if user data is unlocked
if (!meRes.data_unlocked) { if (!meRes.data_unlocked) {
// Data is locked - user needs to re-authenticate // 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"); console.warn("User data is locked - re-authentication required");
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
@@ -51,6 +50,13 @@ function AppContent() {
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); 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 = document.cookie =
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
}) })

View File

@@ -54,7 +54,13 @@ export function Homepage({
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
setUserId(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( setDbError(
"Could not connect to the database. Please try again later.", "Could not connect to the database. Please try again later.",
); );

View File

@@ -37,7 +37,6 @@ const AppContent: FC = () => {
// Check if user data is unlocked // Check if user data is unlocked
if (!meRes.data_unlocked) { if (!meRes.data_unlocked) {
// Data is locked - user needs to re-authenticate // 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"); console.warn("User data is locked - re-authentication required");
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
@@ -49,6 +48,13 @@ const AppContent: FC = () => {
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); 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 = document.cookie =
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
}) })

View File

@@ -272,6 +272,10 @@ function createApiInstance(
} }
if (status === 401) { 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()) { if (isElectron()) {
localStorage.removeItem("jwt"); localStorage.removeItem("jwt");
} else { } else {
@@ -279,29 +283,23 @@ function createApiInstance(
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
localStorage.removeItem("jwt"); localStorage.removeItem("jwt");
} }
}
// Handle DEK (Data Encryption Key) invalidation // If session expired, show notification and reload page
if (status === 423) { if (isSessionExpired && typeof window !== "undefined") {
const errorData = error.response?.data; // Show user-friendly notification
if ((errorData as any)?.error === "DATA_LOCKED" || (errorData as any)?.message?.includes("DATA_LOCKED")) { console.warn("Session expired - please log in again");
// DEK session has expired (likely due to server restart or timeout)
// Force logout to require re-authentication and DEK unlock // Import toast dynamically to avoid circular dependencies
if (isElectron()) { import("sonner").then(({ toast }) => {
localStorage.removeItem("jwt"); toast.warning("Session expired - please log in again");
} else { });
document.cookie =
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
localStorage.removeItem("jwt");
}
// Trigger a page reload to redirect to login // 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); return Promise.reject(error);
}, },
); );