@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.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-jITwnc3m.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-jITwnc3m.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-jITwnc3m.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-jITwnc3m.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
|
@@ -86,6 +86,12 @@ var DEFAULTS = {
|
|
|
86
86
|
account: { capacity: 3, refillPerSec: 3 / min(60) },
|
|
87
87
|
accountField: "email"
|
|
88
88
|
},
|
|
89
|
+
// reset: 10/15min per IP — the reset PIN is a 6-digit code looked up
|
|
90
|
+
// globally, so an unthrottled endpoint is brute-forceable into an account
|
|
91
|
+
// takeover. There's no email in the reset body to key an account bucket on,
|
|
92
|
+
// so this IP bucket is the guard. (Moving reset to a high-entropy opaque
|
|
93
|
+
// token is tracked as a follow-up hardening.)
|
|
94
|
+
reset: { ip: { capacity: 10, refillPerSec: 10 / min(15) } },
|
|
89
95
|
// mfaVerify: 10/15min per IP — TOTP brute-force.
|
|
90
96
|
mfaVerify: { ip: { capacity: 10, refillPerSec: 10 / min(15) } }
|
|
91
97
|
};
|
|
@@ -691,17 +697,23 @@ var SessionModel = class {
|
|
|
691
697
|
this.store = store;
|
|
692
698
|
}
|
|
693
699
|
store;
|
|
694
|
-
async create(userId, token, expiresAt, sid) {
|
|
700
|
+
async create(userId, token, expiresAt, sid, meta) {
|
|
695
701
|
await this.store.query(
|
|
696
|
-
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid)
|
|
697
|
-
VALUES ($1, $2, $3, $4)
|
|
702
|
+
`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)
|
|
703
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
698
704
|
ON CONFLICT (token) DO NOTHING`,
|
|
699
|
-
[userId, token, expiresAt, sid ?? null]
|
|
705
|
+
[userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
|
|
700
706
|
);
|
|
701
707
|
}
|
|
702
708
|
async delete(token) {
|
|
703
709
|
await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
|
|
704
710
|
}
|
|
711
|
+
// Delete the session an access token is bound to (by its sid claim). Lets
|
|
712
|
+
// logout revoke the current session without the client resending the refresh
|
|
713
|
+
// token — the access token already identifies its session.
|
|
714
|
+
async deleteBySid(sid) {
|
|
715
|
+
await this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);
|
|
716
|
+
}
|
|
705
717
|
// Revoke every session for a user (e.g. on password change). Access tokens
|
|
706
718
|
// bound to these sessions via the sid claim die on their next request.
|
|
707
719
|
async deleteByUser(userId) {
|
|
@@ -721,6 +733,42 @@ var SessionModel = class {
|
|
|
721
733
|
[userId]
|
|
722
734
|
);
|
|
723
735
|
}
|
|
736
|
+
// Live sessions for the Active Sessions screen: unexpired only, carrying the
|
|
737
|
+
// sid so the caller can flag the current one (row.sid === the JWT's sid).
|
|
738
|
+
// No token — that never leaves the server.
|
|
739
|
+
async listLiveByUser(userId) {
|
|
740
|
+
return this.store.query(
|
|
741
|
+
`SELECT id, sid,
|
|
742
|
+
user_agent AS "userAgent",
|
|
743
|
+
ip_address AS "ipAddress",
|
|
744
|
+
created_at AS "createdAt",
|
|
745
|
+
expires_at AS "expiresAt"
|
|
746
|
+
FROM fonderie_sessions
|
|
747
|
+
WHERE user_id = $1 AND expires_at > now()
|
|
748
|
+
ORDER BY created_at DESC`,
|
|
749
|
+
[userId]
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
// Terminate one session by row id, scoped to its owner (a user can never
|
|
753
|
+
// delete another user's session). Returns whether a row was removed.
|
|
754
|
+
async terminateById(userId, id) {
|
|
755
|
+
const rows = await this.store.query(
|
|
756
|
+
`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,
|
|
757
|
+
[id, userId]
|
|
758
|
+
);
|
|
759
|
+
return rows.length > 0;
|
|
760
|
+
}
|
|
761
|
+
// Terminate all of a user's sessions except the current one (by sid).
|
|
762
|
+
// Returns the number of sessions removed.
|
|
763
|
+
async terminateOthers(userId, keepSid) {
|
|
764
|
+
const rows = await this.store.query(
|
|
765
|
+
`DELETE FROM fonderie_sessions
|
|
766
|
+
WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
|
|
767
|
+
RETURNING id`,
|
|
768
|
+
[userId, keepSid]
|
|
769
|
+
);
|
|
770
|
+
return rows.length;
|
|
771
|
+
}
|
|
724
772
|
async exists(token) {
|
|
725
773
|
const rows = await this.store.query(
|
|
726
774
|
`SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
|
|
@@ -773,11 +821,92 @@ var BackupCodeModel = class {
|
|
|
773
821
|
}
|
|
774
822
|
};
|
|
775
823
|
|
|
824
|
+
// src/models/login-event.model.ts
|
|
825
|
+
var LoginEventModel = class {
|
|
826
|
+
constructor(store) {
|
|
827
|
+
this.store = store;
|
|
828
|
+
}
|
|
829
|
+
store;
|
|
830
|
+
async record(e) {
|
|
831
|
+
await this.store.query(
|
|
832
|
+
`INSERT INTO fonderie_login_events
|
|
833
|
+
(user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent)
|
|
834
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
835
|
+
[
|
|
836
|
+
e.userId,
|
|
837
|
+
e.emailAttempted ?? null,
|
|
838
|
+
e.method,
|
|
839
|
+
e.outcome,
|
|
840
|
+
e.failureReason ?? null,
|
|
841
|
+
e.ipAddress,
|
|
842
|
+
e.userAgent
|
|
843
|
+
]
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
// Fire-and-forget variant for the login paths: recording history must never
|
|
847
|
+
// block or fail a login (same posture as the bus.emit(...).catch(() => {})
|
|
848
|
+
// notification sends).
|
|
849
|
+
recordSafe(e) {
|
|
850
|
+
void this.record(e).catch(() => {
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
// One page of a user's own login history, newest first. Keyset-paginated on
|
|
854
|
+
// (created_at, id) — mirrors audit's event list so the two log UIs share a
|
|
855
|
+
// cursor contract. The +1 over-fetch that detects a next page lives here so
|
|
856
|
+
// no outer clamp can shave it off.
|
|
857
|
+
async listByUser(query) {
|
|
858
|
+
const limit = Math.min(query.limit ?? 50, MAX_HISTORY_LIMIT);
|
|
859
|
+
const params = [query.userId];
|
|
860
|
+
const where = ["user_id = $1"];
|
|
861
|
+
if (query.outcome) {
|
|
862
|
+
params.push(query.outcome);
|
|
863
|
+
where.push(`outcome = $${params.length}`);
|
|
864
|
+
}
|
|
865
|
+
if (query.from) {
|
|
866
|
+
params.push(query.from);
|
|
867
|
+
where.push(`created_at >= $${params.length}`);
|
|
868
|
+
}
|
|
869
|
+
if (query.to) {
|
|
870
|
+
params.push(query.to);
|
|
871
|
+
where.push(`created_at <= $${params.length}`);
|
|
872
|
+
}
|
|
873
|
+
if (query.cursor) {
|
|
874
|
+
params.push(query.cursor.createdAt, query.cursor.id);
|
|
875
|
+
where.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
|
|
876
|
+
}
|
|
877
|
+
params.push(limit + 1);
|
|
878
|
+
const rows = await this.store.query(
|
|
879
|
+
`SELECT id, method, outcome, failure_reason AS "failureReason",
|
|
880
|
+
ip_address AS "ipAddress", user_agent AS "userAgent",
|
|
881
|
+
created_at AS "createdAt", created_at::text AS "createdAtRaw"
|
|
882
|
+
FROM fonderie_login_events
|
|
883
|
+
WHERE ${where.join(" AND ")}
|
|
884
|
+
ORDER BY created_at DESC, id DESC
|
|
885
|
+
LIMIT $${params.length}`,
|
|
886
|
+
params
|
|
887
|
+
);
|
|
888
|
+
return { events: rows.slice(0, limit), hasMore: rows.length > limit };
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
var MAX_HISTORY_LIMIT = 200;
|
|
892
|
+
|
|
893
|
+
// src/services/request-meta.ts
|
|
894
|
+
var UA_MAX = 512;
|
|
895
|
+
function requestMeta(ctx) {
|
|
896
|
+
const ip = ctx.meta["clientIp"];
|
|
897
|
+
const ua = ctx.request.headers.get("user-agent");
|
|
898
|
+
return {
|
|
899
|
+
ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
|
|
900
|
+
userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
776
904
|
// src/controllers/mfa.controller.ts
|
|
777
905
|
function mfaController(store, config, issuer, bus) {
|
|
778
906
|
const users = new UserModel(store);
|
|
779
907
|
const sessions = new SessionModel(store);
|
|
780
908
|
const backupCodes = new BackupCodeModel(store);
|
|
909
|
+
const loginEvents = new LoginEventModel(store);
|
|
781
910
|
const mfaCipher = makeMfaCipher(config.mfaSecretKey);
|
|
782
911
|
return {
|
|
783
912
|
// ── 1. Setup ───────────────────────────────────────────────
|
|
@@ -807,6 +936,16 @@ function mfaController(store, config, issuer, bus) {
|
|
|
807
936
|
verify: async (ctx) => {
|
|
808
937
|
const body = ctx.meta["body"];
|
|
809
938
|
const token = body?.["token"];
|
|
939
|
+
const isLoginCompletion = ctx.user.mfaPending === true;
|
|
940
|
+
const meta = requestMeta(ctx);
|
|
941
|
+
const failedMfa = () => loginEvents.recordSafe({
|
|
942
|
+
userId: ctx.user.id,
|
|
943
|
+
emailAttempted: ctx.user.email,
|
|
944
|
+
method: "mfa",
|
|
945
|
+
outcome: "failed",
|
|
946
|
+
failureReason: "invalid_code",
|
|
947
|
+
...meta
|
|
948
|
+
});
|
|
810
949
|
if (typeof token !== "string") {
|
|
811
950
|
return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
|
|
812
951
|
}
|
|
@@ -847,6 +986,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
847
986
|
);
|
|
848
987
|
const matched = checks.find((r) => r.match);
|
|
849
988
|
if (!matched) {
|
|
989
|
+
if (isLoginCompletion) failedMfa();
|
|
850
990
|
return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
|
|
851
991
|
}
|
|
852
992
|
await backupCodes.consume(matched.id);
|
|
@@ -864,6 +1004,7 @@ function mfaController(store, config, issuer, bus) {
|
|
|
864
1004
|
}
|
|
865
1005
|
const secret = mfaCipher.decrypt(storedSecret);
|
|
866
1006
|
if (!verifyTotpToken(token, secret)) {
|
|
1007
|
+
if (isLoginCompletion) failedMfa();
|
|
867
1008
|
return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
|
|
868
1009
|
}
|
|
869
1010
|
if (!ctx.user.mfaEnabled) {
|
|
@@ -874,7 +1015,16 @@ function mfaController(store, config, issuer, bus) {
|
|
|
874
1015
|
loginMethod: ctx.user.loginMethod,
|
|
875
1016
|
phoneVerified: ctx.user.phoneVerified
|
|
876
1017
|
});
|
|
877
|
-
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1018
|
+
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1019
|
+
if (isLoginCompletion) {
|
|
1020
|
+
loginEvents.recordSafe({
|
|
1021
|
+
userId: ctx.user.id,
|
|
1022
|
+
emailAttempted: ctx.user.email,
|
|
1023
|
+
method: "mfa",
|
|
1024
|
+
outcome: "success",
|
|
1025
|
+
...meta
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
878
1028
|
const fullUser = await users.findById(ctx.user.id);
|
|
879
1029
|
if (!fullUser) {
|
|
880
1030
|
return setApiResponse2(HTTP2.SERVER_ERROR, "SERVER_ERROR", "User not found after MFA verify");
|
|
@@ -1143,6 +1293,7 @@ function extractRefreshToken(ctx) {
|
|
|
1143
1293
|
function authController(store, config, bus) {
|
|
1144
1294
|
const users = new UserModel(store);
|
|
1145
1295
|
const sessions = new SessionModel(store);
|
|
1296
|
+
const loginEvents = new LoginEventModel(store);
|
|
1146
1297
|
const passwordReset = new PasswordResetModel(store);
|
|
1147
1298
|
const emailVerif = new EmailVerificationModel(store);
|
|
1148
1299
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
@@ -1212,7 +1363,7 @@ function authController(store, config, bus) {
|
|
|
1212
1363
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1213
1364
|
loginMethod: "email"
|
|
1214
1365
|
});
|
|
1215
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1366
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1216
1367
|
const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
|
|
1217
1368
|
const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
|
|
1218
1369
|
return Response.json(
|
|
@@ -1276,7 +1427,7 @@ function authController(store, config, bus) {
|
|
|
1276
1427
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1277
1428
|
loginMethod: "phone"
|
|
1278
1429
|
});
|
|
1279
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1430
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1280
1431
|
return Response.json(
|
|
1281
1432
|
{
|
|
1282
1433
|
reason: "USER_PHONE_REGISTERED",
|
|
@@ -1300,6 +1451,7 @@ function authController(store, config, bus) {
|
|
|
1300
1451
|
},
|
|
1301
1452
|
login: async (ctx) => {
|
|
1302
1453
|
const body = ctx.meta["body"];
|
|
1454
|
+
const meta = requestMeta(ctx);
|
|
1303
1455
|
if (typeof body?.["email"] === "string" && typeof body?.["password"] === "string") {
|
|
1304
1456
|
const { email: rawEmail, password: password2 } = body;
|
|
1305
1457
|
const email2 = normalizeEmailSafe(rawEmail);
|
|
@@ -1308,6 +1460,14 @@ function authController(store, config, bus) {
|
|
|
1308
1460
|
}
|
|
1309
1461
|
const user = await users.findByEmail(email2);
|
|
1310
1462
|
if (!user || !user.passwordHash) {
|
|
1463
|
+
loginEvents.recordSafe({
|
|
1464
|
+
userId: null,
|
|
1465
|
+
emailAttempted: email2,
|
|
1466
|
+
method: "password",
|
|
1467
|
+
outcome: "failed",
|
|
1468
|
+
failureReason: "unknown_email",
|
|
1469
|
+
...meta
|
|
1470
|
+
});
|
|
1311
1471
|
return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1312
1472
|
}
|
|
1313
1473
|
const { valid, needsRehash } = await verifyPasswordForLogin(
|
|
@@ -1316,6 +1476,14 @@ function authController(store, config, bus) {
|
|
|
1316
1476
|
config.legacyVerify
|
|
1317
1477
|
);
|
|
1318
1478
|
if (!valid) {
|
|
1479
|
+
loginEvents.recordSafe({
|
|
1480
|
+
userId: user.id,
|
|
1481
|
+
emailAttempted: email2,
|
|
1482
|
+
method: "password",
|
|
1483
|
+
outcome: "failed",
|
|
1484
|
+
failureReason: "bad_password",
|
|
1485
|
+
...meta
|
|
1486
|
+
});
|
|
1319
1487
|
return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
|
|
1320
1488
|
}
|
|
1321
1489
|
if (needsRehash) {
|
|
@@ -1337,7 +1505,14 @@ function authController(store, config, bus) {
|
|
|
1337
1505
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1338
1506
|
loginMethod: "email"
|
|
1339
1507
|
});
|
|
1340
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1508
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1509
|
+
loginEvents.recordSafe({
|
|
1510
|
+
userId: user.id,
|
|
1511
|
+
emailAttempted: email2,
|
|
1512
|
+
method: "password",
|
|
1513
|
+
outcome: "success",
|
|
1514
|
+
...meta
|
|
1515
|
+
});
|
|
1341
1516
|
const resolvedLogin = { ...config, ...config.resolve?.(ctx) };
|
|
1342
1517
|
const requiresVerification = !!resolvedLogin.requireVerification && !user.emailVerifiedAt;
|
|
1343
1518
|
return Response.json(
|
|
@@ -1382,7 +1557,7 @@ function authController(store, config, bus) {
|
|
|
1382
1557
|
const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
|
|
1383
1558
|
loginMethod: "phone"
|
|
1384
1559
|
});
|
|
1385
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1560
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
1386
1561
|
return Response.json(
|
|
1387
1562
|
{
|
|
1388
1563
|
reason: "USER_PHONE_OTP_SENT",
|
|
@@ -1405,6 +1580,10 @@ function authController(store, config, bus) {
|
|
|
1405
1580
|
);
|
|
1406
1581
|
},
|
|
1407
1582
|
logout: async (ctx) => {
|
|
1583
|
+
const sid = ctx.user?.sid;
|
|
1584
|
+
if (sid) {
|
|
1585
|
+
await sessions.deleteBySid(sid).catch(() => void 0);
|
|
1586
|
+
}
|
|
1408
1587
|
const token = extractRefreshToken(ctx);
|
|
1409
1588
|
if (token) {
|
|
1410
1589
|
await sessions.delete(token).catch(() => void 0);
|
|
@@ -1443,7 +1622,7 @@ function authController(store, config, bus) {
|
|
|
1443
1622
|
loginMethod: payload.loginMethod ?? "email",
|
|
1444
1623
|
phoneVerified: payload.phoneVerified ?? false
|
|
1445
1624
|
});
|
|
1446
|
-
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1625
|
+
await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1447
1626
|
return Response.json(
|
|
1448
1627
|
{
|
|
1449
1628
|
reason: "TOKENS_REFRESHED",
|
|
@@ -1577,7 +1756,7 @@ function authController(store, config, bus) {
|
|
|
1577
1756
|
loginMethod: "phone",
|
|
1578
1757
|
phoneVerified: true
|
|
1579
1758
|
});
|
|
1580
|
-
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
1759
|
+
await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
|
|
1581
1760
|
const verifiedUser = await users.findById(ctx.user.id);
|
|
1582
1761
|
if (!verifiedUser) {
|
|
1583
1762
|
return setApiResponse3(HTTP3.NOT_FOUND, "NOT_FOUND", "User not found");
|
|
@@ -1712,6 +1891,38 @@ function authController(store, config, bus) {
|
|
|
1712
1891
|
import { randomInt as randomInt2 } from "crypto";
|
|
1713
1892
|
import { setApiResponse as setApiResponse4, HTTP as HTTP4, dateOrEmpty as dateOrEmpty2 } from "@fonderie/core";
|
|
1714
1893
|
import { NOTIFICATION_EVENT as NOTIFICATION_EVENT3 } from "@fonderie/events";
|
|
1894
|
+
|
|
1895
|
+
// src/dtos/login-activity.ts
|
|
1896
|
+
import { encodeKeysetCursor, decodeKeysetCursor } from "@fonderie/core";
|
|
1897
|
+
function toLoginEventDTO(row) {
|
|
1898
|
+
return {
|
|
1899
|
+
id: row.id,
|
|
1900
|
+
method: row.method,
|
|
1901
|
+
outcome: row.outcome,
|
|
1902
|
+
failureReason: row.failureReason,
|
|
1903
|
+
ipAddress: row.ipAddress,
|
|
1904
|
+
userAgent: row.userAgent,
|
|
1905
|
+
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
function toLoginHistoryPageDTO(page) {
|
|
1909
|
+
const last = page.events[page.events.length - 1];
|
|
1910
|
+
const nextCursor = page.hasMore && last ? encodeKeysetCursor(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
|
|
1911
|
+
return { events: page.events.map(toLoginEventDTO), nextCursor };
|
|
1912
|
+
}
|
|
1913
|
+
var decodeLoginCursor = decodeKeysetCursor;
|
|
1914
|
+
function toSessionDTO(row, currentSid) {
|
|
1915
|
+
return {
|
|
1916
|
+
id: row.id,
|
|
1917
|
+
current: currentSid !== null && row.sid === currentSid,
|
|
1918
|
+
ipAddress: row.ipAddress,
|
|
1919
|
+
userAgent: row.userAgent,
|
|
1920
|
+
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
|
1921
|
+
expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// src/controllers/user.controller.ts
|
|
1715
1926
|
function normalizePhone2(phone2) {
|
|
1716
1927
|
return phone2.trim().replace(/[\s()\-\.]/g, "");
|
|
1717
1928
|
}
|
|
@@ -1721,6 +1932,7 @@ function isValidPhone2(phone2) {
|
|
|
1721
1932
|
function userController(store, config, bus) {
|
|
1722
1933
|
const users = new UserModel(store);
|
|
1723
1934
|
const sessions = new SessionModel(store);
|
|
1935
|
+
const loginEvents = new LoginEventModel(store);
|
|
1724
1936
|
const emailVerif = new EmailVerificationModel(store);
|
|
1725
1937
|
const phoneVerif = new PhoneVerificationModel(store);
|
|
1726
1938
|
return {
|
|
@@ -1733,6 +1945,69 @@ function userController(store, config, bus) {
|
|
|
1733
1945
|
user: toUserDTO(user, ctx.user.phoneVerified)
|
|
1734
1946
|
});
|
|
1735
1947
|
},
|
|
1948
|
+
// ── Login history (append-only; the caller's own attempts) ────────
|
|
1949
|
+
loginHistory: async (ctx) => {
|
|
1950
|
+
const params = new URL(ctx.request.url).searchParams;
|
|
1951
|
+
const rawLimit = Number(params.get("limit") ?? 50);
|
|
1952
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
|
|
1953
|
+
const outcomeParam = params.get("outcome");
|
|
1954
|
+
if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
|
|
1955
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
|
|
1956
|
+
}
|
|
1957
|
+
const cursorParam = params.get("cursor");
|
|
1958
|
+
let cursor = null;
|
|
1959
|
+
if (cursorParam) {
|
|
1960
|
+
cursor = decodeLoginCursor(cursorParam);
|
|
1961
|
+
if (!cursor) {
|
|
1962
|
+
return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
const query = { userId: ctx.user.id, limit };
|
|
1966
|
+
if (outcomeParam) query.outcome = outcomeParam;
|
|
1967
|
+
if (cursor) query.cursor = cursor;
|
|
1968
|
+
const from = params.get("from");
|
|
1969
|
+
const to = params.get("to");
|
|
1970
|
+
if (from) query.from = new Date(from);
|
|
1971
|
+
if (to) query.to = new Date(to);
|
|
1972
|
+
const page = await loginEvents.listByUser(query);
|
|
1973
|
+
return setApiResponse4(
|
|
1974
|
+
HTTP4.OK,
|
|
1975
|
+
"LOGIN_HISTORY_FETCHED",
|
|
1976
|
+
"Login history retrieved.",
|
|
1977
|
+
toLoginHistoryPageDTO(page)
|
|
1978
|
+
);
|
|
1979
|
+
},
|
|
1980
|
+
// ── Active sessions (live only; the caller's own) ─────────────────
|
|
1981
|
+
listSessions: async (ctx) => {
|
|
1982
|
+
const rows = await sessions.listLiveByUser(ctx.user.id);
|
|
1983
|
+
const currentSid = ctx.user.sid ?? null;
|
|
1984
|
+
return setApiResponse4(HTTP4.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
|
|
1985
|
+
sessions: rows.map((r) => toSessionDTO(r, currentSid))
|
|
1986
|
+
});
|
|
1987
|
+
},
|
|
1988
|
+
terminateSession: async (ctx) => {
|
|
1989
|
+
const id = ctx.meta["params"]?.["id"];
|
|
1990
|
+
if (!id) {
|
|
1991
|
+
return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
1992
|
+
}
|
|
1993
|
+
const removed = await sessions.terminateById(ctx.user.id, id);
|
|
1994
|
+
if (!removed) {
|
|
1995
|
+
return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
|
|
1996
|
+
}
|
|
1997
|
+
return setApiResponse4(HTTP4.OK, "SESSION_TERMINATED", "Session terminated.", { id });
|
|
1998
|
+
},
|
|
1999
|
+
terminateOtherSessions: async (ctx) => {
|
|
2000
|
+
const currentSid = ctx.user.sid ?? null;
|
|
2001
|
+
if (!currentSid) {
|
|
2002
|
+
return setApiResponse4(
|
|
2003
|
+
HTTP4.UNPROCESSABLE,
|
|
2004
|
+
"NO_CURRENT_SESSION",
|
|
2005
|
+
"Cannot terminate others without a current session context"
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
const count = await sessions.terminateOthers(ctx.user.id, currentSid);
|
|
2009
|
+
return setApiResponse4(HTTP4.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
|
|
2010
|
+
},
|
|
1736
2011
|
updateProfile: async (ctx) => {
|
|
1737
2012
|
const body = ctx.meta["body"];
|
|
1738
2013
|
const allowed = ["firstName", "lastName", "avatarUrl"];
|
|
@@ -1960,6 +2235,7 @@ import { setApiResponse as setApiResponse5, HTTP as HTTP5 } from "@fonderie/core
|
|
|
1960
2235
|
function oauthController(store, config) {
|
|
1961
2236
|
const users = new UserModel(store);
|
|
1962
2237
|
const sessions = new SessionModel(store);
|
|
2238
|
+
const loginEvents = new LoginEventModel(store);
|
|
1963
2239
|
return {
|
|
1964
2240
|
googleInit: async (_ctx) => {
|
|
1965
2241
|
const google = config.google;
|
|
@@ -2027,8 +2303,17 @@ function oauthController(store, config) {
|
|
|
2027
2303
|
if (!normalizedEmail) {
|
|
2028
2304
|
return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
|
|
2029
2305
|
}
|
|
2306
|
+
const meta = requestMeta(ctx);
|
|
2030
2307
|
const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
|
|
2031
2308
|
if (!upserted) {
|
|
2309
|
+
loginEvents.recordSafe({
|
|
2310
|
+
userId: null,
|
|
2311
|
+
emailAttempted: normalizedEmail,
|
|
2312
|
+
method: "oauth-google",
|
|
2313
|
+
outcome: "failed",
|
|
2314
|
+
failureReason: "provider_upsert_failed",
|
|
2315
|
+
...meta
|
|
2316
|
+
});
|
|
2032
2317
|
return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
2033
2318
|
}
|
|
2034
2319
|
const fullUser = await users.findById(upserted.id);
|
|
@@ -2038,7 +2323,14 @@ function oauthController(store, config) {
|
|
|
2038
2323
|
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
|
|
2039
2324
|
loginMethod: "google"
|
|
2040
2325
|
});
|
|
2041
|
-
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
|
|
2326
|
+
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
2327
|
+
loginEvents.recordSafe({
|
|
2328
|
+
userId: upserted.id,
|
|
2329
|
+
emailAttempted: normalizedEmail,
|
|
2330
|
+
method: "oauth-google",
|
|
2331
|
+
outcome: "success",
|
|
2332
|
+
...meta
|
|
2333
|
+
});
|
|
2042
2334
|
return Response.json(
|
|
2043
2335
|
{
|
|
2044
2336
|
reason: "GOOGLE_AUTH_SUCCESS",
|
|
@@ -2081,7 +2373,7 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2081
2373
|
R("refresh", "POST", "/auth/refresh", validate(refreshSchema), auth.refresh),
|
|
2082
2374
|
// Email — Password Recovery (Public)
|
|
2083
2375
|
R("forgotPassword", "POST", "/auth/email/forgot", ipLimit("forgot"), validate(forgotPasswordSchema), acctLimit("forgot"), auth.forgotPassword),
|
|
2084
|
-
R("resetPassword", "POST", "/auth/email/reset", validate(resetPasswordSchema), auth.resetPassword),
|
|
2376
|
+
R("resetPassword", "POST", "/auth/email/reset", ipLimit("reset"), validate(resetPasswordSchema), auth.resetPassword),
|
|
2085
2377
|
// Verification (Protected — email or phone, determined by loginMethod)
|
|
2086
2378
|
R("verifyEmail", "POST", "/auth/verify", requireAuth, validate(verifySchema), auth.verify),
|
|
2087
2379
|
R("sendVerification", "GET", "/auth/send-verification", requireAuth, auth.sendVerification),
|
|
@@ -2096,6 +2388,13 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2096
2388
|
R("changePassword", "PUT", "/users/password", requireAuth, validate(changePasswordSchema), user.changePassword),
|
|
2097
2389
|
R("deleteMe", "DELETE", "/users", requireAuth, verifyGate, user.deleteMe),
|
|
2098
2390
|
R("exportMe", "GET", "/users/export", requireAuth, user.exportMe),
|
|
2391
|
+
// Security surfaces (Protected; the caller's own login history + sessions).
|
|
2392
|
+
// The literal /others route is registered before /:id so it isn't
|
|
2393
|
+
// captured as an id.
|
|
2394
|
+
R("loginHistory", "GET", "/auth/login-history", requireAuth, user.loginHistory),
|
|
2395
|
+
R("listSessions", "GET", "/auth/sessions", requireAuth, user.listSessions),
|
|
2396
|
+
R("terminateOtherSessions", "DELETE", "/auth/sessions/others", requireAuth, user.terminateOtherSessions),
|
|
2397
|
+
R("terminateSession", "DELETE", "/auth/sessions/:id", requireAuth, user.terminateSession),
|
|
2099
2398
|
// MFA (email sessions only — requireVerified is always enforced here
|
|
2100
2399
|
// because MFA is a security feature and email verification is meaningful)
|
|
2101
2400
|
R("mfaSetup", "POST", "/auth/mfa/setup", requireAuth, requireEmailLogin, requireVerified, mfa.setup),
|
|
@@ -2216,7 +2515,10 @@ function withSession(store, config) {
|
|
|
2216
2515
|
...user,
|
|
2217
2516
|
loginMethod: payload.loginMethod ?? "email",
|
|
2218
2517
|
phoneVerified: payload.phoneVerified ?? false,
|
|
2219
|
-
mfaPending: payload.mfaPending ?? false
|
|
2518
|
+
mfaPending: payload.mfaPending ?? false,
|
|
2519
|
+
// The session this request is authenticated on, so the sessions
|
|
2520
|
+
// API can flag the current row and "terminate others" can spare it.
|
|
2521
|
+
sid: payload.sid ?? null
|
|
2220
2522
|
}
|
|
2221
2523
|
});
|
|
2222
2524
|
return next();
|