@gatwech/auth 1.0.0-beta.18 → 1.0.0-beta.20
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/README.md +8 -14
- package/dist/{access-C6O_osEz.mjs → access-CrwEFPHE.mjs} +1 -1
- package/dist/{access-DT1f6l5k.mjs → access-bhklJd7m.mjs} +1 -1
- package/dist/{adapter-factory-BZ2Qiqpn.mjs → adapter-factory-xbLUJt85.mjs} +2 -2
- package/dist/adapters/drizzle-adapter/index.mjs +6 -5
- package/dist/adapters/index.d.mts +5 -5
- package/dist/adapters/index.mjs +6 -5
- package/dist/adapters/kysely-adapter/index.d.mts +1 -1
- package/dist/adapters/kysely-adapter/index.mjs +8 -7
- package/dist/adapters/memory-adapter/index.d.mts +1 -1
- package/dist/adapters/memory-adapter/index.mjs +6 -5
- package/dist/adapters/mongodb-adapter/index.mjs +6 -5
- package/dist/adapters/prisma-adapter/index.mjs +6 -5
- package/dist/adapters/test.mjs +4 -3
- package/dist/{admin-Bcq53Nwj.mjs → admin-BzUJL6tr.mjs} +6 -6
- package/dist/{anonymous-CRt1imBW.mjs → anonymous-BjlTk4gl.mjs} +5 -5
- package/dist/api/index.d.mts +5 -5
- package/dist/api/index.mjs +12 -11
- package/dist/{api-0pR_I-Nx.mjs → api-Dk8Yj_J5.mjs} +15 -5
- package/dist/audit-log-DKsUWR8O.mjs +772 -0
- package/dist/{auth-CBUoBX6o.mjs → auth-vfCyIIeV.mjs} +8 -8
- package/dist/{bearer-Djf_awfo.mjs → bearer-DvQLM00l.mjs} +2 -2
- package/dist/client/index.d.mts +19 -19
- package/dist/client/index.mjs +4 -4
- package/dist/client/plugins/index.d.mts +42 -34
- package/dist/client/plugins/index.mjs +16 -12
- package/dist/client/react/index.d.mts +5 -5
- package/dist/client/react/index.mjs +2 -2
- package/dist/client-4QEb0rUr.mjs +95 -0
- package/dist/client-C_qmyez2.d.mts +75 -0
- package/dist/client-DzOqXJUm.mjs +15 -0
- package/dist/client-F67q-Lbi.d.mts +14 -0
- package/dist/{client-aExCkfwm.mjs → client-xga7yFIX.mjs} +2 -2
- package/dist/cookies/index.d.mts +6 -6
- package/dist/cookies/index.mjs +2 -2
- package/dist/{cookies-cJObLwn1.mjs → cookies-BuvlgcA-.mjs} +1 -1
- package/dist/crypto/index.mjs +2 -1
- package/dist/{crypto-Bb42eMrH.mjs → crypto-CCyta8xj.mjs} +1 -6
- package/dist/{custom-session-L3t7ecsd.mjs → custom-session-DN_-pfi_.mjs} +2 -2
- package/dist/db/index.d.mts +5 -5
- package/dist/db/index.mjs +9 -8
- package/dist/{db-BrstmDhc.mjs → db-7vHnL1XA.mjs} +15 -9
- package/dist/{device-authorization-V4s3h4Mj.mjs → device-authorization-BKnNhlsz.mjs} +4 -4
- package/dist/{dialect-CeddeGHf.mjs → dialect-HsWUfpYY.mjs} +2 -2
- package/dist/{email-otp-BtDHSw14.mjs → email-otp-Dw0DypjQ.mjs} +7 -6
- package/dist/{generic-oauth-CL5X27ZI.mjs → generic-oauth-DFj27JY8.mjs} +4 -4
- package/dist/{has-permission-BpAr7uCn.mjs → has-permission-Bvg8de8g.mjs} +1 -1
- package/dist/{haveibeenpwned-cMpJvmzI.mjs → haveibeenpwned-CzNda7Tn.mjs} +28 -15
- package/dist/{index-BYMI9r_d.d.mts → index-4I1YhYhk.d.mts} +4 -4
- package/dist/{index-Cxi7TbLE.d.mts → index-BNhJglHC.d.mts} +122 -122
- package/dist/{index-DlaMq1nb.d.mts → index-BX7rl07Z.d.mts} +14 -14
- package/dist/{client-BOX93C3d.d.mts → index-Bml-PCqP.d.mts} +52 -52
- package/dist/{index-zjjNBRY2.d.mts → index-BsSKcKgO.d.mts} +6 -6
- package/dist/{index-Buhn3hkR.d.mts → index-CBiGsvDd.d.mts} +8 -8
- package/dist/{index-D_k662QG.d.mts → index-CC920PPp.d.mts} +14 -14
- package/dist/{index-B3n8e37d.d.mts → index-CNd5jqLl.d.mts} +7 -7
- package/dist/{index-BqhKCbPH.d.mts → index-CP8x1TFq.d.mts} +6 -6
- package/dist/{index-DKNNV1kb.d.mts → index-CofuaNMl.d.mts} +8 -8
- package/dist/{index-DR0xXno9.d.mts → index-CsxtcAO0.d.mts} +1 -1
- package/dist/{index-C7fQuk-V.d.mts → index-CxlWnL9c.d.mts} +1 -1
- package/dist/{index-CuFzhoS6.d.mts → index-CzpdkTdv.d.mts} +10 -10
- package/dist/index-D57DuhZR.d.mts +3769 -0
- package/dist/{index-C-u1h_WX.d.mts → index-D6ku0rUC.d.mts} +5 -5
- package/dist/{client-CNY0tn9g.d.mts → index-DFSRYWFI.d.mts} +21 -21
- package/dist/{index-BBm8nQ9Q.d.mts → index-DGeoJ4gu.d.mts} +2 -2
- package/dist/{index-G6V0Z_Mn.d.mts → index-DTkcY7xH.d.mts} +10 -10
- package/dist/{index-65G5U5DB-DSdjzreF.d.mts → index-Df0KhQRf-BEa5tdwJ.d.mts} +3 -2
- package/dist/{index-34ONrP8l.d.mts → index-Dnd8S4S4.d.mts} +11 -11
- package/dist/{index-B-XKqFvY.d.mts → index-DwPAddfE.d.mts} +11 -4
- package/dist/{index-DoXZYQpq.d.mts → index-DyXi7S2M.d.mts} +10 -10
- package/dist/{index-BHoQlf7a.d.mts → index-KhbNmMmG.d.mts} +178 -178
- package/dist/{index-z0KSvE5i.d.mts → index-QmocqGM-.d.mts} +1 -1
- package/dist/{admin-DtfO1PLg.d.mts → index-XfE0WjTN.d.mts} +36 -36
- package/dist/{index-DaBcikv9.d.mts → index-sb-UFLuo.d.mts} +2 -2
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +16 -15
- package/dist/integrations/next-js.d.mts +4 -4
- package/dist/integrations/next-js.mjs +2 -2
- package/dist/integrations/node.d.mts +5 -5
- package/dist/integrations/react-start.d.mts +4 -4
- package/dist/integrations/react-start.mjs +2 -2
- package/dist/{jwt-lHV4QHpD.mjs → jwt-DBN2mu0r.mjs} +4 -4
- package/dist/{kysely-adapter-DpzYO8Yf.mjs → kysely-adapter-Bkm1liPW.mjs} +1 -1
- package/dist/{magic-link-Bk-ueorR.mjs → magic-link-D9Em1aOR.mjs} +3 -3
- package/dist/{multi-session-DRrLKrg9.mjs → multi-session-Dno_8DxC.mjs} +5 -5
- package/dist/{oauth-proxy-D1sWh9B_.mjs → oauth-proxy-DW-zt8wv.mjs} +2 -2
- package/dist/oauth2/index.d.mts +5 -5
- package/dist/oauth2/index.mjs +12 -11
- package/dist/{one-tap-DMswkTEc.mjs → one-tap-CodeyQb8.mjs} +2 -2
- package/dist/{organization-BArHO_5v.mjs → organization-CkTKs7bx.mjs} +9 -9
- package/dist/{passkey-CcD4CnhM.mjs → passkey-JNQTz5a0.mjs} +5 -5
- package/dist/{phone-number-rHDIlh-2.mjs → phone-number-Dqz8aeyF.mjs} +4 -4
- package/dist/plugins/access/index.d.mts +2 -2
- package/dist/plugins/access/index.mjs +1 -1
- package/dist/plugins/admin/access/index.d.mts +2 -2
- package/dist/plugins/admin/access/index.mjs +2 -2
- package/dist/plugins/admin/index.d.mts +7 -8
- package/dist/plugins/admin/index.mjs +17 -16
- package/dist/plugins/anonymous/index.d.mts +6 -6
- package/dist/plugins/anonymous/index.mjs +13 -12
- package/dist/plugins/audit-log/client.d.mts +30 -0
- package/dist/plugins/audit-log/client.mjs +3 -0
- package/dist/plugins/audit-log/index.d.mts +29 -0
- package/dist/plugins/audit-log/index.mjs +20 -0
- package/dist/plugins/bearer/index.d.mts +1 -1
- package/dist/plugins/bearer/index.mjs +3 -3
- package/dist/plugins/captcha/index.d.mts +1 -1
- package/dist/plugins/captcha/index.mjs +1 -1
- package/dist/plugins/custom-session/index.d.mts +6 -6
- package/dist/plugins/custom-session/index.mjs +14 -13
- package/dist/plugins/device-authorization/index.d.mts +3 -3
- package/dist/plugins/device-authorization/index.mjs +13 -12
- package/dist/plugins/email-otp/index.d.mts +2 -2
- package/dist/plugins/email-otp/index.mjs +14 -13
- package/dist/plugins/generic-oauth/index.d.mts +6 -6
- package/dist/plugins/generic-oauth/index.mjs +13 -12
- package/dist/plugins/haveibeenpwned/index.d.mts +2 -2
- package/dist/plugins/haveibeenpwned/index.mjs +14 -13
- package/dist/plugins/index.d.mts +29 -139
- package/dist/plugins/index.mjs +47 -42
- package/dist/plugins/jwt/index.d.mts +6 -6
- package/dist/plugins/jwt/index.mjs +13 -12
- package/dist/plugins/magic-link/index.d.mts +1 -1
- package/dist/plugins/magic-link/index.mjs +13 -12
- package/dist/plugins/multi-session/index.d.mts +2 -2
- package/dist/plugins/multi-session/index.mjs +13 -12
- package/dist/plugins/oauth-proxy/index.d.mts +1 -1
- package/dist/plugins/oauth-proxy/index.mjs +13 -12
- package/dist/plugins/one-tap/index.d.mts +1 -1
- package/dist/plugins/one-tap/index.mjs +13 -12
- package/dist/plugins/one-time-token/index.d.mts +6 -6
- package/dist/plugins/one-time-token/index.mjs +46 -41
- package/dist/plugins/organization/access/index.d.mts +3 -3
- package/dist/plugins/organization/access/index.mjs +2 -2
- package/dist/plugins/organization/index.d.mts +8 -8
- package/dist/plugins/organization/index.mjs +17 -16
- package/dist/plugins/passkey/index.d.mts +6 -6
- package/dist/plugins/passkey/index.mjs +13 -12
- package/dist/plugins/phone-number/index.d.mts +6 -6
- package/dist/plugins/phone-number/index.mjs +13 -12
- package/dist/plugins/sentinel/client.d.mts +30 -0
- package/dist/plugins/sentinel/client.mjs +5 -0
- package/dist/plugins/sentinel/index.d.mts +29 -0
- package/dist/plugins/sentinel/index.mjs +22 -0
- package/dist/plugins/two-factor/index.d.mts +6 -6
- package/dist/plugins/two-factor/index.mjs +15 -14
- package/dist/plugins/username/index.d.mts +3 -3
- package/dist/plugins/username/index.mjs +13 -12
- package/dist/{plugins-hFZT3hmK.d.mts → plugins-Dy_FrD4Y.d.mts} +1 -1
- package/dist/{plugins-3jfNste3.mjs → plugins-yF8CFUDR.mjs} +11 -8
- package/dist/pow-OK01yPG3.mjs +61 -0
- package/dist/{proxy-CL9PZXJA.mjs → proxy-C_KXfOhk.mjs} +1 -1
- package/dist/random-32E3cU5o.mjs +7 -0
- package/dist/registry-CqG_3Qnx.mjs +194 -0
- package/dist/sentinel-DiPqydKG.mjs +1426 -0
- package/dist/{session-Dgp133Yt.mjs → session-BB3oqApE.mjs} +2 -2
- package/dist/src/adapters/memory-adapter/memory-adapter.d.ts +1 -1
- package/dist/src/adapters/prisma-adapter/test/auth.d.ts +1 -1
- package/dist/src/adapters/tests/auth-flow.d.ts +3 -3
- package/dist/src/adapters/tests/normal.d.ts +1 -1
- package/dist/src/adapters/tests/number-id.d.ts +3 -3
- package/dist/src/adapters/tests/performance.d.ts +3 -3
- package/dist/src/adapters/tests/transactions.d.ts +3 -3
- package/dist/src/api/index.d.ts +8 -8
- package/dist/src/client/plugins/index.d.ts +2 -0
- package/dist/src/plugins/admin/admin.d.ts +2 -2
- package/dist/src/plugins/api-key/index.d.ts +2 -2
- package/dist/src/plugins/audit-log/client.d.ts +10 -0
- package/dist/src/plugins/audit-log/events.d.ts +90 -0
- package/dist/src/plugins/audit-log/index.d.ts +614 -0
- package/dist/src/plugins/audit-log/registry.d.ts +13 -0
- package/dist/src/plugins/audit-log/remote.d.ts +34 -0
- package/dist/src/plugins/audit-log/schema.d.ts +50 -0
- package/dist/src/plugins/audit-log/types.d.ts +117 -0
- package/dist/src/plugins/email-otp/index.d.ts +1 -1
- package/dist/src/plugins/haveibeenpwned/index.d.ts +6 -0
- package/dist/src/plugins/index.d.ts +2 -0
- package/dist/src/plugins/multi-session/index.d.ts +8 -8
- package/dist/src/plugins/organization/organization.d.ts +5 -5
- package/dist/src/plugins/organization/routes/crud-access-control.d.ts +8 -8
- package/dist/src/plugins/organization/routes/crud-invites.d.ts +46 -46
- package/dist/src/plugins/organization/routes/crud-members.d.ts +40 -40
- package/dist/src/plugins/organization/routes/crud-org.d.ts +36 -36
- package/dist/src/plugins/organization/routes/crud-team.d.ts +50 -50
- package/dist/src/plugins/sentinel/actions.d.ts +64 -0
- package/dist/src/plugins/sentinel/client.d.ts +67 -0
- package/dist/src/plugins/sentinel/email.d.ts +28 -0
- package/dist/src/plugins/sentinel/geo.d.ts +22 -0
- package/dist/src/plugins/sentinel/index.d.ts +326 -0
- package/dist/src/plugins/sentinel/intel.d.ts +45 -0
- package/dist/src/plugins/sentinel/pow.d.ts +21 -0
- package/dist/src/plugins/sentinel/schema.d.ts +110 -0
- package/dist/src/plugins/sentinel/store.d.ts +37 -0
- package/dist/src/plugins/sentinel/types.d.ts +412 -0
- package/dist/src/test-utils/memory-secondary-storage.d.ts +16 -0
- package/dist/src/test-utils/password.d.ts +12 -0
- package/dist/src/test-utils/test-instance.d.ts +25 -25
- package/dist/test-utils/index.d.mts +293 -293
- package/dist/test-utils/index.mjs +53 -48
- package/dist/{two-factor-ClUXxom2.mjs → two-factor-CDVjxx4f.mjs} +6 -5
- package/dist/types/index.d.mts +5 -5
- package/dist/types/index.mjs +1 -1
- package/dist/{username-DiGklOGl.mjs → username-BHNvcvsY.mjs} +3 -3
- package/dist/{utils-SOMB4Px5.mjs → utils-B03WRaGi.mjs} +2 -1
- package/package.json +36 -3
- package/dist/index-CM-CFWwr.d.mts +0 -1919
- package/dist/index-DB7t8whN.d.mts +0 -1
- /package/dist/{access-BO2g7QVe.mjs → access-BM6jSA5j.mjs} +0 -0
- /package/dist/{bun-sqlite-dialect-CeNvMaZz.mjs → bun-sqlite-dialect-CDGusFqF.mjs} +0 -0
- /package/dist/{captcha-BqaMxlKC.mjs → captcha-DGP9lCRe.mjs} +0 -0
- /package/dist/{client-CygJhqeB.mjs → client-BuveH3MN.mjs} +0 -0
- /package/dist/{client-WiN43tAS.mjs → client-cBiytuOL.mjs} +0 -0
- /package/dist/{date-Cx2yCiC4.mjs → date-CMr04reD.mjs} +0 -0
- /package/dist/{esm-C-QicDNz.mjs → esm-BbvI87Z2.mjs} +0 -0
- /package/dist/{get-tables-BQP7YW9c.mjs → get-tables-CKvSyDST.mjs} +0 -0
- /package/dist/{helper-tmxW3BHj.d.mts → helper-BgOIGHsu.d.mts} +0 -0
- /package/dist/{misc-beA1w3XV.mjs → misc-fKH4d5XQ.mjs} +0 -0
- /package/dist/{node-sqlite-dialect-BcKxBQnm.mjs → node-sqlite-dialect-CIOjcg31.mjs} +0 -0
- /package/dist/{parser-DcrIEuXE.mjs → parser-D7EkhAjZ.mjs} +0 -0
- /package/dist/{password-sVC4nePO.mjs → password-5CdcAVaH.mjs} +0 -0
- /package/dist/{permission-DLYUF3cW.mjs → permission-CDDWgA_O.mjs} +0 -0
- /package/dist/{plugin-helper-4L-lzsjZ.mjs → plugin-helper-RJJBTOba.mjs} +0 -0
- /package/dist/{promise-8rrEHRXd.mjs → promise-D7Oo6_cY.mjs} +0 -0
- /package/dist/{types-Bjn15Uy6.mjs → types-DCPpFKUB.mjs} +0 -0
- /package/dist/{types-B1OL7hyf.d.mts → types-DNQ9XQrB.d.mts} +0 -0
|
@@ -0,0 +1,1426 @@
|
|
|
1
|
+
import { t as getIp } from "./get-request-ip-DSjrEpGo.mjs";
|
|
2
|
+
import { t as safeJSONParse } from "./json-D0TBLy4q.mjs";
|
|
3
|
+
import { n as deleteSessionCookie } from "./cookies-BuvlgcA-.mjs";
|
|
4
|
+
import { n as mergeSchema } from "./date-CMr04reD.mjs";
|
|
5
|
+
import { a as CREDENTIAL_SIGN_IN_PATHS, c as SIGN_IN_PATHS, l as VERIFICATION_PATHS, s as SECURITY_EVENT_TYPES, t as getAuditRecorder } from "./registry-CqG_3Qnx.mjs";
|
|
6
|
+
import { t as getPasswordBreachCount } from "./haveibeenpwned-CzNda7Tn.mjs";
|
|
7
|
+
import { c as verifySolution, i as createNonce, n as POW_REASON_HEADER, o as parsePowHeader, r as POW_SOLUTION_HEADER, t as POW_CHALLENGE_HEADER } from "./pow-OK01yPG3.mjs";
|
|
8
|
+
import { env } from "@gatwech/core/env";
|
|
9
|
+
import { PayvoAuthError } from "@gatwech/core/error";
|
|
10
|
+
import { defineErrorCodes } from "@gatwech/core/utils";
|
|
11
|
+
import { APIError } from "better-call";
|
|
12
|
+
import { createAuthMiddleware } from "@gatwech/core/api";
|
|
13
|
+
import { betterFetch } from "@better-fetch/fetch";
|
|
14
|
+
|
|
15
|
+
//#region src/plugins/sentinel/actions.ts
|
|
16
|
+
const CHALLENGE_TTL_MS = 6e4;
|
|
17
|
+
/**
|
|
18
|
+
* Applies a security action: records the event and decides whether the
|
|
19
|
+
* request continues, gets a proof-of-work challenge or is blocked
|
|
20
|
+
*/
|
|
21
|
+
async function executeAction(params) {
|
|
22
|
+
const { auth, event, action, reason } = params;
|
|
23
|
+
const recorder = getAuditRecorder(auth);
|
|
24
|
+
const record = async (eventType, status) => {
|
|
25
|
+
if (!recorder) {
|
|
26
|
+
auth.logger.warn(`[sentinel] ${reason}: ${status}`, params.metadata);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
await recorder(event, {
|
|
30
|
+
eventType,
|
|
31
|
+
status,
|
|
32
|
+
userId: params.userId ?? null,
|
|
33
|
+
identifier: params.identifier ?? null,
|
|
34
|
+
ipAddress: params.ipAddress,
|
|
35
|
+
visitorId: params.visitorId,
|
|
36
|
+
eventData: {
|
|
37
|
+
reason,
|
|
38
|
+
action,
|
|
39
|
+
...params.metadata
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
if (action === "log") {
|
|
44
|
+
await record(params.eventType, "success");
|
|
45
|
+
return { outcome: "allow" };
|
|
46
|
+
}
|
|
47
|
+
if (action === "challenge") {
|
|
48
|
+
if (params.challengePassed) {
|
|
49
|
+
await record(SECURITY_EVENT_TYPES.ALLOWED_AFTER_CHALLENGE, "success");
|
|
50
|
+
return { outcome: "allow" };
|
|
51
|
+
}
|
|
52
|
+
const nonce = createNonce();
|
|
53
|
+
try {
|
|
54
|
+
await params.store.saveChallenge({
|
|
55
|
+
nonce,
|
|
56
|
+
difficulty: params.difficulty,
|
|
57
|
+
reason,
|
|
58
|
+
ipAddress: params.ipAddress,
|
|
59
|
+
visitorId: params.visitorId,
|
|
60
|
+
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS)
|
|
61
|
+
});
|
|
62
|
+
} catch (e) {
|
|
63
|
+
auth.logger.error("[sentinel] Failed to save challenge", e);
|
|
64
|
+
}
|
|
65
|
+
await record(params.eventType, "challenged");
|
|
66
|
+
return {
|
|
67
|
+
outcome: "challenge",
|
|
68
|
+
reason,
|
|
69
|
+
nonce,
|
|
70
|
+
difficulty: params.difficulty
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
await record(params.eventType, "blocked");
|
|
74
|
+
await record(SECURITY_EVENT_TYPES.BLOCKED, "blocked");
|
|
75
|
+
return {
|
|
76
|
+
outcome: "block",
|
|
77
|
+
reason
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function challengeHeaders(decision) {
|
|
81
|
+
return {
|
|
82
|
+
[POW_CHALLENGE_HEADER]: `${decision.nonce}:${decision.difficulty}`,
|
|
83
|
+
[POW_REASON_HEADER]: decision.reason,
|
|
84
|
+
"access-control-expose-headers": `${POW_CHALLENGE_HEADER}, ${POW_REASON_HEADER}`
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const SENTINEL_ERROR_CODES = defineErrorCodes({
|
|
88
|
+
POW_CHALLENGE_REQUIRED: "Challenge required",
|
|
89
|
+
SECURITY_BLOCKED: "Request blocked for security reasons"
|
|
90
|
+
});
|
|
91
|
+
/** The error for a decision, or null when the request is allowed */
|
|
92
|
+
function errorForDecision(decision) {
|
|
93
|
+
if (decision.outcome === "allow") return null;
|
|
94
|
+
if (decision.outcome === "challenge") return new APIError("LOCKED", {
|
|
95
|
+
message: SENTINEL_ERROR_CODES.POW_CHALLENGE_REQUIRED,
|
|
96
|
+
code: "POW_CHALLENGE_REQUIRED",
|
|
97
|
+
challenge: {
|
|
98
|
+
nonce: decision.nonce,
|
|
99
|
+
difficulty: decision.difficulty,
|
|
100
|
+
reason: decision.reason
|
|
101
|
+
}
|
|
102
|
+
}, challengeHeaders(decision));
|
|
103
|
+
return new APIError("FORBIDDEN", {
|
|
104
|
+
message: SENTINEL_ERROR_CODES.SECURITY_BLOCKED,
|
|
105
|
+
code: "SECURITY_BLOCKED",
|
|
106
|
+
reason: decision.reason
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Throws the error for a decision, for use in endpoint hooks */
|
|
110
|
+
function throwForDecision(decision) {
|
|
111
|
+
const error = errorForDecision(decision);
|
|
112
|
+
if (error) throw error;
|
|
113
|
+
}
|
|
114
|
+
/** Builds the response for a decision, for use in `onRequest` */
|
|
115
|
+
function responseForDecision(decision) {
|
|
116
|
+
if (decision.outcome === "allow") return void 0;
|
|
117
|
+
if (decision.outcome === "challenge") return new Response(JSON.stringify({
|
|
118
|
+
message: SENTINEL_ERROR_CODES.POW_CHALLENGE_REQUIRED,
|
|
119
|
+
code: "POW_CHALLENGE_REQUIRED",
|
|
120
|
+
challenge: {
|
|
121
|
+
nonce: decision.nonce,
|
|
122
|
+
difficulty: decision.difficulty,
|
|
123
|
+
reason: decision.reason
|
|
124
|
+
}
|
|
125
|
+
}), {
|
|
126
|
+
status: 423,
|
|
127
|
+
headers: {
|
|
128
|
+
"content-type": "application/json",
|
|
129
|
+
...challengeHeaders(decision)
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return new Response(JSON.stringify({
|
|
133
|
+
message: SENTINEL_ERROR_CODES.SECURITY_BLOCKED,
|
|
134
|
+
code: "SECURITY_BLOCKED",
|
|
135
|
+
reason: decision.reason
|
|
136
|
+
}), {
|
|
137
|
+
status: 403,
|
|
138
|
+
headers: { "content-type": "application/json" }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
//#endregion
|
|
143
|
+
//#region src/plugins/sentinel/email.ts
|
|
144
|
+
const GMAIL_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
|
|
145
|
+
/**
|
|
146
|
+
* Providers that deliver `user+tag@domain` to `user@domain`, so the tag can be
|
|
147
|
+
* dropped without changing the mailbox
|
|
148
|
+
*/
|
|
149
|
+
const PLUS_ADDRESSING_DOMAINS = new Set([
|
|
150
|
+
"gmail.com",
|
|
151
|
+
"googlemail.com",
|
|
152
|
+
"outlook.com",
|
|
153
|
+
"hotmail.com",
|
|
154
|
+
"live.com",
|
|
155
|
+
"msn.com",
|
|
156
|
+
"icloud.com",
|
|
157
|
+
"me.com",
|
|
158
|
+
"mac.com",
|
|
159
|
+
"protonmail.com",
|
|
160
|
+
"proton.me",
|
|
161
|
+
"pm.me",
|
|
162
|
+
"fastmail.com",
|
|
163
|
+
"zoho.com"
|
|
164
|
+
]);
|
|
165
|
+
/**
|
|
166
|
+
* Normalizes an email address so aliases of the same mailbox match:
|
|
167
|
+
* lowercases it, maps `googlemail.com` to `gmail.com`, removes dots in Gmail
|
|
168
|
+
* addresses and strips plus-address tags on providers that support them
|
|
169
|
+
*/
|
|
170
|
+
function normalizeEmail(email) {
|
|
171
|
+
const trimmed = email.trim().toLowerCase();
|
|
172
|
+
const at = trimmed.lastIndexOf("@");
|
|
173
|
+
if (at < 1 || at === trimmed.length - 1) return trimmed;
|
|
174
|
+
let local = trimmed.slice(0, at);
|
|
175
|
+
let domain = trimmed.slice(at + 1);
|
|
176
|
+
if (domain === "googlemail.com") domain = "gmail.com";
|
|
177
|
+
if (PLUS_ADDRESSING_DOMAINS.has(domain)) local = local.split("+")[0];
|
|
178
|
+
if (GMAIL_DOMAINS.has(domain)) local = local.replace(/\./g, "");
|
|
179
|
+
return local ? `${local}@${domain}` : trimmed;
|
|
180
|
+
}
|
|
181
|
+
function getEmailDomain(email) {
|
|
182
|
+
const at = email.lastIndexOf("@");
|
|
183
|
+
return at < 0 ? "" : email.slice(at + 1).trim().toLowerCase();
|
|
184
|
+
}
|
|
185
|
+
let disposableDomains;
|
|
186
|
+
/**
|
|
187
|
+
* Whether a domain, or any parent domain, is a known disposable email
|
|
188
|
+
* provider. The list is loaded on first use.
|
|
189
|
+
*/
|
|
190
|
+
async function isDisposableDomain(domain) {
|
|
191
|
+
disposableDomains ??= import("disposable-email-domains-js").then((module) => module.isDisposableEmailDomain);
|
|
192
|
+
const isDisposable = await disposableDomains;
|
|
193
|
+
const labels = domain.split(".");
|
|
194
|
+
for (let i = 0; i < labels.length - 1; i++) if (isDisposable(labels.slice(i).join("."))) return true;
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
/** How long a mail lookup may take before it counts as unknown */
|
|
198
|
+
const MAIL_LOOKUP_TIMEOUT_MS = 2e3;
|
|
199
|
+
/** The lookup found nothing, as opposed to failing */
|
|
200
|
+
const isMissingRecord = (e) => {
|
|
201
|
+
const code = e.code;
|
|
202
|
+
return code === "ENOTFOUND" || code === "ENODATA" || code === "ENOTIMP";
|
|
203
|
+
};
|
|
204
|
+
/** Whether a domain has an A or AAAA record, null when a lookup failed */
|
|
205
|
+
async function hasAddressRecords(dns, domain) {
|
|
206
|
+
const results = await Promise.allSettled([dns.resolve4(domain), dns.resolve6(domain)]);
|
|
207
|
+
if (results.some((r) => r.status === "fulfilled" && r.value.length > 0)) return true;
|
|
208
|
+
return results.some((r) => r.status === "rejected" && !isMissingRecord(r.reason)) ? null : false;
|
|
209
|
+
}
|
|
210
|
+
async function lookupMail(dns, domain) {
|
|
211
|
+
try {
|
|
212
|
+
const records = await dns.resolveMx(domain);
|
|
213
|
+
if (records.length === 1 && (records[0].exchange === "" || records[0].exchange === ".")) return false;
|
|
214
|
+
if (records.length > 0) return true;
|
|
215
|
+
} catch (e) {
|
|
216
|
+
const code = e.code;
|
|
217
|
+
if (code === "ENOTFOUND" || code === "ENOTIMP") return false;
|
|
218
|
+
if (!isMissingRecord(e)) return null;
|
|
219
|
+
}
|
|
220
|
+
return hasAddressRecords(dns, domain);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Whether a domain can receive mail: it has an MX record, or an address
|
|
224
|
+
* record to fall back to when it has none. A null MX record, which marks a
|
|
225
|
+
* domain that accepts no mail, doesn't count.
|
|
226
|
+
*
|
|
227
|
+
* Returns null when that can't be told: runtimes without DNS access, lookup
|
|
228
|
+
* failures, or a lookup that takes longer than `timeoutMs`.
|
|
229
|
+
*/
|
|
230
|
+
async function hasMxRecords(domain, options = {}) {
|
|
231
|
+
let dns;
|
|
232
|
+
try {
|
|
233
|
+
dns = await import("node:dns/promises");
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
let timer;
|
|
238
|
+
const timeout = new Promise((resolve) => {
|
|
239
|
+
timer = setTimeout(() => resolve(null), options.timeoutMs ?? MAIL_LOOKUP_TIMEOUT_MS);
|
|
240
|
+
});
|
|
241
|
+
try {
|
|
242
|
+
return await Promise.race([lookupMail(dns, domain), timeout]);
|
|
243
|
+
} finally {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Heuristics for addresses that look generated rather than chosen by a
|
|
249
|
+
* person
|
|
250
|
+
*/
|
|
251
|
+
function looksGenerated(email) {
|
|
252
|
+
const at = email.lastIndexOf("@");
|
|
253
|
+
const local = email.slice(0, at);
|
|
254
|
+
const domainName = email.slice(at + 1).split(".").slice(0, -1).join(".");
|
|
255
|
+
const letters = local.replace(/[^a-z]/gi, "");
|
|
256
|
+
return /\d{5,}/.test(local) || /\d{4,}/.test(domainName) || local.length >= 12 && letters.length / local.length < .4 || /(?:[a-z]\d){5,}/i.test(local) || /^[0-9a-f]{16,}$/i.test(local);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region src/plugins/sentinel/geo.ts
|
|
261
|
+
const EARTH_RADIUS_KM = 6371;
|
|
262
|
+
const toRadians = (degrees) => degrees * Math.PI / 180;
|
|
263
|
+
/** Great-circle distance between two coordinates, in kilometres */
|
|
264
|
+
function haversineDistanceKm(from, to) {
|
|
265
|
+
const dLat = toRadians(to.latitude - from.latitude);
|
|
266
|
+
const dLon = toRadians(to.longitude - from.longitude);
|
|
267
|
+
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRadians(from.latitude)) * Math.cos(toRadians(to.latitude)) * Math.sin(dLon / 2) ** 2;
|
|
268
|
+
return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Speed needed to travel between two sign-ins, in km/h. Distances under
|
|
272
|
+
* 50 km are treated as the same place, since IP geolocation isn't precise
|
|
273
|
+
* enough to tell them apart.
|
|
274
|
+
*/
|
|
275
|
+
function travelSpeedKmh(from, to) {
|
|
276
|
+
const distance = haversineDistanceKm(from, to);
|
|
277
|
+
if (distance < 50) return 0;
|
|
278
|
+
return distance / (Math.max(Math.abs(to.at.getTime() - from.at.getTime()), 6e4) / 36e5);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/plugins/sentinel/intel.ts
|
|
283
|
+
const BOT_USER_AGENT = /bot|crawl|spider|slurp|headless|phantomjs|selenium|puppeteer|playwright|curl|wget|httpie|python-requests|python-urllib|aiohttp|go-http-client|okhttp|java\/|libwww|scrapy|postman|insomnia/i;
|
|
284
|
+
/**
|
|
285
|
+
* Local bot heuristics, used when neither a provider nor the hosted backend
|
|
286
|
+
* gives a verdict
|
|
287
|
+
*/
|
|
288
|
+
function looksLikeBot(request) {
|
|
289
|
+
const userAgent = request.headers.get("user-agent")?.trim();
|
|
290
|
+
if (!userAgent) return true;
|
|
291
|
+
if (BOT_USER_AGENT.test(userAgent)) return true;
|
|
292
|
+
return !request.headers.get("accept-language") && !request.headers.get("x-visitor-id");
|
|
293
|
+
}
|
|
294
|
+
const UNKNOWN = {
|
|
295
|
+
suspiciousIp: null,
|
|
296
|
+
bot: null,
|
|
297
|
+
geo: null
|
|
298
|
+
};
|
|
299
|
+
/**
|
|
300
|
+
* Asks the hosted backend about a request's IP address and client. Verdicts
|
|
301
|
+
* are cached in secondary storage for as long as the backend allows.
|
|
302
|
+
* Returns null verdicts when the backend isn't configured or can't answer.
|
|
303
|
+
*/
|
|
304
|
+
function createIntelClient(options) {
|
|
305
|
+
const apiKey = options.apiKey ?? env.PAYVO_INFRA_API_KEY;
|
|
306
|
+
const apiUrl = (options.apiUrl ?? env.PAYVO_INFRA_API_URL)?.replace(/\/+$/, "");
|
|
307
|
+
const kvUrl = (options.kvUrl ?? env.PAYVO_INFRA_KV_URL)?.replace(/\/+$/, "");
|
|
308
|
+
const configured = Boolean(apiKey && apiUrl);
|
|
309
|
+
const kv = {
|
|
310
|
+
timeout: options.kvOptions?.timeout ?? 1e3,
|
|
311
|
+
attempts: options.kvOptions?.retry?.attempts ?? 2,
|
|
312
|
+
baseDelay: options.kvOptions?.retry?.baseDelay ?? 400,
|
|
313
|
+
maxDelay: options.kvOptions?.retry?.maxDelay ?? 600
|
|
314
|
+
};
|
|
315
|
+
return {
|
|
316
|
+
configured,
|
|
317
|
+
verifiesVisitors: Boolean(kvUrl && apiKey),
|
|
318
|
+
misconfigured: Boolean((apiUrl || kvUrl) && !apiKey) || Boolean(apiKey && !apiUrl && !kvUrl),
|
|
319
|
+
async verifyVisitor(auth, request) {
|
|
320
|
+
if (!kvUrl || !apiKey) return request.visitorId;
|
|
321
|
+
const cacheKey = `sentinel:visitor:${request.visitorId}:${request.ip ?? ""}`;
|
|
322
|
+
const cached = await auth.secondaryStorage?.get(cacheKey);
|
|
323
|
+
if (typeof cached === "string") return cached || null;
|
|
324
|
+
for (let attempt = 0; attempt <= kv.attempts; attempt++) {
|
|
325
|
+
if (attempt > 0) {
|
|
326
|
+
const wait = Math.min(kv.baseDelay * 2 ** (attempt - 1), kv.maxDelay);
|
|
327
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
328
|
+
}
|
|
329
|
+
const { data, error } = await betterFetch(`${kvUrl}/v1/visitors/verify`, {
|
|
330
|
+
method: "POST",
|
|
331
|
+
headers: {
|
|
332
|
+
...options.kvOptions?.headers,
|
|
333
|
+
authorization: `Bearer ${apiKey}`
|
|
334
|
+
},
|
|
335
|
+
body: request,
|
|
336
|
+
timeout: kv.timeout
|
|
337
|
+
}).catch((e) => ({
|
|
338
|
+
data: null,
|
|
339
|
+
error: e
|
|
340
|
+
}));
|
|
341
|
+
if (data && !error) {
|
|
342
|
+
const visitorId = data.valid ? data.visitorId ?? request.visitorId : null;
|
|
343
|
+
await auth.secondaryStorage?.set(cacheKey, visitorId ?? "", 300);
|
|
344
|
+
return visitorId;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
auth.logger.error("[sentinel] Failed to verify visitor id");
|
|
348
|
+
},
|
|
349
|
+
async lookup(auth, request) {
|
|
350
|
+
const { ip, visitorId } = request;
|
|
351
|
+
if (!configured || !ip) return UNKNOWN;
|
|
352
|
+
const cacheKey = `sentinel:intel:${ip}:${visitorId ?? ""}`;
|
|
353
|
+
const cached = safeJSONParse(await auth.secondaryStorage?.get(cacheKey));
|
|
354
|
+
if (cached) return cached;
|
|
355
|
+
const { data, error } = await betterFetch(`${apiUrl}/v1/intel/request`, {
|
|
356
|
+
method: "POST",
|
|
357
|
+
headers: {
|
|
358
|
+
...options.apiOptions?.headers,
|
|
359
|
+
authorization: `Bearer ${apiKey}`
|
|
360
|
+
},
|
|
361
|
+
body: {
|
|
362
|
+
ip,
|
|
363
|
+
visitorId,
|
|
364
|
+
userAgent: request.userAgent,
|
|
365
|
+
headers: { "accept-language": request.acceptLanguage }
|
|
366
|
+
},
|
|
367
|
+
timeout: options.apiOptions?.timeout ?? 3e3
|
|
368
|
+
}).catch((e) => ({
|
|
369
|
+
data: null,
|
|
370
|
+
error: e
|
|
371
|
+
}));
|
|
372
|
+
if (error || !data) {
|
|
373
|
+
auth.logger.error("[sentinel] Failed to look up request intel", error);
|
|
374
|
+
return UNKNOWN;
|
|
375
|
+
}
|
|
376
|
+
const intel = {
|
|
377
|
+
suspiciousIp: data.ip?.suspicious ?? null,
|
|
378
|
+
bot: data.bot?.verdict === "bot" ? true : data.bot?.verdict === "human" ? false : null,
|
|
379
|
+
geo: data.geo ?? null
|
|
380
|
+
};
|
|
381
|
+
if (data.ttlSeconds && auth.secondaryStorage) await auth.secondaryStorage.set(cacheKey, JSON.stringify(intel), data.ttlSeconds);
|
|
382
|
+
return intel;
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/plugins/sentinel/schema.ts
|
|
389
|
+
/** Added to users when `emailNormalization` is enabled */
|
|
390
|
+
const normalizedEmailSchema = { user: { fields: { normalizedEmail: {
|
|
391
|
+
type: "string",
|
|
392
|
+
required: false,
|
|
393
|
+
input: false,
|
|
394
|
+
returned: false
|
|
395
|
+
} } } };
|
|
396
|
+
const schema = {
|
|
397
|
+
sentinelCounter: { fields: {
|
|
398
|
+
key: {
|
|
399
|
+
type: "string",
|
|
400
|
+
required: true,
|
|
401
|
+
unique: true
|
|
402
|
+
},
|
|
403
|
+
count: {
|
|
404
|
+
type: "number",
|
|
405
|
+
required: true
|
|
406
|
+
},
|
|
407
|
+
expiresAt: {
|
|
408
|
+
type: "date",
|
|
409
|
+
required: true
|
|
410
|
+
}
|
|
411
|
+
} },
|
|
412
|
+
sentinelSignIn: { fields: {
|
|
413
|
+
userId: {
|
|
414
|
+
type: "string",
|
|
415
|
+
required: true,
|
|
416
|
+
references: {
|
|
417
|
+
model: "user",
|
|
418
|
+
field: "id",
|
|
419
|
+
onDelete: "cascade"
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
ipAddress: {
|
|
423
|
+
type: "string",
|
|
424
|
+
required: false
|
|
425
|
+
},
|
|
426
|
+
visitorId: {
|
|
427
|
+
type: "string",
|
|
428
|
+
required: false
|
|
429
|
+
},
|
|
430
|
+
userAgent: {
|
|
431
|
+
type: "string",
|
|
432
|
+
required: false
|
|
433
|
+
},
|
|
434
|
+
country: {
|
|
435
|
+
type: "string",
|
|
436
|
+
required: false
|
|
437
|
+
},
|
|
438
|
+
city: {
|
|
439
|
+
type: "string",
|
|
440
|
+
required: false
|
|
441
|
+
},
|
|
442
|
+
latitude: {
|
|
443
|
+
type: "number",
|
|
444
|
+
required: false
|
|
445
|
+
},
|
|
446
|
+
longitude: {
|
|
447
|
+
type: "number",
|
|
448
|
+
required: false
|
|
449
|
+
},
|
|
450
|
+
createdAt: {
|
|
451
|
+
type: "date",
|
|
452
|
+
required: true
|
|
453
|
+
}
|
|
454
|
+
} },
|
|
455
|
+
powChallenge: { fields: {
|
|
456
|
+
nonce: {
|
|
457
|
+
type: "string",
|
|
458
|
+
required: true,
|
|
459
|
+
unique: true
|
|
460
|
+
},
|
|
461
|
+
difficulty: {
|
|
462
|
+
type: "number",
|
|
463
|
+
required: true
|
|
464
|
+
},
|
|
465
|
+
reason: {
|
|
466
|
+
type: "string",
|
|
467
|
+
required: true
|
|
468
|
+
},
|
|
469
|
+
ipAddress: {
|
|
470
|
+
type: "string",
|
|
471
|
+
required: false
|
|
472
|
+
},
|
|
473
|
+
visitorId: {
|
|
474
|
+
type: "string",
|
|
475
|
+
required: false
|
|
476
|
+
},
|
|
477
|
+
expiresAt: {
|
|
478
|
+
type: "date",
|
|
479
|
+
required: true
|
|
480
|
+
},
|
|
481
|
+
createdAt: {
|
|
482
|
+
type: "date",
|
|
483
|
+
required: true
|
|
484
|
+
}
|
|
485
|
+
} }
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/plugins/sentinel/store.ts
|
|
490
|
+
const PREFIX = "sentinel:";
|
|
491
|
+
/**
|
|
492
|
+
* Counters and challenges, kept in secondary storage when it's configured
|
|
493
|
+
* and in the database otherwise
|
|
494
|
+
*/
|
|
495
|
+
function createSentinelStore(auth) {
|
|
496
|
+
const storage = auth.secondaryStorage;
|
|
497
|
+
async function findCounter(key) {
|
|
498
|
+
return auth.adapter.findOne({
|
|
499
|
+
model: "sentinelCounter",
|
|
500
|
+
where: [{
|
|
501
|
+
field: "key",
|
|
502
|
+
value: key
|
|
503
|
+
}]
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
async increment(name, ttlSeconds, options = {}) {
|
|
508
|
+
const key = PREFIX + name;
|
|
509
|
+
if (storage?.increment) {
|
|
510
|
+
const count = await storage.increment(key, ttlSeconds);
|
|
511
|
+
if (options.sliding && count > 1) await storage.set(key, String(count), ttlSeconds);
|
|
512
|
+
return count;
|
|
513
|
+
}
|
|
514
|
+
if (storage) {
|
|
515
|
+
const current = safeJSONParse(await storage.get(key));
|
|
516
|
+
const now = Date.now();
|
|
517
|
+
const expiresAt = current && current.expiresAt > now && !options.sliding ? current.expiresAt : now + ttlSeconds * 1e3;
|
|
518
|
+
const count = current && current.expiresAt > now ? current.count + 1 : 1;
|
|
519
|
+
await storage.set(key, JSON.stringify({
|
|
520
|
+
count,
|
|
521
|
+
expiresAt
|
|
522
|
+
}), Math.max(Math.ceil((expiresAt - now) / 1e3), 1));
|
|
523
|
+
return count;
|
|
524
|
+
}
|
|
525
|
+
let row = await findCounter(key);
|
|
526
|
+
if (!row) {
|
|
527
|
+
if (await auth.adapter.create({
|
|
528
|
+
model: "sentinelCounter",
|
|
529
|
+
data: {
|
|
530
|
+
key,
|
|
531
|
+
count: 1,
|
|
532
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
|
|
533
|
+
}
|
|
534
|
+
}).then(() => true, () => false)) return 1;
|
|
535
|
+
row = await findCounter(key);
|
|
536
|
+
if (!row) throw new Error(`Failed to create counter ${key}`);
|
|
537
|
+
}
|
|
538
|
+
if (new Date(row.expiresAt).getTime() <= Date.now()) {
|
|
539
|
+
await auth.adapter.update({
|
|
540
|
+
model: "sentinelCounter",
|
|
541
|
+
where: [{
|
|
542
|
+
field: "id",
|
|
543
|
+
value: row.id
|
|
544
|
+
}],
|
|
545
|
+
update: {
|
|
546
|
+
count: 1,
|
|
547
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
return 1;
|
|
551
|
+
}
|
|
552
|
+
await auth.adapter.update({
|
|
553
|
+
model: "sentinelCounter",
|
|
554
|
+
where: [{
|
|
555
|
+
field: "id",
|
|
556
|
+
value: row.id
|
|
557
|
+
}],
|
|
558
|
+
update: {
|
|
559
|
+
count: row.count + 1,
|
|
560
|
+
...options.sliding ? { expiresAt: new Date(Date.now() + ttlSeconds * 1e3) } : {}
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
return row.count + 1;
|
|
564
|
+
},
|
|
565
|
+
async get(name) {
|
|
566
|
+
const key = PREFIX + name;
|
|
567
|
+
if (storage) {
|
|
568
|
+
const value = await storage.get(key);
|
|
569
|
+
if (value === null || value === void 0) return 0;
|
|
570
|
+
if (storage.increment) return Number(value) || 0;
|
|
571
|
+
const current = safeJSONParse(value);
|
|
572
|
+
return current && current.expiresAt > Date.now() ? current.count : 0;
|
|
573
|
+
}
|
|
574
|
+
const row = await findCounter(key);
|
|
575
|
+
if (!row || new Date(row.expiresAt).getTime() <= Date.now()) return 0;
|
|
576
|
+
return row.count;
|
|
577
|
+
},
|
|
578
|
+
async reset(name) {
|
|
579
|
+
const key = PREFIX + name;
|
|
580
|
+
if (storage) {
|
|
581
|
+
await storage.delete(key);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
await auth.adapter.deleteMany({
|
|
585
|
+
model: "sentinelCounter",
|
|
586
|
+
where: [{
|
|
587
|
+
field: "key",
|
|
588
|
+
value: key
|
|
589
|
+
}]
|
|
590
|
+
});
|
|
591
|
+
},
|
|
592
|
+
async saveChallenge(challenge) {
|
|
593
|
+
const ttlSeconds = Math.max(Math.ceil((challenge.expiresAt.getTime() - Date.now()) / 1e3), 1);
|
|
594
|
+
if (storage) {
|
|
595
|
+
await storage.set(`${PREFIX}pow:${challenge.nonce}`, JSON.stringify(challenge), ttlSeconds);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
await auth.adapter.create({
|
|
599
|
+
model: "powChallenge",
|
|
600
|
+
data: {
|
|
601
|
+
...challenge,
|
|
602
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
},
|
|
606
|
+
async consumeChallenge(nonce) {
|
|
607
|
+
let challenge = null;
|
|
608
|
+
if (storage) {
|
|
609
|
+
const key = `${PREFIX}pow:${nonce}`;
|
|
610
|
+
let value;
|
|
611
|
+
if (storage.getAndDelete) value = await storage.getAndDelete(key);
|
|
612
|
+
else {
|
|
613
|
+
value = await storage.get(key);
|
|
614
|
+
if (value) await storage.delete(key);
|
|
615
|
+
}
|
|
616
|
+
challenge = safeJSONParse(value);
|
|
617
|
+
} else {
|
|
618
|
+
const row = await auth.adapter.findOne({
|
|
619
|
+
model: "powChallenge",
|
|
620
|
+
where: [{
|
|
621
|
+
field: "nonce",
|
|
622
|
+
value: nonce
|
|
623
|
+
}]
|
|
624
|
+
});
|
|
625
|
+
if (!row) return null;
|
|
626
|
+
if (!await auth.adapter.create({
|
|
627
|
+
model: "sentinelCounter",
|
|
628
|
+
data: {
|
|
629
|
+
key: `${PREFIX}pow-used:${nonce}`,
|
|
630
|
+
count: 1,
|
|
631
|
+
expiresAt: new Date(row.expiresAt)
|
|
632
|
+
}
|
|
633
|
+
}).then(() => true).catch(() => false)) return null;
|
|
634
|
+
await auth.adapter.delete({
|
|
635
|
+
model: "powChallenge",
|
|
636
|
+
where: [{
|
|
637
|
+
field: "nonce",
|
|
638
|
+
value: nonce
|
|
639
|
+
}]
|
|
640
|
+
});
|
|
641
|
+
challenge = row;
|
|
642
|
+
}
|
|
643
|
+
if (!challenge) return null;
|
|
644
|
+
if (new Date(challenge.expiresAt).getTime() <= Date.now()) return null;
|
|
645
|
+
return challenge;
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region src/plugins/sentinel/index.ts
|
|
652
|
+
const SIGN_UP_PATHS = ["/sign-up/email"];
|
|
653
|
+
const PASSWORD_RESET_PATHS = ["/request-password-reset", "/forget-password/email-otp"];
|
|
654
|
+
/** The email field normalized on each endpoint */
|
|
655
|
+
const NORMALIZED_EMAIL_FIELDS = {
|
|
656
|
+
"/sign-up/email": "email",
|
|
657
|
+
"/sign-in/email": "email",
|
|
658
|
+
"/sign-in/email-otp": "email",
|
|
659
|
+
"/sign-in/magic-link": "email",
|
|
660
|
+
"/email-otp/send-verification-otp": "email",
|
|
661
|
+
"/email-otp/verify-email": "email",
|
|
662
|
+
"/email-otp/check-verification-otp": "email",
|
|
663
|
+
"/email-otp/reset-password": "email",
|
|
664
|
+
"/request-password-reset": "email",
|
|
665
|
+
"/forget-password/email-otp": "email",
|
|
666
|
+
"/change-email": "newEmail"
|
|
667
|
+
};
|
|
668
|
+
/** The new password field each endpoint accepts */
|
|
669
|
+
const NEW_PASSWORD_FIELDS = {
|
|
670
|
+
"/sign-up/email": "password",
|
|
671
|
+
"/change-password": "newPassword",
|
|
672
|
+
"/set-password": "newPassword",
|
|
673
|
+
"/reset-password": "newPassword",
|
|
674
|
+
"/email-otp/reset-password": "password"
|
|
675
|
+
};
|
|
676
|
+
/**
|
|
677
|
+
* Endpoints that check a password, one-time code or token, so failures count
|
|
678
|
+
* towards credential stuffing
|
|
679
|
+
*/
|
|
680
|
+
const isAttemptPath = (path) => CREDENTIAL_SIGN_IN_PATHS.includes(path) || VERIFICATION_PATHS.includes(path);
|
|
681
|
+
/** Account creation that isn't done by the visitor signing up */
|
|
682
|
+
const isManagedAccountCreation = (path) => !path || path.startsWith("/admin/") || path === "/sign-in/anonymous";
|
|
683
|
+
/** Social sign-ups, whose email the provider has already verified */
|
|
684
|
+
const OAUTH_CALLBACK_PATHS = ["/callback/:id", "/oauth2/callback/:providerId"];
|
|
685
|
+
const DAY_SECONDS = 1440 * 60;
|
|
686
|
+
/**
|
|
687
|
+
* Sign-ins checked with a password, which can be repeated with a challenge
|
|
688
|
+
* solution
|
|
689
|
+
*/
|
|
690
|
+
const REPEATABLE_SIGN_IN_PATHS = [
|
|
691
|
+
"/sign-in/email",
|
|
692
|
+
"/sign-in/username",
|
|
693
|
+
"/sign-in/phone-number"
|
|
694
|
+
];
|
|
695
|
+
/** How long checks that passed on a first factor cover the second factor */
|
|
696
|
+
const SECOND_FACTOR_SECONDS = 600;
|
|
697
|
+
/** Response statuses that sentinel itself returns, not failed credentials */
|
|
698
|
+
const SENTINEL_STATUSES = new Set([
|
|
699
|
+
403,
|
|
700
|
+
423,
|
|
701
|
+
429
|
|
702
|
+
]);
|
|
703
|
+
const sentinel = (options) => {
|
|
704
|
+
const opts = {
|
|
705
|
+
...options,
|
|
706
|
+
failOpen: options?.failOpen ?? true
|
|
707
|
+
};
|
|
708
|
+
const security = opts.security ?? {};
|
|
709
|
+
const difficulty = security.challengeDifficulty ?? 18;
|
|
710
|
+
const intel = createIntelClient(opts);
|
|
711
|
+
const credentialStuffing = security.credentialStuffing?.enabled ? {
|
|
712
|
+
action: security.credentialStuffing.action ?? "challenge",
|
|
713
|
+
thresholds: security.credentialStuffing.thresholds ?? {
|
|
714
|
+
challenge: 3,
|
|
715
|
+
block: 5
|
|
716
|
+
},
|
|
717
|
+
windowSeconds: security.credentialStuffing.windowSeconds ?? 3600,
|
|
718
|
+
cooldownSeconds: security.credentialStuffing.cooldownSeconds ?? 900
|
|
719
|
+
} : null;
|
|
720
|
+
const velocity = security.velocity?.enabled ? {
|
|
721
|
+
thresholds: security.velocity.thresholds ?? {
|
|
722
|
+
challenge: 10,
|
|
723
|
+
block: 20
|
|
724
|
+
},
|
|
725
|
+
maxSignupsPerVisitor: security.velocity.maxSignupsPerVisitor ?? 5,
|
|
726
|
+
maxPasswordResetsPerIp: security.velocity.maxPasswordResetsPerIp ?? 10,
|
|
727
|
+
maxSignInsPerIp: security.velocity.maxSignInsPerIp ?? 50,
|
|
728
|
+
windowSeconds: security.velocity.windowSeconds ?? 3600,
|
|
729
|
+
action: security.velocity.action ?? "challenge"
|
|
730
|
+
} : null;
|
|
731
|
+
const freeTrialAbuse = security.freeTrialAbuse?.enabled ? {
|
|
732
|
+
thresholds: security.freeTrialAbuse.thresholds,
|
|
733
|
+
maxAccountsPerVisitor: security.freeTrialAbuse.maxAccountsPerVisitor ?? 3,
|
|
734
|
+
retentionSeconds: Math.min(Math.max(security.freeTrialAbuse.retentionSeconds ?? 90 * DAY_SECONDS, DAY_SECONDS), 365 * DAY_SECONDS),
|
|
735
|
+
action: security.freeTrialAbuse.action ?? "block"
|
|
736
|
+
} : null;
|
|
737
|
+
const botBlocking = resolveToggle(security.botBlocking, "challenge");
|
|
738
|
+
const suspiciousIpBlocking = resolveToggle(security.suspiciousIpBlocking, "block");
|
|
739
|
+
if (security.geoBlocking?.allowList && security.geoBlocking.denyList) throw new PayvoAuthError("sentinel: use either geoBlocking.allowList or geoBlocking.denyList, not both");
|
|
740
|
+
const geoBlocking = security.geoBlocking?.allowList || security.geoBlocking?.denyList ? {
|
|
741
|
+
allowList: security.geoBlocking.allowList?.map((code) => code.toUpperCase()),
|
|
742
|
+
denyList: security.geoBlocking.denyList?.map((code) => code.toUpperCase()),
|
|
743
|
+
action: security.geoBlocking.action ?? "block"
|
|
744
|
+
} : null;
|
|
745
|
+
const impossibleTravel = security.impossibleTravel?.enabled ? {
|
|
746
|
+
maxSpeedKmh: security.impossibleTravel.maxSpeedKmh ?? 1e3,
|
|
747
|
+
action: security.impossibleTravel.action ?? "challenge"
|
|
748
|
+
} : null;
|
|
749
|
+
const compromisedPassword = security.compromisedPassword?.enabled ? {
|
|
750
|
+
action: security.compromisedPassword.action ?? "block",
|
|
751
|
+
minBreachCount: security.compromisedPassword.minBreachCount ?? 1
|
|
752
|
+
} : null;
|
|
753
|
+
const emailValidation = security.emailValidation?.enabled ? {
|
|
754
|
+
strictness: security.emailValidation.strictness ?? "medium",
|
|
755
|
+
action: security.emailValidation.action ?? "block",
|
|
756
|
+
domainAllowlist: new Set(security.emailValidation.domainAllowlist?.map((domain) => domain.toLowerCase()))
|
|
757
|
+
} : null;
|
|
758
|
+
const emailNormalization = security.emailNormalization?.enabled ?? false;
|
|
759
|
+
if (security.staleUsers?.notifyAdmin && !security.staleUsers.adminEmail) throw new PayvoAuthError("sentinel: staleUsers.adminEmail is required when notifyAdmin is true");
|
|
760
|
+
const staleUsers = security.staleUsers?.enabled ? {
|
|
761
|
+
staleDays: security.staleUsers.staleDays ?? 90,
|
|
762
|
+
action: security.staleUsers.action ?? "log",
|
|
763
|
+
notifyUser: security.staleUsers.notifyUser ?? false,
|
|
764
|
+
adminEmail: security.staleUsers.notifyAdmin ? security.staleUsers.adminEmail : void 0
|
|
765
|
+
} : null;
|
|
766
|
+
const unknownDeviceNotification = security.unknownDeviceNotification ?? false;
|
|
767
|
+
const tracksSignIns = Boolean(impossibleTravel || staleUsers || unknownDeviceNotification);
|
|
768
|
+
const checksAccountCreation = Boolean(freeTrialAbuse || velocity || emailValidation);
|
|
769
|
+
/** Challenge results, so each solution is checked once per request */
|
|
770
|
+
const challengeResults = /* @__PURE__ */ new WeakMap();
|
|
771
|
+
/** Verified visitor ids, so each request is checked once */
|
|
772
|
+
const visitorResults = /* @__PURE__ */ new WeakMap();
|
|
773
|
+
function clientIp(request, auth) {
|
|
774
|
+
if (!request) return null;
|
|
775
|
+
if (opts.providers?.getClientIp) return opts.providers.getClientIp(request);
|
|
776
|
+
return getIp(request, auth.options);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* The visitor id sent by the client, checked with the identification
|
|
780
|
+
* service when one is configured
|
|
781
|
+
*/
|
|
782
|
+
function resolveVisitor(auth, headers, ip) {
|
|
783
|
+
const sent = headers?.get("x-visitor-id") || null;
|
|
784
|
+
if (!headers || !sent) return Promise.resolve({
|
|
785
|
+
id: null,
|
|
786
|
+
verified: false
|
|
787
|
+
});
|
|
788
|
+
const existing = visitorResults.get(headers);
|
|
789
|
+
if (existing) return existing;
|
|
790
|
+
const unchecked = {
|
|
791
|
+
id: opts.failOpen ? sent : null,
|
|
792
|
+
verified: false
|
|
793
|
+
};
|
|
794
|
+
const result = intel.verifyVisitor(auth, {
|
|
795
|
+
visitorId: sent,
|
|
796
|
+
ip,
|
|
797
|
+
userAgent: headers.get("user-agent")
|
|
798
|
+
}).then((verified) => verified === void 0 ? unchecked : {
|
|
799
|
+
id: verified,
|
|
800
|
+
verified: intel.verifiesVisitors
|
|
801
|
+
}).catch(() => unchecked);
|
|
802
|
+
visitorResults.set(headers, result);
|
|
803
|
+
return result;
|
|
804
|
+
}
|
|
805
|
+
function relativePath(request, auth) {
|
|
806
|
+
const pathname = new URL(request.url).pathname;
|
|
807
|
+
const basePath = auth.options.basePath ?? "/api/auth";
|
|
808
|
+
return pathname.startsWith(basePath) ? pathname.slice(basePath.length) || "/" : pathname;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Whether the request carries a valid, unused solution to a challenge
|
|
812
|
+
* issued to the same IP address and visitor
|
|
813
|
+
*/
|
|
814
|
+
function challengePassed(key, details, store, auth) {
|
|
815
|
+
const existing = challengeResults.get(key);
|
|
816
|
+
if (existing) return existing;
|
|
817
|
+
const result = (async () => {
|
|
818
|
+
const parsed = parsePowHeader(details.headers?.get(POW_SOLUTION_HEADER));
|
|
819
|
+
if (!parsed) return false;
|
|
820
|
+
try {
|
|
821
|
+
const challenge = await store.consumeChallenge(parsed.nonce);
|
|
822
|
+
if (!challenge) return false;
|
|
823
|
+
if (challenge.ipAddress && challenge.ipAddress !== details.ip) return false;
|
|
824
|
+
if (challenge.visitorId && challenge.visitorId !== details.visitorId) return false;
|
|
825
|
+
return await verifySolution(parsed.nonce, parsed.value, challenge.difficulty);
|
|
826
|
+
} catch (e) {
|
|
827
|
+
auth.logger.error("[sentinel] Failed to verify challenge", e);
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
})();
|
|
831
|
+
challengeResults.set(key, result);
|
|
832
|
+
return result;
|
|
833
|
+
}
|
|
834
|
+
/** Runs a check, applying `failOpen` when it can't complete */
|
|
835
|
+
async function guard(auth, check) {
|
|
836
|
+
try {
|
|
837
|
+
return await check();
|
|
838
|
+
} catch (e) {
|
|
839
|
+
if (e instanceof APIError) throw e;
|
|
840
|
+
auth.logger.error("[sentinel] Security check failed", e);
|
|
841
|
+
return opts.failOpen ? { outcome: "allow" } : {
|
|
842
|
+
outcome: "block",
|
|
843
|
+
reason: "check_failed"
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
async function requestDetails(ctx, identifier) {
|
|
848
|
+
const headers = ctx.headers ?? ctx.request?.headers;
|
|
849
|
+
const ip = clientIp(ctx.request ?? headers, ctx.context);
|
|
850
|
+
const body = ctx.body ?? {};
|
|
851
|
+
const value = identifier ?? [
|
|
852
|
+
body.email,
|
|
853
|
+
body.username,
|
|
854
|
+
body.phoneNumber
|
|
855
|
+
].find((field) => typeof field === "string");
|
|
856
|
+
const visitor = await resolveVisitor(ctx.context, headers, ip);
|
|
857
|
+
return {
|
|
858
|
+
headers,
|
|
859
|
+
ip,
|
|
860
|
+
visitorId: visitor.id,
|
|
861
|
+
visitorVerified: visitor.verified,
|
|
862
|
+
identifier: value && emailNormalization && value.includes("@") ? normalizeEmail(value) : value ?? null,
|
|
863
|
+
userAgent: headers?.get("user-agent") ?? null,
|
|
864
|
+
acceptLanguage: headers?.get("accept-language") ?? null
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
function act(ctx, store, details, action, eventType, reason, metadata, userId) {
|
|
868
|
+
return guard(ctx.context, async () => executeAction({
|
|
869
|
+
auth: ctx.context,
|
|
870
|
+
event: ctx,
|
|
871
|
+
store,
|
|
872
|
+
action,
|
|
873
|
+
eventType,
|
|
874
|
+
reason,
|
|
875
|
+
ipAddress: details.ip,
|
|
876
|
+
visitorId: details.visitorId,
|
|
877
|
+
identifier: details.identifier,
|
|
878
|
+
userId,
|
|
879
|
+
challengePassed: action === "challenge" ? await challengePassed(challengeKey(ctx, details), details, store, ctx.context) : false,
|
|
880
|
+
difficulty,
|
|
881
|
+
metadata
|
|
882
|
+
}));
|
|
883
|
+
}
|
|
884
|
+
/** The key a request's challenge result is cached under */
|
|
885
|
+
function challengeKey(ctx, details) {
|
|
886
|
+
return ctx.request ?? details.headers ?? ctx;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Counters a sign-in attempt is tracked under: one per visitor and IP
|
|
890
|
+
* address, and one per visitor and IP address for the account it signs in
|
|
891
|
+
* to
|
|
892
|
+
*/
|
|
893
|
+
function attemptKeys(details) {
|
|
894
|
+
const account = details.identifier?.toLowerCase();
|
|
895
|
+
return [details.visitorId ? `cs:visitor:${details.visitorId}` : null, details.ip ? `cs:ip:${details.ip}` : null].filter((scope) => scope !== null).map((scope) => ({
|
|
896
|
+
scope,
|
|
897
|
+
account: account ? `${scope}:account:${account}` : null
|
|
898
|
+
}));
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Counts a sign-in attempt and returns the failed attempts before it.
|
|
902
|
+
* Attempts are counted before the password is checked, so attempts sent at
|
|
903
|
+
* the same time can't all pass the check. Successful sign-ins are counted
|
|
904
|
+
* separately and subtracted.
|
|
905
|
+
*/
|
|
906
|
+
async function countAttempt(store, keys, options$1) {
|
|
907
|
+
if (!credentialStuffing) return 0;
|
|
908
|
+
const { windowSeconds } = credentialStuffing;
|
|
909
|
+
const { readOnly } = options$1;
|
|
910
|
+
const count = (name) => readOnly ? store.get(name) : store.increment(name, windowSeconds);
|
|
911
|
+
const failures = await Promise.all(keys.map(async ({ scope, account }) => {
|
|
912
|
+
const total = await count(scope);
|
|
913
|
+
if (total === 1 && !readOnly) await store.reset(`${scope}:succeeded`);
|
|
914
|
+
const succeeded = await store.get(`${scope}:succeeded`);
|
|
915
|
+
const own = account ? await count(account) : 0;
|
|
916
|
+
return Math.max(total - succeeded, own) - 1;
|
|
917
|
+
}));
|
|
918
|
+
return Math.max(0, ...failures);
|
|
919
|
+
}
|
|
920
|
+
function counterNames(keys) {
|
|
921
|
+
return keys.flatMap(({ scope, account }) => account ? [scope, account] : [scope]);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Identities a rate limit counts under. A visitor id that wasn't verified
|
|
925
|
+
* can be changed on every request, so the IP address counts too.
|
|
926
|
+
*/
|
|
927
|
+
function rateIds(details) {
|
|
928
|
+
if (details.visitorId && details.visitorVerified) return [`visitor:${details.visitorId}`];
|
|
929
|
+
return [details.visitorId ? `visitor:${details.visitorId}` : null, details.ip ? `ip:${details.ip}` : null].filter((id) => id !== null);
|
|
930
|
+
}
|
|
931
|
+
/** Increments a counter for each identity and returns the highest count */
|
|
932
|
+
async function incrementAll(store, names, ttlSeconds) {
|
|
933
|
+
const counts = await Promise.all(names.map((name) => store.increment(name, ttlSeconds)));
|
|
934
|
+
return Math.max(0, ...counts);
|
|
935
|
+
}
|
|
936
|
+
/** The location of a request, from the provider or the hosted backend */
|
|
937
|
+
async function locate(auth, details) {
|
|
938
|
+
if (!details.ip) return null;
|
|
939
|
+
if (opts.providers?.resolveGeo) return opts.providers.resolveGeo(details.ip);
|
|
940
|
+
return (await intel.lookup(auth, details)).geo;
|
|
941
|
+
}
|
|
942
|
+
async function lastSignIn(auth, userId) {
|
|
943
|
+
const [row] = await auth.adapter.findMany({
|
|
944
|
+
model: "sentinelSignIn",
|
|
945
|
+
where: [{
|
|
946
|
+
field: "userId",
|
|
947
|
+
value: userId
|
|
948
|
+
}],
|
|
949
|
+
sortBy: {
|
|
950
|
+
field: "createdAt",
|
|
951
|
+
direction: "desc"
|
|
952
|
+
},
|
|
953
|
+
limit: 1
|
|
954
|
+
});
|
|
955
|
+
return row ?? null;
|
|
956
|
+
}
|
|
957
|
+
function daysSince(date) {
|
|
958
|
+
return Math.floor((Date.now() - new Date(date).getTime()) / (DAY_SECONDS * 1e3));
|
|
959
|
+
}
|
|
960
|
+
/** The travel speed from the previous sign-in, when both have coordinates */
|
|
961
|
+
function travel(previous, geo) {
|
|
962
|
+
if (previous?.latitude == null || previous.longitude == null || geo?.latitude == null || geo.longitude == null) return null;
|
|
963
|
+
const speedKmh = travelSpeedKmh({
|
|
964
|
+
latitude: previous.latitude,
|
|
965
|
+
longitude: previous.longitude,
|
|
966
|
+
at: new Date(previous.createdAt)
|
|
967
|
+
}, {
|
|
968
|
+
latitude: geo.latitude,
|
|
969
|
+
longitude: geo.longitude,
|
|
970
|
+
at: /* @__PURE__ */ new Date()
|
|
971
|
+
});
|
|
972
|
+
return {
|
|
973
|
+
speedKmh: Math.round(speedKmh),
|
|
974
|
+
from: {
|
|
975
|
+
country: previous.country,
|
|
976
|
+
city: previous.city
|
|
977
|
+
},
|
|
978
|
+
to: {
|
|
979
|
+
country: geo.country,
|
|
980
|
+
city: geo.city ?? null
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
async function notify(auth, notification) {
|
|
985
|
+
if (!opts.sendNotification) return;
|
|
986
|
+
try {
|
|
987
|
+
await opts.sendNotification(notification);
|
|
988
|
+
} catch (e) {
|
|
989
|
+
auth.logger.error("[sentinel] Failed to send notification", e);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* The address of the account an email is an alias of, or null when the
|
|
994
|
+
* email is an account's address itself or no account matches
|
|
995
|
+
*/
|
|
996
|
+
async function findAliasedAddress(auth, email) {
|
|
997
|
+
const exact = await auth.adapter.findOne({
|
|
998
|
+
model: "user",
|
|
999
|
+
where: [{
|
|
1000
|
+
field: "email",
|
|
1001
|
+
value: email.toLowerCase()
|
|
1002
|
+
}]
|
|
1003
|
+
});
|
|
1004
|
+
const normalizedEmail = normalizeEmail(email);
|
|
1005
|
+
if (exact) {
|
|
1006
|
+
if (!exact.normalizedEmail) await auth.adapter.update({
|
|
1007
|
+
model: "user",
|
|
1008
|
+
where: [{
|
|
1009
|
+
field: "id",
|
|
1010
|
+
value: exact.id
|
|
1011
|
+
}],
|
|
1012
|
+
update: { normalizedEmail }
|
|
1013
|
+
});
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
return (await auth.adapter.findOne({
|
|
1017
|
+
model: "user",
|
|
1018
|
+
where: [{
|
|
1019
|
+
field: "normalizedEmail",
|
|
1020
|
+
value: normalizedEmail
|
|
1021
|
+
}]
|
|
1022
|
+
}))?.email ?? null;
|
|
1023
|
+
}
|
|
1024
|
+
/** Disposable domains, MX records and generated-looking addresses */
|
|
1025
|
+
async function validateEmail(ctx, store, details, email) {
|
|
1026
|
+
if (!emailValidation) return;
|
|
1027
|
+
const domain = getEmailDomain(email);
|
|
1028
|
+
if (!domain || emailValidation.domainAllowlist.has(domain)) return;
|
|
1029
|
+
throwForDecision(await guard(ctx.context, async () => {
|
|
1030
|
+
const { strictness } = emailValidation;
|
|
1031
|
+
let check = null;
|
|
1032
|
+
if (await isDisposableDomain(domain)) check = "disposable";
|
|
1033
|
+
else if (strictness !== "low" && await hasMxRecords(domain) === false) check = "no_mx_records";
|
|
1034
|
+
else if (strictness === "high" && looksGenerated(email.toLowerCase())) check = "generated";
|
|
1035
|
+
if (!check) return { outcome: "allow" };
|
|
1036
|
+
return act(ctx, store, details, emailValidation.action, SECURITY_EVENT_TYPES.INVALID_EMAIL, "invalid_email", {
|
|
1037
|
+
domain,
|
|
1038
|
+
check,
|
|
1039
|
+
strictness
|
|
1040
|
+
});
|
|
1041
|
+
}));
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Checks run when an account is about to be created, whichever endpoint
|
|
1045
|
+
* creates it: email validation, sign-up velocity and free trial abuse
|
|
1046
|
+
*/
|
|
1047
|
+
async function checkAccountCreation(ctx, email) {
|
|
1048
|
+
if (isManagedAccountCreation(ctx.path)) return;
|
|
1049
|
+
const event = ctx;
|
|
1050
|
+
const store = createSentinelStore(ctx.context);
|
|
1051
|
+
const details = await requestDetails(event, email);
|
|
1052
|
+
if (!OAUTH_CALLBACK_PATHS.includes(ctx.path)) await validateEmail(event, store, details, email);
|
|
1053
|
+
if (velocity) {
|
|
1054
|
+
const ids = rateIds(details);
|
|
1055
|
+
if (ids.length) throwForDecision(await guard(ctx.context, async () => {
|
|
1056
|
+
const count = await incrementAll(store, ids.map((id) => `velocity:sign-up:${id}`), velocity.windowSeconds);
|
|
1057
|
+
if (count <= velocity.maxSignupsPerVisitor) return { outcome: "allow" };
|
|
1058
|
+
return act(event, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
|
|
1059
|
+
count,
|
|
1060
|
+
limit: velocity.maxSignupsPerVisitor,
|
|
1061
|
+
windowSeconds: velocity.windowSeconds
|
|
1062
|
+
});
|
|
1063
|
+
}));
|
|
1064
|
+
}
|
|
1065
|
+
if (freeTrialAbuse && details.visitorId) {
|
|
1066
|
+
const visitorId = details.visitorId;
|
|
1067
|
+
throwForDecision(await guard(ctx.context, async () => {
|
|
1068
|
+
const created = await store.get(`trial:${visitorId}`);
|
|
1069
|
+
const { thresholds } = freeTrialAbuse;
|
|
1070
|
+
let action = null;
|
|
1071
|
+
if (thresholds) {
|
|
1072
|
+
if (created >= thresholds.block) action = "block";
|
|
1073
|
+
else if (created >= thresholds.challenge) action = "challenge";
|
|
1074
|
+
} else if (created >= freeTrialAbuse.maxAccountsPerVisitor) action = freeTrialAbuse.action;
|
|
1075
|
+
if (!action) return { outcome: "allow" };
|
|
1076
|
+
return act(event, store, details, action, SECURITY_EVENT_TYPES.FREE_TRIAL_ABUSE, "free_trial_abuse", { accounts: created });
|
|
1077
|
+
}));
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return {
|
|
1081
|
+
id: "sentinel",
|
|
1082
|
+
init(ctx) {
|
|
1083
|
+
if ((unknownDeviceNotification || staleUsers?.notifyUser || staleUsers?.adminEmail) && !opts.sendNotification) ctx.logger.warn("[sentinel] Notifications are enabled but sendNotification is not set, so none will be sent");
|
|
1084
|
+
if (intel.misconfigured) ctx.logger.warn("[sentinel] Missing PAYVO_INFRA_API_KEY or PAYVO_INFRA_API_URL. Security checks that use the hosted backend fall back to allow mode.");
|
|
1085
|
+
if (!checksAccountCreation && !emailNormalization) return;
|
|
1086
|
+
return { options: { databaseHooks: { user: {
|
|
1087
|
+
create: {
|
|
1088
|
+
async before(user, context) {
|
|
1089
|
+
if (context && checksAccountCreation) await checkAccountCreation(context, user.email);
|
|
1090
|
+
if (emailNormalization) return { data: {
|
|
1091
|
+
...user,
|
|
1092
|
+
normalizedEmail: normalizeEmail(user.email)
|
|
1093
|
+
} };
|
|
1094
|
+
},
|
|
1095
|
+
async after(user, context) {
|
|
1096
|
+
if (!freeTrialAbuse || !context) return;
|
|
1097
|
+
if (isManagedAccountCreation(context.path)) return;
|
|
1098
|
+
const event = context;
|
|
1099
|
+
const headers = event.headers ?? event.request?.headers;
|
|
1100
|
+
const ip = clientIp(event.request ?? headers, context.context);
|
|
1101
|
+
const { id: visitorId } = await resolveVisitor(context.context, headers, ip);
|
|
1102
|
+
if (!visitorId) return;
|
|
1103
|
+
try {
|
|
1104
|
+
await createSentinelStore(context.context).increment(`trial:${visitorId}`, freeTrialAbuse.retentionSeconds, { sliding: true });
|
|
1105
|
+
} catch (e) {
|
|
1106
|
+
context.context.logger.error("[sentinel] Failed to track sign-up", e);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
},
|
|
1110
|
+
update: { async before(data) {
|
|
1111
|
+
if (!emailNormalization || typeof data.email !== "string") return;
|
|
1112
|
+
return { data: {
|
|
1113
|
+
...data,
|
|
1114
|
+
normalizedEmail: normalizeEmail(data.email)
|
|
1115
|
+
} };
|
|
1116
|
+
} }
|
|
1117
|
+
} } } };
|
|
1118
|
+
},
|
|
1119
|
+
async onRequest(request, ctx) {
|
|
1120
|
+
if (!botBlocking && !suspiciousIpBlocking && !geoBlocking) return;
|
|
1121
|
+
const store = createSentinelStore(ctx);
|
|
1122
|
+
const event = {
|
|
1123
|
+
context: ctx,
|
|
1124
|
+
request,
|
|
1125
|
+
headers: request.headers,
|
|
1126
|
+
path: relativePath(request, ctx)
|
|
1127
|
+
};
|
|
1128
|
+
const response = responseForDecision(await guard(ctx, async () => {
|
|
1129
|
+
const details = await requestDetails(event);
|
|
1130
|
+
const { ip } = details;
|
|
1131
|
+
const remote = await intel.lookup(ctx, details);
|
|
1132
|
+
if (suspiciousIpBlocking && ip) {
|
|
1133
|
+
if (opts.providers?.isSuspiciousIp ? await opts.providers.isSuspiciousIp(ip) : remote.suspiciousIp) {
|
|
1134
|
+
const result = await act(event, store, details, suspiciousIpBlocking.action, SECURITY_EVENT_TYPES.SUSPICIOUS_IP, "suspicious_ip", { ip });
|
|
1135
|
+
if (result.outcome !== "allow") return result;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if (geoBlocking && ip) {
|
|
1139
|
+
const country = (opts.providers?.resolveGeo ? await opts.providers.resolveGeo(ip) : remote.geo)?.country?.toUpperCase() ?? null;
|
|
1140
|
+
if (country ? geoBlocking.allowList ? !geoBlocking.allowList.includes(country) : Boolean(geoBlocking.denyList?.includes(country)) : Boolean(geoBlocking.allowList) && !opts.failOpen) {
|
|
1141
|
+
const result = await act(event, store, details, geoBlocking.action, SECURITY_EVENT_TYPES.GEO_BLOCKED, "geo_blocking", { country });
|
|
1142
|
+
if (result.outcome !== "allow") return result;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
if (botBlocking) {
|
|
1146
|
+
if (opts.providers?.isBot ? await opts.providers.isBot(request) : remote.bot ?? looksLikeBot(request)) {
|
|
1147
|
+
const result = await act(event, store, details, botBlocking.action, SECURITY_EVENT_TYPES.BOT_BLOCKED, "bot", { userAgent: request.headers.get("user-agent") });
|
|
1148
|
+
if (result.outcome !== "allow") return result;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return { outcome: "allow" };
|
|
1152
|
+
}));
|
|
1153
|
+
return response ? { response } : void 0;
|
|
1154
|
+
},
|
|
1155
|
+
hooks: {
|
|
1156
|
+
before: [{
|
|
1157
|
+
matcher(context) {
|
|
1158
|
+
return emailNormalization && context.path in NORMALIZED_EMAIL_FIELDS;
|
|
1159
|
+
},
|
|
1160
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
1161
|
+
const field = NORMALIZED_EMAIL_FIELDS[ctx.path];
|
|
1162
|
+
const email = ctx.body?.[field];
|
|
1163
|
+
if (typeof email !== "string") return;
|
|
1164
|
+
const stored = await findAliasedAddress(ctx.context, email).catch((e) => {
|
|
1165
|
+
ctx.context.logger.error("[sentinel] Failed to resolve alias", e);
|
|
1166
|
+
return null;
|
|
1167
|
+
});
|
|
1168
|
+
if (!stored) return;
|
|
1169
|
+
return { context: { body: {
|
|
1170
|
+
...ctx.body,
|
|
1171
|
+
[field]: stored
|
|
1172
|
+
} } };
|
|
1173
|
+
})
|
|
1174
|
+
}, {
|
|
1175
|
+
matcher(context) {
|
|
1176
|
+
const isCredentialSignIn = CREDENTIAL_SIGN_IN_PATHS.includes(context.path);
|
|
1177
|
+
return Boolean(credentialStuffing && isAttemptPath(context.path) || velocity && (isCredentialSignIn || SIGN_UP_PATHS.includes(context.path) || PASSWORD_RESET_PATHS.includes(context.path)) || emailValidation && context.path === "/change-email" || compromisedPassword && context.path in NEW_PASSWORD_FIELDS);
|
|
1178
|
+
},
|
|
1179
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
1180
|
+
const store = createSentinelStore(ctx.context);
|
|
1181
|
+
const details = await requestDetails(ctx);
|
|
1182
|
+
const body = ctx.body ?? {};
|
|
1183
|
+
if (emailValidation && ctx.path === "/change-email" && typeof body.newEmail === "string") await validateEmail(ctx, store, details, body.newEmail);
|
|
1184
|
+
if (compromisedPassword && ctx.path in NEW_PASSWORD_FIELDS) {
|
|
1185
|
+
const password = body[NEW_PASSWORD_FIELDS[ctx.path]];
|
|
1186
|
+
if (typeof password === "string" && password) throwForDecision(await guard(ctx.context, async () => {
|
|
1187
|
+
const breachCount = await getPasswordBreachCount(password);
|
|
1188
|
+
if (breachCount < compromisedPassword.minBreachCount) return { outcome: "allow" };
|
|
1189
|
+
return act(ctx, store, details, compromisedPassword.action, SECURITY_EVENT_TYPES.COMPROMISED_PASSWORD, "compromised_password", { breachCount });
|
|
1190
|
+
}));
|
|
1191
|
+
}
|
|
1192
|
+
if (credentialStuffing && isAttemptPath(ctx.path)) throwForDecision(await guard(ctx.context, async () => {
|
|
1193
|
+
const keys = attemptKeys(details);
|
|
1194
|
+
const solved = Boolean(details.headers?.get(POW_SOLUTION_HEADER)) && await challengePassed(challengeKey(ctx, details), details, store, ctx.context);
|
|
1195
|
+
const blocked = (await Promise.all(counterNames(keys).map((key) => store.get(`${key}:blocked`)))).some((value) => value > 0);
|
|
1196
|
+
const failures = await countAttempt(store, keys, { readOnly: solved || blocked });
|
|
1197
|
+
const { thresholds } = credentialStuffing;
|
|
1198
|
+
const metadata = {
|
|
1199
|
+
failedAttempts: failures,
|
|
1200
|
+
thresholds
|
|
1201
|
+
};
|
|
1202
|
+
if (blocked || failures >= thresholds.block) return act(ctx, store, details, credentialStuffing.action === "log" ? "log" : "block", SECURITY_EVENT_TYPES.CREDENTIAL_STUFFING, "credential_stuffing", metadata);
|
|
1203
|
+
if (failures >= thresholds.challenge) return act(ctx, store, details, credentialStuffing.action === "log" ? "log" : "challenge", SECURITY_EVENT_TYPES.CREDENTIAL_STUFFING, "credential_stuffing", metadata);
|
|
1204
|
+
return { outcome: "allow" };
|
|
1205
|
+
}));
|
|
1206
|
+
if (velocity) {
|
|
1207
|
+
const { windowSeconds } = velocity;
|
|
1208
|
+
let limit = null;
|
|
1209
|
+
if (CREDENTIAL_SIGN_IN_PATHS.includes(ctx.path) && details.ip) limit = {
|
|
1210
|
+
name: `velocity:sign-in:${details.ip}`,
|
|
1211
|
+
max: velocity.maxSignInsPerIp
|
|
1212
|
+
};
|
|
1213
|
+
else if (PASSWORD_RESET_PATHS.includes(ctx.path) && details.ip) limit = {
|
|
1214
|
+
name: `velocity:password-reset:${details.ip}`,
|
|
1215
|
+
max: velocity.maxPasswordResetsPerIp
|
|
1216
|
+
};
|
|
1217
|
+
const ids = rateIds(details);
|
|
1218
|
+
const isTracked = CREDENTIAL_SIGN_IN_PATHS.includes(ctx.path) || SIGN_UP_PATHS.includes(ctx.path) || PASSWORD_RESET_PATHS.includes(ctx.path);
|
|
1219
|
+
throwForDecision(await guard(ctx.context, async () => {
|
|
1220
|
+
if (limit) {
|
|
1221
|
+
const count = await store.increment(limit.name, windowSeconds);
|
|
1222
|
+
if (count > limit.max) {
|
|
1223
|
+
const result = await act(ctx, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
|
|
1224
|
+
count,
|
|
1225
|
+
limit: limit.max,
|
|
1226
|
+
windowSeconds
|
|
1227
|
+
});
|
|
1228
|
+
if (result.outcome !== "allow") return result;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (ids.length && isTracked) {
|
|
1232
|
+
const total = await incrementAll(store, ids.map((id) => `velocity:total:${id}`), windowSeconds);
|
|
1233
|
+
const { thresholds } = velocity;
|
|
1234
|
+
if (total > thresholds.challenge) return act(ctx, store, details, total > thresholds.block ? "block" : "challenge", SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
|
|
1235
|
+
count: total,
|
|
1236
|
+
thresholds,
|
|
1237
|
+
windowSeconds
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
return { outcome: "allow" };
|
|
1241
|
+
}));
|
|
1242
|
+
}
|
|
1243
|
+
})
|
|
1244
|
+
}],
|
|
1245
|
+
after: [{
|
|
1246
|
+
matcher(context) {
|
|
1247
|
+
return Boolean(credentialStuffing && isAttemptPath(context.path));
|
|
1248
|
+
},
|
|
1249
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
1250
|
+
if (!credentialStuffing) return;
|
|
1251
|
+
const returned = ctx.context.returned;
|
|
1252
|
+
const status = returned instanceof APIError ? returned.statusCode : 200;
|
|
1253
|
+
try {
|
|
1254
|
+
const store = createSentinelStore(ctx.context);
|
|
1255
|
+
const keys = attemptKeys(await requestDetails(ctx));
|
|
1256
|
+
const { windowSeconds, cooldownSeconds, thresholds } = credentialStuffing;
|
|
1257
|
+
if (status < 400) for (const { scope, account } of keys) {
|
|
1258
|
+
const [total, succeeded, own] = await Promise.all([
|
|
1259
|
+
store.get(scope),
|
|
1260
|
+
store.get(`${scope}:succeeded`),
|
|
1261
|
+
account ? store.get(account) : 0
|
|
1262
|
+
]);
|
|
1263
|
+
if (account) await store.reset(account);
|
|
1264
|
+
if (total - succeeded <= own) {
|
|
1265
|
+
await store.reset(scope);
|
|
1266
|
+
await store.reset(`${scope}:succeeded`);
|
|
1267
|
+
} else await store.increment(`${scope}:succeeded`, windowSeconds);
|
|
1268
|
+
}
|
|
1269
|
+
else if (!SENTINEL_STATUSES.has(status) && status < 500) {
|
|
1270
|
+
const block = async (key, also = []) => {
|
|
1271
|
+
await store.increment(`${key}:blocked`, cooldownSeconds);
|
|
1272
|
+
await Promise.all([key, ...also].map((name) => store.reset(name)));
|
|
1273
|
+
};
|
|
1274
|
+
for (const { scope, account } of keys) {
|
|
1275
|
+
const [total, succeeded] = await Promise.all([store.get(scope), store.get(`${scope}:succeeded`)]);
|
|
1276
|
+
if (total - succeeded >= thresholds.block) await block(scope, [`${scope}:succeeded`]);
|
|
1277
|
+
if (account && await store.get(account) >= thresholds.block) await block(account);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
} catch (e) {
|
|
1281
|
+
ctx.context.logger.error("[sentinel] Failed to track attempt", e);
|
|
1282
|
+
}
|
|
1283
|
+
})
|
|
1284
|
+
}, {
|
|
1285
|
+
matcher(context) {
|
|
1286
|
+
return tracksSignIns && (SIGN_IN_PATHS.includes(context.path) || SIGN_UP_PATHS.includes(context.path));
|
|
1287
|
+
},
|
|
1288
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
1289
|
+
const returned = ctx.context.returned;
|
|
1290
|
+
const newSession = ctx.context.newSession;
|
|
1291
|
+
if (!newSession || returned instanceof APIError && returned.statusCode >= 400) return;
|
|
1292
|
+
const auth = ctx.context;
|
|
1293
|
+
const store = createSentinelStore(auth);
|
|
1294
|
+
const user = newSession.user;
|
|
1295
|
+
const isSignUp = SIGN_UP_PATHS.includes(ctx.path);
|
|
1296
|
+
const awaitsSecondFactor = Boolean(returned && !(returned instanceof APIError) && "twoFactorRedirect" in returned);
|
|
1297
|
+
const passedKey = `checked:${user.id}`;
|
|
1298
|
+
const checkedBefore = ctx.path.startsWith("/two-factor/") && await store.get(passedKey).catch(() => 0) > 0;
|
|
1299
|
+
if (checkedBefore) await store.reset(passedKey).catch(() => {});
|
|
1300
|
+
const repeatable = REPEATABLE_SIGN_IN_PATHS.includes(ctx.path);
|
|
1301
|
+
/**
|
|
1302
|
+
* Undoes a sign-in that a check rejects after the session was
|
|
1303
|
+
* created. Sign-ins that can't be repeated with a solution are
|
|
1304
|
+
* blocked instead of challenged.
|
|
1305
|
+
*/
|
|
1306
|
+
const reject = async (decision) => {
|
|
1307
|
+
if (decision.outcome === "allow") return null;
|
|
1308
|
+
await auth.internalAdapter.deleteSession(newSession.session.token).catch((e) => auth.logger.error("[sentinel] Failed to revoke session", e));
|
|
1309
|
+
deleteSessionCookie(ctx);
|
|
1310
|
+
auth.setNewSession(null);
|
|
1311
|
+
return errorForDecision(decision);
|
|
1312
|
+
};
|
|
1313
|
+
const enforce = (action) => action === "challenge" && !repeatable ? "block" : action;
|
|
1314
|
+
let details;
|
|
1315
|
+
let previous;
|
|
1316
|
+
let geo;
|
|
1317
|
+
try {
|
|
1318
|
+
details = await requestDetails(ctx, user.email);
|
|
1319
|
+
previous = await lastSignIn(auth, user.id);
|
|
1320
|
+
geo = await locate(auth, details).catch(() => null);
|
|
1321
|
+
} catch (e) {
|
|
1322
|
+
auth.logger.error("[sentinel] Failed to track sign-in", e);
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
const signIn = {
|
|
1326
|
+
at: /* @__PURE__ */ new Date(),
|
|
1327
|
+
ipAddress: details.ip,
|
|
1328
|
+
userAgent: details.userAgent,
|
|
1329
|
+
location: [geo?.city, geo?.country].filter(Boolean).join(", ") || null
|
|
1330
|
+
};
|
|
1331
|
+
if (!isSignUp && !checkedBefore && impossibleTravel && previous) {
|
|
1332
|
+
const trip = travel(previous, geo);
|
|
1333
|
+
if (trip && trip.speedKmh > impossibleTravel.maxSpeedKmh) {
|
|
1334
|
+
const rejected = await reject(await act(ctx, store, details, enforce(impossibleTravel.action), SECURITY_EVENT_TYPES.IMPOSSIBLE_TRAVEL, "impossible_travel", {
|
|
1335
|
+
...trip,
|
|
1336
|
+
maxSpeedKmh: impossibleTravel.maxSpeedKmh
|
|
1337
|
+
}, user.id));
|
|
1338
|
+
if (rejected) return rejected;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
const daysInactive = !isSignUp && staleUsers && previous ? daysSince(previous.createdAt) : null;
|
|
1342
|
+
const isStale = Boolean(staleUsers && daysInactive !== null && daysInactive > staleUsers.staleDays);
|
|
1343
|
+
if (staleUsers && isStale && !checkedBefore) {
|
|
1344
|
+
const rejected = await reject(await act(ctx, store, details, enforce(staleUsers.action), SECURITY_EVENT_TYPES.STALE_ACCOUNT, "stale_account", {
|
|
1345
|
+
daysInactive,
|
|
1346
|
+
staleDays: staleUsers.staleDays
|
|
1347
|
+
}, user.id));
|
|
1348
|
+
if (rejected) return rejected;
|
|
1349
|
+
}
|
|
1350
|
+
if (awaitsSecondFactor) {
|
|
1351
|
+
await store.increment(passedKey, SECOND_FACTOR_SECONDS).catch((e) => auth.logger.error("[sentinel] Failed to track sign-in", e));
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
try {
|
|
1355
|
+
if (staleUsers && isStale && daysInactive !== null) {
|
|
1356
|
+
if (staleUsers.notifyUser) await notify(auth, {
|
|
1357
|
+
type: "stale-account",
|
|
1358
|
+
recipient: "user",
|
|
1359
|
+
email: user.email,
|
|
1360
|
+
user,
|
|
1361
|
+
signIn,
|
|
1362
|
+
daysInactive
|
|
1363
|
+
});
|
|
1364
|
+
if (staleUsers.adminEmail) await notify(auth, {
|
|
1365
|
+
type: "stale-account",
|
|
1366
|
+
recipient: "admin",
|
|
1367
|
+
email: staleUsers.adminEmail,
|
|
1368
|
+
user,
|
|
1369
|
+
signIn,
|
|
1370
|
+
daysInactive
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
if (!isSignUp && unknownDeviceNotification && details.visitorId && previous) {
|
|
1374
|
+
if (!await auth.adapter.findOne({
|
|
1375
|
+
model: "sentinelSignIn",
|
|
1376
|
+
where: [{
|
|
1377
|
+
field: "userId",
|
|
1378
|
+
value: user.id
|
|
1379
|
+
}, {
|
|
1380
|
+
field: "visitorId",
|
|
1381
|
+
value: details.visitorId
|
|
1382
|
+
}]
|
|
1383
|
+
})) await notify(auth, {
|
|
1384
|
+
type: "unknown-device",
|
|
1385
|
+
recipient: "user",
|
|
1386
|
+
email: user.email,
|
|
1387
|
+
user,
|
|
1388
|
+
signIn
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
await auth.adapter.create({
|
|
1392
|
+
model: "sentinelSignIn",
|
|
1393
|
+
data: {
|
|
1394
|
+
userId: user.id,
|
|
1395
|
+
ipAddress: details.ip,
|
|
1396
|
+
visitorId: details.visitorId,
|
|
1397
|
+
userAgent: details.userAgent,
|
|
1398
|
+
country: geo?.country ?? null,
|
|
1399
|
+
city: geo?.city ?? null,
|
|
1400
|
+
latitude: geo?.latitude ?? null,
|
|
1401
|
+
longitude: geo?.longitude ?? null,
|
|
1402
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
} catch (e) {
|
|
1406
|
+
if (e instanceof APIError) throw e;
|
|
1407
|
+
auth.logger.error("[sentinel] Failed to track sign-in", e);
|
|
1408
|
+
}
|
|
1409
|
+
})
|
|
1410
|
+
}]
|
|
1411
|
+
},
|
|
1412
|
+
schema: mergeSchema(emailNormalization ? {
|
|
1413
|
+
...schema,
|
|
1414
|
+
...normalizedEmailSchema
|
|
1415
|
+
} : schema, opts.schema),
|
|
1416
|
+
$ERROR_CODES: SENTINEL_ERROR_CODES,
|
|
1417
|
+
options: opts
|
|
1418
|
+
};
|
|
1419
|
+
};
|
|
1420
|
+
function resolveToggle(value, defaultAction) {
|
|
1421
|
+
if (!value) return null;
|
|
1422
|
+
return { action: value === true ? defaultAction : value.action };
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
//#endregion
|
|
1426
|
+
export { SENTINEL_ERROR_CODES as i, haversineDistanceKm as n, normalizeEmail as r, sentinel as t };
|