@astram/guard 0.10.2 → 0.11.1
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 +76 -19
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +270 -53
- package/dist/auth/auth.controller.d.ts +3 -1
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +10 -2
- package/dist/auth/auth.module.d.ts +8 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +80 -7
- package/dist/auth/confirm-email.controller.d.ts +33 -0
- package/dist/auth/confirm-email.controller.d.ts.map +1 -0
- package/dist/auth/confirm-email.controller.js +205 -0
- package/dist/auth/email-address.d.ts +30 -0
- package/dist/auth/email-address.d.ts.map +1 -0
- package/dist/auth/email-address.js +49 -0
- package/dist/auth/email-change-messages.d.ts +26 -0
- package/dist/auth/email-change-messages.d.ts.map +1 -0
- package/dist/auth/email-change-messages.js +56 -0
- package/dist/auth/link-base-url.d.ts +24 -0
- package/dist/auth/link-base-url.d.ts.map +1 -0
- package/dist/auth/link-base-url.js +93 -0
- package/dist/auth/ports/email-address-changer.d.ts +65 -0
- package/dist/auth/ports/email-address-changer.d.ts.map +1 -0
- package/dist/auth/ports/email-address-changer.js +8 -0
- package/dist/auth/ports/email-change-token-store.d.ts +39 -0
- package/dist/auth/ports/email-change-token-store.d.ts.map +1 -0
- package/dist/auth/ports/email-change-token-store.js +12 -0
- package/dist/auth/tokens.d.ts +39 -1
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +40 -2
- package/dist/authentication/mariadb-email-address-changer.d.ts +33 -0
- package/dist/authentication/mariadb-email-address-changer.d.ts.map +1 -0
- package/dist/authentication/mariadb-email-address-changer.js +97 -0
- package/dist/authentication/mariadb-email-change-token-store.d.ts +25 -0
- package/dist/authentication/mariadb-email-change-token-store.d.ts.map +1 -0
- package/dist/authentication/mariadb-email-change-token-store.js +72 -0
- package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-mfa-device-store.js +8 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -1
- package/dist/module/shield.module.d.ts +11 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +6 -0
- package/package.json +3 -3
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
*/
|
|
45
45
|
import type { FactorVerifier, PasswordHasher } from '@astram/core';
|
|
46
46
|
import type { SessionTokenBinder } from '../request-auth/session-token-binder.js';
|
|
47
|
+
import type { AuditSink } from './ports/audit-sink.js';
|
|
48
|
+
import type { EmailSender } from './ports/email-sender.js';
|
|
49
|
+
import type { EmailChangeTokenStore } from './ports/email-change-token-store.js';
|
|
50
|
+
import type { EmailAddressChanger } from './ports/email-address-changer.js';
|
|
47
51
|
import type { SessionRestrictionStore } from '../session/session-restriction-store.js';
|
|
48
52
|
import type { AttemptLimiter } from './rate-limit/attempt-limiter.js';
|
|
49
53
|
import type { CredentialWriter } from './ports/credential-writer.js';
|
|
@@ -52,6 +56,15 @@ import type { CurrentUserProfile, ProfileResolver } from './profile-resolver.js'
|
|
|
52
56
|
import type { ProfileWriter } from './ports/profile-writer.js';
|
|
53
57
|
import type { RecoveryCodeStore } from './ports/recovery-code-store.js';
|
|
54
58
|
import type { SessionSweeper } from './ports/session-sweeper.js';
|
|
59
|
+
/** The minimal response surface the 202 answer needs (Express-compatible). */
|
|
60
|
+
interface StatusSettable {
|
|
61
|
+
status(code: number): unknown;
|
|
62
|
+
}
|
|
63
|
+
/** The 202 body of an accepted email change (the address changes only after confirmation). */
|
|
64
|
+
export interface PendingEmailChange {
|
|
65
|
+
readonly pendingEmail: true;
|
|
66
|
+
readonly profile: CurrentUserProfile;
|
|
67
|
+
}
|
|
55
68
|
export declare class AccountController {
|
|
56
69
|
private readonly binder?;
|
|
57
70
|
private readonly hasher?;
|
|
@@ -66,7 +79,13 @@ export declare class AccountController {
|
|
|
66
79
|
private readonly loginTotpVerifier?;
|
|
67
80
|
private readonly mfaLimiter?;
|
|
68
81
|
private readonly loginLimiter?;
|
|
69
|
-
|
|
82
|
+
private readonly emailChangeTokens?;
|
|
83
|
+
private readonly emailAddresses?;
|
|
84
|
+
private readonly emailSender?;
|
|
85
|
+
private readonly emailChangeLinkBaseUrl?;
|
|
86
|
+
private readonly emailChangeLimiter?;
|
|
87
|
+
private readonly auditSink?;
|
|
88
|
+
constructor(binder?: SessionTokenBinder | undefined, hasher?: PasswordHasher | undefined, writer?: (CredentialWriter | null) | undefined, sweeper?: (SessionSweeper | null) | undefined, recoveryCodes?: (RecoveryCodeStore | null) | undefined, totpVerifier?: (FactorVerifier | null) | undefined, profileResolver?: (ProfileResolver | null) | undefined, profileWriter?: (ProfileWriter | null) | undefined, mfaDevices?: (MfaDeviceStore | null) | undefined, sessionRestrictions?: (SessionRestrictionStore | null) | undefined, loginTotpVerifier?: (FactorVerifier | null) | undefined, mfaLimiter?: (AttemptLimiter | null) | undefined, loginLimiter?: (AttemptLimiter | null) | undefined, emailChangeTokens?: (EmailChangeTokenStore | null) | undefined, emailAddresses?: (EmailAddressChanger | null) | undefined, emailSender?: (EmailSender | null) | undefined, emailChangeLinkBaseUrl?: string | null | undefined, emailChangeLimiter?: (AttemptLimiter | null) | undefined, auditSink?: (AuditSink | null) | undefined);
|
|
70
89
|
/**
|
|
71
90
|
* Resolve a human-readable otpauth account LABEL for the caller (email, else display
|
|
72
91
|
* name) via the profile resolver. Best-effort: no resolver / null profile / a thrown
|
|
@@ -138,27 +157,45 @@ export declare class AccountController {
|
|
|
138
157
|
*/
|
|
139
158
|
private verifyTotp;
|
|
140
159
|
/**
|
|
141
|
-
* `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* Only the fields actually supplied are written (partial edit). At least one of
|
|
147
|
-
* `name`/`email` must be present and non-empty; a supplied email must look like an
|
|
148
|
-
* email. These are semantic validations on the caller's OWN profile (no credential
|
|
149
|
-
* oracle), so they return precise 422 reasons rather than a generic 4xx.
|
|
160
|
+
* `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN profile. Self-scoped: acts only
|
|
161
|
+
* on `claims.id`. A `name` is applied at once. An `email` is NEVER written here (security
|
|
162
|
+
* hardening Phase 12, KI #14): the request is verified and a confirm link goes to the NEW
|
|
163
|
+
* address; `POST /auth/confirm-email` applies it.
|
|
150
164
|
*
|
|
151
165
|
* Outcomes:
|
|
152
|
-
* • no/invalid token → 401 (generic)
|
|
153
|
-
* •
|
|
154
|
-
* •
|
|
155
|
-
* • email
|
|
156
|
-
* •
|
|
166
|
+
* • no/invalid token → 401 (generic); re-enroll session → 403
|
|
167
|
+
* • neither name nor email supplied → 422 `no_changes`
|
|
168
|
+
* • email malformed → 422 `invalid_email`
|
|
169
|
+
* • email collation-equal to the current → 422 `no_changes`
|
|
170
|
+
* • a seam unplugged → 501 `not_available`
|
|
171
|
+
* • email without / with a wrong `currentPassword` → 400 `invalid_current_password`
|
|
172
|
+
* (counted on the account's `login:pid` budget, like change-password); locked → 429
|
|
173
|
+
* • a confirmed MFA device and no `code` → 400 `mfa_required`; wrong code → 400
|
|
174
|
+
* `invalid_request` (`mfa:pid` budget, replay-guarded TOTP or a recovery code, A1); locked → 429
|
|
175
|
+
* • `email-change:pid` budget exhausted → 429 `too_many_requests`
|
|
176
|
+
* • a limiter / MFA device or recovery-code store / token store / mail outage → 503
|
|
177
|
+
* `authentication_unavailable` (the token is issued before the second factor is checked, so
|
|
178
|
+
* a STORE outage never spends a recovery code or a TOTP; a failed proof burns that token. A
|
|
179
|
+
* mail failure after a successful proof does spend it — the proof precedes the send)
|
|
180
|
+
* • name only → 200 (the refreshed CurrentUserProfile)
|
|
181
|
+
* • email accepted → 202 `{ pendingEmail: true, profile }`
|
|
182
|
+
*
|
|
183
|
+
* The token (32 random bytes) is stored only as its sha256, bound to the canonical new address,
|
|
184
|
+
* the current email and this session (A3), and reaches the user only inside the mailed link's
|
|
185
|
+
* fragment (`#t=`). This handler writes no log line; the dev `LogEmailSender` (development/test
|
|
186
|
+
* only) is the one place the link is printed, exactly like a reset link.
|
|
157
187
|
*/
|
|
158
188
|
updateProfile(authorization: string | undefined, body: {
|
|
159
189
|
name?: string;
|
|
160
190
|
email?: string;
|
|
161
|
-
|
|
191
|
+
currentPassword?: string;
|
|
192
|
+
code?: string;
|
|
193
|
+
}, res: StatusSettable): Promise<CurrentUserProfile | PendingEmailChange>;
|
|
194
|
+
/**
|
|
195
|
+
* The verified half of an email change request (Phase 12): checks, proofs, budget, token, mail,
|
|
196
|
+
* audit. Throws the contract's HttpException on every refusal; returns once the link is sent.
|
|
197
|
+
*/
|
|
198
|
+
private requestEmailChange;
|
|
162
199
|
/**
|
|
163
200
|
* Mint a fresh recovery-code batch, hash each (the SAME Argon2 hasher recovery-code
|
|
164
201
|
* consume verifies against), and REPLACE the caller's prior codes. Returns the RAW
|
|
@@ -206,10 +243,20 @@ export declare class AccountController {
|
|
|
206
243
|
* material (`otpauthUri` for a QR + `secret` for manual entry). NOT active until
|
|
207
244
|
* `confirm`. A blank name defaults to `"Authenticator N"` (N = confirmed count + 1).
|
|
208
245
|
*
|
|
209
|
-
*
|
|
246
|
+
* KI #14 re-review P1-1: when the caller ALREADY has a confirmed device, adding another needs a
|
|
247
|
+
* second-factor `code` (a TOTP from an existing device via the replay-guarded verifier, or a
|
|
248
|
+
* recovery code; `mfa:pid` budget — {@link requireMfaProof}). Otherwise a stolen session alone
|
|
249
|
+
* could enrol the attacker's authenticator and use it for every code-gated step-up (A1). The
|
|
250
|
+
* recovery-login re-enroll session (`mustReenroll`, durable `s_must_reenroll`) is exempt: it
|
|
251
|
+
* proved a recovery code at sign-in and has lost its device, so it cannot produce a code.
|
|
252
|
+
*
|
|
253
|
+
* Outcomes: 401; store absent → 501; MFA on and no `code` → 400 `mfa_required`; wrong code →
|
|
254
|
+
* 400 `invalid_request`; MFA budget exhausted → 429; limiter/replay guard unreachable → 503;
|
|
255
|
+
* success → 200 `{ deviceId, otpauthUri, secret }`.
|
|
210
256
|
*/
|
|
211
257
|
beginDevice(authorization: string | undefined, body: {
|
|
212
258
|
name?: string;
|
|
259
|
+
code?: string;
|
|
213
260
|
}): Promise<{
|
|
214
261
|
deviceId: string;
|
|
215
262
|
otpauthUri: string;
|
|
@@ -222,8 +269,17 @@ export declare class AccountController {
|
|
|
222
269
|
* codes are minted (shown once) and returned; subsequent devices return `{}` and do
|
|
223
270
|
* NOT touch recovery codes.
|
|
224
271
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
272
|
+
* KI #14 re-review P1-1 (defence in depth for the code-gated `begin`): when MFA is already on and
|
|
273
|
+
* the session is not a re-enroll session, only a pending device that `begin` created behind its
|
|
274
|
+
* step-up can be confirmed. A pending device carries no proof flag, so the binding is by time:
|
|
275
|
+
* while MFA is on, `begin` cannot create a pending device without a proof, so the device must be
|
|
276
|
+
* younger than the pending window ({@link PENDING_DEVICE_MAX_AGE_MS}, the store's self-prune
|
|
277
|
+
* TTL) and not older than the newest confirmed device. That refuses a device begun while MFA was
|
|
278
|
+
* still off (e.g. by a stolen session, before the user enrolled) and any stale pending row.
|
|
279
|
+
*
|
|
280
|
+
* Outcomes: 401; store/recovery/hasher absent → 501; device not pending/owned, or (MFA on) not
|
|
281
|
+
* bound to a step-up begin → 404; wrong/expired code → 400 (device stays pending); success →
|
|
282
|
+
* 200 `{ recoveryCodes? }`.
|
|
227
283
|
*/
|
|
228
284
|
confirmDevice(authorization: string | undefined, body: {
|
|
229
285
|
deviceId?: string;
|
|
@@ -272,4 +328,5 @@ export declare class AccountController {
|
|
|
272
328
|
recoveryCodes: string[];
|
|
273
329
|
}>;
|
|
274
330
|
}
|
|
331
|
+
export {};
|
|
275
332
|
//# sourceMappingURL=account.controller.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"account.controller.d.ts","sourceRoot":"","sources":["../../src/auth/account.controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;
|
|
1
|
+
{"version":3,"file":"account.controller.d.ts","sourceRoot":"","sources":["../../src/auth/account.controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAmBH,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAa,MAAM,cAAc,CAAC;AAK9E,OAAO,KAAK,EAEV,kBAAkB,EACnB,MAAM,yCAAyC,CAAC;AAqBjD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,qCAAqC,CAAC;AACjF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AAQ5E,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAEtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7E,OAAO,KAAK,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACjF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AA4HjE,8EAA8E;AAC9E,UAAU,cAAc;IACtB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;CAC/B;AAED,8FAA8F;AAC9F,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,YAAY,EAAE,IAAI,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAC;CACtC;AAED,qBACa,iBAAiB;IAO1B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IAIxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IAIxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IAMxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;IAKzB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC;IAQ/B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;IAO9B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC;IAKjC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC;IAK/B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;IAK5B,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAC;IAOrC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAC;IAKnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;IAK5B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;IAM9B,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAC;IAInC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;IAIhC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAI7B,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAC;IAIxC,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAC;IAIpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC;gBA5FV,MAAM,CAAC,EAAE,kBAAkB,YAAA,EAI3B,MAAM,CAAC,EAAE,cAAc,YAAA,EAIvB,MAAM,CAAC,GAAE,gBAAgB,GAAG,IAAI,aAAA,EAMhC,OAAO,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAK/B,aAAa,CAAC,GAAE,iBAAiB,GAAG,IAAI,aAAA,EAQxC,YAAY,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAOpC,eAAe,CAAC,GAAE,eAAe,GAAG,IAAI,aAAA,EAKxC,aAAa,CAAC,GAAE,aAAa,GAAG,IAAI,aAAA,EAKpC,UAAU,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAKlC,mBAAmB,CAAC,GAAE,uBAAuB,GAAG,IAAI,aAAA,EAOpD,iBAAiB,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAKzC,UAAU,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAKlC,YAAY,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAMpC,iBAAiB,CAAC,GAAE,qBAAqB,GAAG,IAAI,aAAA,EAIhD,cAAc,CAAC,GAAE,mBAAmB,GAAG,IAAI,aAAA,EAI3C,WAAW,CAAC,GAAE,WAAW,GAAG,IAAI,aAAA,EAIhC,sBAAsB,CAAC,EAAE,MAAM,GAAG,IAAI,YAAA,EAItC,kBAAkB,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAI1C,SAAS,CAAC,GAAE,SAAS,GAAG,IAAI,aAAA;IAG/C;;;;;;OAMG;YACW,mBAAmB;IAwBjC;;;;OAIG;YACW,aAAa;IAS3B;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,OAAO,CAAC,eAAe;IAMvB;;;;;;;;;;;;;;;;;;;;OAoBG;IAIG,cAAc,CACQ,aAAa,EAAE,MAAM,GAAG,SAAS,EACnD,IAAI,EAAE;QAAE,eAAe,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,GAC/D,OAAO,CAAC,IAAI,CAAC;IAsEhB;;;;;OAKG;YACW,UAAU;IAMxB;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IAIG,aAAa,CACS,aAAa,EAAE,MAAM,GAAG,SAAS,EACnD,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,eAAe,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,EAC5D,GAAG,EAAE,cAAc,GAC9C,OAAO,CAAC,kBAAkB,GAAG,kBAAkB,CAAC;IAuCnD;;;OAGG;YACW,kBAAkB;IAgJhC;;;;;OAKG;YACW,oBAAoB;IAWlC;;;;;;;;OAQG;YACW,sBAAsB;IAmBpC;;;;;;;;;OASG;YACW,eAAe;IAuB7B;;;;;;;;OAQG;IAIG,WAAW,CACW,aAAa,EAAE,MAAM,GAAG,SAAS,GAC1D,OAAO,CAAC;QAAE,OAAO,EAAE,SAAS,EAAE,CAAC;QAAC,UAAU,EAAE,OAAO,CAAA;KAAE,CAAC;IAUzD;;;;;;;;;;;;;;;;OAgBG;IAIG,WAAW,CACW,aAAa,EAAE,MAAM,GAAG,SAAS,EACnD,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAC7C,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IA0BpE;;;;;;;;;;;;;;;;;;OAkBG;IAIG,aAAa,CACS,aAAa,EAAE,MAAM,GAAG,SAAS,EACnD,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GACjD,OAAO,CAAC;QAAE,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IA8ExC;;;;;;;OAOG;IAIG,YAAY,CACU,aAAa,EAAE,MAAM,GAAG,SAAS,EAC9C,EAAE,EAAE,MAAM,EACf,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAC9B,OAAO,CAAC;QAAE,MAAM,EAAE,SAAS,CAAA;KAAE,CAAC;IAoBjC;;;;;;;;;OASG;IAIG,YAAY,CACU,aAAa,EAAE,MAAM,GAAG,SAAS,EAC9C,EAAE,EAAE,MAAM,EACf,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAC9B,OAAO,CAAC,IAAI,CAAC;IAgChB;;;;;;;;OAQG;IAIG,uBAAuB,CACD,aAAa,EAAE,MAAM,GAAG,SAAS,EACnD,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAC9B,OAAO,CAAC;QAAE,aAAa,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;CA+BxC"}
|
|
@@ -58,15 +58,23 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
58
58
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
59
59
|
exports.AccountController = void 0;
|
|
60
60
|
const common_1 = require("@nestjs/common");
|
|
61
|
+
const node_crypto_1 = require("node:crypto");
|
|
61
62
|
const engine_1 = require("@astram/engine");
|
|
62
63
|
const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
|
|
63
64
|
const request_principal_js_1 = require("../request-auth/request-principal.js");
|
|
64
65
|
const tokens_js_1 = require("../request-auth/tokens.js");
|
|
65
66
|
const tokens_js_2 = require("./tokens.js");
|
|
67
|
+
const email_address_js_1 = require("./email-address.js");
|
|
68
|
+
const email_change_messages_js_1 = require("./email-change-messages.js");
|
|
66
69
|
const canonical_hint_js_1 = require("./rate-limit/canonical-hint.js");
|
|
67
70
|
const totp_enrollment_js_1 = require("./totp-enrollment.js");
|
|
68
71
|
/** Max length of a device name (matches the `md_name VARCHAR(120)` column). */
|
|
69
72
|
const MAX_DEVICE_NAME_LEN = 120;
|
|
73
|
+
/**
|
|
74
|
+
* How long a pending (begun, unconfirmed) device may still be confirmed once MFA is on — the same
|
|
75
|
+
* 15 minutes after which `MariaDbMfaDeviceStore.begin` prunes it (KI #14 re-review P1-1).
|
|
76
|
+
*/
|
|
77
|
+
const PENDING_DEVICE_MAX_AGE_MS = 15 * 60 * 1000;
|
|
70
78
|
/** Minimum acceptable new-password length (the only policy this route enforces). */
|
|
71
79
|
const MIN_PASSWORD_LEN = 8;
|
|
72
80
|
/**
|
|
@@ -154,6 +162,18 @@ async function consumeAttempt(limiter, key) {
|
|
|
154
162
|
function unprocessable(error) {
|
|
155
163
|
throw new common_1.HttpException({ error }, common_1.HttpStatus.UNPROCESSABLE_ENTITY);
|
|
156
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Phase 12 (A1): an email change by a user with a confirmed MFA device must carry a second-factor
|
|
167
|
+
* `code`. Missing → this 400 (the client then asks for the code); a wrong code is the generic
|
|
168
|
+
* {@link badRequest}.
|
|
169
|
+
*/
|
|
170
|
+
function mfaRequired() {
|
|
171
|
+
throw new common_1.HttpException({ error: 'mfa_required' }, common_1.HttpStatus.BAD_REQUEST);
|
|
172
|
+
}
|
|
173
|
+
/** SHA-256 hex of a raw token: stores only ever see this hash. */
|
|
174
|
+
function sha256Hex(value) {
|
|
175
|
+
return (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
|
|
176
|
+
}
|
|
157
177
|
let AccountController = class AccountController {
|
|
158
178
|
binder;
|
|
159
179
|
hasher;
|
|
@@ -168,7 +188,13 @@ let AccountController = class AccountController {
|
|
|
168
188
|
loginTotpVerifier;
|
|
169
189
|
mfaLimiter;
|
|
170
190
|
loginLimiter;
|
|
171
|
-
|
|
191
|
+
emailChangeTokens;
|
|
192
|
+
emailAddresses;
|
|
193
|
+
emailSender;
|
|
194
|
+
emailChangeLinkBaseUrl;
|
|
195
|
+
emailChangeLimiter;
|
|
196
|
+
auditSink;
|
|
197
|
+
constructor(binder, hasher, writer, sweeper, recoveryCodes, totpVerifier, profileResolver, profileWriter, mfaDevices, sessionRestrictions, loginTotpVerifier, mfaLimiter, loginLimiter, emailChangeTokens, emailAddresses, emailSender, emailChangeLinkBaseUrl, emailChangeLimiter, auditSink) {
|
|
172
198
|
this.binder = binder;
|
|
173
199
|
this.hasher = hasher;
|
|
174
200
|
this.writer = writer;
|
|
@@ -182,6 +208,12 @@ let AccountController = class AccountController {
|
|
|
182
208
|
this.loginTotpVerifier = loginTotpVerifier;
|
|
183
209
|
this.mfaLimiter = mfaLimiter;
|
|
184
210
|
this.loginLimiter = loginLimiter;
|
|
211
|
+
this.emailChangeTokens = emailChangeTokens;
|
|
212
|
+
this.emailAddresses = emailAddresses;
|
|
213
|
+
this.emailSender = emailSender;
|
|
214
|
+
this.emailChangeLinkBaseUrl = emailChangeLinkBaseUrl;
|
|
215
|
+
this.emailChangeLimiter = emailChangeLimiter;
|
|
216
|
+
this.auditSink = auditSink;
|
|
185
217
|
}
|
|
186
218
|
/**
|
|
187
219
|
* Resolve a human-readable otpauth account LABEL for the caller (email, else display
|
|
@@ -330,11 +362,20 @@ let AccountController = class AccountController {
|
|
|
330
362
|
await this.loginLimiter?.reset(loginKey);
|
|
331
363
|
const newHash = await hasher.hash(newPassword);
|
|
332
364
|
await writer.setPassword(claims.id, newHash);
|
|
365
|
+
// Phase 12 (A8): a pending email change requested with the OLD password dies with it. Burned
|
|
366
|
+
// after the write (a new request then needs the new password); a failure is surfaced after the
|
|
367
|
+
// session sweep below ran.
|
|
368
|
+
let burnError;
|
|
369
|
+
await this.emailChangeTokens?.invalidateAllForPrincipal(claims.id).catch((err) => {
|
|
370
|
+
burnError = err;
|
|
371
|
+
});
|
|
333
372
|
// Revoke the caller's OTHER sessions (keep the current one live). Best-effort:
|
|
334
373
|
// absent seam → skip (the password is already changed).
|
|
335
374
|
if (this.sweeper !== undefined && this.sweeper !== null) {
|
|
336
375
|
await this.sweeper.revokeOthersForPrincipal(claims.id, claims.sessionId);
|
|
337
376
|
}
|
|
377
|
+
if (burnError !== undefined)
|
|
378
|
+
throw burnError;
|
|
338
379
|
}
|
|
339
380
|
/**
|
|
340
381
|
* Verify a presented TOTP `code` against a base32 `secret` using the injected core
|
|
@@ -349,53 +390,195 @@ let AccountController = class AccountController {
|
|
|
349
390
|
return verdict.satisfied;
|
|
350
391
|
}
|
|
351
392
|
/**
|
|
352
|
-
* `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* Only the fields actually supplied are written (partial edit). At least one of
|
|
358
|
-
* `name`/`email` must be present and non-empty; a supplied email must look like an
|
|
359
|
-
* email. These are semantic validations on the caller's OWN profile (no credential
|
|
360
|
-
* oracle), so they return precise 422 reasons rather than a generic 4xx.
|
|
393
|
+
* `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN profile. Self-scoped: acts only
|
|
394
|
+
* on `claims.id`. A `name` is applied at once. An `email` is NEVER written here (security
|
|
395
|
+
* hardening Phase 12, KI #14): the request is verified and a confirm link goes to the NEW
|
|
396
|
+
* address; `POST /auth/confirm-email` applies it.
|
|
361
397
|
*
|
|
362
398
|
* Outcomes:
|
|
363
|
-
* • no/invalid token → 401 (generic)
|
|
364
|
-
* •
|
|
365
|
-
* •
|
|
366
|
-
* • email
|
|
367
|
-
* •
|
|
399
|
+
* • no/invalid token → 401 (generic); re-enroll session → 403
|
|
400
|
+
* • neither name nor email supplied → 422 `no_changes`
|
|
401
|
+
* • email malformed → 422 `invalid_email`
|
|
402
|
+
* • email collation-equal to the current → 422 `no_changes`
|
|
403
|
+
* • a seam unplugged → 501 `not_available`
|
|
404
|
+
* • email without / with a wrong `currentPassword` → 400 `invalid_current_password`
|
|
405
|
+
* (counted on the account's `login:pid` budget, like change-password); locked → 429
|
|
406
|
+
* • a confirmed MFA device and no `code` → 400 `mfa_required`; wrong code → 400
|
|
407
|
+
* `invalid_request` (`mfa:pid` budget, replay-guarded TOTP or a recovery code, A1); locked → 429
|
|
408
|
+
* • `email-change:pid` budget exhausted → 429 `too_many_requests`
|
|
409
|
+
* • a limiter / MFA device or recovery-code store / token store / mail outage → 503
|
|
410
|
+
* `authentication_unavailable` (the token is issued before the second factor is checked, so
|
|
411
|
+
* a STORE outage never spends a recovery code or a TOTP; a failed proof burns that token. A
|
|
412
|
+
* mail failure after a successful proof does spend it — the proof precedes the send)
|
|
413
|
+
* • name only → 200 (the refreshed CurrentUserProfile)
|
|
414
|
+
* • email accepted → 202 `{ pendingEmail: true, profile }`
|
|
415
|
+
*
|
|
416
|
+
* The token (32 random bytes) is stored only as its sha256, bound to the canonical new address,
|
|
417
|
+
* the current email and this session (A3), and reaches the user only inside the mailed link's
|
|
418
|
+
* fragment (`#t=`). This handler writes no log line; the dev `LogEmailSender` (development/test
|
|
419
|
+
* only) is the one place the link is printed, exactly like a reset link.
|
|
368
420
|
*/
|
|
369
|
-
async updateProfile(authorization, body) {
|
|
421
|
+
async updateProfile(authorization, body, res) {
|
|
370
422
|
const claims = await this.requireClaims(authorization);
|
|
371
423
|
// BLOCKED during re-enroll: profile edits are not part of re-securing the account.
|
|
372
424
|
this.enforceReenroll(claims, false);
|
|
373
425
|
// Normalize: treat only non-empty string fields as a real change (trim so a
|
|
374
426
|
// whitespace-only value is not a change).
|
|
375
427
|
const name = typeof body?.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : undefined;
|
|
376
|
-
const
|
|
377
|
-
? body.email.trim()
|
|
378
|
-
: undefined;
|
|
428
|
+
const rawEmail = typeof body?.email === 'string' && body.email.trim().length > 0 ? body.email : undefined;
|
|
379
429
|
// Nothing to update → explicit 422 (rather than a silent no-op re-read).
|
|
380
|
-
if (name === undefined &&
|
|
430
|
+
if (name === undefined && rawEmail === undefined) {
|
|
381
431
|
unprocessable('no_changes');
|
|
382
432
|
}
|
|
383
|
-
//
|
|
384
|
-
|
|
433
|
+
// A5: canonicalise ONCE; this value is the token row, the collision check and the write.
|
|
434
|
+
const email = rawEmail === undefined ? undefined : (0, email_address_js_1.canonicalEmail)(rawEmail);
|
|
435
|
+
if (email !== undefined && !(0, email_address_js_1.isEmailShaped)(email)) {
|
|
385
436
|
unprocessable('invalid_email');
|
|
386
437
|
}
|
|
387
438
|
// Fail-closed when the write seam is unplugged — an honest "not available".
|
|
388
439
|
if (this.profileWriter === undefined || this.profileWriter === null) {
|
|
389
440
|
notAvailable();
|
|
390
441
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
442
|
+
const profileWriter = this.profileWriter;
|
|
443
|
+
if (email === undefined) {
|
|
444
|
+
return profileWriter.update(claims.id, { name: name });
|
|
445
|
+
}
|
|
446
|
+
await this.requestEmailChange(claims, email, body);
|
|
447
|
+
// A name in the same body still applies at once; the email waits for confirmation.
|
|
448
|
+
const profile = await profileWriter.update(claims.id, name === undefined ? {} : { name });
|
|
449
|
+
res.status(common_1.HttpStatus.ACCEPTED);
|
|
450
|
+
return { pendingEmail: true, profile };
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* The verified half of an email change request (Phase 12): checks, proofs, budget, token, mail,
|
|
454
|
+
* audit. Throws the contract's HttpException on every refusal; returns once the link is sent.
|
|
455
|
+
*/
|
|
456
|
+
async requestEmailChange(claims, email, body) {
|
|
457
|
+
const tokens = this.emailChangeTokens;
|
|
458
|
+
const addresses = this.emailAddresses;
|
|
459
|
+
const sender = this.emailSender;
|
|
460
|
+
const writer = this.writer;
|
|
461
|
+
const hasher = this.hasher;
|
|
462
|
+
const devices = this.mfaDevices;
|
|
463
|
+
if (tokens == null ||
|
|
464
|
+
addresses == null ||
|
|
465
|
+
sender == null ||
|
|
466
|
+
writer == null ||
|
|
467
|
+
hasher === undefined ||
|
|
468
|
+
devices == null) {
|
|
469
|
+
notAvailable();
|
|
470
|
+
}
|
|
471
|
+
let current;
|
|
472
|
+
try {
|
|
473
|
+
current = await addresses.readCurrent(claims.id, email);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
retryableUnavailable();
|
|
477
|
+
}
|
|
478
|
+
if (current === null || !current.active)
|
|
479
|
+
unauthorized();
|
|
480
|
+
if (current.sameAsCandidate)
|
|
481
|
+
unprocessable('no_changes');
|
|
482
|
+
// The current password, on the account's shared `login:pid` budget (as change-password): the
|
|
483
|
+
// attempt is counted first; a missing password is the same counted 400 as a wrong one.
|
|
484
|
+
const currentPassword = typeof body?.currentPassword === 'string' ? body.currentPassword : '';
|
|
485
|
+
const loginKey = (0, canonical_hint_js_1.loginHintKey)('', claims.id);
|
|
486
|
+
await consumeAttempt(this.loginLimiter, loginKey);
|
|
487
|
+
let matches;
|
|
488
|
+
try {
|
|
489
|
+
const stored = await writer.getPasswordHash(claims.id);
|
|
490
|
+
matches = stored !== null && (await hasher.verify(stored, currentPassword));
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
await this.loginLimiter?.refund(loginKey);
|
|
494
|
+
retryableUnavailable();
|
|
495
|
+
}
|
|
496
|
+
if (!matches)
|
|
497
|
+
invalidCurrentPassword();
|
|
498
|
+
await this.loginLimiter?.reset(loginKey);
|
|
499
|
+
// The per-account request budget, counted only once the password proved the caller (a typo
|
|
500
|
+
// never spends it); given back when the second factor is missing or wrong, and when a store
|
|
501
|
+
// outage stops the request before any link is mailed (Phase 13 #2b). A mail failure keeps it
|
|
502
|
+
// counted: the sender may have delivered before it failed.
|
|
503
|
+
const requestKey = `email-change:pid:${claims.id}`;
|
|
504
|
+
await consumeAttempt(this.emailChangeLimiter, requestKey);
|
|
505
|
+
const giveBackRequest = async () => {
|
|
506
|
+
await this.emailChangeLimiter?.refund(requestKey).catch(() => undefined);
|
|
507
|
+
};
|
|
508
|
+
// A1: a confirmed MFA device ⇒ a second factor. A store outage here is a 503, not a 500.
|
|
509
|
+
let code = null;
|
|
510
|
+
try {
|
|
511
|
+
if ((await devices.countConfirmed(claims.id)) > 0) {
|
|
512
|
+
code = typeof body?.code === 'string' ? body.code.trim() : '';
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
await giveBackRequest();
|
|
517
|
+
retryableUnavailable();
|
|
518
|
+
}
|
|
519
|
+
if (code === '') {
|
|
520
|
+
await giveBackRequest();
|
|
521
|
+
mfaRequired();
|
|
522
|
+
}
|
|
523
|
+
// A2/A3/A4: one pending change; 32 random bytes, stored as sha256, bound to the canonical new
|
|
524
|
+
// address, the current email and this session; 30-minute TTL.
|
|
525
|
+
// Phase 13 #2b: the token is issued BEFORE the second factor is checked, so a token-store outage
|
|
526
|
+
// never spends a recovery code (consumed on a match) or a TOTP (claimed by the replay guard).
|
|
527
|
+
// Until the mail goes out the raw token exists only in this handler, so a row whose proof then
|
|
528
|
+
// fails is inert; it is burned anyway (best effort).
|
|
529
|
+
const rawToken = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
530
|
+
try {
|
|
531
|
+
await tokens.issue({
|
|
532
|
+
tokenHash: sha256Hex(rawToken),
|
|
533
|
+
principalId: claims.id,
|
|
534
|
+
newEmail: email,
|
|
535
|
+
oldEmail: current.email,
|
|
536
|
+
sessionId: claims.sessionId,
|
|
537
|
+
tenantId: claims.tenantId,
|
|
538
|
+
expiresAtMs: Date.now() + email_change_messages_js_1.EMAIL_CHANGE_TTL_MS,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
await giveBackRequest();
|
|
543
|
+
retryableUnavailable();
|
|
544
|
+
}
|
|
545
|
+
// A1: TOTP via the replay-guarded verifier, or a recovery code, on the shared `mfa:pid` budget,
|
|
546
|
+
// exactly like the code-gated MFA routes. Any refusal (400/429/503) burns the token just issued
|
|
547
|
+
// and gives the request budget back; a store error inside the proof is a 503, not a 500.
|
|
548
|
+
if (code !== null) {
|
|
549
|
+
try {
|
|
550
|
+
await this.requireMfaProof(devices, claims.id, code);
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
await tokens.invalidateAllForPrincipal(claims.id).catch(() => undefined);
|
|
554
|
+
await giveBackRequest();
|
|
555
|
+
if (err instanceof common_1.HttpException)
|
|
556
|
+
throw err;
|
|
557
|
+
retryableUnavailable();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const link = (0, email_change_messages_js_1.emailChangeLink)(this.emailChangeLinkBaseUrl ?? email_change_messages_js_1.DEFAULT_EMAIL_CHANGE_LINK_BASE_URL, rawToken);
|
|
561
|
+
try {
|
|
562
|
+
await sender.send((0, email_change_messages_js_1.confirmEmailChangeMessage)(email, link));
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
// Undeliverable: burn the token so no orphan link can be used later, and say "retry".
|
|
566
|
+
await tokens.invalidateAllForPrincipal(claims.id).catch(() => undefined);
|
|
567
|
+
retryableUnavailable();
|
|
568
|
+
}
|
|
569
|
+
// A9: audit the request with the new address masked (no addresses in full, no token).
|
|
570
|
+
void this.auditSink
|
|
571
|
+
?.record({
|
|
572
|
+
type: 'authentication',
|
|
573
|
+
occurredAtMs: Date.now(),
|
|
574
|
+
actorId: claims.id,
|
|
575
|
+
tenantId: claims.tenantId,
|
|
576
|
+
action: 'account.email_change_requested',
|
|
577
|
+
attributes: { newEmail: (0, email_address_js_1.maskEmail)(email) },
|
|
578
|
+
})
|
|
579
|
+
.catch(() => {
|
|
580
|
+
/* best-effort: an audit failure never changes the response */
|
|
581
|
+
});
|
|
399
582
|
}
|
|
400
583
|
// ───────────────────────────────────────────────────────────────────────────
|
|
401
584
|
// Multi-device MFA — self-scoped device management. Every route
|
|
@@ -495,7 +678,16 @@ let AccountController = class AccountController {
|
|
|
495
678
|
* material (`otpauthUri` for a QR + `secret` for manual entry). NOT active until
|
|
496
679
|
* `confirm`. A blank name defaults to `"Authenticator N"` (N = confirmed count + 1).
|
|
497
680
|
*
|
|
498
|
-
*
|
|
681
|
+
* KI #14 re-review P1-1: when the caller ALREADY has a confirmed device, adding another needs a
|
|
682
|
+
* second-factor `code` (a TOTP from an existing device via the replay-guarded verifier, or a
|
|
683
|
+
* recovery code; `mfa:pid` budget — {@link requireMfaProof}). Otherwise a stolen session alone
|
|
684
|
+
* could enrol the attacker's authenticator and use it for every code-gated step-up (A1). The
|
|
685
|
+
* recovery-login re-enroll session (`mustReenroll`, durable `s_must_reenroll`) is exempt: it
|
|
686
|
+
* proved a recovery code at sign-in and has lost its device, so it cannot produce a code.
|
|
687
|
+
*
|
|
688
|
+
* Outcomes: 401; store absent → 501; MFA on and no `code` → 400 `mfa_required`; wrong code →
|
|
689
|
+
* 400 `invalid_request`; MFA budget exhausted → 429; limiter/replay guard unreachable → 503;
|
|
690
|
+
* success → 200 `{ deviceId, otpauthUri, secret }`.
|
|
499
691
|
*/
|
|
500
692
|
async beginDevice(authorization, body) {
|
|
501
693
|
const claims = await this.requireClaims(authorization);
|
|
@@ -504,10 +696,17 @@ let AccountController = class AccountController {
|
|
|
504
696
|
if (this.mfaDevices === undefined || this.mfaDevices === null)
|
|
505
697
|
notAvailable();
|
|
506
698
|
const store = this.mfaDevices;
|
|
699
|
+
const confirmedCount = await store.countConfirmed(claims.id);
|
|
700
|
+
if (confirmedCount > 0 && claims.mustReenroll !== true) {
|
|
701
|
+
const code = typeof body?.code === 'string' ? body.code.trim() : '';
|
|
702
|
+
if (code === '')
|
|
703
|
+
mfaRequired();
|
|
704
|
+
await this.requireMfaProof(store, claims.id, code);
|
|
705
|
+
}
|
|
507
706
|
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
508
707
|
const name = rawName.length > 0
|
|
509
708
|
? rawName.slice(0, MAX_DEVICE_NAME_LEN)
|
|
510
|
-
: `Authenticator ${
|
|
709
|
+
: `Authenticator ${confirmedCount + 1}`;
|
|
511
710
|
const secret = (0, totp_enrollment_js_1.generateTotpSecret)();
|
|
512
711
|
const deviceId = await store.begin(claims.id, name, secret);
|
|
513
712
|
const otpauthUri = (0, totp_enrollment_js_1.buildOtpauthUri)(secret, await this.resolveOtpauthLabel(claims), TOTP_ISSUER);
|
|
@@ -520,8 +719,17 @@ let AccountController = class AccountController {
|
|
|
520
719
|
* codes are minted (shown once) and returned; subsequent devices return `{}` and do
|
|
521
720
|
* NOT touch recovery codes.
|
|
522
721
|
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
722
|
+
* KI #14 re-review P1-1 (defence in depth for the code-gated `begin`): when MFA is already on and
|
|
723
|
+
* the session is not a re-enroll session, only a pending device that `begin` created behind its
|
|
724
|
+
* step-up can be confirmed. A pending device carries no proof flag, so the binding is by time:
|
|
725
|
+
* while MFA is on, `begin` cannot create a pending device without a proof, so the device must be
|
|
726
|
+
* younger than the pending window ({@link PENDING_DEVICE_MAX_AGE_MS}, the store's self-prune
|
|
727
|
+
* TTL) and not older than the newest confirmed device. That refuses a device begun while MFA was
|
|
728
|
+
* still off (e.g. by a stolen session, before the user enrolled) and any stale pending row.
|
|
729
|
+
*
|
|
730
|
+
* Outcomes: 401; store/recovery/hasher absent → 501; device not pending/owned, or (MFA on) not
|
|
731
|
+
* bound to a step-up begin → 404; wrong/expired code → 400 (device stays pending); success →
|
|
732
|
+
* 200 `{ recoveryCodes? }`.
|
|
525
733
|
*/
|
|
526
734
|
async confirmDevice(authorization, body) {
|
|
527
735
|
const claims = await this.requireClaims(authorization);
|
|
@@ -547,11 +755,22 @@ let AccountController = class AccountController {
|
|
|
547
755
|
if (secret === null) {
|
|
548
756
|
throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
549
757
|
}
|
|
758
|
+
// First-device check BEFORE confirming (so the just-confirmed device isn't counted).
|
|
759
|
+
const wasFirst = (await store.countConfirmed(claims.id)) === 0;
|
|
760
|
+
// MFA already on: the pending device must come from a step-up `begin` (see the doc above).
|
|
761
|
+
if (!wasFirst && claims.mustReenroll !== true) {
|
|
762
|
+
const all = await store.listAll(claims.id);
|
|
763
|
+
const pending = all.find((d) => d.id === deviceId && !d.confirmed);
|
|
764
|
+
const newestConfirmed = Math.max(...all.filter((d) => d.confirmed).map((d) => d.createdAtMs));
|
|
765
|
+
if (pending === undefined ||
|
|
766
|
+
Date.now() - pending.createdAtMs > PENDING_DEVICE_MAX_AGE_MS ||
|
|
767
|
+
pending.createdAtMs < newestConfirmed) {
|
|
768
|
+
throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
550
771
|
// Wrong/expired code → generic 400; the device stays pending (retry or GC later).
|
|
551
772
|
if (!(await this.verifyTotp(secret, code)))
|
|
552
773
|
badRequest();
|
|
553
|
-
// First-device check BEFORE confirming (so the just-confirmed device isn't counted).
|
|
554
|
-
const wasFirst = (await store.countConfirmed(claims.id)) === 0;
|
|
555
774
|
const confirmed = await store.confirm(claims.id, deviceId);
|
|
556
775
|
// A race (device removed / already confirmed between load and confirm) → 404.
|
|
557
776
|
if (!confirmed) {
|
|
@@ -697,8 +916,9 @@ __decorate([
|
|
|
697
916
|
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
698
917
|
__param(0, (0, common_1.Headers)('authorization')),
|
|
699
918
|
__param(1, (0, common_1.Body)()),
|
|
919
|
+
__param(2, (0, common_1.Res)({ passthrough: true })),
|
|
700
920
|
__metadata("design:type", Function),
|
|
701
|
-
__metadata("design:paramtypes", [Object, Object]),
|
|
921
|
+
__metadata("design:paramtypes", [Object, Object, Object]),
|
|
702
922
|
__metadata("design:returntype", Promise)
|
|
703
923
|
], AccountController.prototype, "updateProfile", null);
|
|
704
924
|
__decorate([
|
|
@@ -790,20 +1010,17 @@ exports.AccountController = AccountController = __decorate([
|
|
|
790
1010
|
__param(11, (0, common_1.Inject)(tokens_js_2.SHIELD_MFA_LIMITER)),
|
|
791
1011
|
__param(12, (0, common_1.Optional)()),
|
|
792
1012
|
__param(12, (0, common_1.Inject)(tokens_js_2.SHIELD_LOGIN_LIMITER)),
|
|
793
|
-
|
|
1013
|
+
__param(13, (0, common_1.Optional)()),
|
|
1014
|
+
__param(13, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_CHANGE_TOKEN_STORE)),
|
|
1015
|
+
__param(14, (0, common_1.Optional)()),
|
|
1016
|
+
__param(14, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_ADDRESS_CHANGER)),
|
|
1017
|
+
__param(15, (0, common_1.Optional)()),
|
|
1018
|
+
__param(15, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_SENDER)),
|
|
1019
|
+
__param(16, (0, common_1.Optional)()),
|
|
1020
|
+
__param(16, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_CHANGE_LINK_BASE_URL)),
|
|
1021
|
+
__param(17, (0, common_1.Optional)()),
|
|
1022
|
+
__param(17, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_CHANGE_LIMITER)),
|
|
1023
|
+
__param(18, (0, common_1.Optional)()),
|
|
1024
|
+
__param(18, (0, common_1.Inject)(tokens_js_2.SHIELD_AUDIT_SINK)),
|
|
1025
|
+
__metadata("design:paramtypes", [Function, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object])
|
|
794
1026
|
], AccountController);
|
|
795
|
-
/**
|
|
796
|
-
* A deliberately-minimal email shape check (non-empty, a single `@`, a `.` in the
|
|
797
|
-
* domain, no whitespace). This is a self-service edit of the caller's own profile —
|
|
798
|
-
* a full RFC-5322 validator adds no security value here, only false rejections. The
|
|
799
|
-
* authoritative check is delivery; this just catches obvious typos client-adjacent.
|
|
800
|
-
*/
|
|
801
|
-
function isEmailShaped(value) {
|
|
802
|
-
if (/\s/.test(value))
|
|
803
|
-
return false;
|
|
804
|
-
const at = value.indexOf('@');
|
|
805
|
-
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1)
|
|
806
|
-
return false;
|
|
807
|
-
const domain = value.slice(at + 1);
|
|
808
|
-
return domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.');
|
|
809
|
-
}
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
* composed engines via DI tokens; it never news-up an engine or an adapter.
|
|
54
54
|
*/
|
|
55
55
|
import type { Authenticator, PasswordHasher, SessionAuthority } from '@astram/core';
|
|
56
|
+
import type { EmailChangeTokenStore } from './ports/email-change-token-store.js';
|
|
56
57
|
import type { SessionRestrictionStore } from '../session/session-restriction-store.js';
|
|
57
58
|
import type { SessionRecordStore } from '@astram/core';
|
|
58
59
|
import type { Clock, SessionLifetimeConfig } from '@astram/core';
|
|
@@ -113,7 +114,8 @@ export declare class AuthController {
|
|
|
113
114
|
private readonly rootIds?;
|
|
114
115
|
private readonly sessionRecords?;
|
|
115
116
|
private readonly sessionRestrictions?;
|
|
116
|
-
|
|
117
|
+
private readonly emailChangeTokens?;
|
|
118
|
+
constructor(authenticator: Authenticator, sessionAuthority: SessionAuthority, pendingMfa: PendingMfaStore, tokenBinder?: SessionTokenBinder | undefined, profileResolver?: ProfileResolver | undefined, credentialResolver?: (CredentialResolver | null) | undefined, resetTokenStore?: (ResetTokenStore | null) | undefined, emailSender?: (EmailSender | null) | undefined, credentialWriter?: (CredentialWriter | null) | undefined, sessionSweeper?: (SessionSweeper | null) | undefined, passwordHasher?: PasswordHasher | undefined, resetLinkBaseUrl?: string | undefined, recoveryCodes?: (RecoveryCodeStore | null) | undefined, loginLimiter?: (AttemptLimiter | null) | undefined, loginIpLimiter?: (AttemptLimiter | null) | undefined, trustProxyCheck?: (TrustProxyBootCheck | null) | undefined, recoveryLimiter?: (AttemptLimiter | null) | undefined, forgotLimiter?: (AttemptLimiter | null) | undefined, forgotHintLimiter?: (AttemptLimiter | null) | undefined, resetLimiter?: (AttemptLimiter | null) | undefined, auditSink?: (AuditSink | null) | undefined, sessionFactsProjector?: (SessionFactsProjector | null) | undefined, scopeSubtreeResolver?: (ScopeSubtreeResolver | null) | undefined, membershipChecker?: (MembershipChecker | null) | undefined, permissionFacts?: (MariaDbPermissionFactsAdapter | null) | undefined, mfaLimiter?: (AttemptLimiter | null) | undefined, mfaStepUp?: (MfaStepUp | null) | undefined, clock?: (Clock | null) | undefined, lifetimes?: (SessionLifetimeConfig | null) | undefined, rootIds?: (readonly string[] | null) | undefined, sessionRecords?: (Pick<SessionRecordStore, "findSessionById"> | null) | undefined, sessionRestrictions?: (SessionRestrictionStore | null) | undefined, emailChangeTokens?: (EmailChangeTokenStore | null) | undefined);
|
|
117
119
|
/**
|
|
118
120
|
* The access-token binding TTL for an establishment (security hardening #8):
|
|
119
121
|
* `clamp(session.expiresAtMs − now, 1, accessTtlMs)` — on login AND refresh. Never derived
|