Add session lock notifications and change timeouts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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=/;";
|
||||
})
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
@@ -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=/;";
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user