@volcanicminds/backend 3.0.0 → 3.1.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.
Files changed (102) hide show
  1. package/README.md +129 -28
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +13 -9
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/api/auth/controller/auth.d.ts +1 -1
  6. package/dist/lib/api/auth/controller/auth.d.ts.map +1 -1
  7. package/dist/lib/api/auth/controller/auth.js +90 -66
  8. package/dist/lib/api/auth/controller/auth.js.map +1 -1
  9. package/dist/lib/api/auth/routes.d.ts +29 -0
  10. package/dist/lib/api/auth/routes.d.ts.map +1 -1
  11. package/dist/lib/api/auth/routes.js +10 -0
  12. package/dist/lib/api/auth/routes.js.map +1 -1
  13. package/dist/lib/api/token/controller/token.js +17 -17
  14. package/dist/lib/api/token/controller/token.js.map +1 -1
  15. package/dist/lib/api/users/controller/user.d.ts.map +1 -1
  16. package/dist/lib/api/users/controller/user.js +25 -19
  17. package/dist/lib/api/users/controller/user.js.map +1 -1
  18. package/dist/lib/api/users/routes.d.ts.map +1 -1
  19. package/dist/lib/api/users/routes.js.map +1 -1
  20. package/dist/lib/config/plugins.d.ts +17 -0
  21. package/dist/lib/config/plugins.d.ts.map +1 -1
  22. package/dist/lib/config/plugins.js +2 -2
  23. package/dist/lib/config/plugins.js.map +1 -1
  24. package/dist/lib/database/typeorm/embedded.d.ts +12 -0
  25. package/dist/lib/database/typeorm/embedded.d.ts.map +1 -0
  26. package/dist/lib/database/typeorm/embedded.js +92 -0
  27. package/dist/lib/database/typeorm/embedded.js.map +1 -0
  28. package/dist/lib/database/typeorm/entities/user.d.ts +1 -0
  29. package/dist/lib/database/typeorm/entities/user.d.ts.map +1 -1
  30. package/dist/lib/database/typeorm/entities/user.js.map +1 -1
  31. package/dist/lib/database/typeorm/loader/tenantManager.d.ts.map +1 -1
  32. package/dist/lib/database/typeorm/loader/tenantManager.js +4 -1
  33. package/dist/lib/database/typeorm/loader/tenantManager.js.map +1 -1
  34. package/dist/lib/database/typeorm/loader/userManager.d.ts.map +1 -1
  35. package/dist/lib/database/typeorm/loader/userManager.js +2 -1
  36. package/dist/lib/database/typeorm/loader/userManager.js.map +1 -1
  37. package/dist/lib/database/typeorm/query/operators.d.ts +10 -0
  38. package/dist/lib/database/typeorm/query/operators.d.ts.map +1 -0
  39. package/dist/lib/database/typeorm/query/operators.js +122 -0
  40. package/dist/lib/database/typeorm/query/operators.js.map +1 -0
  41. package/dist/lib/database/typeorm/query.d.ts +2 -0
  42. package/dist/lib/database/typeorm/query.d.ts.map +1 -1
  43. package/dist/lib/database/typeorm/query.js +29 -62
  44. package/dist/lib/database/typeorm/query.js.map +1 -1
  45. package/dist/lib/database/typeorm/util/crypto.d.ts.map +1 -1
  46. package/dist/lib/database/typeorm/util/crypto.js +50 -26
  47. package/dist/lib/database/typeorm/util/crypto.js.map +1 -1
  48. package/dist/lib/hooks/onRequest.d.ts.map +1 -1
  49. package/dist/lib/hooks/onRequest.js +15 -29
  50. package/dist/lib/hooks/onRequest.js.map +1 -1
  51. package/dist/lib/loader/router.d.ts +2 -0
  52. package/dist/lib/loader/router.d.ts.map +1 -1
  53. package/dist/lib/loader/router.js +7 -3
  54. package/dist/lib/loader/router.js.map +1 -1
  55. package/dist/lib/loader/tracking.d.ts.map +1 -1
  56. package/dist/lib/loader/tracking.js +6 -1
  57. package/dist/lib/loader/tracking.js.map +1 -1
  58. package/dist/lib/middleware/isAdmin.d.ts.map +1 -1
  59. package/dist/lib/middleware/isAdmin.js +2 -1
  60. package/dist/lib/middleware/isAdmin.js.map +1 -1
  61. package/dist/lib/middleware/isAuthenticated.d.ts.map +1 -1
  62. package/dist/lib/middleware/isAuthenticated.js +2 -1
  63. package/dist/lib/middleware/isAuthenticated.js.map +1 -1
  64. package/dist/lib/schemas/user.d.ts +17 -0
  65. package/dist/lib/schemas/user.d.ts.map +1 -1
  66. package/dist/lib/schemas/user.js +8 -1
  67. package/dist/lib/schemas/user.js.map +1 -1
  68. package/dist/lib/util/errors.d.ts.map +1 -1
  69. package/dist/lib/util/errors.js +3 -2
  70. package/dist/lib/util/errors.js.map +1 -1
  71. package/dist/lib/util/httpError.d.ts +8 -0
  72. package/dist/lib/util/httpError.d.ts.map +1 -0
  73. package/dist/lib/util/httpError.js +19 -0
  74. package/dist/lib/util/httpError.js.map +1 -0
  75. package/dist/typeorm.d.ts +3 -2
  76. package/dist/typeorm.d.ts.map +1 -1
  77. package/dist/typeorm.js +14 -2
  78. package/dist/typeorm.js.map +1 -1
  79. package/dist/types/global.d.ts +4 -1
  80. package/lib/api/auth/controller/auth.ts +113 -67
  81. package/lib/api/auth/routes.ts +17 -0
  82. package/lib/api/token/controller/token.ts +17 -17
  83. package/lib/api/users/controller/user.ts +32 -19
  84. package/lib/api/users/routes.ts +3 -0
  85. package/lib/config/plugins.ts +5 -2
  86. package/lib/database/typeorm/embedded.ts +173 -0
  87. package/lib/database/typeorm/entities/user.ts +2 -0
  88. package/lib/database/typeorm/loader/tenantManager.ts +8 -1
  89. package/lib/database/typeorm/loader/userManager.ts +6 -1
  90. package/lib/database/typeorm/query/operators.ts +187 -0
  91. package/lib/database/typeorm/query.ts +48 -73
  92. package/lib/database/typeorm/util/crypto.ts +89 -27
  93. package/lib/defaults/managers.ts +1 -1
  94. package/lib/hooks/onRequest.ts +17 -29
  95. package/lib/loader/router.ts +13 -4
  96. package/lib/loader/tracking.ts +10 -1
  97. package/lib/middleware/isAdmin.ts +3 -1
  98. package/lib/middleware/isAuthenticated.ts +4 -1
  99. package/lib/schemas/user.ts +17 -1
  100. package/lib/util/errors.ts +4 -2
  101. package/lib/util/httpError.ts +37 -0
  102. package/package.json +31 -5
