emilsoftware-utilities 2.0.0-dev.67 → 2.0.0-dev.68

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.
Files changed (47) hide show
  1. package/dist/accessi-module/AccessiModule.d.ts +27 -1
  2. package/dist/accessi-module/AccessiModule.js.map +1 -1
  3. package/dist/accessi-module/Console/console.js +12 -6
  4. package/dist/accessi-module/Console/wiki.js +9 -3
  5. package/dist/accessi-module/Console/wiki.js.map +1 -1
  6. package/dist/accessi-module/Controllers/AuthController.js +6 -6
  7. package/dist/accessi-module/Controllers/AuthController.js.map +1 -1
  8. package/dist/accessi-module/Controllers/ConsoleEntryController.d.ts +2 -0
  9. package/dist/accessi-module/Controllers/ConsoleEntryController.js +11 -3
  10. package/dist/accessi-module/Controllers/ConsoleEntryController.js.map +1 -1
  11. package/dist/accessi-module/Controllers/EmailController.js +1 -1
  12. package/dist/accessi-module/Controllers/EmailController.js.map +1 -1
  13. package/dist/accessi-module/Controllers/UserController.js +1 -1
  14. package/dist/accessi-module/Controllers/UserController.js.map +1 -1
  15. package/dist/accessi-module/database-updates/accessiOptionsFromEnv.js +8 -16
  16. package/dist/accessi-module/database-updates/accessiOptionsFromEnv.js.map +1 -1
  17. package/dist/accessi-module/index.d.ts +9 -1
  18. package/dist/accessi-module/index.js +34 -4
  19. package/dist/accessi-module/index.js.map +1 -1
  20. package/dist/accessi-module/security/consoleEntry.d.ts +56 -11
  21. package/dist/accessi-module/security/consoleEntry.js +165 -31
  22. package/dist/accessi-module/security/consoleEntry.js.map +1 -1
  23. package/dist/accessi-module/security/edgeRateLimit.d.ts +31 -0
  24. package/dist/accessi-module/security/edgeRateLimit.js +70 -0
  25. package/dist/accessi-module/security/edgeRateLimit.js.map +1 -0
  26. package/dist/accessi-module/security/jwks.d.ts +33 -0
  27. package/dist/accessi-module/security/jwks.js +83 -0
  28. package/dist/accessi-module/security/jwks.js.map +1 -0
  29. package/dist/accessi-module/security/publicAuthRateLimit.d.ts +8 -6
  30. package/dist/accessi-module/security/publicAuthRateLimit.js +54 -78
  31. package/dist/accessi-module/security/publicAuthRateLimit.js.map +1 -1
  32. package/dist/accessi-module/security/rateLimitStore.d.ts +32 -0
  33. package/dist/accessi-module/security/rateLimitStore.js +129 -0
  34. package/dist/accessi-module/security/rateLimitStore.js.map +1 -0
  35. package/dist/accessi-module/security/serviceTokenGuard.d.ts +3 -1
  36. package/dist/accessi-module/security/serviceTokenGuard.js +11 -6
  37. package/dist/accessi-module/security/serviceTokenGuard.js.map +1 -1
  38. package/dist/accessi-module/security/serviceTokenRateLimit.d.ts +5 -4
  39. package/dist/accessi-module/security/serviceTokenRateLimit.js +33 -29
  40. package/dist/accessi-module/security/serviceTokenRateLimit.js.map +1 -1
  41. package/dist/emilsoftware-utilities-2.0.0-dev.68.tgz +0 -0
  42. package/dist/initEmilsoftwareModule.js +20 -2
  43. package/dist/initEmilsoftwareModule.js.map +1 -1
  44. package/docs/gateway-rate-limit.md +98 -0
  45. package/integration-prompt.md +139 -8
  46. package/package.json +5 -4
  47. package/dist/emilsoftware-utilities-2.0.0-dev.67.tgz +0 -0
