From 20eca69d560642811152db62f95e28c10ea55a23 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 24 Aug 2026 05:30:55 +0800 Subject: [PATCH] feat: support additional TOTP authenticators (#1312) --- .../database/routes/user-totp-routes.ts | 49 ++++++- .../routes/totp-disable-route.test.ts | 37 +++++ src/ui/api/system-status-api.ts | 5 +- src/ui/locales/en.json | 7 + src/ui/sidebar/UserProfilePanel.tsx | 128 ++++++++++++++++-- 5 files changed, 210 insertions(+), 16 deletions(-) diff --git a/src/backend/database/routes/user-totp-routes.ts b/src/backend/database/routes/user-totp-routes.ts index 20501084..6e055c11 100644 --- a/src/backend/database/routes/user-totp-routes.ts +++ b/src/backend/database/routes/user-totp-routes.ts @@ -130,7 +130,54 @@ export function registerUserTotpRoutes( } if (userRecord.totpEnabled) { - return res.status(400).json({ error: "TOTP is already enabled" }); + const credential = req.body?.credential; + if (!credential) { + return res.status(400).json({ + error: "A TOTP code or password is required", + }); + } + + const userDataKey = authManager.getUserDataKey(userId); + let verified = await verifyTotpReauth( + userRecord, + credential, + userDataKey, + ); + if (!verified && !userRecord.isOidc && userRecord.passwordHash) { + verified = await bcrypt.compare(credential, userRecord.passwordHash); + } + if (!verified) { + return res.status(401).json({ + error: "Incorrect password or invalid TOTP code", + }); + } + + const existingSecret = userDataKey + ? LazyFieldEncryption.safeGetFieldValue( + userRecord.totpSecret, + userDataKey, + userId, + "totpSecret", + ) + : userRecord.totpSecret; + if (!existingSecret) { + return res.status(409).json({ error: "TOTP secret is unavailable" }); + } + + const otpauthUrl = speakeasy.otpauthURL({ + secret: existingSecret, + label: `Termix (${userRecord.username})`, + encoding: "base32", + }); + authLogger.info("Additional TOTP authenticator enrollment started", { + operation: "totp_add_authenticator", + userId, + }); + return res.json({ + secret: existingSecret, + qr_code: await QRCode.toDataURL(otpauthUrl), + additional: true, + }); } const secret = speakeasy.generateSecret({ diff --git a/src/backend/tests/database/routes/totp-disable-route.test.ts b/src/backend/tests/database/routes/totp-disable-route.test.ts index 4ecfcd0c..66b23336 100644 --- a/src/backend/tests/database/routes/totp-disable-route.test.ts +++ b/src/backend/tests/database/routes/totp-disable-route.test.ts @@ -42,6 +42,7 @@ const PASSWORD = "correct-horse"; */ describe("POST /totp/disable", () => { let handler: (req: unknown, res: unknown) => Promise; + let setupHandler: (req: unknown, res: unknown) => Promise; beforeEach(() => { vi.clearAllMocks(); @@ -66,8 +67,10 @@ describe("POST /totp/disable", () => { ); handler = routes.get("/totp/disable") as never; + setupHandler = routes.get("/totp/setup") as never; findById.mockResolvedValue({ id: "user-1", + username: "test-user", isOidc: false, passwordHash: bcrypt.hashSync(PASSWORD, 4), totpSecret: secret, @@ -92,6 +95,40 @@ describe("POST /totp/disable", () => { return handler({ userId: "user-1", body }, res).then(() => res); } + function callSetup(body: Record) { + const res = { + statusCode: 200, + body: undefined as unknown, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + return this; + }, + }; + return setupHandler({ userId: "user-1", body }, res).then(() => res); + } + + it("reveals the existing enrollment only after re-authentication", async () => { + const denied = await callSetup({ credential: "wrong" }); + expect(denied.statusCode).toBe(401); + + const allowed = await callSetup({ credential: PASSWORD }); + expect(allowed.statusCode).toBe(200); + expect(allowed.body).toEqual( + expect.objectContaining({ secret, additional: true }), + ); + expect(userUpdate).not.toHaveBeenCalled(); + }); + + it("requires a credential before adding another authenticator", async () => { + const res = await callSetup({}); + expect(res.statusCode).toBe(400); + expect(userUpdate).not.toHaveBeenCalled(); + }); + it("accepts the TOTP code on its own", async () => { // What the dialog sends: one value, in whichever field the client used. const res = await call({ diff --git a/src/ui/api/system-status-api.ts b/src/ui/api/system-status-api.ts index 6617b55c..efabc4e7 100644 --- a/src/ui/api/system-status-api.ts +++ b/src/ui/api/system-status-api.ts @@ -10,12 +10,13 @@ import { // ALERTS // ============================================================================ -export async function setupTOTP(): Promise<{ +export async function setupTOTP(credential?: string): Promise<{ secret: string; qr_code: string; + additional?: boolean; }> { try { - const response = await authApi.post("/users/totp/setup"); + const response = await authApi.post("/users/totp/setup", { credential }); return response.data; } catch (error) { handleApiError(error as AxiosError, "setup TOTP"); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 0c6b628f..cd80bda8 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -4205,6 +4205,13 @@ "totpAuthenticator": "TOTP Authenticator", "totpEnabled": "2FA is enabled", "totpDisabled": "Add extra login security", + "totpAddAuthenticator": "Add device", + "totpAddTitle": "Add authenticator device", + "totpAddDescription": "Confirm your identity to enroll another authenticator with the same secure TOTP key.", + "totpAddInputRequired": "Enter your TOTP code or password", + "totpAddFailed": "Failed to add authenticator", + "totpAddScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.", + "totpAddSuccess": "Additional authenticator enrolled", "disable": "Disable", "enable": "Enable", "setupTotp": "Setup TOTP", diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 5c2782fe..5805af30 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -566,6 +566,9 @@ export function UserProfilePanel({ const [totpLoading, setTotpLoading] = useState(false); const [showDisableTotp, setShowDisableTotp] = useState(false); const [disableTotpInput, setDisableTotpInput] = useState(""); + const [showAddTotp, setShowAddTotp] = useState(false); + const [addTotpInput, setAddTotpInput] = useState(""); + const [addingTotpAuthenticator, setAddingTotpAuthenticator] = useState(false); const [passkeys, setPasskeys] = useState([]); const [passkeyLoading, setPasskeyLoading] = useState(false); const [passkeyName, setPasskeyName] = useState(""); @@ -1209,6 +1212,29 @@ export function UserProfilePanel({ } } + async function handleAddTotpAuthenticator() { + if (!addTotpInput) { + toast.error(t("newUi.sidebar.userProfile.totpAddInputRequired")); + return; + } + setTotpLoading(true); + try { + const result = await setupTOTP(addTotpInput); + setTotpQrCode(result.qr_code); + setTotpSecret(result.secret); + setAddingTotpAuthenticator(true); + setShowAddTotp(false); + setAddTotpInput(""); + setTotpStep("setup"); + } catch (e: unknown) { + toast.error( + apiErrorMessage(e, t("newUi.sidebar.userProfile.totpAddFailed")), + ); + } finally { + setTotpLoading(false); + } + } + async function handleVerifyTotp() { if (!totpCode || totpCode.length !== 6) { toast.error(t("newUi.sidebar.userProfile.totpEnter6Digits")); @@ -2153,14 +2179,25 @@ export function UserProfilePanel({ {totpEnabled ? ( - +
+ + +
) : ( + + + + )} + {/* Disable TOTP form */} {totpEnabled && showDisableTotp && (
@@ -2214,14 +2295,17 @@ export function UserProfilePanel({ )} {/* TOTP setup: scan QR */} - {!totpEnabled && totpStep === "setup" && ( + {totpStep === "setup" && (
{t("newUi.sidebar.userProfile.setupTotp")}
- {t("newUi.sidebar.userProfile.totpInstructions")} + {t( + addingTotpAuthenticator + ? "newUi.sidebar.userProfile.totpAddScanInstructions" + : "newUi.sidebar.userProfile.totpInstructions", + )}
)}