@lenne.tech/nest-server 11.38.0 → 11.40.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 +24 -1
- package/.claude/rules/module-inheritance.md +2 -0
- package/.claude/rules/package-management.md +117 -2
- package/.claude/rules/testing.md +238 -5
- package/CLAUDE.md +13 -1
- package/FRAMEWORK-API.md +2 -2
- package/dist/config.env.js +4 -2
- 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 +2 -1
- 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/ai/providers/openai-compatible.provider.d.ts +2 -0
- package/dist/core/modules/ai/providers/openai-compatible.provider.js +28 -3
- package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +7 -0
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +3 -1
- 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 +3 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +30 -5
- 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/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 +9 -0
- package/dist/core/modules/user/core-user.service.js +96 -4
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +3 -0
- package/dist/server/modules/user/user.model.d.ts +5 -0
- package/dist/server/modules/user/user.service.js +12 -6
- 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 +14 -0
- package/migration-guides/11.37.x-to-11.38.x.md +18 -1
- package/migration-guides/11.38.x-to-11.39.x.md +456 -0
- package/migration-guides/11.39.0-to-11.40.0.md +186 -0
- package/package.json +5 -4
- package/src/config.env.ts +19 -3
- 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 +120 -4
- package/src/core/common/services/email.service.ts +46 -1
- package/src/core/common/services/module.service.ts +1 -0
- package/src/core/modules/ai/README.md +33 -0
- package/src/core/modules/ai/providers/openai-compatible.provider.ts +76 -3
- package/src/core/modules/better-auth/better-auth.config.ts +25 -0
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +8 -1
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +101 -6
- package/src/core/modules/better-auth/core-better-auth-error-codes.helper.ts +146 -0
- 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 +267 -5
- package/src/server/modules/user/user.service.ts +26 -7
- 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
|
@@ -287,6 +287,61 @@ export const LtnsErrors = {
|
|
|
287
287
|
},
|
|
288
288
|
},
|
|
289
289
|
|
|
290
|
+
/**
|
|
291
|
+
* A link the user followed from an email is invalid, already used, or expired.
|
|
292
|
+
*
|
|
293
|
+
* Deliberately SEPARATE from `INVALID_TOKEN` (LTNS_0003), which the legacy auth service uses for
|
|
294
|
+
* refresh/session tokens. Those two look alike to a developer and are opposites to a user: one
|
|
295
|
+
* means "your session ended, sign in again", the other means "the link in your mail is dead,
|
|
296
|
+
* request another". Telling somebody who cannot sign in to sign in is the worse of the two.
|
|
297
|
+
*
|
|
298
|
+
* Deliberately BROADER than `EMAIL_VERIFICATION_TOKEN_INVALID` (LTNS_0024), because the
|
|
299
|
+
* underlying `INVALID_TOKEN` from Better-Auth covers password reset, email verification and
|
|
300
|
+
* magic links alike, and cannot tell them apart.
|
|
301
|
+
*
|
|
302
|
+
* "nicht (mehr) gültig" covers unknown AND expired on purpose: Better-Auth answers both with the
|
|
303
|
+
* same code, so a message claiming to know which one would be guessing half the time.
|
|
304
|
+
*/
|
|
305
|
+
/**
|
|
306
|
+
* The submitted password is outside the configured length bounds.
|
|
307
|
+
*
|
|
308
|
+
* Separate codes rather than one "does not meet requirements", because the recipient can only
|
|
309
|
+
* act on a message that names the direction. Better-Auth answers `PASSWORD_TOO_LONG` with the
|
|
310
|
+
* same HTTP 400 it uses for an invalid token, so without a distinct code a reset page shows
|
|
311
|
+
* "this link is no longer valid" — the user requests a new link, pastes the same over-long
|
|
312
|
+
* passphrase from their password manager, and fails again. A closed loop with no hint at the
|
|
313
|
+
* cause.
|
|
314
|
+
*
|
|
315
|
+
* The numbers are Better-Auth's defaults (8 / 128) and a project may configure others; the text
|
|
316
|
+
* therefore names the common case rather than claiming to know the deployment's bounds.
|
|
317
|
+
*/
|
|
318
|
+
PASSWORD_TOO_LONG: {
|
|
319
|
+
code: 'LTNS_0028',
|
|
320
|
+
message: 'Password exceeds the maximum length',
|
|
321
|
+
translations: {
|
|
322
|
+
de: 'Das Passwort ist zu lang. Erlaubt sind standardmäßig höchstens 128 Zeichen.',
|
|
323
|
+
en: 'The password is too long. The default maximum is 128 characters.',
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
PASSWORD_TOO_SHORT: {
|
|
328
|
+
code: 'LTNS_0029',
|
|
329
|
+
message: 'Password is below the minimum length',
|
|
330
|
+
translations: {
|
|
331
|
+
de: 'Das Passwort ist zu kurz. Erforderlich sind standardmäßig mindestens 8 Zeichen.',
|
|
332
|
+
en: 'The password is too short. The default minimum is 8 characters.',
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
LINK_INVALID_OR_EXPIRED: {
|
|
337
|
+
code: 'LTNS_0027',
|
|
338
|
+
message: 'Link is invalid or expired',
|
|
339
|
+
translations: {
|
|
340
|
+
de: 'Dieser Link ist nicht (mehr) gültig. Bitte fordern Sie einen neuen an.',
|
|
341
|
+
en: 'This link is no longer valid. Please request a new one.',
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
|
|
290
345
|
// =====================================================
|
|
291
346
|
// Authorization Errors (LTNS_0100-LTNS_0199)
|
|
292
347
|
// =====================================================
|
|
@@ -95,11 +95,38 @@ export abstract class CoreUserModel extends CorePersistenceModel {
|
|
|
95
95
|
*/
|
|
96
96
|
@UnifiedField({
|
|
97
97
|
isOptional: true,
|
|
98
|
-
|
|
98
|
+
// PARTIAL, not plain and not sparse. `findOne({ passwordResetToken: token })` runs on an
|
|
99
|
+
// unauthenticated, unrated endpoint, and without an index every call is a full collection scan
|
|
100
|
+
// of `users` — the dominant cost of that path and an easy amplifier for a request loop.
|
|
101
|
+
// Sparse would be wrong: the reset path clears the field by UNSETTING it, but a document that
|
|
102
|
+
// once held `null` would still count as present, so the index would accumulate an entry for
|
|
103
|
+
// every user who ever requested a reset. The `$type: 'string'` predicate keeps it to the rows
|
|
104
|
+
// that can actually match.
|
|
105
|
+
//
|
|
106
|
+
// NEVER give the companion `passwordResetTokenExpiresAt` a TTL index: it is a Date, and a TTL
|
|
107
|
+
// index there would delete the USER DOCUMENT rather than the token.
|
|
108
|
+
mongoose: { index: { partialFilterExpression: { passwordResetToken: { $type: 'string' } } } },
|
|
99
109
|
roles: RoleEnum.S_NO_ONE,
|
|
100
110
|
})
|
|
101
111
|
passwordResetToken: string = undefined;
|
|
102
112
|
|
|
113
|
+
/**
|
|
114
|
+
* When the current password-reset token stops being valid.
|
|
115
|
+
*
|
|
116
|
+
* `S_NO_ONE` like the token itself: it never belongs in a response, and revealing that a reset is
|
|
117
|
+
* pending is information an account holder gains nothing from and an attacker does.
|
|
118
|
+
*
|
|
119
|
+
* A user document written before 11.38.0 has no value here, and the reset path treats that as
|
|
120
|
+
* expired — see `IAuthPasswordReset.tokenExpiresInMinutes` for why that direction was chosen.
|
|
121
|
+
*/
|
|
122
|
+
@UnifiedField({
|
|
123
|
+
isOptional: true,
|
|
124
|
+
mongoose: true,
|
|
125
|
+
roles: RoleEnum.S_NO_ONE,
|
|
126
|
+
type: () => Date,
|
|
127
|
+
})
|
|
128
|
+
passwordResetTokenExpiresAt: Date = undefined;
|
|
129
|
+
|
|
103
130
|
/**
|
|
104
131
|
* Refresh tokens (for devices)
|
|
105
132
|
* key: Token
|
|
@@ -5,11 +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 { resolveAppUrlFromConfig } from '../../common/helpers/cookies.helper';
|
|
8
9
|
import { maskEmail } from '../../common/helpers/logging.helper';
|
|
9
|
-
import { assignPlain, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
|
|
10
|
+
import { assignPlain, isQueryableString, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
|
|
10
11
|
import { ServiceOptions } from '../../common/interfaces/service-options.interface';
|
|
11
12
|
import { ConfigService } from '../../common/services/config.service';
|
|
12
13
|
import { CrudService } from '../../common/services/crud.service';
|
|
14
|
+
import { ErrorCode } from '../error-code/error-codes';
|
|
13
15
|
import { EmailService } from '../../common/services/email.service';
|
|
14
16
|
import { CoreModelConstructor } from '../../common/types/core-model-constructor.type';
|
|
15
17
|
import { CoreUserModel } from './core-user.model';
|
|
@@ -23,6 +25,14 @@ import { CoreUserServiceOptions } from './interfaces/core-user-service-options.i
|
|
|
23
25
|
* Provides user management with automatic synchronization between
|
|
24
26
|
* Legacy Auth and Better-Auth (IAM) systems when both are enabled.
|
|
25
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
|
+
|
|
26
36
|
export abstract class CoreUserService<
|
|
27
37
|
TUser extends CoreUserModel,
|
|
28
38
|
TUserInput extends CoreUserInput,
|
|
@@ -42,6 +52,56 @@ export abstract class CoreUserService<
|
|
|
42
52
|
protected readonly options?: CoreUserServiceOptions,
|
|
43
53
|
) {
|
|
44
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
|
+
);
|
|
45
105
|
}
|
|
46
106
|
|
|
47
107
|
// ===================================================================================================================
|
|
@@ -137,6 +197,10 @@ export abstract class CoreUserService<
|
|
|
137
197
|
* Get verified state of user by token
|
|
138
198
|
*/
|
|
139
199
|
async getVerifiedState(token: string, _serviceOptions?: ServiceOptions): Promise<boolean> {
|
|
200
|
+
if (!isQueryableString(token)) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
140
204
|
const user = await this.mainDbModel.findOne({ verificationToken: token }).exec();
|
|
141
205
|
|
|
142
206
|
if (!user) {
|
|
@@ -151,6 +215,10 @@ export abstract class CoreUserService<
|
|
|
151
215
|
*/
|
|
152
216
|
async verify(token: string, serviceOptions?: ServiceOptions): Promise<string | TUser> {
|
|
153
217
|
// Get user
|
|
218
|
+
if (!isQueryableString(token)) {
|
|
219
|
+
throw new NotFoundException(ErrorCode.INVALID_TOKEN);
|
|
220
|
+
}
|
|
221
|
+
|
|
154
222
|
const dbObject = await this.mainDbModel.findOne({ verificationToken: token }).exec();
|
|
155
223
|
if (!dbObject) {
|
|
156
224
|
throw new NotFoundException(`No user found with verify token: ${token}`);
|
|
@@ -184,13 +252,36 @@ export abstract class CoreUserService<
|
|
|
184
252
|
*/
|
|
185
253
|
async resetPassword(token: string, newPassword: string, serviceOptions?: ServiceOptions): Promise<TUser> {
|
|
186
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
|
+
|
|
187
262
|
const dbObject = await this.mainDbModel.findOne({ passwordResetToken: token }).exec();
|
|
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)) {
|
|
189
280
|
// The token is NOT echoed. It is attacker-supplied so nothing secret leaks, but it lands
|
|
190
281
|
// in the response body and in every log line that records the exception — and this logger
|
|
191
282
|
// feeds the ADMIN-readable Hub log buffer. An unbounded caller-controlled string there is
|
|
192
283
|
// free log-stuffing, and the message is no more useful for it.
|
|
193
|
-
throw new NotFoundException(
|
|
284
|
+
throw new NotFoundException(ErrorCode.LINK_INVALID_OR_EXPIRED);
|
|
194
285
|
}
|
|
195
286
|
|
|
196
287
|
// Capture the submitted password for the IAM sync before the closure below
|
|
@@ -234,6 +325,7 @@ export abstract class CoreUserService<
|
|
|
234
325
|
const updatedUser = await assignPlain(dbObject, {
|
|
235
326
|
password: await bcrypt.hash(newPassword, 10),
|
|
236
327
|
passwordResetToken: null,
|
|
328
|
+
passwordResetTokenExpiresAt: null,
|
|
237
329
|
// A reset is what somebody reaches for after a suspected takeover, so it must not
|
|
238
330
|
// leave the attacker's session live. Clearing the refresh tokens ends every legacy
|
|
239
331
|
// session; the IAM half is `betterAuth.emailAndPassword.revokeSessionsOnPasswordReset`,
|
|
@@ -303,6 +395,45 @@ export abstract class CoreUserService<
|
|
|
303
395
|
* Anything measuring this honestly should say so rather than claim the channel is closed.
|
|
304
396
|
*/
|
|
305
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
|
+
|
|
306
437
|
// Get user
|
|
307
438
|
const dbObject = await this.mainDbModel.findOne({ email }).exec();
|
|
308
439
|
if (!dbObject) {
|
|
@@ -321,10 +452,14 @@ export abstract class CoreUserService<
|
|
|
321
452
|
return null;
|
|
322
453
|
}
|
|
323
454
|
|
|
324
|
-
|
|
455
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
456
|
+
const expiresAt = this.passwordResetTokenExpiry();
|
|
457
|
+
|
|
458
|
+
const user = await this.process(
|
|
325
459
|
async () => {
|
|
326
460
|
// Set reset token and return
|
|
327
|
-
dbObject.passwordResetToken =
|
|
461
|
+
dbObject.passwordResetToken = token;
|
|
462
|
+
dbObject.passwordResetTokenExpiresAt = expiresAt;
|
|
328
463
|
|
|
329
464
|
// Save
|
|
330
465
|
await dbObject.save();
|
|
@@ -334,6 +469,133 @@ export abstract class CoreUserService<
|
|
|
334
469
|
},
|
|
335
470
|
{ dbObject, serviceOptions },
|
|
336
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)}`;
|
|
337
599
|
}
|
|
338
600
|
|
|
339
601
|
/**
|
|
@@ -91,21 +91,40 @@ export class UserService extends CoreUserService<User, UserInput, UserCreateInpu
|
|
|
91
91
|
* Better-Auth makes on the IAM path.
|
|
92
92
|
*/
|
|
93
93
|
async sendPasswordResetMail(email: string, serviceOptions?: ServiceOptions): Promise<null | User> {
|
|
94
|
-
//
|
|
95
|
-
|
|
94
|
+
// `createPasswordResetToken`, not `setPasswordResetTokenForEmail`: the latter returns the user
|
|
95
|
+
// through `process()`, where the security interceptor strips `passwordResetToken`. Reading the
|
|
96
|
+
// token off that object is what mailed the word `undefined` to a real recipient.
|
|
97
|
+
const created = await this.createPasswordResetToken(email, serviceOptions);
|
|
96
98
|
|
|
97
|
-
if (!
|
|
99
|
+
if (!created) {
|
|
98
100
|
// Unknown address, enumeration protection on. Answer exactly as for a known one.
|
|
99
101
|
return null;
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
// `buildPasswordResetLink`, not string concatenation: `email.passwordResetLink` has no default,
|
|
105
|
+
// and this config file does not set one, so concatenating produced `undefined/<token>`.
|
|
106
|
+
const link = this.buildPasswordResetLink(created.token);
|
|
107
|
+
if (!link) {
|
|
108
|
+
// Sending a mail whose link cannot work is worse than sending none. The recipient has no
|
|
109
|
+
// second way in, and a dead link gives them nothing to act on — while no mail at least reads
|
|
110
|
+
// as "try again". The address is still answered exactly as a known one.
|
|
111
|
+
this.userServiceLogger.error(
|
|
112
|
+
'Password reset mail not sent: no reset link could be built. Set `email.passwordResetLink` or `appUrl`.',
|
|
113
|
+
);
|
|
114
|
+
return created.user;
|
|
115
|
+
}
|
|
116
|
+
|
|
102
117
|
// Deliberately NOT awaited — see the note above.
|
|
103
118
|
void this.emailService
|
|
104
|
-
.sendMail(user.email, 'Password reset', {
|
|
119
|
+
.sendMail(created.user.email, 'Password reset', {
|
|
105
120
|
htmlTemplate: 'password-reset',
|
|
106
121
|
templateData: {
|
|
107
|
-
link
|
|
108
|
-
|
|
122
|
+
link,
|
|
123
|
+
// The mail is the only place the recipient can learn there IS a deadline. Without it an
|
|
124
|
+
// expired link is indistinguishable from a broken one — the same experience this whole
|
|
125
|
+
// area was repaired for.
|
|
126
|
+
linkExpiresInMinutes: this.passwordResetTokenExpiryMinutes(),
|
|
127
|
+
name: created.user.username,
|
|
109
128
|
},
|
|
110
129
|
})
|
|
111
130
|
.catch((error: unknown) => {
|
|
@@ -115,7 +134,7 @@ export class UserService extends CoreUserService<User, UserInput, UserCreateInpu
|
|
|
115
134
|
});
|
|
116
135
|
|
|
117
136
|
// Return user
|
|
118
|
-
return user;
|
|
137
|
+
return created.user;
|
|
119
138
|
}
|
|
120
139
|
|
|
121
140
|
/**
|
|
@@ -50,6 +50,18 @@
|
|
|
50
50
|
Falls die Schaltfläche nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
|
|
51
51
|
<a href="<%= link %>" style="color:#1d4ed8; word-break:break-all; overflow-wrap:break-word;"><%= link %></a>
|
|
52
52
|
</p>
|
|
53
|
+
<%
|
|
54
|
+
/* Optional, like `appName`: a caller that omits it must not turn a password-reset
|
|
55
|
+
mail into a 500. Rendered only for a real, positive value — a deployment that
|
|
56
|
+
switched the expiry off (`0`) has no deadline to announce. */
|
|
57
|
+
const expiryMinutes = (typeof linkExpiresInMinutes !== 'undefined' && Number(linkExpiresInMinutes) > 0)
|
|
58
|
+
? Number(linkExpiresInMinutes) : 0;
|
|
59
|
+
-%>
|
|
60
|
+
<% if (expiryMinutes) { %>
|
|
61
|
+
<p style="margin:16px 0 0 0; font-size:13px; line-height:20px; color:#737373;">
|
|
62
|
+
Der Link ist <%= expiryMinutes %> Minuten lang gültig. Danach kannst du einfach einen neuen anfordern.
|
|
63
|
+
</p>
|
|
64
|
+
<% } %>
|
|
53
65
|
</td>
|
|
54
66
|
</tr>
|
|
55
67
|
<tr>
|
|
@@ -50,6 +50,18 @@
|
|
|
50
50
|
If the button doesn't work, copy and paste this link into your browser:<br>
|
|
51
51
|
<a href="<%= link %>" style="color:#1d4ed8; word-break:break-all; overflow-wrap:break-word;"><%= link %></a>
|
|
52
52
|
</p>
|
|
53
|
+
<%
|
|
54
|
+
/* Optional, like `appName`: a caller that omits it must not turn a password-reset
|
|
55
|
+
mail into a 500. Rendered only for a real, positive value — a deployment that
|
|
56
|
+
switched the expiry off (`0`) has no deadline to announce. */
|
|
57
|
+
const expiryMinutes = (typeof linkExpiresInMinutes !== 'undefined' && Number(linkExpiresInMinutes) > 0)
|
|
58
|
+
? Number(linkExpiresInMinutes) : 0;
|
|
59
|
+
-%>
|
|
60
|
+
<% if (expiryMinutes) { %>
|
|
61
|
+
<p style="margin:16px 0 0 0; font-size:13px; line-height:20px; color:#737373;">
|
|
62
|
+
This link is valid for <%= expiryMinutes %> minutes. After that, just request a new one.
|
|
63
|
+
</p>
|
|
64
|
+
<% } %>
|
|
53
65
|
</td>
|
|
54
66
|
</tr>
|
|
55
67
|
<tr>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
<h1>Hello <%= name %>,</h1>
|
|
2
2
|
<p>you requested a link for setting a new password. Here it is.</p><br />
|
|
3
3
|
<a href="<%= link %>">Reset password</a>
|
|
4
|
+
<% if (typeof linkExpiresInMinutes !== 'undefined' && Number(linkExpiresInMinutes) > 0) { %><br /><p>This link is valid for <%= Number(linkExpiresInMinutes) %> minutes. After that, just request a new one.</p><% } %>
|