@@ -0,0 +1,33 @@
1
+ import type { JsonWebKey } from "crypto";
2
+ import type { Request, Response } from "express";
3
+ import type { AccessiOptions } from "../AccessiModule";
4
+ /** Chiave pubblica in formato JWK, pronta per una risposta JWKS. */
5
+ export type AccessiJwksKey = JsonWebKey & {
6
+ kid: string;
7
+ alg: string;
8
+ use: "sig";
9
+ };
10
+ export interface AccessiJwksDocument {
11
+ keys: AccessiJwksKey[];
12
+ }
13
+ /** Path pubblico della JWKS, oppure `undefined` quando si usa HS256. */
14
+ export declare function getAccessiJwksPath(options: AccessiOptions): string | undefined;
15
+ /**
16
+ * Costruisce la JWKS pubblica con chiave corrente e chiavi precedenti.
17
+ *
18
+ * Restituisce `null` per HS256 o quando non e configurata una chiave
19
+ * pubblica/privata asimmetrica.
20
+ */
21
+ export declare function buildAccessiJwks(options: AccessiOptions): AccessiJwksDocument | null;
22
+ /** Handler Express per la JWKS. Risponde 404 se la configurazione e HS256. */
23
+ export declare function createAccessiJwksHandler(options: AccessiOptions): (req: Request, res: Response) => Response | void;
24
+ /**
25
+ * Monta la route JWKS sull'Express host.
26
+ *
27
+ * Per un'app Nest esistente passare `app.getHttpAdapter().getInstance()`.
28
+ * Restituisce il path montato, oppure `undefined` se HS256.
29
+ */
30
+ export interface AccessiJwksExpressApp {
31
+ get(path: string, handler: (req: Request, res: Response) => unknown): unknown;
32
+ }
33
+ export declare function setupAccessiJwks(app: AccessiJwksExpressApp, options: AccessiOptions): string | undefined;
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getAccessiJwksPath = getAccessiJwksPath;
4
+ exports.buildAccessiJwks = buildAccessiJwks;
5
+ exports.createAccessiJwksHandler = createAccessiJwksHandler;
6
+ exports.setupAccessiJwks = setupAccessiJwks;
7
+ const crypto_1 = require("crypto");
8
+ const DEFAULT_JWKS_PATH = "accessi/.well-known/jwks.json";
9
+ function normalizePath(value) {
10
+ return `/${value.replace(/^\/+|\/+$/g, "")}`;
11
+ }
12
+ function fingerprintPublicKey(publicKey) {
13
+ const der = (0, crypto_1.createPublicKey)(publicKey).export({ type: "spki", format: "der" });
14
+ return (0, crypto_1.createHash)("sha256").update(der).digest("base64url").slice(0, 16);
15
+ }
16
+ function toJwksKey(publicKey, keyId, algorithm) {
17
+ const jwk = (0, crypto_1.createPublicKey)(publicKey).export({ format: "jwk" });
18
+ return Object.assign(Object.assign({}, jwk), { kid: keyId !== null && keyId !== void 0 ? keyId : fingerprintPublicKey(publicKey), alg: algorithm, use: "sig" });
19
+ }
20
+ /** Path pubblico della JWKS, oppure `undefined` quando si usa HS256. */
21
+ function getAccessiJwksPath(options) {
22
+ var _a, _b;
23
+ const algorithm = (_a = options.jwtOptions.algorithm) !== null && _a !== void 0 ? _a : "HS256";
24
+ if (algorithm.startsWith("HS"))
25
+ return undefined;
26
+ return normalizePath((_b = options.jwtOptions.jwksPath) !== null && _b !== void 0 ? _b : DEFAULT_JWKS_PATH);
27
+ }
28
+ /**
29
+ * Costruisce la JWKS pubblica con chiave corrente e chiavi precedenti.
30
+ *
31
+ * Restituisce `null` per HS256 o quando non e configurata una chiave
32
+ * pubblica/privata asimmetrica.
33
+ */
34
+ function buildAccessiJwks(options) {
35
+ var _a, _b, _c, _d;
36
+ const algorithm = (_a = options.jwtOptions.algorithm) !== null && _a !== void 0 ? _a : "HS256";
37
+ if (algorithm.startsWith("HS"))
38
+ return null;
39
+ const currentPublicKey = (_b = options.jwtOptions.publicKey) !== null && _b !== void 0 ? _b : options.jwtOptions.privateKey;
40
+ if (!currentPublicKey)
41
+ return null;
42
+ const keys = [
43
+ toJwksKey(currentPublicKey, options.jwtOptions.keyId, algorithm),
44
+ ];
45
+ for (const previous of (_c = options.jwtOptions.previousPublicKeys) !== null && _c !== void 0 ? _c : []) {
46
+ const previousAlgorithm = (_d = previous.algorithm) !== null && _d !== void 0 ? _d : algorithm;
47
+ keys.push(toJwksKey(previous.publicKey, previous.keyId, previousAlgorithm));
48
+ }
49
+ // Deduplica per kid: la chiave corrente vince sulle precedenti.
50
+ const unique = new Map();
51
+ for (const key of keys) {
52
+ if (!unique.has(key.kid))
53
+ unique.set(key.kid, key);
54
+ }
55
+ return { keys: [...unique.values()] };
56
+ }
57
+ /** Handler Express per la JWKS. Risponde 404 se la configurazione e HS256. */
58
+ function createAccessiJwksHandler(options) {
59
+ return (_req, res) => {
60
+ const document = buildAccessiJwks(options);
61
+ if (!document) {
62
+ return res.status(404).json({
63
+ severity: "error",
64
+ status: 404,
65
+ statusCode: 2,
66
+ code: "ACCESSI_JWKS_NOT_AVAILABLE",
67
+ error: "ACCESSI_JWKS_NOT_AVAILABLE",
68
+ message: "JWKS non disponibile: Accessi usa HS256 o non ha una chiave asimmetrica.",
69
+ });
70
+ }
71
+ res.setHeader("Content-Type", "application/json");
72
+ res.setHeader("Cache-Control", "public, max-age=300");
73
+ return res.status(200).send(document);
74
+ };
75
+ }
76
+ function setupAccessiJwks(app, options) {
77
+ const path = getAccessiJwksPath(options);
78
+ if (!path)
79
+ return undefined;
80
+ app.get(path, createAccessiJwksHandler(options));
81
+ return path;
82
+ }
83
+ //# sourceMappingURL=jwks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jwks.js","sourceRoot":"","sources":["../../../src/accessi-module/security/jwks.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash, createPublicKey } from \"crypto\";\nimport type { JsonWebKey } from \"crypto\";\nimport type { Request, Response } from \"express\";\nimport type { AccessiOptions } from \"../AccessiModule\";\n\n/** Chiave pubblica in formato JWK, pronta per una risposta JWKS. */\nexport type AccessiJwksKey = JsonWebKey & {\n kid: string;\n alg: string;\n use: \"sig\";\n};\n\nexport interface AccessiJwksDocument {\n keys: AccessiJwksKey[];\n}\n\nconst DEFAULT_JWKS_PATH = \"accessi/.well-known/jwks.json\";\n\nfunction normalizePath(value: string): string {\n return `/${value.replace(/^\\/+|\\/+$/g, \"\")}`;\n}\n\nfunction fingerprintPublicKey(publicKey: string | Buffer): string {\n const der = createPublicKey(publicKey).export({ type: \"spki\", format: \"der\" });\n return createHash(\"sha256\").update(der).digest(\"base64url\").slice(0, 16);\n}\n\nfunction toJwksKey(\n publicKey: string | Buffer,\n keyId: string | undefined,\n algorithm: string,\n): AccessiJwksKey {\n const jwk = createPublicKey(publicKey).export({ format: \"jwk\" }) as JsonWebKey;\n return {\n ...jwk,\n kid: keyId ?? fingerprintPublicKey(publicKey),\n alg: algorithm,\n use: \"sig\",\n };\n}\n\n/** Path pubblico della JWKS, oppure `undefined` quando si usa HS256. */\nexport function getAccessiJwksPath(options: AccessiOptions): string | undefined {\n const algorithm = options.jwtOptions.algorithm ?? \"HS256\";\n if (algorithm.startsWith(\"HS\")) return undefined;\n return normalizePath(options.jwtOptions.jwksPath ?? DEFAULT_JWKS_PATH);\n}\n\n/**\n * Costruisce la JWKS pubblica con chiave corrente e chiavi precedenti.\n *\n * Restituisce `null` per HS256 o quando non e configurata una chiave\n * pubblica/privata asimmetrica.\n */\nexport function buildAccessiJwks(options: AccessiOptions): AccessiJwksDocument | null {\n const algorithm = options.jwtOptions.algorithm ?? \"HS256\";\n if (algorithm.startsWith(\"HS\")) return null;\n\n const currentPublicKey = options.jwtOptions.publicKey ?? options.jwtOptions.privateKey;\n if (!currentPublicKey) return null;\n\n const keys: AccessiJwksKey[] = [\n toJwksKey(currentPublicKey, options.jwtOptions.keyId, algorithm),\n ];\n\n for (const previous of options.jwtOptions.previousPublicKeys ?? []) {\n const previousAlgorithm = previous.algorithm ?? algorithm;\n keys.push(toJwksKey(previous.publicKey, previous.keyId, previousAlgorithm));\n }\n\n // Deduplica per kid: la chiave corrente vince sulle precedenti.\n const unique = new Map<string, AccessiJwksKey>();\n for (const key of keys) {\n if (!unique.has(key.kid)) unique.set(key.kid, key);\n }\n\n return { keys: [...unique.values()] };\n}\n\n/** Handler Express per la JWKS. Risponde 404 se la configurazione e HS256. */\nexport function createAccessiJwksHandler(\n options: AccessiOptions,\n): (req: Request, res: Response) => Response | void {\n return (_req: Request, res: Response): Response | void => {\n const document = buildAccessiJwks(options);\n if (!document) {\n return res.status(404).json({\n severity: \"error\",\n status: 404,\n statusCode: 2,\n code: \"ACCESSI_JWKS_NOT_AVAILABLE\",\n error: \"ACCESSI_JWKS_NOT_AVAILABLE\",\n message: \"JWKS non disponibile: Accessi usa HS256 o non ha una chiave asimmetrica.\",\n });\n }\n\n res.setHeader(\"Content-Type\", \"application/json\");\n res.setHeader(\"Cache-Control\", \"public, max-age=300\");\n return res.status(200).send(document);\n };\n}\n\n/**\n * Monta la route JWKS sull'Express host.\n *\n * Per un'app Nest esistente passare `app.getHttpAdapter().getInstance()`.\n * Restituisce il path montato, oppure `undefined` se HS256.\n */\nexport interface AccessiJwksExpressApp {\n get(path: string, handler: (req: Request, res: Response) => unknown): unknown;\n}\n\nexport function setupAccessiJwks(\n app: AccessiJwksExpressApp,\n options: AccessiOptions,\n): string | undefined {\n const path = getAccessiJwksPath(options);\n if (!path) return undefined;\n app.get(path, createAccessiJwksHandler(options));\n return path;\n}\n"]}
@@ -1,10 +1,12 @@
1
1
  import { Request, Response } from 'express';
