@warlock.js/auth 5.23.2 → 5.25.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 +370 -357
- package/LICENSE +21 -21
- package/esm/contracts/types.mjs.map +1 -1
- package/esm/middleware/auth.middleware.mjs +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/auth-token-family/auth-token-family.model.d.mts.map +1 -1
- package/esm/models/auth-token-family/auth-token-family.model.mjs +5 -0
- package/esm/models/auth-token-family/auth-token-family.model.mjs.map +1 -1
- package/esm/models/auth.model.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.d.mts.map +1 -1
- package/esm/services/auth.service.mjs +10 -9
- package/esm/services/auth.service.mjs.map +1 -1
- package/esm/utils/auth-error-codes.mjs.map +1 -1
- package/llms-full.txt +1927 -1927
- package/package.json +8 -8
- package/skills/auth-basics/SKILL.md +1 -1
- package/skills/customize-token-storage/SKILL.md +160 -160
- package/skills/customize-user-type/SKILL.md +168 -168
- package/skills/handle-login-and-logout/SKILL.md +179 -179
- package/skills/login-with-providers/SKILL.md +283 -283
- package/skills/manage-tokens/SKILL.md +196 -196
- package/skills/overview/SKILL.md +74 -74
- package/skills/protect-routes/SKILL.md +299 -299
- package/skills/register-user/SKILL.md +147 -147
- package/skills/run-auth-commands/SKILL.md +159 -159
- package/skills/throttle-login-attempts/SKILL.md +117 -117
- package/skills/verify-email-and-reset-password/SKILL.md +137 -137
|
@@ -1,117 +1,117 @@
|
|
|
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
|
-
`@warlock.js/auth` ships built-in `en` and `ar` text for `auth.errors.tooManyAttempts` (and every other `auth.errors.*` key it uses); define the key in your own locale files, or pass `errorMessage`, to override it. 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
|
-
- **Counts are atomic and exact across servers (redis/pg).** Each failure does a create-only `cache.set(key, 0, { ttl: window })` (only the first failure opens the window and sets the TTL) followed by an atomic `cache.increment(key)`, so concurrent failures on different servers are never lost. The memory cache driver is per-process — counts and locks are then per-process, so use redis/pg when running more than one instance.
|
|
109
|
-
- **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`.
|
|
110
|
-
- **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.
|
|
111
|
-
- **Don't call it per-request.** Like every middleware, call it once at route definition; it returns the function that runs per request.
|
|
112
|
-
|
|
113
|
-
## See also
|
|
114
|
-
|
|
115
|
-
- [`@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.
|
|
116
|
-
- [`@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.
|
|
117
|
-
- [`@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
|
+
`@warlock.js/auth` ships built-in `en` and `ar` text for `auth.errors.tooManyAttempts` (and every other `auth.errors.*` key it uses); define the key in your own locale files, or pass `errorMessage`, to override it. 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
|
+
- **Counts are atomic and exact across servers (redis/pg).** Each failure does a create-only `cache.set(key, 0, { ttl: window })` (only the first failure opens the window and sets the TTL) followed by an atomic `cache.increment(key)`, so concurrent failures on different servers are never lost. The memory cache driver is per-process — counts and locks are then per-process, so use redis/pg when running more than one instance.
|
|
109
|
+
- **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`.
|
|
110
|
+
- **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.
|
|
111
|
+
- **Don't call it per-request.** Like every middleware, call it once at route definition; it returns the function that runs per request.
|
|
112
|
+
|
|
113
|
+
## See also
|
|
114
|
+
|
|
115
|
+
- [`@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.
|
|
116
|
+
- [`@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.
|
|
117
|
+
- [`@warlock.js/core/use-middleware/SKILL.md`](@warlock.js/core/use-middleware/SKILL.md) — `middleware.rateLimit` and the built-in middleware suite.
|
|
@@ -1,137 +1,137 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: verify-email-and-reset-password
|
|
3
|
-
description: 'Email verification and password reset with hashed, single-use, expiring, purpose-bound tokens delivered through `@warlock.js/notifications` — `sendEmailVerification(user)`, `verifyEmail(token)`, `requestPasswordReset(Model, email)`, `resetPassword(token, newPassword)`, the `requireVerifiedEmail()` guard, and the `tokenIssueThrottleMiddleware` / `tokenConsumeThrottleMiddleware` presets. Reset revokes every access token, refresh token and cookie session; a reset request answers the same for unknown emails. Triggers: `sendEmailVerification`, `verifyEmail`, `requestPasswordReset`, `resetPassword`, `requireVerifiedEmail`, `isEmailVerified`, `OneTimeToken`, `InvalidOneTimeTokenError`, `EmailNotVerifiedError`, `NotificationsUnavailableError`, `EC007`, `EC008`, `auth.verification`, `auth.passwordReset`; "verify email", "confirm email address", "forgot password", "reset password link", "block unverified users", "resend verification email"; typical import `import { requestPasswordReset, resetPassword } from "@warlock.js/auth"`. Skip: sign-up itself — `@warlock.js/auth/register-user/SKILL.md`; login throttling — `@warlock.js/auth/throttle-login-attempts/SKILL.md`; defining notifications — `@warlock.js/notifications/define-notification/SKILL.md`.'
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Verify email + reset password
|
|
7
|
-
|
|
8
|
-
Four service functions, one guard, two throttle presets. Auth ships no controllers — wire the routes yourself (examples below).
|
|
9
|
-
|
|
10
|
-
## Prerequisites
|
|
11
|
-
|
|
12
|
-
1. **`@warlock.js/notifications` installed and configured with a `mail` channel.** It is an optional peer; without it (or without `src/config/notifications.ts`) every send/request call throws `NotificationsUnavailableError` **before** a token is issued — never a silent skip.
|
|
13
|
-
2. **The `one_time_tokens` table.** It ships in `authMigrations` next to the access/refresh tables; run your migrations.
|
|
14
|
-
3. **A verified-date field on the user schema** (default `emailVerifiedAt`):
|
|
15
|
-
|
|
16
|
-
```ts
|
|
17
|
-
export const userSchema = v.object({
|
|
18
|
-
// ...
|
|
19
|
-
emailVerifiedAt: v.date().optional(),
|
|
20
|
-
});
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
If the schema strips it, `verifyEmail` throws naming the field instead of pretending it worked.
|
|
24
|
-
|
|
25
|
-
## Tokens
|
|
26
|
-
|
|
27
|
-
- 32 random bytes, base64url. Only the **SHA-256 hash** is stored; the raw token exists only in the notification.
|
|
28
|
-
- **Single use**, consumed with a conditional update — of two concurrent uses, exactly one succeeds.
|
|
29
|
-
- **Expiring**: verification `24h`, reset `60m` by default.
|
|
30
|
-
- **Purpose-bound**: a verification token is never accepted by `resetPassword`, and vice versa.
|
|
31
|
-
- A new reset request **invalidates the user's earlier unused reset tokens**. Verification tokens are not invalidated by a resend.
|
|
32
|
-
- Unknown, wrong-purpose, expired and used tokens all throw one `InvalidOneTimeTokenError` (`400`, `EC008`).
|
|
33
|
-
|
|
34
|
-
## Email verification
|
|
35
|
-
|
|
36
|
-
```ts
|
|
37
|
-
import { requireVerifiedEmail, sendEmailVerification, verifyEmail } from "@warlock.js/auth";
|
|
38
|
-
import { type RequestHandler } from "@warlock.js/core";
|
|
39
|
-
|
|
40
|
-
// after User.create(...) in your register controller
|
|
41
|
-
await sendEmailVerification(user);
|
|
42
|
-
|
|
43
|
-
export const verifyEmailController: RequestHandler = async ({ request, response }) => {
|
|
44
|
-
const user = await verifyEmail(request.input("token")); // throws InvalidOneTimeTokenError
|
|
45
|
-
return response.success({ user });
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
router.post("/auth/verify-email", verifyEmailController, {
|
|
49
|
-
middleware: [tokenConsumeThrottleMiddleware()],
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
router.post("/auth/verify-email/resend", resendController, {
|
|
53
|
-
middleware: [authMiddleware("user"), tokenIssueThrottleMiddleware({ by: ["ip"] })],
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// Opt-in guard, AFTER authMiddleware: 403 EmailNotVerifiedError (EC007)
|
|
57
|
-
router.post("/orders", createOrder, {
|
|
58
|
-
middleware: [authMiddleware("user"), requireVerifiedEmail()],
|
|
59
|
-
});
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
`isEmailVerified(user)` answers the same question in code.
|
|
63
|
-
|
|
64
|
-
## Password reset
|
|
65
|
-
|
|
66
|
-
```ts
|
|
67
|
-
import { requestPasswordReset, resetPassword } from "@warlock.js/auth";
|
|
68
|
-
|
|
69
|
-
export const forgotPasswordController: RequestHandler = async ({ request, response }) => {
|
|
70
|
-
await requestPasswordReset(User, request.input("email"));
|
|
71
|
-
// Same answer whether or not the account exists.
|
|
72
|
-
return response.success({ message: "If that account exists, we sent a reset link." });
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
export const resetPasswordController: RequestHandler = async ({ request, response }) => {
|
|
76
|
-
// Validate strength FIRST — the token is consumed before the password is written.
|
|
77
|
-
await resetPassword(request.input("token"), request.input("password"));
|
|
78
|
-
return response.success({ message: "Password updated. Please log in again." });
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
router.post("/auth/forgot-password", forgotPasswordController, {
|
|
82
|
-
middleware: [tokenIssueThrottleMiddleware()], // 3 / 1h per email + per IP
|
|
83
|
-
});
|
|
84
|
-
router.post("/auth/reset-password", resetPasswordController, {
|
|
85
|
-
middleware: [tokenConsumeThrottleMiddleware()], // 10 failures / 15m per IP
|
|
86
|
-
});
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
`resetPassword` writes the password, then calls `authService.revokeAllTokens(user)`: every refresh token is revoked and every access token deleted, which also kills cookie sessions (a cookie holds an access token). It emits `password.reset`; `requestPasswordReset` emits `password.resetRequested`.
|
|
90
|
-
|
|
91
|
-
**Password hashing.** By default auth saves `hashPassword(plain)` and checks it with `verifyPassword`, the same check login uses. If your model hashes on save (`useHashedPassword()`), that first write gets hashed twice and fails the check, so auth saves the plaintext and lets your transformer hash it once. If neither write passes the check, it throws. Plaintext is never the first thing written. To write it yourself, set `auth.passwordReset.setPassword`.
|
|
92
|
-
|
|
93
|
-
## Throttling
|
|
94
|
-
|
|
95
|
-
Both presets wrap `loginThrottleMiddleware` and accept its options:
|
|
96
|
-
|
|
97
|
-
| Preset | Tracks | Counts | Default |
|
|
98
|
-
| --- | --- | --- | --- |
|
|
99
|
-
| `tokenIssueThrottleMiddleware` | email + IP | **every** request (a reset request's success is the anti-enumeration answer, so failure-aware counting would never trip) | 3 / 1h, 1h lock |
|
|
100
|
-
| `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
|
|
101
|
-
|
|
102
|
-
## Configuration (`src/config/auth.ts`)
|
|
103
|
-
|
|
104
|
-
```ts
|
|
105
|
-
const authConfigurations: AuthConfigurations = {
|
|
106
|
-
// ...
|
|
107
|
-
verification: {
|
|
108
|
-
expiresIn: "24h",
|
|
109
|
-
field: "emailVerifiedAt",
|
|
110
|
-
url: (token) => `${env("APP_URL")}/verify-email?token=${token}`,
|
|
111
|
-
notification: myVerificationNotification, // optional replacement
|
|
112
|
-
},
|
|
113
|
-
passwordReset: {
|
|
114
|
-
expiresIn: "60m",
|
|
115
|
-
identifierField: "email",
|
|
116
|
-
url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
|
|
117
|
-
notification: myResetNotification,
|
|
118
|
-
setPassword: async (user, plain) => { /* optional */ },
|
|
119
|
-
},
|
|
120
|
-
oneTimeToken: { model: AppOneTimeToken }, // optional model override
|
|
121
|
-
};
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
The default notifications are `defineNotification` objects (`type: "auth.email-verification"` / `"auth.password-reset"`, `via: ["mail"]`). To replace one, pass anything with a `send(user, { token, expiresAt, url? })` method, for example your own `defineNotification<OneTimeTokenNotificationData>({...})`. `notifications` still has to be installed and configured.
|
|
125
|
-
|
|
126
|
-
## Gotchas
|
|
127
|
-
|
|
128
|
-
- **Without `url` config, the email contains the raw token**, not a link. Set `url` for anything user-facing.
|
|
129
|
-
- **Timing isn't equalised.** An unknown email skips the database write and the send, so the response can come back measurably faster. The throttle limits probing, but it doesn't hide the timing difference.
|
|
130
|
-
- **Channel send failures don't throw.** `@warlock.js/notifications` only rethrows configuration errors (such as a missing `mail` channel). An SMTP failure goes to its `failed` event, so observe it there.
|
|
131
|
-
- `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (never trashed, even on MongoDB).
|
|
132
|
-
|
|
133
|
-
## See also
|
|
134
|
-
|
|
135
|
-
- [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — where `sendEmailVerification` is usually called
|
|
136
|
-
- [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md) — the underlying throttle
|
|
137
|
-
- [`@warlock.js/notifications/configure-notifications/SKILL.md`](@warlock.js/notifications/configure-notifications/SKILL.md) — mail channel setup
|
|
1
|
+
---
|
|
2
|
+
name: verify-email-and-reset-password
|
|
3
|
+
description: 'Email verification and password reset with hashed, single-use, expiring, purpose-bound tokens delivered through `@warlock.js/notifications` — `sendEmailVerification(user)`, `verifyEmail(token)`, `requestPasswordReset(Model, email)`, `resetPassword(token, newPassword)`, the `requireVerifiedEmail()` guard, and the `tokenIssueThrottleMiddleware` / `tokenConsumeThrottleMiddleware` presets. Reset revokes every access token, refresh token and cookie session; a reset request answers the same for unknown emails. Triggers: `sendEmailVerification`, `verifyEmail`, `requestPasswordReset`, `resetPassword`, `requireVerifiedEmail`, `isEmailVerified`, `OneTimeToken`, `InvalidOneTimeTokenError`, `EmailNotVerifiedError`, `NotificationsUnavailableError`, `EC007`, `EC008`, `auth.verification`, `auth.passwordReset`; "verify email", "confirm email address", "forgot password", "reset password link", "block unverified users", "resend verification email"; typical import `import { requestPasswordReset, resetPassword } from "@warlock.js/auth"`. Skip: sign-up itself — `@warlock.js/auth/register-user/SKILL.md`; login throttling — `@warlock.js/auth/throttle-login-attempts/SKILL.md`; defining notifications — `@warlock.js/notifications/define-notification/SKILL.md`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Verify email + reset password
|
|
7
|
+
|
|
8
|
+
Four service functions, one guard, two throttle presets. Auth ships no controllers — wire the routes yourself (examples below).
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
1. **`@warlock.js/notifications` installed and configured with a `mail` channel.** It is an optional peer; without it (or without `src/config/notifications.ts`) every send/request call throws `NotificationsUnavailableError` **before** a token is issued — never a silent skip.
|
|
13
|
+
2. **The `one_time_tokens` table.** It ships in `authMigrations` next to the access/refresh tables; run your migrations.
|
|
14
|
+
3. **A verified-date field on the user schema** (default `emailVerifiedAt`):
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
export const userSchema = v.object({
|
|
18
|
+
// ...
|
|
19
|
+
emailVerifiedAt: v.date().optional(),
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If the schema strips it, `verifyEmail` throws naming the field instead of pretending it worked.
|
|
24
|
+
|
|
25
|
+
## Tokens
|
|
26
|
+
|
|
27
|
+
- 32 random bytes, base64url. Only the **SHA-256 hash** is stored; the raw token exists only in the notification.
|
|
28
|
+
- **Single use**, consumed with a conditional update — of two concurrent uses, exactly one succeeds.
|
|
29
|
+
- **Expiring**: verification `24h`, reset `60m` by default.
|
|
30
|
+
- **Purpose-bound**: a verification token is never accepted by `resetPassword`, and vice versa.
|
|
31
|
+
- A new reset request **invalidates the user's earlier unused reset tokens**. Verification tokens are not invalidated by a resend.
|
|
32
|
+
- Unknown, wrong-purpose, expired and used tokens all throw one `InvalidOneTimeTokenError` (`400`, `EC008`).
|
|
33
|
+
|
|
34
|
+
## Email verification
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { requireVerifiedEmail, sendEmailVerification, verifyEmail } from "@warlock.js/auth";
|
|
38
|
+
import { type RequestHandler } from "@warlock.js/core";
|
|
39
|
+
|
|
40
|
+
// after User.create(...) in your register controller
|
|
41
|
+
await sendEmailVerification(user);
|
|
42
|
+
|
|
43
|
+
export const verifyEmailController: RequestHandler = async ({ request, response }) => {
|
|
44
|
+
const user = await verifyEmail(request.input("token")); // throws InvalidOneTimeTokenError
|
|
45
|
+
return response.success({ user });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
router.post("/auth/verify-email", verifyEmailController, {
|
|
49
|
+
middleware: [tokenConsumeThrottleMiddleware()],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
router.post("/auth/verify-email/resend", resendController, {
|
|
53
|
+
middleware: [authMiddleware("user"), tokenIssueThrottleMiddleware({ by: ["ip"] })],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Opt-in guard, AFTER authMiddleware: 403 EmailNotVerifiedError (EC007)
|
|
57
|
+
router.post("/orders", createOrder, {
|
|
58
|
+
middleware: [authMiddleware("user"), requireVerifiedEmail()],
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`isEmailVerified(user)` answers the same question in code.
|
|
63
|
+
|
|
64
|
+
## Password reset
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { requestPasswordReset, resetPassword } from "@warlock.js/auth";
|
|
68
|
+
|
|
69
|
+
export const forgotPasswordController: RequestHandler = async ({ request, response }) => {
|
|
70
|
+
await requestPasswordReset(User, request.input("email"));
|
|
71
|
+
// Same answer whether or not the account exists.
|
|
72
|
+
return response.success({ message: "If that account exists, we sent a reset link." });
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const resetPasswordController: RequestHandler = async ({ request, response }) => {
|
|
76
|
+
// Validate strength FIRST — the token is consumed before the password is written.
|
|
77
|
+
await resetPassword(request.input("token"), request.input("password"));
|
|
78
|
+
return response.success({ message: "Password updated. Please log in again." });
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
router.post("/auth/forgot-password", forgotPasswordController, {
|
|
82
|
+
middleware: [tokenIssueThrottleMiddleware()], // 3 / 1h per email + per IP
|
|
83
|
+
});
|
|
84
|
+
router.post("/auth/reset-password", resetPasswordController, {
|
|
85
|
+
middleware: [tokenConsumeThrottleMiddleware()], // 10 failures / 15m per IP
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`resetPassword` writes the password, then calls `authService.revokeAllTokens(user)`: every refresh token is revoked and every access token deleted, which also kills cookie sessions (a cookie holds an access token). It emits `password.reset`; `requestPasswordReset` emits `password.resetRequested`.
|
|
90
|
+
|
|
91
|
+
**Password hashing.** By default auth saves `hashPassword(plain)` and checks it with `verifyPassword`, the same check login uses. If your model hashes on save (`useHashedPassword()`), that first write gets hashed twice and fails the check, so auth saves the plaintext and lets your transformer hash it once. If neither write passes the check, it throws. Plaintext is never the first thing written. To write it yourself, set `auth.passwordReset.setPassword`.
|
|
92
|
+
|
|
93
|
+
## Throttling
|
|
94
|
+
|
|
95
|
+
Both presets wrap `loginThrottleMiddleware` and accept its options:
|
|
96
|
+
|
|
97
|
+
| Preset | Tracks | Counts | Default |
|
|
98
|
+
| --- | --- | --- | --- |
|
|
99
|
+
| `tokenIssueThrottleMiddleware` | email + IP | **every** request (a reset request's success is the anti-enumeration answer, so failure-aware counting would never trip) | 3 / 1h, 1h lock |
|
|
100
|
+
| `tokenConsumeThrottleMiddleware` | IP | failures only, cleared on success | 10 / 15m, 15m lock |
|
|
101
|
+
|
|
102
|
+
## Configuration (`src/config/auth.ts`)
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const authConfigurations: AuthConfigurations = {
|
|
106
|
+
// ...
|
|
107
|
+
verification: {
|
|
108
|
+
expiresIn: "24h",
|
|
109
|
+
field: "emailVerifiedAt",
|
|
110
|
+
url: (token) => `${env("APP_URL")}/verify-email?token=${token}`,
|
|
111
|
+
notification: myVerificationNotification, // optional replacement
|
|
112
|
+
},
|
|
113
|
+
passwordReset: {
|
|
114
|
+
expiresIn: "60m",
|
|
115
|
+
identifierField: "email",
|
|
116
|
+
url: (token) => `${env("APP_URL")}/reset-password?token=${token}`,
|
|
117
|
+
notification: myResetNotification,
|
|
118
|
+
setPassword: async (user, plain) => { /* optional */ },
|
|
119
|
+
},
|
|
120
|
+
oneTimeToken: { model: AppOneTimeToken }, // optional model override
|
|
121
|
+
};
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The default notifications are `defineNotification` objects (`type: "auth.email-verification"` / `"auth.password-reset"`, `via: ["mail"]`). To replace one, pass anything with a `send(user, { token, expiresAt, url? })` method, for example your own `defineNotification<OneTimeTokenNotificationData>({...})`. `notifications` still has to be installed and configured.
|
|
125
|
+
|
|
126
|
+
## Gotchas
|
|
127
|
+
|
|
128
|
+
- **Without `url` config, the email contains the raw token**, not a link. Set `url` for anything user-facing.
|
|
129
|
+
- **Timing isn't equalised.** An unknown email skips the database write and the send, so the response can come back measurably faster. The throttle limits probing, but it doesn't hide the timing difference.
|
|
130
|
+
- **Channel send failures don't throw.** `@warlock.js/notifications` only rethrows configuration errors (such as a missing `mail` channel). An SMTP failure goes to its `failed` event, so observe it there.
|
|
131
|
+
- `auth.cleanup` hard-deletes expired and consumed `one_time_tokens` rows (never trashed, even on MongoDB).
|
|
132
|
+
|
|
133
|
+
## See also
|
|
134
|
+
|
|
135
|
+
- [`@warlock.js/auth/register-user/SKILL.md`](@warlock.js/auth/register-user/SKILL.md) — where `sendEmailVerification` is usually called
|
|
136
|
+
- [`@warlock.js/auth/throttle-login-attempts/SKILL.md`](@warlock.js/auth/throttle-login-attempts/SKILL.md) — the underlying throttle
|
|
137
|
+
- [`@warlock.js/notifications/configure-notifications/SKILL.md`](@warlock.js/notifications/configure-notifications/SKILL.md) — mail channel setup
|