feat: support additional TOTP authenticators (#1312)

This commit is contained in:
ZacharyZcR
2026-08-24 05:30:55 +08:00
committed by GitHub
parent 26757813c8
commit 20eca69d56
5 changed files with 210 additions and 16 deletions
@@ -130,7 +130,54 @@ export function registerUserTotpRoutes(
} }
if (userRecord.totpEnabled) { 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({ const secret = speakeasy.generateSecret({
@@ -42,6 +42,7 @@ const PASSWORD = "correct-horse";
*/ */
describe("POST /totp/disable", () => { describe("POST /totp/disable", () => {
let handler: (req: unknown, res: unknown) => Promise<void>; let handler: (req: unknown, res: unknown) => Promise<void>;
let setupHandler: (req: unknown, res: unknown) => Promise<void>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -66,8 +67,10 @@ describe("POST /totp/disable", () => {
); );
handler = routes.get("/totp/disable") as never; handler = routes.get("/totp/disable") as never;
setupHandler = routes.get("/totp/setup") as never;
findById.mockResolvedValue({ findById.mockResolvedValue({
id: "user-1", id: "user-1",
username: "test-user",
isOidc: false, isOidc: false,
passwordHash: bcrypt.hashSync(PASSWORD, 4), passwordHash: bcrypt.hashSync(PASSWORD, 4),
totpSecret: secret, totpSecret: secret,
@@ -92,6 +95,40 @@ describe("POST /totp/disable", () => {
return handler({ userId: "user-1", body }, res).then(() => res); return handler({ userId: "user-1", body }, res).then(() => res);
} }
function callSetup(body: Record<string, unknown>) {
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 () => { it("accepts the TOTP code on its own", async () => {
// What the dialog sends: one value, in whichever field the client used. // What the dialog sends: one value, in whichever field the client used.
const res = await call({ const res = await call({
+3 -2
View File
@@ -10,12 +10,13 @@ import {
// ALERTS // ALERTS
// ============================================================================ // ============================================================================
export async function setupTOTP(): Promise<{ export async function setupTOTP(credential?: string): Promise<{
secret: string; secret: string;
qr_code: string; qr_code: string;
additional?: boolean;
}> { }> {
try { try {
const response = await authApi.post("/users/totp/setup"); const response = await authApi.post("/users/totp/setup", { credential });
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error as AxiosError, "setup TOTP"); handleApiError(error as AxiosError, "setup TOTP");
+7
View File
@@ -4205,6 +4205,13 @@
"totpAuthenticator": "TOTP Authenticator", "totpAuthenticator": "TOTP Authenticator",
"totpEnabled": "2FA is enabled", "totpEnabled": "2FA is enabled",
"totpDisabled": "Add extra login security", "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", "disable": "Disable",
"enable": "Enable", "enable": "Enable",
"setupTotp": "Setup TOTP", "setupTotp": "Setup TOTP",
+115 -13
View File
@@ -566,6 +566,9 @@ export function UserProfilePanel({
const [totpLoading, setTotpLoading] = useState(false); const [totpLoading, setTotpLoading] = useState(false);
const [showDisableTotp, setShowDisableTotp] = useState(false); const [showDisableTotp, setShowDisableTotp] = useState(false);
const [disableTotpInput, setDisableTotpInput] = useState(""); const [disableTotpInput, setDisableTotpInput] = useState("");
const [showAddTotp, setShowAddTotp] = useState(false);
const [addTotpInput, setAddTotpInput] = useState("");
const [addingTotpAuthenticator, setAddingTotpAuthenticator] = useState(false);
const [passkeys, setPasskeys] = useState<WebAuthnCredentialSummary[]>([]); const [passkeys, setPasskeys] = useState<WebAuthnCredentialSummary[]>([]);
const [passkeyLoading, setPasskeyLoading] = useState(false); const [passkeyLoading, setPasskeyLoading] = useState(false);
const [passkeyName, setPasskeyName] = useState(""); 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() { async function handleVerifyTotp() {
if (!totpCode || totpCode.length !== 6) { if (!totpCode || totpCode.length !== 6) {
toast.error(t("newUi.sidebar.userProfile.totpEnter6Digits")); toast.error(t("newUi.sidebar.userProfile.totpEnter6Digits"));
@@ -2153,14 +2179,25 @@ export function UserProfilePanel({
</span> </span>
</div> </div>
{totpEnabled ? ( {totpEnabled ? (
<Button <div className="ml-3 flex shrink-0 gap-2">
variant="outline" <Button
size="sm" variant="outline"
className="shrink-0 ml-3 text-[10px] h-7 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive" size="sm"
onClick={() => setShowDisableTotp((o) => !o)} className="h-7 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
> onClick={() => setShowAddTotp((open) => !open)}
{t("newUi.sidebar.userProfile.disable")} disabled={totpLoading || totpStep !== "idle"}
</Button> >
{t("newUi.sidebar.userProfile.totpAddAuthenticator")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setShowDisableTotp((o) => !o)}
>
{t("newUi.sidebar.userProfile.disable")}
</Button>
</div>
) : ( ) : (
<Button <Button
variant="outline" variant="outline"
@@ -2174,6 +2211,50 @@ export function UserProfilePanel({
)} )}
</div> </div>
{totpEnabled && showAddTotp && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.totpAddTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.totpAddDescription")}
</span>
<Input
placeholder={t(
"newUi.sidebar.userProfile.totpDisablePlaceholder",
)}
value={addTotpInput}
onChange={(e) => setAddTotpInput(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && handleAddTotpAuthenticator()
}
className="text-sm"
/>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="flex-1 text-xs"
onClick={() => {
setShowAddTotp(false);
setAddTotpInput("");
}}
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleAddTotpAuthenticator}
disabled={totpLoading}
>
{t("common.continue")}
</Button>
</div>
</div>
)}
{/* Disable TOTP form */} {/* Disable TOTP form */}
{totpEnabled && showDisableTotp && ( {totpEnabled && showDisableTotp && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3"> <div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
@@ -2214,14 +2295,17 @@ export function UserProfilePanel({
)} )}
{/* TOTP setup: scan QR */} {/* TOTP setup: scan QR */}
{!totpEnabled && totpStep === "setup" && ( {totpStep === "setup" && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-3"> <div className="border border-border bg-muted/20 p-3 flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.setupTotp")} {t("newUi.sidebar.userProfile.setupTotp")}
</span> </span>
<button <button
onClick={() => setTotpStep("idle")} onClick={() => {
setTotpStep("idle");
setAddingTotpAuthenticator(false);
}}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<X className="size-3.5" /> <X className="size-3.5" />
@@ -2257,15 +2341,33 @@ export function UserProfilePanel({
</button> </button>
</div> </div>
<span className="text-[10px] text-muted-foreground text-center"> <span className="text-[10px] text-muted-foreground text-center">
{t("newUi.sidebar.userProfile.totpInstructions")} {t(
addingTotpAuthenticator
? "newUi.sidebar.userProfile.totpAddScanInstructions"
: "newUi.sidebar.userProfile.totpInstructions",
)}
</span> </span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand" className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => setTotpStep("verify")} onClick={() => {
if (addingTotpAuthenticator) {
setAddingTotpAuthenticator(false);
setTotpStep("idle");
toast.success(
t("newUi.sidebar.userProfile.totpAddSuccess"),
);
} else {
setTotpStep("verify");
}
}}
> >
{t("newUi.sidebar.userProfile.totpContinueVerify")} {t(
addingTotpAuthenticator
? "newUi.sidebar.userProfile.done"
: "newUi.sidebar.userProfile.totpContinueVerify",
)}
</Button> </Button>
</div> </div>
)} )}