1
17import { Inject, Injectable } from "@nestjs/common";
18import type { Request } from "express";
19import { aes256gcmDecrypt, aes256gcmEncrypt } from "../../lib/crypto.js";
20import { ApiError } from "../../common/errors.js";
21import { PrismaService } from "../../common/prisma.service.js";
22import { RedisService } from "../../common/redis.service.js";
23import { AuditService } from "../../common/audit.service.js";
24import { loadEnv } from "../../config/env.js";
25import { generateTotpSeed, totpVerify } from "./totp.js";
26
27export const TWO_FA_PENDING_TTL_SEC = 300;
28const pendingKey = (id: string) => `auth:2fa:pending:${id}`;
29
30@Injectable()
31export class TwoFactorService {
32 constructor(
33 @Inject(PrismaService) private readonly prisma: PrismaService,
34 @Inject(RedisService) private readonly redis: RedisService,
35 @Inject(AuditService) private readonly audit: AuditService
36 ) {}
37
38 private key(): string {
39 const k = loadEnv().MASTER_ENCRYPTION_KEY;
40 if (!k) {
41 throw new ApiError(
42 "INTERNAL_ERROR",
43 500,
44 "MASTER_ENCRYPTION_KEY is not configured — 2FA secrets cannot be stored."
45 );
46 }
47 return k;
48 }
49
50
51 async getSeed(userId: string): Promise<Buffer | null> {
52 const row = await this.prisma.user.findUnique({
53 where: { id: userId },
54 select: { twoFactorSecret: true }
55 });
56 if (!row?.twoFactorSecret) return null;
57 try {
58 return Buffer.from(aes256gcmDecrypt(row.twoFactorSecret, this.key()), "base64");
59 } catch {
60
61
62
63 throw new ApiError("INTERNAL_ERROR", 500, "Stored 2FA secret is unreadable; reset 2FA enrollment.");
64 }
65 }
66
67
68 async enroll(userId: string, req?: Request): Promise<{ secret: string; otpauthUri: string; confirmed: false }> {
69 const { seed, secret } = generateTotpSeed();
70 const envelope = aes256gcmEncrypt(seed.toString("base64"), this.key());
71 await this.prisma.user.update({ where: { id: userId }, data: { twoFactorSecret: envelope } });
72 const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
73 const host = new URL(loadEnv().WEB_ORIGIN).host;
74 const otpauthUri = `otpauth://totp/${encodeURIComponent(host)}:${encodeURIComponent(
75 user?.email ?? userId
76 )}?secret=${secret}&issuer=${encodeURIComponent(host)}&algorithm=SHA1&digits=6&period=30`;
77 await this.audit.audit(req, req?.auth, {
78 action: "user.2fa_enrolled",
79 resourceType: "user",
80 resourceId: userId,
81 metadata: { method: "totp" }
82 });
83 return { secret, otpauthUri, confirmed: false };
84 }
85
86
87 async disable(userId: string, req?: Request): Promise<void> {
88 await this.prisma.user.update({
89 where: { id: userId },
90 data: { twoFactorSecret: null }
91 });
92 await this.audit.audit(req, req?.auth, {
93 action: "user.2fa_disabled",
94 resourceType: "user",
95 resourceId: userId,
96 metadata: {}
97 });
98 }
99
100 async isEnrolled(userId: string): Promise<boolean> {
101 const row = await this.prisma.user.findUnique({
102 where: { id: userId },
103 select: { twoFactorSecret: true }
104 });
105 return row?.twoFactorSecret != null;
106 }
107
108
112 async createPending(userId: string): Promise<string> {
113 const id = Buffer.from(generateTotpSeed().seed).toString("base64url");
114 await this.redis.getRedis().set(pendingKey(id), userId, "EX", TWO_FA_PENDING_TTL_SEC);
115 return id;
116 }
117
118
122 async verifyPending(verificationId: string, code: string): Promise<string> {
123 const r = this.redis.getRedis();
124
125
126 const stored = await (r as unknown as { getDel(k: string): Promise<string | null> }).getDel(
127 pendingKey(verificationId)
128 );
129 if (!stored) throw ApiError.unauthorized("2FA verification expired.", [{ reason: "2fa_pending_expired" }]);
130 const seed = await this.getSeed(stored);
131 if (!seed || !totpVerify(seed, code)) {
132 throw ApiError.unauthorized("Invalid 2FA code.", [{ reason: "2fa_code_invalid" }]);
133 }
134 return stored;
135 }
136}
137