feat(enterprise): add TOTP MFA with enrollment, verification, and recovery codes

This commit is contained in:
SnapOtter
2026-06-13 22:49:00 +08:00
parent a1b5c6d2c3
commit 1787be35fe
5 changed files with 672 additions and 0 deletions
+4
View File
@@ -30,6 +30,7 @@ import {
ensureBuiltinRoles,
ensureDefaultAdmin,
} from "./plugins/auth.js";
import { registerMfa } from "./plugins/mfa.js";
import { oidcRoutes } from "./plugins/oidc.js";
import { registerSaml } from "./plugins/saml.js";
import { registerStatic } from "./plugins/static.js";
@@ -307,6 +308,9 @@ await oidcRoutes(app);
// SAML routes
await registerSaml(app);
// MFA routes (TOTP enrollment, verification, disable)
await registerMfa(app);
// File upload/download routes
await fileRoutes(app);
+37
View File
@@ -342,6 +342,32 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.status(401).send({ error: "Invalid credentials" });
}
// ── MFA challenge ──────────────────────────────────────────
if (user.totpEnabled) {
const mfaToken = randomUUID();
const redis = sharedRedis();
await redis.setex(`mfa:${mfaToken}`, 300, user.id);
await audit("MFA_CHALLENGE_ISSUED", { userId: user.id, username: user.username });
// Determine if MFA policy requires enrollment for this user
let mfaRequired = false;
try {
const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js");
const policy = await getMfaPolicy();
mfaRequired = isMfaRequiredForUser(policy, user.role);
} catch {
// MFA plugin not loaded
}
return reply.status(200).send({
requiresMfa: true,
mfaToken,
mfaRequired,
message: "MFA verification required",
});
}
// Create session
const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
@@ -373,6 +399,16 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, user.team));
// Check if MFA enrollment is required by policy but user hasn't enrolled yet
let mfaRequired = false;
try {
const { getMfaPolicy, isMfaRequiredForUser } = await import("./mfa.js");
const policy = await getMfaPolicy();
mfaRequired = isMfaRequiredForUser(policy, user.role) && !user.totpEnabled;
} catch {
// MFA plugin not loaded
}
return reply.send({
token,
user: {
@@ -387,6 +423,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null,
},
expiresAt: expiresAt.toISOString(),
...(mfaRequired && { mfaRequired: true }),
});
},
);
+439
View File
@@ -0,0 +1,439 @@
import { createHash, randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import * as OTPAuth from "otpauth";
import { z } from "zod";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { sharedRedis } from "../jobs/connection.js";
import { auditFromRequest } from "../lib/audit.js";
import { decrypt, encrypt } from "../lib/encryption.js";
import { getSettingString } from "../lib/settings-helpers.js";
import { getPermissions } from "../permissions.js";
import { createSessionToken, getAuthUser, requireAuth } from "./auth.js";
// ── Constants ─────────────────────────────────────────────────────
const RECOVERY_CODE_COUNT = 8;
const SESSION_DURATION_MS = env.SESSION_DURATION_HOURS * 60 * 60 * 1000;
// ── Zod schemas ───────────────────────────────────────────────────
const verifyCodeSchema = z.object({
code: z.string().min(1, "Code is required").max(20, "Code too long"),
});
const completeSchema = z.object({
mfaToken: z.string().uuid("Invalid MFA token"),
code: z.string().min(1, "Code is required").max(20, "Code too long"),
});
const disableSchema = z.object({
code: z.string().min(1, "Code is required").max(20, "Code too long"),
});
// ── Recovery code helpers ─────────────────────────────────────────
export function hashRecoveryCodes(codes: string[]): string {
return codes.map((c) => createHash("sha256").update(c).digest("hex")).join(",");
}
export function verifyRecoveryCode(
code: string,
hashList: string,
): { valid: boolean; remaining: string } {
const hashes = hashList.split(",");
const codeHash = createHash("sha256").update(code).digest("hex");
const idx = hashes.indexOf(codeHash);
if (idx === -1) return { valid: false, remaining: hashList };
hashes.splice(idx, 1);
return { valid: true, remaining: hashes.join(",") };
}
function generateRecoveryCodes(): string[] {
return Array.from({ length: RECOVERY_CODE_COUNT }, () => randomBytes(4).toString("hex"));
}
// ── TOTP helpers ──────────────────────────────────────────────────
export function createTotp(username: string, secretBase32?: string): OTPAuth.TOTP {
return new OTPAuth.TOTP({
issuer: "SnapOtter",
label: username,
algorithm: "SHA1",
digits: 6,
period: 30,
secret: secretBase32
? OTPAuth.Secret.fromBase32(secretBase32)
: new OTPAuth.Secret({ size: 20 }),
});
}
export function verifyTotpCode(secretBase32: string, code: string): boolean {
const totp = createTotp("verify", secretBase32);
// Allow 1-step window in either direction for clock drift
const delta = totp.validate({ token: code, window: 1 });
return delta !== null;
}
async function encryptSecret(secretBase32: string): Promise<string> {
if (env.DATA_ENCRYPTION_KEY) {
return encrypt(secretBase32, env.DATA_ENCRYPTION_KEY);
}
return secretBase32;
}
async function decryptSecret(stored: string): Promise<string | null> {
if (env.DATA_ENCRYPTION_KEY) {
return decrypt(stored, env.DATA_ENCRYPTION_KEY, env.DATA_ENCRYPTION_KEY_PREVIOUS || undefined);
}
return stored;
}
// ── MFA policy helpers ────────────────────────────────────────────
export type MfaPolicy = "optional" | "admins_only" | "required";
export async function getMfaPolicy(): Promise<MfaPolicy> {
const raw = await getSettingString("mfaPolicy", "optional");
if (raw === "required" || raw === "admins_only") return raw;
return "optional";
}
export function isMfaRequiredForUser(policy: MfaPolicy, userRole: string): boolean {
if (policy === "required") return true;
if (policy === "admins_only" && userRole === "admin") return true;
return false;
}
// ── MFA plugin registration ───────────────────────────────────────
export async function registerMfa(app: FastifyInstance): Promise<void> {
// POST /api/auth/mfa/enroll -- start MFA enrollment
app.post("/api/auth/mfa/enroll", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
// Check enterprise feature gate
let mfaLicensed = false;
try {
const { isFeatureEnabled } = await import("@snapotter/enterprise");
mfaLicensed = isFeatureEnabled("mfa");
} catch {
// Enterprise package not available
}
if (!mfaLicensed) {
return reply.status(403).send({
error: "MFA requires an enterprise license",
code: "FEATURE_NOT_LICENSED",
});
}
// Check if already enrolled
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id));
if (!dbUser) {
return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" });
}
if (dbUser.totpEnabled) {
return reply.status(409).send({
error: "MFA is already enabled. Disable it first to re-enroll.",
code: "MFA_ALREADY_ENABLED",
});
}
// Generate TOTP secret
const totp = createTotp(user.username);
const uri = totp.toString();
// Generate recovery codes
const recoveryCodes = generateRecoveryCodes();
const recoveryHash = hashRecoveryCodes(recoveryCodes);
// Encrypt TOTP secret for storage
const encryptedSecret = await encryptSecret(totp.secret.base32);
// Store pending enrollment (not yet active)
await db
.update(schema.users)
.set({
totpSecret: encryptedSecret,
totpEnabled: false,
recoveryCodesHash: recoveryHash,
updatedAt: new Date(),
})
.where(eq(schema.users.id, user.id));
return reply.send({ uri, recoveryCodes });
});
// POST /api/auth/mfa/verify -- confirm enrollment with a TOTP code
app.post("/api/auth/mfa/verify", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const parsed = verifyCodeSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "A valid TOTP code is required",
code: "VALIDATION_ERROR",
});
}
const { code } = parsed.data;
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id));
if (!dbUser?.totpSecret) {
return reply.status(400).send({
error: "No pending MFA enrollment found. Call /api/auth/mfa/enroll first.",
code: "NO_PENDING_ENROLLMENT",
});
}
if (dbUser.totpEnabled) {
return reply.status(409).send({
error: "MFA is already verified and active",
code: "MFA_ALREADY_ENABLED",
});
}
// Decrypt the stored secret
const secretBase32 = await decryptSecret(dbUser.totpSecret);
if (!secretBase32) {
return reply.status(500).send({
error: "Failed to decrypt TOTP secret",
code: "DECRYPTION_FAILED",
});
}
// Validate the code
if (!verifyTotpCode(secretBase32, code)) {
return reply.status(401).send({
error: "Invalid TOTP code",
code: "INVALID_CODE",
});
}
// Activate MFA
await db
.update(schema.users)
.set({ totpEnabled: true, updatedAt: new Date() })
.where(eq(schema.users.id, user.id));
const audit = auditFromRequest(request);
await audit("MFA_ENROLLED", { userId: user.id, username: user.username });
return reply.send({ ok: true });
});
// POST /api/auth/mfa/complete -- complete login with TOTP code
app.post("/api/auth/mfa/complete", async (request: FastifyRequest, reply: FastifyReply) => {
const parsed = completeSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "MFA token and code are required",
code: "VALIDATION_ERROR",
});
}
const { mfaToken, code } = parsed.data;
// Look up the pending MFA challenge in Redis
const redis = sharedRedis();
const userId = await redis.get(`mfa:${mfaToken}`);
if (!userId) {
return reply.status(401).send({
error: "MFA challenge expired or invalid",
code: "MFA_EXPIRED",
});
}
// Load user
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, userId));
if (!dbUser?.totpSecret) {
return reply.status(401).send({
error: "User not found or MFA not configured",
code: "MFA_NOT_CONFIGURED",
});
}
// Decrypt the stored secret
const secretBase32 = await decryptSecret(dbUser.totpSecret);
if (!secretBase32) {
return reply.status(500).send({
error: "Failed to decrypt TOTP secret",
code: "DECRYPTION_FAILED",
});
}
const audit = auditFromRequest(request);
let verified = false;
let recoveryUsed = false;
// Try TOTP code first
if (verifyTotpCode(secretBase32, code)) {
verified = true;
}
// Try recovery code if TOTP failed
if (!verified && dbUser.recoveryCodesHash) {
const result = verifyRecoveryCode(code, dbUser.recoveryCodesHash);
if (result.valid) {
verified = true;
recoveryUsed = true;
// Consume the recovery code
await db
.update(schema.users)
.set({ recoveryCodesHash: result.remaining || null, updatedAt: new Date() })
.where(eq(schema.users.id, userId));
}
}
if (!verified) {
await audit("MFA_VERIFY_FAILED", { userId, username: dbUser.username });
return reply.status(401).send({
error: "Invalid TOTP or recovery code",
code: "INVALID_CODE",
});
}
// Delete the challenge token
await redis.del(`mfa:${mfaToken}`);
// Create session (same as normal login completion)
const token = createSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.insert(schema.sessions).values({
id: token,
userId: dbUser.id,
expiresAt,
});
await audit(recoveryUsed ? "MFA_RECOVERY_USED" : "MFA_VERIFIED", {
userId: dbUser.id,
username: dbUser.username,
});
const [teamRow] = await db.select().from(schema.teams).where(eq(schema.teams.id, dbUser.team));
return reply.send({
token,
user: {
id: dbUser.id,
username: dbUser.username,
role: dbUser.role,
mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : dbUser.mustChangePassword,
permissions: await getPermissions(dbUser.role),
teamName: teamRow?.name ?? dbUser.team,
analyticsEnabled: dbUser.analyticsEnabled ?? null,
analyticsConsentShownAt: dbUser.analyticsConsentShownAt?.getTime() ?? null,
analyticsConsentRemindAt: dbUser.analyticsConsentRemindAt?.getTime() ?? null,
},
expiresAt: expiresAt.toISOString(),
});
});
// POST /api/auth/mfa/disable -- disable MFA (self-service)
app.post("/api/auth/mfa/disable", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const parsed = disableSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Current TOTP code is required to disable MFA",
code: "VALIDATION_ERROR",
});
}
const { code } = parsed.data;
const [dbUser] = await db.select().from(schema.users).where(eq(schema.users.id, user.id));
if (!dbUser?.totpEnabled || !dbUser.totpSecret) {
return reply.status(400).send({
error: "MFA is not enabled",
code: "MFA_NOT_ENABLED",
});
}
// Decrypt and verify the code
const secretBase32 = await decryptSecret(dbUser.totpSecret);
if (!secretBase32) {
return reply.status(500).send({
error: "Failed to decrypt TOTP secret",
code: "DECRYPTION_FAILED",
});
}
if (!verifyTotpCode(secretBase32, code)) {
return reply.status(401).send({
error: "Invalid TOTP code",
code: "INVALID_CODE",
});
}
// Clear MFA data
await db
.update(schema.users)
.set({
totpSecret: null,
totpEnabled: false,
recoveryCodesHash: null,
updatedAt: new Date(),
})
.where(eq(schema.users.id, user.id));
const audit = auditFromRequest(request);
await audit("MFA_DISABLED", { userId: user.id, username: user.username });
return reply.send({ ok: true });
});
// POST /api/auth/users/:id/mfa/reset -- admin reset
app.post(
"/api/auth/users/:id/mfa/reset",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const admin = getAuthUser(request);
if (!admin) {
return reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
}
// Check users:manage permission
const { hasEffectivePermission } = await import("../permissions.js");
if (!(await hasEffectivePermission(admin, "users:manage"))) {
return reply.status(403).send({ error: "Insufficient permissions", code: "FORBIDDEN" });
}
const { id } = request.params;
const [targetUser] = await db.select().from(schema.users).where(eq(schema.users.id, id));
if (!targetUser) {
return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" });
}
if (!targetUser.totpEnabled) {
return reply.status(400).send({
error: "MFA is not enabled for this user",
code: "MFA_NOT_ENABLED",
});
}
// Clear MFA data
await db
.update(schema.users)
.set({
totpSecret: null,
totpEnabled: false,
recoveryCodesHash: null,
updatedAt: new Date(),
})
.where(eq(schema.users.id, id));
const audit = auditFromRequest(request);
await audit("MFA_RESET", {
adminId: admin.id,
targetUserId: id,
targetUsername: targetUser.username,
});
return reply.send({ ok: true });
},
);
}
+191
View File
@@ -0,0 +1,191 @@
import * as OTPAuth from "otpauth";
import { describe, expect, it } from "vitest";
import {
createTotp,
hashRecoveryCodes,
isMfaRequiredForUser,
verifyRecoveryCode,
verifyTotpCode,
} from "../../../apps/api/src/plugins/mfa.js";
describe("MFA", () => {
describe("createTotp", () => {
it("generates a valid TOTP URI", () => {
const totp = createTotp("testuser");
const uri = totp.toString();
expect(uri).toContain("otpauth://totp/");
expect(uri).toContain("SnapOtter");
expect(uri).toContain("testuser");
expect(uri).toContain("algorithm=SHA1");
expect(uri).toContain("digits=6");
expect(uri).toContain("period=30");
});
it("generates a TOTP with a valid secret", () => {
const totp = createTotp("testuser");
expect(totp.secret.base32).toMatch(/^[A-Z2-7]+=*$/);
// 20-byte secret = 32 base32 chars
expect(totp.secret.base32.length).toBe(32);
});
it("uses a provided secret when given", () => {
const knownSecret = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";
const totp = createTotp("testuser", knownSecret);
expect(totp.secret.base32).toBe(knownSecret);
});
it("generates unique secrets across calls", () => {
const a = createTotp("user1");
const b = createTotp("user2");
expect(a.secret.base32).not.toBe(b.secret.base32);
});
});
describe("verifyTotpCode", () => {
it("verifies a correct TOTP code", () => {
const totp = createTotp("testuser");
const secret = totp.secret.base32;
const code = totp.generate();
expect(verifyTotpCode(secret, code)).toBe(true);
});
it("rejects an incorrect TOTP code", () => {
const totp = createTotp("testuser");
const secret = totp.secret.base32;
expect(verifyTotpCode(secret, "000000")).toBe(false);
});
it("rejects an empty code", () => {
const totp = createTotp("testuser");
const secret = totp.secret.base32;
expect(verifyTotpCode(secret, "")).toBe(false);
});
it("rejects a code from a different secret", () => {
const totp1 = createTotp("user1");
const totp2 = createTotp("user2");
const code = totp1.generate();
expect(verifyTotpCode(totp2.secret.base32, code)).toBe(false);
});
it("accepts codes within the 1-step window", () => {
const secret = new OTPAuth.Secret({ size: 20 });
const totp = new OTPAuth.TOTP({
issuer: "SnapOtter",
label: "test",
algorithm: "SHA1",
digits: 6,
period: 30,
secret,
});
// Generate code for the current period
const code = totp.generate();
expect(verifyTotpCode(secret.base32, code)).toBe(true);
});
});
describe("hashRecoveryCodes", () => {
it("hashes recovery codes into comma-separated SHA-256 hashes", () => {
const codes = ["abcd1234", "efgh5678"];
const result = hashRecoveryCodes(codes);
const parts = result.split(",");
expect(parts).toHaveLength(2);
// Each hash should be 64 hex chars (256 bits)
for (const hash of parts) {
expect(hash).toMatch(/^[0-9a-f]{64}$/);
}
});
it("produces deterministic hashes", () => {
const codes = ["code1", "code2", "code3"];
const a = hashRecoveryCodes(codes);
const b = hashRecoveryCodes(codes);
expect(a).toBe(b);
});
it("handles a single code", () => {
const result = hashRecoveryCodes(["onlycode"]);
expect(result).not.toContain(",");
expect(result).toMatch(/^[0-9a-f]{64}$/);
});
});
describe("verifyRecoveryCode", () => {
it("verifies a valid recovery code", () => {
const codes = ["aaaa1111", "bbbb2222", "cccc3333"];
const hashList = hashRecoveryCodes(codes);
const result = verifyRecoveryCode("bbbb2222", hashList);
expect(result.valid).toBe(true);
});
it("rejects an invalid recovery code", () => {
const codes = ["aaaa1111", "bbbb2222"];
const hashList = hashRecoveryCodes(codes);
const result = verifyRecoveryCode("invalid0", hashList);
expect(result.valid).toBe(false);
expect(result.remaining).toBe(hashList);
});
it("consumes a recovery code on use", () => {
const codes = ["aaaa1111", "bbbb2222", "cccc3333"];
const hashList = hashRecoveryCodes(codes);
const result = verifyRecoveryCode("bbbb2222", hashList);
expect(result.valid).toBe(true);
// Remaining should have 2 hashes
const remaining = result.remaining.split(",");
expect(remaining).toHaveLength(2);
// The used code should no longer verify
const secondTry = verifyRecoveryCode("bbbb2222", result.remaining);
expect(secondTry.valid).toBe(false);
});
it("returns empty remaining when last code is used", () => {
const codes = ["onlycode"];
const hashList = hashRecoveryCodes(codes);
const result = verifyRecoveryCode("onlycode", hashList);
expect(result.valid).toBe(true);
expect(result.remaining).toBe("");
});
it("preserves other codes when one is consumed", () => {
const codes = ["first000", "second00", "third000"];
const hashList = hashRecoveryCodes(codes);
// Use the first code
const r1 = verifyRecoveryCode("first000", hashList);
expect(r1.valid).toBe(true);
// Second and third should still work
const r2 = verifyRecoveryCode("second00", r1.remaining);
expect(r2.valid).toBe(true);
const r3 = verifyRecoveryCode("third000", r2.remaining);
expect(r3.valid).toBe(true);
expect(r3.remaining).toBe("");
});
});
describe("isMfaRequiredForUser", () => {
it("returns false for optional policy", () => {
expect(isMfaRequiredForUser("optional", "admin")).toBe(false);
expect(isMfaRequiredForUser("optional", "editor")).toBe(false);
expect(isMfaRequiredForUser("optional", "user")).toBe(false);
});
it("returns true for required policy regardless of role", () => {
expect(isMfaRequiredForUser("required", "admin")).toBe(true);
expect(isMfaRequiredForUser("required", "editor")).toBe(true);
expect(isMfaRequiredForUser("required", "user")).toBe(true);
});
it("returns true for admins_only policy when role is admin", () => {
expect(isMfaRequiredForUser("admins_only", "admin")).toBe(true);
});
it("returns false for admins_only policy when role is not admin", () => {
expect(isMfaRequiredForUser("admins_only", "editor")).toBe(false);
expect(isMfaRequiredForUser("admins_only", "user")).toBe(false);
});
});
});
+1
View File
@@ -122,6 +122,7 @@ export default defineConfig({
sharp: path.join(apiNodeModules, "sharp"),
ioredis: path.join(apiNodeModules, "ioredis"),
bullmq: path.join(apiNodeModules, "bullmq"),
otpauth: path.join(apiNodeModules, "otpauth"),
"openid-client": path.join(apiNodeModules, "openid-client"),
"opentype.js": path.join(apiNodeModules, "opentype.js"),
"posthog-node": path.join(apiNodeModules, "posthog-node"),