@@ -2,11 +2,34 @@
2
2
  import { FastifyReply, FastifyRequest } from 'fastify'
3
3
  import * as regExp from '../../../util/regexp.js'
4
4
  import { MfaPolicy } from '../../../config/constants.js'
5
+ import { httpError } from '../../../util/httpError.js'
5
6
 
6
7
  // Upper bound for the password accepted at login: a cheap guard against oversized
7
8
  // payloads. Complexity is enforced only when a password is set, not at login.
8
9
  const MAX_PASSWORD_LENGTH = 256
9
10
 
11
+ // TOTP period in seconds — must match the period used by the MFA manager (tools default: 30).
12
+ const TOTP_PERIOD_SECONDS = 30
13
+
14
+ /**
15
+ * Normalizes the MFA manager `verify` result and turns the relative time-step delta into the
16
+ * absolute step consumed, so it can be persisted for anti-replay.
17
+ *
18
+ * - New managers return `number | null` (delta or invalid).
19
+ * - Legacy managers returning a boolean are tolerated: valid/invalid without a usable counter.
20
+ *
21
+ * @returns `{ valid, counter }` — `counter` is the absolute TOTP step, or `null` when unknown.
22
+ */
23
+ function evaluateMfaResult(result: number | boolean | null): { valid: boolean; counter: number | null } {
24
+ if (result === null || result === false) return { valid: false, counter: null }
25
+ if (typeof result === 'number') {
26
+ const currentStep = Math.floor(Date.now() / 1000 / TOTP_PERIOD_SECONDS)
27
+ return { valid: true, counter: currentStep + result }
28
+ }
29
+ // Legacy boolean `true`: valid, but no delta to track replays with.
30
+ return { valid: true, counter: null }
31
+ }
32
+
10
33
  export async function register(req: FastifyRequest, reply: FastifyReply) {
11
34
  const { password1: password, password2, ...data } = req.data()
12
35
 
@@ -15,27 +38,27 @@ export async function register(req: FastifyRequest, reply: FastifyReply) {
15
38
  }
16
39
 
17
40
  if (!data.username) {
18
- return reply.status(400).send(new Error('Username not valid'))
41
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Username not valid' })
19
42
  }
20
43
  if (!regExp.isEmail(data.email)) {
21
- return reply.status(400).send(new Error('Email not valid'))
44
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Email not valid' })
22
45
  }
23
46
  if (!password || !regExp.password.test(password)) {
24
- return reply.status(400).send(new Error('Password not valid'))
47
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Password not valid' })
25
48
  }
26
49
  if (!password2 || password2 !== password) {
27
- return reply.status(400).send(new Error('Repeated password not match'))
50
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Repeated password not match' })
28
51
  }
29
52
 
30
53
  let existings = await req.server['userManager'].retrieveUserByEmail(data.email, req.runner)
31
54
  if (existings) {
32
- return reply.status(400).send(new Error('Email already registered'))
55
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Email already registered' })
33
56
  }
34
57
 
35
58
  if ((data.requiredRoles || []).includes('admin')) {
36
59
  existings = await req.server['userManager'].findQuery({ 'roles:in': 'admin' }, req.runner)
37
60
  if (existings?.records?.length) {
38
- return reply.status(400).send(new Error('User admin already registered'))
61
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'User admin already registered' })
39
62
  }
