@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.d.cts
CHANGED
|
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.cjs';
|
|
|
2
2
|
import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
|
|
3
3
|
import { IStoreAdapter } from '@fonderie/store';
|
|
4
4
|
import { EventBus } from '@fonderie/events';
|
|
5
|
-
import { I as IAuthConfig } from './session-
|
|
6
|
-
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-
|
|
5
|
+
import { I as IAuthConfig } from './session-DJNd1xp_.cjs';
|
|
6
|
+
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-DJNd1xp_.cjs';
|
|
7
7
|
export { IUserDTO, toUserDTO } from './dtos/user.cjs';
|
|
8
8
|
export { requireAuth, validate } from '@fonderie/core/middlewares';
|
|
9
9
|
import { z } from 'zod';
|
|
@@ -65,6 +65,28 @@ declare const DEFAULT_TEMPLATES: {
|
|
|
65
65
|
};
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
interface ILoginEventDTO {
|
|
69
|
+
id: string;
|
|
70
|
+
method: string;
|
|
71
|
+
outcome: string;
|
|
72
|
+
failureReason: string | null;
|
|
73
|
+
ipAddress: string | null;
|
|
74
|
+
userAgent: string | null;
|
|
75
|
+
createdAt: string;
|
|
76
|
+
}
|
|
77
|
+
interface ILoginHistoryPageDTO {
|
|
78
|
+
events: ILoginEventDTO[];
|
|
79
|
+
nextCursor: string | null;
|
|
80
|
+
}
|
|
81
|
+
interface ISessionDTO {
|
|
82
|
+
id: string;
|
|
83
|
+
current: boolean;
|
|
84
|
+
ipAddress: string | null;
|
|
85
|
+
userAgent: string | null;
|
|
86
|
+
createdAt: string;
|
|
87
|
+
expiresAt: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
69
91
|
email: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
70
92
|
password: z.ZodString;
|
|
@@ -183,4 +205,4 @@ declare function startUserRetention(store: IStoreAdapter, options: IUserRetentio
|
|
|
183
205
|
|
|
184
206
|
declare function validateAuthConfig(config: IAuthConfig): void;
|
|
185
207
|
|
|
186
|
-
export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type IPurgeOptions, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
|
|
208
|
+
export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, type ISessionDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
|
package/dist/index.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.js';
|
|
|
2
2
|
import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
|
|
3
3
|
import { IStoreAdapter } from '@fonderie/store';
|
|
4
4
|
import { EventBus } from '@fonderie/events';
|
|
5
|
-
import { I as IAuthConfig } from './session-
|
|
6
|
-
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-
|
|
5
|
+
import { I as IAuthConfig } from './session-DJNd1xp_.js';
|
|
6
|
+
export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-DJNd1xp_.js';
|
|
7
7
|
export { IUserDTO, toUserDTO } from './dtos/user.js';
|
|
8
8
|
export { requireAuth, validate } from '@fonderie/core/middlewares';
|
|
9
9
|
import { z } from 'zod';
|
|
@@ -65,6 +65,28 @@ declare const DEFAULT_TEMPLATES: {
|
|
|
65
65
|
};
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
interface ILoginEventDTO {
|
|
69
|
+
id: string;
|
|
70
|
+
method: string;
|
|
71
|
+
outcome: string;
|
|
72
|
+
failureReason: string | null;
|
|
73
|
+
ipAddress: string | null;
|
|
74
|
+
userAgent: string | null;
|
|
75
|
+
createdAt: string;
|
|
76
|
+
}
|
|
77
|
+
interface ILoginHistoryPageDTO {
|
|
78
|
+
events: ILoginEventDTO[];
|
|
79
|
+
nextCursor: string | null;
|
|
80
|
+
}
|
|
81
|
+
interface ISessionDTO {
|
|
82
|
+
id: string;
|
|
83
|
+
current: boolean;
|
|
84
|
+
ipAddress: string | null;
|
|
85
|
+
userAgent: string | null;
|
|
86
|
+
createdAt: string;
|
|
87
|
+
expiresAt: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
69
91
|
email: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
70
92
|
password: z.ZodString;
|
|
@@ -183,4 +205,4 @@ declare function startUserRetention(store: IStoreAdapter, options: IUserRetentio
|
|
|
183
205
|
|
|
184
206
|
declare function validateAuthConfig(config: IAuthConfig): void;
|
|
185
207
|
|
|
186
|
-
export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type IPurgeOptions, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
|
|
208
|
+
export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, type ISessionDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
|
package/dist/index.js
CHANGED
|
@@ -691,17 +691,23 @@ var SessionModel = class {
|
|
|
691
691
|
this.store = store;
|
|
692
692
|
}
|
|
693
693
|
store;
|
|
694
|
-
async create(userId, token, expiresAt, sid) {
|
|
694
|
+
async create(userId, token, expiresAt, sid, meta) {
|
|
695
695
|
await this.store.query(
|
|
696
|
-
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid)
|
|
697
|
-
VALUES ($1, $2, $3, $4)
|
|
696
|
+
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)
|
|
697
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
698
698
|
ON CONFLICT (token) DO NOTHING`,
|
|
699
|
-
[userId, token, expiresAt, sid ?? null]
|
|
699
|
+
[userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
|
|
700
700
|
);
|
|
701
701
|
}
|
|
702
702
|
async delete(token) {
|
|
703
703
|
await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
|
|
704
704
|
}
|
|
705
|
+
// Delete the session an access token is bound to (by its sid claim). Lets
|
|
706
|
+
// logout revoke the current session without the client resending the refresh
|
|
707
|
+
// token — the access token already identifies its session.
|
|
708
|
+
async deleteBySid(sid) {
|
|
709
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);
|
|
710
|
+
}
|
|
705
711
|
// Revoke every session for a user (e.g. on password change). Access tokens
|
|
706
712
|
// bound to these sessions via the sid claim die on their next request.
|
|
707
713
|
async deleteByUser(userId) {
|
|
@@ -721,6 +727,42 @@ var SessionModel = class {
|
|
|
721
727
|
[userId]
|
|
722
728
|
);
|
|
723
729
|
}
|
|
730
|
+
// Live sessions for the Active Sessions screen: unexpired only, carrying the
|
|
731
|
+
// sid so the caller can flag the current one (row.sid === the JWT's sid).
|
|
732
|
+
// No token — that never leaves the server.
|
|
733
|
+
async listLiveByUser(userId) {
|
|
734
|
+
return this.store.query(
|
|
735
|
+
`SELECT id, sid,
|
|
736
|
+
user_agent AS "userAgent",
|
|
737
|
+
ip_address AS "ipAddress",
|
|
738
|
+
created_at AS "createdAt",
|
|
739
|
+
expires_at AS "expiresAt"
|
|
740
|
+
FROM fonderie_sessions
|
|
741
|
+
WHERE user_id = $1 AND expires_at > now()
|
|
742
|
+
ORDER BY created_at DESC`,
|
|
743
|
+
[userId]
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
// Terminate one session by row id, scoped to its owner (a user can never
|
|
747
|
+
// delete another user's session). Returns whether a row was removed.
|
|
748
|
+
async terminateById(userId, id) {
|
|
749
|
+
const rows = await this.store.query(
|
|
750
|
+
`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,
|
|
751
|
+
[id, userId]
|
|
752
|
+
);
|
|
753
|
+
return rows.length > 0;
|
|
754
|
+
}
|
|
755
|
+
// Terminate all of a user's sessions except the current one (by sid).
|
|
756
|
+
// Returns the number of sessions removed.
|
|
757
|
+
async terminateOthers(userId, keepSid) {
|
|
758
|
+
const rows = await this.store.query(
|
|
759
|
+
`DELETE FROM fonderie_sessions
|
|
760
|
+
WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
|
|
761
|
+
RETURNING id`,
|
|
762
|
+
[userId, keepSid]
|
|
763
|
+
);
|
|
764
|
+
return rows.length;
|
|
765
|
+
}
|
|
724
766
|
async exists(token) {
|
|
725
767
|
const rows = await this.store.query(
|
|
726
768
|
`SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
|
|
@@ -773,11 +815,92 @@ var BackupCodeModel = class {
|
|
|
773
815
|
}
|
|
774
816
|
};
|
|
775
817
|
|
|
818
|
+
// src/models/login-event.model.ts
|
|
819
|
+
var LoginEventModel = class {
|
|
820
|
+
constructor(store) {
|
|
821
|
+
this.store = store;
|
|
822
|
+
}
|
|
823
|
+
store;
|
|
824
|
+
async record(e) {
|
|
825
|
+
await this.store.query(
|
|
826
|
+
`INSERT INTO fonderie_login_events
|
|
827
|
+
(user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent)
|
|
828
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
829
|
+
[
|
|
830
|
+
e.userId,
|
|
831
|
+
e.emailAttempted ?? null,
|
|
832
|
+
e.method,
|
|
833
|
+
e.outcome,
|
|
834
|
+
e.failureReason ?? null,
|
|
835
|
+
e.ipAddress,
|
|
836
|
+
e.userAgent
|
|
837
|
+
]
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
// Fire-and-forget variant for the login paths: recording history must never
|
|
841
|
+
// block or fail a login (same posture as the bus.emit(...).catch(() => {})
|
|
842
|
+
// notification sends).
|
|
843
|
+
recordSafe(e) {
|
|
844
|
+
void this.record(e).catch(() => {
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
// One page of a user's own login history, newest first. Keyset-paginated on
|
|
848
|
+
// (created_at, id) — mirrors audit's event list so the two log UIs share a
|
|
849
|
+
// cursor contract. The +1 over-fetch that detects a next page lives here so
|
|
850
|
+
// no outer clamp can shave it off.
|
|
851
|
+
async listByUser(query) {
|
|
852
|
+
const limit = Math.min(query.limit ?? 50, MAX_HISTORY_LIMIT);
|
|
853
|
+
const params = [query.userId];
|
|
854
|
+
const where = ["user_id = $1"];
|
|
855
|
+
if (query.outcome) {
|
|
856
|
+
params.push(query.outcome);
|
|
857
|
+
where.push(`outcome = $${params.length}`);
|
|
858
|
+
}
|
|
859
|
+
if (query.from) {
|
|
860
|
+
params.push(query.from);
|
|
861
|
+
where.push(`created_at >= $${params.length}`);
|
|
862
|
+
}
|
|
863
|
+
if (query.to) {
|
|
864
|
+
params.push(query.to);
|
|
865
|
+
where.push(`created_at <= $${params.length}`);
|
|
866
|
+
}
|
|
867
|
+
if (query.cursor) {
|
|
868
|
+
params.push(query.cursor.createdAt, query.cursor.id);
|
|
869
|
+
where.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
|
|
870
|
+
}
|
|
871
|
+
params.push(limit + 1);
|
|
872
|
+
const rows = await this.store.query(
|
|
873
|
+
`SELECT id, method, outcome, failure_reason AS "failureReason",
|
|
874
|
+
ip_address AS "ipAddress", user_agent AS "userAgent",
|
|
875
|
+
created_at AS "createdAt", created_at::text AS "createdAtRaw"
|
|
876
|
+
FROM fonderie_login_events
|
|
877
|
+
WHERE ${where.join(" AND ")}
|
|
878
|
+
ORDER BY created_at DESC, id DESC
|
|
879
|
+
LIMIT $${params.length}`,
|
|
880
|
+
params
|
|
881
|
+
);
|
|
882
|
+
return { events: rows.slice(0, limit), hasMore: rows.length > limit };
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
var MAX_HISTORY_LIMIT = 200;
|
|
886
|
+
|
|
887
|
+
// src/services/request-meta.ts
|
|
888
|
+
var UA_MAX = 512;
|
|
889
|
+
function requestMeta(ctx) {
|
|
890
|
+
const ip = ctx.meta["clientIp"];
|
|
891
|
+
const ua = ctx.request.headers.get("user-agent");
|
|
892
|
+
return {
|
|
893
|
+
ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
|
|
894
|
+
userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
776
898
|
// src/controllers/mfa.controller.ts
|
|
777
899
|
function mfaController(store, config, issuer, bus) {
|
|
778
900
|
const users = new UserModel(store);
|
|
779
901
|
const sessions = new SessionModel(store);
|
|
780
902
|
const backupCodes = new BackupCodeModel(store);
|
|
903
|
+
const loginEvents = new LoginEventModel(store);
|
|
781
904
|
const mfaCipher = makeMfaCipher(config.mfaSecretKey);
|
|
782
905
|
return {
|
|
783
906
|
// ── 1. Setup ───────────────────────────────────────────────
|
|
@@ -807,6 +930,16 @@ function mfaController(store, config, issuer, bus) {
|
|
|
807
930
|
verify: async (ctx) => {
|
|
808
931
|
const body = ctx.meta["body"];
|
|
809
932
|
const token = body?.["token"];
|
|
933
|
+
const isLoginCompletion = ctx.user.mfaPending === true;
|
|
934
|
+
const meta = requestMeta(ctx);
|
|
935
|
+
const failedMfa = () => loginEvents.recordSafe({
|
|
936
|
+
userId: ctx.user.id,
|
|
937
|
+
emailAttempted: ctx.user.email,
|
|
938
|
+
method: "mfa",
|
|
939
|
+
outcome: "failed",
|
|
940
|
+
failureReason: "invalid_code",
|
|
941
|
+
...meta
|
|
942
|
+
});
|
|
810
943
|
if (typeof token !== "string") {
|
|
811
944
|
return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
|
|
812
945
|
}
|
|
@@ -847,6 +980,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
847
980
|
);
|
|
848
981
|
const matched = checks.find((r) => r.match);
|
|
849
982
|
if (!matched) {
|
|
983
|
+
if (isLoginCompletion) failedMfa();
|
|
850
984
|
return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
|
|
851
985
|
}
|
|
852
986
|
await backupCodes.consume(matched.id);
|
|
@@ -864,6 +998,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
864
998
|
}
|
|
865
999
|
const secret = mfaCipher.decrypt(storedSecret);
|
|
866
1000
|
if (!verifyTotpToken(token, secret)) {
|
|
1001
|
+
if (isLoginCompletion) failedMfa();
|
|
867
1002
|
return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
|
|
868
1003
|
}
|
|
869
1004
|
if (!ctx.user.mfaEnabled) {
|
|
@@ -874,7 +1009,16 @@ function mfaController(store, config, issuer, bus) {
|
|
|
874
1009
|
loginMethod: ctx.user.loginMethod,
|
|
875
1010
|
phoneVerified: ctx.user.phoneVerified
|
|
876
1011
|
});
|
|
877
|
-
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1012
|
+
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1013
|
+
if (isLoginCompletion) {
|
|
1014
|
+
loginEvents.recordSafe({
|
|
1015
|
+
userId: ctx.user.id,
|
|
1016
|
+
emailAttempted: ctx.user.email,
|
|
1017
|
+
method: "mfa",
|
|
1018
|
+
outcome: "success",
|
|
1019
|
+
...meta
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
878
1022
|
const fullUser = await users.findById(ctx.user.id);
|
|
879
1023
|
if (!fullUser) {
|
|
880
1024
|
return setApiResponse2(HTTP2.SERVER_ERROR, "SERVER_ERROR", "User not found after MFA verify");
|
|
@@ -1143,6 +1287,7 @@ function extractRefreshToken(ctx) {
|
|
|
1143
1287
|
function authController(store, config, bus) {
|
|
1144
1288
|
const users = new UserModel(store);
|
|
1145
1289
|
const sessions = new SessionModel(store);
|
|
1290
|
+
const loginEvents = new LoginEventModel(store);
|
|
1146
1291
|
const passwordReset = new PasswordResetModel(store);
|
|
1147
1292
|
const emailVerif = new EmailVerificationModel(store);
|
|
1148
1293
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
@@ -1212,7 +1357,7 @@ function authController(store, config, bus) {
|
|
|
1212
1357
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1213
1358
|
loginMethod: "email"
|
|
1214
1359
|
});
|
|
1215
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1360
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1216
1361
|
const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
|
|
1217
1362
|
const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
|
|
1218
1363
|
return Response.json(
|
|
@@ -1276,7 +1421,7 @@ function authController(store, config, bus) {
|
|
|
1276
1421
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1277
1422
|
loginMethod: "phone"
|
|
1278
1423
|
});
|
|
1279
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1424
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1280
1425
|
return Response.json(
|
|
1281
1426
|
{
|
|
1282
1427
|
reason: "USER_PHONE_REGISTERED",
|
|
@@ -1300,6 +1445,7 @@ function authController(store, config, bus) {
|
|
|
1300
1445
|
},
|
|
1301
1446
|
login: async (ctx) => {
|
|
1302
1447
|
const body = ctx.meta["body"];
|
|
1448
|
+
const meta = requestMeta(ctx);
|
|
1303
1449
|
if (typeof body?.["email"] === "string" && typeof body?.["password"] === "string") {
|
|
1304
1450
|
const { email: rawEmail, password: password2 } = body;
|
|
1305
1451
|
const email2 = normalizeEmailSafe(rawEmail);
|
|
@@ -1308,6 +1454,14 @@ function authController(store, config, bus) {
|
|
|
1308
1454
|
}
|
|
1309
1455
|
const user = await users.findByEmail(email2);
|
|
1310
1456
|
if (!user || !user.passwordHash) {
|
|
1457
|
+
loginEvents.recordSafe({
|
|
1458
|
+
userId: null,
|
|
1459
|
+
emailAttempted: email2,
|
|
1460
|
+
method: "password",
|
|
1461
|
+
outcome: "failed",
|
|
1462
|
+
failureReason: "unknown_email",
|
|
1463
|
+
...meta
|
|
1464
|
+
});
|
|
1311
1465
|
return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1312
1466
|
}
|
|
1313
1467
|
const { valid, needsRehash } = await verifyPasswordForLogin(
|
|
@@ -1316,6 +1470,14 @@ function authController(store, config, bus) {
|
|
|
1316
1470
|
config.legacyVerify
|
|
1317
1471
|
);
|
|
1318
1472
|
if (!valid) {
|
|
1473
|
+
loginEvents.recordSafe({
|
|
1474
|
+
userId: user.id,
|
|
1475
|
+
emailAttempted: email2,
|
|
1476
|
+
method: "password",
|
|
1477
|
+
outcome: "failed",
|
|
1478
|
+
failureReason: "bad_password",
|
|
1479
|
+
...meta
|
|
1480
|
+
});
|
|
1319
1481
|
return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1320
1482
|
}
|
|
1321
1483
|
if (needsRehash) {
|
|
@@ -1337,7 +1499,14 @@ function authController(store, config, bus) {
|
|
|
1337
1499
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1338
1500
|
loginMethod: "email"
|
|
1339
1501
|
});
|
|
1340
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1502
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1503
|
+
loginEvents.recordSafe({
|
|
1504
|
+
userId: user.id,
|
|
1505
|
+
emailAttempted: email2,
|
|
1506
|
+
method: "password",
|
|
1507
|
+
outcome: "success",
|
|
1508
|
+
...meta
|
|
1509
|
+
});
|
|
1341
1510
|
const resolvedLogin = { ...config, ...config.resolve?.(ctx) };
|
|
1342
1511
|
const requiresVerification = !!resolvedLogin.requireVerification && !user.emailVerifiedAt;
|
|
1343
1512
|
return Response.json(
|
|
@@ -1382,7 +1551,7 @@ function authController(store, config, bus) {
|
|
|
1382
1551
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1383
1552
|
loginMethod: "phone"
|
|
1384
1553
|
});
|
|
1385
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1554
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1386
1555
|
return Response.json(
|
|
1387
1556
|
{
|
|
1388
1557
|
reason: "USER_PHONE_OTP_SENT",
|
|
@@ -1405,6 +1574,10 @@ function authController(store, config, bus) {
|
|
|
1405
1574
|
);
|
|
1406
1575
|
},
|
|
1407
1576
|
logout: async (ctx) => {
|
|
1577
|
+
const sid = ctx.user?.sid;
|
|
1578
|
+
if (sid) {
|
|
1579
|
+
await sessions.deleteBySid(sid).catch(() => void 0);
|
|
1580
|
+
}
|
|
1408
1581
|
const token = extractRefreshToken(ctx);
|
|
1409
1582
|
if (token) {
|
|
1410
1583
|
await sessions.delete(token).catch(() => void 0);
|
|
@@ -1443,7 +1616,7 @@ function authController(store, config, bus) {
|
|
|
1443
1616
|
loginMethod: payload.loginMethod ?? "email",
|
|
1444
1617
|
phoneVerified: payload.phoneVerified ?? false
|
|
1445
1618
|
});
|
|
1446
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1619
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1447
1620
|
return Response.json(
|
|
1448
1621
|
{
|
|
1449
1622
|
reason: "TOKENS_REFRESHED",
|
|
@@ -1577,7 +1750,7 @@ function authController(store, config, bus) {
|
|
|
1577
1750
|
loginMethod: "phone",
|
|
1578
1751
|
phoneVerified: true
|
|
1579
1752
|
});
|
|
1580
|
-
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1753
|
+
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1581
1754
|
const verifiedUser = await users.findById(ctx.user.id);
|
|
1582
1755
|
if (!verifiedUser) {
|
|
1583
1756
|
return setApiResponse3(HTTP3.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
@@ -1712,6 +1885,38 @@ function authController(store, config, bus) {
|
|
|
1712
1885
|
import { randomInt as randomInt2 } from "crypto";
|
|
1713
1886
|
import { setApiResponse as setApiResponse4, HTTP as HTTP4, dateOrEmpty as dateOrEmpty2 } from "@fonderie/core";
|
|
1714
1887
|
import { NOTIFICATION_EVENT as NOTIFICATION_EVENT3 } from "@fonderie/events";
|
|
1888
|
+
|
|
1889
|
+
// src/dtos/login-activity.ts
|
|
1890
|
+
import { encodeKeysetCursor, decodeKeysetCursor } from "@fonderie/core";
|
|
1891
|
+
function toLoginEventDTO(row) {
|
|
1892
|
+
return {
|
|
1893
|
+
id: row.id,
|
|
1894
|
+
method: row.method,
|
|
1895
|
+
outcome: row.outcome,
|
|
1896
|
+
failureReason: row.failureReason,
|
|
1897
|
+
ipAddress: row.ipAddress,
|
|
1898
|
+
userAgent: row.userAgent,
|
|
1899
|
+
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
function toLoginHistoryPageDTO(page) {
|
|
1903
|
+
const last = page.events[page.events.length - 1];
|
|
1904
|
+
const nextCursor = page.hasMore && last ? encodeKeysetCursor(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
|
|
1905
|
+
return { events: page.events.map(toLoginEventDTO), nextCursor };
|
|
1906
|
+
}
|
|
1907
|
+
var decodeLoginCursor = decodeKeysetCursor;
|
|
1908
|
+
function toSessionDTO(row, currentSid) {
|
|
1909
|
+
return {
|
|
1910
|
+
id: row.id,
|
|
1911
|
+
current: currentSid !== null && row.sid === currentSid,
|
|
1912
|
+
ipAddress: row.ipAddress,
|
|
1913
|
+
userAgent: row.userAgent,
|
|
1914
|
+
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
|
1915
|
+
expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// src/controllers/user.controller.ts
|
|
1715
1920
|
function normalizePhone2(phone2) {
|
|
1716
1921
|
return phone2.trim().replace(/[\s()\-\.]/g, "");
|
|
1717
1922
|
}
|
|
@@ -1721,6 +1926,7 @@ function isValidPhone2(phone2) {
|
|
|
1721
1926
|
function userController(store, config, bus) {
|
|
1722
1927
|
const users = new UserModel(store);
|
|
1723
1928
|
const sessions = new SessionModel(store);
|
|
1929
|
+
const loginEvents = new LoginEventModel(store);
|
|
1724
1930
|
const emailVerif = new EmailVerificationModel(store);
|
|
1725
1931
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
1726
1932
|
return {
|
|
@@ -1733,6 +1939,69 @@ function userController(store, config, bus) {
|
|
|
1733
1939
|
user: toUserDTO(user, ctx.user.phoneVerified)
|
|
1734
1940
|
});
|
|
1735
1941
|
},
|
|
1942
|
+
// ── Login history (append-only; the caller's own attempts) ────────
|
|
1943
|
+
loginHistory: async (ctx) => {
|
|
1944
|
+
const params = new URL(ctx.request.url).searchParams;
|
|
1945
|
+
const rawLimit = Number(params.get("limit") ?? 50);
|
|
1946
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
|
|
1947
|
+
const outcomeParam = params.get("outcome");
|
|
1948
|
+
if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
|
|
1949
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
|
|
1950
|
+
}
|
|
1951
|
+
const cursorParam = params.get("cursor");
|
|
1952
|
+
let cursor = null;
|
|
1953
|
+
if (cursorParam) {
|
|
1954
|
+
cursor = decodeLoginCursor(cursorParam);
|
|
1955
|
+
if (!cursor) {
|
|
1956
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
const query = { userId: ctx.user.id, limit };
|
|
1960
|
+
if (outcomeParam) query.outcome = outcomeParam;
|
|
1961
|
+
if (cursor) query.cursor = cursor;
|
|
1962
|
+
const from = params.get("from");
|
|
1963
|
+
const to = params.get("to");
|
|
1964
|
+
if (from) query.from = new Date(from);
|
|
1965
|
+
if (to) query.to = new Date(to);
|
|
1966
|
+
const page = await loginEvents.listByUser(query);
|
|
1967
|
+
return setApiResponse4(
|
|
1968
|
+
HTTP4.OK,
|
|
1969
|
+
"LOGIN_HISTORY_FETCHED",
|
|
1970
|
+
"Login history retrieved.",
|
|
1971
|
+
toLoginHistoryPageDTO(page)
|
|
1972
|
+
);
|
|
1973
|
+
},
|
|
1974
|
+
// ── Active sessions (live only; the caller's own) ─────────────────
|
|
1975
|
+
listSessions: async (ctx) => {
|
|
1976
|
+
const rows = await sessions.listLiveByUser(ctx.user.id);
|
|
1977
|
+
const currentSid = ctx.user.sid ?? null;
|
|
1978
|
+
return setApiResponse4(HTTP4.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
|
|
1979
|
+
sessions: rows.map((r) => toSessionDTO(r, currentSid))
|
|
1980
|
+
});
|
|
1981
|
+
},
|
|
1982
|
+
terminateSession: async (ctx) => {
|
|
1983
|
+
const id = ctx.meta["params"]?.["id"];
|
|
1984
|
+
if (!id) {
|
|
1985
|
+
return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
1986
|
+
}
|
|
1987
|
+
const removed = await sessions.terminateById(ctx.user.id, id);
|
|
1988
|
+
if (!removed) {
|
|
1989
|
+
return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
1990
|
+
}
|
|
1991
|
+
return setApiResponse4(HTTP4.OK, "SESSION_TERMINATED", "Session terminated.", { id });
|
|
1992
|
+
},
|
|
1993
|
+
terminateOtherSessions: async (ctx) => {
|
|
1994
|
+
const currentSid = ctx.user.sid ?? null;
|
|
1995
|
+
if (!currentSid) {
|
|
1996
|
+
return setApiResponse4(
|
|
1997
|
+
HTTP4.UNPROCESSABLE,
|
|
1998
|
+
"NO_CURRENT_SESSION",
|
|
1999
|
+
"Cannot terminate others without a current session context"
|
|
2000
|
+
);
|
|
2001
|
+
}
|
|
2002
|
+
const count = await sessions.terminateOthers(ctx.user.id, currentSid);
|
|
2003
|
+
return setApiResponse4(HTTP4.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
|
|
2004
|
+
},
|
|
1736
2005
|
updateProfile: async (ctx) => {
|
|
1737
2006
|
const body = ctx.meta["body"];
|
|
1738
2007
|
const allowed = ["firstName", "lastName", "avatarUrl"];
|
|
@@ -1960,6 +2229,7 @@ import { setApiResponse as setApiResponse5, HTTP as HTTP5 } from "@fonderie/core
|
|
|
1960
2229
|
function oauthController(store, config) {
|
|
1961
2230
|
const users = new UserModel(store);
|
|
1962
2231
|
const sessions = new SessionModel(store);
|
|
2232
|
+
const loginEvents = new LoginEventModel(store);
|
|
1963
2233
|
return {
|
|
1964
2234
|
googleInit: async (_ctx) => {
|
|
1965
2235
|
const google = config.google;
|
|
@@ -2027,8 +2297,17 @@ function oauthController(store, config) {
|
|
|
2027
2297
|
if (!normalizedEmail) {
|
|
2028
2298
|
return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
|
|
2029
2299
|
}
|
|
2300
|
+
const meta = requestMeta(ctx);
|
|
2030
2301
|
const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
|
|
2031
2302
|
if (!upserted) {
|
|
2303
|
+
loginEvents.recordSafe({
|
|
2304
|
+
userId: null,
|
|
2305
|
+
emailAttempted: normalizedEmail,
|
|
2306
|
+
method: "oauth-google",
|
|
2307
|
+
outcome: "failed",
|
|
2308
|
+
failureReason: "provider_upsert_failed",
|
|
2309
|
+
...meta
|
|
2310
|
+
});
|
|
2032
2311
|
return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
2033
2312
|
}
|
|
2034
2313
|
const fullUser = await users.findById(upserted.id);
|
|
@@ -2038,7 +2317,14 @@ function oauthController(store, config) {
|
|
|
2038
2317
|
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
|
|
2039
2318
|
loginMethod: "google"
|
|
2040
2319
|
});
|
|
2041
|
-
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
2320
|
+
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
2321
|
+
loginEvents.recordSafe({
|
|
2322
|
+
userId: upserted.id,
|
|
2323
|
+
emailAttempted: normalizedEmail,
|
|
2324
|
+
method: "oauth-google",
|
|
2325
|
+
outcome: "success",
|
|
2326
|
+
...meta
|
|
2327
|
+
});
|
|
2042
2328
|
return Response.json(
|
|
2043
2329
|
{
|
|
2044
2330
|
reason: "GOOGLE_AUTH_SUCCESS",
|
|
@@ -2096,6 +2382,13 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2096
2382
|
R("changePassword", "PUT", "/users/password", requireAuth, validate(changePasswordSchema), user.changePassword),
|
|
2097
2383
|
R("deleteMe", "DELETE", "/users", requireAuth, verifyGate, user.deleteMe),
|
|
2098
2384
|
R("exportMe", "GET", "/users/export", requireAuth, user.exportMe),
|
|
2385
|
+
// Security surfaces (Protected; the caller's own login history + sessions).
|
|
2386
|
+
// The literal /others route is registered before /:id so it isn't
|
|
2387
|
+
// captured as an id.
|
|
2388
|
+
R("loginHistory", "GET", "/auth/login-history", requireAuth, user.loginHistory),
|
|
2389
|
+
R("listSessions", "GET", "/auth/sessions", requireAuth, user.listSessions),
|
|
2390
|
+
R("terminateOtherSessions", "DELETE", "/auth/sessions/others", requireAuth, user.terminateOtherSessions),
|
|
2391
|
+
R("terminateSession", "DELETE", "/auth/sessions/:id", requireAuth, user.terminateSession),
|
|
2099
2392
|
// MFA (email sessions only — requireVerified is always enforced here
|
|
2100
2393
|
// because MFA is a security feature and email verification is meaningful)
|
|
2101
2394
|
R("mfaSetup", "POST", "/auth/mfa/setup", requireAuth, requireEmailLogin, requireVerified, mfa.setup),
|
|
@@ -2216,7 +2509,10 @@ function withSession(store, config) {
|
|
|
2216
2509
|
...user,
|
|
2217
2510
|
loginMethod: payload.loginMethod ?? "email",
|
|
2218
2511
|
phoneVerified: payload.phoneVerified ?? false,
|
|
2219
|
-
mfaPending: payload.mfaPending ?? false
|
|
2512
|
+
mfaPending: payload.mfaPending ?? false,
|
|
2513
|
+
// The session this request is authenticated on, so the sessions
|
|
2514
|
+
// API can flag the current row and "terminate others" can spare it.
|
|
2515
|
+
sid: payload.sid ?? null
|
|
2220
2516
|
}
|
|
2221
2517
|
});
|
|
2222
2518
|
return next();
|