diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index d4657167..78f6578d 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -18,7 +18,9 @@ import QRCode from "qrcode"; import type { Request, Response } from "express"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; +import { UserCrypto } from "../../utils/user-crypto.js"; import { DataCrypto } from "../../utils/data-crypto.js"; +import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; const authManager = AuthManager.getInstance(); @@ -893,11 +895,7 @@ router.post("/login", async (req, res) => { await authManager.registerUser(userRecord.id, password); } } catch (setupError) { - authLogger.error("Failed to initialize user encryption", setupError, { - operation: "user_encryption_setup_failed", - username, - userId: userRecord.id, - }); + // Continue if setup fails - authenticateUser will handle it } const dataUnlocked = await authManager.authenticateUser( @@ -905,14 +903,7 @@ router.post("/login", async (req, res) => { password, ); if (!dataUnlocked) { - authLogger.error("Failed to unlock user data during login", undefined, { - operation: "user_login_data_unlock_failed", - username, - userId: userRecord.id, - }); - return res.status(500).json({ - error: "Failed to unlock user data - please contact administrator", - }); + return res.status(401).json({ error: "Incorrect password" }); } if (userRecord.totp_enabled) { @@ -921,6 +912,7 @@ router.post("/login", async (req, res) => { expiresIn: "10m", }); return res.json({ + success: true, requires_totp: true, temp_token: tempToken, }); @@ -1488,17 +1480,42 @@ router.post("/totp/verify-login", async (req, res) => { return res.status(400).json({ error: "TOTP not enabled for this user" }); } + const userDataKey = authManager.getUserDataKey(userRecord.id); + if (!userDataKey) { + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + }); + } + + const totpSecret = LazyFieldEncryption.safeGetFieldValue( + userRecord.totp_secret, + userDataKey, + userRecord.id, + "totp_secret", + ); + const verified = speakeasy.totp.verify({ - secret: userRecord.totp_secret, + secret: totpSecret, encoding: "base32", token: totp_code, window: 2, }); if (!verified) { - const backupCodes = userRecord.totp_backup_codes - ? JSON.parse(userRecord.totp_backup_codes) - : []; + let backupCodes = []; + try { + backupCodes = userRecord.totp_backup_codes + ? JSON.parse(userRecord.totp_backup_codes) + : []; + } catch (parseError) { + backupCodes = []; + } + + if (!Array.isArray(backupCodes)) { + backupCodes = []; + } + const backupIndex = backupCodes.indexOf(totp_code); if (backupIndex === -1) { @@ -1516,18 +1533,40 @@ router.post("/totp/verify-login", async (req, res) => { expiresIn: "50d", }); + const isElectron = + 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", + }); + } + + const response: any = { + success: true, + is_admin: !!userRecord.is_admin, + username: userRecord.username, + userId: userRecord.id, + is_oidc: !!userRecord.is_oidc, + totp_enabled: !!userRecord.totp_enabled, + data_unlocked: isDataUnlocked, + }; + + if (isElectron) { + response.token = token; + } + return res .cookie( "jwt", token, authManager.getSecureCookieOptions(req, 50 * 24 * 60 * 60 * 1000), ) - .json({ - success: true, - is_admin: !!userRecord.is_admin, - username: userRecord.username, - token: req.headers["x-electron-app"] === "true" ? token : undefined, - }); + .json(response); } catch (err) { authLogger.error("TOTP verification failed", err); return res.status(500).json({ error: "TOTP verification failed" }); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index ecf72a6b..262cec0a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1174,6 +1174,7 @@ "networkError": "Network error", "databaseConnection": "Could not connect to the database.", "unknownError": "Unknown error", + "loginFailed": "Login failed", "failedPasswordReset": "Failed to initiate password reset", "failedVerifyCode": "Failed to verify reset code", "failedCompleteReset": "Failed to complete password reset", @@ -1193,7 +1194,8 @@ "usernameExists": "Username already exists", "emailExists": "Email already exists", "loadFailed": "Failed to load data", - "saveError": "Failed to save" + "saveError": "Failed to save", + "sessionExpired": "Session expired - please log in again" }, "messages": { "saveSuccess": "Saved successfully", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 91e2aa0c..5b7ba6b1 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1151,6 +1151,7 @@ "networkError": "网络错误", "databaseConnection": "无法连接到数据库。", "unknownError": "未知错误", + "loginFailed": "登录失败", "failedPasswordReset": "无法启动密码重置", "failedVerifyCode": "验证重置代码失败", "failedCompleteReset": "无法完成密码重置", @@ -1170,7 +1171,8 @@ "usernameExists": "用户名已存在", "emailExists": "邮箱已存在", "loadFailed": "加载数据失败", - "saveError": "保存失败" + "saveError": "保存失败", + "sessionExpired": "会话已过期 - 请重新登录" }, "messages": { "saveSuccess": "保存成功", diff --git a/src/ui/Desktop/Homepage/HomepageAuth.tsx b/src/ui/Desktop/Homepage/HomepageAuth.tsx index 33d0c013..189c7171 100644 --- a/src/ui/Desktop/Homepage/HomepageAuth.tsx +++ b/src/ui/Desktop/Homepage/HomepageAuth.tsx @@ -362,32 +362,46 @@ export function HomepageAuth({ throw new Error(t("errors.loginFailed")); } - // JWT token is now automatically set as HttpOnly cookie by backend - console.log("TOTP login successful - JWT set as secure HttpOnly cookie"); - const meRes = await getUserInfo(); + if (isElectron() && res.token) { + localStorage.setItem("jwt", res.token); + } setInternalLoggedIn(true); setLoggedIn(true); - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - setUserId(meRes.userId || null); + setIsAdmin(!!res.is_admin); + setUsername(res.username || null); + setUserId(res.userId || null); setDbError(null); - onAuthSuccess({ - isAdmin: !!meRes.is_admin, - username: meRes.username || null, - userId: meRes.userId || null, - }); + + setTimeout(() => { + onAuthSuccess({ + isAdmin: !!res.is_admin, + username: res.username || null, + userId: res.userId || null, + }); + }, 100); + setInternalLoggedIn(true); setTotpRequired(false); setTotpCode(""); setTotpTempToken(""); toast.success(t("messages.loginSuccess")); } catch (err: any) { + const errorCode = err?.response?.data?.code; const errorMessage = err?.response?.data?.error || err?.message || t("errors.invalidTotpCode"); - toast.error(errorMessage); + + if (errorCode === "SESSION_EXPIRED") { + setTotpRequired(false); + setTotpCode(""); + setTotpTempToken(""); + setTab("login"); + toast.error(t("errors.sessionExpired")); + } else { + toast.error(errorMessage); + } } finally { setTotpLoading(false); } diff --git a/src/ui/Mobile/Homepage/HomepageAuth.tsx b/src/ui/Mobile/Homepage/HomepageAuth.tsx index 0329a2e2..f7c1bd50 100644 --- a/src/ui/Mobile/Homepage/HomepageAuth.tsx +++ b/src/ui/Mobile/Homepage/HomepageAuth.tsx @@ -22,6 +22,7 @@ import { setCookie, getCookie, logoutUser, + isElectron, } from "@/ui/main-axios.ts"; import { PasswordInput } from "@/components/ui/password-input.tsx"; @@ -341,30 +342,46 @@ export function HomepageAuth({ throw new Error(t("errors.loginFailed")); } - const meRes = await getUserInfo(); + if (isElectron() && res.token) { + localStorage.setItem("jwt", res.token); + } setInternalLoggedIn(true); setLoggedIn(true); - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - setUserId(meRes.userId || null); + setIsAdmin(!!res.is_admin); + setUsername(res.username || null); + setUserId(res.userId || null); setDbError(null); - onAuthSuccess({ - isAdmin: !!meRes.is_admin, - username: meRes.username || null, - userId: meRes.userId || null, - }); + + setTimeout(() => { + onAuthSuccess({ + isAdmin: !!res.is_admin, + username: res.username || null, + userId: res.userId || null, + }); + }, 100); + setInternalLoggedIn(true); setTotpRequired(false); setTotpCode(""); setTotpTempToken(""); toast.success(t("messages.loginSuccess")); } catch (err: any) { + const errorCode = err?.response?.data?.code; const errorMessage = err?.response?.data?.error || err?.message || t("errors.invalidTotpCode"); - toast.error(errorMessage); + + if (errorCode === "SESSION_EXPIRED") { + setTotpRequired(false); + setTotpCode(""); + setTotpTempToken(""); + setTab("login"); + toast.error(t("errors.sessionExpired")); + } else { + toast.error(errorMessage); + } } finally { setTotpLoading(false); } diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 2486d99f..8a0ffb04 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -65,6 +65,10 @@ interface AuthResponse { success?: boolean; is_admin?: boolean; username?: string; + userId?: string; + is_oidc?: boolean; + totp_enabled?: boolean; + data_unlocked?: boolean; } interface UserInfo { @@ -1540,6 +1544,8 @@ export async function loginUser( success: response.data.success, is_admin: response.data.is_admin, username: response.data.username, + requires_totp: response.data.requires_totp, + temp_token: response.data.temp_token, }; } catch (error) { handleApiError(error, "login user");