@gatwech/auth 1.0.0-beta.20 → 1.0.0-beta.22
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/dist/adapters/drizzle-adapter/index.mjs +11 -2
- package/dist/adapters/index.d.mts +2 -2
- package/dist/adapters/kysely-adapter/index.mjs +1 -1
- package/dist/adapters/memory-adapter/index.d.mts +1 -1
- package/dist/adapters/memory-adapter/index.mjs +2 -2
- package/dist/adapters/prisma-adapter/index.mjs +1 -1
- package/dist/{admin-BzUJL6tr.mjs → admin-rd-xHhnP.mjs} +1 -1
- package/dist/{anonymous-BjlTk4gl.mjs → anonymous-qSasVeIe.mjs} +1 -1
- package/dist/api/index.d.mts +2 -2
- package/dist/api/index.mjs +2 -2
- package/dist/{api-Dk8Yj_J5.mjs → api-BV65Y-Rk.mjs} +5 -0
- package/dist/{audit-log-DKsUWR8O.mjs → audit-log-DgqHJ-uv.mjs} +132 -63
- package/dist/{auth-vfCyIIeV.mjs → auth-DtOjarwu.mjs} +15 -2
- package/dist/client/index.d.mts +2 -2
- package/dist/client/plugins/index.d.mts +28 -28
- package/dist/client/plugins/index.mjs +2 -2
- package/dist/client/react/index.d.mts +2 -2
- package/dist/{client-4QEb0rUr.mjs → client-CMsC6-rl.mjs} +1 -1
- package/dist/{client-C_qmyez2.d.mts → client-DO9MgP_0.d.mts} +1 -1
- package/dist/{client-F67q-Lbi.d.mts → client-ukvqpcbI.d.mts} +1 -1
- package/dist/cookies/index.d.mts +3 -3
- package/dist/db/index.d.mts +2 -2
- package/dist/db/index.mjs +1 -1
- package/dist/{db-7vHnL1XA.mjs → db-CV59HpEF.mjs} +2 -2
- package/dist/{email-otp-Dw0DypjQ.mjs → email-otp-BmgAht1S.mjs} +1 -1
- package/dist/{generic-oauth-DFj27JY8.mjs → generic-oauth-C5_AXj2z.mjs} +1 -1
- package/dist/{haveibeenpwned-CzNda7Tn.mjs → haveibeenpwned-_3a5amT-.mjs} +1 -1
- package/dist/{index-D57DuhZR.d.mts → index-AiKdOFSd.d.mts} +116 -64
- package/dist/{index-Bml-PCqP.d.mts → index-B4hJVVRn.d.mts} +19 -19
- package/dist/{index-DwPAddfE.d.mts → index-BC0aAKsT.d.mts} +3 -3
- package/dist/{index-Df0KhQRf-BEa5tdwJ.d.mts → index-BL_t8cRp-C4QYBsac.d.mts} +56 -17
- package/dist/{index-DyXi7S2M.d.mts → index-BfLofiXO.d.mts} +10 -10
- package/dist/{index-CP8x1TFq.d.mts → index-BzzhaGLe.d.mts} +6 -6
- package/dist/{index-DFSRYWFI.d.mts → index-CBGNX7W1.d.mts} +6 -6
- package/dist/{index-CofuaNMl.d.mts → index-CRNh-5Tc.d.mts} +7 -7
- package/dist/{index-CNd5jqLl.d.mts → index-CRiKKdkb.d.mts} +6 -6
- package/dist/{index-CsxtcAO0.d.mts → index-CTSeVJJB.d.mts} +1 -1
- package/dist/{index-sb-UFLuo.d.mts → index-CxhQu1HM.d.mts} +2 -2
- package/dist/{index-Dnd8S4S4.d.mts → index-D-rjuF7p.d.mts} +9 -9
- package/dist/{index-XfE0WjTN.d.mts → index-D3uQsW8T.d.mts} +34 -34
- package/dist/{index-4I1YhYhk.d.mts → index-DKirx_GW.d.mts} +4 -4
- package/dist/{index-KhbNmMmG.d.mts → index-DSoiUc1C.d.mts} +167 -167
- package/dist/{index-DTkcY7xH.d.mts → index-Dkm27DLN.d.mts} +10 -10
- package/dist/{index-BsSKcKgO.d.mts → index-Dl0bD1_6.d.mts} +6 -6
- package/dist/{index-DGeoJ4gu.d.mts → index-DlS-HqZR.d.mts} +2 -2
- package/dist/{index-BNhJglHC.d.mts → index-DngQlUSI.d.mts} +114 -114
- package/dist/{index-CC920PPp.d.mts → index-Do8AV4PF.d.mts} +14 -14
- package/dist/{index-CBiGsvDd.d.mts → index-SYRh5FW7.d.mts} +8 -8
- package/dist/{index-BX7rl07Z.d.mts → index-eOfQxlQV.d.mts} +13 -13
- package/dist/{index-D6ku0rUC.d.mts → index-hP8gvyp-.d.mts} +5 -5
- package/dist/{index-CzpdkTdv.d.mts → index-pdl9MzQu.d.mts} +9 -9
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +4 -4
- package/dist/integrations/next-js.d.mts +4 -4
- package/dist/integrations/node.d.mts +2 -2
- package/dist/integrations/react-start.d.mts +4 -4
- package/dist/{jwt-DBN2mu0r.mjs → jwt-Czv-v097.mjs} +1 -1
- package/dist/{kysely-adapter-Bkm1liPW.mjs → kysely-adapter-BidlNTiV.mjs} +4 -2
- package/dist/{magic-link-D9Em1aOR.mjs → magic-link-CJgE0Ngn.mjs} +1 -1
- package/dist/{multi-session-Dno_8DxC.mjs → multi-session-bveCXtQW.mjs} +1 -1
- package/dist/{oauth-proxy-DW-zt8wv.mjs → oauth-proxy-69n1xA8L.mjs} +1 -1
- package/dist/oauth2/index.d.mts +2 -2
- package/dist/oauth2/index.mjs +2 -2
- package/dist/{one-tap-CodeyQb8.mjs → one-tap-Cm1eHuqi.mjs} +1 -1
- package/dist/{organization-CkTKs7bx.mjs → organization-BK3ks97y.mjs} +2 -2
- package/dist/plugins/admin/index.d.mts +3 -3
- package/dist/plugins/admin/index.mjs +3 -3
- package/dist/plugins/anonymous/index.d.mts +3 -3
- package/dist/plugins/anonymous/index.mjs +3 -3
- package/dist/plugins/audit-log/client.d.mts +24 -24
- package/dist/plugins/audit-log/index.d.mts +23 -23
- package/dist/plugins/audit-log/index.mjs +3 -3
- package/dist/plugins/bearer/index.d.mts +1 -1
- package/dist/plugins/captcha/index.d.mts +1 -1
- package/dist/plugins/custom-session/index.d.mts +3 -3
- package/dist/plugins/custom-session/index.mjs +2 -2
- package/dist/plugins/device-authorization/index.d.mts +1 -1
- package/dist/plugins/device-authorization/index.mjs +1 -1
- package/dist/plugins/email-otp/index.d.mts +2 -2
- package/dist/plugins/email-otp/index.mjs +3 -3
- package/dist/plugins/generic-oauth/index.d.mts +3 -3
- package/dist/plugins/generic-oauth/index.mjs +3 -3
- package/dist/plugins/haveibeenpwned/index.d.mts +1 -1
- package/dist/plugins/haveibeenpwned/index.mjs +3 -3
- package/dist/plugins/index.d.mts +23 -23
- package/dist/plugins/index.mjs +18 -18
- package/dist/plugins/jwt/index.d.mts +3 -3
- package/dist/plugins/jwt/index.mjs +3 -3
- package/dist/plugins/magic-link/index.d.mts +1 -1
- package/dist/plugins/magic-link/index.mjs +3 -3
- package/dist/plugins/multi-session/index.d.mts +2 -2
- package/dist/plugins/multi-session/index.mjs +3 -3
- package/dist/plugins/oauth-proxy/index.d.mts +1 -1
- package/dist/plugins/oauth-proxy/index.mjs +3 -3
- package/dist/plugins/one-tap/index.d.mts +1 -1
- package/dist/plugins/one-tap/index.mjs +3 -3
- package/dist/plugins/one-time-token/index.d.mts +3 -3
- package/dist/plugins/one-time-token/index.mjs +18 -18
- package/dist/plugins/organization/index.d.mts +3 -3
- package/dist/plugins/organization/index.mjs +3 -3
- package/dist/plugins/passkey/index.d.mts +3 -3
- package/dist/plugins/passkey/index.mjs +2 -2
- package/dist/plugins/phone-number/index.d.mts +3 -3
- package/dist/plugins/phone-number/index.mjs +2 -2
- package/dist/plugins/sentinel/client.d.mts +24 -24
- package/dist/plugins/sentinel/client.mjs +2 -2
- package/dist/plugins/sentinel/index.d.mts +23 -23
- package/dist/plugins/sentinel/index.mjs +5 -5
- package/dist/plugins/two-factor/index.d.mts +3 -3
- package/dist/plugins/two-factor/index.mjs +2 -2
- package/dist/plugins/username/index.d.mts +1 -1
- package/dist/plugins/username/index.mjs +3 -3
- package/dist/{plugins-yF8CFUDR.mjs → plugins-RSPSU3ro.mjs} +1 -1
- package/dist/{pow-OK01yPG3.mjs → pow-DvkUopsK.mjs} +17 -1
- package/dist/{sentinel-DiPqydKG.mjs → sentinel-B-2jmmZh.mjs} +325 -98
- 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 +10 -2
- package/dist/src/adapters/tests/number-id.d.ts +4 -4
- 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/plugins/admin/admin.d.ts +2 -2
- package/dist/src/plugins/api-key/index.d.ts +2 -2
- package/dist/src/plugins/audit-log/index.d.ts +5 -14
- package/dist/src/plugins/audit-log/remote.d.ts +4 -4
- package/dist/src/plugins/audit-log/types.d.ts +22 -0
- package/dist/src/plugins/email-otp/index.d.ts +1 -1
- package/dist/src/plugins/multi-session/index.d.ts +8 -8
- 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/geo.d.ts +11 -0
- package/dist/src/plugins/sentinel/index.d.ts +7 -0
- package/dist/src/plugins/sentinel/intel.d.ts +8 -1
- package/dist/src/plugins/sentinel/memory-cache.d.ts +12 -0
- package/dist/src/plugins/sentinel/schema.d.ts +4 -0
- package/dist/src/plugins/sentinel/store.d.ts +7 -0
- package/dist/src/plugins/sentinel/types.d.ts +29 -1
- package/dist/src/test-utils/test-instance.d.ts +25 -25
- package/dist/test-utils/index.d.mts +277 -277
- package/dist/test-utils/index.mjs +20 -20
- package/dist/types/index.d.mts +2 -2
- package/dist/{username-BHNvcvsY.mjs → username-DCp6yugu.mjs} +1 -1
- package/package.json +3 -3
|
@@ -3,8 +3,8 @@ import { t as safeJSONParse } from "./json-D0TBLy4q.mjs";
|
|
|
3
3
|
import { n as deleteSessionCookie } from "./cookies-BuvlgcA-.mjs";
|
|
4
4
|
import { n as mergeSchema } from "./date-CMr04reD.mjs";
|
|
5
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-
|
|
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-
|
|
6
|
+
import { t as getPasswordBreachCount } from "./haveibeenpwned-_3a5amT-.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-DvkUopsK.mjs";
|
|
8
8
|
import { env } from "@gatwech/core/env";
|
|
9
9
|
import { PayvoAuthError } from "@gatwech/core/error";
|
|
10
10
|
import { defineErrorCodes } from "@gatwech/core/utils";
|
|
@@ -15,15 +15,20 @@ import { betterFetch } from "@better-fetch/fetch";
|
|
|
15
15
|
//#region src/plugins/sentinel/actions.ts
|
|
16
16
|
const CHALLENGE_TTL_MS = 6e4;
|
|
17
17
|
/**
|
|
18
|
+
* Challenges an IP address can be issued within one challenge lifetime.
|
|
19
|
+
* Each one is stored, so past this the request is blocked instead.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_CHALLENGES_PER_IP = 30;
|
|
22
|
+
/**
|
|
18
23
|
* Applies a security action: records the event and decides whether the
|
|
19
24
|
* request continues, gets a proof-of-work challenge or is blocked
|
|
20
25
|
*/
|
|
21
26
|
async function executeAction(params) {
|
|
22
27
|
const { auth, event, action, reason } = params;
|
|
23
28
|
const recorder = getAuditRecorder(auth);
|
|
24
|
-
const record = async (eventType, status) => {
|
|
29
|
+
const record = async (eventType, status, metadata = params.metadata) => {
|
|
25
30
|
if (!recorder) {
|
|
26
|
-
auth.logger.warn(`[sentinel] ${reason}: ${status}`,
|
|
31
|
+
auth.logger.warn(`[sentinel] ${reason}: ${status}`, metadata);
|
|
27
32
|
return;
|
|
28
33
|
}
|
|
29
34
|
await recorder(event, {
|
|
@@ -36,7 +41,7 @@ async function executeAction(params) {
|
|
|
36
41
|
eventData: {
|
|
37
42
|
reason,
|
|
38
43
|
action,
|
|
39
|
-
...
|
|
44
|
+
...metadata
|
|
40
45
|
}
|
|
41
46
|
});
|
|
42
47
|
};
|
|
@@ -49,12 +54,32 @@ async function executeAction(params) {
|
|
|
49
54
|
await record(SECURITY_EVENT_TYPES.ALLOWED_AFTER_CHALLENGE, "success");
|
|
50
55
|
return { outcome: "allow" };
|
|
51
56
|
}
|
|
57
|
+
if (params.ipAddress) {
|
|
58
|
+
const challengesIssued = await params.store.increment(`pow-issued:${params.ipAddress}`, CHALLENGE_TTL_MS / 1e3).catch((e) => {
|
|
59
|
+
auth.logger.error("[sentinel] Failed to count challenges", e);
|
|
60
|
+
return 0;
|
|
61
|
+
});
|
|
62
|
+
if (challengesIssued > MAX_CHALLENGES_PER_IP) {
|
|
63
|
+
const metadata = {
|
|
64
|
+
...params.metadata,
|
|
65
|
+
challengesIssued,
|
|
66
|
+
maxChallenges: MAX_CHALLENGES_PER_IP
|
|
67
|
+
};
|
|
68
|
+
await record(params.eventType, "blocked", metadata);
|
|
69
|
+
await record(SECURITY_EVENT_TYPES.BLOCKED, "blocked", metadata);
|
|
70
|
+
return {
|
|
71
|
+
outcome: "block",
|
|
72
|
+
reason
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
52
76
|
const nonce = createNonce();
|
|
53
77
|
try {
|
|
54
78
|
await params.store.saveChallenge({
|
|
55
79
|
nonce,
|
|
56
80
|
difficulty: params.difficulty,
|
|
57
81
|
reason,
|
|
82
|
+
path: event.path ?? null,
|
|
58
83
|
ipAddress: params.ipAddress,
|
|
59
84
|
visitorId: params.visitorId,
|
|
60
85
|
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS)
|
|
@@ -203,15 +228,15 @@ const isMissingRecord = (e) => {
|
|
|
203
228
|
};
|
|
204
229
|
/** Whether a domain has an A or AAAA record, null when a lookup failed */
|
|
205
230
|
async function hasAddressRecords(dns, domain) {
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
208
|
-
return
|
|
231
|
+
const addressLookups = await Promise.allSettled([dns.resolve4(domain), dns.resolve6(domain)]);
|
|
232
|
+
if (addressLookups.some((lookup) => lookup.status === "fulfilled" && lookup.value.length > 0)) return true;
|
|
233
|
+
return addressLookups.some((lookup) => lookup.status === "rejected" && !isMissingRecord(lookup.reason)) ? null : false;
|
|
209
234
|
}
|
|
210
|
-
async function
|
|
235
|
+
async function canReceiveMail(dns, domain) {
|
|
211
236
|
try {
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
214
|
-
if (
|
|
237
|
+
const mxRecords = await dns.resolveMx(domain);
|
|
238
|
+
if (mxRecords.length === 1 && (mxRecords[0].exchange === "" || mxRecords[0].exchange === ".")) return false;
|
|
239
|
+
if (mxRecords.length > 0) return true;
|
|
215
240
|
} catch (e) {
|
|
216
241
|
const code = e.code;
|
|
217
242
|
if (code === "ENOTFOUND" || code === "ENOTIMP") return false;
|
|
@@ -239,7 +264,7 @@ async function hasMxRecords(domain, options = {}) {
|
|
|
239
264
|
timer = setTimeout(() => resolve(null), options.timeoutMs ?? MAIL_LOOKUP_TIMEOUT_MS);
|
|
240
265
|
});
|
|
241
266
|
try {
|
|
242
|
-
return await Promise.race([
|
|
267
|
+
return await Promise.race([canReceiveMail(dns, domain), timeout]);
|
|
243
268
|
} finally {
|
|
244
269
|
clearTimeout(timer);
|
|
245
270
|
}
|
|
@@ -260,6 +285,28 @@ function looksGenerated(email) {
|
|
|
260
285
|
//#region src/plugins/sentinel/geo.ts
|
|
261
286
|
const EARTH_RADIUS_KM = 6371;
|
|
262
287
|
const toRadians = (degrees) => degrees * Math.PI / 180;
|
|
288
|
+
/** Stored coordinates are whole numbers of millionths of a degree */
|
|
289
|
+
const COORDINATE_SCALE = 1e6;
|
|
290
|
+
/** The largest whole-degree value, which tells an older, unscaled row apart */
|
|
291
|
+
const MAX_DEGREES = 360;
|
|
292
|
+
/**
|
|
293
|
+
* A coordinate as stored. The database column is an integer (the schema has
|
|
294
|
+
* no float type), so degrees are scaled to millionths of a degree, which is
|
|
295
|
+
* about 11 cm.
|
|
296
|
+
*/
|
|
297
|
+
function toStoredCoordinate(degrees) {
|
|
298
|
+
if (degrees == null) return null;
|
|
299
|
+
const scaled = Math.round(degrees * COORDINATE_SCALE);
|
|
300
|
+
return scaled !== 0 && Math.abs(scaled) <= MAX_DEGREES ? Math.sign(scaled) * (MAX_DEGREES + 1) : scaled;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* A stored coordinate in degrees. Rows written before coordinates were scaled
|
|
304
|
+
* hold whole degrees, which are never more than 360 in size.
|
|
305
|
+
*/
|
|
306
|
+
function fromStoredCoordinate(stored) {
|
|
307
|
+
if (stored == null) return null;
|
|
308
|
+
return Math.abs(stored) <= MAX_DEGREES ? stored : stored / COORDINATE_SCALE;
|
|
309
|
+
}
|
|
263
310
|
/** Great-circle distance between two coordinates, in kilometres */
|
|
264
311
|
function haversineDistanceKm(from, to) {
|
|
265
312
|
const dLat = toRadians(to.latitude - from.latitude);
|
|
@@ -278,6 +325,41 @@ function travelSpeedKmh(from, to) {
|
|
|
278
325
|
return distance / (Math.max(Math.abs(to.at.getTime() - from.at.getTime()), 6e4) / 36e5);
|
|
279
326
|
}
|
|
280
327
|
|
|
328
|
+
//#endregion
|
|
329
|
+
//#region src/plugins/sentinel/memory-cache.ts
|
|
330
|
+
/**
|
|
331
|
+
* A small in-memory cache that forgets entries after a TTL and drops the
|
|
332
|
+
* least recently used one when it's full. Used when there's no secondary
|
|
333
|
+
* storage to share verdicts through.
|
|
334
|
+
*/
|
|
335
|
+
function createMemoryCache(options) {
|
|
336
|
+
const entries = /* @__PURE__ */ new Map();
|
|
337
|
+
return {
|
|
338
|
+
get(key) {
|
|
339
|
+
const entry = entries.get(key);
|
|
340
|
+
if (!entry) return void 0;
|
|
341
|
+
if (entry.expiresAt <= Date.now()) {
|
|
342
|
+
entries.delete(key);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
entries.delete(key);
|
|
346
|
+
entries.set(key, entry);
|
|
347
|
+
return entry.value;
|
|
348
|
+
},
|
|
349
|
+
set(key, value, ttlMs = options.ttlMs) {
|
|
350
|
+
entries.delete(key);
|
|
351
|
+
if (entries.size >= options.maxSize) {
|
|
352
|
+
const oldest = entries.keys().next().value;
|
|
353
|
+
if (oldest !== void 0) entries.delete(oldest);
|
|
354
|
+
}
|
|
355
|
+
entries.set(key, {
|
|
356
|
+
value,
|
|
357
|
+
expiresAt: Date.now() + ttlMs
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
281
363
|
//#endregion
|
|
282
364
|
//#region src/plugins/sentinel/intel.ts
|
|
283
365
|
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;
|
|
@@ -296,9 +378,18 @@ const UNKNOWN = {
|
|
|
296
378
|
bot: null,
|
|
297
379
|
geo: null
|
|
298
380
|
};
|
|
381
|
+
/** Verdicts kept in memory when there's no secondary storage */
|
|
382
|
+
const MEMORY_CACHE_SIZE = 5e3;
|
|
383
|
+
/** How long a verified visitor id is remembered */
|
|
384
|
+
const VISITOR_CACHE_SECONDS = 300;
|
|
385
|
+
/** How long an in-memory request verdict lasts when the backend sets none */
|
|
386
|
+
const DEFAULT_INTEL_CACHE_SECONDS = 60;
|
|
387
|
+
/** The longest an in-memory request verdict lasts */
|
|
388
|
+
const MAX_INTEL_CACHE_SECONDS = 300;
|
|
299
389
|
/**
|
|
300
390
|
* Asks the hosted backend about a request's IP address and client. Verdicts
|
|
301
|
-
* are cached
|
|
391
|
+
* are cached for as long as the backend allows, in secondary storage or, when
|
|
392
|
+
* there is none, in memory.
|
|
302
393
|
* Returns null verdicts when the backend isn't configured or can't answer.
|
|
303
394
|
*/
|
|
304
395
|
function createIntelClient(options) {
|
|
@@ -312,15 +403,26 @@ function createIntelClient(options) {
|
|
|
312
403
|
baseDelay: options.kvOptions?.retry?.baseDelay ?? 400,
|
|
313
404
|
maxDelay: options.kvOptions?.retry?.maxDelay ?? 600
|
|
314
405
|
};
|
|
406
|
+
const visitorVerdicts = createMemoryCache({
|
|
407
|
+
maxSize: MEMORY_CACHE_SIZE,
|
|
408
|
+
ttlMs: VISITOR_CACHE_SECONDS * 1e3
|
|
409
|
+
});
|
|
410
|
+
const requestVerdicts = createMemoryCache({
|
|
411
|
+
maxSize: MEMORY_CACHE_SIZE,
|
|
412
|
+
ttlMs: DEFAULT_INTEL_CACHE_SECONDS * 1e3
|
|
413
|
+
});
|
|
315
414
|
return {
|
|
316
415
|
configured,
|
|
317
416
|
verifiesVisitors: Boolean(kvUrl && apiKey),
|
|
318
417
|
misconfigured: Boolean((apiUrl || kvUrl) && !apiKey) || Boolean(apiKey && !apiUrl && !kvUrl),
|
|
319
|
-
async verifyVisitor(auth, request) {
|
|
418
|
+
async verifyVisitor(auth, request, limit) {
|
|
320
419
|
if (!kvUrl || !apiKey) return request.visitorId;
|
|
321
420
|
const cacheKey = `sentinel:visitor:${request.visitorId}:${request.ip ?? ""}`;
|
|
322
421
|
const cached = await auth.secondaryStorage?.get(cacheKey);
|
|
323
422
|
if (typeof cached === "string") return cached || null;
|
|
423
|
+
const remembered = visitorVerdicts.get(cacheKey);
|
|
424
|
+
if (remembered !== void 0 && !auth.secondaryStorage) return remembered;
|
|
425
|
+
if (limit && !await limit.allowed()) return null;
|
|
324
426
|
for (let attempt = 0; attempt <= kv.attempts; attempt++) {
|
|
325
427
|
if (attempt > 0) {
|
|
326
428
|
const wait = Math.min(kv.baseDelay * 2 ** (attempt - 1), kv.maxDelay);
|
|
@@ -340,7 +442,9 @@ function createIntelClient(options) {
|
|
|
340
442
|
}));
|
|
341
443
|
if (data && !error) {
|
|
342
444
|
const visitorId = data.valid ? data.visitorId ?? request.visitorId : null;
|
|
343
|
-
|
|
445
|
+
if (!visitorId) await limit?.rejected();
|
|
446
|
+
if (auth.secondaryStorage) await auth.secondaryStorage.set(cacheKey, visitorId ?? "", VISITOR_CACHE_SECONDS);
|
|
447
|
+
else visitorVerdicts.set(cacheKey, visitorId);
|
|
344
448
|
return visitorId;
|
|
345
449
|
}
|
|
346
450
|
}
|
|
@@ -352,6 +456,8 @@ function createIntelClient(options) {
|
|
|
352
456
|
const cacheKey = `sentinel:intel:${ip}:${visitorId ?? ""}`;
|
|
353
457
|
const cached = safeJSONParse(await auth.secondaryStorage?.get(cacheKey));
|
|
354
458
|
if (cached) return cached;
|
|
459
|
+
const remembered = auth.secondaryStorage ? void 0 : requestVerdicts.get(cacheKey);
|
|
460
|
+
if (remembered) return remembered;
|
|
355
461
|
const { data, error } = await betterFetch(`${apiUrl}/v1/intel/request`, {
|
|
356
462
|
method: "POST",
|
|
357
463
|
headers: {
|
|
@@ -378,7 +484,12 @@ function createIntelClient(options) {
|
|
|
378
484
|
bot: data.bot?.verdict === "bot" ? true : data.bot?.verdict === "human" ? false : null,
|
|
379
485
|
geo: data.geo ?? null
|
|
380
486
|
};
|
|
381
|
-
if (
|
|
487
|
+
if (auth.secondaryStorage) {
|
|
488
|
+
if (data.ttlSeconds) await auth.secondaryStorage.set(cacheKey, JSON.stringify(intel), data.ttlSeconds);
|
|
489
|
+
} else {
|
|
490
|
+
const ttlSeconds = Math.min(data.ttlSeconds ?? DEFAULT_INTEL_CACHE_SECONDS, MAX_INTEL_CACHE_SECONDS);
|
|
491
|
+
requestVerdicts.set(cacheKey, intel, ttlSeconds * 1e3);
|
|
492
|
+
}
|
|
382
493
|
return intel;
|
|
383
494
|
}
|
|
384
495
|
};
|
|
@@ -466,6 +577,10 @@ const schema = {
|
|
|
466
577
|
type: "string",
|
|
467
578
|
required: true
|
|
468
579
|
},
|
|
580
|
+
path: {
|
|
581
|
+
type: "string",
|
|
582
|
+
required: false
|
|
583
|
+
},
|
|
469
584
|
ipAddress: {
|
|
470
585
|
type: "string",
|
|
471
586
|
required: false
|
|
@@ -488,6 +603,35 @@ const schema = {
|
|
|
488
603
|
//#endregion
|
|
489
604
|
//#region src/plugins/sentinel/store.ts
|
|
490
605
|
const PREFIX = "sentinel:";
|
|
606
|
+
/** How long after expiring a row is deleted */
|
|
607
|
+
const EXPIRED_ROW_GRACE_MS = 6e4;
|
|
608
|
+
/** Tries to increment a counter in the database before giving up */
|
|
609
|
+
const MAX_COUNTER_ATTEMPTS = 20;
|
|
610
|
+
/** When each auth instance last deleted expired rows, keyed by its adapter */
|
|
611
|
+
const lastChecked = /* @__PURE__ */ new WeakMap();
|
|
612
|
+
/**
|
|
613
|
+
* Deletes expired counters and challenges from the database, at most every
|
|
614
|
+
* 10 seconds per auth instance. Secondary storage expires them itself.
|
|
615
|
+
*/
|
|
616
|
+
async function deleteAllExpiredRows(auth, byPassLastCheckTime = false) {
|
|
617
|
+
if (auth.secondaryStorage) return;
|
|
618
|
+
const checkedAt = lastChecked.get(auth.adapter);
|
|
619
|
+
if (checkedAt && !byPassLastCheckTime) {
|
|
620
|
+
if (Date.now() - checkedAt.getTime() < 1e4) return;
|
|
621
|
+
}
|
|
622
|
+
lastChecked.set(auth.adapter, /* @__PURE__ */ new Date());
|
|
623
|
+
const expiredBefore = new Date(Date.now() - EXPIRED_ROW_GRACE_MS);
|
|
624
|
+
await Promise.all(["sentinelCounter", "powChallenge"].map((model) => auth.adapter.deleteMany({
|
|
625
|
+
model,
|
|
626
|
+
where: [{
|
|
627
|
+
field: "expiresAt",
|
|
628
|
+
operator: "lt",
|
|
629
|
+
value: expiredBefore
|
|
630
|
+
}]
|
|
631
|
+
}))).catch((error) => {
|
|
632
|
+
auth.logger.error("[sentinel] Failed to delete expired rows:", error);
|
|
633
|
+
});
|
|
634
|
+
}
|
|
491
635
|
/**
|
|
492
636
|
* Counters and challenges, kept in secondary storage when it's configured
|
|
493
637
|
* and in the database otherwise
|
|
@@ -522,45 +666,45 @@ function createSentinelStore(auth) {
|
|
|
522
666
|
}), Math.max(Math.ceil((expiresAt - now) / 1e3), 1));
|
|
523
667
|
return count;
|
|
524
668
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
669
|
+
deleteAllExpiredRows(auth);
|
|
670
|
+
for (let attempt = 0; attempt < MAX_COUNTER_ATTEMPTS; attempt++) {
|
|
671
|
+
const row = await findCounter(key);
|
|
672
|
+
if (!row) {
|
|
673
|
+
if (await auth.adapter.create({
|
|
674
|
+
model: "sentinelCounter",
|
|
675
|
+
data: {
|
|
676
|
+
key,
|
|
677
|
+
count: 1,
|
|
678
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
|
|
679
|
+
}
|
|
680
|
+
}).then(() => true, () => false)) return 1;
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const expiresAt = new Date(row.expiresAt);
|
|
684
|
+
const expired = expiresAt.getTime() <= Date.now();
|
|
685
|
+
if (await auth.adapter.updateMany({
|
|
540
686
|
model: "sentinelCounter",
|
|
541
|
-
where: [
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
687
|
+
where: [
|
|
688
|
+
{
|
|
689
|
+
field: "id",
|
|
690
|
+
value: row.id
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
field: "count",
|
|
694
|
+
value: row.count
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
field: "expiresAt",
|
|
698
|
+
value: expiresAt
|
|
699
|
+
}
|
|
700
|
+
],
|
|
545
701
|
update: {
|
|
546
|
-
count: 1,
|
|
547
|
-
expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
|
|
702
|
+
count: expired ? 1 : row.count + 1,
|
|
703
|
+
...expired || options.sliding ? { expiresAt: new Date(Date.now() + ttlSeconds * 1e3) } : {}
|
|
548
704
|
}
|
|
549
|
-
});
|
|
550
|
-
return 1;
|
|
705
|
+
}) > 0) return expired ? 1 : row.count + 1;
|
|
551
706
|
}
|
|
552
|
-
|
|
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;
|
|
707
|
+
throw new Error(`Failed to increment counter ${key}`);
|
|
564
708
|
},
|
|
565
709
|
async get(name) {
|
|
566
710
|
const key = PREFIX + name;
|
|
@@ -595,6 +739,7 @@ function createSentinelStore(auth) {
|
|
|
595
739
|
await storage.set(`${PREFIX}pow:${challenge.nonce}`, JSON.stringify(challenge), ttlSeconds);
|
|
596
740
|
return;
|
|
597
741
|
}
|
|
742
|
+
deleteAllExpiredRows(auth);
|
|
598
743
|
await auth.adapter.create({
|
|
599
744
|
model: "powChallenge",
|
|
600
745
|
data: {
|
|
@@ -678,11 +823,26 @@ const NEW_PASSWORD_FIELDS = {
|
|
|
678
823
|
* towards credential stuffing
|
|
679
824
|
*/
|
|
680
825
|
const isAttemptPath = (path) => CREDENTIAL_SIGN_IN_PATHS.includes(path) || VERIFICATION_PATHS.includes(path);
|
|
826
|
+
/** Endpoints a bot is blocked on: sign-in, sign-up and password reset */
|
|
827
|
+
const BOT_CHECK_PATHS = [
|
|
828
|
+
...CREDENTIAL_SIGN_IN_PATHS,
|
|
829
|
+
...VERIFICATION_PATHS,
|
|
830
|
+
...SIGN_UP_PATHS,
|
|
831
|
+
...PASSWORD_RESET_PATHS
|
|
832
|
+
];
|
|
681
833
|
/** Account creation that isn't done by the visitor signing up */
|
|
682
834
|
const isManagedAccountCreation = (path) => !path || path.startsWith("/admin/") || path === "/sign-in/anonymous";
|
|
683
835
|
/** Social sign-ups, whose email the provider has already verified */
|
|
684
836
|
const OAUTH_CALLBACK_PATHS = ["/callback/:id", "/oauth2/callback/:providerId"];
|
|
685
837
|
const DAY_SECONDS = 1440 * 60;
|
|
838
|
+
/** Window the identification service limits per IP address count over */
|
|
839
|
+
const VISITOR_LIMIT_WINDOW_SECONDS = 60;
|
|
840
|
+
/** Calls to the identification service per IP address in the window */
|
|
841
|
+
const MAX_VERIFICATIONS_PER_IP = 20;
|
|
842
|
+
/** Visitor ids that weren't genuine per IP address in the window */
|
|
843
|
+
const MAX_INVALID_VISITORS_PER_IP = 5;
|
|
844
|
+
/** Sign-ins kept per user for impossible travel, stale and new devices */
|
|
845
|
+
const SIGN_IN_HISTORY_LIMIT = 50;
|
|
686
846
|
/**
|
|
687
847
|
* Sign-ins checked with a password, which can be repeated with a challenge
|
|
688
848
|
* solution
|
|
@@ -732,7 +892,10 @@ const sentinel = (options) => {
|
|
|
732
892
|
thresholds: security.freeTrialAbuse.thresholds,
|
|
733
893
|
maxAccountsPerVisitor: security.freeTrialAbuse.maxAccountsPerVisitor ?? 3,
|
|
734
894
|
retentionSeconds: Math.min(Math.max(security.freeTrialAbuse.retentionSeconds ?? 90 * DAY_SECONDS, DAY_SECONDS), 365 * DAY_SECONDS),
|
|
735
|
-
action: security.freeTrialAbuse.action ?? "block"
|
|
895
|
+
action: security.freeTrialAbuse.action ?? "block",
|
|
896
|
+
maxAccountsPerIp: security.freeTrialAbuse.maxAccountsPerIp ?? 10,
|
|
897
|
+
ipWindowSeconds: Math.min(Math.max(security.freeTrialAbuse.ipWindowSeconds ?? DAY_SECONDS, 1), 365 * DAY_SECONDS),
|
|
898
|
+
requireVerifiedVisitor: security.freeTrialAbuse.requireVerifiedVisitor ?? false
|
|
736
899
|
} : null;
|
|
737
900
|
const botBlocking = resolveToggle(security.botBlocking, "challenge");
|
|
738
901
|
const suspiciousIpBlocking = resolveToggle(security.suspiciousIpBlocking, "block");
|
|
@@ -766,16 +929,32 @@ const sentinel = (options) => {
|
|
|
766
929
|
const unknownDeviceNotification = security.unknownDeviceNotification ?? false;
|
|
767
930
|
const tracksSignIns = Boolean(impossibleTravel || staleUsers || unknownDeviceNotification);
|
|
768
931
|
const checksAccountCreation = Boolean(freeTrialAbuse || velocity || emailValidation);
|
|
769
|
-
/**
|
|
770
|
-
const
|
|
771
|
-
/**
|
|
772
|
-
const
|
|
932
|
+
/** Whether each request's solution was accepted, so it's checked once */
|
|
933
|
+
const acceptedSolutions = /* @__PURE__ */ new WeakMap();
|
|
934
|
+
/** Each request's verified visitor, so it's checked once */
|
|
935
|
+
const verifiedVisitors = /* @__PURE__ */ new WeakMap();
|
|
773
936
|
function clientIp(request, auth) {
|
|
774
937
|
if (!request) return null;
|
|
775
938
|
if (opts.providers?.getClientIp) return opts.providers.getClientIp(request);
|
|
776
939
|
return getIp(request, auth.options);
|
|
777
940
|
}
|
|
778
941
|
/**
|
|
942
|
+
* Limits the calls to the identification service for one IP address: after
|
|
943
|
+
* too many calls, or too many ids that weren't genuine, its ids go
|
|
944
|
+
* unchecked and aren't trusted
|
|
945
|
+
*/
|
|
946
|
+
function visitorVerificationLimit(store, ip) {
|
|
947
|
+
return {
|
|
948
|
+
async allowed() {
|
|
949
|
+
if (await store.get(`visitor-invalid:${ip}`) >= MAX_INVALID_VISITORS_PER_IP) return false;
|
|
950
|
+
return await store.increment(`visitor-verify:${ip}`, VISITOR_LIMIT_WINDOW_SECONDS) <= MAX_VERIFICATIONS_PER_IP;
|
|
951
|
+
},
|
|
952
|
+
async rejected() {
|
|
953
|
+
await store.increment(`visitor-invalid:${ip}`, VISITOR_LIMIT_WINDOW_SECONDS);
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
779
958
|
* The visitor id sent by the client, checked with the identification
|
|
780
959
|
* service when one is configured
|
|
781
960
|
*/
|
|
@@ -785,22 +964,23 @@ const sentinel = (options) => {
|
|
|
785
964
|
id: null,
|
|
786
965
|
verified: false
|
|
787
966
|
});
|
|
788
|
-
const
|
|
789
|
-
if (
|
|
967
|
+
const cachedVisitor = verifiedVisitors.get(headers);
|
|
968
|
+
if (cachedVisitor) return cachedVisitor;
|
|
790
969
|
const unchecked = {
|
|
791
970
|
id: opts.failOpen ? sent : null,
|
|
792
971
|
verified: false
|
|
793
972
|
};
|
|
794
|
-
const
|
|
973
|
+
const store = createSentinelStore(auth);
|
|
974
|
+
const visitor = intel.verifyVisitor(auth, {
|
|
795
975
|
visitorId: sent,
|
|
796
976
|
ip,
|
|
797
977
|
userAgent: headers.get("user-agent")
|
|
798
|
-
}).then((verified) => verified === void 0 ? unchecked : {
|
|
978
|
+
}, ip ? visitorVerificationLimit(store, ip) : void 0).then((verified) => verified === void 0 ? unchecked : {
|
|
799
979
|
id: verified,
|
|
800
980
|
verified: intel.verifiesVisitors
|
|
801
981
|
}).catch(() => unchecked);
|
|
802
|
-
|
|
803
|
-
return
|
|
982
|
+
verifiedVisitors.set(headers, visitor);
|
|
983
|
+
return visitor;
|
|
804
984
|
}
|
|
805
985
|
function relativePath(request, auth) {
|
|
806
986
|
const pathname = new URL(request.url).pathname;
|
|
@@ -809,27 +989,28 @@ const sentinel = (options) => {
|
|
|
809
989
|
}
|
|
810
990
|
/**
|
|
811
991
|
* Whether the request carries a valid, unused solution to a challenge
|
|
812
|
-
* issued to the same IP address and visitor
|
|
992
|
+
* issued to the same IP address and visitor, for the same endpoint
|
|
813
993
|
*/
|
|
814
|
-
function challengePassed(key, details, store, auth) {
|
|
815
|
-
const
|
|
816
|
-
if (
|
|
817
|
-
const
|
|
818
|
-
const
|
|
819
|
-
if (!
|
|
994
|
+
function challengePassed(key, details, store, auth, path) {
|
|
995
|
+
const cachedAcceptance = acceptedSolutions.get(key);
|
|
996
|
+
if (cachedAcceptance) return cachedAcceptance;
|
|
997
|
+
const accepted = (async () => {
|
|
998
|
+
const solution = parsePowHeader(details.headers?.get(POW_SOLUTION_HEADER));
|
|
999
|
+
if (!solution) return false;
|
|
820
1000
|
try {
|
|
821
|
-
const challenge = await store.consumeChallenge(
|
|
1001
|
+
const challenge = await store.consumeChallenge(solution.nonce);
|
|
822
1002
|
if (!challenge) return false;
|
|
823
1003
|
if (challenge.ipAddress && challenge.ipAddress !== details.ip) return false;
|
|
824
1004
|
if (challenge.visitorId && challenge.visitorId !== details.visitorId) return false;
|
|
825
|
-
|
|
1005
|
+
if (challenge.path && challenge.path !== path) return false;
|
|
1006
|
+
return await verifySolution(solution.nonce, solution.value, challenge.difficulty);
|
|
826
1007
|
} catch (e) {
|
|
827
1008
|
auth.logger.error("[sentinel] Failed to verify challenge", e);
|
|
828
1009
|
return false;
|
|
829
1010
|
}
|
|
830
1011
|
})();
|
|
831
|
-
|
|
832
|
-
return
|
|
1012
|
+
acceptedSolutions.set(key, accepted);
|
|
1013
|
+
return accepted;
|
|
833
1014
|
}
|
|
834
1015
|
/** Runs a check, applying `failOpen` when it can't complete */
|
|
835
1016
|
async function guard(auth, check) {
|
|
@@ -876,12 +1057,12 @@ const sentinel = (options) => {
|
|
|
876
1057
|
visitorId: details.visitorId,
|
|
877
1058
|
identifier: details.identifier,
|
|
878
1059
|
userId,
|
|
879
|
-
challengePassed: action === "challenge" ? await challengePassed(challengeKey(ctx, details), details, store, ctx.context) : false,
|
|
1060
|
+
challengePassed: action === "challenge" ? await challengePassed(challengeKey(ctx, details), details, store, ctx.context, ctx.path) : false,
|
|
880
1061
|
difficulty,
|
|
881
1062
|
metadata
|
|
882
1063
|
}));
|
|
883
1064
|
}
|
|
884
|
-
/** The key a request's
|
|
1065
|
+
/** The key a request's accepted solution is cached under */
|
|
885
1066
|
function challengeKey(ctx, details) {
|
|
886
1067
|
return ctx.request ?? details.headers ?? ctx;
|
|
887
1068
|
}
|
|
@@ -939,6 +1120,31 @@ const sentinel = (options) => {
|
|
|
939
1120
|
if (opts.providers?.resolveGeo) return opts.providers.resolveGeo(details.ip);
|
|
940
1121
|
return (await intel.lookup(auth, details)).geo;
|
|
941
1122
|
}
|
|
1123
|
+
/** Deletes a user's sign-ins beyond the most recent ones Sentinel keeps */
|
|
1124
|
+
async function deleteOlderSignIns(auth, userId) {
|
|
1125
|
+
const olderSignIns = await auth.adapter.findMany({
|
|
1126
|
+
model: "sentinelSignIn",
|
|
1127
|
+
where: [{
|
|
1128
|
+
field: "userId",
|
|
1129
|
+
value: userId
|
|
1130
|
+
}],
|
|
1131
|
+
sortBy: {
|
|
1132
|
+
field: "createdAt",
|
|
1133
|
+
direction: "desc"
|
|
1134
|
+
},
|
|
1135
|
+
offset: SIGN_IN_HISTORY_LIMIT,
|
|
1136
|
+
limit: 100
|
|
1137
|
+
});
|
|
1138
|
+
if (!olderSignIns.length) return;
|
|
1139
|
+
await auth.adapter.deleteMany({
|
|
1140
|
+
model: "sentinelSignIn",
|
|
1141
|
+
where: [{
|
|
1142
|
+
field: "id",
|
|
1143
|
+
operator: "in",
|
|
1144
|
+
value: olderSignIns.map((signIn) => signIn.id)
|
|
1145
|
+
}]
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
942
1148
|
async function lastSignIn(auth, userId) {
|
|
943
1149
|
const [row] = await auth.adapter.findMany({
|
|
944
1150
|
model: "sentinelSignIn",
|
|
@@ -959,10 +1165,12 @@ const sentinel = (options) => {
|
|
|
959
1165
|
}
|
|
960
1166
|
/** The travel speed from the previous sign-in, when both have coordinates */
|
|
961
1167
|
function travel(previous, geo) {
|
|
962
|
-
|
|
1168
|
+
const previousLatitude = fromStoredCoordinate(previous?.latitude);
|
|
1169
|
+
const previousLongitude = fromStoredCoordinate(previous?.longitude);
|
|
1170
|
+
if (!previous || previousLatitude == null || previousLongitude == null || geo?.latitude == null || geo.longitude == null) return null;
|
|
963
1171
|
const speedKmh = travelSpeedKmh({
|
|
964
|
-
latitude:
|
|
965
|
-
longitude:
|
|
1172
|
+
latitude: previousLatitude,
|
|
1173
|
+
longitude: previousLongitude,
|
|
966
1174
|
at: new Date(previous.createdAt)
|
|
967
1175
|
}, {
|
|
968
1176
|
latitude: geo.latitude,
|
|
@@ -1062,6 +1270,20 @@ const sentinel = (options) => {
|
|
|
1062
1270
|
});
|
|
1063
1271
|
}));
|
|
1064
1272
|
}
|
|
1273
|
+
if (freeTrialAbuse) {
|
|
1274
|
+
if (details.ip) {
|
|
1275
|
+
const ip = details.ip;
|
|
1276
|
+
throwForDecision(await guard(ctx.context, async () => {
|
|
1277
|
+
const created = await store.get(`trial:ip:${ip}`);
|
|
1278
|
+
if (created < freeTrialAbuse.maxAccountsPerIp) return { outcome: "allow" };
|
|
1279
|
+
return act(event, store, details, freeTrialAbuse.action, SECURITY_EVENT_TYPES.FREE_TRIAL_ABUSE, "free_trial_abuse", {
|
|
1280
|
+
accounts: created,
|
|
1281
|
+
signal: "ip"
|
|
1282
|
+
});
|
|
1283
|
+
}));
|
|
1284
|
+
}
|
|
1285
|
+
if (freeTrialAbuse.requireVerifiedVisitor && !details.visitorVerified) throwForDecision(await act(event, store, details, "challenge", SECURITY_EVENT_TYPES.FREE_TRIAL_ABUSE, "visitor_not_verified", {}));
|
|
1286
|
+
}
|
|
1065
1287
|
if (freeTrialAbuse && details.visitorId) {
|
|
1066
1288
|
const visitorId = details.visitorId;
|
|
1067
1289
|
throwForDecision(await guard(ctx.context, async () => {
|
|
@@ -1099,9 +1321,10 @@ const sentinel = (options) => {
|
|
|
1099
1321
|
const headers = event.headers ?? event.request?.headers;
|
|
1100
1322
|
const ip = clientIp(event.request ?? headers, context.context);
|
|
1101
1323
|
const { id: visitorId } = await resolveVisitor(context.context, headers, ip);
|
|
1102
|
-
if (!visitorId) return;
|
|
1103
1324
|
try {
|
|
1104
|
-
|
|
1325
|
+
const store = createSentinelStore(context.context);
|
|
1326
|
+
if (visitorId) await store.increment(`trial:${visitorId}`, freeTrialAbuse.retentionSeconds, { sliding: true });
|
|
1327
|
+
if (ip) await store.increment(`trial:ip:${ip}`, freeTrialAbuse.ipWindowSeconds);
|
|
1105
1328
|
} catch (e) {
|
|
1106
1329
|
context.context.logger.error("[sentinel] Failed to track sign-up", e);
|
|
1107
1330
|
}
|
|
@@ -1118,34 +1341,37 @@ const sentinel = (options) => {
|
|
|
1118
1341
|
},
|
|
1119
1342
|
async onRequest(request, ctx) {
|
|
1120
1343
|
if (!botBlocking && !suspiciousIpBlocking && !geoBlocking) return;
|
|
1344
|
+
const path = relativePath(request, ctx);
|
|
1345
|
+
const checkBot = Boolean(botBlocking) && BOT_CHECK_PATHS.includes(path);
|
|
1346
|
+
if (!checkBot && !suspiciousIpBlocking && !geoBlocking) return;
|
|
1121
1347
|
const store = createSentinelStore(ctx);
|
|
1122
1348
|
const event = {
|
|
1123
1349
|
context: ctx,
|
|
1124
1350
|
request,
|
|
1125
1351
|
headers: request.headers,
|
|
1126
|
-
path
|
|
1352
|
+
path
|
|
1127
1353
|
};
|
|
1128
1354
|
const response = responseForDecision(await guard(ctx, async () => {
|
|
1129
1355
|
const details = await requestDetails(event);
|
|
1130
1356
|
const { ip } = details;
|
|
1131
|
-
const
|
|
1357
|
+
const backendVerdicts = await intel.lookup(ctx, details);
|
|
1132
1358
|
if (suspiciousIpBlocking && ip) {
|
|
1133
|
-
if (opts.providers?.isSuspiciousIp ? await opts.providers.isSuspiciousIp(ip) :
|
|
1134
|
-
const
|
|
1135
|
-
if (
|
|
1359
|
+
if (opts.providers?.isSuspiciousIp ? await opts.providers.isSuspiciousIp(ip) : backendVerdicts.suspiciousIp) {
|
|
1360
|
+
const suspiciousIpDecision = await act(event, store, details, suspiciousIpBlocking.action, SECURITY_EVENT_TYPES.SUSPICIOUS_IP, "suspicious_ip", { ip });
|
|
1361
|
+
if (suspiciousIpDecision.outcome !== "allow") return suspiciousIpDecision;
|
|
1136
1362
|
}
|
|
1137
1363
|
}
|
|
1138
1364
|
if (geoBlocking && ip) {
|
|
1139
|
-
const country = (opts.providers?.resolveGeo ? await opts.providers.resolveGeo(ip) :
|
|
1365
|
+
const country = (opts.providers?.resolveGeo ? await opts.providers.resolveGeo(ip) : backendVerdicts.geo)?.country?.toUpperCase() ?? null;
|
|
1140
1366
|
if (country ? geoBlocking.allowList ? !geoBlocking.allowList.includes(country) : Boolean(geoBlocking.denyList?.includes(country)) : Boolean(geoBlocking.allowList) && !opts.failOpen) {
|
|
1141
|
-
const
|
|
1142
|
-
if (
|
|
1367
|
+
const geoDecision = await act(event, store, details, geoBlocking.action, SECURITY_EVENT_TYPES.GEO_BLOCKED, "geo_blocking", { country });
|
|
1368
|
+
if (geoDecision.outcome !== "allow") return geoDecision;
|
|
1143
1369
|
}
|
|
1144
1370
|
}
|
|
1145
|
-
if (botBlocking) {
|
|
1146
|
-
if (opts.providers?.isBot ? await opts.providers.isBot(request) :
|
|
1147
|
-
const
|
|
1148
|
-
if (
|
|
1371
|
+
if (botBlocking && checkBot) {
|
|
1372
|
+
if (opts.providers?.isBot ? await opts.providers.isBot(request) : backendVerdicts.bot ?? looksLikeBot(request)) {
|
|
1373
|
+
const botDecision = await act(event, store, details, botBlocking.action, SECURITY_EVENT_TYPES.BOT_BLOCKED, "bot", { userAgent: request.headers.get("user-agent") });
|
|
1374
|
+
if (botDecision.outcome !== "allow") return botDecision;
|
|
1149
1375
|
}
|
|
1150
1376
|
}
|
|
1151
1377
|
return { outcome: "allow" };
|
|
@@ -1191,7 +1417,7 @@ const sentinel = (options) => {
|
|
|
1191
1417
|
}
|
|
1192
1418
|
if (credentialStuffing && isAttemptPath(ctx.path)) throwForDecision(await guard(ctx.context, async () => {
|
|
1193
1419
|
const keys = attemptKeys(details);
|
|
1194
|
-
const solved = Boolean(details.headers?.get(POW_SOLUTION_HEADER)) && await challengePassed(challengeKey(ctx, details), details, store, ctx.context);
|
|
1420
|
+
const solved = Boolean(details.headers?.get(POW_SOLUTION_HEADER)) && await challengePassed(challengeKey(ctx, details), details, store, ctx.context, ctx.path);
|
|
1195
1421
|
const blocked = (await Promise.all(counterNames(keys).map((key) => store.get(`${key}:blocked`)))).some((value) => value > 0);
|
|
1196
1422
|
const failures = await countAttempt(store, keys, { readOnly: solved || blocked });
|
|
1197
1423
|
const { thresholds } = credentialStuffing;
|
|
@@ -1220,12 +1446,12 @@ const sentinel = (options) => {
|
|
|
1220
1446
|
if (limit) {
|
|
1221
1447
|
const count = await store.increment(limit.name, windowSeconds);
|
|
1222
1448
|
if (count > limit.max) {
|
|
1223
|
-
const
|
|
1449
|
+
const limitDecision = await act(ctx, store, details, velocity.action, SECURITY_EVENT_TYPES.VELOCITY_EXCEEDED, "velocity", {
|
|
1224
1450
|
count,
|
|
1225
1451
|
limit: limit.max,
|
|
1226
1452
|
windowSeconds
|
|
1227
1453
|
});
|
|
1228
|
-
if (
|
|
1454
|
+
if (limitDecision.outcome !== "allow") return limitDecision;
|
|
1229
1455
|
}
|
|
1230
1456
|
}
|
|
1231
1457
|
if (ids.length && isTracked) {
|
|
@@ -1397,11 +1623,12 @@ const sentinel = (options) => {
|
|
|
1397
1623
|
userAgent: details.userAgent,
|
|
1398
1624
|
country: geo?.country ?? null,
|
|
1399
1625
|
city: geo?.city ?? null,
|
|
1400
|
-
latitude: geo?.latitude
|
|
1401
|
-
longitude: geo?.longitude
|
|
1626
|
+
latitude: toStoredCoordinate(geo?.latitude),
|
|
1627
|
+
longitude: toStoredCoordinate(geo?.longitude),
|
|
1402
1628
|
createdAt: /* @__PURE__ */ new Date()
|
|
1403
1629
|
}
|
|
1404
1630
|
});
|
|
1631
|
+
await deleteOlderSignIns(auth, user.id);
|
|
1405
1632
|
} catch (e) {
|
|
1406
1633
|
if (e instanceof APIError) throw e;
|
|
1407
1634
|
auth.logger.error("[sentinel] Failed to track sign-in", e);
|