@fonderie/auth 5.0.0 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/brain/outcomes.md +1 -0
- package/brain/signatures.md +20 -0
- package/dist/dtos/user.cjs +26 -3
- package/dist/dtos/user.cjs.map +1 -1
- package/dist/dtos/user.js +26 -3
- package/dist/dtos/user.js.map +1 -1
- package/dist/index.cjs +232 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -7
- package/dist/index.d.ts +24 -7
- package/dist/index.js +221 -19
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +19 -1
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js +19 -1
- package/dist/middlewares/index.js.map +1 -1
- package/dist/{session-DxsmQjXX.d.cts → session-CTSCI09F.d.cts} +8 -2
- package/dist/{session-DxsmQjXX.d.ts → session-CTSCI09F.d.ts} +8 -2
- package/package.json +8 -9
package/dist/index.cjs
CHANGED
|
@@ -76,8 +76,10 @@ __export(index_exports, {
|
|
|
76
76
|
importUser: () => importUser,
|
|
77
77
|
normalizeEmail: () => normalizeEmail,
|
|
78
78
|
normalizeEmailSafe: () => normalizeEmailSafe,
|
|
79
|
+
purgeSoftDeletedUsers: () => purgeSoftDeletedUsers,
|
|
79
80
|
requireAuth: () => import_middlewares3.requireAuth,
|
|
80
81
|
schemas: () => schemas_exports,
|
|
82
|
+
startUserRetention: () => startUserRetention,
|
|
81
83
|
toUserDTO: () => toUserDTO,
|
|
82
84
|
validate: () => import_middlewares.validate,
|
|
83
85
|
validateAuthConfig: () => validateAuthConfig,
|
|
@@ -202,10 +204,18 @@ var updateProfileSchema = import_zod.z.object({
|
|
|
202
204
|
var updatePreferencesSchema = import_zod.z.object({
|
|
203
205
|
locale: import_zod.z.string().max(35).optional(),
|
|
204
206
|
timezone: import_zod.z.string().max(64).optional(),
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
// Typed at last — these four accepted `unknown`, letting null and
|
|
208
|
+
// arbitrary JSON into stored preferences that the user DTO then served
|
|
209
|
+
// against string-typed client fields.
|
|
210
|
+
notifications: import_zod.z.object({
|
|
211
|
+
email: import_zod.z.boolean().optional(),
|
|
212
|
+
inApp: import_zod.z.boolean().optional(),
|
|
213
|
+
sms: import_zod.z.boolean().optional(),
|
|
214
|
+
push: import_zod.z.boolean().optional()
|
|
215
|
+
}).optional(),
|
|
216
|
+
emailDigest: import_zod.z.string().max(20).optional(),
|
|
217
|
+
dateFormat: import_zod.z.string().max(30).optional(),
|
|
218
|
+
timeFormat: import_zod.z.string().max(30).optional()
|
|
209
219
|
}).refine(
|
|
210
220
|
(o) => Object.values(o).some((v) => v !== void 0),
|
|
211
221
|
"Provide at least one preference field"
|
|
@@ -392,10 +402,16 @@ function generateTotpUri(email2, secret, issuer) {
|
|
|
392
402
|
});
|
|
393
403
|
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(email2)}?${params}`;
|
|
394
404
|
}
|
|
405
|
+
function safeCodeEqual(a, b) {
|
|
406
|
+
const bufA = Buffer.from(a);
|
|
407
|
+
const bufB = Buffer.from(b);
|
|
408
|
+
if (bufA.length !== bufB.length) return false;
|
|
409
|
+
return (0, import_node_crypto2.timingSafeEqual)(bufA, bufB);
|
|
410
|
+
}
|
|
395
411
|
function verifyTotpToken(token, secret) {
|
|
396
412
|
const t = timeCounter();
|
|
397
413
|
for (let i = -DRIFT; i <= DRIFT; i++) {
|
|
398
|
-
if (hotp(secret, t + i)
|
|
414
|
+
if (safeCodeEqual(hotp(secret, t + i), token)) {
|
|
399
415
|
return true;
|
|
400
416
|
}
|
|
401
417
|
}
|
|
@@ -408,6 +424,41 @@ function generateBackupCodes(count = 8) {
|
|
|
408
424
|
// src/controllers/mfa.controller.ts
|
|
409
425
|
init_password();
|
|
410
426
|
|
|
427
|
+
// src/services/mfa-crypto.ts
|
|
428
|
+
var import_node_crypto3 = require("crypto");
|
|
429
|
+
var PREFIX = "mfa.v1:";
|
|
430
|
+
function makeMfaCipher(keyHex) {
|
|
431
|
+
if (!keyHex) {
|
|
432
|
+
return { encrypt: (plain) => plain, decrypt: (stored) => stored };
|
|
433
|
+
}
|
|
434
|
+
const key = Buffer.from(keyHex, "hex");
|
|
435
|
+
if (key.length !== 32) {
|
|
436
|
+
throw new Error("[auth] mfaSecretKey must be 32 bytes (64 hex chars, e.g. `openssl rand -hex 32`)");
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
encrypt(plain) {
|
|
440
|
+
const iv = (0, import_node_crypto3.randomBytes)(12);
|
|
441
|
+
const cipher = (0, import_node_crypto3.createCipheriv)("aes-256-gcm", key, iv);
|
|
442
|
+
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
443
|
+
const tag = cipher.getAuthTag();
|
|
444
|
+
return `${PREFIX}${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
|
|
445
|
+
},
|
|
446
|
+
decrypt(stored) {
|
|
447
|
+
if (!stored.startsWith(PREFIX)) return stored;
|
|
448
|
+
const [ivHex, tagHex, dataHex] = stored.slice(PREFIX.length).split(":");
|
|
449
|
+
if (!ivHex || !tagHex || !dataHex) {
|
|
450
|
+
throw new Error("[auth] malformed mfa secret ciphertext");
|
|
451
|
+
}
|
|
452
|
+
const decipher = (0, import_node_crypto3.createDecipheriv)("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
|
|
453
|
+
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
|
|
454
|
+
return Buffer.concat([
|
|
455
|
+
decipher.update(Buffer.from(dataHex, "hex")),
|
|
456
|
+
decipher.final()
|
|
457
|
+
]).toString("utf8");
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
411
462
|
// src/dtos/user.ts
|
|
412
463
|
var import_core2 = require("@fonderie/core");
|
|
413
464
|
var DEFAULT_PREFERENCES = {
|
|
@@ -418,8 +469,27 @@ var DEFAULT_PREFERENCES = {
|
|
|
418
469
|
dateFormat: "MM/DD/YYYY",
|
|
419
470
|
timeFormat: "hh:mm A"
|
|
420
471
|
};
|
|
472
|
+
function cleanPreferences(prefs) {
|
|
473
|
+
const out = {};
|
|
474
|
+
if (typeof prefs.locale === "string") out.locale = prefs.locale;
|
|
475
|
+
if (typeof prefs.timezone === "string") out.timezone = prefs.timezone;
|
|
476
|
+
if (typeof prefs.emailDigest === "string") out.emailDigest = prefs.emailDigest;
|
|
477
|
+
if (typeof prefs.dateFormat === "string") out.dateFormat = prefs.dateFormat;
|
|
478
|
+
if (typeof prefs.timeFormat === "string") out.timeFormat = prefs.timeFormat;
|
|
479
|
+
return out;
|
|
480
|
+
}
|
|
481
|
+
function cleanNotifications(value) {
|
|
482
|
+
if (!value || typeof value !== "object") return {};
|
|
483
|
+
const out = {};
|
|
484
|
+
for (const key of ["email", "inApp", "sms", "push"]) {
|
|
485
|
+
const v = value[key];
|
|
486
|
+
if (typeof v === "boolean") out[key] = v;
|
|
487
|
+
}
|
|
488
|
+
return out;
|
|
489
|
+
}
|
|
421
490
|
function toUserDTO(user, phoneVerified = false) {
|
|
422
491
|
const prefs = user.preferences ?? {};
|
|
492
|
+
const cleaned = cleanPreferences(prefs);
|
|
423
493
|
return {
|
|
424
494
|
id: (0, import_core2.stringOrEmpty)(user.id),
|
|
425
495
|
email: (0, import_core2.stringOrEmpty)(user.email),
|
|
@@ -431,9 +501,13 @@ function toUserDTO(user, phoneVerified = false) {
|
|
|
431
501
|
lastLogin: user.lastLogin instanceof Date ? user.lastLogin.toISOString() : "",
|
|
432
502
|
preferences: {
|
|
433
503
|
...DEFAULT_PREFERENCES,
|
|
434
|
-
...
|
|
435
|
-
|
|
436
|
-
|
|
504
|
+
...cleaned,
|
|
505
|
+
notifications: {
|
|
506
|
+
...DEFAULT_PREFERENCES.notifications,
|
|
507
|
+
...cleanNotifications(prefs.notifications)
|
|
508
|
+
},
|
|
509
|
+
locale: user.locale || cleaned.locale || "en-US",
|
|
510
|
+
timezone: user.timezone || cleaned.timezone || "UTC"
|
|
437
511
|
},
|
|
438
512
|
isEmailVerified: user.emailVerifiedAt !== null,
|
|
439
513
|
isPhoneVerified: phoneVerified,
|
|
@@ -464,7 +538,6 @@ var USER_COLUMNS = `
|
|
|
464
538
|
whitelist,
|
|
465
539
|
ip_whitelist AS "ipWhitelist",
|
|
466
540
|
mfa_enabled AS "mfaEnabled",
|
|
467
|
-
mfa_secret AS "mfaSecret",
|
|
468
541
|
email_verified_at AS "emailVerifiedAt",
|
|
469
542
|
deleted_at AS "deletedAt",
|
|
470
543
|
created_at AS "createdAt",
|
|
@@ -673,6 +746,25 @@ var SessionModel = class {
|
|
|
673
746
|
async delete(token) {
|
|
674
747
|
await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
|
|
675
748
|
}
|
|
749
|
+
// Revoke every session for a user (e.g. on password change). Access tokens
|
|
750
|
+
// bound to these sessions via the sid claim die on their next request.
|
|
751
|
+
async deleteByUser(userId) {
|
|
752
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [userId]);
|
|
753
|
+
}
|
|
754
|
+
// Session metadata for a user (no tokens) — for the data-export / SAR bundle.
|
|
755
|
+
async listByUser(userId) {
|
|
756
|
+
return this.store.query(
|
|
757
|
+
`SELECT id,
|
|
758
|
+
user_agent AS "userAgent",
|
|
759
|
+
ip_address AS "ipAddress",
|
|
760
|
+
created_at AS "createdAt",
|
|
761
|
+
expires_at AS "expiresAt"
|
|
762
|
+
FROM fonderie_sessions
|
|
763
|
+
WHERE user_id = $1
|
|
764
|
+
ORDER BY created_at DESC`,
|
|
765
|
+
[userId]
|
|
766
|
+
);
|
|
767
|
+
}
|
|
676
768
|
async exists(token) {
|
|
677
769
|
const rows = await this.store.query(
|
|
678
770
|
`SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
|
|
@@ -730,6 +822,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
730
822
|
const users = new UserModel(store);
|
|
731
823
|
const sessions = new SessionModel(store);
|
|
732
824
|
const backupCodes = new BackupCodeModel(store);
|
|
825
|
+
const mfaCipher = makeMfaCipher(config.mfaSecretKey);
|
|
733
826
|
return {
|
|
734
827
|
// ── 1. Setup ───────────────────────────────────────────────
|
|
735
828
|
setup: async (ctx) => {
|
|
@@ -739,7 +832,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
739
832
|
const codeHashes = await Promise.all(plainCodes.map((c) => hashPassword(c)));
|
|
740
833
|
const qr = await import_qrcode.default.toDataURL(uri);
|
|
741
834
|
await Promise.all([
|
|
742
|
-
users.saveMfaPendingSecret(ctx.user.id, secret),
|
|
835
|
+
users.saveMfaPendingSecret(ctx.user.id, mfaCipher.encrypt(secret)),
|
|
743
836
|
backupCodes.replace(ctx.user.id, codeHashes)
|
|
744
837
|
]);
|
|
745
838
|
return (0, import_core3.setApiResponse)(
|
|
@@ -761,7 +854,8 @@ function mfaController(store, config, issuer, bus) {
|
|
|
761
854
|
if (typeof token !== "string") {
|
|
762
855
|
return (0, import_core3.setApiResponse)(import_core3.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
|
|
763
856
|
}
|
|
764
|
-
const
|
|
857
|
+
const pendingCipher = await users.getMfaPendingSecret(ctx.user.id);
|
|
858
|
+
const pendingSecret = pendingCipher ? mfaCipher.decrypt(pendingCipher) : null;
|
|
765
859
|
if (pendingSecret) {
|
|
766
860
|
if (!verifyTotpToken(token, pendingSecret)) {
|
|
767
861
|
return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
|
|
@@ -808,10 +902,11 @@ function mfaController(store, config, issuer, bus) {
|
|
|
808
902
|
"Use the mfaToken from the login response"
|
|
809
903
|
);
|
|
810
904
|
}
|
|
811
|
-
const
|
|
812
|
-
if (!
|
|
905
|
+
const storedSecret = await users.getMfaSecret(ctx.user.id);
|
|
906
|
+
if (!storedSecret) {
|
|
813
907
|
return (0, import_core3.setApiResponse)(import_core3.HTTP.BAD_REQUEST, "MFA_NOT_CONFIGURED", "MFA not configured");
|
|
814
908
|
}
|
|
909
|
+
const secret = mfaCipher.decrypt(storedSecret);
|
|
815
910
|
if (!verifyTotpToken(token, secret)) {
|
|
816
911
|
return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
|
|
817
912
|
}
|
|
@@ -820,7 +915,8 @@ function mfaController(store, config, issuer, bus) {
|
|
|
820
915
|
}
|
|
821
916
|
}
|
|
822
917
|
const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
|
|
823
|
-
loginMethod: ctx.user.loginMethod
|
|
918
|
+
loginMethod: ctx.user.loginMethod,
|
|
919
|
+
phoneVerified: ctx.user.phoneVerified
|
|
824
920
|
});
|
|
825
921
|
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
826
922
|
const fullUser = await users.findById(ctx.user.id);
|
|
@@ -833,7 +929,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
833
929
|
explanation: "MFA verified successfully.",
|
|
834
930
|
result: {
|
|
835
931
|
tokens: { access: accessToken, refresh: refreshToken },
|
|
836
|
-
user: toUserDTO(fullUser)
|
|
932
|
+
user: toUserDTO(fullUser, ctx.user.phoneVerified)
|
|
837
933
|
}
|
|
838
934
|
},
|
|
839
935
|
{
|
|
@@ -852,7 +948,8 @@ function mfaController(store, config, issuer, bus) {
|
|
|
852
948
|
if (!ctx.user.mfaEnabled) {
|
|
853
949
|
return (0, import_core3.setApiResponse)(import_core3.HTTP.BAD_REQUEST, "MFA_NOT_ENABLED", "MFA is not enabled");
|
|
854
950
|
}
|
|
855
|
-
const
|
|
951
|
+
const storedSecret = await users.getMfaSecret(ctx.user.id);
|
|
952
|
+
const secret = storedSecret ? mfaCipher.decrypt(storedSecret) : null;
|
|
856
953
|
if (!secret || !verifyTotpToken(token, secret)) {
|
|
857
954
|
return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid TOTP code");
|
|
858
955
|
}
|
|
@@ -881,7 +978,8 @@ function mfaController(store, config, issuer, bus) {
|
|
|
881
978
|
if (!user || !user.mfaEnabled) {
|
|
882
979
|
return (0, import_core3.setApiResponse)(import_core3.HTTP.BAD_REQUEST, "MFA_NOT_ENABLED", "MFA is not enabled");
|
|
883
980
|
}
|
|
884
|
-
const
|
|
981
|
+
const storedSecret = await users.getMfaSecret(ctx.user.id);
|
|
982
|
+
const secret = storedSecret ? mfaCipher.decrypt(storedSecret) : null;
|
|
885
983
|
if (!secret || !verifyTotpToken(token, secret)) {
|
|
886
984
|
return (0, import_core3.setApiResponse)(import_core3.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid TOTP code");
|
|
887
985
|
}
|
|
@@ -899,7 +997,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
899
997
|
}
|
|
900
998
|
|
|
901
999
|
// src/controllers/auth.controller.ts
|
|
902
|
-
var
|
|
1000
|
+
var import_node_crypto4 = require("crypto");
|
|
903
1001
|
var import_events2 = require("@fonderie/events");
|
|
904
1002
|
var import_core4 = require("@fonderie/core");
|
|
905
1003
|
|
|
@@ -1123,7 +1221,7 @@ function authController(store, config, bus) {
|
|
|
1123
1221
|
if (!row) {
|
|
1124
1222
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
|
|
1125
1223
|
}
|
|
1126
|
-
const pin = (0,
|
|
1224
|
+
const pin = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
|
|
1127
1225
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
1128
1226
|
await emailVerif.create(row.id, pin, expiresAt);
|
|
1129
1227
|
const user = await users.findById(row.id);
|
|
@@ -1187,7 +1285,7 @@ function authController(store, config, bus) {
|
|
|
1187
1285
|
firstName ?? null,
|
|
1188
1286
|
lastName ?? null
|
|
1189
1287
|
);
|
|
1190
|
-
const otp = (0,
|
|
1288
|
+
const otp = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
|
|
1191
1289
|
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
|
|
1192
1290
|
await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
|
|
1193
1291
|
const user = await users.findById(id);
|
|
@@ -1315,7 +1413,7 @@ function authController(store, config, bus) {
|
|
|
1315
1413
|
"Account suspended. Please contact support."
|
|
1316
1414
|
);
|
|
1317
1415
|
}
|
|
1318
|
-
const otp = (0,
|
|
1416
|
+
const otp = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
|
|
1319
1417
|
const expiresAt = new Date(Date.now() + OTP_TTL_MS);
|
|
1320
1418
|
await phoneVerif.upsert(user.id, normalizePhone(phone2), otp, expiresAt);
|
|
1321
1419
|
bus?.emit(import_events2.NOTIFICATION_EVENT, {
|
|
@@ -1430,7 +1528,7 @@ function authController(store, config, bus) {
|
|
|
1430
1528
|
"Password reset email sent (if account exists)."
|
|
1431
1529
|
);
|
|
1432
1530
|
}
|
|
1433
|
-
const pin = (0,
|
|
1531
|
+
const pin = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
|
|
1434
1532
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60);
|
|
1435
1533
|
await passwordReset.create(user.id, pin, expiresAt);
|
|
1436
1534
|
bus?.emit(import_events2.NOTIFICATION_EVENT, {
|
|
@@ -1597,7 +1695,7 @@ function authController(store, config, bus) {
|
|
|
1597
1695
|
}
|
|
1598
1696
|
);
|
|
1599
1697
|
}
|
|
1600
|
-
const otp = (0,
|
|
1698
|
+
const otp = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
|
|
1601
1699
|
const expiresAt2 = new Date(Date.now() + OTP_TTL_MS);
|
|
1602
1700
|
await phoneVerif.upsert(ctx.user.id, phone2, otp, expiresAt2);
|
|
1603
1701
|
bus?.emit(import_events2.NOTIFICATION_EVENT, {
|
|
@@ -1637,7 +1735,7 @@ function authController(store, config, bus) {
|
|
|
1637
1735
|
}
|
|
1638
1736
|
);
|
|
1639
1737
|
}
|
|
1640
|
-
const pin = (0,
|
|
1738
|
+
const pin = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
|
|
1641
1739
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
1642
1740
|
await emailVerif.replace(ctx.user.id, pin, expiresAt);
|
|
1643
1741
|
bus?.emit(import_events2.NOTIFICATION_EVENT, {
|
|
@@ -1655,7 +1753,7 @@ function authController(store, config, bus) {
|
|
|
1655
1753
|
}
|
|
1656
1754
|
|
|
1657
1755
|
// src/controllers/user.controller.ts
|
|
1658
|
-
var
|
|
1756
|
+
var import_node_crypto5 = require("crypto");
|
|
1659
1757
|
var import_core5 = require("@fonderie/core");
|
|
1660
1758
|
var import_events3 = require("@fonderie/events");
|
|
1661
1759
|
function normalizePhone2(phone2) {
|
|
@@ -1666,6 +1764,7 @@ function isValidPhone2(phone2) {
|
|
|
1666
1764
|
}
|
|
1667
1765
|
function userController(store, config, bus) {
|
|
1668
1766
|
const users = new UserModel(store);
|
|
1767
|
+
const sessions = new SessionModel(store);
|
|
1669
1768
|
const emailVerif = new EmailVerificationModel(store);
|
|
1670
1769
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
1671
1770
|
return {
|
|
@@ -1748,7 +1847,7 @@ function userController(store, config, bus) {
|
|
|
1748
1847
|
if (existing) {
|
|
1749
1848
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
|
|
1750
1849
|
}
|
|
1751
|
-
const pin = (0,
|
|
1850
|
+
const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1752
1851
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
1753
1852
|
await emailVerif.replace(ctx.user.id, pin, expiresAt);
|
|
1754
1853
|
await users.updateEmail(ctx.user.id, normalised);
|
|
@@ -1792,7 +1891,7 @@ function userController(store, config, bus) {
|
|
|
1792
1891
|
if (existing) {
|
|
1793
1892
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
|
|
1794
1893
|
}
|
|
1795
|
-
const otp = (0,
|
|
1894
|
+
const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1796
1895
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
|
|
1797
1896
|
await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
|
|
1798
1897
|
await users.updatePhone(ctx.user.id, normalised);
|
|
@@ -1840,6 +1939,7 @@ function userController(store, config, bus) {
|
|
|
1840
1939
|
}
|
|
1841
1940
|
const hash = await hashPassword2(newPassword);
|
|
1842
1941
|
await users.updatePassword(ctx.user.id, hash);
|
|
1942
|
+
await sessions.deleteByUser(ctx.user.id);
|
|
1843
1943
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
|
|
1844
1944
|
},
|
|
1845
1945
|
deleteMe: async (ctx) => {
|
|
@@ -1859,6 +1959,42 @@ function userController(store, config, bus) {
|
|
|
1859
1959
|
headers: cookieHeaders(clearedTokenCookies(config))
|
|
1860
1960
|
}
|
|
1861
1961
|
);
|
|
1962
|
+
},
|
|
1963
|
+
// Subject Access Request — the authenticated user's own data as a portable
|
|
1964
|
+
// JSON bundle. Only auth-owned data, and only safe fields: no password hash,
|
|
1965
|
+
// no MFA secret, no session tokens (session metadata only).
|
|
1966
|
+
exportMe: async (ctx) => {
|
|
1967
|
+
const user = await users.findById(ctx.user.id);
|
|
1968
|
+
if (!user) return (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1969
|
+
const sessionRows = await sessions.listByUser(ctx.user.id);
|
|
1970
|
+
const bundle = {
|
|
1971
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1972
|
+
profile: toUserDTO(user, ctx.user.phoneVerified),
|
|
1973
|
+
sessions: sessionRows.map((s) => ({
|
|
1974
|
+
id: s.id,
|
|
1975
|
+
userAgent: s.userAgent,
|
|
1976
|
+
ipAddress: s.ipAddress,
|
|
1977
|
+
createdAt: s.createdAt instanceof Date ? s.createdAt.toISOString() : s.createdAt,
|
|
1978
|
+
expiresAt: s.expiresAt instanceof Date ? s.expiresAt.toISOString() : s.expiresAt
|
|
1979
|
+
})),
|
|
1980
|
+
security: {
|
|
1981
|
+
mfaEnabled: user.mfaEnabled === true,
|
|
1982
|
+
emailVerified: user.emailVerifiedAt !== null
|
|
1983
|
+
}
|
|
1984
|
+
};
|
|
1985
|
+
const contributors = config.dataExportContributors ?? [];
|
|
1986
|
+
if (contributors.length > 0) {
|
|
1987
|
+
const modules = {};
|
|
1988
|
+
for (const c of contributors) {
|
|
1989
|
+
try {
|
|
1990
|
+
modules[c.name] = await c.collect(ctx.user.id);
|
|
1991
|
+
} catch {
|
|
1992
|
+
modules[c.name] = null;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
bundle["modules"] = modules;
|
|
1996
|
+
}
|
|
1997
|
+
return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
|
|
1862
1998
|
}
|
|
1863
1999
|
};
|
|
1864
2000
|
}
|
|
@@ -2003,6 +2139,7 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2003
2139
|
R("updatePhone", "PUT", "/users/phone", import_middlewares2.requireAuth, verifyGate, (0, import_middlewares.validate)(updatePhoneSchema), user.updatePhone),
|
|
2004
2140
|
R("changePassword", "PUT", "/users/password", import_middlewares2.requireAuth, (0, import_middlewares.validate)(changePasswordSchema), user.changePassword),
|
|
2005
2141
|
R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
|
|
2142
|
+
R("exportMe", "GET", "/users/export", import_middlewares2.requireAuth, user.exportMe),
|
|
2006
2143
|
// MFA (email sessions only — requireVerified is always enforced here
|
|
2007
2144
|
// because MFA is a security feature and email verification is meaningful)
|
|
2008
2145
|
R("mfaSetup", "POST", "/auth/mfa/setup", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, mfa.setup),
|
|
@@ -2044,10 +2181,40 @@ function collectAuthConfigProblems(config) {
|
|
|
2044
2181
|
if (config.secureCookies === false) {
|
|
2045
2182
|
problems.push({
|
|
2046
2183
|
module: MODULE,
|
|
2047
|
-
severity: "warning",
|
|
2184
|
+
severity: process.env["NODE_ENV"] === "production" ? "error" : "warning",
|
|
2048
2185
|
message: "secureCookies is false \u2014 auth cookies may be sent over non-HTTPS connections in production"
|
|
2049
2186
|
});
|
|
2050
2187
|
}
|
|
2188
|
+
if (config.google) {
|
|
2189
|
+
const clientSecret = config.google.clientSecret ?? "";
|
|
2190
|
+
if (!clientSecret || !config.google.clientId || !config.google.redirectUri) {
|
|
2191
|
+
problems.push({
|
|
2192
|
+
module: MODULE,
|
|
2193
|
+
severity: "error",
|
|
2194
|
+
message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing"
|
|
2195
|
+
});
|
|
2196
|
+
} else if (PLACEHOLDER_SECRET.test(clientSecret)) {
|
|
2197
|
+
problems.push({
|
|
2198
|
+
module: MODULE,
|
|
2199
|
+
severity: "error",
|
|
2200
|
+
message: "google.clientSecret looks like a placeholder or dev-default value"
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
if (config.mfa && !config.mfaSecretKey) {
|
|
2205
|
+
problems.push({
|
|
2206
|
+
module: MODULE,
|
|
2207
|
+
severity: process.env["NODE_ENV"] === "production" ? "error" : "warning",
|
|
2208
|
+
message: "mfa is enabled without mfaSecretKey \u2014 TOTP secrets are stored plaintext at rest; set a 32-byte key (openssl rand -hex 32)"
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
if (config.mfaSecretKey && !/^[0-9a-fA-F]{64}$/.test(config.mfaSecretKey)) {
|
|
2212
|
+
problems.push({
|
|
2213
|
+
module: MODULE,
|
|
2214
|
+
severity: "error",
|
|
2215
|
+
message: "mfaSecretKey must be 64 hex characters (32 bytes, e.g. `openssl rand -hex 32`)"
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2051
2218
|
return problems;
|
|
2052
2219
|
}
|
|
2053
2220
|
function validateAuthConfig(config) {
|
|
@@ -2168,6 +2335,41 @@ async function importUser(store, user) {
|
|
|
2168
2335
|
if (!row) throw new Error("[auth] importUser: insert returned no row");
|
|
2169
2336
|
return row;
|
|
2170
2337
|
}
|
|
2338
|
+
|
|
2339
|
+
// src/services/retention.ts
|
|
2340
|
+
async function purgeSoftDeletedUsers(store, { olderThanDays }) {
|
|
2341
|
+
if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
|
|
2342
|
+
throw new Error("[auth] purgeSoftDeletedUsers: olderThanDays must be a non-negative number");
|
|
2343
|
+
}
|
|
2344
|
+
const rows = await store.query(
|
|
2345
|
+
`DELETE FROM fonderie_users
|
|
2346
|
+
WHERE deleted_at IS NOT NULL
|
|
2347
|
+
AND deleted_at < now() - make_interval(days => $1)
|
|
2348
|
+
RETURNING id`,
|
|
2349
|
+
[olderThanDays]
|
|
2350
|
+
);
|
|
2351
|
+
return rows.length;
|
|
2352
|
+
}
|
|
2353
|
+
function startUserRetention(store, options) {
|
|
2354
|
+
const intervalMs = options.intervalMs ?? 24 * 60 * 60 * 1e3;
|
|
2355
|
+
let stopped = false;
|
|
2356
|
+
const run = async () => {
|
|
2357
|
+
if (stopped) return;
|
|
2358
|
+
try {
|
|
2359
|
+
const deleted = await purgeSoftDeletedUsers(store, { olderThanDays: options.olderThanDays });
|
|
2360
|
+
options.onPurge?.(deleted);
|
|
2361
|
+
} catch (err) {
|
|
2362
|
+
console.error("[auth] scheduled user retention purge failed:", err);
|
|
2363
|
+
}
|
|
2364
|
+
};
|
|
2365
|
+
const timer = setInterval(run, intervalMs);
|
|
2366
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
2367
|
+
void run();
|
|
2368
|
+
return { stop: () => {
|
|
2369
|
+
stopped = true;
|
|
2370
|
+
clearInterval(timer);
|
|
2371
|
+
} };
|
|
2372
|
+
}
|
|
2171
2373
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2172
2374
|
0 && (module.exports = {
|
|
2173
2375
|
AUTH_CONFIG_KEYS,
|
|
@@ -2178,8 +2380,10 @@ async function importUser(store, user) {
|
|
|
2178
2380
|
importUser,
|
|
2179
2381
|
normalizeEmail,
|
|
2180
2382
|
normalizeEmailSafe,
|
|
2383
|
+
purgeSoftDeletedUsers,
|
|
2181
2384
|
requireAuth,
|
|
2182
2385
|
schemas,
|
|
2386
|
+
startUserRetention,
|
|
2183
2387
|
toUserDTO,
|
|
2184
2388
|
validate,
|
|
2185
2389
|
validateAuthConfig,
|