& so it begins
This commit is contained in:
349
packages/logic/domains/2fa/controller.ts
Normal file
349
packages/logic/domains/2fa/controller.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
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<TwoFaSession, Err> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user