40
63
  }
41
64
 
@@ -48,7 +71,7 @@ export async function register(req: FastifyRequest, reply: FastifyReply) {
48
71
 
49
72
  const user = await req.server['userManager'].createUser({ ...data, password: password }, req.runner)
50
73
  if (!user) {
51
- return reply.status(400).send(new Error('User not registered'))
74
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'User not registered' })
52
75
  }
53
76
 
54
77
  return user
@@ -61,18 +84,18 @@ export async function unregister(req: FastifyRequest, reply: FastifyReply) {
61
84
  let isValid = await req.server['userManager'].isValidUser(user)
62
85
 
63
86
  if (!isValid) {
64
- return reply.status(403).send(new Error('Wrong credentials'))
87
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Wrong credentials' })
65
88
  }
66
89
 
67
90
  if (user.blocked) {
68
- return reply.status(403).send(new Error('User blocked'))
91
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'User blocked' })
69
92
  }
70
93
 
71
94
  user = await req.server['userManager'].disableUserById(user.getId(), req.runner)
72
95
  isValid = await req.server['userManager'].isValidUser(user)
73
96
 
74
97
  if (!isValid) {
75
- return reply.status(400).send(new Error('User not valid'))
98
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'User not valid' })
76
99
  }
77
100
 
78
101
  return { ok: true }
@@ -82,12 +105,12 @@ export async function validatePassword(req: FastifyRequest, reply: FastifyReply)
82
105
  const { password } = req.data()
83
106
 
84
107
  if (!password) {
85
- return reply.status(400).send(new Error('Password cannot be null'))
108
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Password cannot be null' })
86
109
  }
87
110
 
88
111
  const match = regExp.password.test(password)
89
112
  if (!match) {
90
- return reply.status(400).send(new Error('Password is not valid'))
113
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Password is not valid' })
91
114
  }
92
115
 
93
116
  return { ok: match }
@@ -100,32 +123,32 @@ export async function changePassword(req: FastifyRequest, reply: FastifyReply) {
100
123
 
101
124
  const _user = req.user
102
125
  if (!_user) {
103
- return reply.status(401).send(new Error('Unauthorized'))
126
+ return reply.status(401).send({ statusCode: 401, error: 'Unauthorized', message: 'Unauthorized' })
104
127
  }
105
128
 
106
129
  const { email, oldPassword, newPassword1, newPassword2 } = req.data()
107
130
 
108
131
  if (_user.email !== email && (!req.hasRole(roles.admin) || !global.config.options.admin_can_change_passwords)) {
109
- return reply.status(400).send(new Error('Email not valid'))
132
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Email not valid' })
110
133
  }
111
134
 
112
135
  if (!newPassword1 || !regExp.password.test(newPassword1)) {
113
- return reply.status(400).send(new Error('New password is not valid'))
136
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'New password is not valid' })
114
137
  }
115
138
 
116
139
  if (!newPassword2 || newPassword2 !== newPassword1) {
117
- return reply.status(400).send(new Error('Repeated new password not match'))
140
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Repeated new password not match' })
118
141
  }
119
142
 
120
143
  let user = await req.server['userManager'].retrieveUserByPassword(email, oldPassword, req.runner)
121
144
  let isValid = await req.server['userManager'].isValidUser(user)
122
145
 
123
146
  if (!isValid) {
124
- return reply.status(403).send(new Error('Wrong credentials'))
147
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Wrong credentials' })
125
148
  }
126
149
 
127
150
  if (user.blocked) {
128
- return reply.status(403).send(new Error('User blocked'))
151
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'User blocked' })
129
152
  }
130
153
 
131
154
  user = await req.server['userManager'].changePassword(email, newPassword1, oldPassword, req.runner)
@@ -141,7 +164,7 @@ export async function forgotPassword(req: FastifyRequest, reply: FastifyReply) {
141
164
  }
142
165
 
143
166
  if (!username && !regExp.isEmail(email)) {
144
- return reply.status(400).send(new Error('Missing a valid user identifier'))
167
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Missing a valid user identifier' })
145
168
  }
146
169
 
147
170
  let user = null as any
@@ -151,38 +174,35 @@ export async function forgotPassword(req: FastifyRequest, reply: FastifyReply) {
151
174
  user = await req.server['userManager'].retrieveUserByUsername(username, req.runner)
152
175
  }
153
176
 
