@volcanicminds/backend 3.0.1 → 3.2.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/README.md +128 -27
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -9
- package/dist/index.js.map +1 -1
- package/dist/lib/api/admin/controller/manifest.d.ts +3 -0
- package/dist/lib/api/admin/controller/manifest.d.ts.map +1 -0
- package/dist/lib/api/admin/controller/manifest.js +6 -0
- package/dist/lib/api/admin/controller/manifest.js.map +1 -0
- package/dist/lib/api/admin/routes.d.ts +23 -0
- package/dist/lib/api/admin/routes.d.ts.map +1 -0
- package/dist/lib/api/admin/routes.js +26 -0
- package/dist/lib/api/admin/routes.js.map +1 -0
- package/dist/lib/api/auth/controller/auth.d.ts +1 -1
- package/dist/lib/api/auth/controller/auth.d.ts.map +1 -1
- package/dist/lib/api/auth/controller/auth.js +90 -66
- package/dist/lib/api/auth/controller/auth.js.map +1 -1
- package/dist/lib/api/auth/routes.d.ts +29 -0
- package/dist/lib/api/auth/routes.d.ts.map +1 -1
- package/dist/lib/api/auth/routes.js +10 -0
- package/dist/lib/api/auth/routes.js.map +1 -1
- package/dist/lib/api/token/controller/token.js +17 -17
- package/dist/lib/api/token/controller/token.js.map +1 -1
- package/dist/lib/api/users/controller/user.d.ts.map +1 -1
- package/dist/lib/api/users/controller/user.js +25 -19
- package/dist/lib/api/users/controller/user.js.map +1 -1
- package/dist/lib/api/users/routes.d.ts.map +1 -1
- package/dist/lib/api/users/routes.js.map +1 -1
- package/dist/lib/config/general.d.ts +3 -0
- package/dist/lib/config/general.d.ts.map +1 -1
- package/dist/lib/config/general.js +3 -0
- package/dist/lib/config/general.js.map +1 -1
- package/dist/lib/config/plugins.d.ts +17 -0
- package/dist/lib/config/plugins.d.ts.map +1 -1
- package/dist/lib/config/plugins.js +2 -2
- package/dist/lib/config/plugins.js.map +1 -1
- package/dist/lib/database/typeorm/embedded.d.ts +12 -0
- package/dist/lib/database/typeorm/embedded.d.ts.map +1 -0
- package/dist/lib/database/typeorm/embedded.js +92 -0
- package/dist/lib/database/typeorm/embedded.js.map +1 -0
- package/dist/lib/database/typeorm/entities/user.d.ts +1 -0
- package/dist/lib/database/typeorm/entities/user.d.ts.map +1 -1
- package/dist/lib/database/typeorm/entities/user.js.map +1 -1
- package/dist/lib/database/typeorm/loader/tenantManager.d.ts.map +1 -1
- package/dist/lib/database/typeorm/loader/tenantManager.js +4 -1
- package/dist/lib/database/typeorm/loader/tenantManager.js.map +1 -1
- package/dist/lib/database/typeorm/loader/userManager.d.ts.map +1 -1
- package/dist/lib/database/typeorm/loader/userManager.js +2 -1
- package/dist/lib/database/typeorm/loader/userManager.js.map +1 -1
- package/dist/lib/database/typeorm/query/operators.d.ts +10 -0
- package/dist/lib/database/typeorm/query/operators.d.ts.map +1 -0
- package/dist/lib/database/typeorm/query/operators.js +122 -0
- package/dist/lib/database/typeorm/query/operators.js.map +1 -0
- package/dist/lib/database/typeorm/query.d.ts +2 -0
- package/dist/lib/database/typeorm/query.d.ts.map +1 -1
- package/dist/lib/database/typeorm/query.js +29 -62
- package/dist/lib/database/typeorm/query.js.map +1 -1
- package/dist/lib/database/typeorm/util/crypto.d.ts.map +1 -1
- package/dist/lib/database/typeorm/util/crypto.js +50 -26
- package/dist/lib/database/typeorm/util/crypto.js.map +1 -1
- package/dist/lib/hooks/onRequest.d.ts.map +1 -1
- package/dist/lib/hooks/onRequest.js +15 -29
- package/dist/lib/hooks/onRequest.js.map +1 -1
- package/dist/lib/loader/general.d.ts.map +1 -1
- package/dist/lib/loader/general.js +3 -0
- package/dist/lib/loader/general.js.map +1 -1
- package/dist/lib/loader/router.d.ts +2 -0
- package/dist/lib/loader/router.d.ts.map +1 -1
- package/dist/lib/loader/router.js +11 -4
- package/dist/lib/loader/router.js.map +1 -1
- package/dist/lib/loader/tracking.d.ts.map +1 -1
- package/dist/lib/loader/tracking.js +6 -1
- package/dist/lib/loader/tracking.js.map +1 -1
- package/dist/lib/manifest/generator.d.ts +90 -0
- package/dist/lib/manifest/generator.d.ts.map +1 -0
- package/dist/lib/manifest/generator.js +274 -0
- package/dist/lib/manifest/generator.js.map +1 -0
- package/dist/lib/middleware/isAdmin.d.ts.map +1 -1
- package/dist/lib/middleware/isAdmin.js +2 -1
- package/dist/lib/middleware/isAdmin.js.map +1 -1
- package/dist/lib/middleware/isAuthenticated.d.ts.map +1 -1
- package/dist/lib/middleware/isAuthenticated.js +2 -1
- package/dist/lib/middleware/isAuthenticated.js.map +1 -1
- package/dist/lib/schemas/user.d.ts +17 -0
- package/dist/lib/schemas/user.d.ts.map +1 -1
- package/dist/lib/schemas/user.js +8 -1
- package/dist/lib/schemas/user.js.map +1 -1
- package/dist/lib/util/errors.d.ts.map +1 -1
- package/dist/lib/util/errors.js +3 -2
- package/dist/lib/util/errors.js.map +1 -1
- package/dist/lib/util/httpError.d.ts +8 -0
- package/dist/lib/util/httpError.d.ts.map +1 -0
- package/dist/lib/util/httpError.js +19 -0
- package/dist/lib/util/httpError.js.map +1 -0
- package/dist/typeorm.d.ts +3 -2
- package/dist/typeorm.d.ts.map +1 -1
- package/dist/typeorm.js +14 -2
- package/dist/typeorm.js.map +1 -1
- package/dist/types/global.d.ts +22 -1
- package/lib/api/admin/controller/manifest.ts +9 -0
- package/lib/api/admin/routes.ts +26 -0
- package/lib/api/auth/controller/auth.ts +113 -67
- package/lib/api/auth/routes.ts +17 -0
- package/lib/api/token/controller/token.ts +17 -17
- package/lib/api/users/controller/user.ts +32 -19
- package/lib/api/users/routes.ts +3 -0
- package/lib/config/general.ts +3 -0
- package/lib/config/plugins.ts +5 -2
- package/lib/database/typeorm/embedded.ts +173 -0
- package/lib/database/typeorm/entities/user.ts +2 -0
- package/lib/database/typeorm/loader/tenantManager.ts +8 -1
- package/lib/database/typeorm/loader/userManager.ts +6 -1
- package/lib/database/typeorm/query/operators.ts +187 -0
- package/lib/database/typeorm/query.ts +48 -73
- package/lib/database/typeorm/util/crypto.ts +89 -27
- package/lib/defaults/managers.ts +1 -1
- package/lib/hooks/onRequest.ts +17 -29
- package/lib/loader/general.ts +3 -0
- package/lib/loader/router.ts +16 -3
- package/lib/loader/tracking.ts +10 -1
- package/lib/manifest/generator.ts +345 -0
- package/lib/middleware/isAdmin.ts +3 -1
- package/lib/middleware/isAuthenticated.ts +4 -1
- package/lib/schemas/user.ts +17 -1
- package/lib/util/errors.ts +4 -2
- package/lib/util/httpError.ts +37 -0
- package/package.json +29 -4
package/dist/types/global.d.ts
CHANGED
|
@@ -37,6 +37,15 @@ export interface Data {
|
|
|
37
37
|
[option: string]: any
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Structural hints (manifest L1) — domain-only, optional, additive. Usually declared
|
|
41
|
+
// at the file-level `config` of a routes.ts (one file ≈ one resource), overridable per-route.
|
|
42
|
+
export interface ResourceHints {
|
|
43
|
+
name?: string // canonical resource name; maps schemas → resource without heuristics
|
|
44
|
+
titleField?: string | string[]
|
|
45
|
+
subtitleField?: string | string[]
|
|
46
|
+
globalSearch?: string[] // omni-search fields (OR)
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
export interface RouteConfig {
|
|
41
50
|
title: string
|
|
42
51
|
description: string
|
|
@@ -52,6 +61,8 @@ export interface RouteConfig {
|
|
|
52
61
|
response?: any
|
|
53
62
|
consumes?: any
|
|
54
63
|
rawBody?: boolean
|
|
64
|
+
group?: string // sidebar group hint (manifest)
|
|
65
|
+
resource?: ResourceHints // resource-level manifest hints
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
export interface Route {
|
|
@@ -84,6 +95,10 @@ export interface GeneralConfig {
|
|
|
84
95
|
header_key?: string
|
|
85
96
|
query_key?: string
|
|
86
97
|
}
|
|
98
|
+
// Admin manifest capability (opt-in): exposes GET /admin/manifest
|
|
99
|
+
manifest?: {
|
|
100
|
+
enabled: boolean
|
|
101
|
+
}
|
|
87
102
|
}
|
|
88
103
|
}
|
|
89
104
|
|
|
@@ -134,6 +149,8 @@ export interface ConfiguredRoute {
|
|
|
134
149
|
response?: any
|
|
135
150
|
consumes?: any
|
|
136
151
|
}
|
|
152
|
+
group?: string // structural hint (manifest)
|
|
153
|
+
resource?: ResourceHints // structural hints (manifest)
|
|
137
154
|
}
|
|
138
155
|
|
|
139
156
|
export interface TrackChanges {
|
|
@@ -211,7 +228,10 @@ export interface DataBaseManagement {
|
|
|
211
228
|
|
|
212
229
|
export interface MfaManagement {
|
|
213
230
|
generateSetup(appName: string, email: string): Promise<{ secret: string; uri: string; qrCode: string }>
|
|
214
|
-
|
|
231
|
+
// Returns the matched time-step delta (integer) when valid, or null when invalid.
|
|
232
|
+
// The delta enables anti-replay protection (track the consumed step). Legacy managers returning a
|
|
233
|
+
// boolean are still tolerated at runtime (treated as valid/invalid without replay tracking).
|
|
234
|
+
verify(token: string, secret: string): number | null
|
|
215
235
|
}
|
|
216
236
|
|
|
217
237
|
// Callback type signature: (uploadOrId, req, res) => void
|
|
@@ -313,6 +333,7 @@ declare global {
|
|
|
313
333
|
var connection: any
|
|
314
334
|
var entity: any
|
|
315
335
|
var repository: any
|
|
336
|
+
var routes: ConfiguredRoute[]
|
|
316
337
|
}
|
|
317
338
|
|
|
318
339
|
export { global }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { FastifyReply, FastifyRequest } from 'fastify'
|
|
2
|
+
import { generateManifest } from '../../../manifest/generator.js'
|
|
3
|
+
|
|
4
|
+
// GET /admin/manifest — full Manifest v2 (declared roles). Gating is enforced by the
|
|
5
|
+
// route (roles:[admin] + isAuthenticated); the manifest itself is not per-user.
|
|
6
|
+
export function get(req: FastifyRequest, reply: FastifyReply) {
|
|
7
|
+
const server = (req.server as any) || global.server
|
|
8
|
+
return reply.send(generateManifest(server))
|
|
9
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const { manifest } = global.config?.options || {}
|
|
2
|
+
const isEnabled = manifest?.enabled || false
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
config: {
|
|
6
|
+
title: 'Admin',
|
|
7
|
+
description: 'Backoffice support: manifest descriptor (admin scope)',
|
|
8
|
+
controller: 'controller',
|
|
9
|
+
enable: isEnabled, // opt-in via config.options.manifest.enabled
|
|
10
|
+
tenantContext: false,
|
|
11
|
+
tags: ['admin']
|
|
12
|
+
},
|
|
13
|
+
routes: [
|
|
14
|
+
{
|
|
15
|
+
method: 'GET',
|
|
16
|
+
path: '/manifest',
|
|
17
|
+
roles: [roles.admin],
|
|
18
|
+
handler: 'manifest.get',
|
|
19
|
+
middlewares: ['global.isAuthenticated'],
|
|
20
|
+
config: {
|
|
21
|
+
title: 'Admin manifest',
|
|
22
|
+
description: 'Manifest v2 describing the admin-manageable API (full, with declared roles).'
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
417
|
-
if (!
|
|
418
|
-
return reply.status(400).send(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
481
|
-
if (!
|
|
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(
|
|
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(
|
|
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(
|
|
563
|
+
return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'Failed to disable MFA' })
|
|
518
564
|
}
|
|
519
565
|
}
|
package/lib/api/auth/routes.ts
CHANGED
|
@@ -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',
|