@lenne.tech/nest-server 11.37.0 → 11.39.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/.claude/rules/configurable-features.md +51 -0
- package/.claude/rules/module-deprecation.md +25 -1
- package/.claude/rules/package-management.md +66 -0
- package/.claude/rules/testing.md +81 -5
- package/FRAMEWORK-API.md +4 -2
- package/dist/config.env.js +5 -3
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/config.helper.d.ts +2 -0
- package/dist/core/common/helpers/config.helper.js +18 -0
- package/dist/core/common/helpers/config.helper.js.map +1 -1
- package/dist/core/common/helpers/cookies.helper.d.ts +3 -0
- package/dist/core/common/helpers/cookies.helper.js +9 -0
- package/dist/core/common/helpers/cookies.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.d.ts +1 -0
- package/dist/core/common/helpers/input.helper.js +4 -0
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/helpers/service.helper.js +6 -1
- package/dist/core/common/helpers/service.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +1 -0
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +21 -0
- package/dist/core/common/middlewares/security-headers.middleware.d.ts +18 -0
- package/dist/core/common/middlewares/security-headers.middleware.js +90 -0
- package/dist/core/common/middlewares/security-headers.middleware.js.map +1 -0
- package/dist/core/common/services/email.service.d.ts +4 -1
- package/dist/core/common/services/email.service.js +25 -2
- package/dist/core/common/services/email.service.js.map +1 -1
- package/dist/core/common/services/module.service.js +1 -0
- package/dist/core/common/services/module.service.js.map +1 -1
- package/dist/core/modules/auth/core-auth.controller.js +2 -5
- package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +2 -0
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/core-auth.resolver.js +2 -5
- package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.d.ts +12 -0
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js +83 -0
- package/dist/core/modules/auth/core-legacy-auth-deprecation.initializer.js.map +1 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.d.ts +3 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js +14 -0
- package/dist/core/modules/auth/helpers/legacy-endpoints.helper.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.config.d.ts +12 -0
- package/dist/core/modules/better-auth/better-auth.config.js +40 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +9 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +53 -4
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +5 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +57 -4
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js +54 -0
- package/dist/core/modules/better-auth/core-better-auth-error-codes.helper.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js +19 -0
- package/dist/core/modules/better-auth/core-better-auth-password-reset.registry.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +4 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +13 -10
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth.module.js +34 -4
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +27 -0
- package/dist/core/modules/error-code/error-codes.js +24 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/hub/core-hub.service.js +1 -0
- package/dist/core/modules/hub/core-hub.service.js.map +1 -1
- package/dist/core/modules/user/core-user.model.d.ts +1 -0
- package/dist/core/modules/user/core-user.model.js +11 -1
- package/dist/core/modules/user/core-user.model.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +10 -1
- package/dist/core/modules/user/core-user.service.js +124 -15
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
- package/dist/core.module.js +2 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +3 -0
- package/dist/server/modules/user/user.controller.js +2 -1
- package/dist/server/modules/user/user.controller.js.map +1 -1
- package/dist/server/modules/user/user.model.d.ts +5 -0
- package/dist/server/modules/user/user.resolver.js +2 -1
- package/dist/server/modules/user/user.resolver.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +18 -5
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/templates/password-reset-de.ejs +12 -0
- package/dist/templates/password-reset-en.ejs +12 -0
- package/dist/templates/password-reset.ejs +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +49 -1
- package/migration-guides/11.37.x-to-11.38.x.md +528 -0
- package/migration-guides/11.38.x-to-11.39.x.md +456 -0
- package/package.json +1 -1
- package/src/config.env.ts +28 -4
- package/src/core/common/helpers/config.helper.ts +79 -0
- package/src/core/common/helpers/cookies.helper.ts +38 -0
- package/src/core/common/helpers/input.helper.ts +37 -0
- package/src/core/common/helpers/service.helper.ts +9 -1
- package/src/core/common/interceptors/check-security.interceptor.ts +1 -0
- package/src/core/common/interfaces/server-options.interface.ts +326 -10
- package/src/core/common/middlewares/security-headers.middleware.ts +155 -0
- package/src/core/common/services/email.service.ts +46 -1
- package/src/core/common/services/module.service.ts +1 -0
- package/src/core/modules/auth/README.md +104 -0
- package/src/core/modules/auth/core-auth.controller.ts +4 -11
- package/src/core/modules/auth/core-auth.module.ts +5 -0
- package/src/core/modules/auth/core-auth.resolver.ts +4 -11
- package/src/core/modules/auth/core-legacy-auth-deprecation.initializer.ts +128 -0
- package/src/core/modules/auth/helpers/legacy-endpoints.helper.ts +69 -0
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +22 -0
- package/src/core/modules/better-auth/README.md +32 -59
- package/src/core/modules/better-auth/better-auth.config.ts +131 -4
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +126 -3
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +166 -7
- package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
- package/src/core/modules/better-auth/core-better-auth-password-reset.registry.ts +92 -0
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +42 -10
- package/src/core/modules/better-auth/core-better-auth.module.ts +67 -4
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core/modules/error-code/error-codes.ts +55 -0
- package/src/core/modules/hub/core-hub.service.ts +1 -0
- package/src/core/modules/user/core-user.model.ts +28 -1
- package/src/core/modules/user/core-user.service.ts +389 -22
- package/src/core/modules/user/inputs/core-user.input.ts +16 -1
- package/src/core.module.ts +8 -0
- package/src/index.ts +3 -0
- package/src/server/modules/user/user.controller.ts +7 -1
- package/src/server/modules/user/user.resolver.ts +7 -1
- package/src/server/modules/user/user.service.ts +58 -13
- package/src/templates/password-reset-de.ejs +12 -0
- package/src/templates/password-reset-en.ejs +12 -0
- package/src/templates/password-reset.ejs +1 -0
|
@@ -5,10 +5,13 @@ import { sha256 } from 'js-sha256';
|
|
|
5
5
|
import { Document, Model } from 'mongoose';
|
|
6
6
|
|
|
7
7
|
import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
|
|
9
|
+
import { maskEmail } from '../../common/helpers/logging.helper';
|
|
10
|
+
import { assignPlain, isQueryableString, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
|
|
9
11
|
import { ServiceOptions } from '../../common/interfaces/service-options.interface';
|
|
10
12
|
import { ConfigService } from '../../common/services/config.service';
|
|
11
13
|
import { CrudService } from '../../common/services/crud.service';
|
|
14
|
+
import { ErrorCode } from '../error-code/error-codes';
|
|
12
15
|
import { EmailService } from '../../common/services/email.service';
|
|
13
16
|
import { CoreModelConstructor } from '../../common/types/core-model-constructor.type';
|
|
14
17
|
import { CoreUserModel } from './core-user.model';
|
|
@@ -22,6 +25,14 @@ import { CoreUserServiceOptions } from './interfaces/core-user-service-options.i
|
|
|
22
25
|
* Provides user management with automatic synchronization between
|
|
23
26
|
* Legacy Auth and Better-Auth (IAM) systems when both are enabled.
|
|
24
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* Default lifetime of a legacy password-reset token, in minutes.
|
|
30
|
+
*
|
|
31
|
+
* One hour, matching Better-Auth's `resetPasswordTokenExpiresIn` — the half of this framework that
|
|
32
|
+
* already had an expiry. Before 11.38.0 the legacy half had none at all.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES = 60;
|
|
35
|
+
|
|
25
36
|
export abstract class CoreUserService<
|
|
26
37
|
TUser extends CoreUserModel,
|
|
27
38
|
TUserInput extends CoreUserInput,
|
|
@@ -41,6 +52,56 @@ export abstract class CoreUserService<
|
|
|
41
52
|
protected readonly options?: CoreUserServiceOptions,
|
|
42
53
|
) {
|
|
43
54
|
super();
|
|
55
|
+
this.warnOnAmbiguousResetLinkConvention();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Warn once at boot when `email.passwordResetLink` is set WITHOUT a `{token}` placeholder.
|
|
60
|
+
*
|
|
61
|
+
* Two conventions live side by side in this flow, and which one applies depends on something
|
|
62
|
+
* nobody would guess: whether the option is present at all.
|
|
63
|
+
*
|
|
64
|
+
* not set → `<appUrl>/auth/reset-password?token=<token>`
|
|
65
|
+
* set to `<appUrl>/auth/reset-password` → `<appUrl>/auth/reset-password/<token>`
|
|
66
|
+
*
|
|
67
|
+
* Same string, same intent, different link. Writing the default out by hand — to make it
|
|
68
|
+
* overridable per environment, or to derive it from `appUrl` — silently selects the OTHER
|
|
69
|
+
* convention. The mail still arrives, still looks right, and fails only for whoever clicks it:
|
|
70
|
+
* the exact failure mode this whole area was repaired for.
|
|
71
|
+
*
|
|
72
|
+
* The path-segment rule is kept regardless, because a project that already configures such a
|
|
73
|
+
* value has a page built for it, and moving it to `?token=` would break that page. So the fix
|
|
74
|
+
* cannot be to change the behaviour — it has to be to make it visible.
|
|
75
|
+
*
|
|
76
|
+
* Adding `{token}` explicitly silences this warning AND records the intent in the config, which
|
|
77
|
+
* is why that is the remedy named in the message rather than "ignore this if deliberate".
|
|
78
|
+
*
|
|
79
|
+
* The message also names a PRECONDITION for that remedy, and it is load-bearing: `{token}` is
|
|
80
|
+
* substituted by `buildPasswordResetLink()` and by nothing else. A project whose
|
|
81
|
+
* `sendPasswordResetMail()` still concatenates the link by hand — i.e. precisely the projects
|
|
82
|
+
* this warning is aimed at — would follow the advice and mail `/reset-password/{token}/<token>`.
|
|
83
|
+
* That is the same failure this whole area was repaired for: a mail that arrives, looks right,
|
|
84
|
+
* and fails only for whoever clicks it. Advice that is correct for half its audience and
|
|
85
|
+
* silently harmful to the other half is worse than none, so the ORDER of the two steps belongs
|
|
86
|
+
* in the message rather than in a doc the reader may never reach.
|
|
87
|
+
*/
|
|
88
|
+
protected warnOnAmbiguousResetLinkConvention(): void {
|
|
89
|
+
const configured = this.configService.getFastButReadOnly<string>('email.passwordResetLink');
|
|
90
|
+
|
|
91
|
+
if (typeof configured !== 'string' || !configured.trim().length || configured.includes('{token}')) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.userServiceLogger.warn(
|
|
96
|
+
`email.passwordResetLink is set without a {token} placeholder ("${configured.trim()}"), so the token is ` +
|
|
97
|
+
'appended as a PATH segment. Leaving the option unset would instead produce "?token=", the shape the ' +
|
|
98
|
+
'reset page shipped by nuxt-base-starter reads. Both are supported — add {token} where your page ' +
|
|
99
|
+
'expects it (".../reset-password/{token}" or ".../reset-password?token={token}") to state which, and ' +
|
|
100
|
+
'to silence this warning. FIRST make sure the mail is built by ' +
|
|
101
|
+
'CoreUserService.buildPasswordResetLink(): it is the only thing that substitutes {token}, so a ' +
|
|
102
|
+
'sendPasswordResetMail() that still concatenates the link itself would mail the placeholder ' +
|
|
103
|
+
'verbatim ("/reset-password/{token}/<token>").',
|
|
104
|
+
);
|
|
44
105
|
}
|
|
45
106
|
|
|
46
107
|
// ===================================================================================================================
|
|
@@ -136,6 +197,10 @@ export abstract class CoreUserService<
|
|
|
136
197
|
* Get verified state of user by token
|
|
137
198
|
*/
|
|
138
199
|
async getVerifiedState(token: string, _serviceOptions?: ServiceOptions): Promise<boolean> {
|
|
200
|
+
if (!isQueryableString(token)) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
139
204
|
const user = await this.mainDbModel.findOne({ verificationToken: token }).exec();
|
|
140
205
|
|
|
141
206
|
if (!user) {
|
|
@@ -150,6 +215,10 @@ export abstract class CoreUserService<
|
|
|
150
215
|
*/
|
|
151
216
|
async verify(token: string, serviceOptions?: ServiceOptions): Promise<string | TUser> {
|
|
152
217
|
// Get user
|
|
218
|
+
if (!isQueryableString(token)) {
|
|
219
|
+
throw new NotFoundException(ErrorCode.INVALID_TOKEN);
|
|
220
|
+
}
|
|
221
|
+
|
|
153
222
|
const dbObject = await this.mainDbModel.findOne({ verificationToken: token }).exec();
|
|
154
223
|
if (!dbObject) {
|
|
155
224
|
throw new NotFoundException(`No user found with verify token: ${token}`);
|
|
@@ -183,14 +252,66 @@ export abstract class CoreUserService<
|
|
|
183
252
|
*/
|
|
184
253
|
async resetPassword(token: string, newPassword: string, serviceOptions?: ServiceOptions): Promise<TUser> {
|
|
185
254
|
// Get user
|
|
255
|
+
// A client-supplied value reaches here with its declared `string` type erased — see
|
|
256
|
+
// `isQueryableString`. Without this, `{ $ne: null }` selects the first user holding ANY live
|
|
257
|
+
// reset token and takes over that account without the attacker ever seeing the mail.
|
|
258
|
+
if (!isQueryableString(token)) {
|
|
259
|
+
throw new NotFoundException(ErrorCode.LINK_INVALID_OR_EXPIRED);
|
|
260
|
+
}
|
|
261
|
+
|
|
186
262
|
const dbObject = await this.mainDbModel.findOne({ passwordResetToken: token }).exec();
|
|
187
|
-
|
|
188
|
-
|
|
263
|
+
|
|
264
|
+
// An EXPIRED token is answered exactly like an unknown one — same exception, same 404. The
|
|
265
|
+
// distinction would tell a caller holding a stale token that it was once real and belongs to a
|
|
266
|
+
// live account, which is precisely what a stolen mail archive wants confirmed.
|
|
267
|
+
if (dbObject && this.isPasswordResetTokenExpired(dbObject.passwordResetTokenExpiresAt)) {
|
|
268
|
+
// Burn it on sight rather than leaving it to be retried. It is worthless now, and a value
|
|
269
|
+
// that stays in the row is one a later change could start honouring again.
|
|
270
|
+
await this.mainDbModel
|
|
271
|
+
// `$unset`, not `$set: null` — a null still counts as present, so it would keep an entry
|
|
272
|
+
// in the partial index the token field carries and grow it by one per user who ever
|
|
273
|
+
// requested a reset.
|
|
274
|
+
.updateOne({ _id: dbObject._id }, { $unset: { passwordResetToken: 1, passwordResetTokenExpiresAt: 1 } })
|
|
275
|
+
.exec();
|
|
276
|
+
this.userServiceLogger.debug(`Rejected an expired password-reset token for ${maskEmail(dbObject.email)}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!dbObject || this.isPasswordResetTokenExpired(dbObject.passwordResetTokenExpiresAt)) {
|
|
280
|
+
// The token is NOT echoed. It is attacker-supplied so nothing secret leaks, but it lands
|
|
281
|
+
// in the response body and in every log line that records the exception — and this logger
|
|
282
|
+
// feeds the ADMIN-readable Hub log buffer. An unbounded caller-controlled string there is
|
|
283
|
+
// free log-stuffing, and the message is no more useful for it.
|
|
284
|
+
throw new NotFoundException(ErrorCode.LINK_INVALID_OR_EXPIRED);
|
|
189
285
|
}
|
|
190
286
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
287
|
+
// Capture the submitted password for the IAM sync before the closure below
|
|
288
|
+
// reassigns `newPassword` to its sha256 form.
|
|
289
|
+
//
|
|
290
|
+
// It is passed on exactly as received, INCLUDING an already-sha256-hashed
|
|
291
|
+
// one. This used to skip the sync for a 64-hex value, on the reasoning that
|
|
292
|
+
// "IAM uses scrypt, not bcrypt+sha256" — but the sync does not need a plain
|
|
293
|
+
// password: `hashPasswordForBetterAuth` runs its input through
|
|
294
|
+
// `normalizePasswordForIam`, which passes a 64-hex string through unchanged
|
|
295
|
+
// by design, and `migrateAccountToIam` is fed the very same pre-hashed value
|
|
296
|
+
// when an account is created.
|
|
297
|
+
//
|
|
298
|
+
// The guard therefore disabled the sync for exactly the clients this stack
|
|
299
|
+
// ships. The lt frontends hash in the browser before sending — seven call
|
|
300
|
+
// sites in `nuxt-extensions/src/runtime/lib/auth-client.ts` (`signIn.email`,
|
|
301
|
+
// `resetPassword`, `changePassword` and the rest) run `ltSha256` on the
|
|
302
|
+
// password first. That is independent of the `sha256` config option, which
|
|
303
|
+
// only governs what the SERVER does with a plaintext password it happens to
|
|
304
|
+
// receive. So the value arriving here is 64-hex whatever that option says,
|
|
305
|
+
// and every such reset took the skipped branch.
|
|
306
|
+
//
|
|
307
|
+
// The legacy password was updated, the IAM credential was not, and sign-in —
|
|
308
|
+
// which goes through IAM — kept accepting the OLD password and refusing the
|
|
309
|
+
// new one. The endpoint reported success throughout, so the failure surfaced
|
|
310
|
+
// only at the next sign-in.
|
|
311
|
+
//
|
|
312
|
+
// A client that posts a plaintext password was never affected: the old guard
|
|
313
|
+
// let that one through, and the sync normalized it the same way IAM does.
|
|
314
|
+
const passwordForIamSync = newPassword;
|
|
194
315
|
|
|
195
316
|
return this.process(
|
|
196
317
|
async () => {
|
|
@@ -204,17 +325,39 @@ export abstract class CoreUserService<
|
|
|
204
325
|
const updatedUser = await assignPlain(dbObject, {
|
|
205
326
|
password: await bcrypt.hash(newPassword, 10),
|
|
206
327
|
passwordResetToken: null,
|
|
328
|
+
passwordResetTokenExpiresAt: null,
|
|
329
|
+
// A reset is what somebody reaches for after a suspected takeover, so it must not
|
|
330
|
+
// leave the attacker's session live. Clearing the refresh tokens ends every legacy
|
|
331
|
+
// session; the IAM half is `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset`,
|
|
332
|
+
// which is off by default because it is a behaviour change for existing deployments —
|
|
333
|
+
// the migration guide recommends turning it on.
|
|
334
|
+
//
|
|
335
|
+
// Without this, "the reset now lands in both stores" would still leave the account
|
|
336
|
+
// reachable with the credential the reset was meant to retire.
|
|
337
|
+
refreshTokens: {},
|
|
207
338
|
}).save();
|
|
208
339
|
|
|
209
340
|
// Sync password to Better-Auth (IAM) if mapper is available
|
|
210
341
|
// This ensures users can sign in via IAM after password reset
|
|
211
|
-
if (this.options?.betterAuthUserMapper &&
|
|
342
|
+
if (this.options?.betterAuthUserMapper && passwordForIamSync && dbObject.email) {
|
|
212
343
|
try {
|
|
213
|
-
|
|
344
|
+
// Same reasoning as in update(): a `false` return means the reset landed in the
|
|
345
|
+
// legacy store only, which is the shape of failure this whole path exists to
|
|
346
|
+
// prevent. It must not be indistinguishable from success.
|
|
347
|
+
const synced = await this.options.betterAuthUserMapper.syncPasswordChangeToIam(
|
|
348
|
+
dbObject.email,
|
|
349
|
+
passwordForIamSync,
|
|
350
|
+
);
|
|
351
|
+
if (!synced) {
|
|
352
|
+
this.userServiceLogger.warn(
|
|
353
|
+
`Password reset for ${maskEmail(dbObject.email)} was NOT synced to IAM (no credential account) — ` +
|
|
354
|
+
'the legacy password now differs from the IAM credential.',
|
|
355
|
+
);
|
|
356
|
+
}
|
|
214
357
|
} catch (error) {
|
|
215
358
|
// Log but don't fail - Legacy Auth password was updated successfully
|
|
216
359
|
this.userServiceLogger.warn(
|
|
217
|
-
`Failed to sync password reset to IAM for ${dbObject.email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
360
|
+
`Failed to sync password reset to IAM for ${maskEmail(dbObject.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
218
361
|
);
|
|
219
362
|
}
|
|
220
363
|
}
|
|
@@ -226,19 +369,97 @@ export abstract class CoreUserService<
|
|
|
226
369
|
}
|
|
227
370
|
|
|
228
371
|
/**
|
|
229
|
-
* Set password
|
|
372
|
+
* Set a password-reset token for an email address
|
|
373
|
+
*
|
|
374
|
+
* Returns `null` for an unknown address when `auth.passwordReset.preventUserEnumeration` is on
|
|
375
|
+
* (the default since 11.38.0) — the caller must then answer exactly as it would for a known one.
|
|
376
|
+
* With the option off it throws `NotFoundException`, the pre-11.38.0 behaviour.
|
|
377
|
+
*
|
|
378
|
+
* WHY THE DEFAULT CHANGED
|
|
379
|
+
*
|
|
380
|
+
* Throwing made the endpoint an account oracle: HTTP 404 for an unknown address, 201 for a known
|
|
381
|
+
* one, so anyone could test who has an account. In a multi-tenant product that also answers who
|
|
382
|
+
* works at which customer. The framework already answers this correctly on the IAM path —
|
|
383
|
+
* Better-Auth's `/request-password-reset` returns the same body either way — so the two halves
|
|
384
|
+
* of one framework disagreed about the same question.
|
|
385
|
+
*
|
|
386
|
+
* THE STATUS CODE IS THE SMALLER HALF
|
|
387
|
+
*
|
|
388
|
+
* Response TIME distinguishes the cases too, and by far more: the known path writes a token and
|
|
389
|
+
* (in the caller) sends mail, the unknown path returns immediately. This method equalises what it
|
|
390
|
+
* can — the token generation still happens, so the CPU cost matches — but the mail send lives in
|
|
391
|
+
* the caller. A `sendPasswordResetMail()` that AWAITS the send leaks the difference as latency,
|
|
392
|
+
* whatever this method does. `src/server/modules/user/user.service.ts` shows the shape that does
|
|
393
|
+
* not; the IAM path uses the same trick, with the reasoning recorded in `better-auth.config.ts`.
|
|
394
|
+
*
|
|
395
|
+
* Anything measuring this honestly should say so rather than claim the channel is closed.
|
|
230
396
|
*/
|
|
231
|
-
async setPasswordResetTokenForEmail(email: string, serviceOptions?: ServiceOptions): Promise<TUser> {
|
|
397
|
+
async setPasswordResetTokenForEmail(email: string, serviceOptions?: ServiceOptions): Promise<null | TUser> {
|
|
398
|
+
return (await this.createPasswordResetToken(email, serviceOptions))?.user ?? null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Mint a password-reset token and hand back BOTH the token and the user.
|
|
403
|
+
*
|
|
404
|
+
* Use this instead of `setPasswordResetTokenForEmail` whenever the token itself is needed —
|
|
405
|
+
* which is every caller that sends the mail, i.e. every project, because the mail templates are
|
|
406
|
+
* project-specific.
|
|
407
|
+
*
|
|
408
|
+
* ── Why this method exists ──────────────────────────────────────────────────────
|
|
409
|
+
* `setPasswordResetTokenForEmail` returns its result through `process()`, and the security
|
|
410
|
+
* interceptor strips `passwordResetToken` on the way out. That is correct and must stay: a reset
|
|
411
|
+
* token may never leave the server inside a response, and this service backs an endpoint.
|
|
412
|
+
*
|
|
413
|
+
* But the same return value is the only thing a caller had to build the mail link from. The
|
|
414
|
+
* method named after setting the token did not hand it over, so `user.passwordResetToken` read
|
|
415
|
+
* `undefined` and the mail went out with a link ending in `/undefined`.
|
|
416
|
+
*
|
|
417
|
+
* It fails in the worst shape available: the request succeeds, the mail arrives, it looks right,
|
|
418
|
+
* and only the click reveals it — to somebody who by definition has no second way in. It reached
|
|
419
|
+
* real recipients in a downstream project before anyone noticed, with a green test suite either
|
|
420
|
+
* side of it, because the tests read the token from the DATABASE and never from the mail.
|
|
421
|
+
*
|
|
422
|
+
* The token is therefore generated OUTSIDE `process()` and returned alongside the scrubbed user.
|
|
423
|
+
* Nothing about what leaves the server in a response changes.
|
|
424
|
+
*/
|
|
425
|
+
async createPasswordResetToken(
|
|
426
|
+
email: string,
|
|
427
|
+
serviceOptions?: ServiceOptions,
|
|
428
|
+
): Promise<null | { token: string; user: TUser }> {
|
|
429
|
+
// Same erased-type hazard as the token sinks (see `isQueryableString`): `{ $ne: null }` here
|
|
430
|
+
// would select an arbitrary account and mint a live reset token for it. Treated as an unknown
|
|
431
|
+
// address, which is the answer this method already gives for anything it cannot resolve — so
|
|
432
|
+
// the enumeration parity below covers it too.
|
|
433
|
+
if (!isQueryableString(email)) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
|
|
232
437
|
// Get user
|
|
233
438
|
const dbObject = await this.mainDbModel.findOne({ email }).exec();
|
|
234
439
|
if (!dbObject) {
|
|
235
|
-
|
|
440
|
+
const preventEnumeration =
|
|
441
|
+
this.configService.getFastButReadOnly('auth')?.passwordReset?.preventUserEnumeration !== false;
|
|
442
|
+
|
|
443
|
+
if (!preventEnumeration) {
|
|
444
|
+
throw new NotFoundException(`No user found with email: ${email}`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Do the work the known path does, so the two do not differ in CPU cost. It is cheap next to
|
|
448
|
+
// a mail send, which is why this alone does not close the timing channel — see the note above.
|
|
449
|
+
crypto.randomBytes(32).toString('hex');
|
|
450
|
+
|
|
451
|
+
this.userServiceLogger.debug(`Password reset requested for an unknown address (${maskEmail(email)})`);
|
|
452
|
+
return null;
|
|
236
453
|
}
|
|
237
454
|
|
|
238
|
-
|
|
455
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
456
|
+
const expiresAt = this.passwordResetTokenExpiry();
|
|
457
|
+
|
|
458
|
+
const user = await this.process(
|
|
239
459
|
async () => {
|
|
240
460
|
// Set reset token and return
|
|
241
|
-
dbObject.passwordResetToken =
|
|
461
|
+
dbObject.passwordResetToken = token;
|
|
462
|
+
dbObject.passwordResetTokenExpiresAt = expiresAt;
|
|
242
463
|
|
|
243
464
|
// Save
|
|
244
465
|
await dbObject.save();
|
|
@@ -248,6 +469,133 @@ export abstract class CoreUserService<
|
|
|
248
469
|
},
|
|
249
470
|
{ dbObject, serviceOptions },
|
|
250
471
|
);
|
|
472
|
+
|
|
473
|
+
return { token, user };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* How many minutes a password-reset token stays valid; `0` means it never expires.
|
|
478
|
+
*
|
|
479
|
+
* `0` opts OUT while any INVALID value falls back to the default, which is asymmetric on purpose:
|
|
480
|
+
* switching off the expiry of a full-account-takeover credential is a decision somebody has to
|
|
481
|
+
* state, and a typo in an environment variable must never be the thing that states it. So a
|
|
482
|
+
* negative number, `NaN`, an empty string and a word all resolve to 60 rather than to "unbounded".
|
|
483
|
+
*
|
|
484
|
+
* The value is coerced from a string because configuration reaches this through `NEST_SERVER_CONFIG`
|
|
485
|
+
* and `NSC__*`, where a number frequently arrives as one. `Number('')` is `0`, so an empty value
|
|
486
|
+
* would otherwise read as a deliberate opt-out — it is rejected before the coercion.
|
|
487
|
+
*/
|
|
488
|
+
protected passwordResetTokenExpiryMinutes(): number {
|
|
489
|
+
const raw = this.configService.getFastButReadOnly<unknown>('auth.passwordReset.tokenExpiresInMinutes');
|
|
490
|
+
|
|
491
|
+
if (typeof raw === 'string' && !raw.trim().length) {
|
|
492
|
+
return DEFAULT_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const value = typeof raw === 'string' ? Number(raw) : raw;
|
|
496
|
+
|
|
497
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
|
498
|
+
return value;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return DEFAULT_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The moment the token being minted stops being valid, or `null` when expiry is switched off.
|
|
506
|
+
*/
|
|
507
|
+
protected passwordResetTokenExpiry(): Date | null {
|
|
508
|
+
const minutes = this.passwordResetTokenExpiryMinutes();
|
|
509
|
+
return minutes > 0 ? new Date(Date.now() + minutes * 60_000) : null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Whether a stored expiry timestamp has passed.
|
|
514
|
+
*
|
|
515
|
+
* A MISSING timestamp counts as expired. That is the load-bearing half: every token minted before
|
|
516
|
+
* 11.38.0 has none, and reading "no timestamp" as "valid forever" would preserve exactly the
|
|
517
|
+
* defect this closes — permanently, since those rows never gain one. The cost is that an
|
|
518
|
+
* unredeemed reset mail from before the upgrade stops working, and the remedy is one click.
|
|
519
|
+
*
|
|
520
|
+
* With expiry switched off (`0`) nothing is expired, including those legacy rows: a project that
|
|
521
|
+
* deliberately opted out must not have its tokens invalidated by the same setting.
|
|
522
|
+
*/
|
|
523
|
+
protected isPasswordResetTokenExpired(expiresAt: Date | null | undefined): boolean {
|
|
524
|
+
if (this.passwordResetTokenExpiryMinutes() === 0) {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!expiresAt) {
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const time = expiresAt instanceof Date ? expiresAt.getTime() : new Date(expiresAt).getTime();
|
|
533
|
+
|
|
534
|
+
// An unparseable timestamp is treated as expired rather than trusted — the safe direction for
|
|
535
|
+
// a credential.
|
|
536
|
+
return !Number.isFinite(time) || time <= Date.now();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Build the link that goes into the password-reset mail.
|
|
541
|
+
*
|
|
542
|
+
* Mirrors `CoreBetterAuthEmailVerificationService.buildPasswordResetUrl` so the two reset flows
|
|
543
|
+
* in this package resolve their link the same way. Resolution order, first hit wins:
|
|
544
|
+
*
|
|
545
|
+
* 1. `email.passwordResetLink` from the configuration
|
|
546
|
+
* 2. `<appUrl>/auth/reset-password` — the starter's reset page
|
|
547
|
+
* 3. `null`, when neither is available
|
|
548
|
+
*
|
|
549
|
+
* The app URL comes from `resolveServerUrls`, the same resolution the cookie and CORS setup
|
|
550
|
+
* uses, rather than a raw read of `appUrl`. That is what gives `local`/`ci`/`e2e` their
|
|
551
|
+
* documented localhost default and derives the app origin from a host-split `baseUrl` such as
|
|
552
|
+
* `https://api.crm.localhost`. Reading the option directly — which is what the IAM twin still
|
|
553
|
+
* does — makes a link that works in production come back empty in every local environment.
|
|
554
|
+
*
|
|
555
|
+
* `{token}` is substituted anywhere in the configured value. WITHOUT it the token is appended as
|
|
556
|
+
* a PATH segment, which is what this flow has always done — changing that would silently break
|
|
557
|
+
* every project already relying on it.
|
|
558
|
+
*
|
|
559
|
+
* Returns `null` rather than a string containing `undefined`. The option has no default and
|
|
560
|
+
* nothing validated it, so a project that never set it produced `undefined/<token>` and sent it.
|
|
561
|
+
* A caller that gets `null` here can log and send nothing, which is the honest failure: a mail
|
|
562
|
+
* that does not arrive says "try again", one with a dead link says nothing at all.
|
|
563
|
+
*/
|
|
564
|
+
buildPasswordResetLink(token: string): null | string {
|
|
565
|
+
const configured = this.configService.getFastButReadOnly<string>('email.passwordResetLink');
|
|
566
|
+
// One resolver for every mail link in this package — localhost defaults, host-split `baseUrl`,
|
|
567
|
+
// and the `cors.deriveAppUrl` opt-out that keeps a reset token out of an untrusted apex domain.
|
|
568
|
+
// Shared rather than inlined because the IAM twin drifted away from exactly this logic once.
|
|
569
|
+
const appUrl = resolveAppUrlFromConfig(this.configService);
|
|
570
|
+
|
|
571
|
+
let target = typeof configured === 'string' && configured.trim().length ? configured.trim() : undefined;
|
|
572
|
+
|
|
573
|
+
if (!target) {
|
|
574
|
+
if (!appUrl) {
|
|
575
|
+
this.userServiceLogger.error(
|
|
576
|
+
'Cannot build a password-reset link: neither `email.passwordResetLink` nor `appUrl` is configured',
|
|
577
|
+
);
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
target = `${appUrl.replace(/\/$/, '')}/auth/reset-password?token={token}`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// A relative value resolves against appUrl, like the IAM flow does.
|
|
584
|
+
if (target.startsWith('/')) {
|
|
585
|
+
if (!appUrl) {
|
|
586
|
+
this.userServiceLogger.error(
|
|
587
|
+
'Cannot build a password-reset link: `email.passwordResetLink` is relative and `appUrl` is not configured',
|
|
588
|
+
);
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
target = `${appUrl.replace(/\/$/, '')}${target}`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (target.includes('{token}')) {
|
|
595
|
+
return target.replace(/\{token\}/g, encodeURIComponent(token));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return `${target.replace(/\/$/, '')}/${encodeURIComponent(token)}`;
|
|
251
599
|
}
|
|
252
600
|
|
|
253
601
|
/**
|
|
@@ -311,10 +659,14 @@ export abstract class CoreUserService<
|
|
|
311
659
|
const oldUser = (await this.mainDbModel.findById(id).lean().exec()) as null | TUser;
|
|
312
660
|
const oldEmail = oldUser?.email;
|
|
313
661
|
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
662
|
+
// Capture the submitted password for the IAM sync before super.update()
|
|
663
|
+
// hashes it in place.
|
|
664
|
+
//
|
|
665
|
+
// Passed on exactly as received, including an already-sha256-hashed one —
|
|
666
|
+
// see the note in `resetPassword`: the sync normalizes a 64-hex value
|
|
667
|
+
// through unchanged, so skipping it there disabled the sync for the
|
|
668
|
+
// standard setup, where the frontend hashes before sending.
|
|
669
|
+
const passwordForIamSync = (input as any).password;
|
|
318
670
|
|
|
319
671
|
// Perform the update
|
|
320
672
|
const updatedUser = await super.update(id, input, serviceOptions);
|
|
@@ -333,13 +685,28 @@ export abstract class CoreUserService<
|
|
|
333
685
|
}
|
|
334
686
|
|
|
335
687
|
// Sync password change to IAM if password was changed and mapper is available
|
|
336
|
-
if (this.options?.betterAuthUserMapper &&
|
|
688
|
+
if (this.options?.betterAuthUserMapper && passwordForIamSync && oldUser?.email) {
|
|
337
689
|
try {
|
|
338
|
-
|
|
339
|
-
|
|
690
|
+
// Report what actually happened, not that the call was made. `syncPasswordChangeToIam`
|
|
691
|
+
// answers `false` — never throws — when there is no IAM credential to update, and
|
|
692
|
+
// logging success regardless is how a half-applied password change stays invisible:
|
|
693
|
+
// the endpoint reports success, the user is left with two different passwords, and
|
|
694
|
+
// nothing in the log says so.
|
|
695
|
+
const synced = await this.options.betterAuthUserMapper.syncPasswordChangeToIam(
|
|
696
|
+
oldUser.email,
|
|
697
|
+
passwordForIamSync,
|
|
698
|
+
);
|
|
699
|
+
if (synced) {
|
|
700
|
+
this.userServiceLogger.debug(`Synced password change to IAM for user ${maskEmail(oldUser.email)}`);
|
|
701
|
+
} else {
|
|
702
|
+
this.userServiceLogger.warn(
|
|
703
|
+
`Password change for ${maskEmail(oldUser.email)} was NOT synced to IAM (no credential account) — ` +
|
|
704
|
+
'the legacy password now differs from the IAM credential.',
|
|
705
|
+
);
|
|
706
|
+
}
|
|
340
707
|
} catch (error) {
|
|
341
708
|
this.userServiceLogger.warn(
|
|
342
|
-
`Failed to sync password change to IAM for ${oldUser.email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
709
|
+
`Failed to sync password change to IAM for ${maskEmail(oldUser.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
343
710
|
);
|
|
344
711
|
// Don't throw - password sync failure shouldn't block the update
|
|
345
712
|
}
|
|
@@ -97,11 +97,26 @@ export abstract class CoreUserInput extends CoreInput {
|
|
|
97
97
|
|
|
98
98
|
/**
|
|
99
99
|
* Password of the user
|
|
100
|
+
*
|
|
101
|
+
* Restricted to ADMIN and the account owner since 11.38.0. It used to be `S_EVERYONE`, i.e.
|
|
102
|
+
* settable by whoever was allowed to update the record at all — and `updateUser` grants that to
|
|
103
|
+
* `S_CREATOR`, which in an invite or admin-provisioning flow is the INVITING ADMIN, permanently
|
|
104
|
+
* (see `.claude/rules/role-system.md`).
|
|
105
|
+
*
|
|
106
|
+
* That was largely inert before: the lt frontends hash client-side, and the 64-hex guard then
|
|
107
|
+
* routed such a write away from the IAM sync, so it only ever landed in the legacy store — which
|
|
108
|
+
* this release also turns off by default. Removing the guard makes the same write reach the LIVE
|
|
109
|
+
* IAM credential. A dormant privilege issue would have become an account-takeover path in the
|
|
110
|
+
* very release that closes the legacy door, so the field is narrowed in the same step.
|
|
111
|
+
*
|
|
112
|
+
* A user changing their own password is unaffected (`S_SELF`). A project that genuinely needs a
|
|
113
|
+
* third party to set a password should go through an invitation or reset flow, both of which
|
|
114
|
+
* prove possession of the mailbox.
|
|
100
115
|
*/
|
|
101
116
|
@UnifiedField({
|
|
102
117
|
description: 'Password of the user',
|
|
103
118
|
isOptional: true,
|
|
104
|
-
roles: RoleEnum.
|
|
119
|
+
roles: [RoleEnum.ADMIN, RoleEnum.S_SELF],
|
|
105
120
|
})
|
|
106
121
|
password?: string = undefined;
|
|
107
122
|
}
|
package/src/core.module.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
IServerOptions,
|
|
30
30
|
} from './core/common/interfaces/server-options.interface';
|
|
31
31
|
import { RequestContextMiddleware } from './core/common/middleware/request-context.middleware';
|
|
32
|
+
import { SecurityHeadersMiddleware } from './core/common/middlewares/security-headers.middleware';
|
|
32
33
|
import { MapAndValidatePipe } from './core/common/pipes/map-and-validate.pipe';
|
|
33
34
|
import { ComplexityPlugin } from './core/common/plugins/complexity.plugin';
|
|
34
35
|
import { mongooseIdPlugin } from './core/common/plugins/mongoose-id.plugin';
|
|
@@ -84,6 +85,13 @@ export class CoreModule implements NestModule {
|
|
|
84
85
|
configure(consumer: MiddlewareConsumer) {
|
|
85
86
|
// RequestContext middleware must run for all routes to provide AsyncLocalStorage context
|
|
86
87
|
consumer.apply(RequestContextMiddleware).forRoutes('*');
|
|
88
|
+
|
|
89
|
+
// Security headers on EVERY response, guards' rejections included. Middleware rather than an
|
|
90
|
+
// interceptor for exactly that reason: an interceptor never runs for a request a guard turns
|
|
91
|
+
// away, and those are the ones an attacker generates most of. The middleware itself decides
|
|
92
|
+
// whether it is enabled — keeping the policy in one file rather than splitting it between a
|
|
93
|
+
// config read here and the values there.
|
|
94
|
+
consumer.apply(SecurityHeadersMiddleware).forRoutes('*');
|
|
87
95
|
if (CoreModule.graphQlEnabled) {
|
|
88
96
|
consumer.apply(graphqlUploadExpress()).forRoutes('graphql');
|
|
89
97
|
}
|
package/src/index.ts
CHANGED
|
@@ -73,6 +73,7 @@ export * from './core/common/interfaces/server-options.interface';
|
|
|
73
73
|
export * from './core/common/interfaces/service-options.interface';
|
|
74
74
|
export * from './core/common/middleware/request-context.middleware';
|
|
75
75
|
export * from './core/common/middlewares/to-lower-case.middleware';
|
|
76
|
+
export * from './core/common/middlewares/security-headers.middleware';
|
|
76
77
|
export * from './core/common/models/core-model.model';
|
|
77
78
|
export * from './core/common/models/core-persistence.model';
|
|
78
79
|
export * from './core/common/models/pagination-info.model';
|
|
@@ -133,11 +134,13 @@ export * from './core/modules/auth/core-auth.controller';
|
|
|
133
134
|
export * from './core/modules/auth/core-auth.model';
|
|
134
135
|
export * from './core/modules/auth/core-auth.module';
|
|
135
136
|
export * from './core/modules/auth/core-auth.resolver';
|
|
137
|
+
export * from './core/modules/auth/core-legacy-auth-deprecation.initializer';
|
|
136
138
|
export * from './core/modules/auth/exceptions/expired-refresh-token.exception';
|
|
137
139
|
export * from './core/modules/auth/exceptions/expired-token.exception';
|
|
138
140
|
export * from './core/modules/auth/exceptions/invalid-token.exception';
|
|
139
141
|
export * from './core/modules/auth/exceptions/legacy-auth-disabled.exception';
|
|
140
142
|
export * from './core/modules/auth/guards/auth.guard';
|
|
143
|
+
export * from './core/modules/auth/helpers/legacy-endpoints.helper';
|
|
141
144
|
export * from './core/modules/auth/guards/legacy-auth-rate-limit.guard';
|
|
142
145
|
export * from './core/modules/auth/guards/roles-guard-registry';
|
|
143
146
|
export * from './core/modules/auth/guards/roles.guard';
|
|
@@ -144,7 +144,13 @@ export class UserController {
|
|
|
144
144
|
@Post('password/reset-request')
|
|
145
145
|
@Roles(RoleEnum.S_EVERYONE)
|
|
146
146
|
async requestPasswordResetMail(@Body('email') email: string): Promise<boolean> {
|
|
147
|
-
|
|
147
|
+
// Always `true`, whether or not the address exists. `sendPasswordResetMail` returns `null` for
|
|
148
|
+
// an unknown one (see CoreUserService.setPasswordResetTokenForEmail), and forwarding that as
|
|
149
|
+
// `false` would rebuild the account oracle one layer up: the status code is equal, the body
|
|
150
|
+
// is not. The caller is told the same thing either way — "if this address is known, mail is on
|
|
151
|
+
// its way" — which is the only honest answer that reveals nothing.
|
|
152
|
+
await this.userService.sendPasswordResetMail(email);
|
|
153
|
+
return true;
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
/**
|
|
@@ -125,7 +125,13 @@ export class UserResolver {
|
|
|
125
125
|
@Mutation(() => Boolean, { description: 'Request new password for user with email' })
|
|
126
126
|
@Roles(RoleEnum.S_EVERYONE)
|
|
127
127
|
async requestPasswordResetMail(@Args('email') email: string): Promise<boolean> {
|
|
128
|
-
|
|
128
|
+
// Always `true`, whether or not the address exists. `sendPasswordResetMail` returns `null` for
|
|
129
|
+
// an unknown one (see CoreUserService.setPasswordResetTokenForEmail), and forwarding that as
|
|
130
|
+
// `false` would rebuild the account oracle one layer up: the status code is equal, the body
|
|
131
|
+
// is not. The caller is told the same thing either way — "if this address is known, mail is on
|
|
132
|
+
// its way" — which is the only honest answer that reveals nothing.
|
|
133
|
+
await this.userService.sendPasswordResetMail(email);
|
|
134
|
+
return true;
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
/**
|