import { errAsync, okAsync, ResultAsync } from "neverthrow"; import { FlowExecCtx } from "@core/flow.execution.context"; import { UserRepository } from "@domains/user/repository"; import { getRedisInstance, Redis } from "@pkg/redis"; import { TwofaRepository } from "./repository"; import { auth } from "../auth/config.base"; import type { TwoFaSession } from "./data"; import { User } from "@domains/user/data"; import { settings } from "@core/settings"; import { type Err } from "@pkg/result"; import { twofaErrors } from "./errors"; import { logger } from "@pkg/logger"; import { db } from "@pkg/db"; export class TwofaController { constructor( private twofaRepo: TwofaRepository, private userRepo: UserRepository, private store: Redis, ) {} checkTotp(secret: string, code: string) { return this.twofaRepo.checkTotp(secret, code); } is2faEnabled(fctx: FlowExecCtx, userId: string) { return this.twofaRepo .getUsers2FAInfo(fctx, userId, true) .map((data) => !!data) .orElse(() => okAsync(false)); } isUserBanned(fctx: FlowExecCtx, userId: string) { return this.userRepo.isUserBanned(fctx, userId).orElse((error) => { logger.error("Error checking user ban status:", error); return okAsync(false); }); } setup2FA(fctx: FlowExecCtx, user: User) { return this.is2faEnabled(fctx, user.id) .andThen((enabled) => enabled ? errAsync(twofaErrors.alreadyEnabled(fctx)) : this.twofaRepo.setup(fctx, user.id), ) .map((secret) => { const appName = settings.appName; const totpUri = `otpauth://totp/${appName}:${user.email}?secret=${secret}&issuer=${appName}`; return { totpURI: totpUri, secret }; }); } verifyAndEnable2FA( fctx: FlowExecCtx, user: User, code: string, headers: Headers, ) { return this.is2faEnabled(fctx, user.id) .andThen((enabled) => { if (enabled) { return errAsync(twofaErrors.alreadyEnabled(fctx)); } return okAsync(undefined); }) .andThen(() => { logger.info(`Verifying 2fa for ${user.id} : ${code}`, { flowId: fctx.flowId, }); return this.twofaRepo.verifyAndEnable2FA(fctx, user.id, code); }) .andThen((verified) => { if (verified) { return ResultAsync.combine([ ResultAsync.fromPromise( auth.api.revokeOtherSessions({ headers }), () => twofaErrors.revokeSessionsFailed(fctx), ), this.userRepo.updateLastVerified2FaAtToNow( fctx, user.id, ), ]).map(() => true); } return okAsync(verified); }); } disable(fctx: FlowExecCtx, user: User, code: string) { return this.is2faEnabled(fctx, user.id) .andThen((enabled) => { if (!enabled) { return errAsync(twofaErrors.notEnabled(fctx)); } return okAsync(undefined); }) .andThen(() => this.twofaRepo.get2FASecret(fctx, user.id)) .andThen((secret) => { if (!secret) { return errAsync(twofaErrors.invalidSetup(fctx)); } if (!this.checkTotp(secret, code)) { return errAsync(twofaErrors.invalidCode(fctx)); } return okAsync(undefined); }) .andThen(() => this.twofaRepo.disable(fctx, user.id)); } generateBackupCodes(fctx: FlowExecCtx, user: User) { return this.is2faEnabled(fctx, user.id) .andThen((enabled) => { if (!enabled) { return errAsync(twofaErrors.notEnabled(fctx)); } return okAsync(undefined); }) .andThen(() => this.twofaRepo.generateBackupCodes(fctx, user.id)); } requiresInitialVerification( fctx: FlowExecCtx, user: User, sessionId: string, ) { return this.is2faEnabled(fctx, user.id).andThen((enabled) => { if (!enabled) { return okAsync(false); } return ResultAsync.fromPromise( this.store.get(`initial_2fa_completed:${sessionId}`), () => null, ) .map((completed) => !completed && completed !== "0") .orElse(() => okAsync(true)); }); } requiresSensitiveActionVerification(fctx: FlowExecCtx, user: User) { return this.is2faEnabled(fctx, user.id).andThen((enabled) => { if (!enabled) { return okAsync(false); } if (!user.last2FAVerifiedAt) { return okAsync(true); } const requiredHours = settings.twofaRequiredHours || 24; const verificationAge = Date.now() - user.last2FAVerifiedAt.getTime(); const maxAge = requiredHours * 60 * 60 * 1000; return okAsync(verificationAge > maxAge); }); } markInitialVerificationComplete(sessionId: string) { return ResultAsync.fromPromise( this.store.setex( `initial_2fa_completed:${sessionId}`, 60 * 60 * 24 * 7, "true", ), () => null, ) .map(() => undefined) .orElse((error) => { logger.error("Error marking initial 2FA as complete:", error); return okAsync(undefined); }); } startVerification( fctx: FlowExecCtx, params: { userId: string; sessionId: string; ipAddress?: string; userAgent?: string; }, ) { return this.twofaRepo.createSession(fctx, params).map((session) => ({ verificationToken: session.verificationToken, })); } private validateSession(fctx: FlowExecCtx, session: TwoFaSession) { if (session.status !== "pending") { return errAsync(twofaErrors.sessionNotActive(fctx)); } if (session.expiresAt < new Date()) { return this.twofaRepo .updateSession(fctx, session.id, { status: "expired" }) .andThen(() => errAsync(twofaErrors.sessionExpired(fctx))); } return okAsync(session); } private handleMaxAttempts( fctx: FlowExecCtx, session: TwoFaSession, userId: string, ) { const banExpiresAt = new Date(); banExpiresAt.setHours(banExpiresAt.getHours() + 1); return this.twofaRepo .updateSession(fctx, session.id, { status: "failed" }) .andThen(() => this.userRepo.banUser( fctx, userId, "Too many failed 2FA verification attempts", banExpiresAt, ), ) .andThen(() => errAsync(twofaErrors.tooManyAttempts(fctx))); } private checkAttemptsLimit( fctx: FlowExecCtx, session: TwoFaSession, userId: string, ) { if (session.attempts >= session.maxAttempts) { return this.handleMaxAttempts(fctx, session, userId); } return okAsync(session); } private checkCodeReplay( fctx: FlowExecCtx, session: TwoFaSession, code: string, ): ResultAsync { if (session.codeUsed === code) { return this.twofaRepo .incrementAttempts(fctx, session.id) .andThen(() => errAsync(twofaErrors.codeReplay(fctx))); } return okAsync(session); } private verifyTotpCode( fctx: FlowExecCtx, session: TwoFaSession, userId: string, code: string, ) { return this.twofaRepo.get2FASecret(fctx, userId).andThen((secret) => { if (!secret) { return errAsync(twofaErrors.invalidSetup(fctx)); } if (!this.checkTotp(secret, code)) { return this.twofaRepo .incrementAttempts(fctx, session.id) .andThen(() => errAsync(twofaErrors.invalidCode(fctx))); } return okAsync(session); }); } private completeVerification( fctx: FlowExecCtx, session: TwoFaSession, userId: string, code: string, ) { return this.twofaRepo .updateSession(fctx, session.id, { status: "verified", verifiedAt: new Date(), codeUsed: code, }) .andThen(() => ResultAsync.combine([ this.userRepo.updateLastVerified2FaAtToNow(fctx, userId), this.markInitialVerificationComplete(session.sessionId), ]), ) .map(() => undefined); } verifyCode( fctx: FlowExecCtx, params: { verificationSessToken: string; code: string }, user?: User, ) { if (!user) { return errAsync(twofaErrors.userNotFound(fctx)); } return this.is2faEnabled(fctx, user.id) .andThen((enabled) => { if (!enabled) { return errAsync( twofaErrors.notEnabledForVerification(fctx), ); } return okAsync(undefined); }) .andThen(() => this.twofaRepo.getSessionByToken( fctx, params.verificationSessToken, ), ) .andThen((session) => { if (!session) { return errAsync(twofaErrors.sessionNotFound(fctx)); } return okAsync(session); }) .andThen((session) => this.validateSession(fctx, session)) .andThen((session) => this.checkAttemptsLimit(fctx, session, user.id), ) .andThen((session) => this.checkCodeReplay(fctx, session, params.code), ) .andThen((session) => this.verifyTotpCode(fctx, session, user.id, params.code), ) .andThen((session) => this.completeVerification(fctx, session, user.id, params.code), ) .map(() => ({ success: true })); } cleanupExpiredSessions(fctx: FlowExecCtx) { return this.twofaRepo.cleanupExpiredSessions(fctx); } } export function getTwofaController() { const _redis = getRedisInstance(); return new TwofaController( new TwofaRepository(db, _redis), new UserRepository(db), _redis, ); }