@zerotal/arch 1.7.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Two-Factor Authentication
|
|
3
|
+
description: Add TOTP-based two-factor authentication so logins require a one-time code from an authenticator app.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Two-Factor Authentication
|
|
7
|
+
|
|
8
|
+
`@zerotal/auth` ships TOTP-based two-factor authentication (2FA) — generate a
|
|
9
|
+
secret, show a QR code, verify six-digit codes, and gate routes until the user
|
|
10
|
+
completes a challenge. It is RFC 6238 (TOTP) implemented natively for Bun with no
|
|
11
|
+
external dependencies.
|
|
12
|
+
|
|
13
|
+
> **Note** — Looking for roles & permissions? RBAC lives in its own relational
|
|
14
|
+
> model — the `Roles` and `Permissions` mixins composed via
|
|
15
|
+
> `Model.using(Authenticatable, Permissions, Roles)`. See
|
|
16
|
+
> [Authorization](/docs/authorization).
|
|
17
|
+
|
|
18
|
+
## Getting Started
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# in your project root
|
|
22
|
+
bun add @zerotal/auth
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Register the provider
|
|
26
|
+
|
|
27
|
+
2FA is part of `@zerotal/auth`, so registering `AuthProvider` in
|
|
28
|
+
`bootstrap/providers.ts` is all the wiring it needs:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// bootstrap/providers.ts
|
|
32
|
+
import { AuthProvider } from "@zerotal/auth";
|
|
33
|
+
|
|
34
|
+
const providers = [
|
|
35
|
+
// …your other providers
|
|
36
|
+
AuthProvider,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export default providers;
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Registering the provider switches on the following (2FA-relevant hooks):
|
|
43
|
+
|
|
44
|
+
- `onRegister` — binds the `two_factor` singleton (`TwoFactorService`), built from
|
|
45
|
+
your `auth.twoFactor` config.
|
|
46
|
+
- `onBooting` — pre-resolves the `two_factor` singleton so the `TwoFactor` facade
|
|
47
|
+
is ready in any provider's `onBooted`.
|
|
48
|
+
|
|
49
|
+
The `TwoFactor` facade resolves the `two_factor` container binding.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
2FA is tuned inside the `auth` config under the `twoFactor` key. Use the
|
|
54
|
+
`AuthConfig()` helper so every field stays type-checked:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// config/auth.ts
|
|
58
|
+
import { AuthConfig } from "@zerotal/auth";
|
|
59
|
+
|
|
60
|
+
export default AuthConfig({
|
|
61
|
+
algorithm: "argon2id",
|
|
62
|
+
twoFactor: {
|
|
63
|
+
issuer: "My App", // shown in authenticator apps
|
|
64
|
+
window: 1, // ±1 TOTP period (30s) clock tolerance
|
|
65
|
+
recoveryCodeCount: 8,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Field | Required | Default | Description |
|
|
71
|
+
| ------------------- | -------- | ----------- | ------------------------------------------------------------- |
|
|
72
|
+
| `issuer` | no | `"Zerotal"` | Issuer label shown in authenticator apps (your app name). |
|
|
73
|
+
| `window` | no | `1` | TOTP periods checked on each side of now (each period = 30s). |
|
|
74
|
+
| `recoveryCodeCount` | no | `8` | Number of one-time recovery codes generated. |
|
|
75
|
+
|
|
76
|
+
> **Tip** — Prefer the `AuthConfig()` helper over `satisfies AuthConfigShape`: it
|
|
77
|
+
> fills defaults, so you only specify the fields you want to override.
|
|
78
|
+
|
|
79
|
+
## Data model
|
|
80
|
+
|
|
81
|
+
2FA stores three columns on the user. Add a migration:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// database/migrations/003_add_two_factor_to_users.ts
|
|
85
|
+
export default class AddTwoFactorToUsers extends Migration {
|
|
86
|
+
async up(schema: Schema): Promise<void> {
|
|
87
|
+
await schema.table("users", (table) => {
|
|
88
|
+
table.string("two_factor_secret").nullable();
|
|
89
|
+
table.text("two_factor_recovery_codes").nullable(); // JSON array of hashed codes
|
|
90
|
+
table.timestamp("two_factor_confirmed_at").nullable();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
async down(schema: Schema): Promise<void> {
|
|
94
|
+
await schema.table("users", (table) => {
|
|
95
|
+
table.dropColumn("two_factor_secret");
|
|
96
|
+
table.dropColumn("two_factor_recovery_codes");
|
|
97
|
+
table.dropColumn("two_factor_confirmed_at");
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Then expose them on the model:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// app/models/User.ts
|
|
107
|
+
@(table("users").withTimestamps())
|
|
108
|
+
export class User extends AuthUser {
|
|
109
|
+
// ... existing columns ...
|
|
110
|
+
|
|
111
|
+
@column() twoFactorSecret?: string | null;
|
|
112
|
+
@column("json") twoFactorRecoveryCodes?: string[] | null; // stored hashed
|
|
113
|
+
@column("datetime") twoFactorConfirmedAt?: Carbon | null;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
> **Danger** — `twoFactorRecoveryCodes` holds SHA-256 hashes of the recovery
|
|
118
|
+
> codes, never the plaintext. Show the plaintext to the user exactly once at
|
|
119
|
+
> generation time — there is no way to recover it afterwards.
|
|
120
|
+
|
|
121
|
+
## Enabling 2FA
|
|
122
|
+
|
|
123
|
+
Enrolment is a two-step flow: generate a secret and show its QR code, then verify
|
|
124
|
+
the user's first code before persisting. `TwoFactor.verifyCode()` and
|
|
125
|
+
`TwoFactor.generateRecoveryCodes()` are synchronous.
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
// app/controllers/TwoFactorController.ts
|
|
129
|
+
import { TwoFactor } from "@zerotal/auth";
|
|
130
|
+
|
|
131
|
+
export class TwoFactorController extends Controller {
|
|
132
|
+
// GET /user/two-factor — show setup page with QR code
|
|
133
|
+
async setup(ctx: HttpContext) {
|
|
134
|
+
const secret = TwoFactor.generateSecret();
|
|
135
|
+
ctx.session.put("two_factor_pending_secret", secret);
|
|
136
|
+
|
|
137
|
+
// The scannable code, plus the URI as a link and the secret as text for
|
|
138
|
+
// anyone enrolling on the phone that holds the authenticator.
|
|
139
|
+
const qr = TwoFactor.getQrCodeSvg(ctx.user!.email, secret, { size: 220 });
|
|
140
|
+
const uri = TwoFactor.getQrCodeUrl(ctx.user!.email, secret);
|
|
141
|
+
return this.render("two-factor/setup", { qr, uri, secret });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// POST /user/two-factor — confirm and save
|
|
145
|
+
async confirm(ctx: HttpContext) {
|
|
146
|
+
const secret = ctx.session.get("two_factor_pending_secret") as string;
|
|
147
|
+
const code = ctx.input("code") as string;
|
|
148
|
+
|
|
149
|
+
if (!TwoFactor.verifyCode(secret, code)) {
|
|
150
|
+
return this.back().withErrors({ code: "Invalid code." });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const { plain, hashed } = TwoFactor.generateRecoveryCodes();
|
|
154
|
+
|
|
155
|
+
await ctx
|
|
156
|
+
.user!.fill({
|
|
157
|
+
twoFactorSecret: secret,
|
|
158
|
+
twoFactorRecoveryCodes: JSON.stringify(hashed),
|
|
159
|
+
twoFactorConfirmedAt: Carbon.now(),
|
|
160
|
+
})
|
|
161
|
+
.save();
|
|
162
|
+
|
|
163
|
+
ctx.session.forget("two_factor_pending_secret");
|
|
164
|
+
|
|
165
|
+
// Show recovery codes once:
|
|
166
|
+
return this.render("two-factor/recovery-codes", { codes: plain });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// DELETE /user/two-factor — disable
|
|
170
|
+
async disable(ctx: HttpContext) {
|
|
171
|
+
await ctx
|
|
172
|
+
.user!.fill({
|
|
173
|
+
twoFactorSecret: null,
|
|
174
|
+
twoFactorRecoveryCodes: null,
|
|
175
|
+
twoFactorConfirmedAt: null,
|
|
176
|
+
})
|
|
177
|
+
.save();
|
|
178
|
+
return this.redirect("/profile");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
> **Warning** — Setting `twoFactorConfirmedAt` is what makes
|
|
184
|
+
> `TwoFactorMiddleware` start challenging the user. Persist the secret and a
|
|
185
|
+
> non-null `twoFactorConfirmedAt` together, only after a successful
|
|
186
|
+
> `verifyCode()`, so a half-finished enrolment never locks anyone out.
|
|
187
|
+
|
|
188
|
+
## The challenge flow
|
|
189
|
+
|
|
190
|
+
After login, the challenge controller verifies a TOTP code (falling back to a
|
|
191
|
+
recovery code) and marks the session as confirmed. `verifyRecoveryCode()` returns
|
|
192
|
+
the remaining codes synchronously so you can persist the consumed set:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// app/controllers/TwoFactorChallengeController.ts
|
|
196
|
+
import { TwoFactor, TWO_FACTOR_SESSION_KEY } from "@zerotal/auth";
|
|
197
|
+
|
|
198
|
+
export class TwoFactorChallengeController extends Controller {
|
|
199
|
+
// GET /two-factor/challenge
|
|
200
|
+
async show(ctx: HttpContext) {
|
|
201
|
+
return this.render("two-factor/challenge");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// POST /two-factor/challenge
|
|
205
|
+
async verify(ctx: HttpContext) {
|
|
206
|
+
const user = ctx.user! as User;
|
|
207
|
+
const code = ctx.input("code") as string;
|
|
208
|
+
const secret = user.twoFactorSecret!;
|
|
209
|
+
|
|
210
|
+
// Try TOTP code first, then recovery code
|
|
211
|
+
const totpOk = TwoFactor.verifyCode(secret, code);
|
|
212
|
+
|
|
213
|
+
if (!totpOk) {
|
|
214
|
+
const storedHashed = JSON.parse(user.twoFactorRecoveryCodes ?? "[]") as string[];
|
|
215
|
+
const recoveryResult = TwoFactor.verifyRecoveryCode(storedHashed, code);
|
|
216
|
+
|
|
217
|
+
if (!recoveryResult.valid) {
|
|
218
|
+
return this.back().withErrors({ code: "Invalid code." });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Consume the recovery code
|
|
222
|
+
await user
|
|
223
|
+
.fill({
|
|
224
|
+
twoFactorRecoveryCodes: JSON.stringify(recoveryResult.remaining),
|
|
225
|
+
})
|
|
226
|
+
.save();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Mark 2FA as confirmed for this session
|
|
230
|
+
ctx.session.put(TWO_FACTOR_SESSION_KEY, true);
|
|
231
|
+
return this.redirect(ctx.session.get("url.intended", "/") as string);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
> **Tip** — `TWO_FACTOR_SESSION_KEY` (the string `"two_factor_confirmed"`) is
|
|
237
|
+
> exported from `@zerotal/auth`. Use the constant rather than re-typing the key so
|
|
238
|
+
> your controller and the middleware always agree.
|
|
239
|
+
|
|
240
|
+
## Protecting routes
|
|
241
|
+
|
|
242
|
+
Add `TwoFactorMiddleware` **after** `AuthMiddleware` in any group that should
|
|
243
|
+
require 2FA:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
// routes/web.ts
|
|
247
|
+
import { AuthMiddleware, TwoFactorMiddleware } from "@zerotal/auth";
|
|
248
|
+
|
|
249
|
+
Router.group({ middleware: [AuthMiddleware, TwoFactorMiddleware] }, () => {
|
|
250
|
+
Router.get("/dashboard", DashboardController, "index");
|
|
251
|
+
Router.get("/settings", SettingsController, "index");
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`TwoFactorMiddleware` passes the request through if:
|
|
256
|
+
|
|
257
|
+
- The request is authenticated but the user has no `twoFactorSecret`, or
|
|
258
|
+
- `twoFactorConfirmedAt` is null (enrolment never confirmed), or
|
|
259
|
+
- The session already contains `two_factor_confirmed === true`.
|
|
260
|
+
|
|
261
|
+
If the user has a confirmed secret but this session has not passed the challenge,
|
|
262
|
+
it redirects to `TwoFactorMiddleware.challengeRoute` (default
|
|
263
|
+
`/two-factor/challenge`). An unauthenticated request throws `UnauthorizedError`.
|
|
264
|
+
|
|
265
|
+
Override the redirect target globally by setting the static property:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
// bootstrap/app.ts
|
|
269
|
+
import { TwoFactorMiddleware } from "@zerotal/auth";
|
|
270
|
+
|
|
271
|
+
TwoFactorMiddleware.challengeRoute = "/auth/2fa";
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Displaying the QR code
|
|
275
|
+
|
|
276
|
+
`getQrCodeSvg()` returns the scannable code as an inline `<svg>`, drawn in your
|
|
277
|
+
process. Inline it — do not fetch it, and do not log it:
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
// in a Flow page
|
|
281
|
+
const qr = TwoFactor.getQrCodeSvg(user.email, secret, { size: 220 });
|
|
282
|
+
|
|
283
|
+
return <div dangerouslySetInnerHTML={{ __html: qr }} />;
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
> **Danger** — never hand the `otpauth://` URI to a QR image service. It carries
|
|
287
|
+
> the TOTP secret, so a request to `api.qrserver.com` or any similar endpoint
|
|
288
|
+
> posts the second factor to a third party and leaves it in their logs. Serving
|
|
289
|
+
> the image from a route of your own has the same shape of problem: it turns the
|
|
290
|
+
> secret into something requestable, proxy-loggable and browser-cacheable. That
|
|
291
|
+
> is why this renders in-process and returns markup rather than a URL.
|
|
292
|
+
|
|
293
|
+
Options: `issuer` overrides the configured one for this code; `size` sets width
|
|
294
|
+
and height in pixels (omit it and the symbol scales to its container);
|
|
295
|
+
`dark`/`light` set the colours, with `light: null` for a transparent background;
|
|
296
|
+
`quietZone` is the light margin in modules, which defaults to the 4 the spec
|
|
297
|
+
requires — drop it to `0` only when the surrounding element already provides a
|
|
298
|
+
light margin, because scanners use that margin to find the symbol's edges;
|
|
299
|
+
`alt` and `class` set the accessible name and the CSS class.
|
|
300
|
+
|
|
301
|
+
**Offer the secret as text too.** A phone cannot photograph its own screen, so
|
|
302
|
+
anyone enrolling on the device that holds the authenticator needs another way in
|
|
303
|
+
— the secret in readable blocks to type, or the `otpauth://` URI from
|
|
304
|
+
`getQrCodeUrl()` as a link, which opens the authenticator app directly:
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
<a href={TwoFactor.getQrCodeUrl(user.email, secret)}>Open in my authenticator</a>
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
To draw the code yourself — to a canvas, a PNG, or your own markup — `encodeQr()`
|
|
311
|
+
returns the module matrix and `qrSvg()` renders one:
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
import { encodeQr } from "@zerotal/auth";
|
|
315
|
+
|
|
316
|
+
const matrix = encodeQr(TwoFactor.getQrCodeUrl(user.email, secret));
|
|
317
|
+
for (let row = 0; row < matrix.size; row++) {
|
|
318
|
+
for (let col = 0; col < matrix.size; col++) {
|
|
319
|
+
if (matrix.isDark(row, col)) ctx.fillRect(col * 4, row * 4, 4, 4);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The encoder covers byte mode at error-correction level M, versions 1 through 20
|
|
325
|
+
— `maxPayloadBytes()` bytes in all. A typical `otpauth://` URI is about 130
|
|
326
|
+
bytes; past the ceiling, `QrError` is thrown rather than a truncated symbol
|
|
327
|
+
produced, and the fix is a shorter issuer or account label.
|
|
328
|
+
|
|
329
|
+
Authenticator apps that support TOTP: Google Authenticator, Authy, 1Password,
|
|
330
|
+
Bitwarden, Microsoft Authenticator.
|
|
331
|
+
|
|
332
|
+
## Testing
|
|
333
|
+
|
|
334
|
+
Set your suite up once as described in [Testing](/docs/testing) — everything
|
|
335
|
+
below assumes `createApp()` from your `tests/helpers.ts`.
|
|
336
|
+
|
|
337
|
+
Most of the two-factor surface is pure, so it tests without an application, a
|
|
338
|
+
database, or a clock:
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
// tests/auth/two-factor.test.ts
|
|
342
|
+
import { test, expect } from "bun:test";
|
|
343
|
+
import { TwoFactorService } from "@zerotal/auth";
|
|
344
|
+
|
|
345
|
+
test("a recovery code verifies once and is then consumed", () => {
|
|
346
|
+
const tf = new TwoFactorService();
|
|
347
|
+
const { plain, hashed } = tf.generateRecoveryCodes();
|
|
348
|
+
|
|
349
|
+
const first = tf.verifyRecoveryCode(hashed, plain[0]!);
|
|
350
|
+
expect(first.valid).toBe(true);
|
|
351
|
+
expect(first.remaining).toHaveLength(hashed.length - 1);
|
|
352
|
+
|
|
353
|
+
// The same code must not work twice.
|
|
354
|
+
expect(tf.verifyRecoveryCode(first.remaining, plain[0]!).valid).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
**Drive the challenge with a real code.** `generateCode(secret)` produces exactly
|
|
359
|
+
what an authenticator app would show, so the test exercises the TOTP path rather
|
|
360
|
+
than working around it:
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
// tests/http/two-factor.test.ts
|
|
364
|
+
import { test } from "bun:test";
|
|
365
|
+
import { createApp } from "../helpers.ts";
|
|
366
|
+
import { TwoFactorService } from "@zerotal/auth";
|
|
367
|
+
import { User } from "../../app/models/User.ts";
|
|
368
|
+
|
|
369
|
+
test("a valid code clears the challenge", async () => {
|
|
370
|
+
const app = await createApp();
|
|
371
|
+
const tf = new TwoFactorService();
|
|
372
|
+
const secret = tf.generateSecret();
|
|
373
|
+
|
|
374
|
+
const user = await User.create({
|
|
375
|
+
email: "jane@example.com",
|
|
376
|
+
twoFactorSecret: secret,
|
|
377
|
+
twoFactorConfirmedAt: new Date(),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const res = await app
|
|
381
|
+
.actingAs(user)
|
|
382
|
+
.post("/two-factor/challenge", { code: tf.generateCode(secret) });
|
|
383
|
+
|
|
384
|
+
res.assertRedirect("/dashboard");
|
|
385
|
+
await app.close();
|
|
386
|
+
});
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`generateCode(secret, -1)` returns the previous slot's code, which is how you
|
|
390
|
+
assert that your replay guard rejects a code that has already been used.
|
|
391
|
+
|
|
392
|
+
A recovery code works the same way and needs no clock at all — pass one of the
|
|
393
|
+
`plain` values from `generateRecoveryCodes()` to the same endpoint.
|
|
394
|
+
|
|
395
|
+
To prove a route is _protected_, assert the challenge redirect rather than trying
|
|
396
|
+
to get past it — `assertRedirect("/two-factor/challenge")` is the whole test.
|
|
397
|
+
|
|
398
|
+
## References
|
|
399
|
+
|
|
400
|
+
The `TwoFactor` facade proxies the `two_factor` singleton (a `TwoFactorService`).
|
|
401
|
+
All methods are synchronous.
|
|
402
|
+
|
|
403
|
+
| Method | Signature | Description |
|
|
404
|
+
| ---------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
405
|
+
| `generateSecret()` | `() => string` | Random 20-byte base-32 secret for a new enrolment. |
|
|
406
|
+
| `getQrCodeUrl(label, secret, issuer?)` | `(label: string, secret: string, issuer?: string) => string` | `otpauth://totp/...` URI for an authenticator app. |
|
|
407
|
+
| `getQrCodeSvg(label, secret, options?)` | `(label: string, secret: string, options?: TwoFactorQrOptions) => string` | The same URI as an inline `<svg>`, drawn in-process. Inline it; never fetch it. |
|
|
408
|
+
| `generateCode(secret, offset?)` | `(secret: string, offset?: number) => string` | Produce the code an authenticator would show. For tests — never send it to a user. |
|
|
409
|
+
| `verifyCode(secret, token)` | `(secret: string, token: string) => boolean` | Verify a 6-digit TOTP code within the time window. |
|
|
410
|
+
| `generateRecoveryCodes()` | `() => { plain: string[]; hashed: string[] }` | Fresh one-time recovery codes (plaintext + hashes). |
|
|
411
|
+
| `verifyRecoveryCode(storedHashed, code)` | `(storedHashed: string[], code: string) => { valid: boolean; remaining: string[] }` | Consume a recovery code, returning the unused ones. |
|
|
412
|
+
|
|
413
|
+
## Next steps
|
|
414
|
+
|
|
415
|
+
- [Authentication](/docs/authentication) — login, the auth middleware, and the session flow 2FA builds on.
|
|
416
|
+
- [Authorization](/docs/authorization) — gates, policies, roles, and permissions.
|
|
417
|
+
- [Session](/docs/session) — how the per-request session that stores `two_factor_confirmed` works.
|
|
418
|
+
- [Encryption & Hashing](/docs/encryption) — how passwords and other secrets are hashed.
|