@astram/guard 0.1.1 → 0.3.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 +245 -0
- package/dist/auth/account.controller.d.ts.map +1 -0
- package/dist/auth/account.controller.js +673 -0
- package/dist/auth/account.controller.js.map +1 -0
- package/dist/auth/auth.controller.d.ts +316 -0
- package/dist/auth/auth.controller.d.ts.map +1 -0
- package/dist/auth/auth.controller.js +1091 -0
- package/dist/auth/auth.controller.js.map +1 -0
- package/dist/auth/auth.module.d.ts +202 -0
- package/dist/auth/auth.module.d.ts.map +1 -0
- package/dist/auth/auth.module.js +610 -0
- package/dist/auth/auth.module.js.map +1 -0
- package/dist/auth/connections.d.ts +61 -0
- package/dist/auth/connections.d.ts.map +1 -0
- package/dist/auth/connections.js +116 -0
- package/dist/auth/connections.js.map +1 -0
- package/dist/auth/dto.d.ts +154 -0
- package/dist/auth/dto.d.ts.map +1 -0
- package/dist/auth/dto.js +29 -0
- package/dist/auth/dto.js.map +1 -0
- package/dist/auth/pending-mfa-store.d.ts +72 -0
- package/dist/auth/pending-mfa-store.d.ts.map +1 -0
- package/dist/auth/pending-mfa-store.js +106 -0
- package/dist/auth/pending-mfa-store.js.map +1 -0
- package/dist/auth/persistence.module.d.ts +42 -0
- package/dist/auth/persistence.module.d.ts.map +1 -0
- package/dist/auth/persistence.module.js +94 -0
- package/dist/auth/persistence.module.js.map +1 -0
- package/dist/auth/ports/audit-sink.d.ts +47 -0
- package/dist/auth/ports/audit-sink.d.ts.map +1 -0
- package/dist/auth/ports/audit-sink.js +20 -0
- package/dist/auth/ports/audit-sink.js.map +1 -0
- package/dist/auth/ports/credential-writer.d.ts +23 -0
- package/dist/auth/ports/credential-writer.d.ts.map +1 -0
- package/dist/auth/ports/credential-writer.js +2 -0
- package/dist/auth/ports/credential-writer.js.map +1 -0
- package/dist/auth/ports/email-sender.d.ts +14 -0
- package/dist/auth/ports/email-sender.d.ts.map +1 -0
- package/dist/auth/ports/email-sender.js +2 -0
- package/dist/auth/ports/email-sender.js.map +1 -0
- package/dist/auth/ports/mfa-device-store.d.ts +45 -0
- package/dist/auth/ports/mfa-device-store.d.ts.map +1 -0
- package/dist/auth/ports/mfa-device-store.js +13 -0
- package/dist/auth/ports/mfa-device-store.js.map +1 -0
- package/dist/auth/ports/profile-writer.d.ts +12 -0
- package/dist/auth/ports/profile-writer.d.ts.map +1 -0
- package/dist/auth/ports/profile-writer.js +2 -0
- package/dist/auth/ports/profile-writer.js.map +1 -0
- package/dist/auth/ports/recovery-code-store.d.ts +10 -0
- package/dist/auth/ports/recovery-code-store.d.ts.map +1 -0
- package/dist/auth/ports/recovery-code-store.js +2 -0
- package/dist/auth/ports/recovery-code-store.js.map +1 -0
- package/dist/auth/ports/reset-token-store.d.ts +12 -0
- package/dist/auth/ports/reset-token-store.d.ts.map +1 -0
- package/dist/auth/ports/reset-token-store.js +2 -0
- package/dist/auth/ports/reset-token-store.js.map +1 -0
- package/dist/auth/ports/session-facts-projector.d.ts +44 -0
- package/dist/auth/ports/session-facts-projector.d.ts.map +1 -0
- package/dist/auth/ports/session-facts-projector.js +30 -0
- package/dist/auth/ports/session-facts-projector.js.map +1 -0
- package/dist/auth/ports/session-sweeper.d.ts +12 -0
- package/dist/auth/ports/session-sweeper.d.ts.map +1 -0
- package/dist/auth/ports/session-sweeper.js +2 -0
- package/dist/auth/ports/session-sweeper.js.map +1 -0
- package/dist/auth/profile-resolver.d.ts +73 -0
- package/dist/auth/profile-resolver.d.ts.map +1 -0
- package/dist/auth/profile-resolver.js +28 -0
- package/dist/auth/profile-resolver.js.map +1 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts +91 -0
- package/dist/auth/rate-limit/attempt-limiter.d.ts.map +1 -0
- package/dist/auth/rate-limit/attempt-limiter.js +133 -0
- package/dist/auth/rate-limit/attempt-limiter.js.map +1 -0
- package/dist/auth/tokens.d.ts +236 -0
- package/dist/auth/tokens.d.ts.map +1 -0
- package/dist/auth/tokens.js +254 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/auth/totp-enrollment.d.ts +42 -0
- package/dist/auth/totp-enrollment.d.ts.map +1 -0
- package/dist/auth/totp-enrollment.js +96 -0
- package/dist/auth/totp-enrollment.js.map +1 -0
- package/dist/authentication/argon2id-password-hasher.js.map +1 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts +110 -0
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -0
- package/dist/authentication/mariadb-credential-resolver.js +170 -0
- package/dist/authentication/mariadb-credential-resolver.js.map +1 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts +259 -0
- package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -0
- package/dist/authz/cross-tenant-grant-writer.js +397 -0
- package/dist/authz/cross-tenant-grant-writer.js.map +1 -0
- package/dist/authz/manifest-operation-authority-adapter.d.ts +65 -0
- package/dist/authz/manifest-operation-authority-adapter.d.ts.map +1 -0
- package/dist/authz/manifest-operation-authority-adapter.js +108 -0
- package/dist/authz/manifest-operation-authority-adapter.js.map +1 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts +90 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +211 -0
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js.map +1 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts +60 -0
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-record-rule-adapter.js +154 -0
- package/dist/authz/mariadb-record-rule-adapter.js.map +1 -0
- package/dist/authz/mariadb-record-share-adapter.d.ts +75 -0
- package/dist/authz/mariadb-record-share-adapter.d.ts.map +1 -0
- package/dist/authz/mariadb-record-share-adapter.js +106 -0
- package/dist/authz/mariadb-record-share-adapter.js.map +1 -0
- package/dist/authz/record-share-cascade.d.ts +52 -0
- package/dist/authz/record-share-cascade.d.ts.map +1 -0
- package/dist/authz/record-share-cascade.js +49 -0
- package/dist/authz/record-share-cascade.js.map +1 -0
- package/dist/authz/record-share-writer.d.ts +175 -0
- package/dist/authz/record-share-writer.d.ts.map +1 -0
- package/dist/authz/record-share-writer.js +225 -0
- package/dist/authz/record-share-writer.js.map +1 -0
- package/dist/authz/wire-record-authorization.d.ts +66 -0
- package/dist/authz/wire-record-authorization.d.ts.map +1 -0
- package/dist/authz/wire-record-authorization.js +34 -0
- package/dist/authz/wire-record-authorization.js.map +1 -0
- package/dist/boot/boot-sequence.js.map +1 -0
- package/dist/boot/boot.module.js.map +1 -0
- package/dist/completeness/catalog-reconciler.d.ts +21 -0
- package/dist/completeness/catalog-reconciler.d.ts.map +1 -0
- package/dist/completeness/catalog-reconciler.js +74 -0
- package/dist/completeness/catalog-reconciler.js.map +1 -0
- package/dist/completeness/operation-inventory-scanner.d.ts +17 -0
- package/dist/completeness/operation-inventory-scanner.d.ts.map +1 -0
- package/dist/completeness/operation-inventory-scanner.js +79 -0
- package/dist/completeness/operation-inventory-scanner.js.map +1 -0
- package/dist/completeness/ports/catalog-store.d.ts +33 -0
- package/dist/completeness/ports/catalog-store.d.ts.map +1 -0
- package/dist/completeness/ports/catalog-store.js +2 -0
- package/dist/completeness/ports/catalog-store.js.map +1 -0
- package/dist/config/app-config.d.ts +134 -1
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +144 -2
- package/dist/config/app-config.js.map +1 -0
- package/dist/config/config.module.js.map +1 -0
- package/dist/enforcement/classification.decorator.d.ts +33 -14
- package/dist/enforcement/classification.decorator.d.ts.map +1 -1
- package/dist/enforcement/classification.decorator.js +34 -14
- package/dist/enforcement/classification.decorator.js.map +1 -0
- package/dist/enforcement/core-seam.js.map +1 -0
- package/dist/enforcement/enforcement.module.js.map +1 -0
- package/dist/enforcement/global-enforcement.guard.d.ts +29 -19
- package/dist/enforcement/global-enforcement.guard.d.ts.map +1 -1
- package/dist/enforcement/global-enforcement.guard.js +108 -25
- package/dist/enforcement/global-enforcement.guard.js.map +1 -0
- package/dist/enforcement/reenroll-allowlist.d.ts +49 -0
- package/dist/enforcement/reenroll-allowlist.d.ts.map +1 -0
- package/dist/enforcement/reenroll-allowlist.js +77 -0
- package/dist/enforcement/reenroll-allowlist.js.map +1 -0
- package/dist/index.d.ts +43 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +198 -1
- package/dist/index.js.map +1 -0
- package/dist/module/shield-tokens.d.ts +15 -0
- package/dist/module/shield-tokens.d.ts.map +1 -0
- package/dist/module/shield-tokens.js +17 -0
- package/dist/module/shield-tokens.js.map +1 -0
- package/dist/module/shield.module.d.ts +203 -8
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +232 -14
- package/dist/module/shield.module.js.map +1 -0
- package/dist/request-auth/request-principal.d.ts +42 -0
- package/dist/request-auth/request-principal.d.ts.map +1 -0
- package/dist/request-auth/request-principal.js +87 -0
- package/dist/request-auth/request-principal.js.map +1 -0
- package/dist/request-auth/session-facts-als.d.ts +65 -0
- package/dist/request-auth/session-facts-als.d.ts.map +1 -0
- package/dist/request-auth/session-facts-als.js +57 -0
- package/dist/request-auth/session-facts-als.js.map +1 -0
- package/dist/request-auth/session-token-binder.d.ts +130 -0
- package/dist/request-auth/session-token-binder.d.ts.map +1 -0
- package/dist/request-auth/session-token-binder.js +277 -0
- package/dist/request-auth/session-token-binder.js.map +1 -0
- package/dist/request-auth/tokens.d.ts +14 -0
- package/dist/request-auth/tokens.d.ts.map +1 -0
- package/dist/request-auth/tokens.js +16 -0
- package/dist/request-auth/tokens.js.map +1 -0
- package/dist/session/crypto-adapters.js.map +1 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/mariadb-session-record-store.js.map +1 -0
- package/dist/session/redis-hot-session-store.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The framework-owned **`/account/*` self-scoped HTTP controller** — the
|
|
4
|
+
* authenticated user acting on THEIR OWN credential/profile (spec §2). Unlike the
|
|
5
|
+
* `/auth/*` routes (which run BEFORE a session exists), these routes authenticate
|
|
6
|
+
* the CALLER first — they resolve the bearer access token to the principal's claims
|
|
7
|
+
* and operate ONLY on `claims.id`. There is NO `decide()`/object-type authorization
|
|
8
|
+
* here: "change my own password" has no `typeKey` to gate on (that is the later
|
|
9
|
+
* permission-management milestone). So the routes are `@Public()` to the
|
|
10
|
+
* classification guard (not object-gated), but NOT unauthenticated.
|
|
11
|
+
*
|
|
12
|
+
* ── AUTH (fail-closed) ── the caller is authenticated by resolving the bearer token
|
|
13
|
+
* DIRECTLY via the {@link SessionTokenBinder} (`binder.resolve(token)`), NOT via
|
|
14
|
+
* `resolveRequestPrincipal` — because change-password needs the caller's
|
|
15
|
+
* `sessionId` (to keep the current session alive while sweeping the others), and
|
|
16
|
+
* `resolveRequestPrincipal` collapses the claims down to a `Principal` (dropping
|
|
17
|
+
* `sessionId`). No token / no binder / resolve → null → generic 401.
|
|
18
|
+
*
|
|
19
|
+
* ── SEAMS (fail-closed → 501) ── the credential read+write goes through the
|
|
20
|
+
* consumer-plugged {@link CredentialWriter} (`getPasswordHash` + `setPassword`, now
|
|
21
|
+
* password-only); the current-password verify uses the {@link PasswordHasher};
|
|
22
|
+
* sessions are swept by the {@link SessionSweeper}. The {@link RecoveryCodeStore},
|
|
23
|
+
* the core {@link FactorVerifier} (single-secret TOTP, for per-device code checks),
|
|
24
|
+
* the {@link ProfileResolver} (otpauth label), and the {@link MfaDeviceStore}
|
|
25
|
+
* (the N-device persistence) are injected here for the multi-device MFA routes. Each
|
|
26
|
+
* is `@Optional()`; when a required seam is absent the route returns an honest 501
|
|
27
|
+
* (`not_available`) rather than a partial mutation.
|
|
28
|
+
*
|
|
29
|
+
* NOTE: TOTP secrets no longer live on the {@link CredentialWriter} — the
|
|
30
|
+
* multi-device MFA model stores every authenticator secret in the `MfaDeviceStore`.
|
|
31
|
+
* The old single-secret `mfa/enroll/{begin,confirm}` + `mfa/disable` routes were
|
|
32
|
+
* removed; the multi-device `/account/mfa/devices/*` routes replace them.
|
|
33
|
+
*
|
|
34
|
+
* ── ROUTES ── `POST /account/change-password` (change own password);
|
|
35
|
+
* `PATCH /account/profile` (edit own display name/email → the refreshed display
|
|
36
|
+
* profile); the multi-device MFA routes — `GET /account/mfa/devices` (list),
|
|
37
|
+
* `POST /account/mfa/devices/begin` (enroll), `POST /account/mfa/devices/confirm`
|
|
38
|
+
* (activate + first-device recovery codes), `PATCH /account/mfa/devices/:id/rename`,
|
|
39
|
+
* `DELETE /account/mfa/devices/:id` (code-gated), `POST
|
|
40
|
+
* /account/mfa/recovery-codes/regenerate` (code-gated).
|
|
41
|
+
*
|
|
42
|
+
* ── NO ORACLE ── a wrong current password is an indistinguishable generic 401
|
|
43
|
+
* (same body as no-token). We never reveal whether the principal exists, nor which
|
|
44
|
+
* check failed. Passwords/hashes are NEVER logged.
|
|
45
|
+
*/
|
|
46
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
47
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
48
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
49
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
50
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
51
|
+
};
|
|
52
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
53
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
54
|
+
};
|
|
55
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
56
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
57
|
+
};
|
|
58
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
59
|
+
exports.AccountController = void 0;
|
|
60
|
+
const common_1 = require("@nestjs/common");
|
|
61
|
+
const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
|
|
62
|
+
const request_principal_js_1 = require("../request-auth/request-principal.js");
|
|
63
|
+
const tokens_js_1 = require("../request-auth/tokens.js");
|
|
64
|
+
const tokens_js_2 = require("./tokens.js");
|
|
65
|
+
const totp_enrollment_js_1 = require("./totp-enrollment.js");
|
|
66
|
+
/** Max length of a device name (matches the `md_name VARCHAR(120)` column). */
|
|
67
|
+
const MAX_DEVICE_NAME_LEN = 120;
|
|
68
|
+
/** Minimum acceptable new-password length (the only policy this route enforces). */
|
|
69
|
+
const MIN_PASSWORD_LEN = 8;
|
|
70
|
+
/**
|
|
71
|
+
* The issuer name embedded in the `otpauth://` provisioning URI (shown in the user's
|
|
72
|
+
* authenticator app as the account's service). A constant for the framework surface;
|
|
73
|
+
* a deployment that white-labels can lift this to config later without touching the
|
|
74
|
+
* enrollment flow.
|
|
75
|
+
*/
|
|
76
|
+
const TOTP_ISSUER = 'Astram';
|
|
77
|
+
/**
|
|
78
|
+
* The otpauth account label used when the caller's display identity can't be resolved
|
|
79
|
+
* (no profile resolver, or it returns null). Deliberately a NON-UUID generic string —
|
|
80
|
+
* the raw principal id must NEVER become the label (it ends up in the URI shown to the
|
|
81
|
+
* user, and the never-show-UUIDs rule forbids surfacing a `*_uuid`).
|
|
82
|
+
*/
|
|
83
|
+
const FALLBACK_OTPAUTH_LABEL = 'account';
|
|
84
|
+
/**
|
|
85
|
+
* The SINGLE generic 401 for EVERY unauthenticated/bad-credential path here (no
|
|
86
|
+
* token, no binder, resolve→null, wrong current password). Deliberately
|
|
87
|
+
* non-distinguishing — never leaks whether the principal exists or which check
|
|
88
|
+
* failed.
|
|
89
|
+
*/
|
|
90
|
+
function unauthorized() {
|
|
91
|
+
throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
|
|
92
|
+
}
|
|
93
|
+
/** The honest 501 when a required seam is unplugged (fail-closed, no mutation). */
|
|
94
|
+
function notAvailable() {
|
|
95
|
+
throw new common_1.HttpException({ error: 'not_available' }, common_1.HttpStatus.NOT_IMPLEMENTED);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The generic 400 for a bad MFA request (no pending enrollment / wrong or missing
|
|
99
|
+
* code). Like {@link unauthorized}, deliberately non-distinguishing — it never
|
|
100
|
+
* reveals WHICH check failed (no pending vs wrong code), so it is no oracle. No
|
|
101
|
+
* mutation happens on this path.
|
|
102
|
+
*/
|
|
103
|
+
function badRequest() {
|
|
104
|
+
throw new common_1.HttpException({ error: 'invalid_request' }, common_1.HttpStatus.BAD_REQUEST);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The 422 for a semantically invalid profile edit — an empty change (`no_changes`)
|
|
108
|
+
* or a malformed email (`invalid_email`). Distinct from {@link badRequest}: this is
|
|
109
|
+
* a self-scoped edit of the caller's OWN profile (no credential oracle to protect),
|
|
110
|
+
* so a precise field-level reason is safe and helpful for the form.
|
|
111
|
+
*/
|
|
112
|
+
function unprocessable(error) {
|
|
113
|
+
throw new common_1.HttpException({ error }, common_1.HttpStatus.UNPROCESSABLE_ENTITY);
|
|
114
|
+
}
|
|
115
|
+
let AccountController = class AccountController {
|
|
116
|
+
binder;
|
|
117
|
+
hasher;
|
|
118
|
+
writer;
|
|
119
|
+
sweeper;
|
|
120
|
+
recoveryCodes;
|
|
121
|
+
totpVerifier;
|
|
122
|
+
profileResolver;
|
|
123
|
+
profileWriter;
|
|
124
|
+
mfaDevices;
|
|
125
|
+
constructor(binder, hasher, writer, sweeper, recoveryCodes, totpVerifier, profileResolver, profileWriter, mfaDevices) {
|
|
126
|
+
this.binder = binder;
|
|
127
|
+
this.hasher = hasher;
|
|
128
|
+
this.writer = writer;
|
|
129
|
+
this.sweeper = sweeper;
|
|
130
|
+
this.recoveryCodes = recoveryCodes;
|
|
131
|
+
this.totpVerifier = totpVerifier;
|
|
132
|
+
this.profileResolver = profileResolver;
|
|
133
|
+
this.profileWriter = profileWriter;
|
|
134
|
+
this.mfaDevices = mfaDevices;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Resolve a human-readable otpauth account LABEL for the caller (email, else display
|
|
138
|
+
* name) via the profile resolver. Best-effort: no resolver / null profile / a thrown
|
|
139
|
+
* store error → the generic {@link FALLBACK_OTPAUTH_LABEL}. NEVER returns the raw
|
|
140
|
+
* principal id — the label is shown to the user (in the URI), so a `*_uuid` there
|
|
141
|
+
* would violate the never-show-UUIDs rule.
|
|
142
|
+
*/
|
|
143
|
+
async resolveOtpauthLabel(claims) {
|
|
144
|
+
if (this.profileResolver === undefined || this.profileResolver === null) {
|
|
145
|
+
return FALLBACK_OTPAUTH_LABEL;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const profile = await this.profileResolver.resolve({
|
|
149
|
+
id: claims.id,
|
|
150
|
+
tenantId: claims.tenantId,
|
|
151
|
+
roleIds: [...claims.roleIds],
|
|
152
|
+
kind: 'user',
|
|
153
|
+
isRoot: false,
|
|
154
|
+
});
|
|
155
|
+
const label = profile?.email ?? profile?.name;
|
|
156
|
+
return typeof label === 'string' && label.length > 0 ? label : FALLBACK_OTPAUTH_LABEL;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Fail-soft: a profile-store hiccup must not block enrollment — use the generic
|
|
160
|
+
// label rather than leak the id or error out.
|
|
161
|
+
return FALLBACK_OTPAUTH_LABEL;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Resolve the bearer token → the caller's FULL claims (id + sessionId), or throw a
|
|
166
|
+
* generic 401. Uses the binder DIRECTLY (not `resolveRequestPrincipal`) so the
|
|
167
|
+
* `sessionId` survives — change-password needs it to keep the current session.
|
|
168
|
+
*/
|
|
169
|
+
async requireClaims(authorization) {
|
|
170
|
+
if (this.binder === undefined || this.binder === null)
|
|
171
|
+
unauthorized();
|
|
172
|
+
const token = (0, request_principal_js_1.extractBearerToken)(authorization);
|
|
173
|
+
if (token === null)
|
|
174
|
+
unauthorized();
|
|
175
|
+
const claims = await this.binder.resolve(token);
|
|
176
|
+
if (claims === null)
|
|
177
|
+
unauthorized();
|
|
178
|
+
return claims;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* ENFORCED-RE-ENROLL GATE for the self-scoped `/account/*` surface (spec §4.2).
|
|
182
|
+
*
|
|
183
|
+
* WHY IT LIVES HERE (not only in the GlobalEnforcementGuard): every `/account/*`
|
|
184
|
+
* route is `@Public()` to the classification guard (it self-authenticates via
|
|
185
|
+
* {@link requireClaims}), so the guard's `mustReenroll` gate — which fires only on
|
|
186
|
+
* `@Classified` routes — NEVER runs on this surface. Without this chokepoint, a
|
|
187
|
+
* recovery-login (`mustReenroll:true`) session could call the account API DIRECTLY —
|
|
188
|
+
* e.g. `DELETE /account/mfa/devices/:id` to strip every device + wipe recovery codes
|
|
189
|
+
* WITHOUT confirming a fresh device — defeating the whole safeguard (spec §4.2:
|
|
190
|
+
* "calling the API directly cannot bypass it").
|
|
191
|
+
*
|
|
192
|
+
* WHY A PER-HANDLER BOOLEAN (not path matching): each handler declares its OWN
|
|
193
|
+
* re-enroll policy via a literal `allowedDuringReenroll`. This is unambiguous and
|
|
194
|
+
* cannot be fooled by URL/path normalization — there is no string matching to trick.
|
|
195
|
+
*
|
|
196
|
+
* POLICY (spec §4.2 + hardening): a `mustReenroll` session may ADD a device
|
|
197
|
+
* (`begin`/`confirm`), REGENERATE codes, and READ its devices — but must NOT
|
|
198
|
+
* REMOVE devices, rename them, change the password, or edit the profile. Removing
|
|
199
|
+
* devices is the exact attack vector (it strips MFA), so `removeDevice` is BLOCKED.
|
|
200
|
+
*
|
|
201
|
+
* A normal (`mustReenroll:false`) session is unaffected — this is a no-op for it.
|
|
202
|
+
*/
|
|
203
|
+
enforceReenroll(claims, allowedDuringReenroll) {
|
|
204
|
+
if (claims.mustReenroll === true && !allowedDuringReenroll) {
|
|
205
|
+
throw new common_1.HttpException({ error: 'reenrollment_required' }, common_1.HttpStatus.FORBIDDEN);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* `POST /account/change-password` — change the AUTHENTICATED caller's own password.
|
|
210
|
+
* Verifies the current password (read by principal id via the writer seam), writes
|
|
211
|
+
* the new hash, then revokes the caller's OTHER sessions (keeping the current one
|
|
212
|
+
* live). Self-scoped: acts only on `claims.id`.
|
|
213
|
+
*
|
|
214
|
+
* Outcomes:
|
|
215
|
+
* • no/invalid token → 401 (generic)
|
|
216
|
+
* • newPassword shorter than the minimum → 422 `{ error: 'weak_password' }`
|
|
217
|
+
* • writer/hasher seam absent → 501 `{ error: 'not_available' }`
|
|
218
|
+
* • wrong current password → 401 (generic, no write)
|
|
219
|
+
* • success → 204 (no body)
|
|
220
|
+
*/
|
|
221
|
+
async changePassword(authorization, body) {
|
|
222
|
+
const claims = await this.requireClaims(authorization);
|
|
223
|
+
// BLOCKED during re-enroll: a recovery session must re-secure (add a device) before
|
|
224
|
+
// it can change the password.
|
|
225
|
+
this.enforceReenroll(claims, false);
|
|
226
|
+
const currentPassword = typeof body?.currentPassword === 'string' ? body.currentPassword : '';
|
|
227
|
+
const newPassword = typeof body?.newPassword === 'string' ? body.newPassword : '';
|
|
228
|
+
if (newPassword.length < MIN_PASSWORD_LEN) {
|
|
229
|
+
throw new common_1.HttpException({ error: 'weak_password' }, common_1.HttpStatus.UNPROCESSABLE_ENTITY);
|
|
230
|
+
}
|
|
231
|
+
// Fail-closed when the write side or the hasher is unplugged — never a partial
|
|
232
|
+
// mutation, an honest "not available" instead.
|
|
233
|
+
if (this.writer === undefined || this.writer === null || this.hasher === undefined) {
|
|
234
|
+
notAvailable();
|
|
235
|
+
}
|
|
236
|
+
const writer = this.writer;
|
|
237
|
+
const hasher = this.hasher;
|
|
238
|
+
// Verify the current password: read the stored hash BY PRINCIPAL ID via the writer
|
|
239
|
+
// seam's `getPasswordHash` (change-password holds the principal id from the token,
|
|
240
|
+
// whereas CredentialResolver keys by identityHint). A missing hash or a mismatch is
|
|
241
|
+
// the SAME generic 401 (no oracle), and no write happens.
|
|
242
|
+
const stored = await writer.getPasswordHash(claims.id);
|
|
243
|
+
if (stored === null || !(await hasher.verify(stored, currentPassword))) {
|
|
244
|
+
unauthorized();
|
|
245
|
+
}
|
|
246
|
+
const newHash = await hasher.hash(newPassword);
|
|
247
|
+
await writer.setPassword(claims.id, newHash);
|
|
248
|
+
// Revoke the caller's OTHER sessions (keep the current one live). Best-effort:
|
|
249
|
+
// absent seam → skip (the password is already changed).
|
|
250
|
+
if (this.sweeper !== undefined && this.sweeper !== null) {
|
|
251
|
+
await this.sweeper.revokeOthersForPrincipal(claims.id, claims.sessionId);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Verify a presented TOTP `code` against a base32 `secret` using the injected core
|
|
256
|
+
* verifier (the SAME RFC-6238 verifier the login MFA branch uses). Returns the
|
|
257
|
+
* verdict's `satisfied` boolean; a malformed code/secret → `false` (the verifier
|
|
258
|
+
* never throws). NEVER logs the code or the secret.
|
|
259
|
+
*/
|
|
260
|
+
async verifyTotp(secret, code) {
|
|
261
|
+
if (this.totpVerifier === undefined || this.totpVerifier === null)
|
|
262
|
+
return false;
|
|
263
|
+
const verdict = await this.totpVerifier.verify({ factor: 'totp', payload: { code } }, secret);
|
|
264
|
+
return verdict.satisfied;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* `PATCH /account/profile` — edit the AUTHENTICATED caller's OWN display profile
|
|
268
|
+
* (name and/or email). Self-scoped: acts only on `claims.id`. On success returns the
|
|
269
|
+
* refreshed {@link CurrentUserProfile} (display names, never UUIDs — the bare `id`
|
|
270
|
+
* fields are machine-only), so the UI can update the header/user card in place.
|
|
271
|
+
*
|
|
272
|
+
* Only the fields actually supplied are written (partial edit). At least one of
|
|
273
|
+
* `name`/`email` must be present and non-empty; a supplied email must look like an
|
|
274
|
+
* email. These are semantic validations on the caller's OWN profile (no credential
|
|
275
|
+
* oracle), so they return precise 422 reasons rather than a generic 4xx.
|
|
276
|
+
*
|
|
277
|
+
* Outcomes:
|
|
278
|
+
* • no/invalid token → 401 (generic)
|
|
279
|
+
* • profileWriter seam absent → 501 `{ error: 'not_available' }`
|
|
280
|
+
* • neither name nor email supplied → 422 `{ error: 'no_changes' }`
|
|
281
|
+
* • email supplied but malformed → 422 `{ error: 'invalid_email' }`
|
|
282
|
+
* • success → 200 (the refreshed CurrentUserProfile)
|
|
283
|
+
*/
|
|
284
|
+
async updateProfile(authorization, body) {
|
|
285
|
+
const claims = await this.requireClaims(authorization);
|
|
286
|
+
// BLOCKED during re-enroll: profile edits are not part of re-securing the account.
|
|
287
|
+
this.enforceReenroll(claims, false);
|
|
288
|
+
// Normalize: treat only non-empty string fields as a real change (trim so a
|
|
289
|
+
// whitespace-only value is not a change).
|
|
290
|
+
const name = typeof body?.name === 'string' && body.name.trim().length > 0
|
|
291
|
+
? body.name.trim()
|
|
292
|
+
: undefined;
|
|
293
|
+
const email = typeof body?.email === 'string' && body.email.trim().length > 0
|
|
294
|
+
? body.email.trim()
|
|
295
|
+
: undefined;
|
|
296
|
+
// Nothing to update → explicit 422 (rather than a silent no-op re-read).
|
|
297
|
+
if (name === undefined && email === undefined) {
|
|
298
|
+
unprocessable('no_changes');
|
|
299
|
+
}
|
|
300
|
+
// Basic email shape check when an email is supplied (non-empty already ensured).
|
|
301
|
+
if (email !== undefined && !isEmailShaped(email)) {
|
|
302
|
+
unprocessable('invalid_email');
|
|
303
|
+
}
|
|
304
|
+
// Fail-closed when the write seam is unplugged — an honest "not available".
|
|
305
|
+
if (this.profileWriter === undefined || this.profileWriter === null) {
|
|
306
|
+
notAvailable();
|
|
307
|
+
}
|
|
308
|
+
// Build the change with ONLY the supplied fields (partial edit), then return the
|
|
309
|
+
// refreshed display profile the writer re-reads (names, never UUIDs).
|
|
310
|
+
const changes = {};
|
|
311
|
+
if (name !== undefined)
|
|
312
|
+
changes.name = name;
|
|
313
|
+
if (email !== undefined)
|
|
314
|
+
changes.email = email;
|
|
315
|
+
return this.profileWriter.update(claims.id, changes);
|
|
316
|
+
}
|
|
317
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
318
|
+
// Multi-device MFA — self-scoped device management. Every route
|
|
319
|
+
// authenticates the caller (bearer → claims.id) and acts ONLY on the caller's
|
|
320
|
+
// own devices; a device not owned by the caller → 404 (never confirms another
|
|
321
|
+
// principal's device exists). A device `secret` is NEVER returned except the
|
|
322
|
+
// freshly-minted one at `begin` (the caller's own, for their QR). The store
|
|
323
|
+
// (`SHIELD_MFA_DEVICE_STORE`) absent → 501.
|
|
324
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
325
|
+
/**
|
|
326
|
+
* Mint a fresh recovery-code batch, hash each (the SAME Argon2 hasher recovery-code
|
|
327
|
+
* consume verifies against), and REPLACE the caller's prior codes. Returns the RAW
|
|
328
|
+
* codes to show the user exactly once. Caller guarantees the recovery store + hasher
|
|
329
|
+
* are present (checked at the route). NEVER logs the codes or hashes.
|
|
330
|
+
*/
|
|
331
|
+
async replaceRecoveryCodes(principalId, store, hasher) {
|
|
332
|
+
const rawCodes = (0, totp_enrollment_js_1.generateRecoveryCodes)();
|
|
333
|
+
const hashes = await Promise.all(rawCodes.map((c) => hasher.hash(c)));
|
|
334
|
+
await store.replaceAll(principalId, hashes);
|
|
335
|
+
return rawCodes;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Verify a presented `code` against ANY of the caller's CONFIRMED device secrets
|
|
339
|
+
* (TOTP), returning true on the first match. Used by remove/regenerate where the
|
|
340
|
+
* proof may come from any live device (not a specific one). NEVER logs the code.
|
|
341
|
+
*/
|
|
342
|
+
async verifyAgainstAnyDevice(store, principalId, code) {
|
|
343
|
+
const secrets = await store.listConfirmedSecrets(principalId);
|
|
344
|
+
for (const secret of secrets) {
|
|
345
|
+
if (await this.verifyTotp(secret, code))
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* `GET /account/mfa/devices` — list the caller's CONFIRMED authenticator devices.
|
|
352
|
+
* `mfaEnabled` reflects whether any confirmed device exists. Pending (unconfirmed)
|
|
353
|
+
* devices are internal and never surfaced. A device `secret` is never included
|
|
354
|
+
* (the {@link MfaDevice} display shape omits it).
|
|
355
|
+
*
|
|
356
|
+
* Outcomes: no/invalid token → 401; store absent → 501; success → 200
|
|
357
|
+
* `{ devices: MfaDevice[], mfaEnabled: boolean }`.
|
|
358
|
+
*/
|
|
359
|
+
async listDevices(authorization) {
|
|
360
|
+
const claims = await this.requireClaims(authorization);
|
|
361
|
+
// ALLOWED during re-enroll: the re-enroll screen lists devices to show progress.
|
|
362
|
+
this.enforceReenroll(claims, true);
|
|
363
|
+
if (this.mfaDevices === undefined || this.mfaDevices === null)
|
|
364
|
+
notAvailable();
|
|
365
|
+
const all = await this.mfaDevices.listAll(claims.id);
|
|
366
|
+
const devices = all.filter((d) => d.confirmed);
|
|
367
|
+
return { devices, mfaEnabled: devices.length > 0 };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* `POST /account/mfa/devices/begin` — start enrolling a NEW authenticator device.
|
|
371
|
+
* Mints a fresh secret, creates a PENDING device row, and returns the provisioning
|
|
372
|
+
* material (`otpauthUri` for a QR + `secret` for manual entry). NOT active until
|
|
373
|
+
* `confirm`. A blank name defaults to `"Authenticator N"` (N = confirmed count + 1).
|
|
374
|
+
*
|
|
375
|
+
* Outcomes: 401; store absent → 501; success → 200 `{ deviceId, otpauthUri, secret }`.
|
|
376
|
+
*/
|
|
377
|
+
async beginDevice(authorization, body) {
|
|
378
|
+
const claims = await this.requireClaims(authorization);
|
|
379
|
+
// ALLOWED during re-enroll: adding a fresh device is the whole point of re-enroll.
|
|
380
|
+
this.enforceReenroll(claims, true);
|
|
381
|
+
if (this.mfaDevices === undefined || this.mfaDevices === null)
|
|
382
|
+
notAvailable();
|
|
383
|
+
const store = this.mfaDevices;
|
|
384
|
+
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
385
|
+
const name = rawName.length > 0
|
|
386
|
+
? rawName.slice(0, MAX_DEVICE_NAME_LEN)
|
|
387
|
+
: `Authenticator ${(await store.countConfirmed(claims.id)) + 1}`;
|
|
388
|
+
const secret = (0, totp_enrollment_js_1.generateTotpSecret)();
|
|
389
|
+
const deviceId = await store.begin(claims.id, name, secret);
|
|
390
|
+
const otpauthUri = (0, totp_enrollment_js_1.buildOtpauthUri)(secret, await this.resolveOtpauthLabel(claims), TOTP_ISSUER);
|
|
391
|
+
return { deviceId, otpauthUri, secret };
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* `POST /account/mfa/devices/confirm` — activate a pending device by proving a live
|
|
395
|
+
* code from it. Loads THAT device's secret (owned + still pending), verifies the
|
|
396
|
+
* code, and confirms. If this is the caller's FIRST confirmed device, fresh recovery
|
|
397
|
+
* codes are minted (shown once) and returned; subsequent devices return `{}` and do
|
|
398
|
+
* NOT touch recovery codes.
|
|
399
|
+
*
|
|
400
|
+
* Outcomes: 401; store/recovery/hasher absent → 501; device not pending/owned → 404;
|
|
401
|
+
* wrong/expired code → 400 (device stays pending); success → 200 `{ recoveryCodes? }`.
|
|
402
|
+
*/
|
|
403
|
+
async confirmDevice(authorization, body) {
|
|
404
|
+
const claims = await this.requireClaims(authorization);
|
|
405
|
+
// ALLOWED during re-enroll: confirming a fresh device is the action that RE-SECURES
|
|
406
|
+
// the account — and (below) the SOLE action that clears the re-enroll restriction.
|
|
407
|
+
this.enforceReenroll(claims, true);
|
|
408
|
+
if (this.mfaDevices === undefined ||
|
|
409
|
+
this.mfaDevices === null ||
|
|
410
|
+
this.recoveryCodes === undefined ||
|
|
411
|
+
this.recoveryCodes === null ||
|
|
412
|
+
this.hasher === undefined) {
|
|
413
|
+
notAvailable();
|
|
414
|
+
}
|
|
415
|
+
const store = this.mfaDevices;
|
|
416
|
+
const recoveryStore = this.recoveryCodes;
|
|
417
|
+
const hasher = this.hasher;
|
|
418
|
+
const deviceId = typeof body?.deviceId === 'string' ? body.deviceId : '';
|
|
419
|
+
const code = typeof body?.code === 'string' ? body.code : '';
|
|
420
|
+
// Load the pending device's OWN secret (owned by the caller) — else 404. The secret
|
|
421
|
+
// never leaves the server; it is only used to check the presented code here.
|
|
422
|
+
const secret = await store.getSecret(claims.id, deviceId);
|
|
423
|
+
if (secret === null) {
|
|
424
|
+
throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
425
|
+
}
|
|
426
|
+
// Wrong/expired code → generic 400; the device stays pending (retry or GC later).
|
|
427
|
+
if (!(await this.verifyTotp(secret, code)))
|
|
428
|
+
badRequest();
|
|
429
|
+
// First-device check BEFORE confirming (so the just-confirmed device isn't counted).
|
|
430
|
+
const wasFirst = (await store.countConfirmed(claims.id)) === 0;
|
|
431
|
+
const confirmed = await store.confirm(claims.id, deviceId);
|
|
432
|
+
// A race (device removed / already confirmed between load and confirm) → 404.
|
|
433
|
+
if (!confirmed) {
|
|
434
|
+
throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
435
|
+
}
|
|
436
|
+
// ENFORCED-RE-ENROLL CLEAR (spec §4.3): confirming a fresh device is the SOLE
|
|
437
|
+
// action that lifts a recovery-login session's re-enroll restriction. If this
|
|
438
|
+
// caller's session is marked `mustReenroll`, clear the bound claim so its very next
|
|
439
|
+
// request is unrestricted (the access token is NOT re-minted — only the claim
|
|
440
|
+
// flips). Best-effort: a clear failure just means the user re-confirms/retries. The
|
|
441
|
+
// binder is present under `auth: true` (it authenticated `requireClaims` above);
|
|
442
|
+
// guarded for wiring resilience. Regenerate-codes deliberately does NOT clear.
|
|
443
|
+
if (claims.mustReenroll === true && this.binder !== undefined && this.binder !== null) {
|
|
444
|
+
await this.binder.clearReenroll(claims.sessionId);
|
|
445
|
+
}
|
|
446
|
+
if (wasFirst) {
|
|
447
|
+
const recoveryCodes = await this.replaceRecoveryCodes(claims.id, recoveryStore, hasher);
|
|
448
|
+
return { recoveryCodes };
|
|
449
|
+
}
|
|
450
|
+
// Subsequent devices never mint / wipe recovery codes.
|
|
451
|
+
return {};
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* `PATCH /account/mfa/devices/:id/rename` — rename one of the caller's devices.
|
|
455
|
+
* Cosmetic (renaming a label doesn't weaken 2FA), so it is session-auth only (no
|
|
456
|
+
* code required), consistent with profile-edit. Trimmed empty name → 422.
|
|
457
|
+
*
|
|
458
|
+
* Outcomes: 401; store absent → 501; blank name → 422; not found/owned → 404;
|
|
459
|
+
* success → 200 `{ device }`.
|
|
460
|
+
*/
|
|
461
|
+
async renameDevice(authorization, id, body) {
|
|
462
|
+
const claims = await this.requireClaims(authorization);
|
|
463
|
+
// BLOCKED during re-enroll: renaming is not part of re-securing the account.
|
|
464
|
+
this.enforceReenroll(claims, false);
|
|
465
|
+
if (this.mfaDevices === undefined || this.mfaDevices === null)
|
|
466
|
+
notAvailable();
|
|
467
|
+
const store = this.mfaDevices;
|
|
468
|
+
const name = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
469
|
+
if (name.length === 0)
|
|
470
|
+
unprocessable('invalid_name');
|
|
471
|
+
const device = await store.rename(claims.id, id, name.slice(0, MAX_DEVICE_NAME_LEN));
|
|
472
|
+
if (device === null) {
|
|
473
|
+
throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
474
|
+
}
|
|
475
|
+
return { device };
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* `DELETE /account/mfa/devices/:id` — remove one of the caller's devices. Requires
|
|
479
|
+
* proof: a live TOTP code from ANY of the caller's remaining confirmed devices, OR a
|
|
480
|
+
* recovery code — so a hijacked live session can't silently strip MFA. Removing the
|
|
481
|
+
* LAST device is the "disable MFA" path (authorized by that last device's own code)
|
|
482
|
+
* and additionally wipes the account's recovery codes.
|
|
483
|
+
*
|
|
484
|
+
* Outcomes: 401; store absent → 501; wrong code → 400 (no remove); not found/owned →
|
|
485
|
+
* 404; success → 204.
|
|
486
|
+
*/
|
|
487
|
+
async removeDevice(authorization, id, body) {
|
|
488
|
+
const claims = await this.requireClaims(authorization);
|
|
489
|
+
// BLOCKED during re-enroll (THE exploit the safeguard exists to stop): a recovery
|
|
490
|
+
// session must NOT be able to strip devices / disable MFA before re-securing. It
|
|
491
|
+
// must add + confirm a fresh device first (which clears the flag), THEN it may
|
|
492
|
+
// remove devices.
|
|
493
|
+
this.enforceReenroll(claims, false);
|
|
494
|
+
if (this.mfaDevices === undefined || this.mfaDevices === null)
|
|
495
|
+
notAvailable();
|
|
496
|
+
const store = this.mfaDevices;
|
|
497
|
+
const code = typeof body?.code === 'string' ? body.code : '';
|
|
498
|
+
// Proof BEFORE the delete: a TOTP from any confirmed device (incl. the one being
|
|
499
|
+
// removed — legit for the last-device disable) OR a valid recovery code.
|
|
500
|
+
const byTotp = await this.verifyAgainstAnyDevice(store, claims.id, code);
|
|
501
|
+
const byRecovery = !byTotp &&
|
|
502
|
+
this.recoveryCodes !== undefined &&
|
|
503
|
+
this.recoveryCodes !== null &&
|
|
504
|
+
(await this.recoveryCodes.consume(claims.id, code));
|
|
505
|
+
if (!byTotp && !byRecovery)
|
|
506
|
+
badRequest();
|
|
507
|
+
const removed = await store.remove(claims.id, id);
|
|
508
|
+
if (!removed) {
|
|
509
|
+
throw new common_1.HttpException({ error: 'not_found' }, common_1.HttpStatus.NOT_FOUND);
|
|
510
|
+
}
|
|
511
|
+
// Removing the last confirmed device disables MFA → wipe the account recovery codes.
|
|
512
|
+
if ((await store.countConfirmed(claims.id)) === 0) {
|
|
513
|
+
if (this.recoveryCodes !== undefined && this.recoveryCodes !== null) {
|
|
514
|
+
await this.recoveryCodes.replaceAll(claims.id, []);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* `POST /account/mfa/recovery-codes/regenerate` — mint a fresh recovery-code batch
|
|
520
|
+
* (replacing the old set). Requires MFA to be enabled (≥1 confirmed device) and proof
|
|
521
|
+
* — a TOTP from any confirmed device OR an existing recovery code.
|
|
522
|
+
*
|
|
523
|
+
* Outcomes: 401; store/recovery/hasher absent → 501; MFA off → 400; wrong code → 400;
|
|
524
|
+
* success → 200 `{ recoveryCodes }`.
|
|
525
|
+
*/
|
|
526
|
+
async regenerateRecoveryCodes(authorization, body) {
|
|
527
|
+
const claims = await this.requireClaims(authorization);
|
|
528
|
+
// ALLOWED during re-enroll: the user may refresh recovery codes while re-securing.
|
|
529
|
+
// (Allowed ≠ gate-clearing, spec §4.3: regenerating codes does NOT lift the
|
|
530
|
+
// restriction — only confirming a fresh device does.)
|
|
531
|
+
this.enforceReenroll(claims, true);
|
|
532
|
+
if (this.mfaDevices === undefined ||
|
|
533
|
+
this.mfaDevices === null ||
|
|
534
|
+
this.recoveryCodes === undefined ||
|
|
535
|
+
this.recoveryCodes === null ||
|
|
536
|
+
this.hasher === undefined) {
|
|
537
|
+
notAvailable();
|
|
538
|
+
}
|
|
539
|
+
const store = this.mfaDevices;
|
|
540
|
+
const recoveryStore = this.recoveryCodes;
|
|
541
|
+
const hasher = this.hasher;
|
|
542
|
+
const code = typeof body?.code === 'string' ? body.code : '';
|
|
543
|
+
// MFA must be on — else there's nothing to protect / no code source → generic 400.
|
|
544
|
+
if ((await store.countConfirmed(claims.id)) === 0)
|
|
545
|
+
badRequest();
|
|
546
|
+
// Proof: a TOTP from any confirmed device OR an existing recovery code.
|
|
547
|
+
const byTotp = await this.verifyAgainstAnyDevice(store, claims.id, code);
|
|
548
|
+
const byRecovery = !byTotp && (await recoveryStore.consume(claims.id, code));
|
|
549
|
+
if (!byTotp && !byRecovery)
|
|
550
|
+
badRequest();
|
|
551
|
+
const recoveryCodes = await this.replaceRecoveryCodes(claims.id, recoveryStore, hasher);
|
|
552
|
+
return { recoveryCodes };
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
exports.AccountController = AccountController;
|
|
556
|
+
__decorate([
|
|
557
|
+
(0, classification_decorator_js_1.Public)(),
|
|
558
|
+
(0, common_1.Post)('change-password'),
|
|
559
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
|
|
560
|
+
__param(0, (0, common_1.Headers)('authorization')),
|
|
561
|
+
__param(1, (0, common_1.Body)()),
|
|
562
|
+
__metadata("design:type", Function),
|
|
563
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
564
|
+
__metadata("design:returntype", Promise)
|
|
565
|
+
], AccountController.prototype, "changePassword", null);
|
|
566
|
+
__decorate([
|
|
567
|
+
(0, classification_decorator_js_1.Public)(),
|
|
568
|
+
(0, common_1.Patch)('profile'),
|
|
569
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
570
|
+
__param(0, (0, common_1.Headers)('authorization')),
|
|
571
|
+
__param(1, (0, common_1.Body)()),
|
|
572
|
+
__metadata("design:type", Function),
|
|
573
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
574
|
+
__metadata("design:returntype", Promise)
|
|
575
|
+
], AccountController.prototype, "updateProfile", null);
|
|
576
|
+
__decorate([
|
|
577
|
+
(0, classification_decorator_js_1.Public)(),
|
|
578
|
+
(0, common_1.Get)('mfa/devices'),
|
|
579
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
580
|
+
__param(0, (0, common_1.Headers)('authorization')),
|
|
581
|
+
__metadata("design:type", Function),
|
|
582
|
+
__metadata("design:paramtypes", [Object]),
|
|
583
|
+
__metadata("design:returntype", Promise)
|
|
584
|
+
], AccountController.prototype, "listDevices", null);
|
|
585
|
+
__decorate([
|
|
586
|
+
(0, classification_decorator_js_1.Public)(),
|
|
587
|
+
(0, common_1.Post)('mfa/devices/begin'),
|
|
588
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
589
|
+
__param(0, (0, common_1.Headers)('authorization')),
|
|
590
|
+
__param(1, (0, common_1.Body)()),
|
|
591
|
+
__metadata("design:type", Function),
|
|
592
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
593
|
+
__metadata("design:returntype", Promise)
|
|
594
|
+
], AccountController.prototype, "beginDevice", null);
|
|
595
|
+
__decorate([
|
|
596
|
+
(0, classification_decorator_js_1.Public)(),
|
|
597
|
+
(0, common_1.Post)('mfa/devices/confirm'),
|
|
598
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
599
|
+
__param(0, (0, common_1.Headers)('authorization')),
|
|
600
|
+
__param(1, (0, common_1.Body)()),
|
|
601
|
+
__metadata("design:type", Function),
|
|
602
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
603
|
+
__metadata("design:returntype", Promise)
|
|
604
|
+
], AccountController.prototype, "confirmDevice", null);
|
|
605
|
+
__decorate([
|
|
606
|
+
(0, classification_decorator_js_1.Public)(),
|
|
607
|
+
(0, common_1.Patch)('mfa/devices/:id/rename'),
|
|
608
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
609
|
+
__param(0, (0, common_1.Headers)('authorization')),
|
|
610
|
+
__param(1, (0, common_1.Param)('id')),
|
|
611
|
+
__param(2, (0, common_1.Body)()),
|
|
612
|
+
__metadata("design:type", Function),
|
|
613
|
+
__metadata("design:paramtypes", [Object, String, Object]),
|
|
614
|
+
__metadata("design:returntype", Promise)
|
|
615
|
+
], AccountController.prototype, "renameDevice", null);
|
|
616
|
+
__decorate([
|
|
617
|
+
(0, classification_decorator_js_1.Public)(),
|
|
618
|
+
(0, common_1.Delete)('mfa/devices/:id'),
|
|
619
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.NO_CONTENT),
|
|
620
|
+
__param(0, (0, common_1.Headers)('authorization')),
|
|
621
|
+
__param(1, (0, common_1.Param)('id')),
|
|
622
|
+
__param(2, (0, common_1.Body)()),
|
|
623
|
+
__metadata("design:type", Function),
|
|
624
|
+
__metadata("design:paramtypes", [Object, String, Object]),
|
|
625
|
+
__metadata("design:returntype", Promise)
|
|
626
|
+
], AccountController.prototype, "removeDevice", null);
|
|
627
|
+
__decorate([
|
|
628
|
+
(0, classification_decorator_js_1.Public)(),
|
|
629
|
+
(0, common_1.Post)('mfa/recovery-codes/regenerate'),
|
|
630
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
631
|
+
__param(0, (0, common_1.Headers)('authorization')),
|
|
632
|
+
__param(1, (0, common_1.Body)()),
|
|
633
|
+
__metadata("design:type", Function),
|
|
634
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
635
|
+
__metadata("design:returntype", Promise)
|
|
636
|
+
], AccountController.prototype, "regenerateRecoveryCodes", null);
|
|
637
|
+
exports.AccountController = AccountController = __decorate([
|
|
638
|
+
(0, common_1.Controller)('account'),
|
|
639
|
+
__param(0, (0, common_1.Optional)()),
|
|
640
|
+
__param(0, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_TOKEN_BINDER)),
|
|
641
|
+
__param(1, (0, common_1.Optional)()),
|
|
642
|
+
__param(1, (0, common_1.Inject)(tokens_js_2.SHIELD_PASSWORD_HASHER)),
|
|
643
|
+
__param(2, (0, common_1.Optional)()),
|
|
644
|
+
__param(2, (0, common_1.Inject)(tokens_js_2.SHIELD_CREDENTIAL_WRITER)),
|
|
645
|
+
__param(3, (0, common_1.Optional)()),
|
|
646
|
+
__param(3, (0, common_1.Inject)(tokens_js_2.SHIELD_SESSION_SWEEPER)),
|
|
647
|
+
__param(4, (0, common_1.Optional)()),
|
|
648
|
+
__param(4, (0, common_1.Inject)(tokens_js_2.SHIELD_RECOVERY_CODE_STORE)),
|
|
649
|
+
__param(5, (0, common_1.Optional)()),
|
|
650
|
+
__param(5, (0, common_1.Inject)(tokens_js_2.SHIELD_TOTP_VERIFIER)),
|
|
651
|
+
__param(6, (0, common_1.Optional)()),
|
|
652
|
+
__param(6, (0, common_1.Inject)(tokens_js_2.SHIELD_PROFILE_RESOLVER)),
|
|
653
|
+
__param(7, (0, common_1.Optional)()),
|
|
654
|
+
__param(7, (0, common_1.Inject)(tokens_js_2.SHIELD_PROFILE_WRITER)),
|
|
655
|
+
__param(8, (0, common_1.Optional)()),
|
|
656
|
+
__param(8, (0, common_1.Inject)(tokens_js_2.SHIELD_MFA_DEVICE_STORE)),
|
|
657
|
+
__metadata("design:paramtypes", [Function, Object, Object, Object, Object, Object, Object, Object, Object])
|
|
658
|
+
], AccountController);
|
|
659
|
+
/**
|
|
660
|
+
* A deliberately-minimal email shape check (non-empty, a single `@`, a `.` in the
|
|
661
|
+
* domain, no whitespace). This is a self-service edit of the caller's own profile —
|
|
662
|
+
* a full RFC-5322 validator adds no security value here, only false rejections. The
|
|
663
|
+
* authoritative check is delivery; this just catches obvious typos client-adjacent.
|
|
664
|
+
*/
|
|
665
|
+
function isEmailShaped(value) {
|
|
666
|
+
if (/\s/.test(value))
|
|
667
|
+
return false;
|
|
668
|
+
const at = value.indexOf('@');
|
|
669
|
+
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1)
|
|
670
|
+
return false;
|
|
671
|
+
const domain = value.slice(at + 1);
|
|
672
|
+
return domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.');
|
|
673
|
+
}
|