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