@astram/guard 0.10.2 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/account.controller.d.ts +76 -19
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +270 -53
- package/dist/auth/auth.controller.d.ts +3 -1
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +10 -2
- package/dist/auth/auth.module.d.ts +8 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +80 -7
- package/dist/auth/confirm-email.controller.d.ts +33 -0
- package/dist/auth/confirm-email.controller.d.ts.map +1 -0
- package/dist/auth/confirm-email.controller.js +205 -0
- package/dist/auth/email-address.d.ts +30 -0
- package/dist/auth/email-address.d.ts.map +1 -0
- package/dist/auth/email-address.js +49 -0
- package/dist/auth/email-change-messages.d.ts +26 -0
- package/dist/auth/email-change-messages.d.ts.map +1 -0
- package/dist/auth/email-change-messages.js +56 -0
- package/dist/auth/link-base-url.d.ts +24 -0
- package/dist/auth/link-base-url.d.ts.map +1 -0
- package/dist/auth/link-base-url.js +93 -0
- package/dist/auth/ports/email-address-changer.d.ts +65 -0
- package/dist/auth/ports/email-address-changer.d.ts.map +1 -0
- package/dist/auth/ports/email-address-changer.js +8 -0
- package/dist/auth/ports/email-change-token-store.d.ts +39 -0
- package/dist/auth/ports/email-change-token-store.d.ts.map +1 -0
- package/dist/auth/ports/email-change-token-store.js +12 -0
- package/dist/auth/tokens.d.ts +39 -1
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +40 -2
- package/dist/authentication/mariadb-email-address-changer.d.ts +33 -0
- package/dist/authentication/mariadb-email-address-changer.d.ts.map +1 -0
- package/dist/authentication/mariadb-email-address-changer.js +97 -0
- package/dist/authentication/mariadb-email-change-token-store.d.ts +25 -0
- package/dist/authentication/mariadb-email-change-token-store.d.ts.map +1 -0
- package/dist/authentication/mariadb-email-change-token-store.js +72 -0
- package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-mfa-device-store.js +8 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -1
- package/dist/module/shield.module.d.ts +11 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +6 -0
- package/package.json +3 -3
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NOT_YOU_SENTENCE = exports.DEFAULT_EMAIL_CHANGE_LINK_BASE_URL = exports.EMAIL_CHANGE_TTL_MS = void 0;
|
|
4
|
+
exports.emailChangeLink = emailChangeLink;
|
|
5
|
+
exports.confirmEmailChangeMessage = confirmEmailChangeMessage;
|
|
6
|
+
exports.emailChangedNoticeMessage = emailChangedNoticeMessage;
|
|
7
|
+
/** Email-change token time-to-live: 30 minutes (same as a password reset). */
|
|
8
|
+
exports.EMAIL_CHANGE_TTL_MS = 30 * 60 * 1000;
|
|
9
|
+
/** The default confirm-page base (a dev Vite origin), like the reset-link default. */
|
|
10
|
+
exports.DEFAULT_EMAIL_CHANGE_LINK_BASE_URL = 'http://localhost:5173/confirm-email';
|
|
11
|
+
/**
|
|
12
|
+
* What to do if the change was not the owner's (A10, reworded in Phase 13 #2a). After an
|
|
13
|
+
* attacker's change the login hint may have moved with the email (it follows when it equalled the
|
|
14
|
+
* old address), so a reset from the login page may not reach the victim: the administrator comes
|
|
15
|
+
* first, and the reset advice is qualified rather than given as the remedy.
|
|
16
|
+
*/
|
|
17
|
+
exports.NOT_YOU_SENTENCE = "If this wasn't you, contact your administrator immediately. Your sign-in email may have " +
|
|
18
|
+
'changed too, so a password reset from the login page may not reach you.';
|
|
19
|
+
function escapeHtml(value) {
|
|
20
|
+
return value
|
|
21
|
+
.replace(/&/g, '&')
|
|
22
|
+
.replace(/</g, '<')
|
|
23
|
+
.replace(/>/g, '>')
|
|
24
|
+
.replace(/"/g, '"');
|
|
25
|
+
}
|
|
26
|
+
/** The confirm link: the token rides in the fragment, never the query (A4). */
|
|
27
|
+
function emailChangeLink(base, rawToken) {
|
|
28
|
+
return `${base}#t=${rawToken}`;
|
|
29
|
+
}
|
|
30
|
+
function confirmEmailChangeMessage(to, link) {
|
|
31
|
+
const minutes = Math.round(exports.EMAIL_CHANGE_TTL_MS / 60000);
|
|
32
|
+
return {
|
|
33
|
+
to,
|
|
34
|
+
subject: 'Confirm your new email address',
|
|
35
|
+
text: 'You asked to use this address for your account. Confirm it by opening this link ' +
|
|
36
|
+
`within ${minutes} minutes: ${link}\n\n` +
|
|
37
|
+
'If you did not ask for this, ignore this message; nothing changes.',
|
|
38
|
+
html: '<p>You asked to use this address for your account. Confirm it by opening this link ' +
|
|
39
|
+
`within ${minutes} minutes:</p>` +
|
|
40
|
+
`<p><a href="${escapeHtml(link)}" rel="noreferrer">${escapeHtml(link)}</a></p>` +
|
|
41
|
+
'<p>If you did not ask for this, ignore this message; nothing changes.</p>',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function emailChangedNoticeMessage(to, maskedNewEmail, atMs) {
|
|
45
|
+
const when = new Date(atMs)
|
|
46
|
+
.toISOString()
|
|
47
|
+
.replace('T', ' ')
|
|
48
|
+
.replace(/\.\d+Z$/, ' UTC');
|
|
49
|
+
const what = `The email address on your account was changed to ${maskedNewEmail} at ${when}.`;
|
|
50
|
+
return {
|
|
51
|
+
to,
|
|
52
|
+
subject: 'Your account email address was changed',
|
|
53
|
+
text: `${what}\n\n${exports.NOT_YOU_SENTENCE}`,
|
|
54
|
+
html: `<p>${escapeHtml(what)}</p><p>${escapeHtml(exports.NOT_YOU_SENTENCE)}</p>`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The default reset-page base (a dev Vite origin). */
|
|
2
|
+
export declare const DEFAULT_RESET_LINK_BASE_URL = "http://localhost:5173/reset-password";
|
|
3
|
+
export interface AuthLinkBaseUrls {
|
|
4
|
+
readonly resetLinkBaseUrl: string;
|
|
5
|
+
readonly emailChangeLinkBaseUrl: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ResolveAuthLinkBaseUrlsInput {
|
|
8
|
+
/** The raw environment (`NODE_ENV`, `RESET_LINK_BASE_URL`, `EMAIL_CHANGE_LINK_BASE_URL`). */
|
|
9
|
+
readonly env: NodeJS.ProcessEnv;
|
|
10
|
+
/** `ShieldModule.forRoot({ emailChangeLinkBaseUrl })`. */
|
|
11
|
+
readonly emailChangeLinkBaseUrlOption?: string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* `true` when no app-supplied sender exists: `emailSender: 'disabled'` (nothing is delivered) or
|
|
14
|
+
* omitted (outside development/test the email-sender boot rule already fails the boot with its
|
|
15
|
+
* own error; in development/test the link rule is off anyway).
|
|
16
|
+
*/
|
|
17
|
+
readonly mailDisabled: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve both link bases and apply the boot rule. Throws (boot fails) outside development/test
|
|
21
|
+
* when a base is unset or local and mail is not `'disabled'`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveAuthLinkBaseUrls(input: ResolveAuthLinkBaseUrlsInput): AuthLinkBaseUrls;
|
|
24
|
+
//# sourceMappingURL=link-base-url.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link-base-url.d.ts","sourceRoot":"","sources":["../../src/auth/link-base-url.ts"],"names":[],"mappings":"AAmBA,uDAAuD;AACvD,eAAO,MAAM,2BAA2B,yCAAyC,CAAC;AAElF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,sBAAsB,EAAE,MAAM,CAAC;CACzC;AAED,MAAM,WAAW,4BAA4B;IAC3C,6FAA6F;IAC7F,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IAChC,0DAA0D;IAC1D,QAAQ,CAAC,4BAA4B,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;CAChC;AAmCD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,4BAA4B,GAAG,gBAAgB,CAgC7F"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_RESET_LINK_BASE_URL = void 0;
|
|
4
|
+
exports.resolveAuthLinkBaseUrls = resolveAuthLinkBaseUrls;
|
|
5
|
+
/**
|
|
6
|
+
* The two link bases the framework mails (security hardening Phase 13 #1):
|
|
7
|
+
* - the password-reset link `${RESET_LINK_BASE_URL}?token=…` (`POST /auth/forgot-password`);
|
|
8
|
+
* - the email-change confirm link `${emailChangeLinkBaseUrl}#t=…` (`PATCH /account/profile`).
|
|
9
|
+
*
|
|
10
|
+
* Precedence: the `emailChangeLinkBaseUrl` option > `EMAIL_CHANGE_LINK_BASE_URL` > dev default;
|
|
11
|
+
* the reset link: `RESET_LINK_BASE_URL` > dev default. A blank value counts as unset.
|
|
12
|
+
*
|
|
13
|
+
* Boot rule (fail closed): outside `NODE_ENV` development/test (raw value, unset is NOT dev), with
|
|
14
|
+
* a mail sender, each base must be set, parse as an `http:`/`https:` URL and name a host that is
|
|
15
|
+
* not loopback/unspecified (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`, `0.0.0.0`, `::`).
|
|
16
|
+
* Otherwise auth composition throws, naming the variable(s) — never the configured value (it may
|
|
17
|
+
* carry credentials). `emailSender: 'disabled'` skips the rule: the no-op sender delivers nothing,
|
|
18
|
+
* so no link can reach a user (an omitted sender fails the boot earlier, see `auth.module.ts`).
|
|
19
|
+
* `https:` is not required (the reset path never required it).
|
|
20
|
+
*/
|
|
21
|
+
const app_config_js_1 = require("../config/app-config.js");
|
|
22
|
+
const email_change_messages_js_1 = require("./email-change-messages.js");
|
|
23
|
+
/** The default reset-page base (a dev Vite origin). */
|
|
24
|
+
exports.DEFAULT_RESET_LINK_BASE_URL = 'http://localhost:5173/reset-password';
|
|
25
|
+
function present(value) {
|
|
26
|
+
return value !== undefined && value.trim() !== '' ? value.trim() : undefined;
|
|
27
|
+
}
|
|
28
|
+
function isLoopbackOrUnspecifiedHost(hostname) {
|
|
29
|
+
const host = hostname
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/^\[|\]$/gu, '')
|
|
32
|
+
.replace(/\.$/u, '');
|
|
33
|
+
if (host === 'localhost' || host.endsWith('.localhost'))
|
|
34
|
+
return true;
|
|
35
|
+
if (host === '0.0.0.0' || host === '::' || host === '::1')
|
|
36
|
+
return true;
|
|
37
|
+
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(host))
|
|
38
|
+
return true;
|
|
39
|
+
// IPv4-mapped loopback/unspecified (`::ffff:127.0.0.1` normalises to `::ffff:7f00:1`).
|
|
40
|
+
if (/^::ffff:(7f[0-9a-f]{2}:[0-9a-f]{1,4}|0:0)$/u.test(host))
|
|
41
|
+
return true;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
/** Why a production link base is unusable, or `null` when it is fine. */
|
|
45
|
+
function problemWith(value) {
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
return 'is not set';
|
|
48
|
+
let url;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(value);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return 'is not a valid URL';
|
|
54
|
+
}
|
|
55
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
56
|
+
return 'is not an http(s) URL';
|
|
57
|
+
if (isLoopbackOrUnspecifiedHost(url.hostname)) {
|
|
58
|
+
return 'points at a loopback/unspecified host (localhost, 127.0.0.1, ::1, 0.0.0.0)';
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve both link bases and apply the boot rule. Throws (boot fails) outside development/test
|
|
64
|
+
* when a base is unset or local and mail is not `'disabled'`.
|
|
65
|
+
*/
|
|
66
|
+
function resolveAuthLinkBaseUrls(input) {
|
|
67
|
+
const resetConfigured = present(input.env.RESET_LINK_BASE_URL);
|
|
68
|
+
const optionConfigured = present(input.emailChangeLinkBaseUrlOption);
|
|
69
|
+
const emailChangeConfigured = optionConfigured ?? present(input.env.EMAIL_CHANGE_LINK_BASE_URL);
|
|
70
|
+
if (!input.mailDisabled && !(0, app_config_js_1.isDevOrTestNodeEnv)(input.env.NODE_ENV)) {
|
|
71
|
+
const problems = [];
|
|
72
|
+
const resetProblem = problemWith(resetConfigured);
|
|
73
|
+
if (resetProblem)
|
|
74
|
+
problems.push(`RESET_LINK_BASE_URL ${resetProblem}`);
|
|
75
|
+
const changeProblem = problemWith(emailChangeConfigured);
|
|
76
|
+
if (changeProblem) {
|
|
77
|
+
const name = optionConfigured
|
|
78
|
+
? 'the emailChangeLinkBaseUrl option'
|
|
79
|
+
: 'EMAIL_CHANGE_LINK_BASE_URL (or the emailChangeLinkBaseUrl option)';
|
|
80
|
+
problems.push(`${name} ${changeProblem}`);
|
|
81
|
+
}
|
|
82
|
+
if (problems.length > 0) {
|
|
83
|
+
const nodeEnv = input.env.NODE_ENV;
|
|
84
|
+
throw new Error('Astram auth composition: the mailed password-reset and email-change links need a ' +
|
|
85
|
+
`public base URL when NODE_ENV is not 'development' or 'test' (got: ${nodeEnv === undefined ? 'unset' : `'${nodeEnv}'`}): ${problems.join('; ')}. Set them to your app's /reset-password and /confirm-email ` +
|
|
86
|
+
"pages, or pass emailSender: 'disabled' if this deployment sends no email.");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
resetLinkBaseUrl: resetConfigured ?? exports.DEFAULT_RESET_LINK_BASE_URL,
|
|
91
|
+
emailChangeLinkBaseUrl: emailChangeConfigured ?? email_change_messages_js_1.DEFAULT_EMAIL_CHANGE_LINK_BASE_URL,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EmailAddressChanger — the ONE place a user's email address is read for, and written by, an email
|
|
3
|
+
* change (security hardening Phase 12, KI #14). Framework default `MariaDbEmailAddressChanger` over
|
|
4
|
+
* `shield_app.users` + `shield_identity.identity_credential`. Used by guard's self-service routes
|
|
5
|
+
* and constructed by `@astram/identity` for the administrator path, so the two cannot drift.
|
|
6
|
+
*/
|
|
7
|
+
/** A user's address as stored, for the request-time checks. */
|
|
8
|
+
export interface CurrentEmail {
|
|
9
|
+
/** `users.u_email` exactly as stored (may be null). */
|
|
10
|
+
readonly email: string | null;
|
|
11
|
+
/** `users.u_status === 'active'`. */
|
|
12
|
+
readonly active: boolean;
|
|
13
|
+
/** The stored address is collation-equal (`utf8mb4_unicode_ci`) to the candidate. */
|
|
14
|
+
readonly sameAsCandidate: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** A parameterized statement on the change's own transaction connection. */
|
|
17
|
+
export type TransactionQuery = (sql: string, params?: unknown[]) => Promise<unknown>;
|
|
18
|
+
export interface EmailChangeRequest {
|
|
19
|
+
readonly principalId: string;
|
|
20
|
+
/** The CANONICAL new address (`canonicalEmail`, A5). */
|
|
21
|
+
readonly newEmail: string;
|
|
22
|
+
/**
|
|
23
|
+
* When given, the change is refused (`stale`) unless the stored `u_email` still equals it
|
|
24
|
+
* EXACTLY (A3: an admin or another flow changed it after the request). Omit on the admin path.
|
|
25
|
+
*/
|
|
26
|
+
readonly expectedOldEmail?: string | null;
|
|
27
|
+
/** Refuse (`inactive`) unless `u_status = 'active'` (the self-service confirm). */
|
|
28
|
+
readonly requireActive?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Runs on the change's transaction AFTER the writes and BEFORE commit (e.g. the admin path's
|
|
31
|
+
* audit row): a throw rolls the whole change back (fail closed).
|
|
32
|
+
*/
|
|
33
|
+
readonly withinTransaction?: (query: TransactionQuery, result: {
|
|
34
|
+
readonly oldEmail: string | null;
|
|
35
|
+
readonly hintMoved: boolean;
|
|
36
|
+
}) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export type EmailChangeOutcome = {
|
|
39
|
+
readonly status: 'changed';
|
|
40
|
+
readonly oldEmail: string | null;
|
|
41
|
+
readonly hintMoved: boolean;
|
|
42
|
+
}
|
|
43
|
+
/** Another user's `u_email` or another principal's login hint is collation-equal to it. */
|
|
44
|
+
| {
|
|
45
|
+
readonly status: 'unavailable';
|
|
46
|
+
} | {
|
|
47
|
+
readonly status: 'not_found';
|
|
48
|
+
} | {
|
|
49
|
+
readonly status: 'inactive';
|
|
50
|
+
} | {
|
|
51
|
+
readonly status: 'stale';
|
|
52
|
+
};
|
|
53
|
+
export interface EmailAddressChanger {
|
|
54
|
+
/** The user's stored address and whether it already equals `candidate`; `null` = no such user. */
|
|
55
|
+
readCurrent(principalId: string, candidate: string): Promise<CurrentEmail | null>;
|
|
56
|
+
/**
|
|
57
|
+
* Apply an email change in ONE transaction: lock the user row and the user's credential rows,
|
|
58
|
+
* refuse a collision (another user's `u_email`, another principal's `ic_identity_hint`;
|
|
59
|
+
* collation-equal, locked reads, self excluded), write `u_email`, move `ic_identity_hint` when it
|
|
60
|
+
* equalled the OLD email (a username hint is untouched), and burn the user's pending
|
|
61
|
+
* email-change tokens. Throws on a store error (caller → 503).
|
|
62
|
+
*/
|
|
63
|
+
change(request: EmailChangeRequest): Promise<EmailChangeOutcome>;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=email-address-changer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"email-address-changer.d.ts","sourceRoot":"","sources":["../../../src/auth/ports/email-address-changer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,+DAA+D;AAC/D,MAAM,WAAW,YAAY;IAC3B,uDAAuD;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,qCAAqC;IACrC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,qFAAqF;IACrF,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;CACnC;AAED,4EAA4E;AAC5E,MAAM,MAAM,gBAAgB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAErF,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,mFAAmF;IACnF,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAC3B,KAAK,EAAE,gBAAgB,EACvB,MAAM,EAAE;QAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;KAAE,KACtE,OAAO,CAAC,IAAI,CAAC,CAAC;CACpB;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;CAAE;AAC/F,2FAA2F;GACzF;IAAE,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;CAAE,GAClC;IAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GAChC;IAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAA;CAAE,GAC/B;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC;AAEjC,MAAM,WAAW,mBAAmB;IAClC,kGAAkG;IAClG,WAAW,CAAC,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAClF;;;;;;OAMG;IACH,MAAM,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;CAClE"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* EmailAddressChanger — the ONE place a user's email address is read for, and written by, an email
|
|
4
|
+
* change (security hardening Phase 12, KI #14). Framework default `MariaDbEmailAddressChanger` over
|
|
5
|
+
* `shield_app.users` + `shield_identity.identity_credential`. Used by guard's self-service routes
|
|
6
|
+
* and constructed by `@astram/identity` for the administrator path, so the two cannot drift.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EmailChangeTokenStore — persists single-use, TTL'd self-service email-change tokens (security
|
|
3
|
+
* hardening Phase 12, KI #14). The framework hashes the raw token (SHA-256) before it reaches this
|
|
4
|
+
* store; the store never sees the raw token.
|
|
5
|
+
*
|
|
6
|
+
* A token binds (A3) the user, the CANONICAL new address, the email the user had at request time
|
|
7
|
+
* and the requesting session. One pending change per user (A2): `issue` invalidates every earlier
|
|
8
|
+
* unconsumed token of the principal. `consume` is atomic and single-use: it returns the binding iff
|
|
9
|
+
* the token is unexpired AND unconsumed, and marks it consumed in the same statement.
|
|
10
|
+
*/
|
|
11
|
+
/** What an issued token binds (A3). */
|
|
12
|
+
export interface EmailChangeTokenBinding {
|
|
13
|
+
readonly principalId: string;
|
|
14
|
+
/** The canonical new address (trim → NFKC → lower-case, A5). */
|
|
15
|
+
readonly newEmail: string;
|
|
16
|
+
/** `users.u_email` when the change was requested (confirm refuses if it changed since). */
|
|
17
|
+
readonly oldEmail: string | null;
|
|
18
|
+
/** The requesting session (kept on confirm; every other session is revoked, A7). */
|
|
19
|
+
readonly sessionId: string;
|
|
20
|
+
/**
|
|
21
|
+
* The requesting session's tenant. The confirm step may run with no session, so its audit rows
|
|
22
|
+
* (`account.email_changed` / `account.email_change_refused`) take the tenant from here
|
|
23
|
+
* (`audit_log.al_tenant_id` is NOT NULL).
|
|
24
|
+
*/
|
|
25
|
+
readonly tenantId: string;
|
|
26
|
+
}
|
|
27
|
+
export interface EmailChangeTokenIssue extends EmailChangeTokenBinding {
|
|
28
|
+
readonly tokenHash: string;
|
|
29
|
+
readonly expiresAtMs: number;
|
|
30
|
+
}
|
|
31
|
+
export interface EmailChangeTokenStore {
|
|
32
|
+
/** Delete expired rows, invalidate the principal's other pending tokens, insert this one. */
|
|
33
|
+
issue(token: EmailChangeTokenIssue): Promise<void>;
|
|
34
|
+
/** Atomically consume (single use); `null` for unknown / expired / already consumed. */
|
|
35
|
+
consume(tokenHash: string): Promise<EmailChangeTokenBinding | null>;
|
|
36
|
+
/** Burn every pending token of the principal (password reset/change, admin change, confirm). */
|
|
37
|
+
invalidateAllForPrincipal(principalId: string): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=email-change-token-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"email-change-token-store.d.ts","sourceRoot":"","sources":["../../../src/auth/ports/email-change-token-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,uCAAuC;AACvC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,gEAAgE;IAChE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,2FAA2F;IAC3F,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,oFAAoF;IACpF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,qBAAsB,SAAQ,uBAAuB;IACpE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,qBAAqB;IACpC,6FAA6F;IAC7F,KAAK,CAAC,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,wFAAwF;IACxF,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC,CAAC;IACpE,gGAAgG;IAChG,yBAAyB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* EmailChangeTokenStore — persists single-use, TTL'd self-service email-change tokens (security
|
|
4
|
+
* hardening Phase 12, KI #14). The framework hashes the raw token (SHA-256) before it reaches this
|
|
5
|
+
* store; the store never sees the raw token.
|
|
6
|
+
*
|
|
7
|
+
* A token binds (A3) the user, the CANONICAL new address, the email the user had at request time
|
|
8
|
+
* and the requesting session. One pending change per user (A2): `issue` invalidates every earlier
|
|
9
|
+
* unconsumed token of the principal. `consume` is atomic and single-use: it returns the binding iff
|
|
10
|
+
* the token is unexpired AND unconsumed, and marks it consumed in the same statement.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/dist/auth/tokens.d.ts
CHANGED
|
@@ -82,6 +82,43 @@ export declare const SHIELD_CREDENTIAL_WRITER: unique symbol;
|
|
|
82
82
|
* never the raw token). Consumed by forgot-password + reset-password.
|
|
83
83
|
*/
|
|
84
84
|
export declare const SHIELD_RESET_TOKEN_STORE: unique symbol;
|
|
85
|
+
/**
|
|
86
|
+
* The `EmailChangeTokenStore` (guard port, `../auth/ports/email-change-token-store.ts`) — the
|
|
87
|
+
* single-use, TTL'd self-service email-change token store (Phase 12, KI #14). Framework default
|
|
88
|
+
* `MariaDbEmailChangeTokenStore` over `shield_identity.email_change_token`. Consumed by
|
|
89
|
+
* `PATCH /account/profile` (issue), `POST /auth/confirm-email` (consume), and invalidated by a
|
|
90
|
+
* password reset / change (A8). Absent → an email change answers 501.
|
|
91
|
+
*/
|
|
92
|
+
export declare const SHIELD_EMAIL_CHANGE_TOKEN_STORE: unique symbol;
|
|
93
|
+
/**
|
|
94
|
+
* The `EmailAddressChanger` (guard port, `../auth/ports/email-address-changer.ts`) — reads a
|
|
95
|
+
* user's stored address for the request checks and applies a confirmed change in ONE transaction
|
|
96
|
+
* (collision refusal + login-hint follow). Framework default `MariaDbEmailAddressChanger` over
|
|
97
|
+
* {@link SHIELD_DB_POOL}. Phase 12, KI #14.
|
|
98
|
+
*/
|
|
99
|
+
export declare const SHIELD_EMAIL_ADDRESS_CHANGER: unique symbol;
|
|
100
|
+
/**
|
|
101
|
+
* The email-change confirm-link base URL (a plain `string`, `ShieldModule.forRoot({
|
|
102
|
+
* emailChangeLinkBaseUrl })` > env `EMAIL_CHANGE_LINK_BASE_URL` > dev default
|
|
103
|
+
* `http://localhost:5173/confirm-email`; production boot rule in `link-base-url.ts`). The link is
|
|
104
|
+
* `${base}#t=<rawToken>` — the token rides in the FRAGMENT, never the query (A4), so it is not
|
|
105
|
+
* sent to the server that serves the page nor leaked through a Referer.
|
|
106
|
+
*/
|
|
107
|
+
export declare const SHIELD_EMAIL_CHANGE_LINK_BASE_URL: unique symbol;
|
|
108
|
+
/**
|
|
109
|
+
* The per-account email-change REQUEST limiter (Phase 12) — keyed `email-change:pid:<principalId>`,
|
|
110
|
+
* 3 per 60-min window, so `PATCH /account/profile` cannot be used to spam an inbox. Counted
|
|
111
|
+
* (`consume`, fail closed → 503) after the current password verified; 429 when exhausted.
|
|
112
|
+
*/
|
|
113
|
+
export declare const SHIELD_EMAIL_CHANGE_LIMITER: unique symbol;
|
|
114
|
+
/**
|
|
115
|
+
* The `POST /auth/confirm-email` per-client-IP limiter (Phase 12, A6) — keyed
|
|
116
|
+
* `confirm-email:ip:<ip>`, 10 per 15-min window, like reset-password. Every attempt is counted
|
|
117
|
+
* (`consume`, fail closed → 503), a successful confirm gives its attempt back; 429 when exhausted.
|
|
118
|
+
* ENFORCED ONLY with an explicit Express `trust proxy` (`TrustProxyBootCheck`), else OFF — a shared
|
|
119
|
+
* proxy IP would otherwise lock every user out.
|
|
120
|
+
*/
|
|
121
|
+
export declare const SHIELD_CONFIRM_EMAIL_LIMITER: unique symbol;
|
|
85
122
|
/**
|
|
86
123
|
* The `EmailSender` (guard port, `../auth/ports/email-sender.ts`) — the delivery
|
|
87
124
|
* seam (SendGrid/SMTP/test capturer). The framework builds the message; the
|
|
@@ -128,7 +165,8 @@ export declare const SHIELD_SESSION_SWEEPER: unique symbol;
|
|
|
128
165
|
* composes the emailed reset link as `${base}?token=<rawToken>` so the
|
|
129
166
|
* {@link SHIELD_EMAIL_SENDER} seam stays a dumb delivery channel (no URL/token
|
|
130
167
|
* logic). Bound app-wide by `AuthModule` from the environment
|
|
131
|
-
* (`RESET_LINK_BASE_URL`, default `http://localhost:5173/reset-password
|
|
168
|
+
* (`RESET_LINK_BASE_URL`, default `http://localhost:5173/reset-password`; outside
|
|
169
|
+
* development/test an unset or loopback value fails the boot, see `link-base-url.ts`). Consumed
|
|
132
170
|
* by `POST /auth/forgot-password`.
|
|
133
171
|
*/
|
|
134
172
|
export declare const SHIELD_RESET_LINK_BASE_URL: unique symbol;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/auth/tokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,+CAA+C,CAAC;AAEvF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,eAA2B,CAAC;AAEvD;;;;GAIG;AACH,eAAO,MAAM,YAAY,eAAyB,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAW3E;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,eAAgC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,eAA8B,CAAC;AAE7D;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,eAAmC,CAAC;AAEvE
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/auth/tokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,+CAA+C,CAAC;AAEvF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,eAA2B,CAAC;AAEvD;;;;GAIG;AACH,eAAO,MAAM,YAAY,eAAyB,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAW3E;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;;;GAMG;AACH,eAAO,MAAM,+BAA+B,eAA4C,CAAC;AAEzF;;;;;GAKG;AACH,eAAO,MAAM,4BAA4B,eAAyC,CAAC;AAEnF;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,eAA8C,CAAC;AAE7F;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,eAAwC,CAAC;AAEjF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,eAAyC,CAAC;AAEnF;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,eAAgC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,eAA8B,CAAC;AAE7D;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,eAAmC,CAAC;AAEvE;;;;;;;;GAQG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,eAA4B,CAAC;AAEzD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,eAA2C,CAAC;AAEvF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,eAAsC,CAAC;AAU7E;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,eAA+B,CAAC;AAE/D;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,eAA+B,CAAC;AAE/D;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,eAA4C,CAAC;AAEzF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,eAAwC,CAAC"}
|
package/dist/auth/tokens.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* ADR-0003: no framework/DB/crypto imports here — just Symbols (+ one re-export).
|
|
24
24
|
*/
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
-
exports.SHIELD_SESSION_RESTRICTIONS = exports.SHIELD_PERMISSION_VERSION_CACHE = exports.SHIELD_PERMISSION_FACTS = exports.SHIELD_RESET_LIMITER = exports.SHIELD_FORGOT_HINT_LIMITER = exports.SHIELD_FORGOT_LIMITER = exports.SHIELD_LOGIN_TOTP_VERIFIER = exports.SHIELD_MFA_STEP_UP = exports.SHIELD_MFA_LIMITER = exports.SHIELD_RECOVERY_LIMITER = exports.SHIELD_LOGIN_IP_LIMITER = exports.SHIELD_LOGIN_LIMITER = exports.SHIELD_MEMBERSHIP_CHECKER = exports.SHIELD_SCOPE_SUBTREE_RESOLVER = exports.SHIELD_SESSION_FACTS_PROJECTOR = exports.SHIELD_ROOT_IDS = exports.SHIELD_TOTP_VERIFIER_OVERRIDE = exports.SHIELD_TOTP_VERIFIER = exports.SHIELD_RESET_LINK_BASE_URL = exports.SHIELD_SESSION_SWEEPER = exports.SHIELD_MFA_DEVICE_STORE = exports.SHIELD_AUDIT_SINK = exports.SHIELD_PROFILE_WRITER = exports.SHIELD_RECOVERY_CODE_STORE = exports.SHIELD_EMAIL_SENDER = exports.SHIELD_RESET_TOKEN_STORE = exports.SHIELD_CREDENTIAL_WRITER = exports.SHIELD_SESSION_AUTHORITY = exports.SHIELD_AUTHENTICATOR = exports.SHIELD_PROFILE_RESOLVER = exports.SHIELD_CREDENTIAL_RESOLVER = exports.SHIELD_REDIS = exports.SHIELD_DB_POOL = exports.SHIELD_PASSWORD_HASHER = void 0;
|
|
26
|
+
exports.SHIELD_SESSION_RESTRICTIONS = exports.SHIELD_PERMISSION_VERSION_CACHE = exports.SHIELD_PERMISSION_FACTS = exports.SHIELD_RESET_LIMITER = exports.SHIELD_FORGOT_HINT_LIMITER = exports.SHIELD_FORGOT_LIMITER = exports.SHIELD_LOGIN_TOTP_VERIFIER = exports.SHIELD_MFA_STEP_UP = exports.SHIELD_MFA_LIMITER = exports.SHIELD_RECOVERY_LIMITER = exports.SHIELD_LOGIN_IP_LIMITER = exports.SHIELD_LOGIN_LIMITER = exports.SHIELD_MEMBERSHIP_CHECKER = exports.SHIELD_SCOPE_SUBTREE_RESOLVER = exports.SHIELD_SESSION_FACTS_PROJECTOR = exports.SHIELD_ROOT_IDS = exports.SHIELD_TOTP_VERIFIER_OVERRIDE = exports.SHIELD_TOTP_VERIFIER = exports.SHIELD_RESET_LINK_BASE_URL = exports.SHIELD_SESSION_SWEEPER = exports.SHIELD_MFA_DEVICE_STORE = exports.SHIELD_AUDIT_SINK = exports.SHIELD_PROFILE_WRITER = exports.SHIELD_RECOVERY_CODE_STORE = exports.SHIELD_EMAIL_SENDER = exports.SHIELD_CONFIRM_EMAIL_LIMITER = exports.SHIELD_EMAIL_CHANGE_LIMITER = exports.SHIELD_EMAIL_CHANGE_LINK_BASE_URL = exports.SHIELD_EMAIL_ADDRESS_CHANGER = exports.SHIELD_EMAIL_CHANGE_TOKEN_STORE = exports.SHIELD_RESET_TOKEN_STORE = exports.SHIELD_CREDENTIAL_WRITER = exports.SHIELD_SESSION_AUTHORITY = exports.SHIELD_AUTHENTICATOR = exports.SHIELD_PROFILE_RESOLVER = exports.SHIELD_CREDENTIAL_RESOLVER = exports.SHIELD_REDIS = exports.SHIELD_DB_POOL = exports.SHIELD_PASSWORD_HASHER = void 0;
|
|
27
27
|
var argon2id_password_hasher_js_1 = require("../authentication/argon2id-password-hasher.js");
|
|
28
28
|
Object.defineProperty(exports, "SHIELD_PASSWORD_HASHER", { enumerable: true, get: function () { return argon2id_password_hasher_js_1.SHIELD_PASSWORD_HASHER; } });
|
|
29
29
|
/**
|
|
@@ -94,6 +94,43 @@ exports.SHIELD_CREDENTIAL_WRITER = Symbol('SHIELD_CREDENTIAL_WRITER');
|
|
|
94
94
|
* never the raw token). Consumed by forgot-password + reset-password.
|
|
95
95
|
*/
|
|
96
96
|
exports.SHIELD_RESET_TOKEN_STORE = Symbol('SHIELD_RESET_TOKEN_STORE');
|
|
97
|
+
/**
|
|
98
|
+
* The `EmailChangeTokenStore` (guard port, `../auth/ports/email-change-token-store.ts`) — the
|
|
99
|
+
* single-use, TTL'd self-service email-change token store (Phase 12, KI #14). Framework default
|
|
100
|
+
* `MariaDbEmailChangeTokenStore` over `shield_identity.email_change_token`. Consumed by
|
|
101
|
+
* `PATCH /account/profile` (issue), `POST /auth/confirm-email` (consume), and invalidated by a
|
|
102
|
+
* password reset / change (A8). Absent → an email change answers 501.
|
|
103
|
+
*/
|
|
104
|
+
exports.SHIELD_EMAIL_CHANGE_TOKEN_STORE = Symbol('SHIELD_EMAIL_CHANGE_TOKEN_STORE');
|
|
105
|
+
/**
|
|
106
|
+
* The `EmailAddressChanger` (guard port, `../auth/ports/email-address-changer.ts`) — reads a
|
|
107
|
+
* user's stored address for the request checks and applies a confirmed change in ONE transaction
|
|
108
|
+
* (collision refusal + login-hint follow). Framework default `MariaDbEmailAddressChanger` over
|
|
109
|
+
* {@link SHIELD_DB_POOL}. Phase 12, KI #14.
|
|
110
|
+
*/
|
|
111
|
+
exports.SHIELD_EMAIL_ADDRESS_CHANGER = Symbol('SHIELD_EMAIL_ADDRESS_CHANGER');
|
|
112
|
+
/**
|
|
113
|
+
* The email-change confirm-link base URL (a plain `string`, `ShieldModule.forRoot({
|
|
114
|
+
* emailChangeLinkBaseUrl })` > env `EMAIL_CHANGE_LINK_BASE_URL` > dev default
|
|
115
|
+
* `http://localhost:5173/confirm-email`; production boot rule in `link-base-url.ts`). The link is
|
|
116
|
+
* `${base}#t=<rawToken>` — the token rides in the FRAGMENT, never the query (A4), so it is not
|
|
117
|
+
* sent to the server that serves the page nor leaked through a Referer.
|
|
118
|
+
*/
|
|
119
|
+
exports.SHIELD_EMAIL_CHANGE_LINK_BASE_URL = Symbol('SHIELD_EMAIL_CHANGE_LINK_BASE_URL');
|
|
120
|
+
/**
|
|
121
|
+
* The per-account email-change REQUEST limiter (Phase 12) — keyed `email-change:pid:<principalId>`,
|
|
122
|
+
* 3 per 60-min window, so `PATCH /account/profile` cannot be used to spam an inbox. Counted
|
|
123
|
+
* (`consume`, fail closed → 503) after the current password verified; 429 when exhausted.
|
|
124
|
+
*/
|
|
125
|
+
exports.SHIELD_EMAIL_CHANGE_LIMITER = Symbol('SHIELD_EMAIL_CHANGE_LIMITER');
|
|
126
|
+
/**
|
|
127
|
+
* The `POST /auth/confirm-email` per-client-IP limiter (Phase 12, A6) — keyed
|
|
128
|
+
* `confirm-email:ip:<ip>`, 10 per 15-min window, like reset-password. Every attempt is counted
|
|
129
|
+
* (`consume`, fail closed → 503), a successful confirm gives its attempt back; 429 when exhausted.
|
|
130
|
+
* ENFORCED ONLY with an explicit Express `trust proxy` (`TrustProxyBootCheck`), else OFF — a shared
|
|
131
|
+
* proxy IP would otherwise lock every user out.
|
|
132
|
+
*/
|
|
133
|
+
exports.SHIELD_CONFIRM_EMAIL_LIMITER = Symbol('SHIELD_CONFIRM_EMAIL_LIMITER');
|
|
97
134
|
/**
|
|
98
135
|
* The `EmailSender` (guard port, `../auth/ports/email-sender.ts`) — the delivery
|
|
99
136
|
* seam (SendGrid/SMTP/test capturer). The framework builds the message; the
|
|
@@ -140,7 +177,8 @@ exports.SHIELD_SESSION_SWEEPER = Symbol('SHIELD_SESSION_SWEEPER');
|
|
|
140
177
|
* composes the emailed reset link as `${base}?token=<rawToken>` so the
|
|
141
178
|
* {@link SHIELD_EMAIL_SENDER} seam stays a dumb delivery channel (no URL/token
|
|
142
179
|
* logic). Bound app-wide by `AuthModule` from the environment
|
|
143
|
-
* (`RESET_LINK_BASE_URL`, default `http://localhost:5173/reset-password
|
|
180
|
+
* (`RESET_LINK_BASE_URL`, default `http://localhost:5173/reset-password`; outside
|
|
181
|
+
* development/test an unset or loopback value fails the boot, see `link-base-url.ts`). Consumed
|
|
144
182
|
* by `POST /auth/forgot-password`.
|
|
145
183
|
*/
|
|
146
184
|
exports.SHIELD_RESET_LINK_BASE_URL = Symbol('SHIELD_RESET_LINK_BASE_URL');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MariaDbEmailAddressChanger — the framework-default {@link EmailAddressChanger} (security hardening
|
|
3
|
+
* Phase 12, KI #14) over `shield_app.users`, `shield_identity.identity_credential` and
|
|
4
|
+
* `shield_identity.email_change_token`. Guard's `POST /auth/confirm-email` uses it over the auth
|
|
5
|
+
* pool; `@astram/identity`'s administrator path constructs it over its authz pool, so the two
|
|
6
|
+
* paths share one collision rule and one login-hint rule.
|
|
7
|
+
*
|
|
8
|
+
* `change` (one MariaDB transaction, lock-then-recheck):
|
|
9
|
+
* 0. `GET_LOCK('shield_email_change')` — serialises email changes. `u_email` has no index, so the
|
|
10
|
+
* locked collision read below locks a scan of `users`; without the named lock two concurrent
|
|
11
|
+
* changes would deadlock on each other's row locks.
|
|
12
|
+
* 1. lock the user row (`FOR UPDATE`); refuse `not_found`, `inactive` (when required), `stale`
|
|
13
|
+
* (stored email ≠ the expected old one, exact compare — A3);
|
|
14
|
+
* 2. refuse `unavailable` when another user's `u_email` or another principal's login hint is
|
|
15
|
+
* collation-equal (`utf8mb4_unicode_ci`: case-, accent- and trailing-space-insensitive) to
|
|
16
|
+
* the new address — locked reads, self excluded;
|
|
17
|
+
* 3. lock the user's own credential rows; write `u_email`;
|
|
18
|
+
* 4. login-hint follow: `ic_identity_hint` (the PK) is updated IN PLACE to the new address when it
|
|
19
|
+
* equalled the OLD email (collation-equal); a username hint matches nothing and stays;
|
|
20
|
+
* 5. burn the user's pending email-change tokens; run `withinTransaction`; commit.
|
|
21
|
+
* Every query is parameterized; addresses are compared by the column collation in SQL.
|
|
22
|
+
*/
|
|
23
|
+
import type { Pool } from 'mariadb';
|
|
24
|
+
import type { CurrentEmail, EmailAddressChanger, EmailChangeOutcome, EmailChangeRequest } from '../auth/ports/email-address-changer.js';
|
|
25
|
+
export declare class MariaDbEmailAddressChanger implements EmailAddressChanger {
|
|
26
|
+
private readonly pool;
|
|
27
|
+
private readonly now;
|
|
28
|
+
constructor(pool: Pool, now?: () => number);
|
|
29
|
+
readCurrent(principalId: string, candidate: string): Promise<CurrentEmail | null>;
|
|
30
|
+
change(request: EmailChangeRequest): Promise<EmailChangeOutcome>;
|
|
31
|
+
private changeLocked;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=mariadb-email-address-changer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mariadb-email-address-changer.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-email-address-changer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAkB,MAAM,SAAS,CAAC;AACpD,OAAO,KAAK,EACV,YAAY,EACZ,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EACnB,MAAM,wCAAwC,CAAC;AAWhD,qBAAa,0BAA2B,YAAW,mBAAmB;IAElE,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBADH,IAAI,EAAE,IAAI,EACV,GAAG,GAAE,MAAM,MAAiB;IAGzC,WAAW,CAAC,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAejF,MAAM,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC;YA6BxD,YAAY;CA+D3B"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MariaDbEmailAddressChanger = void 0;
|
|
4
|
+
/** Seconds to wait for the email-change named lock before giving up (caller → 503). */
|
|
5
|
+
const LOCK_TIMEOUT_S = 10;
|
|
6
|
+
class MariaDbEmailAddressChanger {
|
|
7
|
+
pool;
|
|
8
|
+
now;
|
|
9
|
+
constructor(pool, now = Date.now) {
|
|
10
|
+
this.pool = pool;
|
|
11
|
+
this.now = now;
|
|
12
|
+
}
|
|
13
|
+
async readCurrent(principalId, candidate) {
|
|
14
|
+
const rows = (await this.pool.query(`SELECT u_email, u_status, (u_email = ?) AS same_as_candidate
|
|
15
|
+
FROM \`shield_app\`.\`users\` WHERE u_uuid = ? LIMIT 1`, [candidate, principalId]));
|
|
16
|
+
const row = rows[0];
|
|
17
|
+
if (row === undefined)
|
|
18
|
+
return null;
|
|
19
|
+
return {
|
|
20
|
+
email: row.u_email ?? null,
|
|
21
|
+
active: row.u_status === 'active',
|
|
22
|
+
sameAsCandidate: Number(row.same_as_candidate ?? 0) === 1,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async change(request) {
|
|
26
|
+
const conn = await this.pool.getConnection();
|
|
27
|
+
let locked = false;
|
|
28
|
+
try {
|
|
29
|
+
const got = (await conn.query("SELECT GET_LOCK('shield_email_change', ?) AS got", [
|
|
30
|
+
LOCK_TIMEOUT_S,
|
|
31
|
+
]));
|
|
32
|
+
if (Number(got[0]?.got ?? 0) !== 1)
|
|
33
|
+
throw new Error('email change lock unavailable');
|
|
34
|
+
locked = true;
|
|
35
|
+
await conn.beginTransaction();
|
|
36
|
+
try {
|
|
37
|
+
const outcome = await this.changeLocked(conn, request);
|
|
38
|
+
if (outcome.status === 'changed')
|
|
39
|
+
await conn.commit();
|
|
40
|
+
else
|
|
41
|
+
await conn.rollback();
|
|
42
|
+
return outcome;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
await conn.rollback().catch(() => undefined);
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
if (locked) {
|
|
51
|
+
await conn
|
|
52
|
+
.query("SELECT RELEASE_LOCK('shield_email_change') AS released")
|
|
53
|
+
.catch(() => undefined);
|
|
54
|
+
}
|
|
55
|
+
conn.release();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async changeLocked(conn, request) {
|
|
59
|
+
const { principalId, newEmail } = request;
|
|
60
|
+
const self = (await conn.query('SELECT u_email, u_status FROM `shield_app`.`users` WHERE u_uuid = ? FOR UPDATE', [principalId]));
|
|
61
|
+
const row = self[0];
|
|
62
|
+
if (row === undefined)
|
|
63
|
+
return { status: 'not_found' };
|
|
64
|
+
if (request.requireActive === true && row.u_status !== 'active')
|
|
65
|
+
return { status: 'inactive' };
|
|
66
|
+
const oldEmail = row.u_email ?? null;
|
|
67
|
+
if (request.expectedOldEmail !== undefined && oldEmail !== request.expectedOldEmail) {
|
|
68
|
+
return { status: 'stale' };
|
|
69
|
+
}
|
|
70
|
+
const otherUser = (await conn.query('SELECT u_uuid FROM `shield_app`.`users` WHERE u_email = ? AND u_uuid <> ? LIMIT 1 FOR UPDATE', [newEmail, principalId]));
|
|
71
|
+
if (otherUser.length > 0)
|
|
72
|
+
return { status: 'unavailable' };
|
|
73
|
+
const hintOwners = (await conn.query('SELECT ic_principal_id FROM `shield_identity`.`identity_credential` WHERE ic_identity_hint = ? FOR UPDATE', [newEmail]));
|
|
74
|
+
if (hintOwners.some((h) => h.ic_principal_id !== principalId))
|
|
75
|
+
return { status: 'unavailable' };
|
|
76
|
+
const selfAlreadyHasNewHint = hintOwners.length > 0;
|
|
77
|
+
await conn.query('SELECT ic_identity_hint FROM `shield_identity`.`identity_credential` WHERE ic_principal_id = ? FOR UPDATE', [principalId]);
|
|
78
|
+
await conn.query('UPDATE `shield_app`.`users` SET u_email = ? WHERE u_uuid = ?', [
|
|
79
|
+
newEmail,
|
|
80
|
+
principalId,
|
|
81
|
+
]);
|
|
82
|
+
let hintMoved = false;
|
|
83
|
+
if (oldEmail !== null && oldEmail !== '' && !selfAlreadyHasNewHint) {
|
|
84
|
+
const moved = (await conn.query(`UPDATE \`shield_identity\`.\`identity_credential\`
|
|
85
|
+
SET ic_identity_hint = ?, ic_updated_at_ms = ?
|
|
86
|
+
WHERE ic_principal_id = ? AND ic_identity_hint = ?`, [newEmail, this.now(), principalId, oldEmail]));
|
|
87
|
+
hintMoved = moved.affectedRows > 0;
|
|
88
|
+
}
|
|
89
|
+
await conn.query(`UPDATE \`shield_identity\`.\`email_change_token\` SET ect_consumed_at_ms = ?
|
|
90
|
+
WHERE ect_principal_id = ? AND ect_consumed_at_ms IS NULL`, [this.now(), principalId]);
|
|
91
|
+
if (request.withinTransaction !== undefined) {
|
|
92
|
+
await request.withinTransaction((sql, params) => conn.query(sql, params ?? []), { oldEmail, hintMoved });
|
|
93
|
+
}
|
|
94
|
+
return { status: 'changed', oldEmail, hintMoved };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
exports.MariaDbEmailAddressChanger = MariaDbEmailAddressChanger;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MariaDbEmailChangeTokenStore — the framework-DEFAULT {@link EmailChangeTokenStore} (security
|
|
3
|
+
* hardening Phase 12, KI #14), over `shield_identity`.`email_change_token` (identity migration
|
|
4
|
+
* `1710000000020`). The framework hashes the raw token (SHA-256) BEFORE it reaches this store.
|
|
5
|
+
*
|
|
6
|
+
* - `issue` runs ONE transaction: it locks the user row (`shield_app.users ... FOR UPDATE`) so two
|
|
7
|
+
* concurrent requests of one user serialise, deletes expired rows (A11), invalidates the user's
|
|
8
|
+
* pending tokens (A2: one pending change) and inserts the new one. An unknown user rolls back.
|
|
9
|
+
* - `consume` is ONE conditional UPDATE (`... WHERE hash = ? AND consumed IS NULL AND not expired`):
|
|
10
|
+
* `affectedRows === 1` wins, so of two concurrent confirms exactly one gets the binding. Unknown /
|
|
11
|
+
* expired / used all return `null` (no distinguishing throw); a store outage propagates.
|
|
12
|
+
*
|
|
13
|
+
* Every query is parameterized; the token hash is never logged nor placed in an error.
|
|
14
|
+
*/
|
|
15
|
+
import type { Pool } from 'mariadb';
|
|
16
|
+
import type { EmailChangeTokenBinding, EmailChangeTokenIssue, EmailChangeTokenStore } from '../auth/ports/email-change-token-store.js';
|
|
17
|
+
export declare class MariaDbEmailChangeTokenStore implements EmailChangeTokenStore {
|
|
18
|
+
private readonly pool;
|
|
19
|
+
private readonly now;
|
|
20
|
+
constructor(pool: Pool, now?: () => number);
|
|
21
|
+
issue(token: EmailChangeTokenIssue): Promise<void>;
|
|
22
|
+
consume(tokenHash: string): Promise<EmailChangeTokenBinding | null>;
|
|
23
|
+
invalidateAllForPrincipal(principalId: string): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=mariadb-email-change-token-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mariadb-email-change-token-store.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-email-change-token-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,IAAI,EAAkB,MAAM,SAAS,CAAC;AACpD,OAAO,KAAK,EACV,uBAAuB,EACvB,qBAAqB,EACrB,qBAAqB,EACtB,MAAM,2CAA2C,CAAC;AAYnD,qBAAa,4BAA6B,YAAW,qBAAqB;IAEtE,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBADH,IAAI,EAAE,IAAI,EACV,GAAG,GAAE,MAAM,MAAiB;IAGzC,KAAK,CAAC,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC;IA2ClD,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC;IAwBnE,yBAAyB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAOpE"}
|