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

@@ -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

View File

@@ -344,6 +344,16 @@ async function fetchHostById(
userId: string,
): Promise<SSHHostWithCredentials | undefined> {
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<void> {
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) {

View File

@@ -37,10 +37,16 @@ class AuthManager {
private static instance: AuthManager;
private systemCrypto: SystemCrypto;
private userCrypto: UserCrypto;
private invalidatedTokens: Set<string> = 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<JWTPayload | null> {
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"
});
}

View File

@@ -64,8 +64,16 @@ class SimpleDBOps {
tableName: TableName,
userId: string,
): Promise<T[]> {
// 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<T | undefined> {
// 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

View File

@@ -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<string, UserSession> = 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;
}

View File

@@ -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=/;";
})

View File

@@ -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.",
);

View File

@@ -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=/;";
})

View File

@@ -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);
},
);