154
- let isValid = await req.server['userManager'].isValidUser(user)
155
-
156
- if (!isValid) {
157
- return reply.status(403).send(new Error('Wrong credentials'))
158
- }
177
+ const isValid = await req.server['userManager'].isValidUser(user)
159
178
 
160
- if (user?.blocked) {
161
- return reply.status(403).send(new Error('User blocked'))
179
+ // Account-enumeration hardening: do NOT reveal whether the account exists, is
180
+ // invalid or is blocked. Always answer 200 with a generic body; only actually
181
+ // trigger the reset flow when the user exists, is valid and not blocked.
182
+ // (A residual timing side-channel remains since the valid path does a DB write.)
183
+ if (isValid && !user?.blocked) {
184
+ await req.server['userManager'].forgotPassword(user.email, req.runner)
162
185
  }
163
186
 
164
- user = await req.server['userManager'].forgotPassword(user.email, req.runner)
165
- isValid = await req.server['userManager'].isValidUser(user)
166
-
167
- return { ok: isValid }
187
+ return { ok: true }
168
188
  }
169
189
 
170
190
  export async function confirmEmail(req: FastifyRequest, reply: FastifyReply) {
171
191
  const { code } = req.data()
172
192
 
173
193
  if (!code) {
174
- return reply.status(400).send(new Error('Missing the confirm email token'))
194
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Missing the confirm email token' })
175
195
  }
176
196
 
177
197
  let user = await req.server['userManager'].retrieveUserByConfirmationToken(code, req.runner)
178
198
  let isValid = await req.server['userManager'].isValidUser(user)
179
199
 
180
200
  if (!isValid) {
181
- return reply.status(403).send(new Error('Wrong credentials'))
201
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Wrong credentials' })
182
202
  }
183
203
 
184
204
  if (user.blocked) {
185
- return reply.status(403).send(new Error('User blocked'))
205
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'User blocked' })
186
206
  }
187
207
 
188
208
  user = await req.server['userManager'].userConfirmation(user, req.runner)
@@ -199,22 +219,22 @@ export async function resetPassword(req: FastifyRequest, reply: FastifyReply) {
199
219
  }
200
220
 
201
221
  if (!newPassword1 || !regExp.password.test(newPassword1)) {
202
- return reply.status(400).send(new Error('New password not valid'))
222
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'New password not valid' })
203
223
  }
204
224
 
205
225
  if (!newPassword2 || newPassword2 !== newPassword1) {
206
- return reply.status(400).send(new Error('Repeated new password not match'))
226
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Repeated new password not match' })
207
227
  }
208
228
 
209
229
  let user = await req.server['userManager'].retrieveUserByResetPasswordToken(code, req.runner)
210
230
  let isValid = await req.server['userManager'].isValidUser(user)
211
231
 
212
232
  if (!isValid) {
213
- return reply.status(403).send(new Error('Wrong credentials'))
233
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Wrong credentials' })
214
234
  }
215
235
 
216
236
  if (user.blocked) {
217
- return reply.status(403).send(new Error('User blocked'))
237
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'User blocked' })
218
238
  }
219
239
 
220
240
  user = await req.server['userManager'].resetPassword(user, newPassword1, req.runner)
@@ -231,7 +251,7 @@ export async function login(req: FastifyRequest, reply: FastifyReply) {
231
251
  }
232
252
 
233
253
  if (!regExp.isEmail(email)) {
234
- return reply.status(400).send(new Error('Email not valid'))
254
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Email not valid' })
235
255
  }
236
256
  // At login we do NOT re-validate the password complexity policy: the password
237
257
  // was already validated when it was set (register/change/reset), and bcrypt is
@@ -239,31 +259,31 @@ export async function login(req: FastifyRequest, reply: FastifyReply) {
239
259
  // would lock out existing users whenever the policy changes. We only bound the
240
260
  // input length as a cheap guard against oversized payloads.
241
261
  if (!password || password.length > MAX_PASSWORD_LENGTH) {
242
- return reply.status(400).send(new Error('Password not valid'))
262
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Password not valid' })
243
263
  }
244
264
 
245
265
  let user = await req.server['userManager'].retrieveUserByPassword(email, password, req.runner)
246
266
  if (!user) {
247
- return reply.status(403).send(new Error('Wrong credentials'))
267
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Wrong credentials' })
248
268
  }
249
269
 
250
270
  const isValid = await req.server['userManager'].isValidUser(user)
251
271
 
252
272
  if (!isValid) {
253
- return reply.status(403).send(new Error('Invalid user'))
273
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Invalid user' })
254
274
  }
255
275
 
256
276
  if (!(user.confirmed === true)) {
257
- return reply.status(403).send(new Error('User email unconfirmed'))
277
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'User email unconfirmed' })
258
278
  }
259
279
 
260
280
  const isPasswordToBeChanged = req.server['userManager'].isPasswordToBeChanged(user)
