servcraft 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/.claude/settings.local.json +29 -0
  2. package/.github/CODEOWNERS +18 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
  4. package/.github/dependabot.yml +59 -0
  5. package/.github/workflows/ci.yml +188 -0
  6. package/.github/workflows/release.yml +195 -0
  7. package/AUDIT.md +602 -0
  8. package/README.md +1070 -1
  9. package/dist/cli/index.cjs +2026 -2168
  10. package/dist/cli/index.cjs.map +1 -1
  11. package/dist/cli/index.js +2026 -2168
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/index.cjs +595 -616
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +114 -52
  16. package/dist/index.d.ts +114 -52
  17. package/dist/index.js +595 -616
  18. package/dist/index.js.map +1 -1
  19. package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
  20. package/docs/DATABASE_MULTI_ORM.md +399 -0
  21. package/docs/PHASE1_BREAKDOWN.md +346 -0
  22. package/docs/PROGRESS.md +550 -0
  23. package/docs/modules/ANALYTICS.md +226 -0
  24. package/docs/modules/API-VERSIONING.md +252 -0
  25. package/docs/modules/AUDIT.md +192 -0
  26. package/docs/modules/AUTH.md +431 -0
  27. package/docs/modules/CACHE.md +346 -0
  28. package/docs/modules/EMAIL.md +254 -0
  29. package/docs/modules/FEATURE-FLAG.md +291 -0
  30. package/docs/modules/I18N.md +294 -0
  31. package/docs/modules/MEDIA-PROCESSING.md +281 -0
  32. package/docs/modules/MFA.md +266 -0
  33. package/docs/modules/NOTIFICATION.md +311 -0
  34. package/docs/modules/OAUTH.md +237 -0
  35. package/docs/modules/PAYMENT.md +804 -0
  36. package/docs/modules/QUEUE.md +540 -0
  37. package/docs/modules/RATE-LIMIT.md +339 -0
  38. package/docs/modules/SEARCH.md +288 -0
  39. package/docs/modules/SECURITY.md +327 -0
  40. package/docs/modules/SESSION.md +382 -0
  41. package/docs/modules/SWAGGER.md +305 -0
  42. package/docs/modules/UPLOAD.md +296 -0
  43. package/docs/modules/USER.md +505 -0
  44. package/docs/modules/VALIDATION.md +294 -0
  45. package/docs/modules/WEBHOOK.md +270 -0
  46. package/docs/modules/WEBSOCKET.md +691 -0
  47. package/package.json +53 -38
  48. package/prisma/schema.prisma +395 -1
  49. package/src/cli/commands/add-module.ts +520 -87
  50. package/src/cli/commands/db.ts +3 -4
  51. package/src/cli/commands/docs.ts +256 -6
  52. package/src/cli/commands/generate.ts +12 -19
  53. package/src/cli/commands/init.ts +384 -214
  54. package/src/cli/index.ts +0 -4
  55. package/src/cli/templates/repository.ts +6 -1
  56. package/src/cli/templates/routes.ts +6 -21
  57. package/src/cli/utils/docs-generator.ts +6 -7
  58. package/src/cli/utils/env-manager.ts +717 -0
  59. package/src/cli/utils/field-parser.ts +16 -7
  60. package/src/cli/utils/interactive-prompt.ts +223 -0
  61. package/src/cli/utils/template-manager.ts +346 -0
  62. package/src/config/database.config.ts +183 -0
  63. package/src/config/env.ts +0 -10
  64. package/src/config/index.ts +0 -14
  65. package/src/core/server.ts +1 -1
  66. package/src/database/adapters/mongoose.adapter.ts +132 -0
  67. package/src/database/adapters/prisma.adapter.ts +118 -0
  68. package/src/database/connection.ts +190 -0
  69. package/src/database/interfaces/database.interface.ts +85 -0
  70. package/src/database/interfaces/index.ts +7 -0
  71. package/src/database/interfaces/repository.interface.ts +129 -0
  72. package/src/database/models/mongoose/index.ts +7 -0
  73. package/src/database/models/mongoose/payment.schema.ts +347 -0
  74. package/src/database/models/mongoose/user.schema.ts +154 -0
  75. package/src/database/prisma.ts +1 -4
  76. package/src/database/redis.ts +101 -0
  77. package/src/database/repositories/mongoose/index.ts +7 -0
  78. package/src/database/repositories/mongoose/payment.repository.ts +380 -0
  79. package/src/database/repositories/mongoose/user.repository.ts +255 -0
  80. package/src/database/seed.ts +6 -1
  81. package/src/index.ts +9 -20
  82. package/src/middleware/security.ts +2 -6
  83. package/src/modules/analytics/analytics.routes.ts +80 -0
  84. package/src/modules/analytics/analytics.service.ts +364 -0
  85. package/src/modules/analytics/index.ts +18 -0
  86. package/src/modules/analytics/types.ts +180 -0
  87. package/src/modules/api-versioning/index.ts +15 -0
  88. package/src/modules/api-versioning/types.ts +86 -0
  89. package/src/modules/api-versioning/versioning.middleware.ts +120 -0
  90. package/src/modules/api-versioning/versioning.routes.ts +54 -0
  91. package/src/modules/api-versioning/versioning.service.ts +189 -0
  92. package/src/modules/audit/audit.repository.ts +206 -0
  93. package/src/modules/audit/audit.service.ts +27 -59
  94. package/src/modules/auth/auth.controller.ts +2 -2
  95. package/src/modules/auth/auth.middleware.ts +3 -9
  96. package/src/modules/auth/auth.routes.ts +10 -107
  97. package/src/modules/auth/auth.service.ts +126 -23
  98. package/src/modules/auth/index.ts +3 -4
  99. package/src/modules/cache/cache.service.ts +367 -0
  100. package/src/modules/cache/index.ts +10 -0
  101. package/src/modules/cache/types.ts +44 -0
  102. package/src/modules/email/email.service.ts +3 -10
  103. package/src/modules/email/templates.ts +2 -8
  104. package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
  105. package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
  106. package/src/modules/feature-flag/feature-flag.service.ts +566 -0
  107. package/src/modules/feature-flag/index.ts +20 -0
  108. package/src/modules/feature-flag/types.ts +192 -0
  109. package/src/modules/i18n/i18n.middleware.ts +186 -0
  110. package/src/modules/i18n/i18n.routes.ts +191 -0
  111. package/src/modules/i18n/i18n.service.ts +456 -0
  112. package/src/modules/i18n/index.ts +18 -0
  113. package/src/modules/i18n/types.ts +118 -0
  114. package/src/modules/media-processing/index.ts +17 -0
  115. package/src/modules/media-processing/media-processing.routes.ts +111 -0
  116. package/src/modules/media-processing/media-processing.service.ts +245 -0
  117. package/src/modules/media-processing/types.ts +156 -0
  118. package/src/modules/mfa/index.ts +20 -0
  119. package/src/modules/mfa/mfa.repository.ts +206 -0
  120. package/src/modules/mfa/mfa.routes.ts +595 -0
  121. package/src/modules/mfa/mfa.service.ts +572 -0
  122. package/src/modules/mfa/totp.ts +150 -0
  123. package/src/modules/mfa/types.ts +57 -0
  124. package/src/modules/notification/index.ts +20 -0
  125. package/src/modules/notification/notification.repository.ts +356 -0
  126. package/src/modules/notification/notification.service.ts +483 -0
  127. package/src/modules/notification/types.ts +119 -0
  128. package/src/modules/oauth/index.ts +20 -0
  129. package/src/modules/oauth/oauth.repository.ts +219 -0
  130. package/src/modules/oauth/oauth.routes.ts +446 -0
  131. package/src/modules/oauth/oauth.service.ts +293 -0
  132. package/src/modules/oauth/providers/apple.provider.ts +250 -0
  133. package/src/modules/oauth/providers/facebook.provider.ts +181 -0
  134. package/src/modules/oauth/providers/github.provider.ts +248 -0
  135. package/src/modules/oauth/providers/google.provider.ts +189 -0
  136. package/src/modules/oauth/providers/twitter.provider.ts +214 -0
  137. package/src/modules/oauth/types.ts +94 -0
  138. package/src/modules/payment/index.ts +19 -0
  139. package/src/modules/payment/payment.repository.ts +733 -0
  140. package/src/modules/payment/payment.routes.ts +390 -0
  141. package/src/modules/payment/payment.service.ts +354 -0
  142. package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
  143. package/src/modules/payment/providers/paypal.provider.ts +190 -0
  144. package/src/modules/payment/providers/stripe.provider.ts +215 -0
  145. package/src/modules/payment/types.ts +140 -0
  146. package/src/modules/queue/cron.ts +438 -0
  147. package/src/modules/queue/index.ts +87 -0
  148. package/src/modules/queue/queue.routes.ts +600 -0
  149. package/src/modules/queue/queue.service.ts +842 -0
  150. package/src/modules/queue/types.ts +222 -0
  151. package/src/modules/queue/workers.ts +366 -0
  152. package/src/modules/rate-limit/index.ts +59 -0
  153. package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
  154. package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
  155. package/src/modules/rate-limit/rate-limit.service.ts +348 -0
  156. package/src/modules/rate-limit/stores/memory.store.ts +165 -0
  157. package/src/modules/rate-limit/stores/redis.store.ts +322 -0
  158. package/src/modules/rate-limit/types.ts +153 -0
  159. package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
  160. package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
  161. package/src/modules/search/adapters/memory.adapter.ts +278 -0
  162. package/src/modules/search/index.ts +21 -0
  163. package/src/modules/search/search.service.ts +234 -0
  164. package/src/modules/search/types.ts +214 -0
  165. package/src/modules/security/index.ts +40 -0
  166. package/src/modules/security/sanitize.ts +223 -0
  167. package/src/modules/security/security-audit.service.ts +388 -0
  168. package/src/modules/security/security.middleware.ts +398 -0
  169. package/src/modules/session/index.ts +3 -0
  170. package/src/modules/session/session.repository.ts +159 -0
  171. package/src/modules/session/session.service.ts +340 -0
  172. package/src/modules/session/types.ts +38 -0
  173. package/src/modules/swagger/index.ts +7 -1
  174. package/src/modules/swagger/schema-builder.ts +16 -4
  175. package/src/modules/swagger/swagger.service.ts +9 -10
  176. package/src/modules/swagger/types.ts +0 -2
  177. package/src/modules/upload/index.ts +14 -0
  178. package/src/modules/upload/types.ts +83 -0
  179. package/src/modules/upload/upload.repository.ts +199 -0
  180. package/src/modules/upload/upload.routes.ts +311 -0
  181. package/src/modules/upload/upload.service.ts +448 -0
  182. package/src/modules/user/index.ts +3 -3
  183. package/src/modules/user/user.controller.ts +15 -9
  184. package/src/modules/user/user.repository.ts +237 -113
  185. package/src/modules/user/user.routes.ts +39 -164
  186. package/src/modules/user/user.service.ts +4 -3
  187. package/src/modules/validation/validator.ts +12 -17
  188. package/src/modules/webhook/index.ts +91 -0
  189. package/src/modules/webhook/retry.ts +196 -0
  190. package/src/modules/webhook/signature.ts +135 -0
  191. package/src/modules/webhook/types.ts +181 -0
  192. package/src/modules/webhook/webhook.repository.ts +358 -0
  193. package/src/modules/webhook/webhook.routes.ts +442 -0
  194. package/src/modules/webhook/webhook.service.ts +457 -0
  195. package/src/modules/websocket/features.ts +504 -0
  196. package/src/modules/websocket/index.ts +106 -0
  197. package/src/modules/websocket/middlewares.ts +298 -0
  198. package/src/modules/websocket/types.ts +181 -0
  199. package/src/modules/websocket/websocket.service.ts +692 -0
  200. package/src/utils/errors.ts +7 -0
  201. package/src/utils/pagination.ts +4 -1
  202. package/tests/helpers/db-check.ts +79 -0
  203. package/tests/integration/auth-redis.test.ts +94 -0
  204. package/tests/integration/cache-redis.test.ts +387 -0
  205. package/tests/integration/mongoose-repositories.test.ts +410 -0
  206. package/tests/integration/payment-prisma.test.ts +637 -0
  207. package/tests/integration/queue-bullmq.test.ts +417 -0
  208. package/tests/integration/user-prisma.test.ts +441 -0
  209. package/tests/integration/websocket-socketio.test.ts +552 -0
  210. package/tests/setup.ts +11 -9
  211. package/vitest.config.ts +3 -8
  212. package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
  213. package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
  214. package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
  215. package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
  216. package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
