@warlock.js/auth 4.2.11 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -56
- package/LICENSE +21 -21
- package/README.md +78 -78
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/middleware/auth.middleware.mjs.map +1 -1
- package/esm/middleware/login-throttle.middleware.mjs.map +1 -1
- package/esm/models/access-token/access-token.model.mjs.map +1 -1
- package/esm/models/access-token/migration.mjs.map +1 -1
- package/esm/models/refresh-token/migration.mjs.map +1 -1
- package/esm/models/refresh-token/refresh-token.model.mjs.map +1 -1
- package/esm/services/auth-config.mjs.map +1 -1
- package/esm/services/auth-events.mjs.map +1 -1
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/utils/auth-error-codes.mjs.map +1 -1
- package/llms-full.txt +1272 -1272
- package/llms.txt +18 -18
- package/package.json +7 -7
- package/skills/auth-basics/SKILL.md +89 -89
- package/skills/customize-token-storage/SKILL.md +112 -112
- package/skills/customize-user-type/SKILL.md +142 -142
- package/skills/handle-login-and-logout/SKILL.md +160 -160
- package/skills/manage-tokens/SKILL.md +169 -169
- package/skills/overview/SKILL.md +72 -72
- package/skills/protect-routes/SKILL.md +106 -106
- package/skills/run-auth-commands/SKILL.md +125 -125
- package/skills/throttle-login-attempts/SKILL.md +116 -116
|
@@ -1,116 +1,116 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: throttle-login-attempts
|
|
3
|
-
description: 'Brute-force / credential-stuffing protection via `loginThrottleMiddleware` — a failure-aware route gate that counts only failed logins (resets on success), locks per-account and per-source after a threshold, and rejects pre-controller with 429 so the DB lookup and bcrypt verify are skipped. Cache-backed (shared across replicas), fixed-window, fails open on a cache outage. Triggers: `loginThrottleMiddleware`, `AuthErrorCodes.TooManyAttempts`, `EC004`, "rate limit login", "brute force protection", "lock account after failed logins", "throttle login attempts", "too many login attempts 429"; typical import `import { loginThrottleMiddleware } from "@warlock.js/auth"`. Skip: generic per-route request rate limiting that counts every request (use core `middleware.rateLimit`); gating a route by auth — `@warlock.js/auth/protect-routes/SKILL.md`; issuing tokens — `@warlock.js/auth/handle-login-and-logout/SKILL.md`.'
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Throttle login attempts with `loginThrottleMiddleware`
|
|
7
|
-
|
|
8
|
-
`loginThrottleMiddleware(options?)` returns a Warlock middleware that defends the login (and refresh / password-reset) routes against brute-force and credential-stuffing — without punishing a user who fat-fingers a password then gets it right.
|
|
9
|
-
|
|
10
|
-
The trick that makes it different from a plain rate limiter: it's **failure-aware**. It hooks the response *after* your controller runs (`response.onSent`), counts only the attempts that actually failed, and **clears the counter the moment a login succeeds**. A generic request limiter can't do that — it runs before the controller and counts every hit, success or not.
|
|
11
|
-
|
|
12
|
-
## The shortest version
|
|
13
|
-
|
|
14
|
-
```ts
|
|
15
|
-
import { loginThrottleMiddleware } from "@warlock.js/auth";
|
|
16
|
-
|
|
17
|
-
router.post("/auth/login", loginController, {
|
|
18
|
-
middleware: [loginThrottleMiddleware()], // 5 failures / 15m → 15m lockout, per email + ip
|
|
19
|
-
});
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
That's the whole feature for most apps. Defaults: **5** failed attempts within a **15m** window trips a **15m** lockout, tracked independently per account (the `email` field) **and** per source IP.
|
|
23
|
-
|
|
24
|
-
> **Heads-up — account-lockout is a DoS surface.** Because the default tracks per-`email`, an attacker who knows a victim's address can lock them out by spamming failed logins. The per-IP counter mitigates it (the attacker's own IP trips too); for purely anonymous endpoints prefer `by: ["ip"]`, and for high-value accounts consider a CAPTCHA step over a hard lock. See [Gotchas](#gotchas).
|
|
25
|
-
|
|
26
|
-
## How it behaves
|
|
27
|
-
|
|
28
|
-
1. **Before the controller** — if the account or the IP is currently locked, it short-circuits with `429` and never touches the database or the bcrypt verify (this is also what neutralises the CPU-DoS angle of brute-forcing). The body carries `AuthErrorCodes.TooManyAttempts` (`EC004`).
|
|
29
|
-
2. **After the controller** — it inspects the outcome. A non-2xx response is a failed login: it bumps a fixed-window counter for each tracked identifier, and once a counter reaches `max` it writes a lock key. A 2xx response is a success: it **clears** the counter and lock for that identifier.
|
|
30
|
-
|
|
31
|
-
Per-account tracking stops a password-spray against one user from many IPs; per-source tracking stops one IP from stuffing many accounts. A lock on **either** identifier rejects the request — defense-in-depth.
|
|
32
|
-
|
|
33
|
-
## Options
|
|
34
|
-
|
|
35
|
-
```ts
|
|
36
|
-
loginThrottleMiddleware({
|
|
37
|
-
max: 5, // failures allowed in the window before lockout
|
|
38
|
-
window: "15m", // counting window — ms-format string or seconds (number)
|
|
39
|
-
lockoutDuration: "15m", // how long the lock lasts once tripped
|
|
40
|
-
by: ["email", "ip"], // identifiers tracked, each independently
|
|
41
|
-
identifierKey: "email", // which credential field is the account key
|
|
42
|
-
errorMessage: "Too many attempts. Try again later.",
|
|
43
|
-
});
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Two escape hatches for non-standard setups:
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
loginThrottleMiddleware({
|
|
50
|
-
// your controller signals failure with a 200 body instead of a status code
|
|
51
|
-
isFailure: (response) => response.parsedBody?.ok === false,
|
|
52
|
-
|
|
53
|
-
// derive the account key from somewhere other than email/ip
|
|
54
|
-
identify: (request) => [`tenant.${request.input("tenant")}.${request.input("username")}`],
|
|
55
|
-
});
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
| Option | Default | Purpose |
|
|
59
|
-
| --- | --- | --- |
|
|
60
|
-
| `max` | `5` | Failures within the window before lockout |
|
|
61
|
-
| `window` | `"15m"` | Counting window (`ms`-string or seconds) |
|
|
62
|
-
| `lockoutDuration` | `"15m"` | Lock TTL once tripped |
|
|
63
|
-
| `by` | `["email", "ip"]` | Identifiers tracked independently |
|
|
64
|
-
| `identifierKey` | `"email"` | Credential field used as the account key |
|
|
65
|
-
| `errorMessage` | i18n `auth.errors.tooManyAttempts` | 429 message override |
|
|
66
|
-
| `isFailure` | `(res) => !res.isOk` | What counts as a failed attempt |
|
|
67
|
-
| `identify` | built-in email + ip extraction | Custom identifier list |
|
|
68
|
-
|
|
69
|
-
## The 429 response
|
|
70
|
-
|
|
71
|
-
On lockout the middleware sends:
|
|
72
|
-
|
|
73
|
-
```jsonc
|
|
74
|
-
{ "error": "...", "errorCode": "EC004" } // AuthErrorCodes.TooManyAttempts
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Define the `auth.errors.tooManyAttempts` translation key (or pass `errorMessage`) so clients see a real message instead of the raw key. Map `EC004` in your error transformer the same way you map the other `AuthErrorCodes`.
|
|
78
|
-
|
|
79
|
-
## Beyond login
|
|
80
|
-
|
|
81
|
-
The middleware isn't login-specific — drop it on any route where repeated failures should lock something out. For the refresh and reset endpoints there's no email in the body, so track by IP only:
|
|
82
|
-
|
|
83
|
-
```ts
|
|
84
|
-
router.post("/auth/refresh-token", refreshController, {
|
|
85
|
-
middleware: [loginThrottleMiddleware({ by: ["ip"], max: 10, window: "1m" })],
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
router.post("/auth/forgot-password", forgotController, {
|
|
89
|
-
middleware: [loginThrottleMiddleware({ max: 3, window: "1h", lockoutDuration: "1h" })],
|
|
90
|
-
});
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
## When to use this vs core's `rateLimit`
|
|
94
|
-
|
|
95
|
-
| | `loginThrottleMiddleware` (auth) | `middleware.rateLimit` (core) |
|
|
96
|
-
| --- | --- | --- |
|
|
97
|
-
| Counts | only **failed** logins | **every** request |
|
|
98
|
-
| Resets on a successful login | yes | no |
|
|
99
|
-
| Locks per account / per IP | yes | per key (default IP) |
|
|
100
|
-
| Storage | `@warlock.js/cache` (shared across replicas) | in-process map |
|
|
101
|
-
|
|
102
|
-
They compose. For a hard ceiling on request volume *and* failure-aware account lockout, stack both: `middleware: [middleware.rateLimit({ max: 30, duration: 60_000 }), loginThrottleMiddleware()]`.
|
|
103
|
-
|
|
104
|
-
## Gotchas
|
|
105
|
-
|
|
106
|
-
- **Needs an initialised cache driver.** Storage is `@warlock.js/cache` (a peer dep, transitively present via core). If the driver isn't configured at runtime, the middleware **fails open** — it logs and lets the request through, because a throttle outage must never become an auth outage. That also means: no cache, no protection. Verify your cache config in production.
|
|
107
|
-
- **Account lockout can be weaponised.** An attacker who knows a victim's email can lock them out by spamming failures. The per-IP counter mitigates this (the attacker's IP locks too), but for purely anonymous endpoints prefer `by: ["ip"]`. For high-value accounts, consider a CAPTCHA step instead of a hard lock.
|
|
108
|
-
- **Fixed window, not sliding.** The window is anchored at the first failure and does not extend on each subsequent one — predictable lockout timing. The lock key is independent and always lasts `lockoutDuration`.
|
|
109
|
-
- **Defense-in-depth, not a WAF.** This is application-layer. It won't stop a volumetric L3/L4 flood — pair it with an edge/CDN rate limit for that.
|
|
110
|
-
- **Don't call it per-request.** Like every middleware, call it once at route definition; it returns the function that runs per request.
|
|
111
|
-
|
|
112
|
-
## See also
|
|
113
|
-
|
|
114
|
-
- [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — gate a route behind a valid token (`authMiddleware`); stack the throttle in front of it on login routes.
|
|
115
|
-
- [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — the login flow whose failures this middleware counts.
|
|
116
|
-
- [`@warlock.js/core/use-middleware/SKILL.md`](@warlock.js/core/use-middleware/SKILL.md) — `middleware.rateLimit` and the built-in middleware suite.
|
|
1
|
+
---
|
|
2
|
+
name: throttle-login-attempts
|
|
3
|
+
description: 'Brute-force / credential-stuffing protection via `loginThrottleMiddleware` — a failure-aware route gate that counts only failed logins (resets on success), locks per-account and per-source after a threshold, and rejects pre-controller with 429 so the DB lookup and bcrypt verify are skipped. Cache-backed (shared across replicas), fixed-window, fails open on a cache outage. Triggers: `loginThrottleMiddleware`, `AuthErrorCodes.TooManyAttempts`, `EC004`, "rate limit login", "brute force protection", "lock account after failed logins", "throttle login attempts", "too many login attempts 429"; typical import `import { loginThrottleMiddleware } from "@warlock.js/auth"`. Skip: generic per-route request rate limiting that counts every request (use core `middleware.rateLimit`); gating a route by auth — `@warlock.js/auth/protect-routes/SKILL.md`; issuing tokens — `@warlock.js/auth/handle-login-and-logout/SKILL.md`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Throttle login attempts with `loginThrottleMiddleware`
|
|
7
|
+
|
|
8
|
+
`loginThrottleMiddleware(options?)` returns a Warlock middleware that defends the login (and refresh / password-reset) routes against brute-force and credential-stuffing — without punishing a user who fat-fingers a password then gets it right.
|
|
9
|
+
|
|
10
|
+
The trick that makes it different from a plain rate limiter: it's **failure-aware**. It hooks the response *after* your controller runs (`response.onSent`), counts only the attempts that actually failed, and **clears the counter the moment a login succeeds**. A generic request limiter can't do that — it runs before the controller and counts every hit, success or not.
|
|
11
|
+
|
|
12
|
+
## The shortest version
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { loginThrottleMiddleware } from "@warlock.js/auth";
|
|
16
|
+
|
|
17
|
+
router.post("/auth/login", loginController, {
|
|
18
|
+
middleware: [loginThrottleMiddleware()], // 5 failures / 15m → 15m lockout, per email + ip
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That's the whole feature for most apps. Defaults: **5** failed attempts within a **15m** window trips a **15m** lockout, tracked independently per account (the `email` field) **and** per source IP.
|
|
23
|
+
|
|
24
|
+
> **Heads-up — account-lockout is a DoS surface.** Because the default tracks per-`email`, an attacker who knows a victim's address can lock them out by spamming failed logins. The per-IP counter mitigates it (the attacker's own IP trips too); for purely anonymous endpoints prefer `by: ["ip"]`, and for high-value accounts consider a CAPTCHA step over a hard lock. See [Gotchas](#gotchas).
|
|
25
|
+
|
|
26
|
+
## How it behaves
|
|
27
|
+
|
|
28
|
+
1. **Before the controller** — if the account or the IP is currently locked, it short-circuits with `429` and never touches the database or the bcrypt verify (this is also what neutralises the CPU-DoS angle of brute-forcing). The body carries `AuthErrorCodes.TooManyAttempts` (`EC004`).
|
|
29
|
+
2. **After the controller** — it inspects the outcome. A non-2xx response is a failed login: it bumps a fixed-window counter for each tracked identifier, and once a counter reaches `max` it writes a lock key. A 2xx response is a success: it **clears** the counter and lock for that identifier.
|
|
30
|
+
|
|
31
|
+
Per-account tracking stops a password-spray against one user from many IPs; per-source tracking stops one IP from stuffing many accounts. A lock on **either** identifier rejects the request — defense-in-depth.
|
|
32
|
+
|
|
33
|
+
## Options
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
loginThrottleMiddleware({
|
|
37
|
+
max: 5, // failures allowed in the window before lockout
|
|
38
|
+
window: "15m", // counting window — ms-format string or seconds (number)
|
|
39
|
+
lockoutDuration: "15m", // how long the lock lasts once tripped
|
|
40
|
+
by: ["email", "ip"], // identifiers tracked, each independently
|
|
41
|
+
identifierKey: "email", // which credential field is the account key
|
|
42
|
+
errorMessage: "Too many attempts. Try again later.",
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Two escape hatches for non-standard setups:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
loginThrottleMiddleware({
|
|
50
|
+
// your controller signals failure with a 200 body instead of a status code
|
|
51
|
+
isFailure: (response) => response.parsedBody?.ok === false,
|
|
52
|
+
|
|
53
|
+
// derive the account key from somewhere other than email/ip
|
|
54
|
+
identify: (request) => [`tenant.${request.input("tenant")}.${request.input("username")}`],
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Option | Default | Purpose |
|
|
59
|
+
| --- | --- | --- |
|
|
60
|
+
| `max` | `5` | Failures within the window before lockout |
|
|
61
|
+
| `window` | `"15m"` | Counting window (`ms`-string or seconds) |
|
|
62
|
+
| `lockoutDuration` | `"15m"` | Lock TTL once tripped |
|
|
63
|
+
| `by` | `["email", "ip"]` | Identifiers tracked independently |
|
|
64
|
+
| `identifierKey` | `"email"` | Credential field used as the account key |
|
|
65
|
+
| `errorMessage` | i18n `auth.errors.tooManyAttempts` | 429 message override |
|
|
66
|
+
| `isFailure` | `(res) => !res.isOk` | What counts as a failed attempt |
|
|
67
|
+
| `identify` | built-in email + ip extraction | Custom identifier list |
|
|
68
|
+
|
|
69
|
+
## The 429 response
|
|
70
|
+
|
|
71
|
+
On lockout the middleware sends:
|
|
72
|
+
|
|
73
|
+
```jsonc
|
|
74
|
+
{ "error": "...", "errorCode": "EC004" } // AuthErrorCodes.TooManyAttempts
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Define the `auth.errors.tooManyAttempts` translation key (or pass `errorMessage`) so clients see a real message instead of the raw key. Map `EC004` in your error transformer the same way you map the other `AuthErrorCodes`.
|
|
78
|
+
|
|
79
|
+
## Beyond login
|
|
80
|
+
|
|
81
|
+
The middleware isn't login-specific — drop it on any route where repeated failures should lock something out. For the refresh and reset endpoints there's no email in the body, so track by IP only:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
router.post("/auth/refresh-token", refreshController, {
|
|
85
|
+
middleware: [loginThrottleMiddleware({ by: ["ip"], max: 10, window: "1m" })],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
router.post("/auth/forgot-password", forgotController, {
|
|
89
|
+
middleware: [loginThrottleMiddleware({ max: 3, window: "1h", lockoutDuration: "1h" })],
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## When to use this vs core's `rateLimit`
|
|
94
|
+
|
|
95
|
+
| | `loginThrottleMiddleware` (auth) | `middleware.rateLimit` (core) |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| Counts | only **failed** logins | **every** request |
|
|
98
|
+
| Resets on a successful login | yes | no |
|
|
99
|
+
| Locks per account / per IP | yes | per key (default IP) |
|
|
100
|
+
| Storage | `@warlock.js/cache` (shared across replicas) | in-process map |
|
|
101
|
+
|
|
102
|
+
They compose. For a hard ceiling on request volume *and* failure-aware account lockout, stack both: `middleware: [middleware.rateLimit({ max: 30, duration: 60_000 }), loginThrottleMiddleware()]`.
|
|
103
|
+
|
|
104
|
+
## Gotchas
|
|
105
|
+
|
|
106
|
+
- **Needs an initialised cache driver.** Storage is `@warlock.js/cache` (a peer dep, transitively present via core). If the driver isn't configured at runtime, the middleware **fails open** — it logs and lets the request through, because a throttle outage must never become an auth outage. That also means: no cache, no protection. Verify your cache config in production.
|
|
107
|
+
- **Account lockout can be weaponised.** An attacker who knows a victim's email can lock them out by spamming failures. The per-IP counter mitigates this (the attacker's IP locks too), but for purely anonymous endpoints prefer `by: ["ip"]`. For high-value accounts, consider a CAPTCHA step instead of a hard lock.
|
|
108
|
+
- **Fixed window, not sliding.** The window is anchored at the first failure and does not extend on each subsequent one — predictable lockout timing. The lock key is independent and always lasts `lockoutDuration`.
|
|
109
|
+
- **Defense-in-depth, not a WAF.** This is application-layer. It won't stop a volumetric L3/L4 flood — pair it with an edge/CDN rate limit for that.
|
|
110
|
+
- **Don't call it per-request.** Like every middleware, call it once at route definition; it returns the function that runs per request.
|
|
111
|
+
|
|
112
|
+
## See also
|
|
113
|
+
|
|
114
|
+
- [`@warlock.js/auth/protect-routes/SKILL.md`](@warlock.js/auth/protect-routes/SKILL.md) — gate a route behind a valid token (`authMiddleware`); stack the throttle in front of it on login routes.
|
|
115
|
+
- [`@warlock.js/auth/handle-login-and-logout/SKILL.md`](@warlock.js/auth/handle-login-and-logout/SKILL.md) — the login flow whose failures this middleware counts.
|
|
116
|
+
- [`@warlock.js/core/use-middleware/SKILL.md`](@warlock.js/core/use-middleware/SKILL.md) — `middleware.rateLimit` and the built-in middleware suite.
|