mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(enterprise): add TOTP MFA with enrollment, verification, and recovery codes
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user