@lenne.tech/nest-server 11.37.0 → 11.38.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 +29 -0
- package/.claude/rules/module-deprecation.md +25 -1
- package/.claude/rules/testing.md +26 -4
- package/FRAMEWORK-API.md +4 -2
- package/dist/config.env.js +1 -1
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +20 -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/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 +33 -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 +51 -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 +2 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +29 -1
- 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-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/user/core-user.service.d.ts +1 -1
- package/dist/core/modules/user/core-user.service.js +29 -12
- 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/user/user.controller.js +2 -1
- package/dist/server/modules/user/user.controller.js.map +1 -1
- 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 +8 -1
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +35 -1
- package/migration-guides/11.37.x-to-11.38.x.md +511 -0
- package/package.json +1 -1
- package/src/config.env.ts +9 -1
- package/src/core/common/interfaces/server-options.interface.ts +225 -9
- package/src/core/common/middlewares/security-headers.middleware.ts +155 -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 +106 -4
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +119 -3
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +67 -3
- 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/user/core-user.service.ts +123 -18
- 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 +35 -9
|
@@ -5,6 +5,7 @@ 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 { maskEmail } from '../../common/helpers/logging.helper';
|
|
8
9
|
import { assignPlain, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
|
|
9
10
|
import { ServiceOptions } from '../../common/interfaces/service-options.interface';
|
|
10
11
|
import { ConfigService } from '../../common/services/config.service';
|
|
@@ -185,12 +186,41 @@ export abstract class CoreUserService<
|
|
|
185
186
|
// Get user
|
|
186
187
|
const dbObject = await this.mainDbModel.findOne({ passwordResetToken: token }).exec();
|
|
187
188
|
if (!dbObject) {
|
|
188
|
-
|
|
189
|
+
// The token is NOT echoed. It is attacker-supplied so nothing secret leaks, but it lands
|
|
190
|
+
// in the response body and in every log line that records the exception — and this logger
|
|
191
|
+
// feeds the ADMIN-readable Hub log buffer. An unbounded caller-controlled string there is
|
|
192
|
+
// free log-stuffing, and the message is no more useful for it.
|
|
193
|
+
throw new NotFoundException('Invalid or expired password reset token');
|
|
189
194
|
}
|
|
190
195
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
196
|
+
// Capture the submitted password for the IAM sync before the closure below
|
|
197
|
+
// reassigns `newPassword` to its sha256 form.
|
|
198
|
+
//
|
|
199
|
+
// It is passed on exactly as received, INCLUDING an already-sha256-hashed
|
|
200
|
+
// one. This used to skip the sync for a 64-hex value, on the reasoning that
|
|
201
|
+
// "IAM uses scrypt, not bcrypt+sha256" — but the sync does not need a plain
|
|
202
|
+
// password: `hashPasswordForBetterAuth` runs its input through
|
|
203
|
+
// `normalizePasswordForIam`, which passes a 64-hex string through unchanged
|
|
204
|
+
// by design, and `migrateAccountToIam` is fed the very same pre-hashed value
|
|
205
|
+
// when an account is created.
|
|
206
|
+
//
|
|
207
|
+
// The guard therefore disabled the sync for exactly the clients this stack
|
|
208
|
+
// ships. The lt frontends hash in the browser before sending — seven call
|
|
209
|
+
// sites in `nuxt-extensions/src/runtime/lib/auth-client.ts` (`signIn.email`,
|
|
210
|
+
// `resetPassword`, `changePassword` and the rest) run `ltSha256` on the
|
|
211
|
+
// password first. That is independent of the `sha256` config option, which
|
|
212
|
+
// only governs what the SERVER does with a plaintext password it happens to
|
|
213
|
+
// receive. So the value arriving here is 64-hex whatever that option says,
|
|
214
|
+
// and every such reset took the skipped branch.
|
|
215
|
+
//
|
|
216
|
+
// The legacy password was updated, the IAM credential was not, and sign-in —
|
|
217
|
+
// which goes through IAM — kept accepting the OLD password and refusing the
|
|
218
|
+
// new one. The endpoint reported success throughout, so the failure surfaced
|
|
219
|
+
// only at the next sign-in.
|
|
220
|
+
//
|
|
221
|
+
// A client that posts a plaintext password was never affected: the old guard
|
|
222
|
+
// let that one through, and the sync normalized it the same way IAM does.
|
|
223
|
+
const passwordForIamSync = newPassword;
|
|
194
224
|
|
|
195
225
|
return this.process(
|
|
196
226
|
async () => {
|
|
@@ -204,17 +234,38 @@ export abstract class CoreUserService<
|
|
|
204
234
|
const updatedUser = await assignPlain(dbObject, {
|
|
205
235
|
password: await bcrypt.hash(newPassword, 10),
|
|
206
236
|
passwordResetToken: null,
|
|
237
|
+
// A reset is what somebody reaches for after a suspected takeover, so it must not
|
|
238
|
+
// leave the attacker's session live. Clearing the refresh tokens ends every legacy
|
|
239
|
+
// session; the IAM half is `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset`,
|
|
240
|
+
// which is off by default because it is a behaviour change for existing deployments —
|
|
241
|
+
// the migration guide recommends turning it on.
|
|
242
|
+
//
|
|
243
|
+
// Without this, "the reset now lands in both stores" would still leave the account
|
|
244
|
+
// reachable with the credential the reset was meant to retire.
|
|
245
|
+
refreshTokens: {},
|
|
207
246
|
}).save();
|
|
208
247
|
|
|
209
248
|
// Sync password to Better-Auth (IAM) if mapper is available
|
|
210
249
|
// This ensures users can sign in via IAM after password reset
|
|
211
|
-
if (this.options?.betterAuthUserMapper &&
|
|
250
|
+
if (this.options?.betterAuthUserMapper && passwordForIamSync && dbObject.email) {
|
|
212
251
|
try {
|
|
213
|
-
|
|
252
|
+
// Same reasoning as in update(): a `false` return means the reset landed in the
|
|
253
|
+
// legacy store only, which is the shape of failure this whole path exists to
|
|
254
|
+
// prevent. It must not be indistinguishable from success.
|
|
255
|
+
const synced = await this.options.betterAuthUserMapper.syncPasswordChangeToIam(
|
|
256
|
+
dbObject.email,
|
|
257
|
+
passwordForIamSync,
|
|
258
|
+
);
|
|
259
|
+
if (!synced) {
|
|
260
|
+
this.userServiceLogger.warn(
|
|
261
|
+
`Password reset for ${maskEmail(dbObject.email)} was NOT synced to IAM (no credential account) — ` +
|
|
262
|
+
'the legacy password now differs from the IAM credential.',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
214
265
|
} catch (error) {
|
|
215
266
|
// Log but don't fail - Legacy Auth password was updated successfully
|
|
216
267
|
this.userServiceLogger.warn(
|
|
217
|
-
`Failed to sync password reset to IAM for ${dbObject.email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
268
|
+
`Failed to sync password reset to IAM for ${maskEmail(dbObject.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
218
269
|
);
|
|
219
270
|
}
|
|
220
271
|
}
|
|
@@ -226,13 +277,48 @@ export abstract class CoreUserService<
|
|
|
226
277
|
}
|
|
227
278
|
|
|
228
279
|
/**
|
|
229
|
-
* Set password
|
|
280
|
+
* Set a password-reset token for an email address
|
|
281
|
+
*
|
|
282
|
+
* Returns `null` for an unknown address when `auth.passwordReset.preventUserEnumeration` is on
|
|
283
|
+
* (the default since 11.38.0) — the caller must then answer exactly as it would for a known one.
|
|
284
|
+
* With the option off it throws `NotFoundException`, the pre-11.38.0 behaviour.
|
|
285
|
+
*
|
|
286
|
+
* WHY THE DEFAULT CHANGED
|
|
287
|
+
*
|
|
288
|
+
* Throwing made the endpoint an account oracle: HTTP 404 for an unknown address, 201 for a known
|
|
289
|
+
* one, so anyone could test who has an account. In a multi-tenant product that also answers who
|
|
290
|
+
* works at which customer. The framework already answers this correctly on the IAM path —
|
|
291
|
+
* Better-Auth's `/request-password-reset` returns the same body either way — so the two halves
|
|
292
|
+
* of one framework disagreed about the same question.
|
|
293
|
+
*
|
|
294
|
+
* THE STATUS CODE IS THE SMALLER HALF
|
|
295
|
+
*
|
|
296
|
+
* Response TIME distinguishes the cases too, and by far more: the known path writes a token and
|
|
297
|
+
* (in the caller) sends mail, the unknown path returns immediately. This method equalises what it
|
|
298
|
+
* can — the token generation still happens, so the CPU cost matches — but the mail send lives in
|
|
299
|
+
* the caller. A `sendPasswordResetMail()` that AWAITS the send leaks the difference as latency,
|
|
300
|
+
* whatever this method does. `src/server/modules/user/user.service.ts` shows the shape that does
|
|
301
|
+
* not; the IAM path uses the same trick, with the reasoning recorded in `better-auth.config.ts`.
|
|
302
|
+
*
|
|
303
|
+
* Anything measuring this honestly should say so rather than claim the channel is closed.
|
|
230
304
|
*/
|
|
231
|
-
async setPasswordResetTokenForEmail(email: string, serviceOptions?: ServiceOptions): Promise<TUser> {
|
|
305
|
+
async setPasswordResetTokenForEmail(email: string, serviceOptions?: ServiceOptions): Promise<null | TUser> {
|
|
232
306
|
// Get user
|
|
233
307
|
const dbObject = await this.mainDbModel.findOne({ email }).exec();
|
|
234
308
|
if (!dbObject) {
|
|
235
|
-
|
|
309
|
+
const preventEnumeration =
|
|
310
|
+
this.configService.getFastButReadOnly('auth')?.passwordReset?.preventUserEnumeration !== false;
|
|
311
|
+
|
|
312
|
+
if (!preventEnumeration) {
|
|
313
|
+
throw new NotFoundException(`No user found with email: ${email}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Do the work the known path does, so the two do not differ in CPU cost. It is cheap next to
|
|
317
|
+
// a mail send, which is why this alone does not close the timing channel — see the note above.
|
|
318
|
+
crypto.randomBytes(32).toString('hex');
|
|
319
|
+
|
|
320
|
+
this.userServiceLogger.debug(`Password reset requested for an unknown address (${maskEmail(email)})`);
|
|
321
|
+
return null;
|
|
236
322
|
}
|
|
237
323
|
|
|
238
324
|
return this.process(
|
|
@@ -311,10 +397,14 @@ export abstract class CoreUserService<
|
|
|
311
397
|
const oldUser = (await this.mainDbModel.findById(id).lean().exec()) as null | TUser;
|
|
312
398
|
const oldEmail = oldUser?.email;
|
|
313
399
|
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
400
|
+
// Capture the submitted password for the IAM sync before super.update()
|
|
401
|
+
// hashes it in place.
|
|
402
|
+
//
|
|
403
|
+
// Passed on exactly as received, including an already-sha256-hashed one —
|
|
404
|
+
// see the note in `resetPassword`: the sync normalizes a 64-hex value
|
|
405
|
+
// through unchanged, so skipping it there disabled the sync for the
|
|
406
|
+
// standard setup, where the frontend hashes before sending.
|
|
407
|
+
const passwordForIamSync = (input as any).password;
|
|
318
408
|
|
|
319
409
|
// Perform the update
|
|
320
410
|
const updatedUser = await super.update(id, input, serviceOptions);
|
|
@@ -333,13 +423,28 @@ export abstract class CoreUserService<
|
|
|
333
423
|
}
|
|
334
424
|
|
|
335
425
|
// Sync password change to IAM if password was changed and mapper is available
|
|
336
|
-
if (this.options?.betterAuthUserMapper &&
|
|
426
|
+
if (this.options?.betterAuthUserMapper && passwordForIamSync && oldUser?.email) {
|
|
337
427
|
try {
|
|
338
|
-
|
|
339
|
-
|
|
428
|
+
// Report what actually happened, not that the call was made. `syncPasswordChangeToIam`
|
|
429
|
+
// answers `false` — never throws — when there is no IAM credential to update, and
|
|
430
|
+
// logging success regardless is how a half-applied password change stays invisible:
|
|
431
|
+
// the endpoint reports success, the user is left with two different passwords, and
|
|
432
|
+
// nothing in the log says so.
|
|
433
|
+
const synced = await this.options.betterAuthUserMapper.syncPasswordChangeToIam(
|
|
434
|
+
oldUser.email,
|
|
435
|
+
passwordForIamSync,
|
|
436
|
+
);
|
|
437
|
+
if (synced) {
|
|
438
|
+
this.userServiceLogger.debug(`Synced password change to IAM for user ${maskEmail(oldUser.email)}`);
|
|
439
|
+
} else {
|
|
440
|
+
this.userServiceLogger.warn(
|
|
441
|
+
`Password change for ${maskEmail(oldUser.email)} was NOT synced to IAM (no credential account) — ` +
|
|
442
|
+
'the legacy password now differs from the IAM credential.',
|
|
443
|
+
);
|
|
444
|
+
}
|
|
340
445
|
} catch (error) {
|
|
341
446
|
this.userServiceLogger.warn(
|
|
342
|
-
`Failed to sync password change to IAM for ${oldUser.email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
447
|
+
`Failed to sync password change to IAM for ${maskEmail(oldUser.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
343
448
|
);
|
|
344
449
|
// Don't throw - password sync failure shouldn't block the update
|
|
345
450
|
}
|
|
@@ -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
|
/**
|
|
@@ -74,19 +74,45 @@ export class UserService extends CoreUserService<User, UserInput, UserCreateInpu
|
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* Request password reset mail
|
|
77
|
+
*
|
|
78
|
+
* REFERENCE IMPLEMENTATION — the two details below are the ones that matter, and a project
|
|
79
|
+
* copying this method needs both. Either one alone leaves the endpoint an account oracle.
|
|
80
|
+
*
|
|
81
|
+
* 1. An unknown address yields `null` (since 11.38.0, unless
|
|
82
|
+
* `auth.passwordReset.preventUserEnumeration` is off) and this method returns quietly, so the
|
|
83
|
+
* caller answers the same way it would for a known one.
|
|
84
|
+
* 2. The mail send is NOT awaited. This is the half that actually closes the channel: the send
|
|
85
|
+
* is a network round trip to SMTP or Brevo, orders of magnitude above everything else in the
|
|
86
|
+
* request. Awaiting it would make the known path visibly slower whatever the status code says
|
|
87
|
+
* — you would have given up the "unknown address" hint in the UI and kept the oracle.
|
|
88
|
+
*
|
|
89
|
+
* A failed send must still be reported rather than crash the process, which is why it carries
|
|
90
|
+
* its own `catch`. The user is told the mail is on its way either way; that is the same trade
|
|
91
|
+
* Better-Auth makes on the IAM path.
|
|
77
92
|
*/
|
|
78
|
-
async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<User> {
|
|
93
|
+
async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<null | User> {
|
|
79
94
|
// Set password reset token
|
|
80
95
|
const user = await super.setPasswordResetTokenForEmail(email, serviceOptions);
|
|
81
96
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
if (!user) {
|
|
98
|
+
// Unknown address, enumeration protection on. Answer exactly as for a known one.
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Deliberately NOT awaited — see the note above.
|
|
103
|
+
void this.emailService
|
|
104
|
+
.sendMail(user.email, 'Password reset', {
|
|
105
|
+
htmlTemplate: 'password-reset',
|
|
106
|
+
templateData: {
|
|
107
|
+
link: `${this.configService.configFastButReadOnly.email.passwordResetLink}/${user.passwordResetToken}`,
|
|
108
|
+
name: user.username,
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
.catch((error: unknown) => {
|
|
112
|
+
this.userServiceLogger.error(
|
|
113
|
+
`Failed to send the password-reset mail: ${error instanceof Error ? error.message : String(error)}`,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
90
116
|
|
|
91
117
|
// Return user
|
|
92
118
|
return user;
|