Files
illusory-mapp/packages/logic/domains/2fa/controller.ts

397 lines
13 KiB
TypeScript

import { errAsync, okAsync, ResultAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context";
import { UserRepository } from "@domains/user/repository";
import { getRedisInstance, Redis } from "@pkg/keystore";
import { TwofaRepository } from "./repository";
import { logDomainEvent } from "@pkg/logger";
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 { db } from "@pkg/db";
export class TwofaController {
constructor(
private twofaRepo: TwofaRepository,
private userRepo: UserRepository,
private store: Redis,
private secret: string,
) {}
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) => {
logDomainEvent({
level: "error",
event: "security.twofa.user_ban_check.failed",
fctx,
error,
meta: { userId },
});
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, this.secret),
)
.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,
) {
const startedAt = Date.now();
logDomainEvent({
event: "security.twofa.verify_and_enable.started",
fctx,
meta: { userId: user.id },
});
return this.is2faEnabled(fctx, user.id)
.andThen((enabled) => {
if (enabled) {
logDomainEvent({
level: "warn",
event: "security.twofa.verify_and_enable.failed",
fctx,
durationMs: Date.now() - startedAt,
error: {
code: "ALREADY_ENABLED",
message: "2FA already enabled",
},
meta: { userId: user.id },
});
return errAsync(twofaErrors.alreadyEnabled(fctx));
}
return okAsync(undefined);
})
.andThen(() =>
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(() => {
logDomainEvent({
event: "security.twofa.verify_and_enable.succeeded",
fctx,
durationMs: Date.now() - startedAt,
meta: { userId: user.id },
});
return true;
});
}
logDomainEvent({
level: "warn",
event: "security.twofa.verify_and_enable.failed",
fctx,
durationMs: Date.now() - startedAt,
error: {
code: "INVALID_CODE",
message: "2FA code verification failed",
},
meta: { userId: user.id },
});
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) => {
logDomainEvent({
level: "error",
event: "security.twofa.mark_initial_verification.failed",
fctx: { flowId: crypto.randomUUID() },
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,
settings.twoFaSecret,
);
}