261
281
  if (isPasswordToBeChanged) {
262
- return reply.status(403).send({ statusCode: 403, code: 'PASSWORD_TO_BE_CHANGED', message: 'Password is expired' })
282
+ return reply.status(403).send(httpError(403, 'Password is expired', 'PASSWORD_TO_BE_CHANGED'))
263
283
  }
264
284
 
265
285
  if (user.blocked) {
266
- return reply.status(403).send(new Error('User blocked'))
286
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'User blocked' })
267
287
  }
268
288
 
269
289
  // MFA Logic Interception
@@ -342,6 +362,18 @@ export async function refreshToken(req: FastifyRequest, reply: FastifyReply) {
342
362
  throw new Error('Not implemented')
343
363
  }
344
364
 
365
+ // Refresh tokens are optional (JWT_REFRESH). When disabled there is no refresh
366
+ // verifier registered — answer a clean 404 instead of throwing a 500 later.
367
+ if (!reply.server.jwt['refreshToken']) {
368
+ return reply.status(404).send(httpError(404, 'Refresh tokens are disabled', 'NOT_FOUND'))
369
+ }
370
+
371
+ if (!token || !refreshToken) {
372
+ return reply
373
+ .status(400)
374
+ .send({ statusCode: 400, error: 'Bad Request', message: 'Missing token or refreshToken' })
375
+ }
376
+
345
377
  // Verify the signature of the (possibly expired) access token: `ignoreExpiration`
346
378
  // lets a stale token through — which is the whole point of refresh — but a forged
347
379
  // or tampered token is now rejected (previously `decode` skipped signature checks).
@@ -349,7 +381,7 @@ export async function refreshToken(req: FastifyRequest, reply: FastifyReply) {
349
381
  try {
350
382
  tokenData = (await reply.server.jwt.verify(token, { ignoreExpiration: true })) as { sub: number; iat?: number }
351
383
  } catch {
352
- return reply.status(403).send(new Error('Invalid token'))
384
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Invalid token' })
353
385
  }
354
386
 
355
387
  // Reject refresh of access tokens issued too long ago. Use the real temporal
@@ -357,19 +389,19 @@ export async function refreshToken(req: FastifyRequest, reply: FastifyReply) {
357
389
  // against a unix timestamp and was effectively dead code.
358
390
  const minAccettable = Math.floor(Date.now() / 1000) - 2592000 // 30 days
359
391
  if (!tokenData?.iat || tokenData.iat < minAccettable) {
360
- return reply.status(403).send(new Error('Token too old'))
392
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Token too old' })
361
393
  }
362
394
 
363
395
  const refreshTokenData = await reply.server.jwt['refreshToken'].verify(refreshToken)
364
396
  if (tokenData?.sub && tokenData?.sub !== refreshTokenData?.sub) {
365
- return reply.status(403).send(new Error('Mismatched tokens'))
397
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Mismatched tokens' })
366
398
  }
367
399
 
368
400
  const user = await req.server['userManager'].retrieveUserByExternalId(tokenData.sub, req.runner)
369
401
  const isValid = await req.server['userManager'].isValidUser(user)
370
402
 
371
403
  if (!isValid) {
372
- return reply.status(403).send(new Error('Wrong refresh token'))
404
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Wrong refresh token' })
373
405
  }
374
406
 
375
407
  const newToken = await reply.jwtSign({ sub: user.externalId, tid: req.tenant?.id })
@@ -381,7 +413,7 @@ export async function refreshToken(req: FastifyRequest, reply: FastifyReply) {
381
413
  export async function invalidateTokens(req: FastifyRequest, reply: FastifyReply) {
382
414
  let isValid = await req.server['userManager'].isValidUser(req.user)
383
415
  if (!req.user || !isValid) {
384
- return reply.status(403).send(new Error('User not linked'))
416
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'User not linked' })
385
417
  }
386
418
 
387
419
  const user = await req.server['userManager'].resetExternalId(req.user.getId(), req.runner)
@@ -391,7 +423,7 @@ export async function invalidateTokens(req: FastifyRequest, reply: FastifyReply)
391
423
 
392
424
  export async function mfaSetup(req: FastifyRequest, reply: FastifyReply) {
393
425
  const user = req.user
394
- if (!user) return reply.status(401).send(new Error('Unauthorized'))
426
+ if (!user) return reply.status(401).send({ statusCode: 401, error: 'Unauthorized', message: 'Unauthorized' })
395
427
 
396
428
  try {
397
429
  // Use mfaManager (injected) for logic
@@ -400,7 +432,7 @@ export async function mfaSetup(req: FastifyRequest, reply: FastifyReply) {
400
432
  return setupData
401
433
  } catch (error: any) {
402
434
  req.log.error({ err: error }, 'MFA Setup failed')
403
- return reply.status(500).send(new Error('Failed to generate MFA setup'))
435
+ return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'Failed to generate MFA setup' })
404
436
  }
405
437
  }
406
438
 
@@ -409,19 +441,24 @@ export async function mfaEnable(req: FastifyRequest, reply: FastifyReply) {
409
441
  const { secret, token } = req.data()
410
442
  const { mfa_policy = MfaPolicy.OPTIONAL } = global.config.options || {}
411
443
 
412
- if (!user || !secret || !token) return reply.status(400).send(new Error('Missing parameters'))
444
+ if (!user || !secret || !token) return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Missing parameters' })
413
445
 
414
446
  try {
415
447
  // 1. Verify using mfaManager (tools)
416
- const isValid = req.server['mfaManager'].verify(token, secret)
417
- if (!isValid) {
418
- return reply.status(400).send(new Error('Invalid token'))
448
+ const { valid, counter } = evaluateMfaResult(req.server['mfaManager'].verify(token, secret))
449
+ if (!valid) {
450
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Invalid token' })
419
451
  }
420
452
 
421
453
  // 2. Save using userManager (typeorm)
422
454
  await req.server['userManager'].saveMfaSecret(user.getId(), secret, req.runner)
423
455
  await req.server['userManager'].enableMfa(user.getId(), req.runner)
424
456
 
457
+ // Record the consumed time-step so the same code cannot be replayed on the first /mfa/verify.
458
+ if (counter !== null) {
459
+ await req.server['userManager'].updateUserById(user.getId(), { mfaLastUsedCounter: counter }, req.runner)
460
+ }
461
+
425
462
  // IMPORTANT: Return full tokens upon enablement if user was in pending state
426
463
  // BUT usually user is already logged in via temp token or full token.
427
464
  // If user is setting up from "Forced Setup", they need tokens now.
@@ -443,7 +480,7 @@ export async function mfaEnable(req: FastifyRequest, reply: FastifyReply) {
443
480
  }
444
481
  } catch (error: any) {
445
482
  req.log.error({ err: error }, 'MFA Enable failed')
446
- return reply.status(500).send(new Error('Failed to enable MFA'))
483
+ return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'Failed to enable MFA' })
447
484
  }
448
485
  }
