Fix TOTP login

This commit is contained in:
LukeGus
2025-10-01 14:28:58 -05:00
parent f223e4c462
commit 66c9937be9
6 changed files with 127 additions and 47 deletions

View File

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

View File

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

View File

@@ -1151,6 +1151,7 @@
"networkError": "网络错误",
"databaseConnection": "无法连接到数据库。",
"unknownError": "未知错误",
"loginFailed": "登录失败",
"failedPasswordReset": "无法启动密码重置",
"failedVerifyCode": "验证重置代码失败",
"failedCompleteReset": "无法完成密码重置",
@@ -1170,7 +1171,8 @@
"usernameExists": "用户名已存在",
"emailExists": "邮箱已存在",
"loadFailed": "加载数据失败",
"saveError": "保存失败"
"saveError": "保存失败",
"sessionExpired": "会话已过期 - 请重新登录"
},
"messages": {
"saveSuccess": "保存成功",

View File

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

View File

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

View File

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