@@ -0,0 +1,572 @@
1
+ /**
2
+ * MFA Service
3
+ * Multi-Factor Authentication with TOTP, SMS, Email, and Backup Codes
4
+ *
5
+ * Persistence:
6
+ * - User MFA settings: Prisma/PostgreSQL (persistent)
7
+ * - MFA challenges: Redis with TTL (5-minute expiration)
8
+ * - Failed attempts/lockouts: Redis with TTL (15-minute expiration)
9
+ */
10
+ import { randomBytes, randomUUID } from 'crypto';
11
+ import { logger } from '../../core/logger.js';
12
+ import { BadRequestError } from '../../utils/errors.js';
13
+ import { prisma } from '../../database/prisma.js';
14
+ import { getRedis } from '../../database/redis.js';
15
+ import { MFARepository } from './mfa.repository.js';
16
+ import {
17
+ generateSecret,
18
+ verifyTOTP,
19
+ generateTOTPUri,
20
+ generateQRCode,
21
+ formatSecretForDisplay,
22
+ } from './totp.js';
23
+ import type {
24
+ MFAConfig,
25
+ MFAMethod,
26
+ UserMFA,
27
+ TOTPSetup,
28
+ MFAChallenge,
29
+ MFAVerifyResult,
30
+ BackupCodesResult,
31
+ } from './types.js';
32
+
33
+ // Redis key prefixes
34
+ const MFA_CHALLENGE_PREFIX = 'mfa:challenge:';
35
+ const MFA_ATTEMPTS_PREFIX = 'mfa:attempts:';
36
+
37
+ // Expiration times
38
+ const CHALLENGE_EXPIRATION_SECONDS = 5 * 60; // 5 minutes
39
+ const LOCKOUT_EXPIRATION_SECONDS = 15 * 60; // 15 minutes
40
+
41
+ const MAX_ATTEMPTS = 5;
42
+
43
+ const defaultConfig: MFAConfig = {
44
+ issuer: 'Servcraft',
45
+ totpWindow: 1,
46
+ backupCodesCount: 10,
47
+ };
48
+
49
+ interface FailedAttempts {
50
+ count: number;
51
+ lockedUntil?: string;
52
+ }
53
+
54
+ export class MFAService {
55
+ private config: MFAConfig;
56
+ private repository: MFARepository;
57
+
58
+ constructor(config: Partial<MFAConfig> = {}) {
59
+ this.config = { ...defaultConfig, ...config };
60
+ this.repository = new MFARepository(prisma);
61
+ }
62
+
63
+ // TOTP Setup
64
+ async setupTOTP(userId: string, email: string): Promise<TOTPSetup> {
65
+ const secret = generateSecret();
66
+ const uri = generateTOTPUri(secret, email, this.config.issuer);
67
+ const qrCode = await generateQRCode(uri);
68
+
69
+ // Get or create user MFA
70
+ let userMFA = await this.repository.getByUserId(userId);
71
+ if (!userMFA) {
72
+ userMFA = this.createUserMFA(userId);
73
+ }
74
+
75
+ userMFA.totpSecret = secret;
76
+ userMFA.totpVerified = false;
77
+
78
+ await this.repository.upsert(userMFA);
79
+
80
+ logger.info({ userId }, 'TOTP setup initiated');
81
+
82
+ return {
83
+ secret,
84
+ qrCode,
85
+ manualEntry: formatSecretForDisplay(secret),
86
+ uri,
87
+ };
88
+ }
89
+
90
+ async verifyTOTPSetup(userId: string, code: string): Promise<boolean> {
91
+ const userMFA = await this.repository.getByUserId(userId);
92
+ if (!userMFA || !userMFA.totpSecret) {
93
+ throw new BadRequestError('TOTP not set up');
94
+ }
95
+
96
+ if (userMFA.totpVerified) {
97
+ throw new BadRequestError('TOTP already verified');
98
+ }
99
+
100
+ const isValid = verifyTOTP(userMFA.totpSecret, code, this.config.totpWindow);
101
+
102
+ if (isValid) {
103
+ userMFA.totpVerified = true;
104
+ if (!userMFA.methods.includes('totp')) {
105
+ userMFA.methods.push('totp');
106
+ }
107
+ userMFA.enabled = true;
108
+
109
+ await this.repository.upsert(userMFA);
110
+ logger.info({ userId }, 'TOTP setup verified');
111
+ }
112
+
113
+ return isValid;
114
+ }
115
+
116
+ async disableTOTP(userId: string, code: string): Promise<void> {
117
+ const userMFA = await this.repository.getByUserId(userId);
118
+ if (!userMFA || !userMFA.totpVerified) {
119
+ throw new BadRequestError('TOTP not enabled');
120
+ }
121
+
122
+ // Verify code before disabling
123
+ const isValid = verifyTOTP(userMFA.totpSecret!, code, this.config.totpWindow);
124
+ if (!isValid) {
125
+ throw new BadRequestError('Invalid TOTP code');
126
+ }
127
+
128
+ userMFA.totpSecret = undefined;
129
+ userMFA.totpVerified = false;
130
+ userMFA.methods = userMFA.methods.filter((m) => m !== 'totp');
131
+ userMFA.enabled = userMFA.methods.length > 0;
132
+
133
+ await this.repository.upsert(userMFA);
134
+ logger.info({ userId }, 'TOTP disabled');
135
+ }
136
+
137
+ // SMS MFA
138
+ async setupSMS(userId: string, phoneNumber: string): Promise<void> {
139
+ let userMFA = await this.repository.getByUserId(userId);
140
+ if (!userMFA) {
141
+ userMFA = this.createUserMFA(userId);
142
+ }
143
+
144
+ userMFA.phoneNumber = phoneNumber;
145
+ userMFA.phoneVerified = false;
146
+
147
+ await this.repository.upsert(userMFA);
148
+
149
+ // Send verification code
150
+ await this.sendSMSChallenge(userId, phoneNumber);
151
+
152
+ logger.info({ userId }, 'SMS MFA setup initiated');
153
+ }
154
+
155
+ async verifySMSSetup(userId: string, code: string): Promise<boolean> {
156
+ const result = await this.verifyChallenge(userId, code, 'sms');
157
+
158
+ if (result.success) {
159
+ const userMFA = (await this.repository.getByUserId(userId))!;
160
+ userMFA.phoneVerified = true;
161
+ if (!userMFA.methods.includes('sms')) {
162
+ userMFA.methods.push('sms');
163
+ }
164
+ userMFA.enabled = true;
165
+
166
+ await this.repository.upsert(userMFA);
167
+ logger.info({ userId }, 'SMS MFA setup verified');
168
+ }
169
+
170
+ return result.success;
171
+ }
172
+
173
+ // Email MFA
174
+ async setupEmail(userId: string, email: string): Promise<void> {
175
+ let userMFA = await this.repository.getByUserId(userId);
176
+ if (!userMFA) {
177
+ userMFA = this.createUserMFA(userId);
178
+ }
179
+
180
+ userMFA.email = email;
181
+ userMFA.emailVerified = false;
182
+
183
+ await this.repository.upsert(userMFA);
184
+
185
+ // Send verification code
186
+ await this.sendEmailChallenge(userId, email);
187
+
188
+ logger.info({ userId }, 'Email MFA setup initiated');
189
+ }
190
+
191
+ async verifyEmailSetup(userId: string, code: string): Promise<boolean> {
192
+ const result = await this.verifyChallenge(userId, code, 'email');
193
+
194
+ if (result.success) {
195
+ const userMFA = (await this.repository.getByUserId(userId))!;
196
+ userMFA.emailVerified = true;
197
+ if (!userMFA.methods.includes('email')) {
198
+ userMFA.methods.push('email');
199
+ }
200
+ userMFA.enabled = true;
201
+
202
+ await this.repository.upsert(userMFA);
203
+ logger.info({ userId }, 'Email MFA setup verified');
204
+ }
205
+
206
+ return result.success;
207
+ }
208
+
209
+ // Backup Codes
210
+ async generateBackupCodes(userId: string): Promise<BackupCodesResult> {
211
+ let userMFA = await this.repository.getByUserId(userId);
212
+ if (!userMFA) {
213
+ userMFA = this.createUserMFA(userId);
214
+ }
215
+
216
+ const codes: string[] = [];
217
+ for (let i = 0; i < (this.config.backupCodesCount || 10); i++) {
218
+ // Generate 8-character codes
219
+ const code = randomBytes(4).toString('hex').toUpperCase();
220
+ codes.push(`${code.slice(0, 4)}-${code.slice(4)}`);
221
+ }
222
+
223
+ userMFA.backupCodes = codes;
224
+ userMFA.backupCodesUsed = [];
225
+ if (!userMFA.methods.includes('backup_codes')) {
226
+ userMFA.methods.push('backup_codes');
227
+ }
228
+
229
+ await this.repository.upsert(userMFA);
230
+
231
+ logger.info({ userId, count: codes.length }, 'Backup codes generated');
232
+
233
+ return {
234
+ codes,
235
+ generatedAt: new Date(),
236
+ };
237
+ }
238
+
239
+ async verifyBackupCode(userId: string, code: string): Promise<boolean> {
240
+ const userMFA = await this.repository.getByUserId(userId);
241
+ if (!userMFA || !userMFA.backupCodes) {
242
+ return false;
243
+ }
244
+
245
+ const normalizedCode = code.toUpperCase().replace(/[^A-F0-9]/g, '');
246
+ const formattedCode = `${normalizedCode.slice(0, 4)}-${normalizedCode.slice(4)}`;
247
+
248
+ const index = userMFA.backupCodes.indexOf(formattedCode);
249
+ if (index === -1) {
250
+ return false;
251
+ }
252
+
253
+ // Check if already used
254
+ if (userMFA.backupCodesUsed?.includes(formattedCode)) {
255
+ return false;
256
+ }
257
+
258
+ // Mark as used
259
+ userMFA.backupCodesUsed = userMFA.backupCodesUsed || [];
260
+ userMFA.backupCodesUsed.push(formattedCode);
261
+ userMFA.lastUsed = new Date();
262
+
263
+ await this.repository.upsert(userMFA);
264
+
265
+ logger.info({ userId }, 'Backup code used');
266
+
267
+ return true;
268
+ }
269
+
270
+ async getRemainingBackupCodes(userId: string): Promise<number> {
271
+ const userMFA = await this.repository.getByUserId(userId);
272
+ if (!userMFA || !userMFA.backupCodes) {
273
+ return 0;
274
+ }
275
+
276
+ return userMFA.backupCodes.length - (userMFA.backupCodesUsed?.length || 0);
277
+ }
278
+
279
+ // Challenge Management
280
+ async createChallenge(userId: string, method: MFAMethod): Promise<MFAChallenge> {
281
+ // Check for lockout
282
+ const attempts = await this.getFailedAttempts(userId);
283
+ if (attempts?.lockedUntil) {
284
+ const lockedUntilDate = new Date(attempts.lockedUntil);
285
+ if (lockedUntilDate > new Date()) {
286
+ throw new BadRequestError(
287
+ `Account locked. Try again after ${lockedUntilDate.toISOString()}`
288
+ );
289
+ }
290
+ }
291
+
292
+ const userMFA = await this.repository.getByUserId(userId);
293
+ if (!userMFA || !userMFA.enabled) {
294
+ throw new BadRequestError('MFA not enabled');
295
+ }
296
+
297
+ if (!userMFA.methods.includes(method)) {
298
+ throw new BadRequestError(`MFA method '${method}' not enabled`);
299
+ }
300
+
301
+ const challenge: MFAChallenge = {
302
+ id: randomUUID(),
303
+ userId,
304
+ method,
305
+ expiresAt: new Date(Date.now() + CHALLENGE_EXPIRATION_SECONDS * 1000),
306
+ attempts: 0,
307
+ maxAttempts: MAX_ATTEMPTS,
308
+ verified: false,
309
+ createdAt: new Date(),
310
+ };
311
+
312
+ // Generate and send code for SMS/email
313
+ if (method === 'sms') {
314
+ challenge.code = this.generateNumericCode();
315
+ await this.sendSMSChallenge(userId, userMFA.phoneNumber!, challenge.code);
316
+ } else if (method === 'email') {
317
+ challenge.code = this.generateNumericCode();
318
+ await this.sendEmailChallenge(userId, userMFA.email!, challenge.code);
319
+ }
320
+
321
+ // Store challenge in Redis
322
+ const redis = getRedis();
323
+ await redis.setex(
324
+ `${MFA_CHALLENGE_PREFIX}${challenge.id}`,
325
+ CHALLENGE_EXPIRATION_SECONDS,
326
+ JSON.stringify(challenge)
327
+ );
328
+
329
+ logger.info({ userId, method, challengeId: challenge.id }, 'MFA challenge created');
330
+
331
+ return {
332
+ ...challenge,
333
+ code: undefined, // Don't return the code
334
+ };
335
+ }
336
+
337
+ async verifyChallenge(
338
+ userId: string,
339
+ code: string,
340
+ method?: MFAMethod,
341
+ challengeId?: string
342
+ ): Promise<MFAVerifyResult> {
343
+ // Check for lockout
344
+ const attempts = await this.getFailedAttempts(userId);
345
+ if (attempts?.lockedUntil) {
346
+ const lockedUntilDate = new Date(attempts.lockedUntil);
347
+ if (lockedUntilDate > new Date()) {
348
+ return {
349
+ success: false,
350
+ method: method || 'totp',
351
+ lockedUntil: lockedUntilDate,
352
+ };
353
+ }
354
+ }
355
+
356
+ const userMFA = await this.repository.getByUserId(userId);
357
+ if (!userMFA || !userMFA.enabled) {
358
+ throw new BadRequestError('MFA not enabled');
359
+ }
360
+
361
+ // Determine method if not specified
362
+ if (!method) {
363
+ if (userMFA.totpVerified) {
364
+ method = 'totp';
365
+ } else if (userMFA.methods.length > 0) {
366
+ method = userMFA.methods[0];
367
+ } else {
368
+ throw new BadRequestError('No MFA method available');
369
+ }
370
+ }
371
+
372
+ let success = false;
373
+
374
+ switch (method) {
375
+ case 'totp':
376
+ if (userMFA.totpSecret && userMFA.totpVerified) {
377
+ success = verifyTOTP(userMFA.totpSecret, code, this.config.totpWindow);
378
+ }
379
+ break;
380
+
381
+ case 'backup_codes':
382
+ success = await this.verifyBackupCode(userId, code);
383
+ break;
384
+
385
+ case 'sms':
386
+ case 'email':
387
+ if (challengeId) {
388
+ success = await this.verifyChallengeCode(userId, challengeId, method, code);
389
+ }
390
+ break;
391
+ }
392
+
393
+ if (success) {
394
+ // Reset failed attempts
395
+ await this.clearFailedAttempts(userId);
396
+ userMFA.lastUsed = new Date();
397
+ await this.repository.upsert(userMFA);
398
+
399
+ logger.info({ userId, method }, 'MFA verification successful');
400
+ } else {
401
+ // Track failed attempt
402
+ const currentAttempts = await this.incrementFailedAttempts(userId);
403
+
404
+ logger.info({ userId, method, attempts: currentAttempts.count }, 'MFA verification failed');
405
+ }
406
+
407
+ const finalAttempts = await this.getFailedAttempts(userId);
408
+ return {
409
+ success,
410
+ method: method || 'totp',
411
+ remainingAttempts: success ? undefined : MAX_ATTEMPTS - (finalAttempts?.count || 0),
412
+ lockedUntil: finalAttempts?.lockedUntil ? new Date(finalAttempts.lockedUntil) : undefined,
413
+ };
414
+ }
415
+
416
+ // User MFA Status
417
+ async getUserMFA(userId: string): Promise<UserMFA | null> {
418
+ return this.repository.getByUserId(userId);
419
+ }
420
+
421
+ async isMFAEnabled(userId: string): Promise<boolean> {
422
+ return this.repository.isEnabled(userId);
423
+ }
424
+
425
+ async getEnabledMethods(userId: string): Promise<MFAMethod[]> {
426
+ return this.repository.getEnabledMethods(userId);
427
+ }
428
+
429
+ async disableAllMFA(userId: string): Promise<void> {
430
+ await this.repository.delete(userId);
431
+ await this.clearFailedAttempts(userId);
432
+ logger.info({ userId }, 'All MFA disabled');
433
+ }
434
+
435
+ // Private methods
436
+ private createUserMFA(userId: string): UserMFA {
437
+ return {
438
+ userId,
439
+ enabled: false,
440
+ methods: [],
441
+ totpVerified: false,
442
+ phoneVerified: false,
443
+ emailVerified: false,
444
+ createdAt: new Date(),
445
+ updatedAt: new Date(),
446
+ };
447
+ }
448
+
449
+ private generateNumericCode(length = 6): string {
450
+ const digits = '0123456789';
451
+ let code = '';
452
+ const bytes = randomBytes(length);
453
+ for (let i = 0; i < length; i++) {
454
+ const byteValue = bytes[i];
455
+ if (byteValue !== undefined) {
456
+ code += digits[byteValue % 10];
457
+ }
458
+ }
459
+ return code;
460
+ }
461
+
462
+ private async sendSMSChallenge(
463
+ userId: string,
464
+ phoneNumber: string,
465
+ code?: string
466
+ ): Promise<void> {
467
+ const challengeCode = code || this.generateNumericCode();
468
+ logger.debug({ userId, phoneNumber }, `SMS challenge code: ${challengeCode}`);
469
+ // In production, use notification service to send SMS
470
+ }
471
+
472
+ private async sendEmailChallenge(userId: string, email: string, code?: string): Promise<void> {
473
+ const challengeCode = code || this.generateNumericCode();
474
+ logger.debug({ userId, email }, `Email challenge code: ${challengeCode}`);
475
+ // In production, use notification service to send email
476
+ }
477
+
478
+ // Redis helpers for challenges
479
+ private async verifyChallengeCode(
480
+ userId: string,
481
+ challengeId: string,
482
+ method: MFAMethod,
483
+ code: string
484
+ ): Promise<boolean> {
485
+ const redis = getRedis();
486
+ const challengeJson = await redis.get(`${MFA_CHALLENGE_PREFIX}${challengeId}`);
487
+
488
+ if (!challengeJson) {
489
+ return false;
490
+ }
491
+
492
+ const challenge = JSON.parse(challengeJson) as MFAChallenge;
493
+
494
+ if (
495
+ challenge.userId !== userId ||
496
+ challenge.method !== method ||
497
+ new Date(challenge.expiresAt) < new Date() ||
498
+ challenge.verified
499
+ ) {
500
+ return false;
501
+ }
502
+
503
+ if (challenge.code === code) {
504
+ challenge.verified = true;
505
+ await redis.setex(
506
+ `${MFA_CHALLENGE_PREFIX}${challengeId}`,
507
+ 60, // Keep for 1 minute after verification
508
+ JSON.stringify(challenge)
509
+ );
510
+ return true;
511
+ }
512
+
513
+ challenge.attempts++;
514
+ await redis.setex(
515
+ `${MFA_CHALLENGE_PREFIX}${challengeId}`,
516
+ CHALLENGE_EXPIRATION_SECONDS,
517
+ JSON.stringify(challenge)
518
+ );
519
+ return false;
520
+ }
521
+
522
+ // Redis helpers for failed attempts
523
+ private async getFailedAttempts(userId: string): Promise<FailedAttempts | null> {
524
+ const redis = getRedis();
525
+ const attemptsJson = await redis.get(`${MFA_ATTEMPTS_PREFIX}${userId}`);
526
+
527
+ if (!attemptsJson) {
528
+ return null;
529
+ }
530
+
531
+ return JSON.parse(attemptsJson) as FailedAttempts;
532
+ }
533
+
534
+ private async incrementFailedAttempts(userId: string): Promise<FailedAttempts> {
535
+ const redis = getRedis();
536
+ const current = (await this.getFailedAttempts(userId)) || { count: 0 };
537
+
538
+ current.count++;
539
+
540
+ if (current.count >= MAX_ATTEMPTS) {
541
+ current.lockedUntil = new Date(Date.now() + LOCKOUT_EXPIRATION_SECONDS * 1000).toISOString();
542
+ logger.warn({ userId }, 'MFA account locked due to too many failed attempts');
543
+ }
544
+
545
+ await redis.setex(
546
+ `${MFA_ATTEMPTS_PREFIX}${userId}`,
547
+ LOCKOUT_EXPIRATION_SECONDS,
548
+ JSON.stringify(current)
549
+ );
550
+
551
+ return current;
552
+ }
553
+
554
+ private async clearFailedAttempts(userId: string): Promise<void> {
555
+ const redis = getRedis();
556
+ await redis.del(`${MFA_ATTEMPTS_PREFIX}${userId}`);
557
+ }
558
+ }
559
+
560
+ let mfaService: MFAService | null = null;
561
+
562
+ export function getMFAService(): MFAService {
563
+ if (!mfaService) {
564
+ mfaService = new MFAService();
565
+ }
566
+ return mfaService;
567
+ }
568
+
569
+ export function createMFAService(config: Partial<MFAConfig>): MFAService {
570
+ mfaService = new MFAService(config);
571
+ return mfaService;
572
+ }
@@ -0,0 +1,150 @@
1
+ import { createHmac, randomBytes } from 'crypto';
2
+
3
+ /**
4
+ * TOTP (Time-based One-Time Password) implementation
5
+ * RFC 6238 compliant
6
+ */
7
+
8
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
9
+ const TOTP_PERIOD = 30; // seconds
10
+ const TOTP_DIGITS = 6;
11
+
12
+ /**
13
+ * Generate a random secret for TOTP
14
+ */
15
+ export function generateSecret(length = 20): string {
16
+ const bytes = randomBytes(length);
17
+ let secret = '';
18
+
19
+ for (const byte of bytes) {
20
+ secret += BASE32_ALPHABET[byte % 32];
21
+ }
22
+
23
+ return secret;
24
+ }
25
+
26
+ /**
27
+ * Decode base32 string to buffer
28
+ */
29
+ function base32Decode(encoded: string): Buffer {
30
+ const cleaned = encoded.toUpperCase().replace(/[^A-Z2-7]/g, '');
31
+ const bits: number[] = [];
32
+
33
+ for (const char of cleaned) {
34
+ const value = BASE32_ALPHABET.indexOf(char);
35
+ if (value === -1) continue;
36
+ bits.push(...[...value.toString(2).padStart(5, '0')].map(Number));
37
+ }
38
+
39
+ const bytes: number[] = [];
40
+ for (let i = 0; i + 8 <= bits.length; i += 8) {
41
+ bytes.push(parseInt(bits.slice(i, i + 8).join(''), 2));
42
+ }
43
+
44
+ return Buffer.from(bytes);
45
+ }
46
+
47
+ /**
48
+ * Generate TOTP code for a given time
49
+ */
50
+ export function generateTOTP(secret: string, timestamp?: number): string {
51
+ const time = timestamp || Date.now();
52
+ const counter = Math.floor(time / 1000 / TOTP_PERIOD);
53
+
54
+ // Convert counter to 8-byte buffer (big-endian)
55
+ const counterBuffer = Buffer.alloc(8);
56
+ counterBuffer.writeBigUInt64BE(BigInt(counter));
57
+
58
+ // Decode secret
59
+ const secretBuffer = base32Decode(secret);
60
+
61
+ // Generate HMAC-SHA1
62
+ const hmac = createHmac('sha1', secretBuffer);
63
+ hmac.update(counterBuffer);
64
+ const hash = hmac.digest();
65
+
66
+ // Dynamic truncation
67
+ const lastByte = hash[hash.length - 1] ?? 0;
68
+ const offset = lastByte & 0xf;
69
+ const binary =
70
+ (((hash[offset] ?? 0) & 0x7f) << 24) |
71
+ (((hash[offset + 1] ?? 0) & 0xff) << 16) |
72
+ (((hash[offset + 2] ?? 0) & 0xff) << 8) |
73
+ ((hash[offset + 3] ?? 0) & 0xff);
74
+
75
+ // Generate OTP
76
+ const otp = binary % Math.pow(10, TOTP_DIGITS);
77
+ return otp.toString().padStart(TOTP_DIGITS, '0');
78
+ }
79
+
80
+ /**
81
+ * Verify TOTP code with time window
82
+ */
83
+ export function verifyTOTP(secret: string, code: string, window = 1): boolean {
84
+ const time = Date.now();
85
+
86
+ // Check current and adjacent time windows
87
+ for (let i = -window; i <= window; i++) {
88
+ const checkTime = time + i * TOTP_PERIOD * 1000;
89
+ const expectedCode = generateTOTP(secret, checkTime);
90
+
91
+ if (constantTimeCompare(code, expectedCode)) {
92
+ return true;
93
+ }
94
+ }
95
+
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Generate otpauth:// URI for authenticator apps
101
+ */
102
+ export function generateTOTPUri(secret: string, accountName: string, issuer: string): string {
103
+ const encodedIssuer = encodeURIComponent(issuer);
104
+ const encodedAccount = encodeURIComponent(accountName);
105
+
106
+ return `otpauth://totp/${encodedIssuer}:${encodedAccount}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_PERIOD}`;
107
+ }
108
+
109
+ /**
110
+ * Generate QR code as data URL
111
+ */
112
+ export async function generateQRCode(data: string): Promise<string> {
113
+ // Simple QR code generation using a public API
114
+ // In production, use a library like 'qrcode' package
115
+ const url = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(data)}`;
116
+
117
+ // For local generation without external API, you can implement QR encoding
118
+ // or use the 'qrcode' npm package
119
+ return url;
120
+ }
121
+
122
+ /**
123
+ * Format secret for manual entry (groups of 4)
124
+ */
125
+ export function formatSecretForDisplay(secret: string): string {
126
+ return secret.match(/.{1,4}/g)?.join(' ') || secret;
127
+ }
128
+
129
+ /**
130
+ * Constant-time string comparison to prevent timing attacks
131
+ */
132
+ function constantTimeCompare(a: string, b: string): boolean {
133
+ if (a.length !== b.length) {
134
+ return false;
135
+ }
136
+
137
+ let result = 0;
138
+ for (let i = 0; i < a.length; i++) {
139
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
140
+ }
141
+
142
+ return result === 0;
143
+ }
144
+
145
+ /**
146
+ * Get remaining seconds until next TOTP code
147
+ */
148
+ export function getRemainingSeconds(): number {
149
+ return TOTP_PERIOD - (Math.floor(Date.now() / 1000) % TOTP_PERIOD);
150
+ }