2
2
  import type { AccessiOptions } from '../AccessiModule';
3
+ import type { RateLimitDecision } from './rateLimitStore';
3
4
  export type PublicAuthRateLimitScope = 'login' | 'twoFactorVerify' | 'twoFactorResend' | 'register' | 'passwordResetEmail' | 'passwordResetConfirm' | 'getUserByToken' | 'adminBootstrap' | 'consoleEntry';
4
- type RateLimitDecision = {
5
- allowed: boolean;
6
- retryAfterSeconds: number;
7
- };
8
- export declare function checkPublicAuthRateLimit(options: AccessiOptions, scope: PublicAuthRateLimitScope, req: Request, identifiers?: string[]): RateLimitDecision;
5
+ /**
6
+ * Applica il rate limit per IP e, opzionalmente, per soggetto.
7
+ *
8
+ * Con `publicAuthRateLimit.store` e possibile usare uno store Redis condiviso;
9
+ * senza store usa la memoria del processo.
10
+ */
11
+ export declare function checkPublicAuthRateLimit(options: AccessiOptions, scope: PublicAuthRateLimitScope, req: Request, identifiers?: string[]): Promise<RateLimitDecision>;
9
12
  export declare function sendPublicAuthRateLimitExceeded(res: Response, retryAfterSeconds: number): Response;
10
- export {};
@@ -1,9 +1,19 @@
1
1
  "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
2
11
  Object.defineProperty(exports, "__esModule", { value: true });
3
12
  exports.checkPublicAuthRateLimit = checkPublicAuthRateLimit;
4
13
  exports.sendPublicAuthRateLimitExceeded = sendPublicAuthRateLimitExceeded;
5
14
  const common_1 = require("@nestjs/common");
6
15
  const Utilities_1 = require("../../Utilities");
16
+ const rateLimitStore_1 = require("./rateLimitStore");
7
17
  const DEFAULT_PUBLIC_AUTH_RATE_LIMITS = {
8
18
  login: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },
9
19
  twoFactorVerify: { windowMs: 15 * 60 * 1000, maxAttempts: 20 },
@@ -15,9 +25,6 @@ const DEFAULT_PUBLIC_AUTH_RATE_LIMITS = {
15
25
  adminBootstrap: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },
16
26
  consoleEntry: { windowMs: 5 * 60 * 1000, maxAttempts: 30 },
17
27
  };
18
- const RATE_LIMIT_BUCKETS = new Map();
19
- const MAX_TRACKED_BUCKETS = 20000;
20
- const MAX_WINDOW_MS = Math.max(...Object.values(DEFAULT_PUBLIC_AUTH_RATE_LIMITS).map((rule) => rule.windowMs));
21
28
  function getRule(options, scope) {
22
29
  var _a;
23
30
  const configuredRule = (_a = options.publicAuthRateLimit) === null || _a === void 0 ? void 0 : _a[scope];
@@ -33,6 +40,19 @@ function getRule(options, scope) {
33
40
  }
34
41
  return DEFAULT_PUBLIC_AUTH_RATE_LIMITS[scope];
35
42
  }
