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,
|
ensureBuiltinRoles,
|
||||||
ensureDefaultAdmin,
|
ensureDefaultAdmin,
|
||||||
} from "./plugins/auth.js";
|
} from "./plugins/auth.js";
|
||||||
|
import { registerMfa } from "./plugins/mfa.js";
|
||||||
import { oidcRoutes } from "./plugins/oidc.js";
|
import { oidcRoutes } from "./plugins/oidc.js";
|
||||||
import { registerSaml } from "./plugins/saml.js";
|
import { registerSaml } from "./plugins/saml.js";
|
||||||
import { registerStatic } from "./plugins/static.js";
|
import { registerStatic } from "./plugins/static.js";
|
||||||
@@ -307,6 +308,9 @@ await oidcRoutes(app);
|
|||||||
// SAML routes
|
// SAML routes
|
||||||
await registerSaml(app);
|
await registerSaml(app);
|
||||||
|
|
||||||
|
// MFA routes (TOTP enrollment, verification, disable)
|
||||||
|
await registerMfa(app);
|
||||||
|
|
||||||
// File upload/download routes
|
// File upload/download routes
|
||||||
await fileRoutes(app);
|
await fileRoutes(app);
|
||||||
|
|
||||||
|
|||||||
@@ -342,6 +342,32 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.status(401).send({ error: "Invalid credentials" });
|
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
|
// Create session
|
||||||
const token = createSessionToken();
|
const token = createSessionToken();
|
||||||
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
|
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));
|
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({
|
return reply.send({
|
||||||
token,
|
token,
|
||||||
user: {
|
user: {
|
||||||
@@ -387,6 +423,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null,
|
analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null,
|
||||||
},
|
},
|
||||||
expiresAt: expiresAt.toISOString(),
|
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"),
|
sharp: path.join(apiNodeModules, "sharp"),
|
||||||
ioredis: path.join(apiNodeModules, "ioredis"),
|
ioredis: path.join(apiNodeModules, "ioredis"),
|
||||||
bullmq: path.join(apiNodeModules, "bullmq"),
|
bullmq: path.join(apiNodeModules, "bullmq"),
|
||||||
|
otpauth: path.join(apiNodeModules, "otpauth"),
|
||||||
"openid-client": path.join(apiNodeModules, "openid-client"),
|
"openid-client": path.join(apiNodeModules, "openid-client"),
|
||||||
"opentype.js": path.join(apiNodeModules, "opentype.js"),
|
"opentype.js": path.join(apiNodeModules, "opentype.js"),
|
||||||
"posthog-node": path.join(apiNodeModules, "posthog-node"),
|
"posthog-node": path.join(apiNodeModules, "posthog-node"),
|
||||||
|
|||||||
Reference in New Issue
Block a user