@fonderie/auth 5.1.0 → 5.3.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 +19 -1
- package/brain/signatures.md +25 -1
- package/dist/index.cjs +365 -63
- 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 +316 -14
- 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-jITwnc3m.d.cts} +2 -2
- package/dist/{session-CTSCI09F.d.ts → session-jITwnc3m.d.ts} +2 -2
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -126,6 +126,12 @@ var DEFAULTS = {
|
|
|
126
126
|
account: { capacity: 3, refillPerSec: 3 / min(60) },
|
|
127
127
|
accountField: "email"
|
|
128
128
|
},
|
|
129
|
+
// reset: 10/15min per IP — the reset PIN is a 6-digit code looked up
|
|
130
|
+
// globally, so an unthrottled endpoint is brute-forceable into an account
|
|
131
|
+
// takeover. There's no email in the reset body to key an account bucket on,
|
|
132
|
+
// so this IP bucket is the guard. (Moving reset to a high-entropy opaque
|
|
133
|
+
// token is tracked as a follow-up hardening.)
|
|
134
|
+
reset: { ip: { capacity: 10, refillPerSec: 10 / min(15) } },
|
|
129
135
|
// mfaVerify: 10/15min per IP — TOTP brute-force.
|
|
130
136
|
mfaVerify: { ip: { capacity: 10, refillPerSec: 10 / min(15) } }
|
|
131
137
|
};
|
|
@@ -731,17 +737,23 @@ var SessionModel = class {
|
|
|
731
737
|
this.store = store;
|
|
732
738
|
}
|
|
733
739
|
store;
|
|
734
|
-
async create(userId, token, expiresAt, sid) {
|
|
740
|
+
async create(userId, token, expiresAt, sid, meta) {
|
|
735
741
|
await this.store.query(
|
|
736
|
-
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid)
|
|
737
|
-
VALUES ($1, $2, $3, $4)
|
|
742
|
+
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)
|
|
743
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
738
744
|
ON CONFLICT (token) DO NOTHING`,
|
|
739
|
-
[userId, token, expiresAt, sid ?? null]
|
|
745
|
+
[userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
|
|
740
746
|
);
|
|
741
747
|
}
|
|
742
748
|
async delete(token) {
|
|
743
749
|
await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
|
|
744
750
|
}
|
|
751
|
+
// Delete the session an access token is bound to (by its sid claim). Lets
|
|
752
|
+
// logout revoke the current session without the client resending the refresh
|
|
753
|
+
// token — the access token already identifies its session.
|
|
754
|
+
async deleteBySid(sid) {
|
|
755
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);
|
|
756
|
+
}
|
|
745
757
|
// Revoke every session for a user (e.g. on password change). Access tokens
|
|
746
758
|
// bound to these sessions via the sid claim die on their next request.
|
|
747
759
|
async deleteByUser(userId) {
|
|
@@ -761,6 +773,42 @@ var SessionModel = class {
|
|
|
761
773
|
[userId]
|
|
762
774
|
);
|
|
763
775
|
}
|
|
776
|
+
// Live sessions for the Active Sessions screen: unexpired only, carrying the
|
|
777
|
+
// sid so the caller can flag the current one (row.sid === the JWT's sid).
|
|
778
|
+
// No token — that never leaves the server.
|
|
779
|
+
async listLiveByUser(userId) {
|
|
780
|
+
return this.store.query(
|
|
781
|
+
`SELECT id, sid,
|
|
782
|
+
user_agent AS "userAgent",
|
|
783
|
+
ip_address AS "ipAddress",
|
|
784
|
+
created_at AS "createdAt",
|
|
785
|
+
expires_at AS "expiresAt"
|
|
786
|
+
FROM fonderie_sessions
|
|
787
|
+
WHERE user_id = $1 AND expires_at > now()
|
|
788
|
+
ORDER BY created_at DESC`,
|
|
789
|
+
[userId]
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
// Terminate one session by row id, scoped to its owner (a user can never
|
|
793
|
+
// delete another user's session). Returns whether a row was removed.
|
|
794
|
+
async terminateById(userId, id) {
|
|
795
|
+
const rows = await this.store.query(
|
|
796
|
+
`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,
|
|
797
|
+
[id, userId]
|
|
798
|
+
);
|
|
799
|
+
return rows.length > 0;
|
|
800
|
+
}
|
|
801
|
+
// Terminate all of a user's sessions except the current one (by sid).
|
|
802
|
+
// Returns the number of sessions removed.
|
|
803
|
+
async terminateOthers(userId, keepSid) {
|
|
804
|
+
const rows = await this.store.query(
|
|
805
|
+
`DELETE FROM fonderie_sessions
|
|
806
|
+
WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
|
|
807
|
+
RETURNING id`,
|
|
808
|
+
[userId, keepSid]
|
|
809
|
+
);
|
|
810
|
+
return rows.length;
|
|
811
|
+
}
|
|
764
812
|
async exists(token) {
|
|
765
813
|
const rows = await this.store.query(
|
|
766
814
|
`SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
|
|
@@ -813,11 +861,92 @@ var BackupCodeModel = class {
|
|
|
813
861
|
}
|
|
814
862
|
};
|
|
815
863
|
|
|
864
|
+
// src/models/login-event.model.ts
|
|
865
|
+
var LoginEventModel = class {
|
|
866
|
+
constructor(store) {
|
|
867
|
+
this.store = store;
|
|
868
|
+
}
|
|
869
|
+
store;
|
|
870
|
+
async record(e) {
|
|
871
|
+
await this.store.query(
|
|
872
|
+
`INSERT INTO fonderie_login_events
|
|
873
|
+
(user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent)
|
|
874
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
875
|
+
[
|
|
876
|
+
e.userId,
|
|
877
|
+
e.emailAttempted ?? null,
|
|
878
|
+
e.method,
|
|
879
|
+
e.outcome,
|
|
880
|
+
e.failureReason ?? null,
|
|
881
|
+
e.ipAddress,
|
|
882
|
+
e.userAgent
|
|
883
|
+
]
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
// Fire-and-forget variant for the login paths: recording history must never
|
|
887
|
+
// block or fail a login (same posture as the bus.emit(...).catch(() => {})
|
|
888
|
+
// notification sends).
|
|
889
|
+
recordSafe(e) {
|
|
890
|
+
void this.record(e).catch(() => {
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
// One page of a user's own login history, newest first. Keyset-paginated on
|
|
894
|
+
// (created_at, id) — mirrors audit's event list so the two log UIs share a
|
|
895
|
+
// cursor contract. The +1 over-fetch that detects a next page lives here so
|
|
896
|
+
// no outer clamp can shave it off.
|
|
897
|
+
async listByUser(query) {
|
|
898
|
+
const limit = Math.min(query.limit ?? 50, MAX_HISTORY_LIMIT);
|
|
899
|
+
const params = [query.userId];
|
|
900
|
+
const where = ["user_id = $1"];
|
|
901
|
+
if (query.outcome) {
|
|
902
|
+
params.push(query.outcome);
|
|
903
|
+
where.push(`outcome = $${params.length}`);
|
|
904
|
+
}
|
|
905
|
+
if (query.from) {
|
|
906
|
+
params.push(query.from);
|
|
907
|
+
where.push(`created_at >= $${params.length}`);
|
|
908
|
+
}
|
|
909
|
+
if (query.to) {
|
|
910
|
+
params.push(query.to);
|
|
911
|
+
where.push(`created_at <= $${params.length}`);
|
|
912
|
+
}
|
|
913
|
+
if (query.cursor) {
|
|
914
|
+
params.push(query.cursor.createdAt, query.cursor.id);
|
|
915
|
+
where.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
|
|
916
|
+
}
|
|
917
|
+
params.push(limit + 1);
|
|
918
|
+
const rows = await this.store.query(
|
|
919
|
+
`SELECT id, method, outcome, failure_reason AS "failureReason",
|
|
920
|
+
ip_address AS "ipAddress", user_agent AS "userAgent",
|
|
921
|
+
created_at AS "createdAt", created_at::text AS "createdAtRaw"
|
|
922
|
+
FROM fonderie_login_events
|
|
923
|
+
WHERE ${where.join(" AND ")}
|
|
924
|
+
ORDER BY created_at DESC, id DESC
|
|
925
|
+
LIMIT $${params.length}`,
|
|
926
|
+
params
|
|
927
|
+
);
|
|
928
|
+
return { events: rows.slice(0, limit), hasMore: rows.length > limit };
|
|
929
|
+
}
|
|
930
|
+
};
|
|
931
|
+
var MAX_HISTORY_LIMIT = 200;
|
|
932
|
+
|
|
933
|
+
// src/services/request-meta.ts
|
|
934
|
+
var UA_MAX = 512;
|
|
935
|
+
function requestMeta(ctx) {
|
|
936
|
+
const ip = ctx.meta["clientIp"];
|
|
937
|
+
const ua = ctx.request.headers.get("user-agent");
|
|
938
|
+
return {
|
|
939
|
+
ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
|
|
940
|
+
userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
816
944
|
// src/controllers/mfa.controller.ts
|
|
817
945
|
function mfaController(store, config, issuer, bus) {
|
|
818
946
|
const users = new UserModel(store);
|
|
819
947
|
const sessions = new SessionModel(store);
|
|
820
948
|
const backupCodes = new BackupCodeModel(store);
|
|
949
|
+
const loginEvents = new LoginEventModel(store);
|
|
821
950
|
const mfaCipher = makeMfaCipher(config.mfaSecretKey);
|
|
822
951
|
return {
|
|
823
952
|
// ── 1. Setup ───────────────────────────────────────────────
|
|
@@ -847,6 +976,16 @@ function mfaController(store, config, issuer, bus) {
|
|
|
847
976
|
verify: async (ctx) => {
|
|
848
977
|
const body = ctx.meta["body"];
|
|
849
978
|
const token = body?.["token"];
|
|
979
|
+
const isLoginCompletion = ctx.user.mfaPending === true;
|
|
980
|
+
const meta = requestMeta(ctx);
|
|
981
|
+
const failedMfa = () => loginEvents.recordSafe({
|
|
982
|
+
userId: ctx.user.id,
|
|
983
|
+
emailAttempted: ctx.user.email,
|
|
984
|
+
method: "mfa",
|
|
985
|
+
outcome: "failed",
|
|
986
|
+
failureReason: "invalid_code",
|
|
987
|
+
...meta
|
|
988
|
+
});
|
|
850
989
|
if (typeof token !== "string") {
|
|
851
990
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
|
|
852
991
|
}
|
|
@@ -887,6 +1026,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
887
1026
|
);
|
|
888
1027
|
const matched = checks.find((r) => r.match);
|
|
889
1028
|
if (!matched) {
|
|
1029
|
+
if (isLoginCompletion) failedMfa();
|
|
890
1030
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
|
|
891
1031
|
}
|
|
892
1032
|
await backupCodes.consume(matched.id);
|
|
@@ -904,6 +1044,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
904
1044
|
}
|
|
905
1045
|
const secret = mfaCipher.decrypt(storedSecret);
|
|
906
1046
|
if (!verifyTotpToken(token, secret)) {
|
|
1047
|
+
if (isLoginCompletion) failedMfa();
|
|
907
1048
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
|
|
908
1049
|
}
|
|
909
1050
|
if (!ctx.user.mfaEnabled) {
|
|
@@ -914,7 +1055,16 @@ function mfaController(store, config, issuer, bus) {
|
|
|
914
1055
|
loginMethod: ctx.user.loginMethod,
|
|
915
1056
|
phoneVerified: ctx.user.phoneVerified
|
|
916
1057
|
});
|
|
917
|
-
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1058
|
+
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1059
|
+
if (isLoginCompletion) {
|
|
1060
|
+
loginEvents.recordSafe({
|
|
1061
|
+
userId: ctx.user.id,
|
|
1062
|
+
emailAttempted: ctx.user.email,
|
|
1063
|
+
method: "mfa",
|
|
1064
|
+
outcome: "success",
|
|
1065
|
+
...meta
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
918
1068
|
const fullUser = await users.findById(ctx.user.id);
|
|
919
1069
|
if (!fullUser) {
|
|
920
1070
|
return (0, import_core4.setApiResponse)(import_core4.HTTP.SERVER_ERROR, "SERVER_ERROR", "User not found after MFA verify");
|
|
@@ -1183,6 +1333,7 @@ function extractRefreshToken(ctx) {
|
|
|
1183
1333
|
function authController(store, config, bus) {
|
|
1184
1334
|
const users = new UserModel(store);
|
|
1185
1335
|
const sessions = new SessionModel(store);
|
|
1336
|
+
const loginEvents = new LoginEventModel(store);
|
|
1186
1337
|
const passwordReset = new PasswordResetModel(store);
|
|
1187
1338
|
const emailVerif = new EmailVerificationModel(store);
|
|
1188
1339
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
@@ -1252,7 +1403,7 @@ function authController(store, config, bus) {
|
|
|
1252
1403
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1253
1404
|
loginMethod: "email"
|
|
1254
1405
|
});
|
|
1255
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1406
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1256
1407
|
const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
|
|
1257
1408
|
const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
|
|
1258
1409
|
return Response.json(
|
|
@@ -1316,7 +1467,7 @@ function authController(store, config, bus) {
|
|
|
1316
1467
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1317
1468
|
loginMethod: "phone"
|
|
1318
1469
|
});
|
|
1319
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1470
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1320
1471
|
return Response.json(
|
|
1321
1472
|
{
|
|
1322
1473
|
reason: "USER_PHONE_REGISTERED",
|
|
@@ -1340,6 +1491,7 @@ function authController(store, config, bus) {
|
|
|
1340
1491
|
},
|
|
1341
1492
|
login: async (ctx) => {
|
|
1342
1493
|
const body = ctx.meta["body"];
|
|
1494
|
+
const meta = requestMeta(ctx);
|
|
1343
1495
|
if (typeof body?.["email"] === "string" && typeof body?.["password"] === "string") {
|
|
1344
1496
|
const { email: rawEmail, password: password2 } = body;
|
|
1345
1497
|
const email2 = normalizeEmailSafe(rawEmail);
|
|
@@ -1348,6 +1500,14 @@ function authController(store, config, bus) {
|
|
|
1348
1500
|
}
|
|
1349
1501
|
const user = await users.findByEmail(email2);
|
|
1350
1502
|
if (!user || !user.passwordHash) {
|
|
1503
|
+
loginEvents.recordSafe({
|
|
1504
|
+
userId: null,
|
|
1505
|
+
emailAttempted: email2,
|
|
1506
|
+
method: "password",
|
|
1507
|
+
outcome: "failed",
|
|
1508
|
+
failureReason: "unknown_email",
|
|
1509
|
+
...meta
|
|
1510
|
+
});
|
|
1351
1511
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1352
1512
|
}
|
|
1353
1513
|
const { valid, needsRehash } = await verifyPasswordForLogin(
|
|
@@ -1356,6 +1516,14 @@ function authController(store, config, bus) {
|
|
|
1356
1516
|
config.legacyVerify
|
|
1357
1517
|
);
|
|
1358
1518
|
if (!valid) {
|
|
1519
|
+
loginEvents.recordSafe({
|
|
1520
|
+
userId: user.id,
|
|
1521
|
+
emailAttempted: email2,
|
|
1522
|
+
method: "password",
|
|
1523
|
+
outcome: "failed",
|
|
1524
|
+
failureReason: "bad_password",
|
|
1525
|
+
...meta
|
|
1526
|
+
});
|
|
1359
1527
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1360
1528
|
}
|
|
1361
1529
|
if (needsRehash) {
|
|
@@ -1377,7 +1545,14 @@ function authController(store, config, bus) {
|
|
|
1377
1545
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1378
1546
|
loginMethod: "email"
|
|
1379
1547
|
});
|
|
1380
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1548
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1549
|
+
loginEvents.recordSafe({
|
|
1550
|
+
userId: user.id,
|
|
1551
|
+
emailAttempted: email2,
|
|
1552
|
+
method: "password",
|
|
1553
|
+
outcome: "success",
|
|
1554
|
+
...meta
|
|
1555
|
+
});
|
|
1381
1556
|
const resolvedLogin = { ...config, ...config.resolve?.(ctx) };
|
|
1382
1557
|
const requiresVerification = !!resolvedLogin.requireVerification && !user.emailVerifiedAt;
|
|
1383
1558
|
return Response.json(
|
|
@@ -1422,7 +1597,7 @@ function authController(store, config, bus) {
|
|
|
1422
1597
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1423
1598
|
loginMethod: "phone"
|
|
1424
1599
|
});
|
|
1425
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1600
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1426
1601
|
return Response.json(
|
|
1427
1602
|
{
|
|
1428
1603
|
reason: "USER_PHONE_OTP_SENT",
|
|
@@ -1445,6 +1620,10 @@ function authController(store, config, bus) {
|
|
|
1445
1620
|
);
|
|
1446
1621
|
},
|
|
1447
1622
|
logout: async (ctx) => {
|
|
1623
|
+
const sid = ctx.user?.sid;
|
|
1624
|
+
if (sid) {
|
|
1625
|
+
await sessions.deleteBySid(sid).catch(() => void 0);
|
|
1626
|
+
}
|
|
1448
1627
|
const token = extractRefreshToken(ctx);
|
|
1449
1628
|
if (token) {
|
|
1450
1629
|
await sessions.delete(token).catch(() => void 0);
|
|
@@ -1483,7 +1662,7 @@ function authController(store, config, bus) {
|
|
|
1483
1662
|
loginMethod: payload.loginMethod ?? "email",
|
|
1484
1663
|
phoneVerified: payload.phoneVerified ?? false
|
|
1485
1664
|
});
|
|
1486
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1665
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1487
1666
|
return Response.json(
|
|
1488
1667
|
{
|
|
1489
1668
|
reason: "TOKENS_REFRESHED",
|
|
@@ -1617,7 +1796,7 @@ function authController(store, config, bus) {
|
|
|
1617
1796
|
loginMethod: "phone",
|
|
1618
1797
|
phoneVerified: true
|
|
1619
1798
|
});
|
|
1620
|
-
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1799
|
+
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1621
1800
|
const verifiedUser = await users.findById(ctx.user.id);
|
|
1622
1801
|
if (!verifiedUser) {
|
|
1623
1802
|
return (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
@@ -1750,8 +1929,40 @@ function authController(store, config, bus) {
|
|
|
1750
1929
|
|
|
1751
1930
|
// src/controllers/user.controller.ts
|
|
1752
1931
|
var import_node_crypto5 = require("crypto");
|
|
1753
|
-
var
|
|
1932
|
+
var import_core7 = require("@fonderie/core");
|
|
1754
1933
|
var import_events3 = require("@fonderie/events");
|
|
1934
|
+
|
|
1935
|
+
// src/dtos/login-activity.ts
|
|
1936
|
+
var import_core6 = require("@fonderie/core");
|
|
1937
|
+
function toLoginEventDTO(row) {
|
|
1938
|
+
return {
|
|
1939
|
+
id: row.id,
|
|
1940
|
+
method: row.method,
|
|
1941
|
+
outcome: row.outcome,
|
|
1942
|
+
failureReason: row.failureReason,
|
|
1943
|
+
ipAddress: row.ipAddress,
|
|
1944
|
+
userAgent: row.userAgent,
|
|
1945
|
+
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
function toLoginHistoryPageDTO(page) {
|
|
1949
|
+
const last = page.events[page.events.length - 1];
|
|
1950
|
+
const nextCursor = page.hasMore && last ? (0, import_core6.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
|
|
1951
|
+
return { events: page.events.map(toLoginEventDTO), nextCursor };
|
|
1952
|
+
}
|
|
1953
|
+
var decodeLoginCursor = import_core6.decodeKeysetCursor;
|
|
1954
|
+
function toSessionDTO(row, currentSid) {
|
|
1955
|
+
return {
|
|
1956
|
+
id: row.id,
|
|
1957
|
+
current: currentSid !== null && row.sid === currentSid,
|
|
1958
|
+
ipAddress: row.ipAddress,
|
|
1959
|
+
userAgent: row.userAgent,
|
|
1960
|
+
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
|
1961
|
+
expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// src/controllers/user.controller.ts
|
|
1755
1966
|
function normalizePhone2(phone2) {
|
|
1756
1967
|
return phone2.trim().replace(/[\s()\-\.]/g, "");
|
|
1757
1968
|
}
|
|
@@ -1761,18 +1972,82 @@ function isValidPhone2(phone2) {
|
|
|
1761
1972
|
function userController(store, config, bus) {
|
|
1762
1973
|
const users = new UserModel(store);
|
|
1763
1974
|
const sessions = new SessionModel(store);
|
|
1975
|
+
const loginEvents = new LoginEventModel(store);
|
|
1764
1976
|
const emailVerif = new EmailVerificationModel(store);
|
|
1765
1977
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
1766
1978
|
return {
|
|
1767
1979
|
me: async (ctx) => {
|
|
1768
1980
|
const user = await users.findById(ctx.user.id);
|
|
1769
1981
|
if (!user) {
|
|
1770
|
-
return (0,
|
|
1982
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1771
1983
|
}
|
|
1772
|
-
return (0,
|
|
1984
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
|
|
1773
1985
|
user: toUserDTO(user, ctx.user.phoneVerified)
|
|
1774
1986
|
});
|
|
1775
1987
|
},
|
|
1988
|
+
// ── Login history (append-only; the caller's own attempts) ────────
|
|
1989
|
+
loginHistory: async (ctx) => {
|
|
1990
|
+
const params = new URL(ctx.request.url).searchParams;
|
|
1991
|
+
const rawLimit = Number(params.get("limit") ?? 50);
|
|
1992
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
|
|
1993
|
+
const outcomeParam = params.get("outcome");
|
|
1994
|
+
if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
|
|
1995
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
|
|
1996
|
+
}
|
|
1997
|
+
const cursorParam = params.get("cursor");
|
|
1998
|
+
let cursor = null;
|
|
1999
|
+
if (cursorParam) {
|
|
2000
|
+
cursor = decodeLoginCursor(cursorParam);
|
|
2001
|
+
if (!cursor) {
|
|
2002
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
const query = { userId: ctx.user.id, limit };
|
|
2006
|
+
if (outcomeParam) query.outcome = outcomeParam;
|
|
2007
|
+
if (cursor) query.cursor = cursor;
|
|
2008
|
+
const from = params.get("from");
|
|
2009
|
+
const to = params.get("to");
|
|
2010
|
+
if (from) query.from = new Date(from);
|
|
2011
|
+
if (to) query.to = new Date(to);
|
|
2012
|
+
const page = await loginEvents.listByUser(query);
|
|
2013
|
+
return (0, import_core7.setApiResponse)(
|
|
2014
|
+
import_core7.HTTP.OK,
|
|
2015
|
+
"LOGIN_HISTORY_FETCHED",
|
|
2016
|
+
"Login history retrieved.",
|
|
2017
|
+
toLoginHistoryPageDTO(page)
|
|
2018
|
+
);
|
|
2019
|
+
},
|
|
2020
|
+
// ── Active sessions (live only; the caller's own) ─────────────────
|
|
2021
|
+
listSessions: async (ctx) => {
|
|
2022
|
+
const rows = await sessions.listLiveByUser(ctx.user.id);
|
|
2023
|
+
const currentSid = ctx.user.sid ?? null;
|
|
2024
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
|
|
2025
|
+
sessions: rows.map((r) => toSessionDTO(r, currentSid))
|
|
2026
|
+
});
|
|
2027
|
+
},
|
|
2028
|
+
terminateSession: async (ctx) => {
|
|
2029
|
+
const id = ctx.meta["params"]?.["id"];
|
|
2030
|
+
if (!id) {
|
|
2031
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2032
|
+
}
|
|
2033
|
+
const removed = await sessions.terminateById(ctx.user.id, id);
|
|
2034
|
+
if (!removed) {
|
|
2035
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
2036
|
+
}
|
|
2037
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
|
|
2038
|
+
},
|
|
2039
|
+
terminateOtherSessions: async (ctx) => {
|
|
2040
|
+
const currentSid = ctx.user.sid ?? null;
|
|
2041
|
+
if (!currentSid) {
|
|
2042
|
+
return (0, import_core7.setApiResponse)(
|
|
2043
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
2044
|
+
"NO_CURRENT_SESSION",
|
|
2045
|
+
"Cannot terminate others without a current session context"
|
|
2046
|
+
);
|
|
2047
|
+
}
|
|
2048
|
+
const count = await sessions.terminateOthers(ctx.user.id, currentSid);
|
|
2049
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
|
|
2050
|
+
},
|
|
1776
2051
|
updateProfile: async (ctx) => {
|
|
1777
2052
|
const body = ctx.meta["body"];
|
|
1778
2053
|
const allowed = ["firstName", "lastName", "avatarUrl"];
|
|
@@ -1781,18 +2056,18 @@ function userController(store, config, bus) {
|
|
|
1781
2056
|
if (body?.[key] !== void 0) fields[key] = body[key];
|
|
1782
2057
|
}
|
|
1783
2058
|
if (Object.keys(fields).length === 0) {
|
|
1784
|
-
return (0,
|
|
1785
|
-
|
|
2059
|
+
return (0, import_core7.setApiResponse)(
|
|
2060
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1786
2061
|
"INVALID_PARAMETER",
|
|
1787
2062
|
"Provide at least one of: firstName, lastName, avatarUrl"
|
|
1788
2063
|
);
|
|
1789
2064
|
}
|
|
1790
2065
|
const row = await users.update(ctx.user.id, fields);
|
|
1791
2066
|
if (!row) {
|
|
1792
|
-
return (0,
|
|
2067
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1793
2068
|
}
|
|
1794
2069
|
const updated = await users.findById(ctx.user.id);
|
|
1795
|
-
return (0,
|
|
2070
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
|
|
1796
2071
|
user: toUserDTO(updated, ctx.user.phoneVerified)
|
|
1797
2072
|
});
|
|
1798
2073
|
},
|
|
@@ -1809,14 +2084,14 @@ function userController(store, config, bus) {
|
|
|
1809
2084
|
if (Object.keys(patch).length > 0) fields.patch = patch;
|
|
1810
2085
|
const row = await users.updatePreferences(ctx.user.id, fields);
|
|
1811
2086
|
if (!row) {
|
|
1812
|
-
return (0,
|
|
1813
|
-
|
|
2087
|
+
return (0, import_core7.setApiResponse)(
|
|
2088
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1814
2089
|
"INVALID_PARAMETER",
|
|
1815
2090
|
"Provide at least one preference field"
|
|
1816
2091
|
);
|
|
1817
2092
|
}
|
|
1818
2093
|
const updated = await users.findById(ctx.user.id);
|
|
1819
|
-
return (0,
|
|
2094
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
|
|
1820
2095
|
user: toUserDTO(updated, ctx.user.phoneVerified)
|
|
1821
2096
|
});
|
|
1822
2097
|
},
|
|
@@ -1825,23 +2100,23 @@ function userController(store, config, bus) {
|
|
|
1825
2100
|
const newEmail = body?.["email"];
|
|
1826
2101
|
const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
|
|
1827
2102
|
if (!normalised) {
|
|
1828
|
-
return (0,
|
|
1829
|
-
|
|
2103
|
+
return (0, import_core7.setApiResponse)(
|
|
2104
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1830
2105
|
"INVALID_PARAMETER",
|
|
1831
2106
|
"A valid email address is required"
|
|
1832
2107
|
);
|
|
1833
2108
|
}
|
|
1834
2109
|
const oldEmail = ctx.user.email;
|
|
1835
2110
|
if (normalised === oldEmail) {
|
|
1836
|
-
return (0,
|
|
1837
|
-
|
|
2111
|
+
return (0, import_core7.setApiResponse)(
|
|
2112
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1838
2113
|
"INVALID_PARAMETER",
|
|
1839
2114
|
"New email must differ from current email"
|
|
1840
2115
|
);
|
|
1841
2116
|
}
|
|
1842
2117
|
const existing = await users.findByEmail(normalised);
|
|
1843
2118
|
if (existing) {
|
|
1844
|
-
return (0,
|
|
2119
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
|
|
1845
2120
|
}
|
|
1846
2121
|
const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1847
2122
|
const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
|
|
@@ -1863,8 +2138,8 @@ function userController(store, config, bus) {
|
|
|
1863
2138
|
}).catch(() => {
|
|
1864
2139
|
});
|
|
1865
2140
|
}
|
|
1866
|
-
return (0,
|
|
1867
|
-
|
|
2141
|
+
return (0, import_core7.setApiResponse)(
|
|
2142
|
+
import_core7.HTTP.OK,
|
|
1868
2143
|
"EMAIL_UPDATED",
|
|
1869
2144
|
"Email updated. A verification code has been sent to your new address.",
|
|
1870
2145
|
{
|
|
@@ -1876,8 +2151,8 @@ function userController(store, config, bus) {
|
|
|
1876
2151
|
const body = ctx.meta["body"];
|
|
1877
2152
|
const newPhone = body?.["phone"];
|
|
1878
2153
|
if (!isValidPhone2(newPhone)) {
|
|
1879
|
-
return (0,
|
|
1880
|
-
|
|
2154
|
+
return (0, import_core7.setApiResponse)(
|
|
2155
|
+
import_core7.HTTP.UNPROCESSABLE,
|
|
1881
2156
|
"INVALID_PARAMETER",
|
|
1882
2157
|
"A valid phone number is required"
|
|
1883
2158
|
);
|
|
@@ -1885,7 +2160,7 @@ function userController(store, config, bus) {
|
|
|
1885
2160
|
const normalised = normalizePhone2(newPhone);
|
|
1886
2161
|
const existing = await users.findByPhone(normalised);
|
|
1887
2162
|
if (existing) {
|
|
1888
|
-
return (0,
|
|
2163
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
|
|
1889
2164
|
}
|
|
1890
2165
|
const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
|
|
1891
2166
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
|
|
@@ -1907,8 +2182,8 @@ function userController(store, config, bus) {
|
|
|
1907
2182
|
}).catch(() => {
|
|
1908
2183
|
});
|
|
1909
2184
|
}
|
|
1910
|
-
return (0,
|
|
1911
|
-
|
|
2185
|
+
return (0, import_core7.setApiResponse)(
|
|
2186
|
+
import_core7.HTTP.OK,
|
|
1912
2187
|
"PHONE_UPDATED",
|
|
1913
2188
|
"Phone number updated. A verification code has been sent to your new number.",
|
|
1914
2189
|
{
|
|
@@ -1921,22 +2196,22 @@ function userController(store, config, bus) {
|
|
|
1921
2196
|
const currentPassword = body?.["currentPassword"];
|
|
1922
2197
|
const newPassword = body?.["newPassword"];
|
|
1923
2198
|
if (typeof currentPassword !== "string" || !currentPassword) {
|
|
1924
|
-
return (0,
|
|
2199
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
|
|
1925
2200
|
}
|
|
1926
2201
|
if (typeof newPassword !== "string" || newPassword.length < 8) {
|
|
1927
|
-
return (0,
|
|
2202
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
|
|
1928
2203
|
}
|
|
1929
2204
|
const user = await users.findById(ctx.user.id);
|
|
1930
|
-
if (!user) return (0,
|
|
2205
|
+
if (!user) return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1931
2206
|
const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
|
|
1932
2207
|
const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
|
|
1933
2208
|
if (!valid) {
|
|
1934
|
-
return (0,
|
|
2209
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
|
|
1935
2210
|
}
|
|
1936
2211
|
const hash = await hashPassword2(newPassword);
|
|
1937
2212
|
await users.updatePassword(ctx.user.id, hash);
|
|
1938
2213
|
await sessions.deleteByUser(ctx.user.id);
|
|
1939
|
-
return (0,
|
|
2214
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
|
|
1940
2215
|
},
|
|
1941
2216
|
deleteMe: async (ctx) => {
|
|
1942
2217
|
const userId = ctx.user.id;
|
|
@@ -1961,7 +2236,7 @@ function userController(store, config, bus) {
|
|
|
1961
2236
|
// no MFA secret, no session tokens (session metadata only).
|
|
1962
2237
|
exportMe: async (ctx) => {
|
|
1963
2238
|
const user = await users.findById(ctx.user.id);
|
|
1964
|
-
if (!user) return (0,
|
|
2239
|
+
if (!user) return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
1965
2240
|
const sessionRows = await sessions.listByUser(ctx.user.id);
|
|
1966
2241
|
const bundle = {
|
|
1967
2242
|
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -1970,8 +2245,8 @@ function userController(store, config, bus) {
|
|
|
1970
2245
|
id: s.id,
|
|
1971
2246
|
userAgent: s.userAgent,
|
|
1972
2247
|
ipAddress: s.ipAddress,
|
|
1973
|
-
createdAt: (0,
|
|
1974
|
-
expiresAt: (0,
|
|
2248
|
+
createdAt: (0, import_core7.dateOrEmpty)(s.createdAt),
|
|
2249
|
+
expiresAt: (0, import_core7.dateOrEmpty)(s.expiresAt)
|
|
1975
2250
|
})),
|
|
1976
2251
|
security: {
|
|
1977
2252
|
mfaEnabled: user.mfaEnabled === true,
|
|
@@ -1990,22 +2265,23 @@ function userController(store, config, bus) {
|
|
|
1990
2265
|
}
|
|
1991
2266
|
bundle["modules"] = modules;
|
|
1992
2267
|
}
|
|
1993
|
-
return (0,
|
|
2268
|
+
return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
|
|
1994
2269
|
}
|
|
1995
2270
|
};
|
|
1996
2271
|
}
|
|
1997
2272
|
|
|
1998
2273
|
// src/controllers/oauth.controller.ts
|
|
1999
|
-
var
|
|
2274
|
+
var import_core8 = require("@fonderie/core");
|
|
2000
2275
|
function oauthController(store, config) {
|
|
2001
2276
|
const users = new UserModel(store);
|
|
2002
2277
|
const sessions = new SessionModel(store);
|
|
2278
|
+
const loginEvents = new LoginEventModel(store);
|
|
2003
2279
|
return {
|
|
2004
2280
|
googleInit: async (_ctx) => {
|
|
2005
2281
|
const google = config.google;
|
|
2006
2282
|
if (!google) {
|
|
2007
|
-
return (0,
|
|
2008
|
-
|
|
2283
|
+
return (0, import_core8.setApiResponse)(
|
|
2284
|
+
import_core8.HTTP.NOT_IMPLEMENTED,
|
|
2009
2285
|
"NOT_CONFIGURED",
|
|
2010
2286
|
"Google OAuth not configured"
|
|
2011
2287
|
);
|
|
@@ -2017,8 +2293,8 @@ function oauthController(store, config) {
|
|
|
2017
2293
|
scope: "openid email profile"
|
|
2018
2294
|
});
|
|
2019
2295
|
const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
|
2020
|
-
return (0,
|
|
2021
|
-
|
|
2296
|
+
return (0, import_core8.setApiResponse)(
|
|
2297
|
+
import_core8.HTTP.OK,
|
|
2022
2298
|
"GOOGLE_AUTH_URL",
|
|
2023
2299
|
"Redirect the user to the returned URL to begin Google OAuth.",
|
|
2024
2300
|
{ url }
|
|
@@ -2027,8 +2303,8 @@ function oauthController(store, config) {
|
|
|
2027
2303
|
googleCallback: async (ctx) => {
|
|
2028
2304
|
const google = config.google;
|
|
2029
2305
|
if (!google) {
|
|
2030
|
-
return (0,
|
|
2031
|
-
|
|
2306
|
+
return (0, import_core8.setApiResponse)(
|
|
2307
|
+
import_core8.HTTP.NOT_IMPLEMENTED,
|
|
2032
2308
|
"NOT_CONFIGURED",
|
|
2033
2309
|
"Google OAuth not configured"
|
|
2034
2310
|
);
|
|
@@ -2036,7 +2312,7 @@ function oauthController(store, config) {
|
|
|
2036
2312
|
const url = new URL(ctx.request.url);
|
|
2037
2313
|
const code = url.searchParams.get("code");
|
|
2038
2314
|
if (!code) {
|
|
2039
|
-
return (0,
|
|
2315
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
|
|
2040
2316
|
}
|
|
2041
2317
|
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
|
2042
2318
|
method: "POST",
|
|
@@ -2051,8 +2327,8 @@ function oauthController(store, config) {
|
|
|
2051
2327
|
});
|
|
2052
2328
|
const tokenData = await tokenRes.json();
|
|
2053
2329
|
if (!tokenData.id_token) {
|
|
2054
|
-
return (0,
|
|
2055
|
-
|
|
2330
|
+
return (0, import_core8.setApiResponse)(
|
|
2331
|
+
import_core8.HTTP.BAD_REQUEST,
|
|
2056
2332
|
"GOOGLE_AUTH_FAILED",
|
|
2057
2333
|
"OAuth token exchange failed"
|
|
2058
2334
|
);
|
|
@@ -2061,24 +2337,40 @@ function oauthController(store, config) {
|
|
|
2061
2337
|
Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
|
|
2062
2338
|
);
|
|
2063
2339
|
if (!payload.email) {
|
|
2064
|
-
return (0,
|
|
2340
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
|
|
2065
2341
|
}
|
|
2066
2342
|
const normalizedEmail = normalizeEmailSafe(payload.email);
|
|
2067
2343
|
if (!normalizedEmail) {
|
|
2068
|
-
return (0,
|
|
2344
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
|
|
2069
2345
|
}
|
|
2346
|
+
const meta = requestMeta(ctx);
|
|
2070
2347
|
const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
|
|
2071
2348
|
if (!upserted) {
|
|
2072
|
-
|
|
2349
|
+
loginEvents.recordSafe({
|
|
2350
|
+
userId: null,
|
|
2351
|
+
emailAttempted: normalizedEmail,
|
|
2352
|
+
method: "oauth-google",
|
|
2353
|
+
outcome: "failed",
|
|
2354
|
+
failureReason: "provider_upsert_failed",
|
|
2355
|
+
...meta
|
|
2356
|
+
});
|
|
2357
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
2073
2358
|
}
|
|
2074
2359
|
const fullUser = await users.findById(upserted.id);
|
|
2075
2360
|
if (!fullUser) {
|
|
2076
|
-
return (0,
|
|
2361
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
2077
2362
|
}
|
|
2078
2363
|
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
|
|
2079
2364
|
loginMethod: "google"
|
|
2080
2365
|
});
|
|
2081
|
-
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
2366
|
+
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
2367
|
+
loginEvents.recordSafe({
|
|
2368
|
+
userId: upserted.id,
|
|
2369
|
+
emailAttempted: normalizedEmail,
|
|
2370
|
+
method: "oauth-google",
|
|
2371
|
+
outcome: "success",
|
|
2372
|
+
...meta
|
|
2373
|
+
});
|
|
2082
2374
|
return Response.json(
|
|
2083
2375
|
{
|
|
2084
2376
|
reason: "GOOGLE_AUTH_SUCCESS",
|
|
@@ -2121,7 +2413,7 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2121
2413
|
R("refresh", "POST", "/auth/refresh", (0, import_middlewares.validate)(refreshSchema), auth.refresh),
|
|
2122
2414
|
// Email — Password Recovery (Public)
|
|
2123
2415
|
R("forgotPassword", "POST", "/auth/email/forgot", ipLimit("forgot"), (0, import_middlewares.validate)(forgotPasswordSchema), acctLimit("forgot"), auth.forgotPassword),
|
|
2124
|
-
R("resetPassword", "POST", "/auth/email/reset", (0, import_middlewares.validate)(resetPasswordSchema), auth.resetPassword),
|
|
2416
|
+
R("resetPassword", "POST", "/auth/email/reset", ipLimit("reset"), (0, import_middlewares.validate)(resetPasswordSchema), auth.resetPassword),
|
|
2125
2417
|
// Verification (Protected — email or phone, determined by loginMethod)
|
|
2126
2418
|
R("verifyEmail", "POST", "/auth/verify", import_middlewares2.requireAuth, (0, import_middlewares.validate)(verifySchema), auth.verify),
|
|
2127
2419
|
R("sendVerification", "GET", "/auth/send-verification", import_middlewares2.requireAuth, auth.sendVerification),
|
|
@@ -2136,6 +2428,13 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2136
2428
|
R("changePassword", "PUT", "/users/password", import_middlewares2.requireAuth, (0, import_middlewares.validate)(changePasswordSchema), user.changePassword),
|
|
2137
2429
|
R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
|
|
2138
2430
|
R("exportMe", "GET", "/users/export", import_middlewares2.requireAuth, user.exportMe),
|
|
2431
|
+
// Security surfaces (Protected; the caller's own login history + sessions).
|
|
2432
|
+
// The literal /others route is registered before /:id so it isn't
|
|
2433
|
+
// captured as an id.
|
|
2434
|
+
R("loginHistory", "GET", "/auth/login-history", import_middlewares2.requireAuth, user.loginHistory),
|
|
2435
|
+
R("listSessions", "GET", "/auth/sessions", import_middlewares2.requireAuth, user.listSessions),
|
|
2436
|
+
R("terminateOtherSessions", "DELETE", "/auth/sessions/others", import_middlewares2.requireAuth, user.terminateOtherSessions),
|
|
2437
|
+
R("terminateSession", "DELETE", "/auth/sessions/:id", import_middlewares2.requireAuth, user.terminateSession),
|
|
2139
2438
|
// MFA (email sessions only — requireVerified is always enforced here
|
|
2140
2439
|
// because MFA is a security feature and email verification is meaningful)
|
|
2141
2440
|
R("mfaSetup", "POST", "/auth/mfa/setup", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, mfa.setup),
|
|
@@ -2155,17 +2454,17 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2155
2454
|
}
|
|
2156
2455
|
|
|
2157
2456
|
// src/services/config-guard.ts
|
|
2158
|
-
var
|
|
2457
|
+
var import_core9 = require("@fonderie/core");
|
|
2159
2458
|
var MODULE = "@fonderie/auth";
|
|
2160
2459
|
function collectAuthConfigProblems(config) {
|
|
2161
2460
|
const problems = [];
|
|
2162
2461
|
const secret = config.jwtSecret ?? "";
|
|
2163
|
-
const secretProblem = (0,
|
|
2462
|
+
const secretProblem = (0, import_core9.secretStrengthProblem)(secret);
|
|
2164
2463
|
if (secretProblem === "too-short") {
|
|
2165
2464
|
problems.push({
|
|
2166
2465
|
module: MODULE,
|
|
2167
2466
|
severity: "error",
|
|
2168
|
-
message: `jwtSecret must be at least ${
|
|
2467
|
+
message: `jwtSecret must be at least ${import_core9.MIN_SECRET_LENGTH} characters (got ${secret.length})`
|
|
2169
2468
|
});
|
|
2170
2469
|
} else if (secretProblem === "placeholder") {
|
|
2171
2470
|
problems.push({
|
|
@@ -2189,7 +2488,7 @@ function collectAuthConfigProblems(config) {
|
|
|
2189
2488
|
severity: "error",
|
|
2190
2489
|
message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing"
|
|
2191
2490
|
});
|
|
2192
|
-
} else if (
|
|
2491
|
+
} else if (import_core9.PLACEHOLDER_SECRET.test(clientSecret)) {
|
|
2193
2492
|
problems.push({
|
|
2194
2493
|
module: MODULE,
|
|
2195
2494
|
severity: "error",
|
|
@@ -2256,7 +2555,10 @@ function withSession(store, config) {
|
|
|
2256
2555
|
...user,
|
|
2257
2556
|
loginMethod: payload.loginMethod ?? "email",
|
|
2258
2557
|
phoneVerified: payload.phoneVerified ?? false,
|
|
2259
|
-
mfaPending: payload.mfaPending ?? false
|
|
2558
|
+
mfaPending: payload.mfaPending ?? false,
|
|
2559
|
+
// The session this request is authenticated on, so the sessions
|
|
2560
|
+
// API can flag the current row and "terminate others" can spare it.
|
|
2561
|
+
sid: payload.sid ?? null
|
|
2260
2562
|
}
|
|
2261
2563
|
});
|
|
2262
2564
|
return next();
|