@astram/guard 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/account.controller.d.ts +73 -19
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +241 -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 +6 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +53 -1
- 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 +21 -0
- package/dist/auth/email-change-messages.d.ts.map +1 -0
- package/dist/auth/email-change-messages.js +50 -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 +36 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +37 -1
- 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 +9 -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,42 @@ 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 / token store / mail outage → 503 `authentication_unavailable`
|
|
177
|
+
* • name only → 200 (the refreshed CurrentUserProfile)
|
|
178
|
+
* • email accepted → 202 `{ pendingEmail: true, profile }`
|
|
179
|
+
*
|
|
180
|
+
* The token (32 random bytes) is stored only as its sha256, bound to the canonical new address,
|
|
181
|
+
* the current email and this session (A3), and reaches the user only inside the mailed link's
|
|
182
|
+
* fragment (`#t=`). This handler writes no log line; the dev `LogEmailSender` (development/test
|
|
183
|
+
* only) is the one place the link is printed, exactly like a reset link.
|
|
157
184
|
*/
|
|
158
185
|
updateProfile(authorization: string | undefined, body: {
|
|
159
186
|
name?: string;
|
|
160
187
|
email?: string;
|
|
161
|
-
|
|
188
|
+
currentPassword?: string;
|
|
189
|
+
code?: string;
|
|
190
|
+
}, res: StatusSettable): Promise<CurrentUserProfile | PendingEmailChange>;
|
|
191
|
+
/**
|
|
192
|
+
* The verified half of an email change request (Phase 12): checks, proofs, budget, token, mail,
|
|
193
|
+
* audit. Throws the contract's HttpException on every refusal; returns once the link is sent.
|
|
194
|
+
*/
|
|
195
|
+
private requestEmailChange;
|
|
162
196
|
/**
|
|
163
197
|
* Mint a fresh recovery-code batch, hash each (the SAME Argon2 hasher recovery-code
|
|
164
198
|
* consume verifies against), and REPLACE the caller's prior codes. Returns the RAW
|
|
@@ -206,10 +240,20 @@ export declare class AccountController {
|
|
|
206
240
|
* material (`otpauthUri` for a QR + `secret` for manual entry). NOT active until
|
|
207
241
|
* `confirm`. A blank name defaults to `"Authenticator N"` (N = confirmed count + 1).
|
|
208
242
|
*
|
|
209
|
-
*
|
|
243
|
+
* KI #14 re-review P1-1: when the caller ALREADY has a confirmed device, adding another needs a
|
|
244
|
+
* second-factor `code` (a TOTP from an existing device via the replay-guarded verifier, or a
|
|
245
|
+
* recovery code; `mfa:pid` budget — {@link requireMfaProof}). Otherwise a stolen session alone
|
|
246
|
+
* could enrol the attacker's authenticator and use it for every code-gated step-up (A1). The
|
|
247
|
+
* recovery-login re-enroll session (`mustReenroll`, durable `s_must_reenroll`) is exempt: it
|
|
248
|
+
* proved a recovery code at sign-in and has lost its device, so it cannot produce a code.
|
|
249
|
+
*
|
|
250
|
+
* Outcomes: 401; store absent → 501; MFA on and no `code` → 400 `mfa_required`; wrong code →
|
|
251
|
+
* 400 `invalid_request`; MFA budget exhausted → 429; limiter/replay guard unreachable → 503;
|
|
252
|
+
* success → 200 `{ deviceId, otpauthUri, secret }`.
|
|
210
253
|
*/
|
|
211
254
|
beginDevice(authorization: string | undefined, body: {
|
|
212
255
|
name?: string;
|
|
256
|
+
code?: string;
|
|
213
257
|
}): Promise<{
|
|
214
258
|
deviceId: string;
|
|
215
259
|
otpauthUri: string;
|
|
@@ -222,8 +266,17 @@ export declare class AccountController {
|
|
|
222
266
|
* codes are minted (shown once) and returned; subsequent devices return `{}` and do
|
|
223
267
|
* NOT touch recovery codes.
|
|
224
268
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
269
|
+
* KI #14 re-review P1-1 (defence in depth for the code-gated `begin`): when MFA is already on and
|
|
270
|
+
* the session is not a re-enroll session, only a pending device that `begin` created behind its
|
|
271
|
+
* step-up can be confirmed. A pending device carries no proof flag, so the binding is by time:
|
|
272
|
+
* while MFA is on, `begin` cannot create a pending device without a proof, so the device must be
|
|
273
|
+
* younger than the pending window ({@link PENDING_DEVICE_MAX_AGE_MS}, the store's self-prune
|
|
274
|
+
* TTL) and not older than the newest confirmed device. That refuses a device begun while MFA was
|
|
275
|
+
* still off (e.g. by a stolen session, before the user enrolled) and any stale pending row.
|
|
276
|
+
*
|
|
277
|
+
* Outcomes: 401; store/recovery/hasher absent → 501; device not pending/owned, or (MFA on) not
|
|
278
|
+
* bound to a step-up begin → 404; wrong/expired code → 400 (device stays pending); success →
|
|
279
|
+
* 200 `{ recoveryCodes? }`.
|
|
227
280
|
*/
|
|
228
281
|
confirmDevice(authorization: string | undefined, body: {
|
|
229
282
|
deviceId?: string;
|
|
@@ -272,4 +325,5 @@ export declare class AccountController {
|
|
|
272
325
|
recoveryCodes: string[];
|
|
273
326
|
}>;
|
|
274
327
|
}
|
|
328
|
+
export {};
|
|
275
329
|
//# 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;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;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;IAsHhC;;;;;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,166 @@ 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 / token store / mail outage → 503 `authentication_unavailable`
|
|
410
|
+
* • name only → 200 (the refreshed CurrentUserProfile)
|
|
411
|
+
* • email accepted → 202 `{ pendingEmail: true, profile }`
|
|
412
|
+
*
|
|
413
|
+
* The token (32 random bytes) is stored only as its sha256, bound to the canonical new address,
|
|
414
|
+
* the current email and this session (A3), and reaches the user only inside the mailed link's
|
|
415
|
+
* fragment (`#t=`). This handler writes no log line; the dev `LogEmailSender` (development/test
|
|
416
|
+
* only) is the one place the link is printed, exactly like a reset link.
|
|
368
417
|
*/
|
|
369
|
-
async updateProfile(authorization, body) {
|
|
418
|
+
async updateProfile(authorization, body, res) {
|
|
370
419
|
const claims = await this.requireClaims(authorization);
|
|
371
420
|
// BLOCKED during re-enroll: profile edits are not part of re-securing the account.
|
|
372
421
|
this.enforceReenroll(claims, false);
|
|
373
422
|
// Normalize: treat only non-empty string fields as a real change (trim so a
|
|
374
423
|
// whitespace-only value is not a change).
|
|
375
424
|
const name = typeof body?.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : undefined;
|
|
376
|
-
const
|
|
377
|
-
? body.email.trim()
|
|
378
|
-
: undefined;
|
|
425
|
+
const rawEmail = typeof body?.email === 'string' && body.email.trim().length > 0 ? body.email : undefined;
|
|
379
426
|
// Nothing to update → explicit 422 (rather than a silent no-op re-read).
|
|
380
|
-
if (name === undefined &&
|
|
427
|
+
if (name === undefined && rawEmail === undefined) {
|
|
381
428
|
unprocessable('no_changes');
|
|
382
429
|
}
|
|
383
|
-
//
|
|
384
|
-
|
|
430
|
+
// A5: canonicalise ONCE; this value is the token row, the collision check and the write.
|
|
431
|
+
const email = rawEmail === undefined ? undefined : (0, email_address_js_1.canonicalEmail)(rawEmail);
|
|
432
|
+
if (email !== undefined && !(0, email_address_js_1.isEmailShaped)(email)) {
|
|
385
433
|
unprocessable('invalid_email');
|
|
386
434
|
}
|
|
387
435
|
// Fail-closed when the write seam is unplugged — an honest "not available".
|
|
388
436
|
if (this.profileWriter === undefined || this.profileWriter === null) {
|
|
389
437
|
notAvailable();
|
|
390
438
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
439
|
+
const profileWriter = this.profileWriter;
|
|
440
|
+
if (email === undefined) {
|
|
441
|
+
return profileWriter.update(claims.id, { name: name });
|
|
442
|
+
}
|
|
443
|
+
await this.requestEmailChange(claims, email, body);
|
|
444
|
+
// A name in the same body still applies at once; the email waits for confirmation.
|
|
445
|
+
const profile = await profileWriter.update(claims.id, name === undefined ? {} : { name });
|
|
446
|
+
res.status(common_1.HttpStatus.ACCEPTED);
|
|
447
|
+
return { pendingEmail: true, profile };
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The verified half of an email change request (Phase 12): checks, proofs, budget, token, mail,
|
|
451
|
+
* audit. Throws the contract's HttpException on every refusal; returns once the link is sent.
|
|
452
|
+
*/
|
|
453
|
+
async requestEmailChange(claims, email, body) {
|
|
454
|
+
const tokens = this.emailChangeTokens;
|
|
455
|
+
const addresses = this.emailAddresses;
|
|
456
|
+
const sender = this.emailSender;
|
|
457
|
+
const writer = this.writer;
|
|
458
|
+
const hasher = this.hasher;
|
|
459
|
+
const devices = this.mfaDevices;
|
|
460
|
+
if (tokens == null ||
|
|
461
|
+
addresses == null ||
|
|
462
|
+
sender == null ||
|
|
463
|
+
writer == null ||
|
|
464
|
+
hasher === undefined ||
|
|
465
|
+
devices == null) {
|
|
466
|
+
notAvailable();
|
|
467
|
+
}
|
|
468
|
+
let current;
|
|
469
|
+
try {
|
|
470
|
+
current = await addresses.readCurrent(claims.id, email);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
retryableUnavailable();
|
|
474
|
+
}
|
|
475
|
+
if (current === null || !current.active)
|
|
476
|
+
unauthorized();
|
|
477
|
+
if (current.sameAsCandidate)
|
|
478
|
+
unprocessable('no_changes');
|
|
479
|
+
// The current password, on the account's shared `login:pid` budget (as change-password): the
|
|
480
|
+
// attempt is counted first; a missing password is the same counted 400 as a wrong one.
|
|
481
|
+
const currentPassword = typeof body?.currentPassword === 'string' ? body.currentPassword : '';
|
|
482
|
+
const loginKey = (0, canonical_hint_js_1.loginHintKey)('', claims.id);
|
|
483
|
+
await consumeAttempt(this.loginLimiter, loginKey);
|
|
484
|
+
let matches;
|
|
485
|
+
try {
|
|
486
|
+
const stored = await writer.getPasswordHash(claims.id);
|
|
487
|
+
matches = stored !== null && (await hasher.verify(stored, currentPassword));
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
await this.loginLimiter?.refund(loginKey);
|
|
491
|
+
retryableUnavailable();
|
|
492
|
+
}
|
|
493
|
+
if (!matches)
|
|
494
|
+
invalidCurrentPassword();
|
|
495
|
+
await this.loginLimiter?.reset(loginKey);
|
|
496
|
+
// The per-account request budget, counted only once the password proved the caller (a typo
|
|
497
|
+
// never spends it); given back when the second factor is missing or wrong.
|
|
498
|
+
const requestKey = `email-change:pid:${claims.id}`;
|
|
499
|
+
await consumeAttempt(this.emailChangeLimiter, requestKey);
|
|
500
|
+
// A1: a confirmed MFA device ⇒ a second factor (TOTP via the replay-guarded verifier, or a
|
|
501
|
+
// recovery code) on the shared `mfa:pid` budget, exactly like the code-gated MFA routes.
|
|
502
|
+
try {
|
|
503
|
+
if ((await devices.countConfirmed(claims.id)) > 0) {
|
|
504
|
+
const code = typeof body?.code === 'string' ? body.code.trim() : '';
|
|
505
|
+
if (code === '')
|
|
506
|
+
mfaRequired();
|
|
507
|
+
await this.requireMfaProof(devices, claims.id, code);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
await this.emailChangeLimiter?.refund(requestKey);
|
|
512
|
+
throw err;
|
|
513
|
+
}
|
|
514
|
+
// A2/A3/A4: one pending change; 32 random bytes, stored as sha256, bound to the canonical new
|
|
515
|
+
// address, the current email and this session; 30-minute TTL.
|
|
516
|
+
const rawToken = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
517
|
+
try {
|
|
518
|
+
await tokens.issue({
|
|
519
|
+
tokenHash: sha256Hex(rawToken),
|
|
520
|
+
principalId: claims.id,
|
|
521
|
+
newEmail: email,
|
|
522
|
+
oldEmail: current.email,
|
|
523
|
+
sessionId: claims.sessionId,
|
|
524
|
+
tenantId: claims.tenantId,
|
|
525
|
+
expiresAtMs: Date.now() + email_change_messages_js_1.EMAIL_CHANGE_TTL_MS,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
retryableUnavailable();
|
|
530
|
+
}
|
|
531
|
+
const link = (0, email_change_messages_js_1.emailChangeLink)(this.emailChangeLinkBaseUrl ?? email_change_messages_js_1.DEFAULT_EMAIL_CHANGE_LINK_BASE_URL, rawToken);
|
|
532
|
+
try {
|
|
533
|
+
await sender.send((0, email_change_messages_js_1.confirmEmailChangeMessage)(email, link));
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
// Undeliverable: burn the token so no orphan link can be used later, and say "retry".
|
|
537
|
+
await tokens.invalidateAllForPrincipal(claims.id).catch(() => undefined);
|
|
538
|
+
retryableUnavailable();
|
|
539
|
+
}
|
|
540
|
+
// A9: audit the request with the new address masked (no addresses in full, no token).
|
|
541
|
+
void this.auditSink
|
|
542
|
+
?.record({
|
|
543
|
+
type: 'authentication',
|
|
544
|
+
occurredAtMs: Date.now(),
|
|
545
|
+
actorId: claims.id,
|
|
546
|
+
tenantId: claims.tenantId,
|
|
547
|
+
action: 'account.email_change_requested',
|
|
548
|
+
attributes: { newEmail: (0, email_address_js_1.maskEmail)(email) },
|
|
549
|
+
})
|
|
550
|
+
.catch(() => {
|
|
551
|
+
/* best-effort: an audit failure never changes the response */
|
|
552
|
+
});
|
|
399
553
|
}
|
|
400
554
|
// ───────────────────────────────────────────────────────────────────────────
|
|
401
555
|
// Multi-device MFA — self-scoped device management. Every route
|
|
@@ -495,7 +649,16 @@ let AccountController = class AccountController {
|
|
|
495
649
|
* material (`otpauthUri` for a QR + `secret` for manual entry). NOT active until
|
|
496
650
|
* `confirm`. A blank name defaults to `"Authenticator N"` (N = confirmed count + 1).
|
|
497
651
|
*
|
|
498
|
-
*
|
|
652
|
+
* KI #14 re-review P1-1: when the caller ALREADY has a confirmed device, adding another needs a
|
|
653
|
+
* second-factor `code` (a TOTP from an existing device via the replay-guarded verifier, or a
|
|
654
|
+
* recovery code; `mfa:pid` budget — {@link requireMfaProof}). Otherwise a stolen session alone
|
|
655
|
+
* could enrol the attacker's authenticator and use it for every code-gated step-up (A1). The
|
|
656
|
+
* recovery-login re-enroll session (`mustReenroll`, durable `s_must_reenroll`) is exempt: it
|
|
657
|
+
* proved a recovery code at sign-in and has lost its device, so it cannot produce a code.
|
|
658
|
+
*
|
|
659
|
+
* Outcomes: 401; store absent → 501; MFA on and no `code` → 400 `mfa_required`; wrong code →
|
|
660
|
+
* 400 `invalid_request`; MFA budget exhausted → 429; limiter/replay guard unreachable → 503;
|
|
661
|
+
* success → 200 `{ deviceId, otpauthUri, secret }`.
|
|
499
662
|
*/
|
|
500
663
|
async beginDevice(authorization, body) {
|
|
501
664
|
const claims = await this.requireClaims(authorization);
|
|
@@ -504,10 +667,17 @@ let AccountController = class AccountController {
|
|
|
504
667
|
if (this.mfaDevices === undefined || this.mfaDevices === null)
|
|
505
668
|
notAvailable();
|
|
506
669
|
const store = this.mfaDevices;
|
|
670
|
+
const confirmedCount = await store.countConfirmed(claims.id);
|
|
671
|
+
if (confirmedCount > 0 && claims.mustReenroll !== true) {
|
|
672
|
+
const code = typeof body?.code === 'string' ? body.code.trim() : '';
|
|
673
|
+
if (code === '')
|
|
674
|
+
mfaRequired();
|
|
675
|
+
await this.requireMfaProof(store, claims.id, code);
|
|
676
|
+
}
|
|
507
677
|
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
508
678
|
const name = rawName.length > 0
|
|
509
679
|
? rawName.slice(0, MAX_DEVICE_NAME_LEN)
|
|
510
|
-
: `Authenticator ${
|
|
680
|
+
: `Authenticator ${confirmedCount + 1}`;
|
|
511
681
|
const secret = (0, totp_enrollment_js_1.generateTotpSecret)();
|
|
512
682
|
const deviceId = await store.begin(claims.id, name, secret);
|
|
513
683
|
const otpauthUri = (0, totp_enrollment_js_1.buildOtpauthUri)(secret, await this.resolveOtpauthLabel(claims), TOTP_ISSUER);
|
|
@@ -520,8 +690,17 @@ let AccountController = class AccountController {
|
|
|
520
690
|
* codes are minted (shown once) and returned; subsequent devices return `{}` and do
|
|
521
691
|
* NOT touch recovery codes.
|
|
522
692
|
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
693
|
+
* KI #14 re-review P1-1 (defence in depth for the code-gated `begin`): when MFA is already on and
|
|
694
|
+
* the session is not a re-enroll session, only a pending device that `begin` created behind its
|
|
695
|
+
* step-up can be confirmed. A pending device carries no proof flag, so the binding is by time:
|
|
696
|
+
* while MFA is on, `begin` cannot create a pending device without a proof, so the device must be
|
|
697
|
+
* younger than the pending window ({@link PENDING_DEVICE_MAX_AGE_MS}, the store's self-prune
|
|
698
|
+
* TTL) and not older than the newest confirmed device. That refuses a device begun while MFA was
|
|
699
|
+
* still off (e.g. by a stolen session, before the user enrolled) and any stale pending row.
|
|
700
|
+
*
|
|
701
|
+
* Outcomes: 401; store/recovery/hasher absent → 501; device not pending/owned, or (MFA on) not
|
|
702
|
+
* bound to a step-up begin → 404; wrong/expired code → 400 (device stays pending); success →
|
|
703
|
+
* 200 `{ recoveryCodes? }`.
|
|
525
704
|
*/
|
|
526
705
|
async confirmDevice(authorization, body) {
|
|
527
706
|
const claims = await this.requireClaims(authorization);
|
|
@@ -547,11 +726,22 @@ let AccountController = class AccountController {
|
|
|
547
726
|
if (secret === null) {
|
|
548
727
|
throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
549
728
|
}
|
|
729
|
+
// First-device check BEFORE confirming (so the just-confirmed device isn't counted).
|
|
730
|
+
const wasFirst = (await store.countConfirmed(claims.id)) === 0;
|
|
731
|
+
// MFA already on: the pending device must come from a step-up `begin` (see the doc above).
|
|
732
|
+
if (!wasFirst && claims.mustReenroll !== true) {
|
|
733
|
+
const all = await store.listAll(claims.id);
|
|
734
|
+
const pending = all.find((d) => d.id === deviceId && !d.confirmed);
|
|
735
|
+
const newestConfirmed = Math.max(...all.filter((d) => d.confirmed).map((d) => d.createdAtMs));
|
|
736
|
+
if (pending === undefined ||
|
|
737
|
+
Date.now() - pending.createdAtMs > PENDING_DEVICE_MAX_AGE_MS ||
|
|
738
|
+
pending.createdAtMs < newestConfirmed) {
|
|
739
|
+
throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
550
742
|
// Wrong/expired code → generic 400; the device stays pending (retry or GC later).
|
|
551
743
|
if (!(await this.verifyTotp(secret, code)))
|
|
552
744
|
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
745
|
const confirmed = await store.confirm(claims.id, deviceId);
|
|
556
746
|
// A race (device removed / already confirmed between load and confirm) → 404.
|
|
557
747
|
if (!confirmed) {
|
|
@@ -697,8 +887,9 @@ __decorate([
|
|
|
697
887
|
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
698
888
|
__param(0, (0, common_1.Headers)('authorization')),
|
|
699
889
|
__param(1, (0, common_1.Body)()),
|
|
890
|
+
__param(2, (0, common_1.Res)({ passthrough: true })),
|
|
700
891
|
__metadata("design:type", Function),
|
|
701
|
-
__metadata("design:paramtypes", [Object, Object]),
|
|
892
|
+
__metadata("design:paramtypes", [Object, Object, Object]),
|
|
702
893
|
__metadata("design:returntype", Promise)
|
|
703
894
|
], AccountController.prototype, "updateProfile", null);
|
|
704
895
|
__decorate([
|
|
@@ -790,20 +981,17 @@ exports.AccountController = AccountController = __decorate([
|
|
|
790
981
|
__param(11, (0, common_1.Inject)(tokens_js_2.SHIELD_MFA_LIMITER)),
|
|
791
982
|
__param(12, (0, common_1.Optional)()),
|
|
792
983
|
__param(12, (0, common_1.Inject)(tokens_js_2.SHIELD_LOGIN_LIMITER)),
|
|
793
|
-
|
|
984
|
+
__param(13, (0, common_1.Optional)()),
|
|
985
|
+
__param(13, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_CHANGE_TOKEN_STORE)),
|
|
986
|
+
__param(14, (0, common_1.Optional)()),
|
|
987
|
+
__param(14, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_ADDRESS_CHANGER)),
|
|
988
|
+
__param(15, (0, common_1.Optional)()),
|
|
989
|
+
__param(15, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_SENDER)),
|
|
990
|
+
__param(16, (0, common_1.Optional)()),
|
|
991
|
+
__param(16, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_CHANGE_LINK_BASE_URL)),
|
|
992
|
+
__param(17, (0, common_1.Optional)()),
|
|
993
|
+
__param(17, (0, common_1.Inject)(tokens_js_2.SHIELD_EMAIL_CHANGE_LIMITER)),
|
|
994
|
+
__param(18, (0, common_1.Optional)()),
|
|
995
|
+
__param(18, (0, common_1.Inject)(tokens_js_2.SHIELD_AUDIT_SINK)),
|
|
996
|
+
__metadata("design:paramtypes", [Function, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object])
|
|
794
997
|
], 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
|