449
486
 
@@ -451,34 +488,43 @@ export async function mfaVerify(req: FastifyRequest, reply: FastifyReply) {
451
488
  const authHeader = req.headers.authorization
452
489
  const { mfa_policy = MfaPolicy.OPTIONAL } = global.config.options || {}
453
490
 
454
- if (!authHeader) return reply.status(401).send(new Error('Missing authorization'))
491
+ if (!authHeader) return reply.status(401).send({ statusCode: 401, error: 'Unauthorized', message: 'Missing authorization' })
455
492
 
456
493
  const tokenStr = authHeader.split(' ')[1]
457
494
  let decoded: any
458
495
  try {
459
496
  decoded = req.server.jwt.verify(tokenStr)
460
497
  } catch (_e) {
461
- return reply.status(401).send(new Error('Invalid token'))
498
+ return reply.status(401).send({ statusCode: 401, error: 'Unauthorized', message: 'Invalid token' })
462
499
  }
463
500
 
464
501
  if (decoded.role !== 'pre-auth-mfa' && (!req.user || !req.user.getId())) {
465
- return reply.status(403).send(new Error('Invalid token scope'))
502
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Invalid token scope' })
466
503
  }
467
504
 
468
505
  const subjectId = decoded.sub
469
506
  const { token } = req.data()
470
- if (!token) return reply.status(400).send(new Error('Missing token'))
507
+ if (!token) return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Missing token' })
471
508
 
472
509
  // 1. Retrieve secret via userManager
473
510
  const user = await req.server['userManager'].retrieveUserByExternalId(subjectId, req.runner)
474
- if (!user) return reply.status(404).send(new Error('User not found'))
511
+ if (!user) return reply.status(404).send({ statusCode: 404, error: 'Not Found', message: 'User not found' })
475
512
 
476
513
  const secret = await req.server['userManager'].retrieveMfaSecret(user.getId(), req.runner)
477
- if (!secret) return reply.status(403).send(new Error('MFA not configured for user'))
514
+ if (!secret) return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'MFA not configured for user' })
478
515
 
479
516
  // 2. Verify via mfaManager
480
- const isValid = req.server['mfaManager'].verify(token, secret)
481
- if (!isValid) return reply.status(403).send(new Error('Invalid MFA token'))
517
+ const { valid, counter } = evaluateMfaResult(req.server['mfaManager'].verify(token, secret))
518
+ if (!valid) return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Invalid MFA token' })
519
+
520
+ // 3. Anti-replay: reject a code whose time-step was already consumed (same or earlier than the last).
521
+ const lastCounter = user.mfaLastUsedCounter
522
+ if (counter !== null && lastCounter != null && counter <= lastCounter) {
523
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'MFA token already used' })
524
+ }
525
+ if (counter !== null) {
526
+ await req.server['userManager'].updateUserById(user.getId(), { mfaLastUsedCounter: counter }, req.runner)
527
+ }
482
528
 
