diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 25a6e6ee..6e1797be 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index c54bd136..097de121 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -342,6 +342,32 @@ export async function authRoutes(app: FastifyInstance): Promise { 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 { 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 { analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null, }, expiresAt: expiresAt.toISOString(), + ...(mfaRequired && { mfaRequired: true }), }); }, ); diff --git a/apps/api/src/plugins/mfa.ts b/apps/api/src/plugins/mfa.ts new file mode 100644 index 00000000..e37b7113 --- /dev/null +++ b/apps/api/src/plugins/mfa.ts @@ -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 { + if (env.DATA_ENCRYPTION_KEY) { + return encrypt(secretBase32, env.DATA_ENCRYPTION_KEY); + } + return secretBase32; +} + +async function decryptSecret(stored: string): Promise { + 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 { + 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 { + // 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 }); + }, + ); +} diff --git a/tests/unit/api/mfa.test.ts b/tests/unit/api/mfa.test.ts new file mode 100644 index 00000000..24857863 --- /dev/null +++ b/tests/unit/api/mfa.test.ts @@ -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); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 78827e10..e19c9286 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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"),