@astram/guard 0.10.1 → 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 +251 -56
- 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 +17 -4
- 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/authz/permission-version-cache.d.ts.map +1 -1
- package/dist/authz/permission-version-cache.js +12 -2
- package/dist/enforcement/uuid.pipe.d.ts +35 -0
- package/dist/enforcement/uuid.pipe.d.ts.map +1 -0
- package/dist/enforcement/uuid.pipe.js +67 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -3
- 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":"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"}
|
|
@@ -58,14 +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");
|
|
62
|
+
const engine_1 = require("@astram/engine");
|
|
61
63
|
const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
|
|
62
64
|
const request_principal_js_1 = require("../request-auth/request-principal.js");
|
|
63
65
|
const tokens_js_1 = require("../request-auth/tokens.js");
|
|
64
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");
|
|
65
69
|
const canonical_hint_js_1 = require("./rate-limit/canonical-hint.js");
|
|
66
70
|
const totp_enrollment_js_1 = require("./totp-enrollment.js");
|
|
67
71
|
/** Max length of a device name (matches the `md_name VARCHAR(120)` column). */
|
|
68
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;
|
|
69
78
|
/** Minimum acceptable new-password length (the only policy this route enforces). */
|
|
70
79
|
const MIN_PASSWORD_LEN = 8;
|
|
71
80
|
/**
|
|
@@ -153,6 +162,18 @@ async function consumeAttempt(limiter, key) {
|
|
|
153
162
|
function unprocessable(error) {
|
|
154
163
|
throw new common_1.HttpException({ error }, common_1.HttpStatus.UNPROCESSABLE_ENTITY);
|
|
155
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
|
+
}
|
|
156
177
|
let AccountController = class AccountController {
|
|
157
178
|
binder;
|
|
158
179
|
hasher;
|
|
@@ -167,7 +188,13 @@ let AccountController = class AccountController {
|
|
|
167
188
|
loginTotpVerifier;
|
|
168
189
|
mfaLimiter;
|
|
169
190
|
loginLimiter;
|
|
170
|
-
|
|
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) {
|
|
171
198
|
this.binder = binder;
|
|
172
199
|
this.hasher = hasher;
|
|
173
200
|
this.writer = writer;
|
|
@@ -181,6 +208,12 @@ let AccountController = class AccountController {
|
|
|
181
208
|
this.loginTotpVerifier = loginTotpVerifier;
|
|
182
209
|
this.mfaLimiter = mfaLimiter;
|
|
183
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;
|
|
184
217
|
}
|
|
185
218
|
/**
|
|
186
219
|
* Resolve a human-readable otpauth account LABEL for the caller (email, else display
|
|
@@ -329,11 +362,20 @@ let AccountController = class AccountController {
|
|
|
329
362
|
await this.loginLimiter?.reset(loginKey);
|
|
330
363
|
const newHash = await hasher.hash(newPassword);
|
|
331
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
|
+
});
|
|
332
372
|
// Revoke the caller's OTHER sessions (keep the current one live). Best-effort:
|
|
333
373
|
// absent seam → skip (the password is already changed).
|
|
334
374
|
if (this.sweeper !== undefined && this.sweeper !== null) {
|
|
335
375
|
await this.sweeper.revokeOthersForPrincipal(claims.id, claims.sessionId);
|
|
336
376
|
}
|
|
377
|
+
if (burnError !== undefined)
|
|
378
|
+
throw burnError;
|
|
337
379
|
}
|
|
338
380
|
/**
|
|
339
381
|
* Verify a presented TOTP `code` against a base32 `secret` using the injected core
|
|
@@ -348,53 +390,166 @@ let AccountController = class AccountController {
|
|
|
348
390
|
return verdict.satisfied;
|
|
349
391
|
}
|
|
350
392
|
/**
|
|
351
|
-
* `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* Only the fields actually supplied are written (partial edit). At least one of
|
|
357
|
-
* `name`/`email` must be present and non-empty; a supplied email must look like an
|
|
358
|
-
* email. These are semantic validations on the caller's OWN profile (no credential
|
|
359
|
-
* 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.
|
|
360
397
|
*
|
|
361
398
|
* Outcomes:
|
|
362
|
-
* • no/invalid token → 401 (generic)
|
|
363
|
-
* •
|
|
364
|
-
* •
|
|
365
|
-
* • email
|
|
366
|
-
* •
|
|
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.
|
|
367
417
|
*/
|
|
368
|
-
async updateProfile(authorization, body) {
|
|
418
|
+
async updateProfile(authorization, body, res) {
|
|
369
419
|
const claims = await this.requireClaims(authorization);
|
|
370
420
|
// BLOCKED during re-enroll: profile edits are not part of re-securing the account.
|
|
371
421
|
this.enforceReenroll(claims, false);
|
|
372
422
|
// Normalize: treat only non-empty string fields as a real change (trim so a
|
|
373
423
|
// whitespace-only value is not a change).
|
|
374
424
|
const name = typeof body?.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : undefined;
|
|
375
|
-
const
|
|
376
|
-
? body.email.trim()
|
|
377
|
-
: undefined;
|
|
425
|
+
const rawEmail = typeof body?.email === 'string' && body.email.trim().length > 0 ? body.email : undefined;
|
|
378
426
|
// Nothing to update → explicit 422 (rather than a silent no-op re-read).
|
|
379
|
-
if (name === undefined &&
|
|
427
|
+
if (name === undefined && rawEmail === undefined) {
|
|
380
428
|
unprocessable('no_changes');
|
|
381
429
|
}
|
|
382
|
-
//
|
|
383
|
-
|
|
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)) {
|
|
384
433
|
unprocessable('invalid_email');
|
|
385
434
|
}
|
|
386
435
|
// Fail-closed when the write seam is unplugged — an honest "not available".
|
|
387
436
|
if (this.profileWriter === undefined || this.profileWriter === null) {
|
|
388
437
|
notAvailable();
|
|
389
438
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
+
});
|
|
398
553
|
}
|
|
399
554
|
// ───────────────────────────────────────────────────────────────────────────
|
|
400
555
|
// Multi-device MFA — self-scoped device management. Every route
|
|
@@ -494,7 +649,16 @@ let AccountController = class AccountController {
|
|
|
494
649
|
* material (`otpauthUri` for a QR + `secret` for manual entry). NOT active until
|
|
495
650
|
* `confirm`. A blank name defaults to `"Authenticator N"` (N = confirmed count + 1).
|
|
496
651
|
*
|
|
497
|
-
*
|
|
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 }`.
|
|
498
662
|
*/
|
|
499
663
|
async beginDevice(authorization, body) {
|
|
500
664
|
const claims = await this.requireClaims(authorization);
|
|
@@ -503,10 +667,17 @@ let AccountController = class AccountController {
|
|
|
503
667
|
if (this.mfaDevices === undefined || this.mfaDevices === null)
|
|
504
668
|
notAvailable();
|
|
505
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
|
+
}
|
|
506
677
|
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
507
678
|
const name = rawName.length > 0
|
|
508
679
|
? rawName.slice(0, MAX_DEVICE_NAME_LEN)
|
|
509
|
-
: `Authenticator ${
|
|
680
|
+
: `Authenticator ${confirmedCount + 1}`;
|
|
510
681
|
const secret = (0, totp_enrollment_js_1.generateTotpSecret)();
|
|
511
682
|
const deviceId = await store.begin(claims.id, name, secret);
|
|
512
683
|
const otpauthUri = (0, totp_enrollment_js_1.buildOtpauthUri)(secret, await this.resolveOtpauthLabel(claims), TOTP_ISSUER);
|
|
@@ -519,8 +690,17 @@ let AccountController = class AccountController {
|
|
|
519
690
|
* codes are minted (shown once) and returned; subsequent devices return `{}` and do
|
|
520
691
|
* NOT touch recovery codes.
|
|
521
692
|
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
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? }`.
|
|
524
704
|
*/
|
|
525
705
|
async confirmDevice(authorization, body) {
|
|
526
706
|
const claims = await this.requireClaims(authorization);
|
|
@@ -537,7 +717,8 @@ let AccountController = class AccountController {
|
|
|
537
717
|
const store = this.mfaDevices;
|
|
538
718
|
const recoveryStore = this.recoveryCodes;
|
|
539
719
|
const hasher = this.hasher;
|
|
540
|
-
|
|
720
|
+
// Phase 11 #1: device ids are uuids; only the canonical form reaches the store (400 otherwise).
|
|
721
|
+
const deviceId = (0, engine_1.canonicalUuid)(body?.deviceId) ?? badRequest();
|
|
541
722
|
const code = typeof body?.code === 'string' ? body.code : '';
|
|
542
723
|
// Load the pending device's OWN secret (owned by the caller) — else 404. The secret
|
|
543
724
|
// never leaves the server; it is only used to check the presented code here.
|
|
@@ -545,11 +726,22 @@ let AccountController = class AccountController {
|
|
|
545
726
|
if (secret === null) {
|
|
546
727
|
throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
547
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
|
+
}
|
|
548
742
|
// Wrong/expired code → generic 400; the device stays pending (retry or GC later).
|
|
549
743
|
if (!(await this.verifyTotp(secret, code)))
|
|
550
744
|
badRequest();
|
|
551
|
-
// First-device check BEFORE confirming (so the just-confirmed device isn't counted).
|
|
552
|
-
const wasFirst = (await store.countConfirmed(claims.id)) === 0;
|
|
553
745
|
const confirmed = await store.confirm(claims.id, deviceId);
|
|
554
746
|
// A race (device removed / already confirmed between load and confirm) → 404.
|
|
555
747
|
if (!confirmed) {
|
|
@@ -592,10 +784,13 @@ let AccountController = class AccountController {
|
|
|
592
784
|
if (this.mfaDevices === undefined || this.mfaDevices === null)
|
|
593
785
|
notAvailable();
|
|
594
786
|
const store = this.mfaDevices;
|
|
787
|
+
// Phase 11 #1: the canonical device id or 400 — checked in the handler, after the session
|
|
788
|
+
// (a self-authenticated route answers 401 before anything about its input).
|
|
789
|
+
const deviceId = (0, engine_1.canonicalUuid)(id) ?? badRequest();
|
|
595
790
|
const name = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
596
791
|
if (name.length === 0)
|
|
597
792
|
unprocessable('invalid_name');
|
|
598
|
-
const device = await store.rename(claims.id,
|
|
793
|
+
const device = await store.rename(claims.id, deviceId, name.slice(0, MAX_DEVICE_NAME_LEN));
|
|
599
794
|
if (device === null) {
|
|
600
795
|
throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
601
796
|
}
|
|
@@ -621,12 +816,14 @@ let AccountController = class AccountController {
|
|
|
621
816
|
if (this.mfaDevices === undefined || this.mfaDevices === null)
|
|
622
817
|
notAvailable();
|
|
623
818
|
const store = this.mfaDevices;
|
|
819
|
+
// Phase 11 #1: the canonical device id or 400 (after the session, before any proof is spent).
|
|
820
|
+
const deviceId = (0, engine_1.canonicalUuid)(id) ?? badRequest();
|
|
624
821
|
const code = typeof body?.code === 'string' ? body.code : '';
|
|
625
822
|
// Proof BEFORE the delete: a TOTP from any confirmed device (incl. the one being
|
|
626
823
|
// removed — legit for the last-device disable) OR a valid recovery code. Rate-limited
|
|
627
824
|
// and replay-guarded like `/auth/mfa` (Phase 8 #1).
|
|
628
825
|
await this.requireMfaProof(store, claims.id, code);
|
|
629
|
-
const removed = await store.remove(claims.id,
|
|
826
|
+
const removed = await store.remove(claims.id, deviceId);
|
|
630
827
|
if (!removed) {
|
|
631
828
|
throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
632
829
|
}
|
|
@@ -690,8 +887,9 @@ __decorate([
|
|
|
690
887
|
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
691
888
|
__param(0, (0, common_1.Headers)('authorization')),
|
|
692
889
|
__param(1, (0, common_1.Body)()),
|
|
890
|
+
__param(2, (0, common_1.Res)({ passthrough: true })),
|
|
693
891
|
__metadata("design:type", Function),
|
|
694
|
-
__metadata("design:paramtypes", [Object, Object]),
|
|
892
|
+
__metadata("design:paramtypes", [Object, Object, Object]),
|
|
695
893
|
__metadata("design:returntype", Promise)
|
|
696
894
|
], AccountController.prototype, "updateProfile", null);
|
|
697
895
|
__decorate([
|
|
@@ -783,20 +981,17 @@ exports.AccountController = AccountController = __decorate([
|
|
|
783
981
|
__param(11, (0, common_1.Inject)(tokens_js_2.SHIELD_MFA_LIMITER)),
|
|
784
982
|
__param(12, (0, common_1.Optional)()),
|
|
785
983
|
__param(12, (0, common_1.Inject)(tokens_js_2.SHIELD_LOGIN_LIMITER)),
|
|
786
|
-
|
|
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])
|
|
787
997
|
], AccountController);
|
|
788
|
-
/**
|
|
789
|
-
* A deliberately-minimal email shape check (non-empty, a single `@`, a `.` in the
|
|
790
|
-
* domain, no whitespace). This is a self-service edit of the caller's own profile —
|
|
791
|
-
* a full RFC-5322 validator adds no security value here, only false rejections. The
|
|
792
|
-
* authoritative check is delivery; this just catches obvious typos client-adjacent.
|
|
793
|
-
*/
|
|
794
|
-
function isEmailShaped(value) {
|
|
795
|
-
if (/\s/.test(value))
|
|
796
|
-
return false;
|
|
797
|
-
const at = value.indexOf('@');
|
|
798
|
-
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1)
|
|
799
|
-
return false;
|
|
800
|
-
const domain = value.slice(at + 1);
|
|
801
|
-
return domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.');
|
|
802
|
-
}
|