483
529
  if (config.options.reset_external_id_on_login) {
484
530
  await req.server['userManager'].resetExternalId(user.getId(), req.runner)
@@ -502,11 +548,11 @@ export async function mfaVerify(req: FastifyRequest, reply: FastifyReply) {
502
548
 
503
549
  export async function mfaDisable(req: FastifyRequest, reply: FastifyReply) {
504
550
  const user = req.user
505
- if (!user) return reply.status(401).send(new Error('Unauthorized'))
551
+ if (!user) return reply.status(401).send({ statusCode: 401, error: 'Unauthorized', message: 'Unauthorized' })
506
552
 
507
553
  const { mfa_policy = MfaPolicy.OPTIONAL } = global.config.options || {}
508
554
  if (mfa_policy === MfaPolicy.MANDATORY || mfa_policy === MfaPolicy.ONE_WAY) {
509
- return reply.status(403).send(new Error('MFA disable is not allowed by security policy'))
555
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'MFA disable is not allowed by security policy' })
510
556
  }
511
557
 
512
558
  try {
@@ -514,6 +560,6 @@ export async function mfaDisable(req: FastifyRequest, reply: FastifyReply) {
514
560
  return { ok: true }
515
561
  } catch (error: any) {
516
562
  req.log.error({ err: error }, 'MFA Disable failed')
517
- return reply.status(500).send(new Error('Failed to disable MFA'))
563
+ return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'Failed to disable MFA' })
518
564
  }
519
565
  }
