servcraft 0.1.0 → 0.1.3
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.
- package/.claude/settings.local.json +30 -0
- package/.github/CODEOWNERS +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/.github/dependabot.yml +59 -0
- package/.github/workflows/ci.yml +188 -0
- package/.github/workflows/release.yml +195 -0
- package/AUDIT.md +602 -0
- package/LICENSE +21 -0
- package/README.md +1102 -1
- package/dist/cli/index.cjs +2026 -2168
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2026 -2168
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +595 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -52
- package/dist/index.d.ts +114 -52
- package/dist/index.js +595 -616
- package/dist/index.js.map +1 -1
- package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
- package/docs/DATABASE_MULTI_ORM.md +399 -0
- package/docs/PHASE1_BREAKDOWN.md +346 -0
- package/docs/PROGRESS.md +550 -0
- package/docs/modules/ANALYTICS.md +226 -0
- package/docs/modules/API-VERSIONING.md +252 -0
- package/docs/modules/AUDIT.md +192 -0
- package/docs/modules/AUTH.md +431 -0
- package/docs/modules/CACHE.md +346 -0
- package/docs/modules/EMAIL.md +254 -0
- package/docs/modules/FEATURE-FLAG.md +291 -0
- package/docs/modules/I18N.md +294 -0
- package/docs/modules/MEDIA-PROCESSING.md +281 -0
- package/docs/modules/MFA.md +266 -0
- package/docs/modules/NOTIFICATION.md +311 -0
- package/docs/modules/OAUTH.md +237 -0
- package/docs/modules/PAYMENT.md +804 -0
- package/docs/modules/QUEUE.md +540 -0
- package/docs/modules/RATE-LIMIT.md +339 -0
- package/docs/modules/SEARCH.md +288 -0
- package/docs/modules/SECURITY.md +327 -0
- package/docs/modules/SESSION.md +382 -0
- package/docs/modules/SWAGGER.md +305 -0
- package/docs/modules/UPLOAD.md +296 -0
- package/docs/modules/USER.md +505 -0
- package/docs/modules/VALIDATION.md +294 -0
- package/docs/modules/WEBHOOK.md +270 -0
- package/docs/modules/WEBSOCKET.md +691 -0
- package/package.json +53 -38
- package/prisma/schema.prisma +395 -1
- package/src/cli/commands/add-module.ts +520 -87
- package/src/cli/commands/db.ts +3 -4
- package/src/cli/commands/docs.ts +256 -6
- package/src/cli/commands/generate.ts +12 -19
- package/src/cli/commands/init.ts +384 -214
- package/src/cli/index.ts +0 -4
- package/src/cli/templates/repository.ts +6 -1
- package/src/cli/templates/routes.ts +6 -21
- package/src/cli/utils/docs-generator.ts +6 -7
- package/src/cli/utils/env-manager.ts +717 -0
- package/src/cli/utils/field-parser.ts +16 -7
- package/src/cli/utils/interactive-prompt.ts +223 -0
- package/src/cli/utils/template-manager.ts +346 -0
- package/src/config/database.config.ts +183 -0
- package/src/config/env.ts +0 -10
- package/src/config/index.ts +0 -14
- package/src/core/server.ts +1 -1
- package/src/database/adapters/mongoose.adapter.ts +132 -0
- package/src/database/adapters/prisma.adapter.ts +118 -0
- package/src/database/connection.ts +190 -0
- package/src/database/interfaces/database.interface.ts +85 -0
- package/src/database/interfaces/index.ts +7 -0
- package/src/database/interfaces/repository.interface.ts +129 -0
- package/src/database/models/mongoose/index.ts +7 -0
- package/src/database/models/mongoose/payment.schema.ts +347 -0
- package/src/database/models/mongoose/user.schema.ts +154 -0
- package/src/database/prisma.ts +1 -4
- package/src/database/redis.ts +101 -0
- package/src/database/repositories/mongoose/index.ts +7 -0
- package/src/database/repositories/mongoose/payment.repository.ts +380 -0
- package/src/database/repositories/mongoose/user.repository.ts +255 -0
- package/src/database/seed.ts +6 -1
- package/src/index.ts +9 -20
- package/src/middleware/security.ts +2 -6
- package/src/modules/analytics/analytics.routes.ts +80 -0
- package/src/modules/analytics/analytics.service.ts +364 -0
- package/src/modules/analytics/index.ts +18 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/api-versioning/index.ts +15 -0
- package/src/modules/api-versioning/types.ts +86 -0
- package/src/modules/api-versioning/versioning.middleware.ts +120 -0
- package/src/modules/api-versioning/versioning.routes.ts +54 -0
- package/src/modules/api-versioning/versioning.service.ts +189 -0
- package/src/modules/audit/audit.repository.ts +206 -0
- package/src/modules/audit/audit.service.ts +27 -59
- package/src/modules/auth/auth.controller.ts +2 -2
- package/src/modules/auth/auth.middleware.ts +3 -9
- package/src/modules/auth/auth.routes.ts +10 -107
- package/src/modules/auth/auth.service.ts +126 -23
- package/src/modules/auth/index.ts +3 -4
- package/src/modules/cache/cache.service.ts +367 -0
- package/src/modules/cache/index.ts +10 -0
- package/src/modules/cache/types.ts +44 -0
- package/src/modules/email/email.service.ts +3 -10
- package/src/modules/email/templates.ts +2 -8
- package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
- package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
- package/src/modules/feature-flag/feature-flag.service.ts +566 -0
- package/src/modules/feature-flag/index.ts +20 -0
- package/src/modules/feature-flag/types.ts +192 -0
- package/src/modules/i18n/i18n.middleware.ts +186 -0
- package/src/modules/i18n/i18n.routes.ts +191 -0
- package/src/modules/i18n/i18n.service.ts +456 -0
- package/src/modules/i18n/index.ts +18 -0
- package/src/modules/i18n/types.ts +118 -0
- package/src/modules/media-processing/index.ts +17 -0
- package/src/modules/media-processing/media-processing.routes.ts +111 -0
- package/src/modules/media-processing/media-processing.service.ts +245 -0
- package/src/modules/media-processing/types.ts +156 -0
- package/src/modules/mfa/index.ts +20 -0
- package/src/modules/mfa/mfa.repository.ts +206 -0
- package/src/modules/mfa/mfa.routes.ts +595 -0
- package/src/modules/mfa/mfa.service.ts +572 -0
- package/src/modules/mfa/totp.ts +150 -0
- package/src/modules/mfa/types.ts +57 -0
- package/src/modules/notification/index.ts +20 -0
- package/src/modules/notification/notification.repository.ts +356 -0
- package/src/modules/notification/notification.service.ts +483 -0
- package/src/modules/notification/types.ts +119 -0
- package/src/modules/oauth/index.ts +20 -0
- package/src/modules/oauth/oauth.repository.ts +219 -0
- package/src/modules/oauth/oauth.routes.ts +446 -0
- package/src/modules/oauth/oauth.service.ts +293 -0
- package/src/modules/oauth/providers/apple.provider.ts +250 -0
- package/src/modules/oauth/providers/facebook.provider.ts +181 -0
- package/src/modules/oauth/providers/github.provider.ts +248 -0
- package/src/modules/oauth/providers/google.provider.ts +189 -0
- package/src/modules/oauth/providers/twitter.provider.ts +214 -0
- package/src/modules/oauth/types.ts +94 -0
- package/src/modules/payment/index.ts +19 -0
- package/src/modules/payment/payment.repository.ts +733 -0
- package/src/modules/payment/payment.routes.ts +390 -0
- package/src/modules/payment/payment.service.ts +354 -0
- package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
- package/src/modules/payment/providers/paypal.provider.ts +190 -0
- package/src/modules/payment/providers/stripe.provider.ts +215 -0
- package/src/modules/payment/types.ts +140 -0
- package/src/modules/queue/cron.ts +438 -0
- package/src/modules/queue/index.ts +87 -0
- package/src/modules/queue/queue.routes.ts +600 -0
- package/src/modules/queue/queue.service.ts +842 -0
- package/src/modules/queue/types.ts +222 -0
- package/src/modules/queue/workers.ts +366 -0
- package/src/modules/rate-limit/index.ts +59 -0
- package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
- package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
- package/src/modules/rate-limit/rate-limit.service.ts +348 -0
- package/src/modules/rate-limit/stores/memory.store.ts +165 -0
- package/src/modules/rate-limit/stores/redis.store.ts +322 -0
- package/src/modules/rate-limit/types.ts +153 -0
- package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
- package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
- package/src/modules/search/adapters/memory.adapter.ts +278 -0
- package/src/modules/search/index.ts +21 -0
- package/src/modules/search/search.service.ts +234 -0
- package/src/modules/search/types.ts +214 -0
- package/src/modules/security/index.ts +40 -0
- package/src/modules/security/sanitize.ts +223 -0
- package/src/modules/security/security-audit.service.ts +388 -0
- package/src/modules/security/security.middleware.ts +398 -0
- package/src/modules/session/index.ts +3 -0
- package/src/modules/session/session.repository.ts +159 -0
- package/src/modules/session/session.service.ts +340 -0
- package/src/modules/session/types.ts +38 -0
- package/src/modules/swagger/index.ts +7 -1
- package/src/modules/swagger/schema-builder.ts +16 -4
- package/src/modules/swagger/swagger.service.ts +9 -10
- package/src/modules/swagger/types.ts +0 -2
- package/src/modules/upload/index.ts +14 -0
- package/src/modules/upload/types.ts +83 -0
- package/src/modules/upload/upload.repository.ts +199 -0
- package/src/modules/upload/upload.routes.ts +311 -0
- package/src/modules/upload/upload.service.ts +448 -0
- package/src/modules/user/index.ts +3 -3
- package/src/modules/user/user.controller.ts +15 -9
- package/src/modules/user/user.repository.ts +237 -113
- package/src/modules/user/user.routes.ts +39 -164
- package/src/modules/user/user.service.ts +4 -3
- package/src/modules/validation/validator.ts +12 -17
- package/src/modules/webhook/index.ts +91 -0
- package/src/modules/webhook/retry.ts +196 -0
- package/src/modules/webhook/signature.ts +135 -0
- package/src/modules/webhook/types.ts +181 -0
- package/src/modules/webhook/webhook.repository.ts +358 -0
- package/src/modules/webhook/webhook.routes.ts +442 -0
- package/src/modules/webhook/webhook.service.ts +457 -0
- package/src/modules/websocket/features.ts +504 -0
- package/src/modules/websocket/index.ts +106 -0
- package/src/modules/websocket/middlewares.ts +298 -0
- package/src/modules/websocket/types.ts +181 -0
- package/src/modules/websocket/websocket.service.ts +692 -0
- package/src/utils/errors.ts +7 -0
- package/src/utils/pagination.ts +4 -1
- package/tests/helpers/db-check.ts +79 -0
- package/tests/integration/auth-redis.test.ts +94 -0
- package/tests/integration/cache-redis.test.ts +387 -0
- package/tests/integration/mongoose-repositories.test.ts +410 -0
- package/tests/integration/payment-prisma.test.ts +637 -0
- package/tests/integration/queue-bullmq.test.ts +417 -0
- package/tests/integration/user-prisma.test.ts +441 -0
- package/tests/integration/websocket-socketio.test.ts +552 -0
- package/tests/setup.ts +11 -9
- package/vitest.config.ts +3 -8
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- 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
|
+
}
|