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) {
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({
@@ -42,6 +42,7 @@ const PASSWORD = "correct-horse";
*/
describe("POST /totp/disable", () => {
let handler: (req: unknown, res: unknown) => Promise<void>;
let setupHandler: (req: unknown, res: unknown) => Promise<void>;
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<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 () => {
// What the dialog sends: one value, in whichever field the client used.
const res = await call({