397 lines
13 KiB
TypeScript
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,
|
|
);
|
|
}
|