43
+ function consumeRateLimit(store, key, rule) {
44
+ return __awaiter(this, void 0, void 0, function* () {
45
+ try {
46
+ return yield store.consume(key, rule.windowMs, rule.maxAttempts);
47
+ }
48
+ catch (_a) {
49
+ throw new common_1.HttpException({
50
+ code: 'ACCESSI_RATE_LIMIT_UNAVAILABLE',
51
+ message: 'Rate limit non disponibile. Riprova piu tardi.',
52
+ }, common_1.HttpStatus.SERVICE_UNAVAILABLE);
53
+ }
54
+ });
55
+ }
36
56
  function normalizeIdentifier(identifier) {
37
57
  if (typeof identifier !== 'string') {
38
58
  return null;
@@ -40,87 +60,43 @@ function normalizeIdentifier(identifier) {
40
60
  const normalized = identifier.trim().toLowerCase();
41
61
  return normalized === '' ? null : normalized;
42
62
  }
43
- function pruneRateLimitBuckets(now) {
44
- if (RATE_LIMIT_BUCKETS.size < MAX_TRACKED_BUCKETS) {
45
- return;
46
- }
47
- for (const [key, bucket] of RATE_LIMIT_BUCKETS.entries()) {
48
- if (bucket.resetAt <= now || bucket.lastSeenAt <= now - MAX_WINDOW_MS * 2) {
49
- RATE_LIMIT_BUCKETS.delete(key);
63
+ /**
64
+ * Applica il rate limit per IP e, opzionalmente, per soggetto.
65
+ *
66
+ * Con `publicAuthRateLimit.store` e possibile usare uno store Redis condiviso;
67
+ * senza store usa la memoria del processo.
68
+ */
69
+ function checkPublicAuthRateLimit(options_1, scope_1, req_1) {
70
+ return __awaiter(this, arguments, void 0, function* (options, scope, req, identifiers = []) {
71
+ var _a, _b, _c, _d, _e, _f, _g;
72
+ if (((_a = options.publicAuthRateLimit) === null || _a === void 0 ? void 0 : _a.enabled) === false) {
73
+ return { allowed: true, retryAfterSeconds: 0 };
50
74
  }
51
- if (RATE_LIMIT_BUCKETS.size < MAX_TRACKED_BUCKETS) {
52
- return;
75
+ const rule = getRule(options, scope);
76
+ const store = (_c = (_b = options.publicAuthRateLimit) === null || _b === void 0 ? void 0 : _b.store) !== null && _c !== void 0 ? _c : (0, rateLimitStore_1.getDefaultRateLimitStore)();
77
+ const ipAddress = (_g = normalizeIdentifier((_f = (_d = req.ip) !== null && _d !== void 0 ? _d : (_e = req.socket) === null || _e === void 0 ? void 0 : _e.remoteAddress) !== null && _f !== void 0 ? _f : 'unknown')) !== null && _g !== void 0 ? _g : 'unknown';
78
+ // Consuma prima il bucket per IP: se gia bloccato non crea bucket per soggetto,
79
+ // evitando crescita di memoria con identificatori sempre diversi.
80
+ const ipDecision = yield consumeRateLimit(store, `${scope}|ip|${ipAddress}`, rule);
81
+ if (!ipDecision.allowed) {
82
+ return ipDecision;
53
83
  }
54
- }
55
- }
56
- /** Mantiene il numero di bucket sotto la soglia, scartando i meno recenti. */
57
- function enforceBucketCap() {
58
- while (RATE_LIMIT_BUCKETS.size >= MAX_TRACKED_BUCKETS) {
59
- let oldestKey;
60
- let oldestSeen = Number.POSITIVE_INFINITY;
61
- for (const [key, bucket] of RATE_LIMIT_BUCKETS.entries()) {
62
- if (bucket.lastSeenAt < oldestSeen) {
63
- oldestSeen = bucket.lastSeenAt;
64
- oldestKey = key;
84
+ let retryAfterSeconds = 0;
85
+ for (const identifier of identifiers) {
86
+ const normalizedIdentifier = normalizeIdentifier(identifier);
87
+ if (!normalizedIdentifier) {
88
+ continue;
89
+ }
90
+ const subjectDecision = yield consumeRateLimit(store, `${scope}|ip-subject|${ipAddress}|${normalizedIdentifier}`, rule);
91
+ if (!subjectDecision.allowed) {
92
+ retryAfterSeconds = Math.max(retryAfterSeconds, subjectDecision.retryAfterSeconds);
65
93
  }
66
94
  }
67
- if (oldestKey === undefined) {
68
- return;
95
+ if (retryAfterSeconds > 0) {
96
+ return { allowed: false, retryAfterSeconds };
69
97
  }
70
- RATE_LIMIT_BUCKETS.delete(oldestKey);
71
- }
72
- }
73
- function consumeBucket(bucketKey, rule, now) {
74
- const existingBucket = RATE_LIMIT_BUCKETS.get(bucketKey);
75
- if (!existingBucket || existingBucket.resetAt <= now) {
76
- enforceBucketCap();
77
- RATE_LIMIT_BUCKETS.set(bucketKey, {
78
- count: 1,
79
- resetAt: now + rule.windowMs,
80
- lastSeenAt: now,
81
- });
82
- return null;
83
- }
84
- existingBucket.lastSeenAt = now;
85
- if (existingBucket.count >= rule.maxAttempts) {
86
- return Math.max(1, Math.ceil((existingBucket.resetAt - now) / 1000));
87
- }
88
- existingBucket.count += 1;
89
- return null;
90
- }
91
- function checkPublicAuthRateLimit(options, scope, req, identifiers = []) {
92
- var _a, _b, _c, _d, _e;
93
- if (((_a = options.publicAuthRateLimit) === null || _a === void 0 ? void 0 : _a.enabled) === false) {
94
98
  return { allowed: true, retryAfterSeconds: 0 };
95
- }
96
- const rule = getRule(options, scope);
97
- const now = Date.now();
98
- pruneRateLimitBuckets(now);
99
- const ipAddress = (_e = normalizeIdentifier((_d = (_b = req.ip) !== null && _b !== void 0 ? _b : (_c = req.socket) === null || _c === void 0 ? void 0 : _c.remoteAddress) !== null && _d !== void 0 ? _d : 'unknown')) !== null && _e !== void 0 ? _e : 'unknown';
100
- // Consuma prima il bucket per IP: se gia bloccato non crea bucket per soggetto,
101
- // evitando crescita di memoria con identificatori sempre diversi.
102
- const ipRetryAfterSeconds = consumeBucket(`${scope}|ip|${ipAddress}`, rule, now);
103
- if (ipRetryAfterSeconds) {
104
- return { allowed: false, retryAfterSeconds: ipRetryAfterSeconds };
105
- }
106
- let retryAfterSeconds = 0;
107
- for (const identifier of identifiers) {
108
- const normalizedIdentifier = normalizeIdentifier(identifier);
109
- if (!normalizedIdentifier) {
110
- continue;
111
- }
112
- const bucketRetryAfterSeconds = consumeBucket(`${scope}|ip-subject|${ipAddress}|${normalizedIdentifier}`, rule, now);
113
- if (bucketRetryAfterSeconds) {
114
- retryAfterSeconds = Math.max(retryAfterSeconds, bucketRetryAfterSeconds);
115
- }
116
- }
117
- if (retryAfterSeconds > 0) {
118
- return {
119
- allowed: false,
120
- retryAfterSeconds,
121
- };
122
- }
123
- return { allowed: true, retryAfterSeconds: 0 };
99
+ });
124
100
  }
125
101
  function sendPublicAuthRateLimitExceeded(res, retryAfterSeconds) {
126
102
  res.setHeader('Retry-After', String(retryAfterSeconds));
@@ -1 +1 @@
1
- {"version":3,"file":"publicAuthRateLimit.js","sourceRoot":"","sources":["../../../src/accessi-module/security/publicAuthRateLimit.ts"],"names":[],"mappings":"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","sourcesContent":["import { HttpStatus } from '@nestjs/common';\nimport { Request, Response } from 'express';\nimport { RestUtilities } from '../../Utilities';\nimport type { AccessiOptions, PublicAuthRateLimitRuleOptions } from '../AccessiModule';\n\nexport type PublicAuthRateLimitScope =\n | 'login'\n | 'twoFactorVerify'\n | 'twoFactorResend'\n | 'register'\n | 'passwordResetEmail'\n | 'passwordResetConfirm'\n | 'getUserByToken'\n | 'adminBootstrap'\n | 'consoleEntry';\n\ntype RateLimitBucket = {\n count: number;\n resetAt: number;\n lastSeenAt: number;\n};\n\ntype RateLimitDecision = {\n allowed: boolean;\n retryAfterSeconds: number;\n};\n\nconst DEFAULT_PUBLIC_AUTH_RATE_LIMITS: Record<\n PublicAuthRateLimitScope,\n PublicAuthRateLimitRuleOptions\n> = {\n login: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },\n twoFactorVerify: { windowMs: 15 * 60 * 1000, maxAttempts: 20 },\n twoFactorResend: { windowMs: 15 * 60 * 1000, maxAttempts: 5 },\n register: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },\n passwordResetEmail: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },\n passwordResetConfirm: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },\n getUserByToken: { windowMs: 5 * 60 * 1000, maxAttempts: 30 },\n adminBootstrap: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },\n consoleEntry: { windowMs: 5 * 60 * 1000, maxAttempts: 30 },\n};\n\nconst RATE_LIMIT_BUCKETS = new Map<string, RateLimitBucket>();\nconst MAX_TRACKED_BUCKETS = 20000;\nconst MAX_WINDOW_MS = Math.max(\n ...Object.values(DEFAULT_PUBLIC_AUTH_RATE_LIMITS).map((rule) => rule.windowMs),\n);\n\nfunction getRule(\n options: AccessiOptions,\n scope: PublicAuthRateLimitScope,\n): PublicAuthRateLimitRuleOptions {\n const configuredRule = options.publicAuthRateLimit?.[scope];\n\n if (\n configuredRule &&\n Number.isFinite(configuredRule.windowMs) &&\n configuredRule.windowMs > 0 &&\n Number.isFinite(configuredRule.maxAttempts) &&\n configuredRule.maxAttempts > 0\n ) {\n return {\n windowMs: Math.trunc(configuredRule.windowMs),\n maxAttempts: Math.trunc(configuredRule.maxAttempts),\n };\n }\n\n return DEFAULT_PUBLIC_AUTH_RATE_LIMITS[scope];\n}\n\nfunction normalizeIdentifier(identifier: string): string | null {\n if (typeof identifier !== 'string') {\n return null;\n }\n\n const normalized = identifier.trim().toLowerCase();\n return normalized === '' ? null : normalized;\n}\n\nfunction pruneRateLimitBuckets(now: number): void {\n if (RATE_LIMIT_BUCKETS.size < MAX_TRACKED_BUCKETS) {\n return;\n }\n\n for (const [key, bucket] of RATE_LIMIT_BUCKETS.entries()) {\n if (bucket.resetAt <= now || bucket.lastSeenAt <= now - MAX_WINDOW_MS * 2) {\n RATE_LIMIT_BUCKETS.delete(key);\n }\n\n if (RATE_LIMIT_BUCKETS.size < MAX_TRACKED_BUCKETS) {\n return;\n }\n }\n}\n\n/** Mantiene il numero di bucket sotto la soglia, scartando i meno recenti. */\nfunction enforceBucketCap(): void {\n while (RATE_LIMIT_BUCKETS.size >= MAX_TRACKED_BUCKETS) {\n let oldestKey: string | undefined;\n let oldestSeen = Number.POSITIVE_INFINITY;\n\n for (const [key, bucket] of RATE_LIMIT_BUCKETS.entries()) {\n if (bucket.lastSeenAt < oldestSeen) {\n oldestSeen = bucket.lastSeenAt;\n oldestKey = key;\n }\n }\n\n if (oldestKey === undefined) {\n return;\n }\n\n RATE_LIMIT_BUCKETS.delete(oldestKey);\n }\n}\n\nfunction consumeBucket(\n bucketKey: string,\n rule: PublicAuthRateLimitRuleOptions,\n now: number,\n): number | null {\n const existingBucket = RATE_LIMIT_BUCKETS.get(bucketKey);\n if (!existingBucket || existingBucket.resetAt <= now) {\n enforceBucketCap();\n RATE_LIMIT_BUCKETS.set(bucketKey, {\n count: 1,\n resetAt: now + rule.windowMs,\n lastSeenAt: now,\n });\n return null;\n }\n\n existingBucket.lastSeenAt = now;\n if (existingBucket.count >= rule.maxAttempts) {\n return Math.max(1, Math.ceil((existingBucket.resetAt - now) / 1000));\n }\n\n existingBucket.count += 1;\n return null;\n}\n\nexport function checkPublicAuthRateLimit(\n options: AccessiOptions,\n scope: PublicAuthRateLimitScope,\n req: Request,\n identifiers: string[] = [],\n): RateLimitDecision {\n if (options.publicAuthRateLimit?.enabled === false) {\n return { allowed: true, retryAfterSeconds: 0 };\n }\n\n const rule = getRule(options, scope);\n const now = Date.now();\n pruneRateLimitBuckets(now);\n\n const ipAddress =\n normalizeIdentifier(req.ip ?? req.socket?.remoteAddress ?? 'unknown') ?? 'unknown';\n\n // Consuma prima il bucket per IP: se gia bloccato non crea bucket per soggetto,\n // evitando crescita di memoria con identificatori sempre diversi.\n const ipRetryAfterSeconds = consumeBucket(`${scope}|ip|${ipAddress}`, rule, now);\n if (ipRetryAfterSeconds) {\n return { allowed: false, retryAfterSeconds: ipRetryAfterSeconds };\n }\n\n let retryAfterSeconds = 0;\n for (const identifier of identifiers) {\n const normalizedIdentifier = normalizeIdentifier(identifier);\n if (!normalizedIdentifier) {\n continue;\n }\n\n const bucketRetryAfterSeconds = consumeBucket(\n `${scope}|ip-subject|${ipAddress}|${normalizedIdentifier}`,\n rule,\n now,\n );\n if (bucketRetryAfterSeconds) {\n retryAfterSeconds = Math.max(retryAfterSeconds, bucketRetryAfterSeconds);\n }\n }\n\n if (retryAfterSeconds > 0) {\n return {\n allowed: false,\n retryAfterSeconds,\n };\n }\n\n return { allowed: true, retryAfterSeconds: 0 };\n}\n\nexport function sendPublicAuthRateLimitExceeded(\n res: Response,\n retryAfterSeconds: number,\n): Response {\n res.setHeader('Retry-After', String(retryAfterSeconds));\n return RestUtilities.sendErrorMessage(\n res,\n 'Troppi tentativi. Riprova piu tardi.',\n 'PublicAuthRateLimit',\n HttpStatus.TOO_MANY_REQUESTS,\n );\n}\n"]}
1
+ {"version":3,"file":"publicAuthRateLimit.js","sourceRoot":"","sources":["../../../src/accessi-module/security/publicAuthRateLimit.ts"],"names":[],"mappings":"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","sourcesContent":["import { HttpException, HttpStatus } from '@nestjs/common';\nimport { Request, Response } from 'express';\nimport { RestUtilities } from '../../Utilities';\nimport type { AccessiOptions, PublicAuthRateLimitRuleOptions } from '../AccessiModule';\nimport { getDefaultRateLimitStore } from './rateLimitStore';\nimport type { RateLimitDecision, RateLimitStore } from './rateLimitStore';\n\nexport type PublicAuthRateLimitScope =\n | 'login'\n | 'twoFactorVerify'\n | 'twoFactorResend'\n | 'register'\n | 'passwordResetEmail'\n | 'passwordResetConfirm'\n | 'getUserByToken'\n | 'adminBootstrap'\n | 'consoleEntry';\n\nconst DEFAULT_PUBLIC_AUTH_RATE_LIMITS: Record<\n PublicAuthRateLimitScope,\n PublicAuthRateLimitRuleOptions\n> = {\n login: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },\n twoFactorVerify: { windowMs: 15 * 60 * 1000, maxAttempts: 20 },\n twoFactorResend: { windowMs: 15 * 60 * 1000, maxAttempts: 5 },\n register: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },\n passwordResetEmail: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },\n passwordResetConfirm: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },\n getUserByToken: { windowMs: 5 * 60 * 1000, maxAttempts: 30 },\n adminBootstrap: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },\n consoleEntry: { windowMs: 5 * 60 * 1000, maxAttempts: 30 },\n};\n\nfunction getRule(\n options: AccessiOptions,\n scope: PublicAuthRateLimitScope,\n): PublicAuthRateLimitRuleOptions {\n const configuredRule = options.publicAuthRateLimit?.[scope];\n\n if (\n configuredRule &&\n Number.isFinite(configuredRule.windowMs) &&\n configuredRule.windowMs > 0 &&\n Number.isFinite(configuredRule.maxAttempts) &&\n configuredRule.maxAttempts > 0\n ) {\n return {\n windowMs: Math.trunc(configuredRule.windowMs),\n maxAttempts: Math.trunc(configuredRule.maxAttempts),\n };\n }\n\n return DEFAULT_PUBLIC_AUTH_RATE_LIMITS[scope];\n}\n\nasync function consumeRateLimit(\n store: RateLimitStore,\n key: string,\n rule: PublicAuthRateLimitRuleOptions,\n): Promise<RateLimitDecision> {\n try {\n return await store.consume(key, rule.windowMs, rule.maxAttempts);\n } catch {\n throw new HttpException(\n {\n code: 'ACCESSI_RATE_LIMIT_UNAVAILABLE',\n message: 'Rate limit non disponibile. Riprova piu tardi.',\n },\n HttpStatus.SERVICE_UNAVAILABLE,\n );\n }\n}\n\nfunction normalizeIdentifier(identifier: string): string | null {\n if (typeof identifier !== 'string') {\n return null;\n }\n\n const normalized = identifier.trim().toLowerCase();\n return normalized === '' ? null : normalized;\n}\n\n/**\n * Applica il rate limit per IP e, opzionalmente, per soggetto.\n *\n * Con `publicAuthRateLimit.store` e possibile usare uno store Redis condiviso;\n * senza store usa la memoria del processo.\n */\nexport async function checkPublicAuthRateLimit(\n options: AccessiOptions,\n scope: PublicAuthRateLimitScope,\n req: Request,\n identifiers: string[] = [],\n): Promise<RateLimitDecision> {\n if (options.publicAuthRateLimit?.enabled === false) {\n return { allowed: true, retryAfterSeconds: 0 };\n }\n\n const rule = getRule(options, scope);\n const store = options.publicAuthRateLimit?.store ?? getDefaultRateLimitStore();\n const ipAddress =\n normalizeIdentifier(req.ip ?? req.socket?.remoteAddress ?? 'unknown') ?? 'unknown';\n\n // Consuma prima il bucket per IP: se gia bloccato non crea bucket per soggetto,\n // evitando crescita di memoria con identificatori sempre diversi.\n const ipDecision = await consumeRateLimit(store, `${scope}|ip|${ipAddress}`, rule);\n if (!ipDecision.allowed) {\n return ipDecision;\n }\n\n let retryAfterSeconds = 0;\n for (const identifier of identifiers) {\n const normalizedIdentifier = normalizeIdentifier(identifier);\n if (!normalizedIdentifier) {\n continue;\n }\n\n const subjectDecision = await consumeRateLimit(\n store,\n `${scope}|ip-subject|${ipAddress}|${normalizedIdentifier}`,\n rule,\n );\n if (!subjectDecision.allowed) {\n retryAfterSeconds = Math.max(retryAfterSeconds, subjectDecision.retryAfterSeconds);\n }\n }\n\n if (retryAfterSeconds > 0) {\n return { allowed: false, retryAfterSeconds };\n }\n\n return { allowed: true, retryAfterSeconds: 0 };\n}\n\nexport function sendPublicAuthRateLimitExceeded(\n res: Response,\n retryAfterSeconds: number,\n): Response {\n res.setHeader('Retry-After', String(retryAfterSeconds));\n return RestUtilities.sendErrorMessage(\n res,\n 'Troppi tentativi. Riprova piu tardi.',\n 'PublicAuthRateLimit',\n HttpStatus.TOO_MANY_REQUESTS,\n );\n}\n"]}
@@ -0,0 +1,32 @@
1
+ /** Esito di un controllo di rate limit. */
2
+ export interface RateLimitDecision {
3
+ allowed: boolean;
4
+ retryAfterSeconds: number;
5
+ }
6
+ /** Store pluggable per rate limit: memoria per singola istanza, Redis per cluster. */
7
+ export interface RateLimitStore {
8
+ consume(key: string, windowMs: number, maxAttempts: number): Promise<RateLimitDecision>;
9
+ }
10
+ export interface MemoryRateLimitStore extends RateLimitStore {
11
+ reset(): void;
12
+ }
13
+ /** Store in memoria con finestra fissa e limite di bucket. */
14
+ export declare function createMemoryRateLimitStore(maxBuckets?: number): MemoryRateLimitStore;
15
+ /** Store di default in memoria, usato quando non viene configurato uno store condiviso. */
16
+ export declare function getDefaultRateLimitStore(): MemoryRateLimitStore;
17
+ /** Client Redis minimale: compatibile con ioredis (`redis.eval(script, numKeys, ...args)`). */
18
+ export interface RateLimitRedisClient {
19
+ eval(script: string, numKeys: number, ...args: Array<string | number>): Promise<unknown>;
20
+ }
21
+ export interface RedisRateLimitStoreOptions {
22
+ client: RateLimitRedisClient;
23
+ /** Default: `accessi:rate:`. */
24
+ keyPrefix?: string;
25
+ }
26
+ /**
27
+ * Store Redis per rate limit distribuito.
28
+ *
29
+ * Usa `INCR` + `PEXPIRE` atomici. Adatto a login, reset password, 2FA,
30
+ * console entry e service token in deployment multi-istanza.
31
+ */
32
+ export declare function createRedisRateLimitStore(options: RedisRateLimitStoreOptions): RateLimitStore;
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.createMemoryRateLimitStore = createMemoryRateLimitStore;
13
+ exports.getDefaultRateLimitStore = getDefaultRateLimitStore;
14
+ exports.createRedisRateLimitStore = createRedisRateLimitStore;
15
+ const DEFAULT_MAX_BUCKETS = 20000;
16
+ function normalizeLimits(windowMs, maxAttempts) {
17
+ return {
18
+ windowMs: Math.max(1, Math.trunc(windowMs)),
19
+ maxAttempts: Math.max(1, Math.trunc(maxAttempts)),
20
+ };
21
+ }
22
+ /** Store in memoria con finestra fissa e limite di bucket. */
23
+ function createMemoryRateLimitStore(maxBuckets = DEFAULT_MAX_BUCKETS) {
24
+ const buckets = new Map();
25
+ const safeMaxBuckets = Math.max(1, Math.trunc(maxBuckets));
26
+ const prune = (now) => {
27
+ if (buckets.size < safeMaxBuckets)
28
+ return;
29
+ for (const [key, bucket] of buckets) {
30
+ if (bucket.resetAt <= now)
31
+ buckets.delete(key);
32
+ }
33
+ };
34
+ const enforceCap = () => {
35
+ while (buckets.size >= safeMaxBuckets) {
36
+ let oldestKey;
37
+ let oldestSeen = Number.POSITIVE_INFINITY;
38
+ for (const [key, bucket] of buckets) {
39
+ if (bucket.lastSeenAt < oldestSeen) {
40
+ oldestSeen = bucket.lastSeenAt;
41
+ oldestKey = key;
42
+ }
43
+ }
44
+ if (oldestKey === undefined)
45
+ return;
46
+ buckets.delete(oldestKey);
47
+ }
48
+ };
49
+ return {
50
+ consume(key, windowMs, maxAttempts) {
51
+ return __awaiter(this, void 0, void 0, function* () {
52
+ const limits = normalizeLimits(windowMs, maxAttempts);
53
+ const now = Date.now();
54
+ prune(now);
55
+ const existing = buckets.get(key);
56
+ if (!existing || existing.resetAt <= now) {
57
+ enforceCap();
58
+ buckets.set(key, {
59
+ count: 1,
60
+ resetAt: now + limits.windowMs,
61
+ lastSeenAt: now,
62
+ });
63
+ return { allowed: true, retryAfterSeconds: 0 };
64
+ }
65
+ existing.lastSeenAt = now;
66
+ if (existing.count >= limits.maxAttempts) {
67
+ return {
68
+ allowed: false,
69
+ retryAfterSeconds: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)),
70
+ };
71
+ }
72
+ existing.count += 1;
73
+ return { allowed: true, retryAfterSeconds: 0 };
74
+ });
75
+ },
76
+ reset() {
77
+ buckets.clear();
78
+ },
79
+ };
80
+ }
81
+ const defaultStore = createMemoryRateLimitStore();
82
+ /** Store di default in memoria, usato quando non viene configurato uno store condiviso. */
83
+ function getDefaultRateLimitStore() {
84
+ return defaultStore;
85
+ }
86
+ const REDIS_SCRIPT = `
87
+ local current = redis.call('INCR', KEYS[1])
88
+ if current == 1 then
89
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
90
+ end
91
+ local ttl = redis.call('PTTL', KEYS[1])
92
+ if current > tonumber(ARGV[2]) then
93
+ return {0, ttl}
94
+ end
95
+ return {1, ttl}
96
+ `;
97
+ /**
98
+ * Store Redis per rate limit distribuito.
99
+ *
100
+ * Usa `INCR` + `PEXPIRE` atomici. Adatto a login, reset password, 2FA,
101
+ * console entry e service token in deployment multi-istanza.
102
+ */
103
+ function createRedisRateLimitStore(options) {
104
+ var _a;
105
+ if (!(options === null || options === void 0 ? void 0 : options.client) || typeof options.client.eval !== 'function') {
106
+ throw new Error('Redis rate limit store: client.eval non configurato.');
107
+ }
108
+ const keyPrefix = ((_a = options.keyPrefix) !== null && _a !== void 0 ? _a : 'accessi:rate:').replace(/:+$/, ':');
109
+ return {
110
+ consume(key, windowMs, maxAttempts) {
111
+ return __awaiter(this, void 0, void 0, function* () {
112
+ const limits = normalizeLimits(windowMs, maxAttempts);
113
+ const raw = yield options.client.eval(REDIS_SCRIPT, 1, `${keyPrefix}${key}`, limits.windowMs, limits.maxAttempts);
114
+ if (!Array.isArray(raw) || raw.length < 2) {
115
+ throw new Error('Redis rate limit store: risposta non valida.');
116
+ }
117
+ const allowed = Number(raw[0]) === 1;
118
+ const ttlMs = Number(raw[1]);
119
+ return {
120
+ allowed,
121
+ retryAfterSeconds: allowed
122
+ ? 0
123
+ : Math.max(1, Number.isFinite(ttlMs) && ttlMs > 0 ? Math.ceil(ttlMs / 1000) : 1),
124
+ };
125
+ });
126
+ },
127
+ };
128
+ }
129
+ //# sourceMappingURL=rateLimitStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rateLimitStore.js","sourceRoot":"","sources":["../../../src/accessi-module/security/rateLimitStore.ts"],"names":[],"mappings":"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","sourcesContent":["/** Esito di un controllo di rate limit. */\nexport interface RateLimitDecision {\n allowed: boolean;\n retryAfterSeconds: number;\n}\n\n/** Store pluggable per rate limit: memoria per singola istanza, Redis per cluster. */\nexport interface RateLimitStore {\n consume(key: string, windowMs: number, maxAttempts: number): Promise<RateLimitDecision>;\n}\n\nexport interface MemoryRateLimitStore extends RateLimitStore {\n reset(): void;\n}\n\ninterface RateLimitBucket {\n count: number;\n resetAt: number;\n lastSeenAt: number;\n}\n\nconst DEFAULT_MAX_BUCKETS = 20_000;\n\nfunction normalizeLimits(windowMs: number, maxAttempts: number): { windowMs: number; maxAttempts: number } {\n return {\n windowMs: Math.max(1, Math.trunc(windowMs)),\n maxAttempts: Math.max(1, Math.trunc(maxAttempts)),\n };\n}\n\n/** Store in memoria con finestra fissa e limite di bucket. */\nexport function createMemoryRateLimitStore(\n maxBuckets: number = DEFAULT_MAX_BUCKETS,\n): MemoryRateLimitStore {\n const buckets = new Map<string, RateLimitBucket>();\n const safeMaxBuckets = Math.max(1, Math.trunc(maxBuckets));\n\n const prune = (now: number): void => {\n if (buckets.size < safeMaxBuckets) return;\n for (const [key, bucket] of buckets) {\n if (bucket.resetAt <= now) buckets.delete(key);\n }\n };\n\n const enforceCap = (): void => {\n while (buckets.size >= safeMaxBuckets) {\n let oldestKey: string | undefined;\n let oldestSeen = Number.POSITIVE_INFINITY;\n for (const [key, bucket] of buckets) {\n if (bucket.lastSeenAt < oldestSeen) {\n oldestSeen = bucket.lastSeenAt;\n oldestKey = key;\n }\n }\n if (oldestKey === undefined) return;\n buckets.delete(oldestKey);\n }\n };\n\n return {\n async consume(key: string, windowMs: number, maxAttempts: number): Promise<RateLimitDecision> {\n const limits = normalizeLimits(windowMs, maxAttempts);\n const now = Date.now();\n prune(now);\n\n const existing = buckets.get(key);\n if (!existing || existing.resetAt <= now) {\n enforceCap();\n buckets.set(key, {\n count: 1,\n resetAt: now + limits.windowMs,\n lastSeenAt: now,\n });\n return { allowed: true, retryAfterSeconds: 0 };\n }\n\n existing.lastSeenAt = now;\n if (existing.count >= limits.maxAttempts) {\n return {\n allowed: false,\n retryAfterSeconds: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)),\n };\n }\n\n existing.count += 1;\n return { allowed: true, retryAfterSeconds: 0 };\n },\n reset() {\n buckets.clear();\n },\n };\n}\n\nconst defaultStore = createMemoryRateLimitStore();\n\n/** Store di default in memoria, usato quando non viene configurato uno store condiviso. */\nexport function getDefaultRateLimitStore(): MemoryRateLimitStore {\n return defaultStore;\n}\n\n/** Client Redis minimale: compatibile con ioredis (`redis.eval(script, numKeys, ...args)`). */\nexport interface RateLimitRedisClient {\n eval(script: string, numKeys: number, ...args: Array<string | number>): Promise<unknown>;\n}\n\nexport interface RedisRateLimitStoreOptions {\n client: RateLimitRedisClient;\n /** Default: `accessi:rate:`. */\n keyPrefix?: string;\n}\n\nconst REDIS_SCRIPT = `\nlocal current = redis.call('INCR', KEYS[1])\nif current == 1 then\n redis.call('PEXPIRE', KEYS[1], ARGV[1])\nend\nlocal ttl = redis.call('PTTL', KEYS[1])\nif current > tonumber(ARGV[2]) then\n return {0, ttl}\nend\nreturn {1, ttl}\n`;\n\n/**\n * Store Redis per rate limit distribuito.\n *\n * Usa `INCR` + `PEXPIRE` atomici. Adatto a login, reset password, 2FA,\n * console entry e service token in deployment multi-istanza.\n */\nexport function createRedisRateLimitStore(\n options: RedisRateLimitStoreOptions,\n): RateLimitStore {\n if (!options?.client || typeof options.client.eval !== 'function') {\n throw new Error('Redis rate limit store: client.eval non configurato.');\n }\n\n const keyPrefix = (options.keyPrefix ?? 'accessi:rate:').replace(/:+$/, ':');\n\n return {\n async consume(key, windowMs, maxAttempts) {\n const limits = normalizeLimits(windowMs, maxAttempts);\n const raw = await options.client.eval(\n REDIS_SCRIPT,\n 1,\n `${keyPrefix}${key}`,\n limits.windowMs,\n limits.maxAttempts,\n );\n\n if (!Array.isArray(raw) || raw.length < 2) {\n throw new Error('Redis rate limit store: risposta non valida.');\n }\n\n const allowed = Number(raw[0]) === 1;\n const ttlMs = Number(raw[1]);\n return {\n allowed,\n retryAfterSeconds: allowed\n ? 0\n : Math.max(1, Number.isFinite(ttlMs) && ttlMs > 0 ? Math.ceil(ttlMs / 1000) : 1),\n };\n },\n };\n}\n"]}
@@ -1,6 +1,7 @@
1
1
  import { CanActivate, ExecutionContext } from '@nestjs/common';
2
2
  import { Reflector } from '@nestjs/core';
3
3
  import type { Request } from 'express';
4
+ import type { AccessiOptions } from '../AccessiModule';
4
5
  import { ServiceTokenService, VerifiedServiceToken } from '../Services/ServiceTokenService/ServiceTokenService';
5
6
  /** Chiave dei metadata usata da `@RequireServiceTokenScopes`. */
6
7
  export declare const SERVICE_TOKEN_SCOPES_METADATA = "accessi:service-token-scopes";
@@ -20,6 +21,7 @@ export declare function getAccessiServiceToken(req: Request): VerifiedServiceTok
20
21
  export declare class ServiceTokenGuard implements CanActivate {
21
22
  private readonly serviceTokenService;
22
23
  private readonly reflector;
23
- constructor(serviceTokenService: ServiceTokenService, reflector: Reflector);
24
+ private readonly accessiOptions;
25
+ constructor(serviceTokenService: ServiceTokenService, reflector: Reflector, accessiOptions: AccessiOptions);
24
26
  canActivate(context: ExecutionContext): Promise<boolean>;
25
27
  }
@@ -8,6 +8,9 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
8
8
  var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
11
14
  var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
12
15
  function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
13
16
  return new (P || (P = Promise))(function (resolve, reject) {
@@ -45,16 +48,17 @@ function getAccessiServiceToken(req) {
45
48
  * con `@RequireServiceTokenScopes('ia')`.
46
49
  */
47
50
  let ServiceTokenGuard = class ServiceTokenGuard {
48
- constructor(serviceTokenService, reflector) {
51
+ constructor(serviceTokenService, reflector, accessiOptions) {
49
52
  this.serviceTokenService = serviceTokenService;
50
53
  this.reflector = reflector;
54
+ this.accessiOptions = accessiOptions;
51
55
  }
52
56
  canActivate(context) {
53
57
  return __awaiter(this, void 0, void 0, function* () {
54
- var _a, _b, _c, _d;
58
+ var _a, _b, _c, _d, _e;
55
59
  const request = context.switchToHttp().getRequest();
56
60
  const ip = (_c = (_a = request.ip) !== null && _a !== void 0 ? _a : (_b = request.socket) === null || _b === void 0 ? void 0 : _b.remoteAddress) !== null && _c !== void 0 ? _c : 'unknown';
57
- (0, serviceTokenRateLimit_1.assertServiceTokenRateLimit)(ip);
61
+ yield (0, serviceTokenRateLimit_1.assertServiceTokenRateLimit)(ip, (_d = this.accessiOptions.serviceTokens) === null || _d === void 0 ? void 0 : _d.rateLimitStore);
58
62
  const token = (0, authenticatedToken_1.extractAccessiBearerToken)(request.headers['authorization']);
59
63
  if (!token) {
60
64
  throw new common_1.UnauthorizedException('Token di servizio mancante.');
@@ -64,10 +68,10 @@ let ServiceTokenGuard = class ServiceTokenGuard {
64
68
  throw new common_1.UnauthorizedException('Token di servizio non valido, scaduto o revocato.');
65
69
  }
66
70
  request.accessiServiceToken = verified;
67
- const requiredScopes = (_d = this.reflector.getAllAndOverride(exports.SERVICE_TOKEN_SCOPES_METADATA, [
71
+ const requiredScopes = (_e = this.reflector.getAllAndOverride(exports.SERVICE_TOKEN_SCOPES_METADATA, [
68
72
  context.getHandler(),
69
73
  context.getClass(),
70
- ])) !== null && _d !== void 0 ? _d : [];
74
+ ])) !== null && _e !== void 0 ? _e : [];
71
75
  const missingScope = requiredScopes.find((scope) => !(0, ServiceTokenService_1.serviceTokenHasScope)(verified.scopes, scope));
72
76
  if (missingScope) {
73
77
  throw new common_1.ForbiddenException(`Scope '${missingScope}' non autorizzato per questo token.`);
@@ -79,7 +83,8 @@ let ServiceTokenGuard = class ServiceTokenGuard {
79
83
  exports.ServiceTokenGuard = ServiceTokenGuard;
80
84
  exports.ServiceTokenGuard = ServiceTokenGuard = __decorate([
81
85
  (0, common_1.Injectable)(),
86
+ __param(2, (0, common_1.Inject)('ACCESSI_OPTIONS')),
82
87
  __metadata("design:paramtypes", [ServiceTokenService_1.ServiceTokenService,
83
- core_1.Reflector])
88
+ core_1.Reflector, Object])
84
89
  ], ServiceTokenGuard);
85
90
  //# sourceMappingURL=serviceTokenGuard.js.map