import { errAsync, okAsync, ResultAsync } from "neverthrow"; import { FlowExecCtx } from "@core/flow.execution.context"; import { hashString, verifyHash } from "@/core/hash.utils"; import { twoFactor, twofaSessions } from "@pkg/db/schema"; import { TwoFactor, type TwoFaSession } from "./data"; import { crypto } from "@otplib/plugin-crypto-noble"; import { base32 } from "@otplib/plugin-base32-scure"; import { and, Database, eq, gt, lt } from "@pkg/db"; import { generate, verify } from "@otplib/totp"; import { settings } from "@core/settings"; import type { Err } from "@pkg/result"; import { twofaErrors } from "./errors"; import { Redis } from "@pkg/keystore"; import { logDomainEvent, logger } from "@pkg/logger"; import { nanoid } from "nanoid"; type TwoFaSetup = { secret: string; lastUsedCode: string; tries: number; }; export class TwofaRepository { private PENDING_KEY_PREFIX = "pending_enabling_2fa:"; private EXPIRY_TIME = 60 * 20; // 20 mins private DEFAULT_BACKUP_CODES_AMT = 8; private MAX_SETUP_ATTEMPTS = 3; constructor( private db: Database, private store: Redis, ) {} checkTotp(secret: string, code: string) { const checked = verify({ secret, token: code, crypto, base32 }); logger.debug("TOTP check result", { checked }); return checked; } async checkBackupCode(hash: string, code: string) { return verifyHash({ hash, target: code }); } private getKey(userId: string) { if (userId.includes(this.PENDING_KEY_PREFIX)) { return userId; } return `${this.PENDING_KEY_PREFIX}${userId}`; } getUsers2FAInfo( fctx: FlowExecCtx, userId: string, returnUndefined?: boolean, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "security.twofa.get_info.started", fctx, meta: { userId }, }); return ResultAsync.fromPromise( this.db.query.twoFactor.findFirst({ where: eq(twoFactor.userId, userId), }), (error) => { logDomainEvent({ level: "error", event: "security.twofa.get_info.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { userId }, }); return twofaErrors.dbError(fctx, "Failed to query 2FA info"); }, ).andThen((found) => { if (!found) { logDomainEvent({ level: "warn", event: "security.twofa.get_info.failed", fctx, durationMs: Date.now() - startedAt, error: { code: "NOT_FOUND", message: "2FA info not found" }, meta: { userId }, }); if (returnUndefined) { return okAsync(undefined); } return errAsync(twofaErrors.notFound(fctx)); } logDomainEvent({ event: "security.twofa.get_info.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { userId }, }); return okAsync(found as TwoFactor); }); } isSetupPending( fctx: FlowExecCtx, userId: string, ): ResultAsync { logger.debug("Checking if 2FA setup is pending", { ...fctx, userId }); return ResultAsync.fromPromise( this.store.get(this.getKey(userId)), () => twofaErrors.dbError( fctx, "Failed to check setup pending status", ), ).map((found) => { const isPending = !!found; logger.debug("Setup pending status checked", { ...fctx, userId, isPending, }); return isPending; }); } setup( fctx: FlowExecCtx, userId: string, secret: string, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "security.twofa.setup.started", fctx, meta: { userId }, }); return ResultAsync.fromSafePromise( (async () => { const token = await generate({ secret, crypto, base32, }); const payload = { secret: token, lastUsedCode: "", tries: 0, } as TwoFaSetup; await this.store.setex( this.getKey(userId), this.EXPIRY_TIME, JSON.stringify(payload), ); logDomainEvent({ event: "security.twofa.setup.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { userId, expiresInSec: this.EXPIRY_TIME }, }); return secret; })(), ).mapErr((error) => { logDomainEvent({ level: "error", event: "security.twofa.setup.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { userId }, }); return twofaErrors.dbError(fctx, "Setting to data store failed"); }); } verifyAndEnable2FA( fctx: FlowExecCtx, userId: string, code: string, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "security.twofa.verify_enable.started", fctx, meta: { userId }, }); return ResultAsync.fromPromise( this.store.get(this.getKey(userId)), () => twofaErrors.dbError(fctx, "Failed to get setup session"), ) .andThen((payload) => { if (!payload) { logDomainEvent({ level: "warn", event: "security.twofa.verify_enable.failed", fctx, durationMs: Date.now() - startedAt, error: { code: "SETUP_NOT_FOUND", message: "2FA setup session not found", }, meta: { userId }, }); return errAsync(twofaErrors.setupNotFound(fctx)); } return okAsync(JSON.parse(payload) as TwoFaSetup); }) .andThen((payloadObj) => { const key = this.getKey(userId); if (payloadObj.tries >= this.MAX_SETUP_ATTEMPTS) { logDomainEvent({ level: "warn", event: "security.twofa.verify_enable.failed", fctx, durationMs: Date.now() - startedAt, error: { code: "MAX_ATTEMPTS_REACHED", message: "Max setup attempts reached", }, meta: { userId, attempts: payloadObj.tries }, }); return ResultAsync.fromPromise(this.store.del(key), () => twofaErrors.dbError( fctx, "Failed to delete setup session", ), ).andThen(() => errAsync(twofaErrors.maxAttemptsReached(fctx)), ); } if ( !this.checkTotp(payloadObj.secret, code) || code === payloadObj.lastUsedCode ) { logDomainEvent({ level: "warn", event: "security.twofa.verify_enable.failed", fctx, durationMs: Date.now() - startedAt, error: { code: "INVALID_CODE", message: "Invalid or replayed setup code", }, meta: { userId, attempts: payloadObj.tries + 1, codeReused: code === payloadObj.lastUsedCode, }, }); return ResultAsync.fromPromise( this.store.setex( key, this.EXPIRY_TIME, JSON.stringify({ secret: payloadObj.secret, lastUsedCode: code, tries: payloadObj.tries + 1, }), ), () => twofaErrors.dbError( fctx, "Failed to update setup session", ), ).map(() => false); } logger.info("2FA code verified successfully, enabling 2FA", { ...fctx, userId, }); return ResultAsync.fromPromise(this.store.del(key), () => twofaErrors.dbError(fctx, "Failed to delete setup session"), ) .andThen(() => ResultAsync.fromPromise( this.db .insert(twoFactor) .values({ id: nanoid(), secret: payloadObj.secret, userId: userId, createdAt: new Date(), updatedAt: new Date(), }) .execute(), () => twofaErrors.dbError( fctx, "Failed to insert 2FA record", ), ), ) .map(() => { logDomainEvent({ event: "security.twofa.verify_enable.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { userId }, }); return true; }); }); } disable(fctx: FlowExecCtx, userId: string): ResultAsync { logger.info("Disabling 2FA", { ...fctx, userId }); return ResultAsync.fromPromise( this.db .delete(twoFactor) .where(eq(twoFactor.userId, userId)) .execute(), () => twofaErrors.dbError(fctx, "Failed to delete 2FA record"), ).map((result) => { logger.info("2FA disabled successfully", { ...fctx, userId }); return true; }); } generateBackupCodes( fctx: FlowExecCtx, userId: string, ): ResultAsync { logger.info("Generating backup codes", { ...fctx, userId }); return ResultAsync.fromPromise( this.db.query.twoFactor.findFirst({ where: eq(twoFactor.userId, userId), }), () => twofaErrors.dbError(fctx, "Failed to query 2FA info"), ) .andThen((found) => { if (!found) { logger.error("2FA not enabled for user", { ...fctx, userId, }); return errAsync(twofaErrors.backupCodesNotFound(fctx)); } if (found.backupCodes && found.backupCodes.length) { logger.warn("Backup codes already generated", { ...fctx, userId, }); return errAsync( twofaErrors.backupCodesAlreadyGenerated(fctx), ); } return okAsync(found); }) .andThen(() => { const codes = Array.from( { length: this.DEFAULT_BACKUP_CODES_AMT }, () => nanoid(12), ); logger.debug("Backup codes generated, hashing", { ...fctx, userId, count: codes.length, }); return ResultAsync.fromPromise( (async () => { const hashed = []; for (const code of codes) { const hash = await hashString(code); hashed.push(hash); } return { codes, hashed }; })(), () => twofaErrors.dbError( fctx, "Failed to hash backup codes", ), ).andThen(({ codes, hashed }) => ResultAsync.fromPromise( this.db .update(twoFactor) .set({ backupCodes: hashed }) .where(eq(twoFactor.userId, userId)) .returning(), () => twofaErrors.dbError( fctx, "Failed to update backup codes", ), ).map(() => { logger.info("Backup codes generated successfully", { ...fctx, userId, }); return codes; }), ); }); } get2FASecret( fctx: FlowExecCtx, userId: string, ): ResultAsync { logger.debug("Getting 2FA secret", { ...fctx, userId }); return ResultAsync.fromPromise( this.db .select() .from(twoFactor) .where(eq(twoFactor.userId, userId)) .limit(1), () => twofaErrors.dbError(fctx, "Failed to query 2FA secret"), ).map((result) => { if (!result.length) { logger.debug("No 2FA secret found", { ...fctx, userId }); return null; } logger.debug("2FA secret retrieved", { ...fctx, userId }); return result[0].secret; }); } createSession( fctx: FlowExecCtx, params: { userId: string; sessionId: string; ipAddress?: string; userAgent?: string; }, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "security.twofa.create_session.started", fctx, meta: { userId: params.userId, sessionId: params.sessionId }, }); return ResultAsync.fromSafePromise( (async () => { const expiryMinutes = settings.twofaSessionExpiryMinutes || 10; const now = new Date(); const expiresAt = new Date( now.getTime() + expiryMinutes * 60 * 1000, ); return { expiresAt, now, params }; })(), ).andThen(({ expiresAt, now, params }) => ResultAsync.fromPromise( this.db .insert(twofaSessions) .values({ id: nanoid(), userId: params.userId, sessionId: params.sessionId, verificationToken: nanoid(32), status: "pending", attempts: 0, maxAttempts: 5, expiresAt, createdAt: now, ipAddress: params.ipAddress, userAgent: params.userAgent, }) .returning(), (error) => { logDomainEvent({ level: "error", event: "security.twofa.create_session.failed", fctx, durationMs: Date.now() - startedAt, error, meta: { userId: params.userId }, }); return twofaErrors.dbError( fctx, "Failed to create 2FA session", ); }, ).map(([session]) => { logDomainEvent({ event: "security.twofa.create_session.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { twofaSessionId: session.id, userId: params.userId, }, }); return session as TwoFaSession; }), ); } getSessionByToken( fctx: FlowExecCtx, token: string, ): ResultAsync { const startedAt = Date.now(); logDomainEvent({ level: "debug", event: "security.twofa.get_session.started", fctx, }); return ResultAsync.fromPromise( this.db .select() .from(twofaSessions) .where( and( eq(twofaSessions.verificationToken, token), gt(twofaSessions.expiresAt, new Date()), ), ) .limit(1), (error) => { logDomainEvent({ level: "error", event: "security.twofa.get_session.failed", fctx, durationMs: Date.now() - startedAt, error, }); return twofaErrors.dbError(fctx, "Failed to query 2FA session"); }, ).map((result) => { if (!result.length) { logDomainEvent({ level: "warn", event: "security.twofa.get_session.failed", fctx, durationMs: Date.now() - startedAt, error: { code: "SESSION_NOT_FOUND", message: "2FA session not found or expired", }, }); return null; } logDomainEvent({ level: "debug", event: "security.twofa.get_session.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { twofaSessionId: result[0].id }, }); return result[0] as TwoFaSession; }); } updateSession( fctx: FlowExecCtx, id: string, updates: Partial< Pick< TwoFaSession, "status" | "attempts" | "verifiedAt" | "codeUsed" > >, ): ResultAsync { logger.debug("Updating 2FA session", { ...fctx, sessionId: id, updates, }); return ResultAsync.fromPromise( this.db .update(twofaSessions) .set(updates) .where(eq(twofaSessions.id, id)) .returning(), () => twofaErrors.dbError(fctx, "Failed to update 2FA session"), ).andThen(([session]) => { if (!session) { logger.error("2FA session not found for update", { ...fctx, sessionId: id, }); return errAsync(twofaErrors.sessionNotFoundById(fctx)); } logger.debug("2FA session updated successfully", { ...fctx, sessionId: id, }); return okAsync(session as TwoFaSession); }); } incrementAttempts( fctx: FlowExecCtx, id: string, ): ResultAsync { logger.debug("Incrementing session attempts", { ...fctx, sessionId: id, }); return ResultAsync.fromPromise( this.db.query.twofaSessions.findFirst({ where: eq(twofaSessions.id, id), columns: { id: true, attempts: true }, }), () => twofaErrors.dbError( fctx, "Failed to query session for increment", ), ) .andThen((s) => { if (!s) { logger.error("Session not found for increment", { ...fctx, sessionId: id, }); return errAsync(twofaErrors.sessionNotFoundById(fctx)); } return okAsync(s); }) .andThen((s) => ResultAsync.fromPromise( this.db .update(twofaSessions) .set({ attempts: s.attempts + 1 }) .where(eq(twofaSessions.id, id)) .returning(), () => twofaErrors.dbError( fctx, "Failed to increment attempts", ), ).andThen(([session]) => { if (!session) { logger.error("Session not found after increment", { ...fctx, sessionId: id, }); return errAsync(twofaErrors.sessionNotFoundById(fctx)); } logger.warn("Failed verification attempt", { ...fctx, sessionId: session.id, attempts: session.attempts, }); return okAsync(session as TwoFaSession); }), ); } cleanupExpiredSessions(fctx: FlowExecCtx): ResultAsync { const startedAt = Date.now(); logDomainEvent({ event: "security.twofa.cleanup_expired.started", fctx, }); return ResultAsync.fromPromise( this.db .delete(twofaSessions) .where(lt(twofaSessions.expiresAt, new Date())), (error) => { logDomainEvent({ level: "error", event: "security.twofa.cleanup_expired.failed", fctx, durationMs: Date.now() - startedAt, error, }); return twofaErrors.dbError( fctx, "Failed to cleanup expired sessions", ); }, ).map((result) => { const count = result.length || 0; logDomainEvent({ event: "security.twofa.cleanup_expired.succeeded", fctx, durationMs: Date.now() - startedAt, meta: { count }, }); return count; }); } }