@astram/guard 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/account.controller.d.ts +21 -13
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +91 -38
- package/dist/auth/auth.controller.d.ts +6 -5
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +127 -75
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +10 -7
- package/dist/auth/connections.d.ts.map +1 -1
- package/dist/auth/connections.js +3 -1
- package/dist/auth/rate-limit/attempt-limiter.d.ts +59 -1
- package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -1
- package/dist/auth/rate-limit/attempt-limiter.js +85 -2
- package/dist/auth/tokens.d.ts +13 -10
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +13 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/module/shield-tokens.d.ts +12 -0
- package/dist/module/shield-tokens.d.ts.map +1 -1
- package/dist/module/shield-tokens.js +13 -1
- package/dist/module/shield.module.d.ts +6 -2
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +40 -18
- package/package.json +3 -3
|
@@ -118,6 +118,21 @@ function genericUnauthorized() {
|
|
|
118
118
|
function retryableUnavailable() {
|
|
119
119
|
throw new common_1.HttpException(RETRYABLE_BODY, common_1.HttpStatus.SERVICE_UNAVAILABLE);
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Phase 9 #10 — count this attempt BEFORE verifying (atomic increment-first). Returns the
|
|
123
|
+
* verdict, or `null` when no limiter is wired. A limiter that cannot answer throws the
|
|
124
|
+
* retryable 503: the gate fails CLOSED, never lets an unthrottled guess through.
|
|
125
|
+
*/
|
|
126
|
+
async function consumeOrUnavailable(limiter, key) {
|
|
127
|
+
if (limiter == null)
|
|
128
|
+
return null;
|
|
129
|
+
try {
|
|
130
|
+
return await limiter.consume(key);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
retryableUnavailable();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
121
136
|
/**
|
|
122
137
|
* The generic 429 for a rate-limited RESET-password (feature #141). Reset is
|
|
123
138
|
* token-gated (a 256-bit single-use token), NOT enumeration-sensitive, so surfacing
|
|
@@ -510,15 +525,26 @@ let AuthController = class AuthController {
|
|
|
510
525
|
// Rate-limit gate (spec §5 / H1 + #5): after M failed attempts within the window —
|
|
511
526
|
// for this ACCOUNT, or from this client IP when per-IP limiting is on — reject BEFORE
|
|
512
527
|
// consulting the authenticator, with the SAME generic 401 as a wrong password
|
|
513
|
-
// (anti-enumeration: a locked-out account is indistinguishable).
|
|
514
|
-
//
|
|
528
|
+
// (anti-enumeration: a locked-out account is indistinguishable).
|
|
529
|
+
//
|
|
530
|
+
// Phase 9 #10: INCREMENT-FIRST. Each limiter counts THIS attempt atomically before the
|
|
531
|
+
// password is checked (`consume`), so N parallel guesses get N distinct counts and at
|
|
532
|
+
// most `maxAttempts` of them reach the authenticator. An attempt that turns out not to
|
|
533
|
+
// be a failure is given back (`refund`) or cleared (`reset`) below, so the counters
|
|
534
|
+
// keep their meaning (failures only; the IP counter is never reset by a success). A
|
|
535
|
+
// Redis error on the gate fails CLOSED (503) — before, it failed open (KI #40).
|
|
515
536
|
//
|
|
516
537
|
// Per-IP first (cheap, no DB): a locked IP never reaches the account lookup.
|
|
517
538
|
const ipLimiter = this.perIp(this.loginIpLimiter);
|
|
518
539
|
const ipKey = (0, canonical_hint_js_1.loginIpKey)(ip);
|
|
519
|
-
|
|
540
|
+
const ipVerdict = await consumeOrUnavailable(ipLimiter, ipKey);
|
|
541
|
+
if (ipVerdict !== null && !ipVerdict.allowed)
|
|
520
542
|
genericUnauthorized();
|
|
521
|
-
|
|
543
|
+
/** Give back this attempt's IP count (it was not a password failure). */
|
|
544
|
+
const refundIp = async () => {
|
|
545
|
+
if (ipVerdict !== null)
|
|
546
|
+
await ipLimiter?.refund(ipKey);
|
|
547
|
+
};
|
|
522
548
|
// F1: key the per-account budget on the account the store resolves for this hint
|
|
523
549
|
// (`login:pid:<id>`; unknown → canonical-hash fallback). A store ERROR fails CLOSED
|
|
524
550
|
// (503), consistent with the authenticator's treatment of a resolver outage.
|
|
@@ -527,22 +553,44 @@ let AuthController = class AuthController {
|
|
|
527
553
|
account = await this.lookupAccount(identityHint);
|
|
528
554
|
}
|
|
529
555
|
catch {
|
|
556
|
+
await refundIp();
|
|
530
557
|
retryableUnavailable();
|
|
531
558
|
}
|
|
532
559
|
const accountId = (0, canonical_hint_js_1.principalIdOf)(account);
|
|
533
560
|
const loginKey = (0, canonical_hint_js_1.loginHintKey)(identityHint, accountId);
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
561
|
+
let hintVerdict;
|
|
562
|
+
try {
|
|
563
|
+
hintVerdict = await consumeOrUnavailable(this.loginLimiter, loginKey);
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
await refundIp();
|
|
567
|
+
throw err;
|
|
568
|
+
}
|
|
569
|
+
if (hintVerdict !== null && !hintVerdict.allowed) {
|
|
570
|
+
// Already locked → generic 401. We do NOT audit here: every attempt past the limit
|
|
571
|
+
// lands here, so auditing at this gate would flood one event per attempt. The lockout
|
|
572
|
+
// is audited ONCE on the TRANSITION instead — in the wrong-password branch below,
|
|
573
|
+
// when the attempt's count equals the threshold (feature #140 audit-on-the-edge).
|
|
574
|
+
// An attempt refused by the account lock is not an IP failure (as before #10).
|
|
575
|
+
await refundIp();
|
|
541
576
|
genericUnauthorized();
|
|
542
577
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
578
|
+
let result;
|
|
579
|
+
try {
|
|
580
|
+
result = await this.authenticator.authenticate(identityHint, [passwordCredential(password)]);
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
// No verdict: give both attempts back, then surface the error.
|
|
584
|
+
await this.loginLimiter?.refund(loginKey);
|
|
585
|
+
await refundIp();
|
|
586
|
+
throw err;
|
|
587
|
+
}
|
|
588
|
+
if (result.outcome !== 'rejected') {
|
|
589
|
+
// Not a password failure (authenticated / mfa_required): the IP counter counts
|
|
590
|
+
// failures only, and a success must never reset it (so an attacker's own valid login
|
|
591
|
+
// cannot wipe the budget) — give back just this attempt.
|
|
592
|
+
await refundIp();
|
|
593
|
+
}
|
|
546
594
|
if (result.outcome === 'authenticated') {
|
|
547
595
|
// Correct password → clear the login counter (do not carry earlier failures).
|
|
548
596
|
await this.loginLimiter?.reset(loginKey);
|
|
@@ -556,7 +604,9 @@ let AuthController = class AuthController {
|
|
|
556
604
|
if (result.outcome === 'mfa_required') {
|
|
557
605
|
// Password verified, a second factor is outstanding. NOT a failure — but NOT a
|
|
558
606
|
// success either (#9): the login counter is reset only once `/auth/mfa` completes,
|
|
559
|
-
// so a stolen password cannot loop login→mfa to guess TOTP codes for free.
|
|
607
|
+
// so a stolen password cannot loop login→mfa to guess TOTP codes for free. This
|
|
608
|
+
// attempt's own count is given back (Phase 9 #10), leaving earlier failures intact.
|
|
609
|
+
await this.loginLimiter?.refund(loginKey);
|
|
560
610
|
//
|
|
561
611
|
// M6: record the password step's SUCCESS — the resolved principal, the typed hint,
|
|
562
612
|
// and a fingerprint of the credential it was checked against — NEVER the password.
|
|
@@ -584,33 +634,33 @@ let AuthController = class AuthController {
|
|
|
584
634
|
// else is the generic 401.
|
|
585
635
|
if (result.reason === REASON_AUTH_UNAVAILABLE) {
|
|
586
636
|
// A transient outage is NOT a credential failure — do NOT count it toward the
|
|
587
|
-
// lockout (avoids a Redis/DB blip storm from locking out everyone)
|
|
637
|
+
// lockout (avoids a Redis/DB blip storm from locking out everyone): give both
|
|
638
|
+
// attempts back.
|
|
639
|
+
await this.loginLimiter?.refund(loginKey);
|
|
640
|
+
await refundIp();
|
|
588
641
|
retryableUnavailable();
|
|
589
642
|
}
|
|
590
|
-
// A genuine wrong-password deny
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
643
|
+
// A genuine wrong-password deny: the attempt was already counted by `consume` and
|
|
644
|
+
// stays counted. When its count equals the limiter's threshold this attempt is the one
|
|
645
|
+
// that TRIPPED the lockout — audit `auth.login.locked` exactly ONCE here (the edge),
|
|
646
|
+
// never on the already-locked gate above (which would flood).
|
|
594
647
|
// NO notify: this is pre-auth (the caller has not proven ownership), and mailing the
|
|
595
648
|
// account on every lockout would let anyone email-bomb it / confirm it exists
|
|
596
649
|
// (recordSecurityEvent enforces this too).
|
|
597
|
-
if (this.loginLimiter != null) {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
notify: false,
|
|
605
|
-
});
|
|
606
|
-
}
|
|
650
|
+
if (this.loginLimiter != null && hintVerdict?.count === this.loginLimiter.maxAttempts) {
|
|
651
|
+
this.recordSecurityEvent({
|
|
652
|
+
action: 'auth.login.locked',
|
|
653
|
+
identityHint,
|
|
654
|
+
attributes: { reason: 'rate_limit' },
|
|
655
|
+
notify: false,
|
|
656
|
+
});
|
|
607
657
|
}
|
|
608
|
-
// #5:
|
|
609
|
-
//
|
|
610
|
-
// Audited once on the edge, with no hint (the IP budget
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
if (
|
|
658
|
+
// #5: the failure also stays counted against the client IP (failures only — a success
|
|
659
|
+
// gives its attempt back and never resets this counter, so an attacker's own valid
|
|
660
|
+
// login cannot wipe the budget). Audited once on the edge, with no hint (the IP budget
|
|
661
|
+
// spans many accounts).
|
|
662
|
+
if (ipLimiter != null && ipVerdict !== null) {
|
|
663
|
+
if (ipVerdict.count === ipLimiter.maxAttempts) {
|
|
614
664
|
this.recordSecurityEvent({
|
|
615
665
|
action: 'auth.login.ip_locked',
|
|
616
666
|
identityHint: null,
|
|
@@ -1090,42 +1140,46 @@ let AuthController = class AuthController {
|
|
|
1090
1140
|
* {@link MfaStepUp} (same `OrTotpVerifier` + replay guard as the authenticator, principal
|
|
1091
1141
|
* minted by the identity broker).
|
|
1092
1142
|
*
|
|
1093
|
-
* - `mfa:pid:<principalId>`
|
|
1094
|
-
* step-up is never
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1143
|
+
* - the attempt is counted against `mfa:pid:<principalId>` BEFORE verifying (Phase 9
|
|
1144
|
+
* #10, atomic increment-first): over budget → generic 401 and the step-up is never
|
|
1145
|
+
* consulted; limiter unreachable → 503 (fail closed).
|
|
1146
|
+
* - a rejected code stays counted; the attempt whose count equals the threshold audits
|
|
1147
|
+
* `auth.mfa.locked` ONCE (no notify — pre-auth, see recovery lockout).
|
|
1148
|
+
* - a transient outage → 503, the attempt is given back (never counted).
|
|
1098
1149
|
* - success → establish + bind, then reset the TOTP counter AND the login counter (the
|
|
1099
1150
|
* login counter is reset only here, once authentication fully succeeded).
|
|
1100
1151
|
*/
|
|
1101
1152
|
async completeViaTotp(pending, code) {
|
|
1102
1153
|
const mfaKey = `mfa:pid:${pending.principalId}`;
|
|
1103
|
-
if (this.mfaLimiter != null && !(await this.mfaLimiter.check(mfaKey))) {
|
|
1104
|
-
genericUnauthorized();
|
|
1105
|
-
}
|
|
1106
1154
|
const stepUp = this.mfaStepUp;
|
|
1107
1155
|
if (stepUp == null)
|
|
1108
1156
|
genericUnauthorized(); // not wired → fail closed
|
|
1157
|
+
// Phase 9 #10: count the attempt FIRST (atomic), so parallel guesses cannot all pass a
|
|
1158
|
+
// read-only check. Over budget → generic 401; limiter unreachable → 503 (fail closed).
|
|
1159
|
+
const verdict = await consumeOrUnavailable(this.mfaLimiter, mfaKey);
|
|
1160
|
+
if (verdict !== null && !verdict.allowed)
|
|
1161
|
+
genericUnauthorized();
|
|
1109
1162
|
let outcome;
|
|
1110
1163
|
try {
|
|
1111
1164
|
outcome = await stepUp.verify(pending, totpCredential(code));
|
|
1112
1165
|
}
|
|
1113
1166
|
catch {
|
|
1114
|
-
|
|
1167
|
+
outcome = { outcome: 'unavailable' };
|
|
1115
1168
|
}
|
|
1116
|
-
if (outcome.outcome === 'unavailable')
|
|
1169
|
+
if (outcome.outcome === 'unavailable') {
|
|
1170
|
+
// A transient outage is not a wrong code: give the attempt back (never counted).
|
|
1171
|
+
await this.mfaLimiter?.refund(mfaKey);
|
|
1117
1172
|
retryableUnavailable();
|
|
1173
|
+
}
|
|
1118
1174
|
if (outcome.outcome !== 'authenticated' || outcome.principal.id !== pending.principalId) {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
});
|
|
1128
|
-
}
|
|
1175
|
+
// The attempt stays counted; the edge (count === threshold) audits ONCE.
|
|
1176
|
+
if (this.mfaLimiter != null && verdict?.count === this.mfaLimiter.maxAttempts) {
|
|
1177
|
+
this.recordSecurityEvent({
|
|
1178
|
+
action: 'auth.mfa.locked',
|
|
1179
|
+
actorId: pending.principalId,
|
|
1180
|
+
attributes: { reason: 'rate_limit' },
|
|
1181
|
+
notify: false,
|
|
1182
|
+
});
|
|
1129
1183
|
}
|
|
1130
1184
|
genericUnauthorized();
|
|
1131
1185
|
}
|
|
@@ -1176,34 +1230,32 @@ let AuthController = class AuthController {
|
|
|
1176
1230
|
// consulting the recovery store — the SAME generic 401 as a wrong code (no leak
|
|
1177
1231
|
// of "rate-limited"; the store is never even touched when locked). Keyed by the
|
|
1178
1232
|
// RESOLVED principalId (recovery codes don't rotate → a lower ceiling than login).
|
|
1179
|
-
//
|
|
1233
|
+
// Phase 9 #10: the attempt is counted FIRST (atomic increment-first), so parallel
|
|
1234
|
+
// guesses cannot all pass a read-only check and reach the Argon2 scan. A Redis error on
|
|
1235
|
+
// the gate fails CLOSED (503).
|
|
1180
1236
|
const recoveryKey = `recovery:${principalId}`;
|
|
1181
|
-
|
|
1237
|
+
const verdict = await consumeOrUnavailable(this.recoveryLimiter, recoveryKey);
|
|
1238
|
+
if (verdict !== null && !verdict.allowed)
|
|
1182
1239
|
genericUnauthorized();
|
|
1183
|
-
}
|
|
1184
1240
|
// Atomic single-use argon2-verify. A throw is swallowed to `false` → generic
|
|
1185
1241
|
// 401 (never surface a store error as a distinct outcome).
|
|
1186
1242
|
const ok = await this.recoveryCodes.consume(principalId, code).catch(() => false);
|
|
1187
1243
|
if (!ok) {
|
|
1188
|
-
// A wrong (or unmatched) recovery code →
|
|
1189
|
-
//
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
// check-gate above, which returns false on every attempt = flood).
|
|
1244
|
+
// A wrong (or unmatched) recovery code → the attempt stays counted, THEN the
|
|
1245
|
+
// identical generic 401. When its count equals the threshold this attempt TRIPPED
|
|
1246
|
+
// the lockout — audit `auth.mfa.recovery_locked` exactly ONCE on that edge (never on
|
|
1247
|
+
// the already-locked gate above, which every later attempt hits = flood).
|
|
1193
1248
|
// NO notify: a locked-out attacker is (by assumption) NOT the account owner, so
|
|
1194
1249
|
// emailing on every recovery lockout would let an attacker email-bomb the owner —
|
|
1195
1250
|
// audit-only is safer; the once-per-window edge already dedups it.
|
|
1196
|
-
if (this.recoveryLimiter != null) {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
notify: false,
|
|
1205
|
-
});
|
|
1206
|
-
}
|
|
1251
|
+
if (this.recoveryLimiter != null && verdict?.count === this.recoveryLimiter.maxAttempts) {
|
|
1252
|
+
this.recordSecurityEvent({
|
|
1253
|
+
action: 'auth.mfa.recovery_locked',
|
|
1254
|
+
actorId: principalId,
|
|
1255
|
+
tenantId: typeof claims.tenantId === 'string' ? claims.tenantId : null,
|
|
1256
|
+
attributes: { reason: 'rate_limit' },
|
|
1257
|
+
notify: false,
|
|
1258
|
+
});
|
|
1207
1259
|
}
|
|
1208
1260
|
genericUnauthorized();
|
|
1209
1261
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.module.d.ts","sourceRoot":"","sources":["../../src/auth/auth.module.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AAEH,OAAO,EAAkB,KAAK,aAAa,EAAiB,MAAM,gBAAgB,CAAC;AAanF,OAAO,KAAK,EAGV,kBAAkB,EAOlB,QAAQ,EACR,cAAc,EAEf,MAAM,cAAc,CAAC;AAwFtB,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAC;AAChF,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAGvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AACpE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAClE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAavD;;;;;GAKG;AACH,eAAO,MAAM,QAAQ,EAAE,QACqE,CAAC;AAE7F;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,cAAc,GAAG,IAAI,GAAG,SAAS,EAC3C,eAAe,EAAE,cAAc,GAC9B,cAAc,CAEhB;AAED;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,IAAI,OAAO,CAAC,cAAc,CAAC,CAGvE;
|
|
1
|
+
{"version":3,"file":"auth.module.d.ts","sourceRoot":"","sources":["../../src/auth/auth.module.ts"],"names":[],"mappings":"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"}
|
package/dist/auth/auth.module.js
CHANGED
|
@@ -328,8 +328,8 @@ const pendingMfaStoreProvider = {
|
|
|
328
328
|
};
|
|
329
329
|
/**
|
|
330
330
|
* The TOTP-step limiter (#9) — 5 failures / 15-min window, keyed `mfa:pid:<principalId>`.
|
|
331
|
-
*
|
|
332
|
-
*
|
|
331
|
+
* Each attempt is counted before the factor is verified (Phase 9 #10, increment-first);
|
|
332
|
+
* a Redis outage fails closed (503), like its replay guard.
|
|
333
333
|
*/
|
|
334
334
|
const mfaLimiterProvider = {
|
|
335
335
|
provide: tokens_js_1.SHIELD_MFA_LIMITER,
|
|
@@ -366,8 +366,9 @@ const authBootLogger = new common_1.Logger('ShieldAuth');
|
|
|
366
366
|
* the DB collation maps to one account shares one budget; an unknown hint falls back to
|
|
367
367
|
* `login:<sha256(canonical hint)>`; no PII in the Redis key). Same
|
|
368
368
|
* live-Redis provider pattern as {@link sessionTokenBinderProvider} (a `useFactory`
|
|
369
|
-
* injecting {@link SHIELD_REDIS}); provided only under `auth: true`.
|
|
370
|
-
*
|
|
369
|
+
* injecting {@link SHIELD_REDIS}); provided only under `auth: true`. Each attempt is
|
|
370
|
+
* counted before the password is checked (Phase 9 #10, increment-first); a Redis outage
|
|
371
|
+
* fails closed (503) — login could not bind a session then anyway.
|
|
371
372
|
*/
|
|
372
373
|
const loginLimiterProvider = {
|
|
373
374
|
provide: tokens_js_1.SHIELD_LOGIN_LIMITER,
|
|
@@ -379,8 +380,9 @@ const loginLimiterProvider = {
|
|
|
379
380
|
* Redis-injected {@link AttemptLimiter} keyed `login:ip:<req.ip>`, default 50 failures /
|
|
380
381
|
* 15-min window, configurable through `AppConfig.loginIpLimit` (env
|
|
381
382
|
* `LOGIN_IP_MAX_FAILURES` / `LOGIN_IP_WINDOW_MS`). Stops password spraying across many
|
|
382
|
-
* distinct hints from one client. Failures only
|
|
383
|
-
*
|
|
383
|
+
* distinct hints from one client. Failures only (a non-failure gives its attempt back);
|
|
384
|
+
* never reset on success. Counted before the password check (Phase 9 #10); a Redis outage
|
|
385
|
+
* fails closed (503). ENFORCED ONLY when the app set Express `trust proxy`
|
|
384
386
|
* explicitly (0 = no proxy, else the hop count); otherwise {@link TrustProxyBootCheck}
|
|
385
387
|
* switches it OFF at boot with a warning, because every client behind a proxy would share
|
|
386
388
|
* one IP and 50 failures would lock everyone out.
|
|
@@ -395,7 +397,8 @@ const loginIpLimiterProvider = {
|
|
|
395
397
|
* 5 failures / 15-min window, keyed `recovery:<principalId>` (a lower ceiling than
|
|
396
398
|
* login: recovery codes don't rotate, so they're higher-value). Same live-Redis
|
|
397
399
|
* provider pattern as {@link sessionTokenBinderProvider}; provided only under
|
|
398
|
-
* `auth: true`.
|
|
400
|
+
* `auth: true`. Each attempt is counted before the code is checked (Phase 9 #10); a Redis
|
|
401
|
+
* outage fails closed (503).
|
|
399
402
|
*/
|
|
400
403
|
const recoveryLimiterProvider = {
|
|
401
404
|
provide: tokens_js_1.SHIELD_RECOVERY_LIMITER,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connections.d.ts","sourceRoot":"","sources":["../../src/auth/connections.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,KAAK,MAAM,SAAS,CAAC;AAC5B,OAAO,EAAoC,KAAK,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAE3F;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAcpD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,KAAK,
|
|
1
|
+
{"version":3,"file":"connections.d.ts","sourceRoot":"","sources":["../../src/auth/connections.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAc,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAgB,EAAE,KAAK,IAAI,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,KAAK,MAAM,SAAS,CAAC;AAC5B,OAAO,EAAoC,KAAK,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAE3F;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAcpD;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,KAAK,CAgB1D;AAED;;;;;;GAMG;AACH,qBACa,mBAAoB,YAAW,eAAe;IAEvD,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,IAAI,EAAE,IAAI,EACV,KAAK,EAAE,KAAK;IAGzB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC;CAavC"}
|
package/dist/auth/connections.js
CHANGED
|
@@ -81,7 +81,9 @@ function createRedisClient(config) {
|
|
|
81
81
|
// back instead of failing closed (ADR-0013: "if Redis cannot answer, the request is denied").
|
|
82
82
|
// Now each command fails after `commandTimeout` (default 2 s, `REDIS_COMMAND_TIMEOUT_MS`) or
|
|
83
83
|
// after one reconnect attempt; every caller treats a Redis error as its fail-closed outcome
|
|
84
|
-
// (401 / 503)
|
|
84
|
+
// (401 / 503). The attempt limiters' increment-first gate (`consume`, Phase 9 #10: login,
|
|
85
|
+
// /auth/mfa, change-password, the code-gated account MFA routes) fails closed too; only
|
|
86
|
+
// the read-only `check` (forgot/reset-password) still fails OPEN (never a lockout blip).
|
|
85
87
|
// Reconnects keep ioredis's default strategy. Nothing in the framework relies on queued
|
|
86
88
|
// commands (no pub/sub, no blocking reads, no MULTI/pipeline).
|
|
87
89
|
return new ioredis_1.default(config.redis.url, {
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
* (default 5/15min, keyed `recovery:<principalId>`).
|
|
7
7
|
*
|
|
8
8
|
* ── CONTRACT ─────────────────────────────────────────────────────────────────
|
|
9
|
+
* consume(key) → Phase 9 #10, the gate for credential-verifying routes (login,
|
|
10
|
+
* /auth/mfa, change-password, the code-gated account MFA routes):
|
|
11
|
+
* atomically COUNTS this attempt first and returns
|
|
12
|
+
* `{ allowed: count <= maxAttempts, count }`. Parallel guesses get
|
|
13
|
+
* distinct counts, so at most `maxAttempts` reach the verifier.
|
|
14
|
+
* FAILS CLOSED: a Redis error throws (caller → 503).
|
|
15
|
+
* refund(key) → give back one consumed attempt that reached no verdict
|
|
16
|
+
* (transient outage; a step that is not a failure). Best-effort.
|
|
9
17
|
* check(key) → `true` if the caller is UNDER the threshold (allowed);
|
|
10
18
|
* `false` if AT/over it (locked out for the window).
|
|
11
19
|
* recordFailure(key) → increment the counter; on the FIRST failure in a window,
|
|
@@ -21,7 +29,10 @@
|
|
|
21
29
|
* reset(key) → clear the counter (a SUCCESS resets — a legitimate user is
|
|
22
30
|
* never penalized for their earlier fat-fingers).
|
|
23
31
|
*
|
|
24
|
-
* ── FAIL-OPEN-WITH-LOG (spec §5
|
|
32
|
+
* ── FAIL-OPEN-WITH-LOG (spec §5) — the read-only `check()` path only ──────────
|
|
33
|
+
* (`consume()` fails CLOSED instead — Phase 9 #10; the routes that use it also need
|
|
34
|
+
* Redis to bind or resolve a session, so failing closed locks no one out who could
|
|
35
|
+
* otherwise have signed in.) `check()` remains for forgot/reset-password, where:
|
|
25
36
|
* A limiter differs from the credential path: a Redis outage must NOT lock out
|
|
26
37
|
* every legitimate login/recovery. So `check()` FAILS OPEN on a Redis error —
|
|
27
38
|
* it returns `true` (allowed) and LOGS the blip. This never grants access on its
|
|
@@ -39,6 +50,29 @@
|
|
|
39
50
|
* `SHIELD_RECOVERY_LIMITER`), each provided with the live Redis + its config.
|
|
40
51
|
*/
|
|
41
52
|
import type Redis from 'ioredis';
|
|
53
|
+
/**
|
|
54
|
+
* Phase 9 #10 — the atomic increment-first step: count the attempt and arm the window TTL
|
|
55
|
+
* in one script, so the counter can never be left without an expiry between two commands.
|
|
56
|
+
* The TTL is also re-armed when the key somehow has none (`PTTL == -1`), so a counter
|
|
57
|
+
* left persistent by an older non-atomic write heals instead of locking forever.
|
|
58
|
+
*/
|
|
59
|
+
export declare const ATTEMPT_CONSUME_SCRIPT = "local n = redis.call('INCR', KEYS[1])\nif n == 1 or redis.call('PTTL', KEYS[1]) == -1 then\n redis.call('PEXPIRE', KEYS[1], ARGV[1])\nend\nreturn n";
|
|
60
|
+
/** Phase 9 #10 — give back one consumed attempt; deletes the key when it reaches zero. */
|
|
61
|
+
export declare const ATTEMPT_REFUND_SCRIPT = "if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end\nlocal n = redis.call('DECR', KEYS[1])\nif n <= 0 then redis.call('DEL', KEYS[1]) end\nreturn n";
|
|
62
|
+
/** The verdict of {@link AttemptLimiter.consume}. */
|
|
63
|
+
export interface AttemptVerdict {
|
|
64
|
+
/** `true` iff this attempt is within the budget (`count <= maxAttempts`). */
|
|
65
|
+
readonly allowed: boolean;
|
|
66
|
+
/** The attempt count in the current window, including this attempt. */
|
|
67
|
+
readonly count: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Thrown by {@link AttemptLimiter.consume} when Redis cannot answer: the gate fails CLOSED
|
|
71
|
+
* and the caller answers a retryable 503. Carries no key or Redis detail.
|
|
72
|
+
*/
|
|
73
|
+
export declare class AttemptLimiterUnavailableError extends Error {
|
|
74
|
+
constructor();
|
|
75
|
+
}
|
|
42
76
|
/** Per-limiter tuning: the failure ceiling and the sliding-window length (ms). */
|
|
43
77
|
export interface AttemptLimiterConfig {
|
|
44
78
|
/** Number of failures within the window that trips the lockout (check → false). */
|
|
@@ -68,6 +102,30 @@ export declare class AttemptLimiter {
|
|
|
68
102
|
* brute-force.
|
|
69
103
|
*/
|
|
70
104
|
check(key: string): Promise<boolean>;
|
|
105
|
+
/**
|
|
106
|
+
* INCREMENT-FIRST attempt gate (Phase 9 #10). Atomically counts THIS attempt (one Lua
|
|
107
|
+
* `INCR`, arming the window TTL on the first hit) and reports whether it is within the
|
|
108
|
+
* budget (`count <= maxAttempts`). Call it BEFORE verifying the credential: N parallel
|
|
109
|
+
* guesses each get a distinct count, so at most `maxAttempts` of them ever reach the
|
|
110
|
+
* verifier (the old check → verify → recordFailure shape let every in-flight guess pass
|
|
111
|
+
* the check). The attempt stays counted when the credential is wrong; on success the
|
|
112
|
+
* caller calls {@link reset}; when the attempt never reached a verdict (a transient
|
|
113
|
+
* outage, or a step that is not a failure) the caller calls {@link refund}.
|
|
114
|
+
*
|
|
115
|
+
* `count === maxAttempts` on a FAILED attempt is the lockout edge (audit-on-the-edge).
|
|
116
|
+
*
|
|
117
|
+
* FAILS CLOSED: a Redis error throws {@link AttemptLimiterUnavailableError} (the caller
|
|
118
|
+
* answers a retryable 503). Unlike {@link check}, an outage never lets an unthrottled
|
|
119
|
+
* guess through.
|
|
120
|
+
*/
|
|
121
|
+
consume(key: string): Promise<AttemptVerdict>;
|
|
122
|
+
/**
|
|
123
|
+
* Give back one attempt taken by {@link consume} that never reached a verdict (a
|
|
124
|
+
* transient dependency failure, or a step that is neither failure nor success, e.g. a
|
|
125
|
+
* password step that now needs a second factor). Removes the key when it reaches zero,
|
|
126
|
+
* so a refunded first attempt leaves no counter behind. Best-effort, never throws.
|
|
127
|
+
*/
|
|
128
|
+
refund(key: string): Promise<void>;
|
|
71
129
|
/**
|
|
72
130
|
* Increment the failure counter; set the window TTL on the FIRST failure so the
|
|
73
131
|
* counter auto-expires after `windowMs` of inactivity. Best-effort — a write we
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attempt-limiter.d.ts","sourceRoot":"","sources":["../../../src/auth/rate-limit/attempt-limiter.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"attempt-limiter.d.ts","sourceRoot":"","sources":["../../../src/auth/rate-limit/attempt-limiter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAGH,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAEjC;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,yJAI1B,CAAC;AAEV,0FAA0F;AAC1F,eAAO,MAAM,qBAAqB,4JAGzB,CAAC;AAEV,qDAAqD;AACrD,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,uEAAuE;IACvE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,qBAAa,8BAA+B,SAAQ,KAAK;;CAKxD;AAED,kFAAkF;AAClF,MAAM,WAAW,oBAAoB;IACnC,mFAAmF;IACnF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,8EAA8E;IAC9E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,qBAAa,cAAc;IAKvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,GAAG;IALtB,oFAAoF;IACpF,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAmC;gBAG9C,KAAK,EAAE,KAAK,EACZ,GAAG,EAAE,oBAAoB;IAG5C;;;;;OAKG;IACH,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,iFAAiF;IACjF,OAAO,CAAC,CAAC;IAIT;;;;;OAKG;IACG,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAc1C;;;;;;;;;;;;;;;OAeG;IACG,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAmBnD;;;;;OAKG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAUxC;;;;;;;;;;;OAWG;IACG,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAmBjD;;;;OAIG;IACG,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAOxC"}
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* (default 5/15min, keyed `recovery:<principalId>`).
|
|
8
8
|
*
|
|
9
9
|
* ── CONTRACT ─────────────────────────────────────────────────────────────────
|
|
10
|
+
* consume(key) → Phase 9 #10, the gate for credential-verifying routes (login,
|
|
11
|
+
* /auth/mfa, change-password, the code-gated account MFA routes):
|
|
12
|
+
* atomically COUNTS this attempt first and returns
|
|
13
|
+
* `{ allowed: count <= maxAttempts, count }`. Parallel guesses get
|
|
14
|
+
* distinct counts, so at most `maxAttempts` reach the verifier.
|
|
15
|
+
* FAILS CLOSED: a Redis error throws (caller → 503).
|
|
16
|
+
* refund(key) → give back one consumed attempt that reached no verdict
|
|
17
|
+
* (transient outage; a step that is not a failure). Best-effort.
|
|
10
18
|
* check(key) → `true` if the caller is UNDER the threshold (allowed);
|
|
11
19
|
* `false` if AT/over it (locked out for the window).
|
|
12
20
|
* recordFailure(key) → increment the counter; on the FIRST failure in a window,
|
|
@@ -22,7 +30,10 @@
|
|
|
22
30
|
* reset(key) → clear the counter (a SUCCESS resets — a legitimate user is
|
|
23
31
|
* never penalized for their earlier fat-fingers).
|
|
24
32
|
*
|
|
25
|
-
* ── FAIL-OPEN-WITH-LOG (spec §5
|
|
33
|
+
* ── FAIL-OPEN-WITH-LOG (spec §5) — the read-only `check()` path only ──────────
|
|
34
|
+
* (`consume()` fails CLOSED instead — Phase 9 #10; the routes that use it also need
|
|
35
|
+
* Redis to bind or resolve a session, so failing closed locks no one out who could
|
|
36
|
+
* otherwise have signed in.) `check()` remains for forgot/reset-password, where:
|
|
26
37
|
* A limiter differs from the credential path: a Redis outage must NOT lock out
|
|
27
38
|
* every legitimate login/recovery. So `check()` FAILS OPEN on a Redis error —
|
|
28
39
|
* it returns `true` (allowed) and LOGS the blip. This never grants access on its
|
|
@@ -40,8 +51,35 @@
|
|
|
40
51
|
* `SHIELD_RECOVERY_LIMITER`), each provided with the live Redis + its config.
|
|
41
52
|
*/
|
|
42
53
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
-
exports.AttemptLimiter = void 0;
|
|
54
|
+
exports.AttemptLimiter = exports.AttemptLimiterUnavailableError = exports.ATTEMPT_REFUND_SCRIPT = exports.ATTEMPT_CONSUME_SCRIPT = void 0;
|
|
44
55
|
const common_1 = require("@nestjs/common");
|
|
56
|
+
/**
|
|
57
|
+
* Phase 9 #10 — the atomic increment-first step: count the attempt and arm the window TTL
|
|
58
|
+
* in one script, so the counter can never be left without an expiry between two commands.
|
|
59
|
+
* The TTL is also re-armed when the key somehow has none (`PTTL == -1`), so a counter
|
|
60
|
+
* left persistent by an older non-atomic write heals instead of locking forever.
|
|
61
|
+
*/
|
|
62
|
+
exports.ATTEMPT_CONSUME_SCRIPT = `local n = redis.call('INCR', KEYS[1])
|
|
63
|
+
if n == 1 or redis.call('PTTL', KEYS[1]) == -1 then
|
|
64
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
|
65
|
+
end
|
|
66
|
+
return n`;
|
|
67
|
+
/** Phase 9 #10 — give back one consumed attempt; deletes the key when it reaches zero. */
|
|
68
|
+
exports.ATTEMPT_REFUND_SCRIPT = `if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
|
|
69
|
+
local n = redis.call('DECR', KEYS[1])
|
|
70
|
+
if n <= 0 then redis.call('DEL', KEYS[1]) end
|
|
71
|
+
return n`;
|
|
72
|
+
/**
|
|
73
|
+
* Thrown by {@link AttemptLimiter.consume} when Redis cannot answer: the gate fails CLOSED
|
|
74
|
+
* and the caller answers a retryable 503. Carries no key or Redis detail.
|
|
75
|
+
*/
|
|
76
|
+
class AttemptLimiterUnavailableError extends Error {
|
|
77
|
+
constructor() {
|
|
78
|
+
super('attempt limiter unavailable');
|
|
79
|
+
this.name = 'AttemptLimiterUnavailableError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
exports.AttemptLimiterUnavailableError = AttemptLimiterUnavailableError;
|
|
45
83
|
class AttemptLimiter {
|
|
46
84
|
redis;
|
|
47
85
|
cfg;
|
|
@@ -82,6 +120,51 @@ class AttemptLimiter {
|
|
|
82
120
|
return true;
|
|
83
121
|
}
|
|
84
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* INCREMENT-FIRST attempt gate (Phase 9 #10). Atomically counts THIS attempt (one Lua
|
|
125
|
+
* `INCR`, arming the window TTL on the first hit) and reports whether it is within the
|
|
126
|
+
* budget (`count <= maxAttempts`). Call it BEFORE verifying the credential: N parallel
|
|
127
|
+
* guesses each get a distinct count, so at most `maxAttempts` of them ever reach the
|
|
128
|
+
* verifier (the old check → verify → recordFailure shape let every in-flight guess pass
|
|
129
|
+
* the check). The attempt stays counted when the credential is wrong; on success the
|
|
130
|
+
* caller calls {@link reset}; when the attempt never reached a verdict (a transient
|
|
131
|
+
* outage, or a step that is not a failure) the caller calls {@link refund}.
|
|
132
|
+
*
|
|
133
|
+
* `count === maxAttempts` on a FAILED attempt is the lockout edge (audit-on-the-edge).
|
|
134
|
+
*
|
|
135
|
+
* FAILS CLOSED: a Redis error throws {@link AttemptLimiterUnavailableError} (the caller
|
|
136
|
+
* answers a retryable 503). Unlike {@link check}, an outage never lets an unthrottled
|
|
137
|
+
* guess through.
|
|
138
|
+
*/
|
|
139
|
+
async consume(key) {
|
|
140
|
+
let n;
|
|
141
|
+
try {
|
|
142
|
+
n = Number(await this.redis.eval(exports.ATTEMPT_CONSUME_SCRIPT, 1, this.k(key), String(this.cfg.windowMs)));
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
AttemptLimiter.logger.warn(`attempt-limiter consume failed closed (denying) — Redis unavailable: ${describeError(err)}`);
|
|
146
|
+
throw new AttemptLimiterUnavailableError();
|
|
147
|
+
}
|
|
148
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
149
|
+
// An answer that is not a count cannot prove the attempt is within budget.
|
|
150
|
+
throw new AttemptLimiterUnavailableError();
|
|
151
|
+
}
|
|
152
|
+
return { allowed: n <= this.cfg.maxAttempts, count: n };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Give back one attempt taken by {@link consume} that never reached a verdict (a
|
|
156
|
+
* transient dependency failure, or a step that is neither failure nor success, e.g. a
|
|
157
|
+
* password step that now needs a second factor). Removes the key when it reaches zero,
|
|
158
|
+
* so a refunded first attempt leaves no counter behind. Best-effort, never throws.
|
|
159
|
+
*/
|
|
160
|
+
async refund(key) {
|
|
161
|
+
try {
|
|
162
|
+
await this.redis.eval(exports.ATTEMPT_REFUND_SCRIPT, 1, this.k(key));
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
AttemptLimiter.logger.warn(`attempt-limiter refund best-effort miss — Redis unavailable: ${describeError(err)}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
85
168
|
/**
|
|
86
169
|
* Increment the failure counter; set the window TTL on the FIRST failure so the
|
|
87
170
|
* counter auto-expires after `windowMs` of inactivity. Best-effort — a write we
|