mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
feat: support additional TOTP authenticators (#1312)
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user