@fonderie/auth 5.1.0 → 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 +24 -0
- package/dist/index.cjs +358 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -3
- package/dist/index.d.ts +25 -3
- package/dist/index.js +309 -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
|
@@ -731,17 +731,23 @@ var SessionModel = class {
|
|
|
731
731
|
this.store = store;
|
|
732
732
|
}
|
|
733
733
|
store;
|
|
734
|
-
async create(userId, token, expiresAt, sid) {
|
|
734
|
+
async create(userId, token, expiresAt, sid, meta) {
|
|
735
735
|
await this.store.query(
|
|
736
|
-
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid)
|
|
737
|
-
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)
|
|
738
738
|
ON CONFLICT (token) DO NOTHING`,
|
|
739
|
-
[userId, token, expiresAt, sid ?? null]
|
|
739
|
+
[userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
|
|
740
740
|
);
|
|
741
741
|
}
|
|
742
742
|
async delete(token) {
|
|
743
743
|
await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
|
|
744
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
|
+
}
|
|
745
751
|
// Revoke every session for a user (e.g. on password change). Access tokens
|
|
746
752
|
// bound to these sessions via the sid claim die on their next request.
|
|
747
753
|
async deleteByUser(userId) {
|
|
@@ -761,6 +767,42 @@ var SessionModel = class {
|
|
|
761
767
|
[userId]
|
|
762
768
|
);
|
|
763
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
|
+
}
|
|
764
806
|
async exists(token) {
|
|
765
807
|
const rows = await this.store.query(
|
|
766
808
|
`SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
|
|
@@ -813,11 +855,92 @@ var BackupCodeModel = class {
|
|
|
813
855
|
}
|
|
814
856
|
};
|
|
815
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
|
+
|
|
816
938
|
// src/controllers/mfa.controller.ts
|
|
817
939
|
function mfaController(store, config, issuer, bus) {
|
|
818
940
|
const users = new UserModel(store);
|
|
819
941
|
const sessions = new SessionModel(store);
|
|
820
942
|
const backupCodes = new BackupCodeModel(store);
|
|
943
|
+
const loginEvents = new LoginEventModel(store);
|
|
821
944
|
const mfaCipher = makeMfaCipher(config.mfaSecretKey);
|
|
822
945
|
return {
|
|
823
946
|
// ── 1. Setup ───────────────────────────────────────────────
|
|
@@ -847,6 +970,16 @@ function mfaController(store, config, issuer, bus) {
|
|
|
847
970
|
verify: async (ctx) => {
|
|
848
971
|
const body = ctx.meta["body"];
|
|
849
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
|
+
});
|
|
850
983
|
if (typeof token !== "string") {
|
|
851
984
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
|
|
852
985
|
}
|
|
@@ -887,6 +1020,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
887
1020
|
);
|
|
888
1021
|
const matched = checks.find((r) => r.match);
|
|
889
1022
|
if (!matched) {
|
|
1023
|
+
if (isLoginCompletion) failedMfa();
|
|
890
1024
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
|
|
891
1025
|
}
|
|
892
1026
|
await backupCodes.consume(matched.id);
|
|
@@ -904,6 +1038,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
904
1038
|
}
|
|
905
1039
|
const secret = mfaCipher.decrypt(storedSecret);
|
|
906
1040
|
if (!verifyTotpToken(token, secret)) {
|
|
1041
|
+
if (isLoginCompletion) failedMfa();
|
|
907
1042
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
|
|
908
1043
|
}
|
|
909
1044
|
if (!ctx.user.mfaEnabled) {
|
|
@@ -914,7 +1049,16 @@ function mfaController(store, config, issuer, bus) {
|
|
|
914
1049
|
loginMethod: ctx.user.loginMethod,
|
|
915
1050
|
phoneVerified: ctx.user.phoneVerified
|
|
916
1051
|
});
|
|
917
|
-
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
|
+
}
|
|
918
1062
|
const fullUser = await users.findById(ctx.user.id);
|
|
919
1063
|
if (!fullUser) {
|
|
920
1064
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.SERVER_ERROR, "SERVER_ERROR", "User not found after MFA verify");
|
|
@@ -1183,6 +1327,7 @@ function extractRefreshToken(ctx) {
|
|
|
1183
1327
|
function authController(store, config, bus) {
|
|
1184
1328
|
const users = new UserModel(store);
|
|
1185
1329
|
const sessions = new SessionModel(store);
|
|
1330
|
+
const loginEvents = new LoginEventModel(store);
|
|
1186
1331
|
const passwordReset = new PasswordResetModel(store);
|
|
1187
1332
|
const emailVerif = new EmailVerificationModel(store);
|
|
1188
1333
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
@@ -1252,7 +1397,7 @@ function authController(store, config, bus) {
|
|
|
1252
1397
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1253
1398
|
loginMethod: "email"
|
|
1254
1399
|
});
|
|
1255
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1400
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1256
1401
|
const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
|
|
1257
1402
|
const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
|
|
1258
1403
|
return Response.json(
|
|
@@ -1316,7 +1461,7 @@ function authController(store, config, bus) {
|
|
|
1316
1461
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1317
1462
|
loginMethod: "phone"
|
|
1318
1463
|
});
|
|
1319
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1464
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1320
1465
|
return Response.json(
|
|
1321
1466
|
{
|
|
1322
1467
|
reason: "USER_PHONE_REGISTERED",
|
|
@@ -1340,6 +1485,7 @@ function authController(store, config, bus) {
|
|
|
1340
1485
|
},
|
|
1341
1486
|
login: async (ctx) => {
|
|
1342
1487
|
const body = ctx.meta["body"];
|
|
1488
|
+
const meta = requestMeta(ctx);
|
|
1343
1489
|
if (typeof body?.["email"] === "string" && typeof body?.["password"] === "string") {
|
|
1344
1490
|
const { email: rawEmail, password: password2 } = body;
|
|
1345
1491
|
const email2 = normalizeEmailSafe(rawEmail);
|
|
@@ -1348,6 +1494,14 @@ function authController(store, config, bus) {
|
|
|
1348
1494
|
}
|
|
1349
1495
|
const user = await users.findByEmail(email2);
|
|
1350
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
|
+
});
|
|
1351
1505
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1352
1506
|
}
|
|
1353
1507
|
const { valid, needsRehash } = await verifyPasswordForLogin(
|
|
@@ -1356,6 +1510,14 @@ function authController(store, config, bus) {
|
|
|
1356
1510
|
config.legacyVerify
|
|
1357
1511
|
);
|
|
1358
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
|
+
});
|
|
1359
1521
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1360
1522
|
}
|
|
1361
1523
|
if (needsRehash) {
|
|
@@ -1377,7 +1539,14 @@ function authController(store, config, bus) {
|
|
|
1377
1539
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1378
1540
|
loginMethod: "email"
|
|
1379
1541
|
});
|
|
1380
|
-
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
|
+
});
|
|
1381
1550
|
const resolvedLogin = { ...config, ...config.resolve?.(ctx) };
|
|
1382
1551
|
const requiresVerification = !!resolvedLogin.requireVerification && !user.emailVerifiedAt;
|
|
1383
1552
|
return Response.json(
|
|
@@ -1422,7 +1591,7 @@ function authController(store, config, bus) {
|
|
|
1422
1591
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1423
1592
|
loginMethod: "phone"
|
|
1424
1593
|
});
|
|
1425
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1594
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1426
1595
|
return Response.json(
|
|
1427
1596
|
{
|
|
1428
1597
|
reason: "USER_PHONE_OTP_SENT",
|
|
@@ -1445,6 +1614,10 @@ function authController(store, config, bus) {
|
|
|
1445
1614
|
);
|
|
1446
1615
|
},
|
|
1447
1616
|
logout: async (ctx) => {
|
|
1617
|
+
const sid = ctx.user?.sid;
|
|
1618
|
+
if (sid) {
|
|
1619
|
+
await sessions.deleteBySid(sid).catch(() => void 0);
|
|
1620
|
+
}
|
|
1448
1621
|
const token = extractRefreshToken(ctx);
|
|
1449
1622
|
if (token) {
|
|
1450
1623
|
await sessions.delete(token).catch(() => void 0);
|
|
@@ -1483,7 +1656,7 @@ function authController(store, config, bus) {
|
|
|
1483
1656
|
loginMethod: payload.loginMethod ?? "email",
|
|
1484
1657
|
phoneVerified: payload.phoneVerified ?? false
|
|
1485
1658
|
});
|
|
1486
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1659
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1487
1660
|
return Response.json(
|
|
1488
1661
|
{
|
|
1489
1662
|
reason: "TOKENS_REFRESHED",
|
|
@@ -1617,7 +1790,7 @@ function authController(store, config, bus) {
|
|
|
1617
1790
|
loginMethod: "phone",
|
|
1618
1791
|
phoneVerified: true
|
|
1619
1792
|
});
|
|
1620
|
-
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1793
|
+
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1621
1794
|
const verifiedUser = await users.findById(ctx.user.id);
|
|
1622
1795
|
if (!verifiedUser) {
|
|
1623
1796
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
@@ -1750,8 +1923,40 @@ function authController(store, config, bus) {
|
|
|
1750
1923
|
|
|
1751
1924
|
// src/controllers/user.controller.ts
|
|
1752
1925
|
var import_node_crypto5 = require("crypto");
|
|
1753
|
-
var
|
|
1926
|
+
var import_core7 = require("@fonderie/core");
|
|
1754
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
|
|
1755
1960
|
function normalizePhone2(phone2) {
|
|
1756
1961
|
return phone2.trim().replace(/[\s()\-\.]/g, "");
|
|
1757
1962
|
}
|
|
@@ -1761,18 +1966,82 @@ function isValidPhone2(phone2) {
|
|
|
1761
1966
|
function userController(store, config, bus) {
|
|
1762
1967
|
const users = new UserModel(store);
|
|
1763
1968
|
const sessions = new SessionModel(store);
|
|
1969
|
+
const loginEvents = new LoginEventModel(store);
|
|
1764
1970
|
const emailVerif = new EmailVerificationModel(store);
|
|
1765
1971
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
1766
1972
|
return {
|
|
1767
1973
|
me: async (ctx) => {
|
|
1768
1974
|
const user = await users.findById(ctx.user.id);
|
|
1769
1975
|
if (!user) {
|
|
1770
|
-
return (0,
|
|
1976
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1771
1977
|
}
|
|
1772
|
-
return (0,
|
|
1978
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
|
|
1773
1979
|
user: toUserDTO(user, ctx.user.phoneVerified)
|
|
1774
1980
|
});
|
|
1775
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
|
+
},
|
|
1776
2045
|
updateProfile: async (ctx) => {
|
|
1777
2046
|
const body = ctx.meta["body"];
|
|
1778
2047
|
const allowed = ["firstName", "lastName", "avatarUrl"];
|
|
@@ -1781,18 +2050,18 @@ function userController(store, config, bus) {
|
|
|
1781
2050
|
if (body?.[key] !== void 0) fields[key] = body[key];
|
|
1782
2051
|
}
|
|
1783
2052
|
if (Object.keys(fields).length === 0) {
|
|
1784
|
-
return (0,
|
|
1785
|
-
|
|
2053
|
+
return (0, import_core7.setApiResponse)(
|
|
2054
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1786
2055
|
"INVALID_PARAMETER",
|
|
1787
2056
|
"Provide at least one of: firstName, lastName, avatarUrl"
|
|
1788
2057
|
);
|
|
1789
2058
|
}
|
|
1790
2059
|
const row = await users.update(ctx.user.id, fields);
|
|
1791
2060
|
if (!row) {
|
|
1792
|
-
return (0,
|
|
2061
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1793
2062
|
}
|
|
1794
2063
|
const updated = await users.findById(ctx.user.id);
|
|
1795
|
-
return (0,
|
|
2064
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
|
|
1796
2065
|
user: toUserDTO(updated, ctx.user.phoneVerified)
|
|
1797
2066
|
});
|
|
1798
2067
|
},
|
|
@@ -1809,14 +2078,14 @@ function userController(store, config, bus) {
|
|
|
1809
2078
|
if (Object.keys(patch).length > 0) fields.patch = patch;
|
|
1810
2079
|
const row = await users.updatePreferences(ctx.user.id, fields);
|
|
1811
2080
|
if (!row) {
|
|
1812
|
-
return (0,
|
|
1813
|
-
|
|
2081
|
+
return (0, import_core7.setApiResponse)(
|
|
2082
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1814
2083
|
"INVALID_PARAMETER",
|
|
1815
2084
|
"Provide at least one preference field"
|
|
1816
2085
|
);
|
|
1817
2086
|
}
|
|
1818
2087
|
const updated = await users.findById(ctx.user.id);
|
|
1819
|
-
return (0,
|
|
2088
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
|
|
1820
2089
|
user: toUserDTO(updated, ctx.user.phoneVerified)
|
|
1821
2090
|
});
|
|
1822
2091
|
},
|
|
@@ -1825,23 +2094,23 @@ function userController(store, config, bus) {
|
|
|
1825
2094
|
const newEmail = body?.["email"];
|
|
1826
2095
|
const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
|
|
1827
2096
|
if (!normalised) {
|
|
1828
|
-
return (0,
|
|
1829
|
-
|
|
2097
|
+
return (0, import_core7.setApiResponse)(
|
|
2098
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1830
2099
|
"INVALID_PARAMETER",
|
|
1831
2100
|
"A valid email address is required"
|
|
1832
2101
|
);
|
|
1833
2102
|
}
|
|
1834
2103
|
const oldEmail = ctx.user.email;
|
|
1835
2104
|
if (normalised === oldEmail) {
|
|
1836
|
-
return (0,
|
|
1837
|
-
|
|
2105
|
+
return (0, import_core7.setApiResponse)(
|
|
2106
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1838
2107
|
"INVALID_PARAMETER",
|
|
1839
2108
|
"New email must differ from current email"
|
|
1840
2109
|
);
|
|
1841
2110
|
}
|
|
1842
2111
|
const existing = await users.findByEmail(normalised);
|
|
1843
2112
|
if (existing) {
|
|
1844
|
-
return (0,
|
|
2113
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
|
|
1845
2114
|
}
|
|
1846
2115
|
const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1847
2116
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
@@ -1863,8 +2132,8 @@ function userController(store, config, bus) {
|
|
|
1863
2132
|
}).catch(() => {
|
|
1864
2133
|
});
|
|
1865
2134
|
}
|
|
1866
|
-
return (0,
|
|
1867
|
-
|
|
2135
|
+
return (0, import_core7.setApiResponse)(
|
|
2136
|
+
import_core7.HTTP.OK,
|
|
1868
2137
|
"EMAIL_UPDATED",
|
|
1869
2138
|
"Email updated. A verification code has been sent to your new address.",
|
|
1870
2139
|
{
|
|
@@ -1876,8 +2145,8 @@ function userController(store, config, bus) {
|
|
|
1876
2145
|
const body = ctx.meta["body"];
|
|
1877
2146
|
const newPhone = body?.["phone"];
|
|
1878
2147
|
if (!isValidPhone2(newPhone)) {
|
|
1879
|
-
return (0,
|
|
1880
|
-
|
|
2148
|
+
return (0, import_core7.setApiResponse)(
|
|
2149
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1881
2150
|
"INVALID_PARAMETER",
|
|
1882
2151
|
"A valid phone number is required"
|
|
1883
2152
|
);
|
|
@@ -1885,7 +2154,7 @@ function userController(store, config, bus) {
|
|
|
1885
2154
|
const normalised = normalizePhone2(newPhone);
|
|
1886
2155
|
const existing = await users.findByPhone(normalised);
|
|
1887
2156
|
if (existing) {
|
|
1888
|
-
return (0,
|
|
2157
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
|
|
1889
2158
|
}
|
|
1890
2159
|
const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1891
2160
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
|
|
@@ -1907,8 +2176,8 @@ function userController(store, config, bus) {
|
|
|
1907
2176
|
}).catch(() => {
|
|
1908
2177
|
});
|
|
1909
2178
|
}
|
|
1910
|
-
return (0,
|
|
1911
|
-
|
|
2179
|
+
return (0, import_core7.setApiResponse)(
|
|
2180
|
+
import_core7.HTTP.OK,
|
|
1912
2181
|
"PHONE_UPDATED",
|
|
1913
2182
|
"Phone number updated. A verification code has been sent to your new number.",
|
|
1914
2183
|
{
|
|
@@ -1921,22 +2190,22 @@ function userController(store, config, bus) {
|
|
|
1921
2190
|
const currentPassword = body?.["currentPassword"];
|
|
1922
2191
|
const newPassword = body?.["newPassword"];
|
|
1923
2192
|
if (typeof currentPassword !== "string" || !currentPassword) {
|
|
1924
|
-
return (0,
|
|
2193
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
|
|
1925
2194
|
}
|
|
1926
2195
|
if (typeof newPassword !== "string" || newPassword.length < 8) {
|
|
1927
|
-
return (0,
|
|
2196
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
|
|
1928
2197
|
}
|
|
1929
2198
|
const user = await users.findById(ctx.user.id);
|
|
1930
|
-
if (!user) return (0,
|
|
2199
|
+
if (!user) return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1931
2200
|
const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
|
|
1932
2201
|
const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
|
|
1933
2202
|
if (!valid) {
|
|
1934
|
-
return (0,
|
|
2203
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
|
|
1935
2204
|
}
|
|
1936
2205
|
const hash = await hashPassword2(newPassword);
|
|
1937
2206
|
await users.updatePassword(ctx.user.id, hash);
|
|
1938
2207
|
await sessions.deleteByUser(ctx.user.id);
|
|
1939
|
-
return (0,
|
|
2208
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
|
|
1940
2209
|
},
|
|
1941
2210
|
deleteMe: async (ctx) => {
|
|
1942
2211
|
const userId = ctx.user.id;
|
|
@@ -1961,7 +2230,7 @@ function userController(store, config, bus) {
|
|
|
1961
2230
|
// no MFA secret, no session tokens (session metadata only).
|
|
1962
2231
|
exportMe: async (ctx) => {
|
|
1963
2232
|
const user = await users.findById(ctx.user.id);
|
|
1964
|
-
if (!user) return (0,
|
|
2233
|
+
if (!user) return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1965
2234
|
const sessionRows = await sessions.listByUser(ctx.user.id);
|
|
1966
2235
|
const bundle = {
|
|
1967
2236
|
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -1970,8 +2239,8 @@ function userController(store, config, bus) {
|
|
|
1970
2239
|
id: s.id,
|
|
1971
2240
|
userAgent: s.userAgent,
|
|
1972
2241
|
ipAddress: s.ipAddress,
|
|
1973
|
-
createdAt: (0,
|
|
1974
|
-
expiresAt: (0,
|
|
2242
|
+
createdAt: (0, import_core7.dateOrEmpty)(s.createdAt),
|
|
2243
|
+
expiresAt: (0, import_core7.dateOrEmpty)(s.expiresAt)
|
|
1975
2244
|
})),
|
|
1976
2245
|
security: {
|
|
1977
2246
|
mfaEnabled: user.mfaEnabled === true,
|
|
@@ -1990,22 +2259,23 @@ function userController(store, config, bus) {
|
|
|
1990
2259
|
}
|
|
1991
2260
|
bundle["modules"] = modules;
|
|
1992
2261
|
}
|
|
1993
|
-
return (0,
|
|
2262
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
|
|
1994
2263
|
}
|
|
1995
2264
|
};
|
|
1996
2265
|
}
|
|
1997
2266
|
|
|
1998
2267
|
// src/controllers/oauth.controller.ts
|
|
1999
|
-
var
|
|
2268
|
+
var import_core8 = require("@fonderie/core");
|
|
2000
2269
|
function oauthController(store, config) {
|
|
2001
2270
|
const users = new UserModel(store);
|
|
2002
2271
|
const sessions = new SessionModel(store);
|
|
2272
|
+
const loginEvents = new LoginEventModel(store);
|
|
2003
2273
|
return {
|
|
2004
2274
|
googleInit: async (_ctx) => {
|
|
2005
2275
|
const google = config.google;
|
|
2006
2276
|
if (!google) {
|
|
2007
|
-
return (0,
|
|
2008
|
-
|
|
2277
|
+
return (0, import_core8.setApiResponse)(
|
|
2278
|
+
import_core8.HTTP.NOT_IMPLEMENTED,
|
|
2009
2279
|
"NOT_CONFIGURED",
|
|
2010
2280
|
"Google OAuth not configured"
|
|
2011
2281
|
);
|
|
@@ -2017,8 +2287,8 @@ function oauthController(store, config) {
|
|
|
2017
2287
|
scope: "openid email profile"
|
|
2018
2288
|
});
|
|
2019
2289
|
const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
2020
|
-
return (0,
|
|
2021
|
-
|
|
2290
|
+
return (0, import_core8.setApiResponse)(
|
|
2291
|
+
import_core8.HTTP.OK,
|
|
2022
2292
|
"GOOGLE_AUTH_URL",
|
|
2023
2293
|
"Redirect the user to the returned URL to begin Google OAuth.",
|
|
2024
2294
|
{ url }
|
|
@@ -2027,8 +2297,8 @@ function oauthController(store, config) {
|
|
|
2027
2297
|
googleCallback: async (ctx) => {
|
|
2028
2298
|
const google = config.google;
|
|
2029
2299
|
if (!google) {
|
|
2030
|
-
return (0,
|
|
2031
|
-
|
|
2300
|
+
return (0, import_core8.setApiResponse)(
|
|
2301
|
+
import_core8.HTTP.NOT_IMPLEMENTED,
|
|
2032
2302
|
"NOT_CONFIGURED",
|
|
2033
2303
|
"Google OAuth not configured"
|
|
2034
2304
|
);
|
|
@@ -2036,7 +2306,7 @@ function oauthController(store, config) {
|
|
|
2036
2306
|
const url = new URL(ctx.request.url);
|
|
2037
2307
|
const code = url.searchParams.get("code");
|
|
2038
2308
|
if (!code) {
|
|
2039
|
-
return (0,
|
|
2309
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
|
|
2040
2310
|
}
|
|
2041
2311
|
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
|
2042
2312
|
method: "POST",
|
|
@@ -2051,8 +2321,8 @@ function oauthController(store, config) {
|
|
|
2051
2321
|
});
|
|
2052
2322
|
const tokenData = await tokenRes.json();
|
|
2053
2323
|
if (!tokenData.id_token) {
|
|
2054
|
-
return (0,
|
|
2055
|
-
|
|
2324
|
+
return (0, import_core8.setApiResponse)(
|
|
2325
|
+
import_core8.HTTP.BAD_REQUEST,
|
|
2056
2326
|
"GOOGLE_AUTH_FAILED",
|
|
2057
2327
|
"OAuth token exchange failed"
|
|
2058
2328
|
);
|
|
@@ -2061,24 +2331,40 @@ function oauthController(store, config) {
|
|
|
2061
2331
|
Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
|
|
2062
2332
|
);
|
|
2063
2333
|
if (!payload.email) {
|
|
2064
|
-
return (0,
|
|
2334
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
|
|
2065
2335
|
}
|
|
2066
2336
|
const normalizedEmail = normalizeEmailSafe(payload.email);
|
|
2067
2337
|
if (!normalizedEmail) {
|
|
2068
|
-
return (0,
|
|
2338
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
|
|
2069
2339
|
}
|
|
2340
|
+
const meta = requestMeta(ctx);
|
|
2070
2341
|
const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
|
|
2071
2342
|
if (!upserted) {
|
|
2072
|
-
|
|
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");
|
|
2073
2352
|
}
|
|
2074
2353
|
const fullUser = await users.findById(upserted.id);
|
|
2075
2354
|
if (!fullUser) {
|
|
2076
|
-
return (0,
|
|
2355
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
2077
2356
|
}
|
|
2078
2357
|
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
|
|
2079
2358
|
loginMethod: "google"
|
|
2080
2359
|
});
|
|
2081
|
-
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
|
+
});
|
|
2082
2368
|
return Response.json(
|
|
2083
2369
|
{
|
|
2084
2370
|
reason: "GOOGLE_AUTH_SUCCESS",
|
|
@@ -2136,6 +2422,13 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2136
2422
|
R("changePassword", "PUT", "/users/password", import_middlewares2.requireAuth, (0, import_middlewares.validate)(changePasswordSchema), user.changePassword),
|
|
2137
2423
|
R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
|
|
2138
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),
|
|
2139
2432
|
// MFA (email sessions only — requireVerified is always enforced here
|
|
2140
2433
|
// because MFA is a security feature and email verification is meaningful)
|
|
2141
2434
|
R("mfaSetup", "POST", "/auth/mfa/setup", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, mfa.setup),
|
|
@@ -2155,17 +2448,17 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2155
2448
|
}
|
|
2156
2449
|
|
|
2157
2450
|
// src/services/config-guard.ts
|
|
2158
|
-
var
|
|
2451
|
+
var import_core9 = require("@fonderie/core");
|
|
2159
2452
|
var MODULE = "@fonderie/auth";
|
|
2160
2453
|
function collectAuthConfigProblems(config) {
|
|
2161
2454
|
const problems = [];
|
|
2162
2455
|
const secret = config.jwtSecret ?? "";
|
|
2163
|
-
const secretProblem = (0,
|
|
2456
|
+
const secretProblem = (0, import_core9.secretStrengthProblem)(secret);
|
|
2164
2457
|
if (secretProblem === "too-short") {
|
|
2165
2458
|
problems.push({
|
|
2166
2459
|
module: MODULE,
|
|
2167
2460
|
severity: "error",
|
|
2168
|
-
message: `jwtSecret must be at least ${
|
|
2461
|
+
message: `jwtSecret must be at least ${import_core9.MIN_SECRET_LENGTH} characters (got ${secret.length})`
|
|
2169
2462
|
});
|
|
2170
2463
|
} else if (secretProblem === "placeholder") {
|
|
2171
2464
|
problems.push({
|
|
@@ -2189,7 +2482,7 @@ function collectAuthConfigProblems(config) {
|
|
|
2189
2482
|
severity: "error",
|
|
2190
2483
|
message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing"
|
|
2191
2484
|
});
|
|
2192
|
-
} else if (
|
|
2485
|
+
} else if (import_core9.PLACEHOLDER_SECRET.test(clientSecret)) {
|
|
2193
2486
|
problems.push({
|
|
2194
2487
|
module: MODULE,
|
|
2195
2488
|
severity: "error",
|
|
@@ -2256,7 +2549,10 @@ function withSession(store, config) {
|
|
|
2256
2549
|
...user,
|
|
2257
2550
|
loginMethod: payload.loginMethod ?? "email",
|
|
2258
2551
|
phoneVerified: payload.phoneVerified ?? false,
|
|
2259
|
-
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
|
|
2260
2556
|
}
|
|
2261
2557
|
});
|
|
2262
2558
|
return next();
|