@fonderie/auth 5.0.4 → 5.2.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/brain/outcomes.md +18 -0
- package/brain/signatures.md +26 -0
- package/dist/index.cjs +476 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -3
- package/dist/index.d.ts +71 -3
- package/dist/index.js +426 -13
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +50 -5
- 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 +50 -5
- package/dist/middlewares/index.js.map +1 -1
- package/dist/migrations/sql/014_login_events.sql +19 -0
- package/dist/{session-CTSCI09F.d.cts → session-DJNd1xp_.d.cts} +1 -1
- package/dist/{session-CTSCI09F.d.ts → session-DJNd1xp_.d.ts} +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -70,6 +70,7 @@ var index_exports = {};
|
|
|
70
70
|
__export(index_exports, {
|
|
71
71
|
AUTH_CONFIG_KEYS: () => AUTH_CONFIG_KEYS,
|
|
72
72
|
AuthModule: () => AuthModule,
|
|
73
|
+
DEFAULT_TEMPLATES: () => DEFAULT_TEMPLATES,
|
|
73
74
|
MESSAGE_KEYS: () => MESSAGE_KEYS,
|
|
74
75
|
buildAuthAccountLimiter: () => buildAuthAccountLimiter,
|
|
75
76
|
buildAuthIpLimiter: () => buildAuthIpLimiter,
|
|
@@ -730,17 +731,23 @@ var SessionModel = class {
|
|
|
730
731
|
this.store = store;
|
|
731
732
|
}
|
|
732
733
|
store;
|
|
733
|
-
async create(userId, token, expiresAt, sid) {
|
|
734
|
+
async create(userId, token, expiresAt, sid, meta) {
|
|
734
735
|
await this.store.query(
|
|
735
|
-
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid)
|
|
736
|
-
VALUES ($1, $2, $3, $4)
|
|
736
|
+
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)
|
|
737
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
737
738
|
ON CONFLICT (token) DO NOTHING`,
|
|
738
|
-
[userId, token, expiresAt, sid ?? null]
|
|
739
|
+
[userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
|
|
739
740
|
);
|
|
740
741
|
}
|
|
741
742
|
async delete(token) {
|
|
742
743
|
await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
|
|
743
744
|
}
|
|
745
|
+
// Delete the session an access token is bound to (by its sid claim). Lets
|
|
746
|
+
// logout revoke the current session without the client resending the refresh
|
|
747
|
+
// token — the access token already identifies its session.
|
|
748
|
+
async deleteBySid(sid) {
|
|
749
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);
|
|
750
|
+
}
|
|
744
751
|
// Revoke every session for a user (e.g. on password change). Access tokens
|
|
745
752
|
// bound to these sessions via the sid claim die on their next request.
|
|
746
753
|
async deleteByUser(userId) {
|
|
@@ -760,6 +767,42 @@ var SessionModel = class {
|
|
|
760
767
|
[userId]
|
|
761
768
|
);
|
|
762
769
|
}
|
|
770
|
+
// Live sessions for the Active Sessions screen: unexpired only, carrying the
|
|
771
|
+
// sid so the caller can flag the current one (row.sid === the JWT's sid).
|
|
772
|
+
// No token — that never leaves the server.
|
|
773
|
+
async listLiveByUser(userId) {
|
|
774
|
+
return this.store.query(
|
|
775
|
+
`SELECT id, sid,
|
|
776
|
+
user_agent AS "userAgent",
|
|
777
|
+
ip_address AS "ipAddress",
|
|
778
|
+
created_at AS "createdAt",
|
|
779
|
+
expires_at AS "expiresAt"
|
|
780
|
+
FROM fonderie_sessions
|
|
781
|
+
WHERE user_id = $1 AND expires_at > now()
|
|
782
|
+
ORDER BY created_at DESC`,
|
|
783
|
+
[userId]
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
// Terminate one session by row id, scoped to its owner (a user can never
|
|
787
|
+
// delete another user's session). Returns whether a row was removed.
|
|
788
|
+
async terminateById(userId, id) {
|
|
789
|
+
const rows = await this.store.query(
|
|
790
|
+
`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,
|
|
791
|
+
[id, userId]
|
|
792
|
+
);
|
|
793
|
+
return rows.length > 0;
|
|
794
|
+
}
|
|
795
|
+
// Terminate all of a user's sessions except the current one (by sid).
|
|
796
|
+
// Returns the number of sessions removed.
|
|
797
|
+
async terminateOthers(userId, keepSid) {
|
|
798
|
+
const rows = await this.store.query(
|
|
799
|
+
`DELETE FROM fonderie_sessions
|
|
800
|
+
WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
|
|
801
|
+
RETURNING id`,
|
|
802
|
+
[userId, keepSid]
|
|
803
|
+
);
|
|
804
|
+
return rows.length;
|
|
805
|
+
}
|
|
763
806
|
async exists(token) {
|
|
764
807
|
const rows = await this.store.query(
|
|
765
808
|
`SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
|
|
@@ -812,11 +855,92 @@ var BackupCodeModel = class {
|
|
|
812
855
|
}
|
|
813
856
|
};
|
|
814
857
|
|
|
858
|
+
// src/models/login-event.model.ts
|
|
859
|
+
var LoginEventModel = class {
|
|
860
|
+
constructor(store) {
|
|
861
|
+
this.store = store;
|
|
862
|
+
}
|
|
863
|
+
store;
|
|
864
|
+
async record(e) {
|
|
865
|
+
await this.store.query(
|
|
866
|
+
`INSERT INTO fonderie_login_events
|
|
867
|
+
(user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent)
|
|
868
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
869
|
+
[
|
|
870
|
+
e.userId,
|
|
871
|
+
e.emailAttempted ?? null,
|
|
872
|
+
e.method,
|
|
873
|
+
e.outcome,
|
|
874
|
+
e.failureReason ?? null,
|
|
875
|
+
e.ipAddress,
|
|
876
|
+
e.userAgent
|
|
877
|
+
]
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
// Fire-and-forget variant for the login paths: recording history must never
|
|
881
|
+
// block or fail a login (same posture as the bus.emit(...).catch(() => {})
|
|
882
|
+
// notification sends).
|
|
883
|
+
recordSafe(e) {
|
|
884
|
+
void this.record(e).catch(() => {
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
// One page of a user's own login history, newest first. Keyset-paginated on
|
|
888
|
+
// (created_at, id) — mirrors audit's event list so the two log UIs share a
|
|
889
|
+
// cursor contract. The +1 over-fetch that detects a next page lives here so
|
|
890
|
+
// no outer clamp can shave it off.
|
|
891
|
+
async listByUser(query) {
|
|
892
|
+
const limit = Math.min(query.limit ?? 50, MAX_HISTORY_LIMIT);
|
|
893
|
+
const params = [query.userId];
|
|
894
|
+
const where = ["user_id = $1"];
|
|
895
|
+
if (query.outcome) {
|
|
896
|
+
params.push(query.outcome);
|
|
897
|
+
where.push(`outcome = $${params.length}`);
|
|
898
|
+
}
|
|
899
|
+
if (query.from) {
|
|
900
|
+
params.push(query.from);
|
|
901
|
+
where.push(`created_at >= $${params.length}`);
|
|
902
|
+
}
|
|
903
|
+
if (query.to) {
|
|
904
|
+
params.push(query.to);
|
|
905
|
+
where.push(`created_at <= $${params.length}`);
|
|
906
|
+
}
|
|
907
|
+
if (query.cursor) {
|
|
908
|
+
params.push(query.cursor.createdAt, query.cursor.id);
|
|
909
|
+
where.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
|
|
910
|
+
}
|
|
911
|
+
params.push(limit + 1);
|
|
912
|
+
const rows = await this.store.query(
|
|
913
|
+
`SELECT id, method, outcome, failure_reason AS "failureReason",
|
|
914
|
+
ip_address AS "ipAddress", user_agent AS "userAgent",
|
|
915
|
+
created_at AS "createdAt", created_at::text AS "createdAtRaw"
|
|
916
|
+
FROM fonderie_login_events
|
|
917
|
+
WHERE ${where.join(" AND ")}
|
|
918
|
+
ORDER BY created_at DESC, id DESC
|
|
919
|
+
LIMIT $${params.length}`,
|
|
920
|
+
params
|
|
921
|
+
);
|
|
922
|
+
return { events: rows.slice(0, limit), hasMore: rows.length > limit };
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
var MAX_HISTORY_LIMIT = 200;
|
|
926
|
+
|
|
927
|
+
// src/services/request-meta.ts
|
|
928
|
+
var UA_MAX = 512;
|
|
929
|
+
function requestMeta(ctx) {
|
|
930
|
+
const ip = ctx.meta["clientIp"];
|
|
931
|
+
const ua = ctx.request.headers.get("user-agent");
|
|
932
|
+
return {
|
|
933
|
+
ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
|
|
934
|
+
userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
815
938
|
// src/controllers/mfa.controller.ts
|
|
816
939
|
function mfaController(store, config, issuer, bus) {
|
|
817
940
|
const users = new UserModel(store);
|
|
818
941
|
const sessions = new SessionModel(store);
|
|
819
942
|
const backupCodes = new BackupCodeModel(store);
|
|
943
|
+
const loginEvents = new LoginEventModel(store);
|
|
820
944
|
const mfaCipher = makeMfaCipher(config.mfaSecretKey);
|
|
821
945
|
return {
|
|
822
946
|
// ── 1. Setup ───────────────────────────────────────────────
|
|
@@ -846,6 +970,16 @@ function mfaController(store, config, issuer, bus) {
|
|
|
846
970
|
verify: async (ctx) => {
|
|
847
971
|
const body = ctx.meta["body"];
|
|
848
972
|
const token = body?.["token"];
|
|
973
|
+
const isLoginCompletion = ctx.user.mfaPending === true;
|
|
974
|
+
const meta = requestMeta(ctx);
|
|
975
|
+
const failedMfa = () => loginEvents.recordSafe({
|
|
976
|
+
userId: ctx.user.id,
|
|
977
|
+
emailAttempted: ctx.user.email,
|
|
978
|
+
method: "mfa",
|
|
979
|
+
outcome: "failed",
|
|
980
|
+
failureReason: "invalid_code",
|
|
981
|
+
...meta
|
|
982
|
+
});
|
|
849
983
|
if (typeof token !== "string") {
|
|
850
984
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
|
|
851
985
|
}
|
|
@@ -886,6 +1020,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
886
1020
|
);
|
|
887
1021
|
const matched = checks.find((r) => r.match);
|
|
888
1022
|
if (!matched) {
|
|
1023
|
+
if (isLoginCompletion) failedMfa();
|
|
889
1024
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
|
|
890
1025
|
}
|
|
891
1026
|
await backupCodes.consume(matched.id);
|
|
@@ -903,6 +1038,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
903
1038
|
}
|
|
904
1039
|
const secret = mfaCipher.decrypt(storedSecret);
|
|
905
1040
|
if (!verifyTotpToken(token, secret)) {
|
|
1041
|
+
if (isLoginCompletion) failedMfa();
|
|
906
1042
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
|
|
907
1043
|
}
|
|
908
1044
|
if (!ctx.user.mfaEnabled) {
|
|
@@ -913,7 +1049,16 @@ function mfaController(store, config, issuer, bus) {
|
|
|
913
1049
|
loginMethod: ctx.user.loginMethod,
|
|
914
1050
|
phoneVerified: ctx.user.phoneVerified
|
|
915
1051
|
});
|
|
916
|
-
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1052
|
+
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1053
|
+
if (isLoginCompletion) {
|
|
1054
|
+
loginEvents.recordSafe({
|
|
1055
|
+
userId: ctx.user.id,
|
|
1056
|
+
emailAttempted: ctx.user.email,
|
|
1057
|
+
method: "mfa",
|
|
1058
|
+
outcome: "success",
|
|
1059
|
+
...meta
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
917
1062
|
const fullUser = await users.findById(ctx.user.id);
|
|
918
1063
|
if (!fullUser) {
|
|
919
1064
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.SERVER_ERROR, "SERVER_ERROR", "User not found after MFA verify");
|
|
@@ -1182,6 +1327,7 @@ function extractRefreshToken(ctx) {
|
|
|
1182
1327
|
function authController(store, config, bus) {
|
|
1183
1328
|
const users = new UserModel(store);
|
|
1184
1329
|
const sessions = new SessionModel(store);
|
|
1330
|
+
const loginEvents = new LoginEventModel(store);
|
|
1185
1331
|
const passwordReset = new PasswordResetModel(store);
|
|
1186
1332
|
const emailVerif = new EmailVerificationModel(store);
|
|
1187
1333
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
@@ -1251,7 +1397,7 @@ function authController(store, config, bus) {
|
|
|
1251
1397
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1252
1398
|
loginMethod: "email"
|
|
1253
1399
|
});
|
|
1254
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1400
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1255
1401
|
const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
|
|
1256
1402
|
const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
|
|
1257
1403
|
return Response.json(
|
|
@@ -1315,7 +1461,7 @@ function authController(store, config, bus) {
|
|
|
1315
1461
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1316
1462
|
loginMethod: "phone"
|
|
1317
1463
|
});
|
|
1318
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1464
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1319
1465
|
return Response.json(
|
|
1320
1466
|
{
|
|
1321
1467
|
reason: "USER_PHONE_REGISTERED",
|
|
@@ -1339,6 +1485,7 @@ function authController(store, config, bus) {
|
|
|
1339
1485
|
},
|
|
1340
1486
|
login: async (ctx) => {
|
|
1341
1487
|
const body = ctx.meta["body"];
|
|
1488
|
+
const meta = requestMeta(ctx);
|
|
1342
1489
|
if (typeof body?.["email"] === "string" && typeof body?.["password"] === "string") {
|
|
1343
1490
|
const { email: rawEmail, password: password2 } = body;
|
|
1344
1491
|
const email2 = normalizeEmailSafe(rawEmail);
|
|
@@ -1347,6 +1494,14 @@ function authController(store, config, bus) {
|
|
|
1347
1494
|
}
|
|
1348
1495
|
const user = await users.findByEmail(email2);
|
|
1349
1496
|
if (!user || !user.passwordHash) {
|
|
1497
|
+
loginEvents.recordSafe({
|
|
1498
|
+
userId: null,
|
|
1499
|
+
emailAttempted: email2,
|
|
1500
|
+
method: "password",
|
|
1501
|
+
outcome: "failed",
|
|
1502
|
+
failureReason: "unknown_email",
|
|
1503
|
+
...meta
|
|
1504
|
+
});
|
|
1350
1505
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1351
1506
|
}
|
|
1352
1507
|
const { valid, needsRehash } = await verifyPasswordForLogin(
|
|
@@ -1355,6 +1510,14 @@ function authController(store, config, bus) {
|
|
|
1355
1510
|
config.legacyVerify
|
|
1356
1511
|
);
|
|
1357
1512
|
if (!valid) {
|
|
1513
|
+
loginEvents.recordSafe({
|
|
1514
|
+
userId: user.id,
|
|
1515
|
+
emailAttempted: email2,
|
|
1516
|
+
method: "password",
|
|
1517
|
+
outcome: "failed",
|
|
1518
|
+
failureReason: "bad_password",
|
|
1519
|
+
...meta
|
|
1520
|
+
});
|
|
1358
1521
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1359
1522
|
}
|
|
1360
1523
|
if (needsRehash) {
|
|
@@ -1376,7 +1539,14 @@ function authController(store, config, bus) {
|
|
|
1376
1539
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1377
1540
|
loginMethod: "email"
|
|
1378
1541
|
});
|
|
1379
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1542
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1543
|
+
loginEvents.recordSafe({
|
|
1544
|
+
userId: user.id,
|
|
1545
|
+
emailAttempted: email2,
|
|
1546
|
+
method: "password",
|
|
1547
|
+
outcome: "success",
|
|
1548
|
+
...meta
|
|
1549
|
+
});
|
|
1380
1550
|
const resolvedLogin = { ...config, ...config.resolve?.(ctx) };
|
|
1381
1551
|
const requiresVerification = !!resolvedLogin.requireVerification && !user.emailVerifiedAt;
|
|
1382
1552
|
return Response.json(
|
|
@@ -1421,7 +1591,7 @@ function authController(store, config, bus) {
|
|
|
1421
1591
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1422
1592
|
loginMethod: "phone"
|
|
1423
1593
|
});
|
|
1424
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1594
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1425
1595
|
return Response.json(
|
|
1426
1596
|
{
|
|
1427
1597
|
reason: "USER_PHONE_OTP_SENT",
|
|
@@ -1444,6 +1614,10 @@ function authController(store, config, bus) {
|
|
|
1444
1614
|
);
|
|
1445
1615
|
},
|
|
1446
1616
|
logout: async (ctx) => {
|
|
1617
|
+
const sid = ctx.user?.sid;
|
|
1618
|
+
if (sid) {
|
|
1619
|
+
await sessions.deleteBySid(sid).catch(() => void 0);
|
|
1620
|
+
}
|
|
1447
1621
|
const token = extractRefreshToken(ctx);
|
|
1448
1622
|
if (token) {
|
|
1449
1623
|
await sessions.delete(token).catch(() => void 0);
|
|
@@ -1482,7 +1656,7 @@ function authController(store, config, bus) {
|
|
|
1482
1656
|
loginMethod: payload.loginMethod ?? "email",
|
|
1483
1657
|
phoneVerified: payload.phoneVerified ?? false
|
|
1484
1658
|
});
|
|
1485
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1659
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1486
1660
|
return Response.json(
|
|
1487
1661
|
{
|
|
1488
1662
|
reason: "TOKENS_REFRESHED",
|
|
@@ -1616,7 +1790,7 @@ function authController(store, config, bus) {
|
|
|
1616
1790
|
loginMethod: "phone",
|
|
1617
1791
|
phoneVerified: true
|
|
1618
1792
|
});
|
|
1619
|
-
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1793
|
+
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1620
1794
|
const verifiedUser = await users.findById(ctx.user.id);
|
|
1621
1795
|
if (!verifiedUser) {
|
|
1622
1796
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
@@ -1749,8 +1923,40 @@ function authController(store, config, bus) {
|
|
|
1749
1923
|
|
|
1750
1924
|
// src/controllers/user.controller.ts
|
|
1751
1925
|
var import_node_crypto5 = require("crypto");
|
|
1752
|
-
var
|
|
1926
|
+
var import_core7 = require("@fonderie/core");
|
|
1753
1927
|
var import_events3 = require("@fonderie/events");
|
|
1928
|
+
|
|
1929
|
+
// src/dtos/login-activity.ts
|
|
1930
|
+
var import_core6 = require("@fonderie/core");
|
|
1931
|
+
function toLoginEventDTO(row) {
|
|
1932
|
+
return {
|
|
1933
|
+
id: row.id,
|
|
1934
|
+
method: row.method,
|
|
1935
|
+
outcome: row.outcome,
|
|
1936
|
+
failureReason: row.failureReason,
|
|
1937
|
+
ipAddress: row.ipAddress,
|
|
1938
|
+
userAgent: row.userAgent,
|
|
1939
|
+
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
function toLoginHistoryPageDTO(page) {
|
|
1943
|
+
const last = page.events[page.events.length - 1];
|
|
1944
|
+
const nextCursor = page.hasMore && last ? (0, import_core6.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
|
|
1945
|
+
return { events: page.events.map(toLoginEventDTO), nextCursor };
|
|
1946
|
+
}
|
|
1947
|
+
var decodeLoginCursor = import_core6.decodeKeysetCursor;
|
|
1948
|
+
function toSessionDTO(row, currentSid) {
|
|
1949
|
+
return {
|
|
1950
|
+
id: row.id,
|
|
1951
|
+
current: currentSid !== null && row.sid === currentSid,
|
|
1952
|
+
ipAddress: row.ipAddress,
|
|
1953
|
+
userAgent: row.userAgent,
|
|
1954
|
+
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
|
1955
|
+
expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// src/controllers/user.controller.ts
|
|
1754
1960
|
function normalizePhone2(phone2) {
|
|
1755
1961
|
return phone2.trim().replace(/[\s()\-\.]/g, "");
|
|
1756
1962
|
}
|
|
@@ -1760,18 +1966,82 @@ function isValidPhone2(phone2) {
|
|
|
1760
1966
|
function userController(store, config, bus) {
|
|
1761
1967
|
const users = new UserModel(store);
|
|
1762
1968
|
const sessions = new SessionModel(store);
|
|
1969
|
+
const loginEvents = new LoginEventModel(store);
|
|
1763
1970
|
const emailVerif = new EmailVerificationModel(store);
|
|
1764
1971
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
1765
1972
|
return {
|
|
1766
1973
|
me: async (ctx) => {
|
|
1767
1974
|
const user = await users.findById(ctx.user.id);
|
|
1768
1975
|
if (!user) {
|
|
1769
|
-
return (0,
|
|
1976
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1770
1977
|
}
|
|
1771
|
-
return (0,
|
|
1978
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
|
|
1772
1979
|
user: toUserDTO(user, ctx.user.phoneVerified)
|
|
1773
1980
|
});
|
|
1774
1981
|
},
|
|
1982
|
+
// ── Login history (append-only; the caller's own attempts) ────────
|
|
1983
|
+
loginHistory: async (ctx) => {
|
|
1984
|
+
const params = new URL(ctx.request.url).searchParams;
|
|
1985
|
+
const rawLimit = Number(params.get("limit") ?? 50);
|
|
1986
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
|
|
1987
|
+
const outcomeParam = params.get("outcome");
|
|
1988
|
+
if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
|
|
1989
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
|
|
1990
|
+
}
|
|
1991
|
+
const cursorParam = params.get("cursor");
|
|
1992
|
+
let cursor = null;
|
|
1993
|
+
if (cursorParam) {
|
|
1994
|
+
cursor = decodeLoginCursor(cursorParam);
|
|
1995
|
+
if (!cursor) {
|
|
1996
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
const query = { userId: ctx.user.id, limit };
|
|
2000
|
+
if (outcomeParam) query.outcome = outcomeParam;
|
|
2001
|
+
if (cursor) query.cursor = cursor;
|
|
2002
|
+
const from = params.get("from");
|
|
2003
|
+
const to = params.get("to");
|
|
2004
|
+
if (from) query.from = new Date(from);
|
|
2005
|
+
if (to) query.to = new Date(to);
|
|
2006
|
+
const page = await loginEvents.listByUser(query);
|
|
2007
|
+
return (0, import_core7.setApiResponse)(
|
|
2008
|
+
import_core7.HTTP.OK,
|
|
2009
|
+
"LOGIN_HISTORY_FETCHED",
|
|
2010
|
+
"Login history retrieved.",
|
|
2011
|
+
toLoginHistoryPageDTO(page)
|
|
2012
|
+
);
|
|
2013
|
+
},
|
|
2014
|
+
// ── Active sessions (live only; the caller's own) ─────────────────
|
|
2015
|
+
listSessions: async (ctx) => {
|
|
2016
|
+
const rows = await sessions.listLiveByUser(ctx.user.id);
|
|
2017
|
+
const currentSid = ctx.user.sid ?? null;
|
|
2018
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
|
|
2019
|
+
sessions: rows.map((r) => toSessionDTO(r, currentSid))
|
|
2020
|
+
});
|
|
2021
|
+
},
|
|
2022
|
+
terminateSession: async (ctx) => {
|
|
2023
|
+
const id = ctx.meta["params"]?.["id"];
|
|
2024
|
+
if (!id) {
|
|
2025
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2026
|
+
}
|
|
2027
|
+
const removed = await sessions.terminateById(ctx.user.id, id);
|
|
2028
|
+
if (!removed) {
|
|
2029
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2030
|
+
}
|
|
2031
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
|
|
2032
|
+
},
|
|
2033
|
+
terminateOtherSessions: async (ctx) => {
|
|
2034
|
+
const currentSid = ctx.user.sid ?? null;
|
|
2035
|
+
if (!currentSid) {
|
|
2036
|
+
return (0, import_core7.setApiResponse)(
|
|
2037
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
2038
|
+
"NO_CURRENT_SESSION",
|
|
2039
|
+
"Cannot terminate others without a current session context"
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
const count = await sessions.terminateOthers(ctx.user.id, currentSid);
|
|
2043
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
|
|
2044
|
+
},
|
|
1775
2045
|
updateProfile: async (ctx) => {
|
|
1776
2046
|
const body = ctx.meta["body"];
|
|
1777
2047
|
const allowed = ["firstName", "lastName", "avatarUrl"];
|
|
@@ -1780,18 +2050,18 @@ function userController(store, config, bus) {
|
|
|
1780
2050
|
if (body?.[key] !== void 0) fields[key] = body[key];
|
|
1781
2051
|
}
|
|
1782
2052
|
if (Object.keys(fields).length === 0) {
|
|
1783
|
-
return (0,
|
|
1784
|
-
|
|
2053
|
+
return (0, import_core7.setApiResponse)(
|
|
2054
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1785
2055
|
"INVALID_PARAMETER",
|
|
1786
2056
|
"Provide at least one of: firstName, lastName, avatarUrl"
|
|
1787
2057
|
);
|
|
1788
2058
|
}
|
|
1789
2059
|
const row = await users.update(ctx.user.id, fields);
|
|
1790
2060
|
if (!row) {
|
|
1791
|
-
return (0,
|
|
2061
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1792
2062
|
}
|
|
1793
2063
|
const updated = await users.findById(ctx.user.id);
|
|
1794
|
-
return (0,
|
|
2064
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
|
|
1795
2065
|
user: toUserDTO(updated, ctx.user.phoneVerified)
|
|
1796
2066
|
});
|
|
1797
2067
|
},
|
|
@@ -1808,14 +2078,14 @@ function userController(store, config, bus) {
|
|
|
1808
2078
|
if (Object.keys(patch).length > 0) fields.patch = patch;
|
|
1809
2079
|
const row = await users.updatePreferences(ctx.user.id, fields);
|
|
1810
2080
|
if (!row) {
|
|
1811
|
-
return (0,
|
|
1812
|
-
|
|
2081
|
+
return (0, import_core7.setApiResponse)(
|
|
2082
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1813
2083
|
"INVALID_PARAMETER",
|
|
1814
2084
|
"Provide at least one preference field"
|
|
1815
2085
|
);
|
|
1816
2086
|
}
|
|
1817
2087
|
const updated = await users.findById(ctx.user.id);
|
|
1818
|
-
return (0,
|
|
2088
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
|
|
1819
2089
|
user: toUserDTO(updated, ctx.user.phoneVerified)
|
|
1820
2090
|
});
|
|
1821
2091
|
},
|
|
@@ -1824,23 +2094,23 @@ function userController(store, config, bus) {
|
|
|
1824
2094
|
const newEmail = body?.["email"];
|
|
1825
2095
|
const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
|
|
1826
2096
|
if (!normalised) {
|
|
1827
|
-
return (0,
|
|
1828
|
-
|
|
2097
|
+
return (0, import_core7.setApiResponse)(
|
|
2098
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1829
2099
|
"INVALID_PARAMETER",
|
|
1830
2100
|
"A valid email address is required"
|
|
1831
2101
|
);
|
|
1832
2102
|
}
|
|
1833
2103
|
const oldEmail = ctx.user.email;
|
|
1834
2104
|
if (normalised === oldEmail) {
|
|
1835
|
-
return (0,
|
|
1836
|
-
|
|
2105
|
+
return (0, import_core7.setApiResponse)(
|
|
2106
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1837
2107
|
"INVALID_PARAMETER",
|
|
1838
2108
|
"New email must differ from current email"
|
|
1839
2109
|
);
|
|
1840
2110
|
}
|
|
1841
2111
|
const existing = await users.findByEmail(normalised);
|
|
1842
2112
|
if (existing) {
|
|
1843
|
-
return (0,
|
|
2113
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
|
|
1844
2114
|
}
|
|
1845
2115
|
const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1846
2116
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
@@ -1862,8 +2132,8 @@ function userController(store, config, bus) {
|
|
|
1862
2132
|
}).catch(() => {
|
|
1863
2133
|
});
|
|
1864
2134
|
}
|
|
1865
|
-
return (0,
|
|
1866
|
-
|
|
2135
|
+
return (0, import_core7.setApiResponse)(
|
|
2136
|
+
import_core7.HTTP.OK,
|
|
1867
2137
|
"EMAIL_UPDATED",
|
|
1868
2138
|
"Email updated. A verification code has been sent to your new address.",
|
|
1869
2139
|
{
|
|
@@ -1875,8 +2145,8 @@ function userController(store, config, bus) {
|
|
|
1875
2145
|
const body = ctx.meta["body"];
|
|
1876
2146
|
const newPhone = body?.["phone"];
|
|
1877
2147
|
if (!isValidPhone2(newPhone)) {
|
|
1878
|
-
return (0,
|
|
1879
|
-
|
|
2148
|
+
return (0, import_core7.setApiResponse)(
|
|
2149
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1880
2150
|
"INVALID_PARAMETER",
|
|
1881
2151
|
"A valid phone number is required"
|
|
1882
2152
|
);
|
|
@@ -1884,7 +2154,7 @@ function userController(store, config, bus) {
|
|
|
1884
2154
|
const normalised = normalizePhone2(newPhone);
|
|
1885
2155
|
const existing = await users.findByPhone(normalised);
|
|
1886
2156
|
if (existing) {
|
|
1887
|
-
return (0,
|
|
2157
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
|
|
1888
2158
|
}
|
|
1889
2159
|
const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1890
2160
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
|
|
@@ -1906,8 +2176,8 @@ function userController(store, config, bus) {
|
|
|
1906
2176
|
}).catch(() => {
|
|
1907
2177
|
});
|
|
1908
2178
|
}
|
|
1909
|
-
return (0,
|
|
1910
|
-
|
|
2179
|
+
return (0, import_core7.setApiResponse)(
|
|
2180
|
+
import_core7.HTTP.OK,
|
|
1911
2181
|
"PHONE_UPDATED",
|
|
1912
2182
|
"Phone number updated. A verification code has been sent to your new number.",
|
|
1913
2183
|
{
|
|
@@ -1920,22 +2190,22 @@ function userController(store, config, bus) {
|
|
|
1920
2190
|
const currentPassword = body?.["currentPassword"];
|
|
1921
2191
|
const newPassword = body?.["newPassword"];
|
|
1922
2192
|
if (typeof currentPassword !== "string" || !currentPassword) {
|
|
1923
|
-
return (0,
|
|
2193
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
|
|
1924
2194
|
}
|
|
1925
2195
|
if (typeof newPassword !== "string" || newPassword.length < 8) {
|
|
1926
|
-
return (0,
|
|
2196
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
|
|
1927
2197
|
}
|
|
1928
2198
|
const user = await users.findById(ctx.user.id);
|
|
1929
|
-
if (!user) return (0,
|
|
2199
|
+
if (!user) return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1930
2200
|
const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
|
|
1931
2201
|
const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
|
|
1932
2202
|
if (!valid) {
|
|
1933
|
-
return (0,
|
|
2203
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
|
|
1934
2204
|
}
|
|
1935
2205
|
const hash = await hashPassword2(newPassword);
|
|
1936
2206
|
await users.updatePassword(ctx.user.id, hash);
|
|
1937
2207
|
await sessions.deleteByUser(ctx.user.id);
|
|
1938
|
-
return (0,
|
|
2208
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
|
|
1939
2209
|
},
|
|
1940
2210
|
deleteMe: async (ctx) => {
|
|
1941
2211
|
const userId = ctx.user.id;
|
|
@@ -1960,7 +2230,7 @@ function userController(store, config, bus) {
|
|
|
1960
2230
|
// no MFA secret, no session tokens (session metadata only).
|
|
1961
2231
|
exportMe: async (ctx) => {
|
|
1962
2232
|
const user = await users.findById(ctx.user.id);
|
|
1963
|
-
if (!user) return (0,
|
|
2233
|
+
if (!user) return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1964
2234
|
const sessionRows = await sessions.listByUser(ctx.user.id);
|
|
1965
2235
|
const bundle = {
|
|
1966
2236
|
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -1969,8 +2239,8 @@ function userController(store, config, bus) {
|
|
|
1969
2239
|
id: s.id,
|
|
1970
2240
|
userAgent: s.userAgent,
|
|
1971
2241
|
ipAddress: s.ipAddress,
|
|
1972
|
-
createdAt: (0,
|
|
1973
|
-
expiresAt: (0,
|
|
2242
|
+
createdAt: (0, import_core7.dateOrEmpty)(s.createdAt),
|
|
2243
|
+
expiresAt: (0, import_core7.dateOrEmpty)(s.expiresAt)
|
|
1974
2244
|
})),
|
|
1975
2245
|
security: {
|
|
1976
2246
|
mfaEnabled: user.mfaEnabled === true,
|
|
@@ -1989,22 +2259,23 @@ function userController(store, config, bus) {
|
|
|
1989
2259
|
}
|
|
1990
2260
|
bundle["modules"] = modules;
|
|
1991
2261
|
}
|
|
1992
|
-
return (0,
|
|
2262
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
|
|
1993
2263
|
}
|
|
1994
2264
|
};
|
|
1995
2265
|
}
|
|
1996
2266
|
|
|
1997
2267
|
// src/controllers/oauth.controller.ts
|
|
1998
|
-
var
|
|
2268
|
+
var import_core8 = require("@fonderie/core");
|
|
1999
2269
|
function oauthController(store, config) {
|
|
2000
2270
|
const users = new UserModel(store);
|
|
2001
2271
|
const sessions = new SessionModel(store);
|
|
2272
|
+
const loginEvents = new LoginEventModel(store);
|
|
2002
2273
|
return {
|
|
2003
2274
|
googleInit: async (_ctx) => {
|
|
2004
2275
|
const google = config.google;
|
|
2005
2276
|
if (!google) {
|
|
2006
|
-
return (0,
|
|
2007
|
-
|
|
2277
|
+
return (0, import_core8.setApiResponse)(
|
|
2278
|
+
import_core8.HTTP.NOT_IMPLEMENTED,
|
|
2008
2279
|
"NOT_CONFIGURED",
|
|
2009
2280
|
"Google OAuth not configured"
|
|
2010
2281
|
);
|
|
@@ -2016,8 +2287,8 @@ function oauthController(store, config) {
|
|
|
2016
2287
|
scope: "openid email profile"
|
|
2017
2288
|
});
|
|
2018
2289
|
const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
2019
|
-
return (0,
|
|
2020
|
-
|
|
2290
|
+
return (0, import_core8.setApiResponse)(
|
|
2291
|
+
import_core8.HTTP.OK,
|
|
2021
2292
|
"GOOGLE_AUTH_URL",
|
|
2022
2293
|
"Redirect the user to the returned URL to begin Google OAuth.",
|
|
2023
2294
|
{ url }
|
|
@@ -2026,8 +2297,8 @@ function oauthController(store, config) {
|
|
|
2026
2297
|
googleCallback: async (ctx) => {
|
|
2027
2298
|
const google = config.google;
|
|
2028
2299
|
if (!google) {
|
|
2029
|
-
return (0,
|
|
2030
|
-
|
|
2300
|
+
return (0, import_core8.setApiResponse)(
|
|
2301
|
+
import_core8.HTTP.NOT_IMPLEMENTED,
|
|
2031
2302
|
"NOT_CONFIGURED",
|
|
2032
2303
|
"Google OAuth not configured"
|
|
2033
2304
|
);
|
|
@@ -2035,7 +2306,7 @@ function oauthController(store, config) {
|
|
|
2035
2306
|
const url = new URL(ctx.request.url);
|
|
2036
2307
|
const code = url.searchParams.get("code");
|
|
2037
2308
|
if (!code) {
|
|
2038
|
-
return (0,
|
|
2309
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
|
|
2039
2310
|
}
|
|
2040
2311
|
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
|
2041
2312
|
method: "POST",
|
|
@@ -2050,8 +2321,8 @@ function oauthController(store, config) {
|
|
|
2050
2321
|
});
|
|
2051
2322
|
const tokenData = await tokenRes.json();
|
|
2052
2323
|
if (!tokenData.id_token) {
|
|
2053
|
-
return (0,
|
|
2054
|
-
|
|
2324
|
+
return (0, import_core8.setApiResponse)(
|
|
2325
|
+
import_core8.HTTP.BAD_REQUEST,
|
|
2055
2326
|
"GOOGLE_AUTH_FAILED",
|
|
2056
2327
|
"OAuth token exchange failed"
|
|
2057
2328
|
);
|
|
@@ -2060,24 +2331,40 @@ function oauthController(store, config) {
|
|
|
2060
2331
|
Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
|
|
2061
2332
|
);
|
|
2062
2333
|
if (!payload.email) {
|
|
2063
|
-
return (0,
|
|
2334
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
|
|
2064
2335
|
}
|
|
2065
2336
|
const normalizedEmail = normalizeEmailSafe(payload.email);
|
|
2066
2337
|
if (!normalizedEmail) {
|
|
2067
|
-
return (0,
|
|
2338
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
|
|
2068
2339
|
}
|
|
2340
|
+
const meta = requestMeta(ctx);
|
|
2069
2341
|
const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
|
|
2070
2342
|
if (!upserted) {
|
|
2071
|
-
|
|
2343
|
+
loginEvents.recordSafe({
|
|
2344
|
+
userId: null,
|
|
2345
|
+
emailAttempted: normalizedEmail,
|
|
2346
|
+
method: "oauth-google",
|
|
2347
|
+
outcome: "failed",
|
|
2348
|
+
failureReason: "provider_upsert_failed",
|
|
2349
|
+
...meta
|
|
2350
|
+
});
|
|
2351
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
2072
2352
|
}
|
|
2073
2353
|
const fullUser = await users.findById(upserted.id);
|
|
2074
2354
|
if (!fullUser) {
|
|
2075
|
-
return (0,
|
|
2355
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
2076
2356
|
}
|
|
2077
2357
|
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
|
|
2078
2358
|
loginMethod: "google"
|
|
2079
2359
|
});
|
|
2080
|
-
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
2360
|
+
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
2361
|
+
loginEvents.recordSafe({
|
|
2362
|
+
userId: upserted.id,
|
|
2363
|
+
emailAttempted: normalizedEmail,
|
|
2364
|
+
method: "oauth-google",
|
|
2365
|
+
outcome: "success",
|
|
2366
|
+
...meta
|
|
2367
|
+
});
|
|
2081
2368
|
return Response.json(
|
|
2082
2369
|
{
|
|
2083
2370
|
reason: "GOOGLE_AUTH_SUCCESS",
|
|
@@ -2135,6 +2422,13 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2135
2422
|
R("changePassword", "PUT", "/users/password", import_middlewares2.requireAuth, (0, import_middlewares.validate)(changePasswordSchema), user.changePassword),
|
|
2136
2423
|
R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
|
|
2137
2424
|
R("exportMe", "GET", "/users/export", import_middlewares2.requireAuth, user.exportMe),
|
|
2425
|
+
// Security surfaces (Protected; the caller's own login history + sessions).
|
|
2426
|
+
// The literal /others route is registered before /:id so it isn't
|
|
2427
|
+
// captured as an id.
|
|
2428
|
+
R("loginHistory", "GET", "/auth/login-history", import_middlewares2.requireAuth, user.loginHistory),
|
|
2429
|
+
R("listSessions", "GET", "/auth/sessions", import_middlewares2.requireAuth, user.listSessions),
|
|
2430
|
+
R("terminateOtherSessions", "DELETE", "/auth/sessions/others", import_middlewares2.requireAuth, user.terminateOtherSessions),
|
|
2431
|
+
R("terminateSession", "DELETE", "/auth/sessions/:id", import_middlewares2.requireAuth, user.terminateSession),
|
|
2138
2432
|
// MFA (email sessions only — requireVerified is always enforced here
|
|
2139
2433
|
// because MFA is a security feature and email verification is meaningful)
|
|
2140
2434
|
R("mfaSetup", "POST", "/auth/mfa/setup", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, mfa.setup),
|
|
@@ -2154,17 +2448,17 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2154
2448
|
}
|
|
2155
2449
|
|
|
2156
2450
|
// src/services/config-guard.ts
|
|
2157
|
-
var
|
|
2451
|
+
var import_core9 = require("@fonderie/core");
|
|
2158
2452
|
var MODULE = "@fonderie/auth";
|
|
2159
2453
|
function collectAuthConfigProblems(config) {
|
|
2160
2454
|
const problems = [];
|
|
2161
2455
|
const secret = config.jwtSecret ?? "";
|
|
2162
|
-
const secretProblem = (0,
|
|
2456
|
+
const secretProblem = (0, import_core9.secretStrengthProblem)(secret);
|
|
2163
2457
|
if (secretProblem === "too-short") {
|
|
2164
2458
|
problems.push({
|
|
2165
2459
|
module: MODULE,
|
|
2166
2460
|
severity: "error",
|
|
2167
|
-
message: `jwtSecret must be at least ${
|
|
2461
|
+
message: `jwtSecret must be at least ${import_core9.MIN_SECRET_LENGTH} characters (got ${secret.length})`
|
|
2168
2462
|
});
|
|
2169
2463
|
} else if (secretProblem === "placeholder") {
|
|
2170
2464
|
problems.push({
|
|
@@ -2188,7 +2482,7 @@ function collectAuthConfigProblems(config) {
|
|
|
2188
2482
|
severity: "error",
|
|
2189
2483
|
message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing"
|
|
2190
2484
|
});
|
|
2191
|
-
} else if (
|
|
2485
|
+
} else if (import_core9.PLACEHOLDER_SECRET.test(clientSecret)) {
|
|
2192
2486
|
problems.push({
|
|
2193
2487
|
module: MODULE,
|
|
2194
2488
|
severity: "error",
|
|
@@ -2255,7 +2549,10 @@ function withSession(store, config) {
|
|
|
2255
2549
|
...user,
|
|
2256
2550
|
loginMethod: payload.loginMethod ?? "email",
|
|
2257
2551
|
phoneVerified: payload.phoneVerified ?? false,
|
|
2258
|
-
mfaPending: payload.mfaPending ?? false
|
|
2552
|
+
mfaPending: payload.mfaPending ?? false,
|
|
2553
|
+
// The session this request is authenticated on, so the sessions
|
|
2554
|
+
// API can flag the current row and "terminate others" can spare it.
|
|
2555
|
+
sid: payload.sid ?? null
|
|
2259
2556
|
}
|
|
2260
2557
|
});
|
|
2261
2558
|
return next();
|
|
@@ -2296,6 +2593,122 @@ var AuthModule = class {
|
|
|
2296
2593
|
}
|
|
2297
2594
|
};
|
|
2298
2595
|
|
|
2596
|
+
// src/templates.ts
|
|
2597
|
+
var DEFAULT_TEMPLATES = {
|
|
2598
|
+
[MESSAGE_KEYS.emailRegistration]: {
|
|
2599
|
+
subject: "Confirm your account",
|
|
2600
|
+
html: `<h1>Welcome aboard</h1>
|
|
2601
|
+
<p>Hi {{firstName}},</p>
|
|
2602
|
+
<p>Thanks for signing up. Confirm your account with this code:</p>
|
|
2603
|
+
<p><span class="pin-code">{{pin}}</span></p>
|
|
2604
|
+
<p class="muted">This code expires in 24 hours. If you didn’t create an account, you can safely ignore this email.</p>`,
|
|
2605
|
+
text: `Welcome aboard
|
|
2606
|
+
|
|
2607
|
+
Hi {{firstName}},
|
|
2608
|
+
|
|
2609
|
+
Thanks for signing up. Confirm your account with this code: {{pin}}
|
|
2610
|
+
|
|
2611
|
+
This code expires in 24 hours. If you didn't create an account, you can safely ignore this email.`
|
|
2612
|
+
},
|
|
2613
|
+
[MESSAGE_KEYS.emailVerification]: {
|
|
2614
|
+
subject: "Your verification code",
|
|
2615
|
+
html: `<h1>Verify your email</h1>
|
|
2616
|
+
<p>Hi {{firstName}},</p>
|
|
2617
|
+
<p>Use this code to finish setting up your account:</p>
|
|
2618
|
+
<p><span class="pin-code">{{pin}}</span></p>
|
|
2619
|
+
<p class="muted">This code expires in 24 hours. If you didn’t create an account, you can safely ignore this email.</p>`,
|
|
2620
|
+
text: `Verify your email
|
|
2621
|
+
|
|
2622
|
+
Hi {{firstName}},
|
|
2623
|
+
|
|
2624
|
+
Use this code to finish setting up your account: {{pin}}
|
|
2625
|
+
|
|
2626
|
+
This code expires in 24 hours. If you didn't create an account, you can safely ignore this email.`
|
|
2627
|
+
},
|
|
2628
|
+
[MESSAGE_KEYS.passwordReset]: {
|
|
2629
|
+
subject: "Reset your password",
|
|
2630
|
+
html: `<h1>Reset your password</h1>
|
|
2631
|
+
<p>We received a request to reset your password. Use this code to continue:</p>
|
|
2632
|
+
<p><span class="pin-code">{{pin}}</span></p>
|
|
2633
|
+
<p class="muted">This code expires soon. If you didn’t request a reset, no action is needed — your password stays the same.</p>`,
|
|
2634
|
+
text: `Reset your password
|
|
2635
|
+
|
|
2636
|
+
We received a request to reset your password. Use this code to continue: {{pin}}
|
|
2637
|
+
|
|
2638
|
+
This code expires soon. If you didn't request a reset, no action is needed \u2014 your password stays the same.`
|
|
2639
|
+
},
|
|
2640
|
+
// SMS — text only, no subject/html.
|
|
2641
|
+
[MESSAGE_KEYS.phoneOtp]: {
|
|
2642
|
+
text: "{{otp}} is your verification code. It expires in 10 minutes."
|
|
2643
|
+
},
|
|
2644
|
+
[MESSAGE_KEYS.mfaEnabled]: {
|
|
2645
|
+
subject: "Two-factor authentication is on",
|
|
2646
|
+
html: `<h1>Two-factor authentication enabled</h1>
|
|
2647
|
+
<p>Two-factor authentication was just turned on for your account. From now on you’ll enter a code from your authenticator app when you sign in.</p>
|
|
2648
|
+
<p class="muted">If you didn’t do this, contact support right away — someone may have access to your account.</p>`,
|
|
2649
|
+
text: `Two-factor authentication enabled
|
|
2650
|
+
|
|
2651
|
+
Two-factor authentication was just turned on for your account. From now on you'll enter a code from your authenticator app when you sign in.
|
|
2652
|
+
|
|
2653
|
+
If you didn't do this, contact support right away \u2014 someone may have access to your account.`
|
|
2654
|
+
},
|
|
2655
|
+
[MESSAGE_KEYS.mfaDisabled]: {
|
|
2656
|
+
subject: "Two-factor authentication is off",
|
|
2657
|
+
html: `<h1>Two-factor authentication disabled</h1>
|
|
2658
|
+
<p>Two-factor authentication was just turned off for your account. Your account is now protected by your password alone.</p>
|
|
2659
|
+
<p class="muted">If you didn’t do this, contact support right away and re-enable two-factor authentication — someone may have access to your account.</p>`,
|
|
2660
|
+
text: `Two-factor authentication disabled
|
|
2661
|
+
|
|
2662
|
+
Two-factor authentication was just turned off for your account. Your account is now protected by your password alone.
|
|
2663
|
+
|
|
2664
|
+
If you didn't do this, contact support right away and re-enable two-factor authentication \u2014 someone may have access to your account.`
|
|
2665
|
+
},
|
|
2666
|
+
[MESSAGE_KEYS.mfaBackupCodesRegenerated]: {
|
|
2667
|
+
subject: "Your backup codes were regenerated",
|
|
2668
|
+
html: `<h1>New backup codes generated</h1>
|
|
2669
|
+
<p>A new set of two-factor backup codes was just generated for your account. Your previous backup codes no longer work.</p>
|
|
2670
|
+
<p class="muted">If you didn’t do this, contact support right away — someone may have access to your account.</p>`,
|
|
2671
|
+
text: `New backup codes generated
|
|
2672
|
+
|
|
2673
|
+
A new set of two-factor backup codes was just generated for your account. Your previous backup codes no longer work.
|
|
2674
|
+
|
|
2675
|
+
If you didn't do this, contact support right away \u2014 someone may have access to your account.`
|
|
2676
|
+
},
|
|
2677
|
+
[MESSAGE_KEYS.emailChanged]: {
|
|
2678
|
+
subject: "Your email address was changed",
|
|
2679
|
+
html: `<h1>Your email was changed</h1>
|
|
2680
|
+
<p>The email address on your account was just changed to <strong>{{newEmail}}</strong>.</p>
|
|
2681
|
+
<p class="muted">If you made this change, you’re all set. If not, contact support right away — someone may have access to your account.</p>`,
|
|
2682
|
+
text: `Your email was changed
|
|
2683
|
+
|
|
2684
|
+
The email address on your account was just changed to {{newEmail}}.
|
|
2685
|
+
|
|
2686
|
+
If you made this change, you're all set. If not, contact support right away \u2014 someone may have access to your account.`
|
|
2687
|
+
},
|
|
2688
|
+
[MESSAGE_KEYS.phoneChanged]: {
|
|
2689
|
+
subject: "Your phone number was changed",
|
|
2690
|
+
html: `<h1>Your phone number was changed</h1>
|
|
2691
|
+
<p>The phone number on your account was just updated.</p>
|
|
2692
|
+
<p class="muted">If you made this change, you’re all set. If not, contact support right away — someone may have access to your account.</p>`,
|
|
2693
|
+
text: `Your phone number was changed
|
|
2694
|
+
|
|
2695
|
+
The phone number on your account was just updated.
|
|
2696
|
+
|
|
2697
|
+
If you made this change, you're all set. If not, contact support right away \u2014 someone may have access to your account.`
|
|
2698
|
+
}
|
|
2699
|
+
};
|
|
2700
|
+
var SAMPLE_PAYLOADS = {
|
|
2701
|
+
[MESSAGE_KEYS.emailRegistration]: { firstName: "", pin: "123456" },
|
|
2702
|
+
[MESSAGE_KEYS.emailVerification]: { firstName: "Ada", pin: "123456" },
|
|
2703
|
+
[MESSAGE_KEYS.passwordReset]: { pin: "123456" },
|
|
2704
|
+
[MESSAGE_KEYS.phoneOtp]: { otp: "123456" },
|
|
2705
|
+
[MESSAGE_KEYS.mfaEnabled]: {},
|
|
2706
|
+
[MESSAGE_KEYS.mfaDisabled]: {},
|
|
2707
|
+
[MESSAGE_KEYS.mfaBackupCodesRegenerated]: {},
|
|
2708
|
+
[MESSAGE_KEYS.emailChanged]: { newEmail: "new@example.com" },
|
|
2709
|
+
[MESSAGE_KEYS.phoneChanged]: {}
|
|
2710
|
+
};
|
|
2711
|
+
|
|
2299
2712
|
// src/middlewares/require-auth.ts
|
|
2300
2713
|
var import_middlewares3 = require("@fonderie/core/middlewares");
|
|
2301
2714
|
|
|
@@ -2369,6 +2782,7 @@ function startUserRetention(store, options) {
|
|
|
2369
2782
|
0 && (module.exports = {
|
|
2370
2783
|
AUTH_CONFIG_KEYS,
|
|
2371
2784
|
AuthModule,
|
|
2785
|
+
DEFAULT_TEMPLATES,
|
|
2372
2786
|
MESSAGE_KEYS,
|
|
2373
2787
|
buildAuthAccountLimiter,
|
|
2374
2788
|
buildAuthIpLimiter,
|