mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: move totp and passkeys into plugins (C1)
This commit is contained in:
1 parent
f4f26a9b8b
commit
5c18435105
235 files changed
+22545
-5610
No files matched your search
@@ -0,0 +1,41 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { enrollments } from "./tables.js";
|
||||
import { createEnrollmentRepository } from "./repository.js";
|
||||
import { createTotpService, FACTOR_ID, normalizeCode } from "./totp.js";
|
||||
import { registerTotpRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(enrollments);
|
||||
const totp = createTotpService(
|
||||
ctx,
|
||||
createEnrollmentRepository(ctx.db, table),
|
||||
);
|
||||
|
||||
ctx.auth.registerSecondFactor({
|
||||
id: FACTOR_ID,
|
||||
labelKey: "factor",
|
||||
isEnrolled: (userId) => totp.isEnrolled(userId),
|
||||
verify: async (userId, body) => {
|
||||
const result = await totp.checkCode(
|
||||
userId,
|
||||
normalizeCode(body.totp_code ?? body.code),
|
||||
);
|
||||
if (result === null) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Your authenticator could not be read. Contact an admin to reset your second factor.",
|
||||
};
|
||||
}
|
||||
return result;
|
||||
},
|
||||
reset: (userId) => totp.remove(userId),
|
||||
});
|
||||
|
||||
registerTotpRoutes(ctx.http.router<Router>(), ctx, totp);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface EnrollmentRecord {
|
||||
userId: string;
|
||||
secret: string | null;
|
||||
pendingSecret: string | null;
|
||||
backupCodes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands the table back untyped and the drizzle handle is the
|
||||
// server's own, so both are typed at this module's edge.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type EnrollmentRepository = ReturnType<
|
||||
typeof createEnrollmentRepository
|
||||
>;
|
||||
|
||||
/** Sealed values only: nothing here ever sees a plaintext secret. */
|
||||
export function createEnrollmentRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
async function find(userId: string): Promise<EnrollmentRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.userId, userId))
|
||||
.limit(1);
|
||||
return (rows[0] as EnrollmentRecord) ?? null;
|
||||
}
|
||||
|
||||
async function save(
|
||||
userId: string,
|
||||
values: Partial<
|
||||
Pick<EnrollmentRecord, "secret" | "pendingSecret" | "backupCodes">
|
||||
>,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
const now = new Date().toISOString();
|
||||
if (await find(userId)) {
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({ ...values, updatedAt: now })
|
||||
.where(eq(table.userId, userId));
|
||||
} else {
|
||||
await drizzle
|
||||
.insert(table)
|
||||
.values({ userId, ...values, createdAt: now, updatedAt: now });
|
||||
}
|
||||
await db.persist();
|
||||
}
|
||||
|
||||
return {
|
||||
find,
|
||||
save,
|
||||
|
||||
async remove(userId: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.delete(table).where(eq(table.userId, userId));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import QRCode from "qrcode";
|
||||
import speakeasy from "speakeasy";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PluginContext,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { FACTOR_ID, normalizeCode, type TotpService } from "./totp.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function registerTotpRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
totp: TotpService,
|
||||
): void {
|
||||
const actor = () => ctx.currentActor()!;
|
||||
|
||||
async function accountLabel(userId: string): Promise<string> {
|
||||
try {
|
||||
const { users } = await ctx.db.refs<{ users: Table }>();
|
||||
const drizzle = await ctx.db.client<Drizzle>();
|
||||
const rows = await drizzle
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
if (rows[0]?.username) return `Termix (${rows[0].username})`;
|
||||
} catch {
|
||||
// The label is cosmetic.
|
||||
}
|
||||
return "Termix";
|
||||
}
|
||||
|
||||
const fail = (res: Response, message: string, error: unknown) => {
|
||||
ctx.log.error(
|
||||
message,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
res.status(500).json({ error: message });
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/status:
|
||||
* get:
|
||||
* summary: TOTP status for the caller
|
||||
* tags:
|
||||
* - TOTP
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Whether the caller has TOTP on.
|
||||
*/
|
||||
router.get("/status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ enabled: await totp.isEnrolled(actor()) });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to read TOTP status", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/setup:
|
||||
* post:
|
||||
* summary: Start TOTP setup
|
||||
* description: Returns a new secret and QR code. With TOTP already on, a current code or backup code in `credential` returns the existing secret instead, to add another authenticator.
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* credential:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Secret and QR code.
|
||||
* 400:
|
||||
* description: A code is required.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
*/
|
||||
router.post("/setup", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
try {
|
||||
const label = await accountLabel(userId);
|
||||
if (await totp.isEnrolled(userId)) {
|
||||
const credential = normalizeCode(req.body?.credential);
|
||||
if (!credential) {
|
||||
return res.status(400).json({ error: "A TOTP code is required" });
|
||||
}
|
||||
if (!(await totp.checkCode(userId, credential))) {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
const secret = await totp.activeSecret(userId);
|
||||
if (!secret) {
|
||||
return res.status(409).json({ error: "TOTP secret is unavailable" });
|
||||
}
|
||||
const url = speakeasy.otpauthURL({
|
||||
secret,
|
||||
label,
|
||||
encoding: "base32",
|
||||
});
|
||||
return res.json({
|
||||
secret,
|
||||
qr_code: await QRCode.toDataURL(url),
|
||||
additional: true,
|
||||
});
|
||||
}
|
||||
|
||||
const { secret, otpauthUrl } = await totp.startSetup(userId, label);
|
||||
res.json({ secret, qr_code: await QRCode.toDataURL(otpauthUrl) });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to setup TOTP", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/enable:
|
||||
* post:
|
||||
* summary: Turn TOTP on
|
||||
* description: Checks a code from the secret /setup returned, turns TOTP on and returns backup codes. Signs the caller out everywhere else.
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP on, with backup codes.
|
||||
* 400:
|
||||
* description: Missing code, already on, or setup not started.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
* 409:
|
||||
* description: Core does not allow second factors right now.
|
||||
*/
|
||||
router.post("/enable", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
const code = normalizeCode(req.body?.totp_code);
|
||||
if (!code) return res.status(400).json({ error: "TOTP code is required" });
|
||||
try {
|
||||
if (await totp.isEnrolled(userId)) {
|
||||
return res.status(400).json({ error: "TOTP is already enabled" });
|
||||
}
|
||||
const pending = await totp.pendingSecretFor(userId, code);
|
||||
if (pending.status === "not-started") {
|
||||
return res.status(400).json({ error: "TOTP setup not initiated" });
|
||||
}
|
||||
if (pending.status === "invalid") {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
// Core's policy first, so a refusal leaves nothing half enabled.
|
||||
await ctx.auth.recordEnrollment(userId, FACTOR_ID);
|
||||
const backupCodes = await totp.activate(userId, pending.secret);
|
||||
res.json({
|
||||
message: "TOTP enabled successfully",
|
||||
backup_codes: backupCodes,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LoginMethodError) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
fail(res, "Failed to enable TOTP", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/disable:
|
||||
* post:
|
||||
* summary: Turn TOTP off
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP off.
|
||||
* 400:
|
||||
* description: Missing code or TOTP is not on.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
*/
|
||||
router.post("/disable", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
const code = normalizeCode(req.body?.totp_code);
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "A TOTP code is required" });
|
||||
}
|
||||
try {
|
||||
const ok = await totp.checkCode(userId, code);
|
||||
if (ok === null) {
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
if (!ok) return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
await totp.remove(userId);
|
||||
await ctx.auth.removeEnrollment(userId, FACTOR_ID);
|
||||
ctx.log.info(`Two-factor authentication disabled for ${userId}`);
|
||||
res.json({ message: "TOTP disabled successfully" });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to disable TOTP", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/backup-codes:
|
||||
* post:
|
||||
* summary: Replace the backup codes
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New backup codes.
|
||||
* 400:
|
||||
* description: Missing code or TOTP is not on.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
*/
|
||||
router.post("/backup-codes", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
const code = normalizeCode(req.body?.totp_code);
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "A TOTP code is required" });
|
||||
}
|
||||
try {
|
||||
const ok = await totp.checkCode(userId, code);
|
||||
if (ok === null) {
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
if (!ok) return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
res.json({ backup_codes: await totp.replaceBackupCodes(userId) });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to generate backup codes", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
defineTable,
|
||||
encryptedText,
|
||||
refUser,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* One row per user who started or finished setup. The secret columns hold
|
||||
* values sealed with ctx.secrets.seal, so they can be read during login
|
||||
* without an acting user. A non-null secret means the user is enrolled.
|
||||
*/
|
||||
export const enrollments = defineTable("enrollments", {
|
||||
userId: refUser().primaryKey(),
|
||||
secret: encryptedText(),
|
||||
// Set by /setup, promoted to secret once a code from it verifies.
|
||||
pendingSecret: encryptedText(),
|
||||
// A sealed JSON array of the unused backup codes.
|
||||
backupCodes: encryptedText(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const tables = [enrollments];
|
||||
@@ -0,0 +1,136 @@
|
||||
import crypto from "node:crypto";
|
||||
import speakeasy from "speakeasy";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { EnrollmentRepository } from "./repository.js";
|
||||
|
||||
export const FACTOR_ID = "totp";
|
||||
|
||||
const BACKUP_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const BACKUP_CODE_COUNT = 8;
|
||||
|
||||
/** Eight characters from a CSPRNG; Math.random is not fit for secrets. */
|
||||
export function generateBackupCode(): string {
|
||||
let code = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += BACKUP_CODE_ALPHABET[crypto.randomInt(BACKUP_CODE_ALPHABET.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export function generateBackupCodes(): string[] {
|
||||
return Array.from({ length: BACKUP_CODE_COUNT }, () => generateBackupCode());
|
||||
}
|
||||
|
||||
export function verifyTotpCode(secret: string, code: string): boolean {
|
||||
return speakeasy.totp.verify({
|
||||
secret,
|
||||
encoding: "base32",
|
||||
token: code,
|
||||
window: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCode(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toUpperCase() : "";
|
||||
}
|
||||
|
||||
export type TotpService = ReturnType<typeof createTotpService>;
|
||||
|
||||
/**
|
||||
* Reads and checks a user's enrolment. Secrets are sealed with the
|
||||
* installation key, so this works during login with no acting user.
|
||||
*/
|
||||
export function createTotpService(
|
||||
ctx: PluginContext,
|
||||
repository: EnrollmentRepository,
|
||||
) {
|
||||
const unseal = (value: string | null) =>
|
||||
value ? ctx.secrets.unseal(value) : Promise.resolve(null);
|
||||
|
||||
async function readBackupCodes(sealed: string | null): Promise<string[]> {
|
||||
const raw = await unseal(sealed);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const codes = JSON.parse(raw);
|
||||
return Array.isArray(codes) ? codes : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function sealBackupCodes(codes: string[]): Promise<string> {
|
||||
return ctx.secrets.seal(JSON.stringify(codes));
|
||||
}
|
||||
|
||||
return {
|
||||
async isEnrolled(userId: string): Promise<boolean> {
|
||||
return !!(await repository.find(userId))?.secret;
|
||||
},
|
||||
|
||||
/** The active secret, or null when not enrolled or unreadable. */
|
||||
async activeSecret(userId: string): Promise<string | null> {
|
||||
return unseal((await repository.find(userId))?.secret ?? null);
|
||||
},
|
||||
|
||||
/**
|
||||
* True for a current code from the authenticator or an unused backup
|
||||
* code, which is burnt. Null when the user has no readable secret.
|
||||
*/
|
||||
async checkCode(userId: string, code: string): Promise<boolean | null> {
|
||||
const row = await repository.find(userId);
|
||||
const secret = await unseal(row?.secret ?? null);
|
||||
if (!row || !secret) return null;
|
||||
if (!code) return false;
|
||||
if (verifyTotpCode(secret, code)) return true;
|
||||
|
||||
const codes = await readBackupCodes(row.backupCodes);
|
||||
const index = codes.indexOf(code);
|
||||
if (index === -1) return false;
|
||||
codes.splice(index, 1);
|
||||
await repository.save(userId, {
|
||||
backupCodes: await sealBackupCodes(codes),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Starts setup with a new secret, replacing any unfinished one. */
|
||||
async startSetup(userId: string, label: string) {
|
||||
const secret = speakeasy.generateSecret({ name: label, length: 32 });
|
||||
await repository.save(userId, {
|
||||
pendingSecret: await ctx.secrets.seal(secret.base32),
|
||||
});
|
||||
return { secret: secret.base32, otpauthUrl: secret.otpauth_url ?? "" };
|
||||
},
|
||||
|
||||
/** The unfinished setup's secret, when a code from it verifies. */
|
||||
async pendingSecretFor(userId: string, code: string) {
|
||||
const pending = await unseal(
|
||||
(await repository.find(userId))?.pendingSecret ?? null,
|
||||
);
|
||||
if (!pending) return { status: "not-started" as const };
|
||||
if (!verifyTotpCode(pending, code)) return { status: "invalid" as const };
|
||||
return { status: "ok" as const, secret: pending };
|
||||
},
|
||||
|
||||
/** Makes the verified pending secret active and issues backup codes. */
|
||||
async activate(userId: string, secret: string): Promise<string[]> {
|
||||
const codes = generateBackupCodes();
|
||||
await repository.save(userId, {
|
||||
secret: await ctx.secrets.seal(secret),
|
||||
pendingSecret: null,
|
||||
backupCodes: await sealBackupCodes(codes),
|
||||
});
|
||||
return codes;
|
||||
},
|
||||
|
||||
async replaceBackupCodes(userId: string): Promise<string[]> {
|
||||
const codes = generateBackupCodes();
|
||||
await repository.save(userId, {
|
||||
backupCodes: await sealBackupCodes(codes),
|
||||
});
|
||||
return codes;
|
||||
},
|
||||
|
||||
remove: (userId: string) => repository.remove(userId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
type SecondFactorUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { isValidTotpInput, normalizeTotpInput } from "./totp-input";
|
||||
|
||||
const primaryButton =
|
||||
"w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold";
|
||||
|
||||
export function TotpChallenge({
|
||||
disabled,
|
||||
verify,
|
||||
cancel,
|
||||
}: SecondFactorUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [code, setCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (!isValidTotpInput(code)) {
|
||||
toast.error(t("challenge.enterCode"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await verify({ totp_code: code });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("challenge.verify")}
|
||||
</span>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={8}
|
||||
value={code}
|
||||
onChange={(e) => setCode(normalizeTotpInput(e.target.value))}
|
||||
disabled={disabled || loading}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("challenge.backupCodeHint")}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
className={primaryButton}
|
||||
disabled={disabled || loading || !isValidTotpInput(code)}
|
||||
>
|
||||
{loading ? t("common.loading") : t("challenge.verify")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={cancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle2, Copy, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input, copyToClipboard } from "@termix/plugin-sdk/ui";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { apiErrorMessage } from "./totp-input";
|
||||
|
||||
type Step = "idle" | "setup" | "verify" | "backup";
|
||||
|
||||
interface SetupResponse {
|
||||
secret: string;
|
||||
qr_code: string;
|
||||
additional?: boolean;
|
||||
}
|
||||
|
||||
const accentButton =
|
||||
"text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand";
|
||||
const destructiveButton =
|
||||
"text-xs border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive";
|
||||
const box = "border border-border bg-muted/20 p-3 flex flex-col gap-3";
|
||||
const boxTitle =
|
||||
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
|
||||
|
||||
/** Settings > Security: turn TOTP on and off, add devices, backup codes. */
|
||||
export function TotpEnrollment() {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [step, setStep] = useState<Step>("idle");
|
||||
const [qrCode, setQrCode] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDisable, setShowDisable] = useState(false);
|
||||
const [disableInput, setDisableInput] = useState("");
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addInput, setAddInput] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ enabled: boolean }>("status")
|
||||
.then(({ data }) => setEnabled(!!data.enabled))
|
||||
.catch(() => {});
|
||||
}, [api]);
|
||||
|
||||
async function startSetup() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post<SetupResponse>("setup", {});
|
||||
setQrCode(data.qr_code);
|
||||
setSecret(data.secret);
|
||||
setCode("");
|
||||
setStep("setup");
|
||||
} catch {
|
||||
toast.error(t("enrollment.setupFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addAuthenticator() {
|
||||
if (!addInput) {
|
||||
toast.error(t("enrollment.codeRequired"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post<SetupResponse>("setup", {
|
||||
credential: addInput,
|
||||
});
|
||||
setQrCode(data.qr_code);
|
||||
setSecret(data.secret);
|
||||
setAdding(true);
|
||||
setShowAdd(false);
|
||||
setAddInput("");
|
||||
setStep("setup");
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("enrollment.addFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
if (code.length !== 6) {
|
||||
toast.error(t("enrollment.enter6Digits"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post<{ backup_codes?: string[] }>("enable", {
|
||||
totp_code: code,
|
||||
});
|
||||
setBackupCodes(data.backup_codes ?? []);
|
||||
setEnabled(true);
|
||||
setStep("backup");
|
||||
toast.success(t("enrollment.enabledSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("enrollment.invalidCode")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
if (!disableInput) {
|
||||
toast.error(t("enrollment.codeRequired"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post("disable", { totp_code: disableInput });
|
||||
setEnabled(false);
|
||||
setShowDisable(false);
|
||||
setDisableInput("");
|
||||
toast.success(t("enrollment.disabledSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("enrollment.disableFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBackupCodes() {
|
||||
const blob = new Blob([backupCodes.join("\n")], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "termix-backup-codes.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{t("enrollment.title")}</span>
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/totp"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("enrollment.docs")}
|
||||
</a>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{enabled ? t("enrollment.on") : t("enrollment.off")}
|
||||
</span>
|
||||
</div>
|
||||
{enabled ? (
|
||||
<div className="ml-3 flex shrink-0 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-7 ${accentButton}`}
|
||||
onClick={() => setShowAdd((open) => !open)}
|
||||
disabled={loading || step !== "idle"}
|
||||
>
|
||||
{t("enrollment.addDevice")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-7 ${destructiveButton}`}
|
||||
onClick={() => setShowDisable((open) => !open)}
|
||||
>
|
||||
{t("enrollment.disable")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`shrink-0 ml-3 h-7 ${accentButton}`}
|
||||
onClick={startSetup}
|
||||
disabled={loading || step !== "idle"}
|
||||
>
|
||||
{t("enrollment.enable")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enabled && showAdd && (
|
||||
<div className={box}>
|
||||
<span className={boxTitle}>{t("enrollment.addTitle")}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("enrollment.addDescription")}
|
||||
</span>
|
||||
<Input
|
||||
placeholder={t("enrollment.codePlaceholder")}
|
||||
value={addInput}
|
||||
onChange={(e) => setAddInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addAuthenticator()}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setAddInput("");
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${accentButton}`}
|
||||
onClick={addAuthenticator}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && showDisable && (
|
||||
<div className={box}>
|
||||
<span className={boxTitle}>{t("enrollment.disableTitle")}</span>
|
||||
<Input
|
||||
placeholder={t("enrollment.codePlaceholder")}
|
||||
value={disableInput}
|
||||
onChange={(e) => setDisableInput(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => {
|
||||
setShowDisable(false);
|
||||
setDisableInput("");
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${destructiveButton}`}
|
||||
onClick={disable}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("enrollment.disableTitle")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "setup" && (
|
||||
<div className={box}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={boxTitle}>{t("enrollment.setupTitle")}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep("idle");
|
||||
setAdding(false);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center p-3 bg-background border border-border">
|
||||
{qrCode ? (
|
||||
<img
|
||||
src={qrCode}
|
||||
alt={t("enrollment.qrCode")}
|
||||
className="size-32"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-24 bg-muted flex items-center justify-center text-[10px] text-muted-foreground">
|
||||
{t("enrollment.qrCode")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-muted/30 border border-border px-2 py-1.5">
|
||||
<span className="text-[10px] font-mono flex-1 tracking-widest select-all truncate">
|
||||
{secret}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
copyToClipboard(secret);
|
||||
toast.info(t("enrollment.secretCopied"));
|
||||
}}
|
||||
className="text-muted-foreground hover:text-accent-brand shrink-0"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground text-center">
|
||||
{t(
|
||||
adding
|
||||
? "enrollment.addScanInstructions"
|
||||
: "enrollment.instructions",
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={accentButton}
|
||||
onClick={() => {
|
||||
if (adding) {
|
||||
setAdding(false);
|
||||
setStep("idle");
|
||||
toast.success(t("enrollment.addSuccess"));
|
||||
} else {
|
||||
setStep("verify");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(adding ? "enrollment.done" : "enrollment.continueVerify")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!enabled && step === "verify" && (
|
||||
<div className={box}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={boxTitle}>{t("enrollment.verifyTitle")}</span>
|
||||
<button
|
||||
onClick={() => setStep("setup")}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="000000"
|
||||
value={code}
|
||||
onChange={(e) =>
|
||||
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))
|
||||
}
|
||||
onKeyDown={(e) => e.key === "Enter" && verify()}
|
||||
className="text-center font-mono tracking-widest text-lg h-10"
|
||||
maxLength={6}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => setStep("setup")}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${accentButton}`}
|
||||
onClick={verify}
|
||||
disabled={loading || code.length !== 6}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
{t("enrollment.verify")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "backup" && backupCodes.length > 0 && (
|
||||
<div className={box}>
|
||||
<span className={boxTitle}>{t("enrollment.backupTitle")}</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{backupCodes.map((backupCode) => (
|
||||
<span
|
||||
key={backupCode}
|
||||
className="text-[10px] font-mono bg-muted border border-border px-2 py-1 text-center"
|
||||
>
|
||||
{backupCode}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={accentButton}
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
{t("enrollment.downloadBackup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => setStep("idle")}
|
||||
>
|
||||
{t("enrollment.done")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { TotpChallenge } from "./TotpChallenge";
|
||||
import { TotpEnrollment } from "./TotpEnrollment";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerSecondFactorUI({
|
||||
id: "totp",
|
||||
titleKey: "title",
|
||||
component: TotpChallenge,
|
||||
enrollment: TotpEnrollment,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Six-digit codes, or a backup code of up to eight letters and digits. */
|
||||
export function normalizeTotpInput(value: string): string {
|
||||
return value
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.toUpperCase()
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
export function isValidTotpInput(value: string): boolean {
|
||||
return /^[A-Z0-9]{6,8}$/.test(value);
|
||||
}
|
||||
|
||||
export function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data;
|
||||
return typeof data?.error === "string" && data.error ? data.error : fallback;
|
||||
}
|
||||
Reference in new issue
Block a user