@@ -1,3 +1,12 @@
1
+ // Throttle credential-sensitive endpoints (login, register, forgot/reset password)
2
+ // to blunt brute-force / credential-stuffing / password-spray (OWASP API2/API4).
3
+ // Per-IP, configurable via env. The rateLimit plugin is registered global:false,
4
+ // so only routes carrying this opt in.
5
+ const authRateLimit = {
6
+ max: Math.floor(Number(process.env.AUTH_RATELIMIT_MAX) || 10),
7
+ timeWindow: Math.floor(Number(process.env.AUTH_RATELIMIT_WINDOW) || 60000)
8
+ }
9
+
1
10
  export default {
2
11
  config: {
3
12
  title: 'Authentication functions',
@@ -15,6 +24,7 @@ export default {
15
24
  roles: [],
16
25
  handler: 'auth.register',
17
26
  middlewares: ['global.preAuth', 'global.postAuth'],
27
+ rateLimit: authRateLimit,
18
28
  config: {
19
29
  title: 'Register new user',
20
30
  description: 'Register a new user',
@@ -90,6 +100,7 @@ export default {
90
100
  roles: [],
91
101
  handler: 'auth.forgotPassword',
92
102
  middlewares: ['global.preForgotPassword', 'global.postForgotPassword'],
103
+ rateLimit: authRateLimit,
93
104
  config: {
94
105
  title: 'Forgot password',
95
106
  description:
@@ -106,6 +117,7 @@ export default {
106
117
  roles: [],
107
118
  handler: 'auth.resetPassword',
108
119
  middlewares: [],
120
+ rateLimit: authRateLimit,
109
121
  config: {
110
122
  title: 'Reset password',
111
123
  description:
@@ -122,6 +134,7 @@ export default {
122
134
  roles: [],
123
135
  handler: 'auth.login',
124
136
  middlewares: ['global.preAuth', 'global.postAuth'],
137
+ rateLimit: authRateLimit,
125
138
  config: {
126
139
  title: 'Login',
127
140
  description: 'Login authentication',
@@ -195,6 +208,8 @@ export default {
195
208
  roles: [],
196
209
  handler: 'auth.mfaEnable',
197
210
  middlewares: ['global.isAuthenticated'],
211
+ // Throttle TOTP attempts to curb online brute-force of the 6-digit code (S11).
212
+ rateLimit: { max: 10, timeWindow: 60000 },
198
213
  config: {
199
214
  title: 'Enable MFA',
200
215
  description: 'Enable MFA by verifying a token against the generated secret. Returns tokens on success.',
@@ -210,6 +225,8 @@ export default {
210
225
  roles: [],
211
226
  handler: 'auth.mfaVerify',
212
227
  middlewares: [],
228
+ // Throttle TOTP attempts to curb online brute-force of the 6-digit code (S11).
229
+ rateLimit: { max: 10, timeWindow: 60000 },
213
230
  config: {
214
231
  title: 'Verify MFA',
215
232
  description: 'Verify MFA token during login to obtain final JWT',
@@ -1,18 +1,18 @@
1
1
  import { FastifyReply, FastifyRequest } from 'fastify'
2
2
 
3
3
  export async function count(req: FastifyRequest, _reply: FastifyReply) {
4
- return await req.server['tokenManager'].countQuery(req.data())
4
+ return await req.server['tokenManager'].countQuery(req.data(), req.runner)
5
5
  }
6
6
 
7
7
  export async function find(req: FastifyRequest, reply: FastifyReply) {
8
- const { headers, records } = await req.server['tokenManager'].findQuery(req.data())
8
+ const { headers, records } = await req.server['tokenManager'].findQuery(req.data(), req.runner)
9
9
  return reply.type('application/json').headers(headers).send(records)
10
10
  }
11
11
 
12
12
  export async function findOne(req: FastifyRequest, reply: FastifyReply) {
13
13
  const { id } = req.parameters()
14
14
 
15
- const token = await req.server['tokenManager'].retrieveTokenById(id)
15
+ const token = await req.server['tokenManager'].retrieveTokenById(id, req.runner)
16
16
  return token || reply.status(404).send()
17
17
  }
18
18
 
@@ -20,7 +20,7 @@ export async function create(req: FastifyRequest, reply: FastifyReply) {
20
20
  const data = req.data()
21
21
 
22
22
  if (!data.name) {
23
- return reply.status(404).send(new Error('Token name not valid'))
23
+ return reply.status(404).send({ statusCode: 404, error: 'Not Found', message: 'Token name not valid' })
24
24
  }
25
25
 
26
26
  // public is the default
@@ -30,9 +30,9 @@ export async function create(req: FastifyRequest, reply: FastifyReply) {
30
30
  data.roles.push(publicRole)
31
31
  }
32
32
 
33
- let token = await req.server['tokenManager'].createToken(data)
33
+ let token = await req.server['tokenManager'].createToken(data, req.runner)
34
34
  if (!token || !token.getId() || !token.externalId) {
35
- return reply.status(400).send(new Error('Token not registered'))
35
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Token not registered' })
36
36
  }
37
37
 
38
38
  const bearerToken = await reply.jwtSign(
@@ -42,10 +42,10 @@ export async function create(req: FastifyRequest, reply: FastifyReply) {
42
42
  }
43
43
  )
44
44
  if (!bearerToken) {
45
- return reply.status(400).send(new Error('Token not signed'))
45
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Token not signed' })
46
46
  }
47
47
 
48
- token = await req.server['tokenManager'].updateTokenById(token.getId(), { token: bearerToken })
48
+ token = await req.server['tokenManager'].updateTokenById(token.getId(), { token: bearerToken }, req.runner)
49
49
  return token
50
50
  }
51
51
 
@@ -55,12 +55,12 @@ export async function remove(req: FastifyRequest, reply: FastifyReply) {
55
55
  return reply.status(404).send()
56
56
  }
57
57
 
58
- let token = await req.server['tokenManager'].retrieveTokenById(id)
58
+ let token = await req.server['tokenManager'].retrieveTokenById(id, req.runner)
59
59
  if (!token) {
60
- return reply.status(403).send(new Error('Token not found'))
60
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Token not found' })
61
61
  }
62
62
 
63
- token = await req.server['tokenManager'].removeTokenById(id)
63
+ token = await req.server['tokenManager'].removeTokenById(id, req.runner)
64
64
  return { ok: true }
65
65
  }
66
66
 
@@ -70,13 +70,13 @@ export async function update(req: FastifyRequest, reply: FastifyReply) {
70
70
  return reply.status(404).send()
71
71
  }
72
72
 
73
- const token = await req.server['tokenManager'].retrieveTokenById(id)
73
+ const token = await req.server['tokenManager'].retrieveTokenById(id, req.runner)
74
74
  if (!token || !token.getId()) {
75
75
  return reply.status(404).send()
76
76
  }
77
77
 
78
78
  const data = req.data() || {}
79
- return req.server['tokenManager'].updateTokenById(token.getId(), data)
79
+ return req.server['tokenManager'].updateTokenById(token.getId(), data, req.runner)
80
80
  }
81
81
 
82
82
  export async function block(req: FastifyRequest, reply: FastifyReply) {
@@ -87,8 +87,8 @@ export async function block(req: FastifyRequest, reply: FastifyReply) {
87
87
  const { id: userId } = req.parameters()
88
88
  const { reason } = req.data()
89
89
 
90
- await req.server['tokenManager'].blockTokenById(userId, reason)
91
- const token = await req.server['tokenManager'].retrieveTokenById(userId)
90
+ await req.server['tokenManager'].blockTokenById(userId, reason, req.runner)
91
+ const token = await req.server['tokenManager'].retrieveTokenById(userId, req.runner)
92
92
  return { ok: !!token.getId() }
93
93
  }
94
94
 
@@ -100,7 +100,7 @@ export async function unblock(req: FastifyRequest, reply: FastifyReply) {
100
100
  }
101
101
 
102
102
  const { id: userId } = req.parameters()
103
- await req.server['tokenManager'].unblockTokenById(userId)
104
- const token = await req.server['tokenManager'].retrieveTokenById(userId)
103
+ await req.server['tokenManager'].unblockTokenById(userId, req.runner)
104
+ const token = await req.server['tokenManager'].retrieveTokenById(userId, req.runner)
105
105
  return { ok: !!token.getId() }
106
106
  }