say-auth 1.0.0
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/LICENSE.md +21 -0
- package/README.md +121 -0
- package/dist/index.d.mts +312 -0
- package/dist/index.d.ts +312 -0
- package/dist/index.js +1496 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1446 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +117 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1496 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var axios = require('axios');
|
|
4
|
+
var CryptoJS = require('crypto-js');
|
|
5
|
+
var OTPAuth = require('otpauth');
|
|
6
|
+
var QRCode = require('qrcode');
|
|
7
|
+
var rfc4648 = require('rfc4648');
|
|
8
|
+
var react = require('react');
|
|
9
|
+
var navigation = require('next/navigation');
|
|
10
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
11
|
+
var lucideReact = require('lucide-react');
|
|
12
|
+
|
|
13
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
14
|
+
|
|
15
|
+
function _interopNamespace(e) {
|
|
16
|
+
if (e && e.__esModule) return e;
|
|
17
|
+
var n = Object.create(null);
|
|
18
|
+
if (e) {
|
|
19
|
+
Object.keys(e).forEach(function (k) {
|
|
20
|
+
if (k !== 'default') {
|
|
21
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
22
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
23
|
+
enumerable: true,
|
|
24
|
+
get: function () { return e[k]; }
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
n.default = e;
|
|
30
|
+
return Object.freeze(n);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
var axios__default = /*#__PURE__*/_interopDefault(axios);
|
|
34
|
+
var CryptoJS__default = /*#__PURE__*/_interopDefault(CryptoJS);
|
|
35
|
+
var OTPAuth__namespace = /*#__PURE__*/_interopNamespace(OTPAuth);
|
|
36
|
+
var QRCode__default = /*#__PURE__*/_interopDefault(QRCode);
|
|
37
|
+
|
|
38
|
+
// src/services/auth-service.ts
|
|
39
|
+
|
|
40
|
+
// src/utils/constants.ts
|
|
41
|
+
var AUTH_CONFIG = {
|
|
42
|
+
tokenRefreshThreshold: 5 * 60 * 1e3,
|
|
43
|
+
// 5 minutes
|
|
44
|
+
maxLoginAttempts: 5,
|
|
45
|
+
lockoutDuration: 15 * 60 * 1e3,
|
|
46
|
+
// 15 minutes
|
|
47
|
+
sessionTimeout: 30 * 60 * 1e3,
|
|
48
|
+
// 30 minutes
|
|
49
|
+
mfaCodeLength: 6,
|
|
50
|
+
backupCodesCount: 10,
|
|
51
|
+
trustDeviceDuration: 30 * 24 * 60 * 60 * 1e3
|
|
52
|
+
// 30 days
|
|
53
|
+
};
|
|
54
|
+
var STORAGE_KEYS = {
|
|
55
|
+
tokens: "auth_tokens_encrypted",
|
|
56
|
+
user: "auth_user_encrypted",
|
|
57
|
+
encryptionKey: "encryption_key",
|
|
58
|
+
deviceId: "device_id",
|
|
59
|
+
trustedDevices: "trusted_devices"
|
|
60
|
+
};
|
|
61
|
+
var API_ENDPOINTS = {
|
|
62
|
+
login: "/auth/login",
|
|
63
|
+
logout: "/auth/logout",
|
|
64
|
+
register: "/auth/register",
|
|
65
|
+
refresh: "/auth/refresh",
|
|
66
|
+
mfaSetup: "/auth/mfa/setup",
|
|
67
|
+
mfaVerify: "/auth/mfa/verify",
|
|
68
|
+
mfaDisable: "/auth/mfa/disable",
|
|
69
|
+
changePassword: "/auth/change-password",
|
|
70
|
+
audit: "/auth/audit"
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// src/services/token-storage.ts
|
|
74
|
+
var SecureTokenStorage = class _SecureTokenStorage {
|
|
75
|
+
// Backward compatible versions
|
|
76
|
+
constructor() {
|
|
77
|
+
this.memoryCache = /* @__PURE__ */ new Map();
|
|
78
|
+
this.TOKEN_VERSION = "2.0";
|
|
79
|
+
// Current version
|
|
80
|
+
this.SUPPORTED_VERSIONS = ["1.0", "2.0"];
|
|
81
|
+
this.encryptionKey = this.getOrCreateEncryptionKey();
|
|
82
|
+
}
|
|
83
|
+
static getInstance() {
|
|
84
|
+
if (!_SecureTokenStorage.instance) {
|
|
85
|
+
_SecureTokenStorage.instance = new _SecureTokenStorage();
|
|
86
|
+
}
|
|
87
|
+
return _SecureTokenStorage.instance;
|
|
88
|
+
}
|
|
89
|
+
getOrCreateEncryptionKey() {
|
|
90
|
+
if (typeof window === "undefined") return "";
|
|
91
|
+
let key = localStorage.getItem(STORAGE_KEYS.encryptionKey);
|
|
92
|
+
if (!key) {
|
|
93
|
+
key = this.generateEncryptionKey();
|
|
94
|
+
localStorage.setItem(STORAGE_KEYS.encryptionKey, key);
|
|
95
|
+
}
|
|
96
|
+
return key;
|
|
97
|
+
}
|
|
98
|
+
generateEncryptionKey() {
|
|
99
|
+
return CryptoJS__default.default.lib.WordArray.random(32).toString();
|
|
100
|
+
}
|
|
101
|
+
encrypt(data) {
|
|
102
|
+
return CryptoJS__default.default.AES.encrypt(JSON.stringify(data), this.encryptionKey).toString();
|
|
103
|
+
}
|
|
104
|
+
decrypt(encrypted) {
|
|
105
|
+
try {
|
|
106
|
+
const bytes = CryptoJS__default.default.AES.decrypt(encrypted, this.encryptionKey);
|
|
107
|
+
return JSON.parse(bytes.toString(CryptoJS__default.default.enc.Utf8));
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// NEW: Set tokens with versioning
|
|
113
|
+
setTokens(tokens) {
|
|
114
|
+
const dataWithVersion = {
|
|
115
|
+
version: this.TOKEN_VERSION,
|
|
116
|
+
data: tokens,
|
|
117
|
+
timestamp: Date.now()
|
|
118
|
+
};
|
|
119
|
+
const encrypted = this.encrypt(dataWithVersion);
|
|
120
|
+
if (typeof window !== "undefined") {
|
|
121
|
+
localStorage.setItem(STORAGE_KEYS.tokens, encrypted);
|
|
122
|
+
}
|
|
123
|
+
this.memoryCache.set("tokens", tokens);
|
|
124
|
+
}
|
|
125
|
+
// NEW: Get tokens with backward compatibility
|
|
126
|
+
getTokens() {
|
|
127
|
+
if (this.memoryCache.has("tokens")) {
|
|
128
|
+
return this.memoryCache.get("tokens");
|
|
129
|
+
}
|
|
130
|
+
if (typeof window !== "undefined") {
|
|
131
|
+
const encrypted = localStorage.getItem(STORAGE_KEYS.tokens);
|
|
132
|
+
if (encrypted) {
|
|
133
|
+
const decrypted = this.decrypt(encrypted);
|
|
134
|
+
if (decrypted) {
|
|
135
|
+
if (decrypted.version && this.SUPPORTED_VERSIONS.includes(decrypted.version)) {
|
|
136
|
+
const tokens = decrypted.data;
|
|
137
|
+
this.memoryCache.set("tokens", tokens);
|
|
138
|
+
if (decrypted.version !== this.TOKEN_VERSION) {
|
|
139
|
+
this.migrateTokenFormat(tokens);
|
|
140
|
+
}
|
|
141
|
+
return tokens;
|
|
142
|
+
} else if (decrypted.accessToken) {
|
|
143
|
+
const tokens = decrypted;
|
|
144
|
+
this.migrateTokenFormat(tokens);
|
|
145
|
+
this.memoryCache.set("tokens", tokens);
|
|
146
|
+
return tokens;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
// NEW: Migrate legacy tokens to versioned format
|
|
154
|
+
migrateTokenFormat(tokens) {
|
|
155
|
+
try {
|
|
156
|
+
const dataWithVersion = {
|
|
157
|
+
version: this.TOKEN_VERSION,
|
|
158
|
+
data: tokens,
|
|
159
|
+
timestamp: Date.now()
|
|
160
|
+
};
|
|
161
|
+
const encrypted = this.encrypt(dataWithVersion);
|
|
162
|
+
localStorage.setItem(STORAGE_KEYS.tokens, encrypted);
|
|
163
|
+
console.log("Token format migrated to version", this.TOKEN_VERSION);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error("Token migration failed:", error);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// NEW: Check if token needs refresh based on timestamp
|
|
169
|
+
shouldRefreshToken() {
|
|
170
|
+
if (typeof window === "undefined") return false;
|
|
171
|
+
const encrypted = localStorage.getItem(STORAGE_KEYS.tokens);
|
|
172
|
+
if (!encrypted) return false;
|
|
173
|
+
const decrypted = this.decrypt(encrypted);
|
|
174
|
+
if (!decrypted) return false;
|
|
175
|
+
if (decrypted.timestamp) {
|
|
176
|
+
Date.now() - decrypted.timestamp;
|
|
177
|
+
const REFRESH_THRESHOLD = 5 * 60 * 1e3;
|
|
178
|
+
const tokens = decrypted.data || decrypted;
|
|
179
|
+
if (tokens.accessToken) {
|
|
180
|
+
try {
|
|
181
|
+
const payload = JSON.parse(atob(tokens.accessToken.split(".")[1]));
|
|
182
|
+
const expiresIn = payload.exp * 1e3 - Date.now();
|
|
183
|
+
return expiresIn < REFRESH_THRESHOLD;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
// NEW: Get token age (for debugging)
|
|
192
|
+
getTokenAge() {
|
|
193
|
+
if (typeof window === "undefined") return null;
|
|
194
|
+
const encrypted = localStorage.getItem(STORAGE_KEYS.tokens);
|
|
195
|
+
if (!encrypted) return null;
|
|
196
|
+
const decrypted = this.decrypt(encrypted);
|
|
197
|
+
if (decrypted?.timestamp) {
|
|
198
|
+
return Date.now() - decrypted.timestamp;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
setUser(user) {
|
|
203
|
+
const dataWithVersion = {
|
|
204
|
+
version: this.TOKEN_VERSION,
|
|
205
|
+
data: user,
|
|
206
|
+
timestamp: Date.now()
|
|
207
|
+
};
|
|
208
|
+
const encrypted = this.encrypt(dataWithVersion);
|
|
209
|
+
if (typeof window !== "undefined") {
|
|
210
|
+
localStorage.setItem(STORAGE_KEYS.user, encrypted);
|
|
211
|
+
}
|
|
212
|
+
this.memoryCache.set("user", user);
|
|
213
|
+
}
|
|
214
|
+
getUser() {
|
|
215
|
+
if (this.memoryCache.has("user")) {
|
|
216
|
+
return this.memoryCache.get("user");
|
|
217
|
+
}
|
|
218
|
+
if (typeof window !== "undefined") {
|
|
219
|
+
const encrypted = localStorage.getItem(STORAGE_KEYS.user);
|
|
220
|
+
if (encrypted) {
|
|
221
|
+
const decrypted = this.decrypt(encrypted);
|
|
222
|
+
if (decrypted) {
|
|
223
|
+
if (decrypted.version && this.SUPPORTED_VERSIONS.includes(decrypted.version)) {
|
|
224
|
+
const user = decrypted.data;
|
|
225
|
+
this.memoryCache.set("user", user);
|
|
226
|
+
return user;
|
|
227
|
+
} else if (decrypted.id) {
|
|
228
|
+
this.memoryCache.set("user", decrypted);
|
|
229
|
+
return decrypted;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
clear() {
|
|
237
|
+
this.memoryCache.clear();
|
|
238
|
+
if (typeof window !== "undefined") {
|
|
239
|
+
localStorage.removeItem(STORAGE_KEYS.tokens);
|
|
240
|
+
localStorage.removeItem(STORAGE_KEYS.user);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// NEW: Clear everything including encryption key (use with caution)
|
|
244
|
+
hardClear() {
|
|
245
|
+
this.memoryCache.clear();
|
|
246
|
+
if (typeof window !== "undefined") {
|
|
247
|
+
localStorage.removeItem(STORAGE_KEYS.tokens);
|
|
248
|
+
localStorage.removeItem(STORAGE_KEYS.user);
|
|
249
|
+
localStorage.removeItem(STORAGE_KEYS.encryptionKey);
|
|
250
|
+
localStorage.removeItem(STORAGE_KEYS.deviceId);
|
|
251
|
+
localStorage.removeItem(STORAGE_KEYS.trustedDevices);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// NEW: Get storage info (for debugging)
|
|
255
|
+
getStorageInfo() {
|
|
256
|
+
let tokenVersion = null;
|
|
257
|
+
if (typeof window !== "undefined") {
|
|
258
|
+
const encrypted = localStorage.getItem(STORAGE_KEYS.tokens);
|
|
259
|
+
if (encrypted) {
|
|
260
|
+
const decrypted = this.decrypt(encrypted);
|
|
261
|
+
if (decrypted?.version) {
|
|
262
|
+
tokenVersion = decrypted.version;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
hasTokens: !!this.getTokens(),
|
|
268
|
+
hasUser: !!this.getUser(),
|
|
269
|
+
tokenVersion
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// src/services/token-manager.ts
|
|
275
|
+
var TokenManager = class _TokenManager {
|
|
276
|
+
constructor() {
|
|
277
|
+
this.refreshPromise = null;
|
|
278
|
+
}
|
|
279
|
+
static getInstance() {
|
|
280
|
+
if (!_TokenManager.instance) {
|
|
281
|
+
_TokenManager.instance = new _TokenManager();
|
|
282
|
+
}
|
|
283
|
+
return _TokenManager.instance;
|
|
284
|
+
}
|
|
285
|
+
getAccessToken() {
|
|
286
|
+
const tokens = SecureTokenStorage.getInstance().getTokens();
|
|
287
|
+
return tokens?.accessToken || null;
|
|
288
|
+
}
|
|
289
|
+
isTokenExpired() {
|
|
290
|
+
const tokens = SecureTokenStorage.getInstance().getTokens();
|
|
291
|
+
if (!tokens) return true;
|
|
292
|
+
try {
|
|
293
|
+
const payload = JSON.parse(atob(tokens.accessToken.split(".")[1]));
|
|
294
|
+
return payload.exp * 1e3 < Date.now();
|
|
295
|
+
} catch {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async refreshToken(refreshFn) {
|
|
300
|
+
if (this.refreshPromise) {
|
|
301
|
+
return this.refreshPromise;
|
|
302
|
+
}
|
|
303
|
+
this.refreshPromise = this.performRefresh(refreshFn);
|
|
304
|
+
return this.refreshPromise;
|
|
305
|
+
}
|
|
306
|
+
async performRefresh(refreshFn) {
|
|
307
|
+
try {
|
|
308
|
+
const tokens = await refreshFn();
|
|
309
|
+
SecureTokenStorage.getInstance().setTokens(tokens);
|
|
310
|
+
return tokens.accessToken;
|
|
311
|
+
} finally {
|
|
312
|
+
this.refreshPromise = null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// src/services/token-blacklist.ts
|
|
318
|
+
var TokenBlacklist = class _TokenBlacklist {
|
|
319
|
+
constructor() {
|
|
320
|
+
this.blacklist = /* @__PURE__ */ new Map();
|
|
321
|
+
this.cleanupInterval = null;
|
|
322
|
+
this.startCleanup();
|
|
323
|
+
}
|
|
324
|
+
static getInstance() {
|
|
325
|
+
if (!_TokenBlacklist.instance) {
|
|
326
|
+
_TokenBlacklist.instance = new _TokenBlacklist();
|
|
327
|
+
}
|
|
328
|
+
return _TokenBlacklist.instance;
|
|
329
|
+
}
|
|
330
|
+
async addToBlacklist(token, userId, reason) {
|
|
331
|
+
try {
|
|
332
|
+
const payload = JSON.parse(atob(token.split(".")[1]));
|
|
333
|
+
const expiresAt = new Date(payload.exp * 1e3);
|
|
334
|
+
const entry = {
|
|
335
|
+
token,
|
|
336
|
+
userId,
|
|
337
|
+
expiresAt,
|
|
338
|
+
reason
|
|
339
|
+
};
|
|
340
|
+
this.blacklist.set(this.hashToken(token), entry);
|
|
341
|
+
await this.syncToBackend(entry);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
console.error("Failed to blacklist token:", error);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async isBlacklisted(token) {
|
|
347
|
+
const hashedToken = this.hashToken(token);
|
|
348
|
+
const entry = this.blacklist.get(hashedToken);
|
|
349
|
+
if (!entry) return false;
|
|
350
|
+
if (entry.expiresAt < /* @__PURE__ */ new Date()) {
|
|
351
|
+
this.blacklist.delete(hashedToken);
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
async revokeAllUserTokens(userId) {
|
|
357
|
+
for (const [key, entry] of this.blacklist.entries()) {
|
|
358
|
+
if (entry.userId === userId) {
|
|
359
|
+
this.blacklist.delete(key);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
await this.revokeAllOnBackend(userId);
|
|
363
|
+
}
|
|
364
|
+
hashToken(token) {
|
|
365
|
+
let hash = 0;
|
|
366
|
+
for (let i = 0; i < token.length; i++) {
|
|
367
|
+
const char = token.charCodeAt(i);
|
|
368
|
+
hash = (hash << 5) - hash + char;
|
|
369
|
+
hash = hash & hash;
|
|
370
|
+
}
|
|
371
|
+
return hash.toString();
|
|
372
|
+
}
|
|
373
|
+
async syncToBackend(entry) {
|
|
374
|
+
try {
|
|
375
|
+
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/blacklist`, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
headers: { "Content-Type": "application/json" },
|
|
378
|
+
body: JSON.stringify(entry)
|
|
379
|
+
});
|
|
380
|
+
} catch (error) {
|
|
381
|
+
console.error("Failed to sync blacklist to backend");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async revokeAllOnBackend(userId) {
|
|
385
|
+
try {
|
|
386
|
+
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/revoke-all/${userId}`, {
|
|
387
|
+
method: "POST"
|
|
388
|
+
});
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error("Failed to revoke tokens on backend");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
startCleanup() {
|
|
394
|
+
this.cleanupInterval = setInterval(() => {
|
|
395
|
+
const now = /* @__PURE__ */ new Date();
|
|
396
|
+
for (const [key, entry] of this.blacklist.entries()) {
|
|
397
|
+
if (entry.expiresAt < now) {
|
|
398
|
+
this.blacklist.delete(key);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}, 60 * 60 * 1e3);
|
|
402
|
+
}
|
|
403
|
+
stopCleanup() {
|
|
404
|
+
if (this.cleanupInterval) {
|
|
405
|
+
clearInterval(this.cleanupInterval);
|
|
406
|
+
this.cleanupInterval = null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
var MFAService = class _MFAService {
|
|
411
|
+
static getInstance() {
|
|
412
|
+
if (!_MFAService.instance) {
|
|
413
|
+
_MFAService.instance = new _MFAService();
|
|
414
|
+
}
|
|
415
|
+
return _MFAService.instance;
|
|
416
|
+
}
|
|
417
|
+
async setupMFA(userId, email) {
|
|
418
|
+
const secret = new OTPAuth__namespace.Secret({ size: 20 });
|
|
419
|
+
const secretBase32 = secret.base32;
|
|
420
|
+
const totp = new OTPAuth__namespace.TOTP({
|
|
421
|
+
issuer: "YourApp",
|
|
422
|
+
label: email,
|
|
423
|
+
algorithm: "SHA1",
|
|
424
|
+
digits: 6,
|
|
425
|
+
period: 30,
|
|
426
|
+
secret
|
|
427
|
+
});
|
|
428
|
+
const otpUri = totp.toString();
|
|
429
|
+
const qrCode = await QRCode__default.default.toDataURL(otpUri);
|
|
430
|
+
const backupCodes = this.generateBackupCodes();
|
|
431
|
+
sessionStorage.setItem(`mfa_setup_${userId}`, secretBase32);
|
|
432
|
+
sessionStorage.setItem(`mfa_backup_${userId}`, JSON.stringify(backupCodes));
|
|
433
|
+
return { secret: secretBase32, qrCode, backupCodes };
|
|
434
|
+
}
|
|
435
|
+
async verifyAndEnableMFA(userId, code) {
|
|
436
|
+
const storedSecret = sessionStorage.getItem(`mfa_setup_${userId}`);
|
|
437
|
+
if (!storedSecret) return false;
|
|
438
|
+
try {
|
|
439
|
+
const secretBuffer = rfc4648.base32.parse(storedSecret);
|
|
440
|
+
const secret = new OTPAuth__namespace.Secret({ buffer: secretBuffer.buffer });
|
|
441
|
+
const totp = new OTPAuth__namespace.TOTP({
|
|
442
|
+
issuer: "YourApp",
|
|
443
|
+
label: userId,
|
|
444
|
+
algorithm: "SHA1",
|
|
445
|
+
digits: 6,
|
|
446
|
+
period: 30,
|
|
447
|
+
secret
|
|
448
|
+
});
|
|
449
|
+
const isValid = totp.validate({ token: code, window: 1 }) !== null;
|
|
450
|
+
if (isValid) {
|
|
451
|
+
const backupCodes = JSON.parse(sessionStorage.getItem(`mfa_backup_${userId}`) || "[]");
|
|
452
|
+
await this.saveMFAConfig(userId, storedSecret, backupCodes);
|
|
453
|
+
sessionStorage.removeItem(`mfa_setup_${userId}`);
|
|
454
|
+
sessionStorage.removeItem(`mfa_backup_${userId}`);
|
|
455
|
+
}
|
|
456
|
+
return isValid;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
console.error("MFA verification failed:", error);
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async verifyMFA(userId, code) {
|
|
463
|
+
const mfaSecret = await this.getUserMFASecret(userId);
|
|
464
|
+
if (!mfaSecret) return false;
|
|
465
|
+
try {
|
|
466
|
+
const secretBuffer = rfc4648.base32.parse(mfaSecret);
|
|
467
|
+
const secret = new OTPAuth__namespace.Secret({ buffer: secretBuffer.buffer });
|
|
468
|
+
const totp = new OTPAuth__namespace.TOTP({
|
|
469
|
+
issuer: "YourApp",
|
|
470
|
+
label: userId,
|
|
471
|
+
algorithm: "SHA1",
|
|
472
|
+
digits: 6,
|
|
473
|
+
period: 30,
|
|
474
|
+
secret
|
|
475
|
+
});
|
|
476
|
+
return totp.validate({ token: code, window: 1 }) !== null;
|
|
477
|
+
} catch (error) {
|
|
478
|
+
console.error("MFA verification failed:", error);
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
generateBackupCodes() {
|
|
483
|
+
const codes = [];
|
|
484
|
+
for (let i = 0; i < 10; i++) {
|
|
485
|
+
const code = Math.random().toString(36).substring(2, 10).toUpperCase();
|
|
486
|
+
codes.push(code);
|
|
487
|
+
}
|
|
488
|
+
return codes;
|
|
489
|
+
}
|
|
490
|
+
async saveMFAConfig(userId, secret, backupCodes) {
|
|
491
|
+
try {
|
|
492
|
+
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/mfa/enable`, {
|
|
493
|
+
method: "POST",
|
|
494
|
+
headers: { "Content-Type": "application/json" },
|
|
495
|
+
body: JSON.stringify({ userId, secret, backupCodes })
|
|
496
|
+
});
|
|
497
|
+
} catch (error) {
|
|
498
|
+
console.error("Failed to save MFA config:", error);
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async getUserMFASecret(userId) {
|
|
503
|
+
try {
|
|
504
|
+
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/mfa/secret/${userId}`);
|
|
505
|
+
if (!response.ok) return null;
|
|
506
|
+
const data = await response.json();
|
|
507
|
+
return data.secret || null;
|
|
508
|
+
} catch (error) {
|
|
509
|
+
console.error("Failed to get user MFA secret:", error);
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// src/services/rate-limiter.ts
|
|
516
|
+
var RateLimiter = class _RateLimiter {
|
|
517
|
+
constructor() {
|
|
518
|
+
this.attempts = /* @__PURE__ */ new Map();
|
|
519
|
+
this.MAX_ATTEMPTS = 5;
|
|
520
|
+
this.WINDOW_MS = 15 * 60 * 1e3;
|
|
521
|
+
this.BLOCK_DURATION_MS = 30 * 60 * 1e3;
|
|
522
|
+
}
|
|
523
|
+
static getInstance() {
|
|
524
|
+
if (!_RateLimiter.instance) {
|
|
525
|
+
_RateLimiter.instance = new _RateLimiter();
|
|
526
|
+
}
|
|
527
|
+
return _RateLimiter.instance;
|
|
528
|
+
}
|
|
529
|
+
checkRateLimit(identifier) {
|
|
530
|
+
const now = Date.now();
|
|
531
|
+
const record = this.attempts.get(identifier);
|
|
532
|
+
if (record && record.blockedUntil > now) {
|
|
533
|
+
return {
|
|
534
|
+
allowed: false,
|
|
535
|
+
waitTime: Math.ceil((record.blockedUntil - now) / 1e3)
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (record && now - record.firstAttempt > this.WINDOW_MS) {
|
|
539
|
+
this.attempts.delete(identifier);
|
|
540
|
+
return { allowed: true };
|
|
541
|
+
}
|
|
542
|
+
if (!record) {
|
|
543
|
+
this.attempts.set(identifier, { count: 1, firstAttempt: now, blockedUntil: 0 });
|
|
544
|
+
return { allowed: true };
|
|
545
|
+
}
|
|
546
|
+
record.count++;
|
|
547
|
+
if (record.count >= this.MAX_ATTEMPTS) {
|
|
548
|
+
record.blockedUntil = now + this.BLOCK_DURATION_MS;
|
|
549
|
+
this.attempts.set(identifier, record);
|
|
550
|
+
return {
|
|
551
|
+
allowed: false,
|
|
552
|
+
waitTime: Math.ceil(this.BLOCK_DURATION_MS / 1e3)
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
this.attempts.set(identifier, record);
|
|
556
|
+
return { allowed: true };
|
|
557
|
+
}
|
|
558
|
+
reset(identifier) {
|
|
559
|
+
this.attempts.delete(identifier);
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// src/services/audit-logger.ts
|
|
564
|
+
var AuditAction = /* @__PURE__ */ ((AuditAction2) => {
|
|
565
|
+
AuditAction2["LOGIN_SUCCESS"] = "LOGIN_SUCCESS";
|
|
566
|
+
AuditAction2["LOGIN_FAILURE"] = "LOGIN_FAILURE";
|
|
567
|
+
AuditAction2["LOGOUT"] = "LOGOUT";
|
|
568
|
+
AuditAction2["REGISTER_SUCCESS"] = "REGISTER_SUCCESS";
|
|
569
|
+
AuditAction2["PASSWORD_CHANGE"] = "PASSWORD_CHANGE";
|
|
570
|
+
AuditAction2["TOKEN_REFRESH"] = "TOKEN_REFRESH";
|
|
571
|
+
AuditAction2["SESSION_TERMINATED"] = "SESSION_TERMINATED";
|
|
572
|
+
AuditAction2["MFA_ENABLED"] = "MFA_ENABLED";
|
|
573
|
+
AuditAction2["MFA_DISABLED"] = "MFA_DISABLED";
|
|
574
|
+
AuditAction2["MFA_VERIFIED"] = "MFA_VERIFIED";
|
|
575
|
+
return AuditAction2;
|
|
576
|
+
})(AuditAction || {});
|
|
577
|
+
var AuditLogger = class _AuditLogger {
|
|
578
|
+
constructor() {
|
|
579
|
+
this.logQueue = [];
|
|
580
|
+
this.isFlushing = false;
|
|
581
|
+
}
|
|
582
|
+
static getInstance() {
|
|
583
|
+
if (!_AuditLogger.instance) {
|
|
584
|
+
_AuditLogger.instance = new _AuditLogger();
|
|
585
|
+
}
|
|
586
|
+
return _AuditLogger.instance;
|
|
587
|
+
}
|
|
588
|
+
async log(entry) {
|
|
589
|
+
const fullEntry = {
|
|
590
|
+
...entry,
|
|
591
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
592
|
+
ipAddress: await this.getClientIP(),
|
|
593
|
+
userAgent: navigator.userAgent
|
|
594
|
+
};
|
|
595
|
+
this.logQueue.push(fullEntry);
|
|
596
|
+
this.scheduleFlush();
|
|
597
|
+
}
|
|
598
|
+
scheduleFlush() {
|
|
599
|
+
if (this.isFlushing) return;
|
|
600
|
+
setTimeout(() => this.flush(), 5e3);
|
|
601
|
+
}
|
|
602
|
+
async flush() {
|
|
603
|
+
if (this.logQueue.length === 0) return;
|
|
604
|
+
this.isFlushing = true;
|
|
605
|
+
const entries = [...this.logQueue];
|
|
606
|
+
this.logQueue = [];
|
|
607
|
+
try {
|
|
608
|
+
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/audit`, {
|
|
609
|
+
method: "POST",
|
|
610
|
+
headers: { "Content-Type": "application/json" },
|
|
611
|
+
body: JSON.stringify({ entries }),
|
|
612
|
+
keepalive: true
|
|
613
|
+
});
|
|
614
|
+
} catch (error) {
|
|
615
|
+
this.logQueue.unshift(...entries);
|
|
616
|
+
} finally {
|
|
617
|
+
this.isFlushing = false;
|
|
618
|
+
if (this.logQueue.length > 0) {
|
|
619
|
+
this.scheduleFlush();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
async getClientIP() {
|
|
624
|
+
try {
|
|
625
|
+
const response = await fetch("https://api.ipify.org?format=json");
|
|
626
|
+
const data = await response.json();
|
|
627
|
+
return data.ip;
|
|
628
|
+
} catch {
|
|
629
|
+
return "unknown";
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// src/services/device-fingerprint.ts
|
|
635
|
+
var DeviceFingerprint = class {
|
|
636
|
+
static async generate() {
|
|
637
|
+
const components = {
|
|
638
|
+
userAgent: navigator.userAgent,
|
|
639
|
+
language: navigator.language,
|
|
640
|
+
platform: navigator.platform,
|
|
641
|
+
screenResolution: `${screen.width}x${screen.height}`,
|
|
642
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
643
|
+
hardwareConcurrency: navigator.hardwareConcurrency,
|
|
644
|
+
deviceMemory: navigator.deviceMemory,
|
|
645
|
+
colorDepth: screen.colorDepth
|
|
646
|
+
};
|
|
647
|
+
const fingerprint = await this.hashObject(components);
|
|
648
|
+
return fingerprint;
|
|
649
|
+
}
|
|
650
|
+
static async hashObject(obj) {
|
|
651
|
+
const str = JSON.stringify(obj);
|
|
652
|
+
const encoder = new TextEncoder();
|
|
653
|
+
const data = encoder.encode(str);
|
|
654
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
655
|
+
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
// src/services/session-manager.ts
|
|
660
|
+
var SessionManager = class _SessionManager {
|
|
661
|
+
constructor() {
|
|
662
|
+
this.activeSessions = /* @__PURE__ */ new Map();
|
|
663
|
+
}
|
|
664
|
+
static getInstance() {
|
|
665
|
+
if (!_SessionManager.instance) {
|
|
666
|
+
_SessionManager.instance = new _SessionManager();
|
|
667
|
+
}
|
|
668
|
+
return _SessionManager.instance;
|
|
669
|
+
}
|
|
670
|
+
async registerSession(userId) {
|
|
671
|
+
const deviceId = await DeviceFingerprint.generate();
|
|
672
|
+
this.activeSessions.set(userId, { deviceId, lastActive: /* @__PURE__ */ new Date() });
|
|
673
|
+
return deviceId;
|
|
674
|
+
}
|
|
675
|
+
async validateSession(userId, deviceId) {
|
|
676
|
+
const session = this.activeSessions.get(userId);
|
|
677
|
+
if (!session) return false;
|
|
678
|
+
session.lastActive = /* @__PURE__ */ new Date();
|
|
679
|
+
this.activeSessions.set(userId, session);
|
|
680
|
+
return session.deviceId === deviceId;
|
|
681
|
+
}
|
|
682
|
+
async terminateSession(userId) {
|
|
683
|
+
this.activeSessions.delete(userId);
|
|
684
|
+
}
|
|
685
|
+
async terminateAllOtherSessions(userId, currentDeviceId) {
|
|
686
|
+
const session = this.activeSessions.get(userId);
|
|
687
|
+
if (session && session.deviceId !== currentDeviceId) {
|
|
688
|
+
this.activeSessions.delete(userId);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
getActiveSessionsCount(userId) {
|
|
692
|
+
let count = 0;
|
|
693
|
+
for (const [key] of this.activeSessions.entries()) {
|
|
694
|
+
if (key === userId) count++;
|
|
695
|
+
}
|
|
696
|
+
return count;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// src/services/auth-service.ts
|
|
701
|
+
var AuthService = class _AuthService {
|
|
702
|
+
constructor(apiUrl) {
|
|
703
|
+
this.state = {
|
|
704
|
+
user: null,
|
|
705
|
+
tokens: null,
|
|
706
|
+
isAuthenticated: false,
|
|
707
|
+
isLoading: true,
|
|
708
|
+
error: null
|
|
709
|
+
};
|
|
710
|
+
this.listeners = [];
|
|
711
|
+
this.baseURL = apiUrl || process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api";
|
|
712
|
+
this.axiosInstance = axios__default.default.create({
|
|
713
|
+
baseURL: this.baseURL,
|
|
714
|
+
withCredentials: true,
|
|
715
|
+
timeout: 3e4
|
|
716
|
+
});
|
|
717
|
+
this.loadInitialState();
|
|
718
|
+
}
|
|
719
|
+
static getInstance(apiUrl) {
|
|
720
|
+
if (!_AuthService.instance) {
|
|
721
|
+
_AuthService.instance = new _AuthService(apiUrl);
|
|
722
|
+
}
|
|
723
|
+
return _AuthService.instance;
|
|
724
|
+
}
|
|
725
|
+
// ============ NEW: Retry Logic & Health Check ============
|
|
726
|
+
async makeRequest(method, url, data, retries = 3) {
|
|
727
|
+
let lastError = null;
|
|
728
|
+
for (let i = 0; i < retries; i++) {
|
|
729
|
+
try {
|
|
730
|
+
const response = await this.axiosInstance.request({
|
|
731
|
+
method,
|
|
732
|
+
url,
|
|
733
|
+
data,
|
|
734
|
+
// Exponential backoff
|
|
735
|
+
timeout: 3e4 * (i + 1)
|
|
736
|
+
});
|
|
737
|
+
return response.data;
|
|
738
|
+
} catch (error) {
|
|
739
|
+
lastError = error;
|
|
740
|
+
if (error.response?.status >= 400 && error.response?.status < 500) {
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
if (i === retries - 1) {
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
const delay = 1e3 * Math.pow(2, i);
|
|
747
|
+
console.warn(`Request failed, retrying in ${delay}ms... (Attempt ${i + 2}/${retries})`);
|
|
748
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
throw lastError || new Error("Request failed after multiple retries");
|
|
752
|
+
}
|
|
753
|
+
async healthCheck() {
|
|
754
|
+
try {
|
|
755
|
+
const response = await this.axiosInstance.get("/health", { timeout: 5e3 });
|
|
756
|
+
return response.status === 200;
|
|
757
|
+
} catch (error) {
|
|
758
|
+
console.error("Health check failed:", error);
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async healthCheckWithRetry(retries = 2) {
|
|
763
|
+
for (let i = 0; i < retries; i++) {
|
|
764
|
+
const isHealthy = await this.healthCheck();
|
|
765
|
+
if (isHealthy) return true;
|
|
766
|
+
if (i < retries - 1) {
|
|
767
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3 * (i + 1)));
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
// ============ END NEW METHODS ============
|
|
773
|
+
async loadInitialState() {
|
|
774
|
+
const tokens = SecureTokenStorage.getInstance().getTokens();
|
|
775
|
+
const user = SecureTokenStorage.getInstance().getUser();
|
|
776
|
+
this.state = {
|
|
777
|
+
user,
|
|
778
|
+
tokens,
|
|
779
|
+
isAuthenticated: !!tokens && !TokenManager.getInstance().isTokenExpired(),
|
|
780
|
+
isLoading: false,
|
|
781
|
+
error: null
|
|
782
|
+
};
|
|
783
|
+
this.notifyListeners();
|
|
784
|
+
}
|
|
785
|
+
notifyListeners() {
|
|
786
|
+
this.listeners.forEach((listener) => listener({ ...this.state }));
|
|
787
|
+
}
|
|
788
|
+
subscribe(listener) {
|
|
789
|
+
this.listeners.push(listener);
|
|
790
|
+
listener({ ...this.state });
|
|
791
|
+
return () => {
|
|
792
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
updateState(updates) {
|
|
796
|
+
this.state = { ...this.state, ...updates };
|
|
797
|
+
this.notifyListeners();
|
|
798
|
+
}
|
|
799
|
+
async login(credentials) {
|
|
800
|
+
this.updateState({ isLoading: true, error: null });
|
|
801
|
+
const rateLimit = RateLimiter.getInstance().checkRateLimit(credentials.email);
|
|
802
|
+
if (!rateLimit.allowed) {
|
|
803
|
+
await AuditLogger.getInstance().log({
|
|
804
|
+
action: "LOGIN_FAILURE" /* LOGIN_FAILURE */,
|
|
805
|
+
email: credentials.email,
|
|
806
|
+
details: { reason: "Rate limit exceeded" },
|
|
807
|
+
success: false
|
|
808
|
+
});
|
|
809
|
+
this.updateState({ isLoading: false, error: `Too many attempts. Try again later.` });
|
|
810
|
+
throw new Error(`Too many attempts. Try again later.`);
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
const response = await this.makeRequest("POST", API_ENDPOINTS.login, credentials);
|
|
814
|
+
const { user, requiresMFA, accessToken, refreshToken, expiresIn } = response;
|
|
815
|
+
if (requiresMFA) {
|
|
816
|
+
sessionStorage.setItem("temp_auth_email", credentials.email);
|
|
817
|
+
sessionStorage.setItem("temp_auth_user_id", user.id);
|
|
818
|
+
this.updateState({ isLoading: false });
|
|
819
|
+
return { requiresMFA: true };
|
|
820
|
+
}
|
|
821
|
+
const deviceId = await SessionManager.getInstance().registerSession(user.id);
|
|
822
|
+
const tokens = { accessToken, refreshToken, expiresIn };
|
|
823
|
+
SecureTokenStorage.getInstance().setTokens(tokens);
|
|
824
|
+
SecureTokenStorage.getInstance().setUser(user);
|
|
825
|
+
await AuditLogger.getInstance().log({
|
|
826
|
+
action: "LOGIN_SUCCESS" /* LOGIN_SUCCESS */,
|
|
827
|
+
userId: user.id,
|
|
828
|
+
email: user.email,
|
|
829
|
+
details: { deviceId },
|
|
830
|
+
success: true
|
|
831
|
+
});
|
|
832
|
+
RateLimiter.getInstance().reset(credentials.email);
|
|
833
|
+
this.updateState({
|
|
834
|
+
user,
|
|
835
|
+
tokens,
|
|
836
|
+
isAuthenticated: true,
|
|
837
|
+
isLoading: false,
|
|
838
|
+
error: null
|
|
839
|
+
});
|
|
840
|
+
return { requiresMFA: false, user };
|
|
841
|
+
} catch (error) {
|
|
842
|
+
await AuditLogger.getInstance().log({
|
|
843
|
+
action: "LOGIN_FAILURE" /* LOGIN_FAILURE */,
|
|
844
|
+
email: credentials.email,
|
|
845
|
+
success: false,
|
|
846
|
+
errorMessage: error.message
|
|
847
|
+
});
|
|
848
|
+
this.updateState({
|
|
849
|
+
isLoading: false,
|
|
850
|
+
error: error.response?.data?.message || error.message || "Login failed"
|
|
851
|
+
});
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
async verifyMFA(code, trustDevice = false) {
|
|
856
|
+
const email = sessionStorage.getItem("temp_auth_email");
|
|
857
|
+
const userId = sessionStorage.getItem("temp_auth_user_id");
|
|
858
|
+
if (!email || !userId) {
|
|
859
|
+
throw new Error("No pending MFA verification");
|
|
860
|
+
}
|
|
861
|
+
const isValid = await MFAService.getInstance().verifyMFA(userId, code);
|
|
862
|
+
if (!isValid) {
|
|
863
|
+
throw new Error("Invalid MFA code");
|
|
864
|
+
}
|
|
865
|
+
const response = await this.makeRequest("POST", API_ENDPOINTS.mfaVerify, {
|
|
866
|
+
email,
|
|
867
|
+
userId,
|
|
868
|
+
trustDevice
|
|
869
|
+
});
|
|
870
|
+
const { user, accessToken, refreshToken, expiresIn } = response;
|
|
871
|
+
const tokens = { accessToken, refreshToken, expiresIn };
|
|
872
|
+
SecureTokenStorage.getInstance().setTokens(tokens);
|
|
873
|
+
SecureTokenStorage.getInstance().setUser(user);
|
|
874
|
+
sessionStorage.removeItem("temp_auth_email");
|
|
875
|
+
sessionStorage.removeItem("temp_auth_user_id");
|
|
876
|
+
this.updateState({
|
|
877
|
+
user,
|
|
878
|
+
tokens,
|
|
879
|
+
isAuthenticated: true,
|
|
880
|
+
isLoading: false,
|
|
881
|
+
error: null
|
|
882
|
+
});
|
|
883
|
+
return user;
|
|
884
|
+
}
|
|
885
|
+
async register(data) {
|
|
886
|
+
this.updateState({ isLoading: true, error: null });
|
|
887
|
+
try {
|
|
888
|
+
const response = await this.makeRequest("POST", API_ENDPOINTS.register, data);
|
|
889
|
+
const { user, accessToken, refreshToken, expiresIn } = response;
|
|
890
|
+
const tokens = { accessToken, refreshToken, expiresIn };
|
|
891
|
+
SecureTokenStorage.getInstance().setTokens(tokens);
|
|
892
|
+
SecureTokenStorage.getInstance().setUser(user);
|
|
893
|
+
await AuditLogger.getInstance().log({
|
|
894
|
+
action: "REGISTER_SUCCESS" /* REGISTER_SUCCESS */,
|
|
895
|
+
userId: user.id,
|
|
896
|
+
email: user.email,
|
|
897
|
+
success: true
|
|
898
|
+
});
|
|
899
|
+
this.updateState({
|
|
900
|
+
user,
|
|
901
|
+
tokens,
|
|
902
|
+
isAuthenticated: true,
|
|
903
|
+
isLoading: false,
|
|
904
|
+
error: null
|
|
905
|
+
});
|
|
906
|
+
return user;
|
|
907
|
+
} catch (error) {
|
|
908
|
+
this.updateState({
|
|
909
|
+
isLoading: false,
|
|
910
|
+
error: error.response?.data?.message || error.message || "Registration failed"
|
|
911
|
+
});
|
|
912
|
+
throw error;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
async logout(reason = "logout") {
|
|
916
|
+
const user = SecureTokenStorage.getInstance().getUser();
|
|
917
|
+
const tokens = SecureTokenStorage.getInstance().getTokens();
|
|
918
|
+
if (tokens?.accessToken) {
|
|
919
|
+
await TokenBlacklist.getInstance().addToBlacklist(
|
|
920
|
+
tokens.accessToken,
|
|
921
|
+
user?.id || "",
|
|
922
|
+
reason
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
try {
|
|
926
|
+
await this.axiosInstance.post(API_ENDPOINTS.logout);
|
|
927
|
+
} catch (error) {
|
|
928
|
+
console.warn("Logout API call failed, but clearing local state");
|
|
929
|
+
}
|
|
930
|
+
SecureTokenStorage.getInstance().clear();
|
|
931
|
+
this.updateState({
|
|
932
|
+
user: null,
|
|
933
|
+
tokens: null,
|
|
934
|
+
isAuthenticated: false,
|
|
935
|
+
isLoading: false,
|
|
936
|
+
error: null
|
|
937
|
+
});
|
|
938
|
+
if (typeof window !== "undefined") {
|
|
939
|
+
window.location.href = "/login";
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
async refreshSession() {
|
|
943
|
+
try {
|
|
944
|
+
const tokens = SecureTokenStorage.getInstance().getTokens();
|
|
945
|
+
if (!tokens?.refreshToken) throw new Error("No refresh token");
|
|
946
|
+
const response = await this.makeRequest("POST", API_ENDPOINTS.refresh, {
|
|
947
|
+
refreshToken: tokens.refreshToken
|
|
948
|
+
});
|
|
949
|
+
const { accessToken, refreshToken, expiresIn } = response;
|
|
950
|
+
SecureTokenStorage.getInstance().setTokens({ accessToken, refreshToken, expiresIn });
|
|
951
|
+
this.updateState({
|
|
952
|
+
tokens: { accessToken, refreshToken, expiresIn },
|
|
953
|
+
isAuthenticated: true
|
|
954
|
+
});
|
|
955
|
+
} catch (error) {
|
|
956
|
+
console.error("Session refresh failed:", error);
|
|
957
|
+
this.logout("security");
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
async logoutAllDevices() {
|
|
961
|
+
const user = SecureTokenStorage.getInstance().getUser();
|
|
962
|
+
if (!user) return;
|
|
963
|
+
try {
|
|
964
|
+
await TokenBlacklist.getInstance().revokeAllUserTokens(user.id);
|
|
965
|
+
await this.axiosInstance.post("/auth/logout-all");
|
|
966
|
+
} catch (error) {
|
|
967
|
+
console.warn("Logout all devices API call failed");
|
|
968
|
+
}
|
|
969
|
+
this.logout("revoke");
|
|
970
|
+
}
|
|
971
|
+
async changePassword(currentPassword, newPassword) {
|
|
972
|
+
const user = SecureTokenStorage.getInstance().getUser();
|
|
973
|
+
try {
|
|
974
|
+
await this.makeRequest("POST", API_ENDPOINTS.changePassword, {
|
|
975
|
+
currentPassword,
|
|
976
|
+
newPassword
|
|
977
|
+
});
|
|
978
|
+
await AuditLogger.getInstance().log({
|
|
979
|
+
action: "PASSWORD_CHANGE" /* PASSWORD_CHANGE */,
|
|
980
|
+
userId: user?.id,
|
|
981
|
+
email: user?.email,
|
|
982
|
+
success: true
|
|
983
|
+
});
|
|
984
|
+
} catch (error) {
|
|
985
|
+
await AuditLogger.getInstance().log({
|
|
986
|
+
action: "PASSWORD_CHANGE" /* PASSWORD_CHANGE */,
|
|
987
|
+
userId: user?.id,
|
|
988
|
+
email: user?.email,
|
|
989
|
+
success: false,
|
|
990
|
+
errorMessage: error.message
|
|
991
|
+
});
|
|
992
|
+
throw error;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
hasRole(role) {
|
|
996
|
+
if (!this.state.user) return false;
|
|
997
|
+
const roles = Array.isArray(role) ? role : [role];
|
|
998
|
+
return roles.includes(this.state.user.role || "");
|
|
999
|
+
}
|
|
1000
|
+
getAxiosInstance() {
|
|
1001
|
+
return this.axiosInstance;
|
|
1002
|
+
}
|
|
1003
|
+
getUser() {
|
|
1004
|
+
return this.state.user;
|
|
1005
|
+
}
|
|
1006
|
+
getState() {
|
|
1007
|
+
return { ...this.state };
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
function useAuth(apiUrl) {
|
|
1011
|
+
const [state, setState] = react.useState(AuthService.getInstance(apiUrl).getState());
|
|
1012
|
+
react.useEffect(() => {
|
|
1013
|
+
const unsubscribe = AuthService.getInstance(apiUrl).subscribe(setState);
|
|
1014
|
+
return unsubscribe;
|
|
1015
|
+
}, [apiUrl]);
|
|
1016
|
+
const login = async (credentials) => {
|
|
1017
|
+
return AuthService.getInstance(apiUrl).login(credentials);
|
|
1018
|
+
};
|
|
1019
|
+
const verifyMFA = async (code, trustDevice = false) => {
|
|
1020
|
+
return AuthService.getInstance(apiUrl).verifyMFA(code, trustDevice);
|
|
1021
|
+
};
|
|
1022
|
+
const register = async (data) => {
|
|
1023
|
+
return AuthService.getInstance(apiUrl).register(data);
|
|
1024
|
+
};
|
|
1025
|
+
const logout = async () => {
|
|
1026
|
+
return AuthService.getInstance(apiUrl).logout();
|
|
1027
|
+
};
|
|
1028
|
+
const refreshSession = async () => {
|
|
1029
|
+
return AuthService.getInstance(apiUrl).refreshSession();
|
|
1030
|
+
};
|
|
1031
|
+
const hasRole = (role) => {
|
|
1032
|
+
return AuthService.getInstance(apiUrl).hasRole(role);
|
|
1033
|
+
};
|
|
1034
|
+
return {
|
|
1035
|
+
...state,
|
|
1036
|
+
login,
|
|
1037
|
+
verifyMFA,
|
|
1038
|
+
register,
|
|
1039
|
+
logout,
|
|
1040
|
+
refreshSession,
|
|
1041
|
+
hasRole
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
function useProtectedRoute(redirectTo = "/login", requiredRole) {
|
|
1045
|
+
const { isAuthenticated, isLoading, hasRole } = useAuth();
|
|
1046
|
+
const router = navigation.useRouter();
|
|
1047
|
+
react.useEffect(() => {
|
|
1048
|
+
if (!isLoading && !isAuthenticated) {
|
|
1049
|
+
router.push(redirectTo);
|
|
1050
|
+
}
|
|
1051
|
+
if (!isLoading && isAuthenticated && requiredRole && !hasRole(requiredRole)) {
|
|
1052
|
+
router.push("/unauthorized");
|
|
1053
|
+
}
|
|
1054
|
+
}, [isAuthenticated, isLoading, router, redirectTo, requiredRole, hasRole]);
|
|
1055
|
+
return { isAuthenticated, isLoading };
|
|
1056
|
+
}
|
|
1057
|
+
var AuthContext = react.createContext(null);
|
|
1058
|
+
function AuthProvider({ children, apiUrl }) {
|
|
1059
|
+
const [state, setState] = react.useState({
|
|
1060
|
+
user: null,
|
|
1061
|
+
tokens: null,
|
|
1062
|
+
isAuthenticated: false,
|
|
1063
|
+
isLoading: true,
|
|
1064
|
+
error: null
|
|
1065
|
+
});
|
|
1066
|
+
react.useEffect(() => {
|
|
1067
|
+
const authService2 = AuthService.getInstance(apiUrl);
|
|
1068
|
+
const unsubscribe = authService2.subscribe(setState);
|
|
1069
|
+
return unsubscribe;
|
|
1070
|
+
}, [apiUrl]);
|
|
1071
|
+
const authService = AuthService.getInstance(apiUrl);
|
|
1072
|
+
const value = {
|
|
1073
|
+
...state,
|
|
1074
|
+
login: (credentials) => authService.login(credentials),
|
|
1075
|
+
register: (data) => authService.register(data),
|
|
1076
|
+
logout: () => authService.logout(),
|
|
1077
|
+
refreshSession: () => authService.refreshSession(),
|
|
1078
|
+
verifyMFA: (code, trustDevice) => authService.verifyMFA(code, trustDevice),
|
|
1079
|
+
hasRole: (role) => authService.hasRole(role),
|
|
1080
|
+
getAxiosInstance: () => authService.getAxiosInstance(),
|
|
1081
|
+
getUser: () => authService.getUser()
|
|
1082
|
+
};
|
|
1083
|
+
return /* @__PURE__ */ jsxRuntime.jsx(AuthContext.Provider, { value, children });
|
|
1084
|
+
}
|
|
1085
|
+
function ProtectedRoute({ children, requiredRole, fallback }) {
|
|
1086
|
+
const { isAuthenticated, isLoading } = useProtectedRoute("/login", requiredRole);
|
|
1087
|
+
if (isLoading) {
|
|
1088
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center justify-center min-h-screen", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" }) });
|
|
1089
|
+
}
|
|
1090
|
+
if (!isAuthenticated) {
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children });
|
|
1094
|
+
}
|
|
1095
|
+
function MFASetup({ userId, email, onComplete, onCancel }) {
|
|
1096
|
+
const [step, setStep] = react.useState("setup");
|
|
1097
|
+
const [qrCode, setQrCode] = react.useState("");
|
|
1098
|
+
const [backupCodes, setBackupCodes] = react.useState([]);
|
|
1099
|
+
const [verificationCode, setVerificationCode] = react.useState("");
|
|
1100
|
+
const [error, setError] = react.useState("");
|
|
1101
|
+
const [copied, setCopied] = react.useState(false);
|
|
1102
|
+
const [isLoading, setIsLoading] = react.useState(false);
|
|
1103
|
+
const handleSetup = async () => {
|
|
1104
|
+
setIsLoading(true);
|
|
1105
|
+
try {
|
|
1106
|
+
const result = await MFAService.getInstance().setupMFA(userId, email);
|
|
1107
|
+
setQrCode(result.qrCode);
|
|
1108
|
+
setBackupCodes(result.backupCodes);
|
|
1109
|
+
setStep("verify");
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
setError("Failed to setup MFA");
|
|
1112
|
+
} finally {
|
|
1113
|
+
setIsLoading(false);
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
const handleVerify = async () => {
|
|
1117
|
+
if (!verificationCode) {
|
|
1118
|
+
setError("Please enter verification code");
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
setIsLoading(true);
|
|
1122
|
+
try {
|
|
1123
|
+
const isValid = await MFAService.getInstance().verifyAndEnableMFA(userId, verificationCode);
|
|
1124
|
+
if (isValid) {
|
|
1125
|
+
onComplete();
|
|
1126
|
+
} else {
|
|
1127
|
+
setError("Invalid verification code");
|
|
1128
|
+
}
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
setError("Verification failed");
|
|
1131
|
+
} finally {
|
|
1132
|
+
setIsLoading(false);
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
const copyBackupCodes = () => {
|
|
1136
|
+
navigator.clipboard.writeText(backupCodes.join("\n"));
|
|
1137
|
+
setCopied(true);
|
|
1138
|
+
setTimeout(() => setCopied(false), 2e3);
|
|
1139
|
+
};
|
|
1140
|
+
if (step === "setup") {
|
|
1141
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "max-w-md mx-auto p-6 bg-white dark:bg-gray-900 rounded-lg border shadow-xl", children: [
|
|
1142
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "text-center mb-6", children: [
|
|
1143
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "inline-flex p-3 rounded-full bg-blue-100 dark:bg-blue-900/30 mb-4", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Shield, { className: "h-6 w-6 text-blue-600" }) }),
|
|
1144
|
+
/* @__PURE__ */ jsxRuntime.jsx("h2", { className: "text-xl font-semibold", children: "Set Up Two-Factor Authentication" }),
|
|
1145
|
+
/* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-gray-500 mt-2", children: "Enhance your account security with 2FA" })
|
|
1146
|
+
] }),
|
|
1147
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-4", children: [
|
|
1148
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-4 bg-blue-50 dark:bg-blue-950 rounded-lg", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-blue-700 dark:text-blue-300", children: "Two-factor authentication adds an extra layer of security to your account. You'll need to enter a verification code from your authenticator app when signing in." }) }),
|
|
1149
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1150
|
+
"button",
|
|
1151
|
+
{
|
|
1152
|
+
onClick: handleSetup,
|
|
1153
|
+
disabled: isLoading,
|
|
1154
|
+
className: "w-full py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50",
|
|
1155
|
+
children: isLoading ? "Setting up..." : "Get Started"
|
|
1156
|
+
}
|
|
1157
|
+
),
|
|
1158
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1159
|
+
"button",
|
|
1160
|
+
{
|
|
1161
|
+
onClick: onCancel,
|
|
1162
|
+
className: "w-full py-2 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors",
|
|
1163
|
+
children: "Cancel"
|
|
1164
|
+
}
|
|
1165
|
+
)
|
|
1166
|
+
] })
|
|
1167
|
+
] });
|
|
1168
|
+
}
|
|
1169
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "max-w-md mx-auto p-6 bg-white dark:bg-gray-900 rounded-lg border shadow-xl", children: [
|
|
1170
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "text-center mb-6", children: [
|
|
1171
|
+
/* @__PURE__ */ jsxRuntime.jsx("h2", { className: "text-xl font-semibold", children: "Scan QR Code" }),
|
|
1172
|
+
/* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-gray-500 mt-2", children: "Scan this QR code with your authenticator app" })
|
|
1173
|
+
] }),
|
|
1174
|
+
qrCode && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex justify-center mb-6", children: /* @__PURE__ */ jsxRuntime.jsx("img", { src: qrCode, alt: "QR Code", className: "w-48 h-48" }) }),
|
|
1175
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "space-y-4", children: [
|
|
1176
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
|
|
1177
|
+
/* @__PURE__ */ jsxRuntime.jsx("label", { className: "block text-sm font-medium mb-2", children: "Backup Codes" }),
|
|
1178
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "bg-gray-50 dark:bg-gray-800 rounded-lg p-3", children: [
|
|
1179
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-2 gap-1 font-mono text-xs", children: backupCodes.map((code, idx) => /* @__PURE__ */ jsxRuntime.jsx("div", { children: code }, idx)) }),
|
|
1180
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2 mt-3", children: [
|
|
1181
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
1182
|
+
"button",
|
|
1183
|
+
{
|
|
1184
|
+
onClick: copyBackupCodes,
|
|
1185
|
+
className: "flex-1 py-1.5 text-sm border rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center justify-center gap-1",
|
|
1186
|
+
children: [
|
|
1187
|
+
copied ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Check, { className: "h-3 w-3" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Copy, { className: "h-3 w-3" }),
|
|
1188
|
+
copied ? "Copied!" : "Copy Codes"
|
|
1189
|
+
]
|
|
1190
|
+
}
|
|
1191
|
+
),
|
|
1192
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
1193
|
+
"button",
|
|
1194
|
+
{
|
|
1195
|
+
onClick: () => {
|
|
1196
|
+
const blob = new Blob([backupCodes.join("\n")], { type: "text/plain" });
|
|
1197
|
+
const url = URL.createObjectURL(blob);
|
|
1198
|
+
const a = document.createElement("a");
|
|
1199
|
+
a.href = url;
|
|
1200
|
+
a.download = "backup-codes.txt";
|
|
1201
|
+
a.click();
|
|
1202
|
+
URL.revokeObjectURL(url);
|
|
1203
|
+
},
|
|
1204
|
+
className: "flex-1 py-1.5 text-sm border rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center justify-center gap-1",
|
|
1205
|
+
children: [
|
|
1206
|
+
/* @__PURE__ */ jsxRuntime.jsx(lucideReact.Download, { className: "h-3 w-3" }),
|
|
1207
|
+
"Download"
|
|
1208
|
+
]
|
|
1209
|
+
}
|
|
1210
|
+
)
|
|
1211
|
+
] })
|
|
1212
|
+
] }),
|
|
1213
|
+
/* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-gray-500 mt-2", children: "Save these backup codes in a secure place. You can use them to access your account if you lose your device." })
|
|
1214
|
+
] }),
|
|
1215
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
|
|
1216
|
+
/* @__PURE__ */ jsxRuntime.jsx("label", { className: "block text-sm font-medium mb-2", children: "Verification Code" }),
|
|
1217
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1218
|
+
"input",
|
|
1219
|
+
{
|
|
1220
|
+
type: "text",
|
|
1221
|
+
value: verificationCode,
|
|
1222
|
+
onChange: (e) => setVerificationCode(e.target.value),
|
|
1223
|
+
placeholder: "Enter 6-digit code",
|
|
1224
|
+
className: "w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent",
|
|
1225
|
+
maxLength: 6
|
|
1226
|
+
}
|
|
1227
|
+
)
|
|
1228
|
+
] }),
|
|
1229
|
+
error && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-3 bg-red-50 dark:bg-red-950/50 border border-red-200 rounded-lg text-sm text-red-600", children: error }),
|
|
1230
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1231
|
+
"button",
|
|
1232
|
+
{
|
|
1233
|
+
onClick: handleVerify,
|
|
1234
|
+
disabled: isLoading,
|
|
1235
|
+
className: "w-full py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50",
|
|
1236
|
+
children: isLoading ? "Verifying..." : "Verify and Enable"
|
|
1237
|
+
}
|
|
1238
|
+
)
|
|
1239
|
+
] })
|
|
1240
|
+
] });
|
|
1241
|
+
}
|
|
1242
|
+
function MFAVerification({ onSubmit, onBack }) {
|
|
1243
|
+
const [code, setCode] = react.useState("");
|
|
1244
|
+
const [trustDevice, setTrustDevice] = react.useState(false);
|
|
1245
|
+
const [error, setError] = react.useState("");
|
|
1246
|
+
const [isLoading, setIsLoading] = react.useState(false);
|
|
1247
|
+
const handleSubmit = async (e) => {
|
|
1248
|
+
e.preventDefault();
|
|
1249
|
+
if (code.length !== 6) {
|
|
1250
|
+
setError("Please enter a valid 6-digit code");
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
setIsLoading(true);
|
|
1254
|
+
try {
|
|
1255
|
+
await onSubmit(code, trustDevice);
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
setError(err.message || "Invalid verification code");
|
|
1258
|
+
} finally {
|
|
1259
|
+
setIsLoading(false);
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "max-w-md mx-auto p-6 bg-white dark:bg-gray-900 rounded-lg border shadow-xl", children: [
|
|
1263
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "text-center mb-6", children: [
|
|
1264
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "inline-flex p-3 rounded-full bg-blue-100 dark:bg-blue-900/30 mb-4", children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Shield, { className: "h-6 w-6 text-blue-600" }) }),
|
|
1265
|
+
/* @__PURE__ */ jsxRuntime.jsx("h2", { className: "text-xl font-semibold", children: "Two-Factor Authentication" }),
|
|
1266
|
+
/* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-gray-500 mt-2", children: "Enter the verification code from your authenticator app" })
|
|
1267
|
+
] }),
|
|
1268
|
+
/* @__PURE__ */ jsxRuntime.jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [
|
|
1269
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
|
|
1270
|
+
/* @__PURE__ */ jsxRuntime.jsx("label", { className: "block text-sm font-medium mb-2", children: "Verification Code" }),
|
|
1271
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1272
|
+
"input",
|
|
1273
|
+
{
|
|
1274
|
+
type: "text",
|
|
1275
|
+
value: code,
|
|
1276
|
+
onChange: (e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6)),
|
|
1277
|
+
placeholder: "000000",
|
|
1278
|
+
className: "w-full text-center text-2xl tracking-widest font-mono px-3 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent",
|
|
1279
|
+
maxLength: 6,
|
|
1280
|
+
autoFocus: true
|
|
1281
|
+
}
|
|
1282
|
+
)
|
|
1283
|
+
] }),
|
|
1284
|
+
/* @__PURE__ */ jsxRuntime.jsxs("label", { className: "flex items-center gap-2 cursor-pointer", children: [
|
|
1285
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1286
|
+
"input",
|
|
1287
|
+
{
|
|
1288
|
+
type: "checkbox",
|
|
1289
|
+
checked: trustDevice,
|
|
1290
|
+
onChange: (e) => setTrustDevice(e.target.checked),
|
|
1291
|
+
className: "rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
1292
|
+
}
|
|
1293
|
+
),
|
|
1294
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm text-gray-600 dark:text-gray-400", children: "Trust this device for 30 days" })
|
|
1295
|
+
] }),
|
|
1296
|
+
error && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "p-3 bg-red-50 dark:bg-red-950/50 border border-red-200 rounded-lg flex items-start gap-2", children: [
|
|
1297
|
+
/* @__PURE__ */ jsxRuntime.jsx(lucideReact.AlertCircle, { className: "h-4 w-4 text-red-600 mt-0.5" }),
|
|
1298
|
+
/* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-sm text-red-600", children: error })
|
|
1299
|
+
] }),
|
|
1300
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1301
|
+
"button",
|
|
1302
|
+
{
|
|
1303
|
+
type: "submit",
|
|
1304
|
+
disabled: isLoading,
|
|
1305
|
+
className: "w-full py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50",
|
|
1306
|
+
children: isLoading ? "Verifying..." : "Verify & Continue"
|
|
1307
|
+
}
|
|
1308
|
+
),
|
|
1309
|
+
onBack && /* @__PURE__ */ jsxRuntime.jsx(
|
|
1310
|
+
"button",
|
|
1311
|
+
{
|
|
1312
|
+
type: "button",
|
|
1313
|
+
onClick: onBack,
|
|
1314
|
+
className: "w-full py-2 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors",
|
|
1315
|
+
children: "Back to Login"
|
|
1316
|
+
}
|
|
1317
|
+
)
|
|
1318
|
+
] })
|
|
1319
|
+
] });
|
|
1320
|
+
}
|
|
1321
|
+
function SessionWarning({ warningMinutes = 2 }) {
|
|
1322
|
+
const { refreshSession } = useAuth();
|
|
1323
|
+
const [showWarning, setShowWarning] = react.useState(false);
|
|
1324
|
+
const [timeLeft, setTimeLeft] = react.useState(0);
|
|
1325
|
+
react.useEffect(() => {
|
|
1326
|
+
const checkSession = () => {
|
|
1327
|
+
const tokens = localStorage.getItem("auth_tokens_encrypted");
|
|
1328
|
+
if (!tokens) return;
|
|
1329
|
+
try {
|
|
1330
|
+
const payload = JSON.parse(atob(tokens.split(".")[1]));
|
|
1331
|
+
const expiresIn = payload.exp * 1e3 - Date.now();
|
|
1332
|
+
const minutesLeft = Math.floor(expiresIn / 1e3 / 60);
|
|
1333
|
+
if (minutesLeft <= warningMinutes && minutesLeft > 0) {
|
|
1334
|
+
setShowWarning(true);
|
|
1335
|
+
setTimeLeft(minutesLeft);
|
|
1336
|
+
} else if (minutesLeft <= 0) {
|
|
1337
|
+
setShowWarning(false);
|
|
1338
|
+
} else {
|
|
1339
|
+
setShowWarning(false);
|
|
1340
|
+
}
|
|
1341
|
+
} catch {
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
checkSession();
|
|
1345
|
+
const interval = setInterval(checkSession, 6e4);
|
|
1346
|
+
return () => clearInterval(interval);
|
|
1347
|
+
}, [warningMinutes]);
|
|
1348
|
+
const handleRefresh = async () => {
|
|
1349
|
+
await refreshSession();
|
|
1350
|
+
setShowWarning(false);
|
|
1351
|
+
};
|
|
1352
|
+
if (!showWarning) return null;
|
|
1353
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "fixed bottom-4 right-4 z-50 animate-in slide-in-from-right-5", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg shadow-lg p-4 max-w-sm", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-start gap-3", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex-1", children: [
|
|
1354
|
+
/* @__PURE__ */ jsxRuntime.jsx("h3", { className: "font-semibold text-yellow-800 dark:text-yellow-200", children: "Session Expiring Soon" }),
|
|
1355
|
+
/* @__PURE__ */ jsxRuntime.jsxs("p", { className: "text-sm text-yellow-700 dark:text-yellow-300 mt-1", children: [
|
|
1356
|
+
"Your session will expire in ",
|
|
1357
|
+
timeLeft,
|
|
1358
|
+
" minute",
|
|
1359
|
+
timeLeft !== 1 ? "s" : "",
|
|
1360
|
+
'. Click "Stay Logged In" to extend your session.'
|
|
1361
|
+
] }),
|
|
1362
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2 mt-3", children: [
|
|
1363
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1364
|
+
"button",
|
|
1365
|
+
{
|
|
1366
|
+
onClick: handleRefresh,
|
|
1367
|
+
className: "px-3 py-1 text-sm bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors",
|
|
1368
|
+
children: "Stay Logged In"
|
|
1369
|
+
}
|
|
1370
|
+
),
|
|
1371
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1372
|
+
"button",
|
|
1373
|
+
{
|
|
1374
|
+
onClick: () => setShowWarning(false),
|
|
1375
|
+
className: "px-3 py-1 text-sm border border-yellow-300 rounded hover:bg-yellow-100 dark:hover:bg-yellow-900 transition-colors",
|
|
1376
|
+
children: "Dismiss"
|
|
1377
|
+
}
|
|
1378
|
+
)
|
|
1379
|
+
] })
|
|
1380
|
+
] }) }) }) });
|
|
1381
|
+
}
|
|
1382
|
+
function setupAuthInterceptors(axiosInstance, baseURL) {
|
|
1383
|
+
axiosInstance.interceptors.request.use(
|
|
1384
|
+
async (config) => {
|
|
1385
|
+
const token = TokenManager.getInstance().getAccessToken();
|
|
1386
|
+
if (token) {
|
|
1387
|
+
const isBlacklisted = await TokenBlacklist.getInstance().isBlacklisted(token);
|
|
1388
|
+
if (isBlacklisted) {
|
|
1389
|
+
SecureTokenStorage.getInstance().clear();
|
|
1390
|
+
window.location.href = "/login";
|
|
1391
|
+
return Promise.reject(new Error("Token revoked"));
|
|
1392
|
+
}
|
|
1393
|
+
if (!TokenManager.getInstance().isTokenExpired()) {
|
|
1394
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return config;
|
|
1398
|
+
},
|
|
1399
|
+
(error) => Promise.reject(error)
|
|
1400
|
+
);
|
|
1401
|
+
let isRefreshing = false;
|
|
1402
|
+
let failedQueue = [];
|
|
1403
|
+
const processQueue = (error, token = null) => {
|
|
1404
|
+
failedQueue.forEach((prom) => {
|
|
1405
|
+
if (error) {
|
|
1406
|
+
prom.reject(error);
|
|
1407
|
+
} else {
|
|
1408
|
+
if (prom.config.headers) {
|
|
1409
|
+
prom.config.headers.Authorization = `Bearer ${token}`;
|
|
1410
|
+
}
|
|
1411
|
+
axiosInstance(prom.config).then(prom.resolve).catch(prom.reject);
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
failedQueue = [];
|
|
1415
|
+
};
|
|
1416
|
+
axiosInstance.interceptors.response.use(
|
|
1417
|
+
(response) => response,
|
|
1418
|
+
async (error) => {
|
|
1419
|
+
const originalRequest = error.config;
|
|
1420
|
+
if (error.response?.status === 401 && !originalRequest._retry) {
|
|
1421
|
+
if (isRefreshing) {
|
|
1422
|
+
return new Promise((resolve, reject) => {
|
|
1423
|
+
failedQueue.push({ resolve, reject, config: originalRequest });
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
originalRequest._retry = true;
|
|
1427
|
+
isRefreshing = true;
|
|
1428
|
+
try {
|
|
1429
|
+
const refreshToken = SecureTokenStorage.getInstance().getTokens()?.refreshToken;
|
|
1430
|
+
if (!refreshToken) throw new Error("No refresh token");
|
|
1431
|
+
const response = await axios__default.default.post(`${baseURL}${API_ENDPOINTS.refresh}`, { refreshToken });
|
|
1432
|
+
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
|
1433
|
+
SecureTokenStorage.getInstance().setTokens({
|
|
1434
|
+
accessToken,
|
|
1435
|
+
refreshToken: newRefreshToken,
|
|
1436
|
+
expiresIn: 900
|
|
1437
|
+
});
|
|
1438
|
+
processQueue(null, accessToken);
|
|
1439
|
+
if (originalRequest.headers) {
|
|
1440
|
+
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
|
1441
|
+
}
|
|
1442
|
+
return axiosInstance(originalRequest);
|
|
1443
|
+
} catch (refreshError) {
|
|
1444
|
+
processQueue(refreshError);
|
|
1445
|
+
SecureTokenStorage.getInstance().clear();
|
|
1446
|
+
window.location.href = "/login";
|
|
1447
|
+
return Promise.reject(refreshError);
|
|
1448
|
+
} finally {
|
|
1449
|
+
isRefreshing = false;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
return Promise.reject(error);
|
|
1453
|
+
}
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// src/utils/helpers.ts
|
|
1458
|
+
function cn(...classes) {
|
|
1459
|
+
return classes.filter(Boolean).join(" ");
|
|
1460
|
+
}
|
|
1461
|
+
function getErrorMessage(error) {
|
|
1462
|
+
if (error instanceof Error) return error.message;
|
|
1463
|
+
if (typeof error === "string") return error;
|
|
1464
|
+
return "An unexpected error occurred";
|
|
1465
|
+
}
|
|
1466
|
+
function isValidEmail(email) {
|
|
1467
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1468
|
+
return emailRegex.test(email);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
exports.API_ENDPOINTS = API_ENDPOINTS;
|
|
1472
|
+
exports.AUTH_CONFIG = AUTH_CONFIG;
|
|
1473
|
+
exports.AuditAction = AuditAction;
|
|
1474
|
+
exports.AuditLogger = AuditLogger;
|
|
1475
|
+
exports.AuthProvider = AuthProvider;
|
|
1476
|
+
exports.AuthService = AuthService;
|
|
1477
|
+
exports.DeviceFingerprint = DeviceFingerprint;
|
|
1478
|
+
exports.MFAService = MFAService;
|
|
1479
|
+
exports.MFASetup = MFASetup;
|
|
1480
|
+
exports.MFAVerification = MFAVerification;
|
|
1481
|
+
exports.ProtectedRoute = ProtectedRoute;
|
|
1482
|
+
exports.RateLimiter = RateLimiter;
|
|
1483
|
+
exports.STORAGE_KEYS = STORAGE_KEYS;
|
|
1484
|
+
exports.SessionManager = SessionManager;
|
|
1485
|
+
exports.SessionWarning = SessionWarning;
|
|
1486
|
+
exports.TokenBlacklist = TokenBlacklist;
|
|
1487
|
+
exports.TokenManager = TokenManager;
|
|
1488
|
+
exports.TokenStorage = SecureTokenStorage;
|
|
1489
|
+
exports.cn = cn;
|
|
1490
|
+
exports.getErrorMessage = getErrorMessage;
|
|
1491
|
+
exports.isValidEmail = isValidEmail;
|
|
1492
|
+
exports.setupAuthInterceptors = setupAuthInterceptors;
|
|
1493
|
+
exports.useAuth = useAuth;
|
|
1494
|
+
exports.useProtectedRoute = useProtectedRoute;
|
|
1495
|
+
//# sourceMappingURL=index.js.map
|
|
1496
|
+
//# sourceMappingURL=index.js.map
|