@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.
- package/README.md +129 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -9
- package/dist/index.js.map +1 -1
- 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/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/router.d.ts +2 -0
- package/dist/lib/loader/router.d.ts.map +1 -1
- package/dist/lib/loader/router.js +7 -3
- 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/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 +4 -1
- 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/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/router.ts +13 -4
- package/lib/loader/tracking.ts +10 -1
- 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 +31 -5
|
@@ -1,24 +1,11 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
/* eslint-disable prefer-const */
|
|
3
|
-
import {
|
|
4
|
-
Not,
|
|
5
|
-
Like,
|
|
6
|
-
ILike,
|
|
7
|
-
Raw,
|
|
8
|
-
Equal,
|
|
9
|
-
IsNull,
|
|
10
|
-
In,
|
|
11
|
-
Between,
|
|
12
|
-
MoreThan,
|
|
13
|
-
MoreThanOrEqual,
|
|
14
|
-
LessThan,
|
|
15
|
-
LessThanOrEqual,
|
|
16
|
-
QueryRunner
|
|
17
|
-
} from 'typeorm'
|
|
3
|
+
import { QueryRunner } from 'typeorm'
|
|
18
4
|
import yn from './util/yn.js'
|
|
19
5
|
import * as log from './util/logger.js'
|
|
20
6
|
import { parseLogicExpression } from './query/parser.js'
|
|
21
7
|
import { buildWhereFromAst } from './query/builder.js'
|
|
8
|
+
import { buildReservedOperators } from './query/operators.js'
|
|
22
9
|
|
|
23
10
|
let sensitiveFields = ['password', 'mfaSecret', 'resetPasswordToken', 'confirmationToken']
|
|
24
11
|
|
|
@@ -29,12 +16,34 @@ export const configureSensitiveFields = (fields: string[]) => {
|
|
|
29
16
|
}
|
|
30
17
|
}
|
|
31
18
|
|
|
32
|
-
|
|
19
|
+
// When true, the base text operators (eq, contains, starts, ends, like and their
|
|
20
|
+
// negations) are CASE-INSENSITIVE for string values; the `*s` variants force
|
|
21
|
+
// case-sensitive. When false, the legacy behavior is restored (base = sensitive,
|
|
22
|
+
// `*i` variants = insensitive). Default: true (matches the common expectation
|
|
23
|
+
// that `?name=mario` finds "Mario"). Override per-app via the data-layer option
|
|
24
|
+
// `caseInsensitiveByDefault` or the env `VOLCANIC_CASE_INSENSITIVE_DEFAULT`.
|
|
25
|
+
let caseInsensitiveByDefault = yn(process.env.VOLCANIC_CASE_INSENSITIVE_DEFAULT, true)
|
|
26
|
+
|
|
27
|
+
export const configureCaseInsensitiveDefault = (flag: boolean) => {
|
|
28
|
+
caseInsensitiveByDefault = !!flag
|
|
29
|
+
log.info(`Volcanic-TypeORM: caseInsensitiveByDefault = ${caseInsensitiveByDefault}`)
|
|
30
|
+
}
|
|
33
31
|
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
// Upper bound for how many rows a single Magic Query can return. Prevents a caller
|
|
33
|
+
// from requesting `?take=10000000` and exhausting memory/DB (OWASP API4). Default
|
|
34
|
+
// 100; override per-app via the data-layer option `maxPageSize` or the env
|
|
35
|
+
// `VOLCANIC_MAX_PAGE_SIZE`. A value <= 0 disables the cap (not recommended).
|
|
36
|
+
let maxPageSize = Math.floor(Number(process.env.VOLCANIC_MAX_PAGE_SIZE) || 100)
|
|
37
|
+
|
|
38
|
+
export const configureMaxPageSize = (size: number) => {
|
|
39
|
+
if (typeof size === 'number' && Number.isFinite(size)) {
|
|
40
|
+
maxPageSize = Math.floor(size)
|
|
41
|
+
log.info(`Volcanic-TypeORM: maxPageSize = ${maxPageSize}`)
|
|
42
|
+
}
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
const evalOrder = (val: string = '') => (['desc', 'd', 'false', '1'].includes(val.toLowerCase()) ? 'desc' : 'asc')
|
|
46
|
+
|
|
38
47
|
const isValidIdentifier = (str: string) => /^[a-zA-Z0-9_.]+$/.test(str)
|
|
39
48
|
|
|
40
49
|
const hasProtoRisk = (str: string) => ['__proto__', 'constructor', 'prototype'].includes(str)
|
|
@@ -76,63 +85,26 @@ export const useOrder = (order: string[] = []) => {
|
|
|
76
85
|
return result
|
|
77
86
|
}
|
|
78
87
|
|
|
79
|
-
const typecastValue = (value: any) => {
|
|
80
|
-
if (typeof value !== 'string') return value
|
|
81
|
-
const lowerValue = value.toLowerCase()
|
|
82
|
-
if (lowerValue === 'true') return true
|
|
83
|
-
if (lowerValue === 'false') return false
|
|
84
|
-
if (lowerValue === 'null') return null
|
|
85
|
-
return value
|
|
86
|
-
}
|
|
87
|
-
|
|
88
88
|
export const useWhere = (where: any, repo?: any) => {
|
|
89
89
|
const aliasMap = new Map<string, any>()
|
|
90
90
|
const isTargetMongo = isMongo(repo)
|
|
91
|
-
const val = (v) => v || 'notFound'
|
|
92
|
-
|
|
93
|
-
const reservedOperators = {
|
|
94
|
-
':null': (v) => (typecastValue(v) === false ? Not(IsNull()) : IsNull()),
|
|
95
|
-
':notNull': (v) => (typecastValue(v) === true ? Not(IsNull()) : IsNull()),
|
|
96
|
-
':in': (v) => In(val(v).split(',').map(typecastValue)),
|
|
97
|
-
':nin': (v) => Not(In(val(v).split(',').map(typecastValue))),
|
|
98
|
-
':likei': (v) => (isTargetMongo ? new RegExp(escapeRegExp(val(v)), 'i') : ILike(`%${val(v)}%`)),
|
|
99
|
-
':containsi': (v) => (isTargetMongo ? new RegExp(escapeRegExp(val(v)), 'i') : ILike(`%${val(v)}%`)),
|
|
100
|
-
':ncontainsi': (v) => (isTargetMongo ? Not(new RegExp(escapeRegExp(val(v)), 'i')) : Not(ILike(`%${val(v)}%`))),
|
|
101
|
-
':startsi': (v) => (isTargetMongo ? new RegExp(`^${escapeRegExp(val(v))}`, 'i') : ILike(`${val(v)}%`)),
|
|
102
|
-
':endsi': (v) => (isTargetMongo ? new RegExp(`${escapeRegExp(val(v))}$`, 'i') : ILike(`%${val(v)}`)),
|
|
103
|
-
':eqi': (v) => (isTargetMongo ? new RegExp(`^${escapeRegExp(val(v))}$`, 'i') : ILike(v)),
|
|
104
|
-
':neqi': (v) => (isTargetMongo ? Not(new RegExp(`^${escapeRegExp(val(v))}$`, 'i')) : Not(ILike(v))),
|
|
105
|
-
':like': (v) => Like(`${val(v)}`),
|
|
106
|
-
':contains': (v) => Like(`%${val(v)}%`),
|
|
107
|
-
':ncontains': (v) => Not(Like(`%${val(v)}%`)),
|
|
108
|
-
':starts': (v) => Like(`${val(v)}%`),
|
|
109
|
-
':ends': (v) => Like(`%${val(v)}`),
|
|
110
|
-
':eq': (v) => {
|
|
111
|
-
const typedValue = typecastValue(v)
|
|
112
|
-
if (typedValue === null) return IsNull()
|
|
113
|
-
return isTargetMongo ? typedValue : Equal(typedValue)
|
|
114
|
-
},
|
|
115
|
-
':neq': (v) => Not(Equal(typecastValue(v))),
|
|
116
|
-
':gt': (v) => MoreThan(v),
|
|
117
|
-
':ge': (v) => MoreThanOrEqual(v),
|
|
118
|
-
':lt': (v) => LessThan(v),
|
|
119
|
-
':le': (v) => LessThanOrEqual(v),
|
|
120
|
-
':between': (v) => {
|
|
121
|
-
const s = v?.split(':')
|
|
122
|
-
return s?.length == 2 ? Between(s[0], s[1]) : v
|
|
123
|
-
},
|
|
124
|
-
':overlap': (v) => {
|
|
125
|
-
const values = val(v).split(',').map(typecastValue)
|
|
126
|
-
if (isTargetMongo) {
|
|
127
|
-
return { $in: values }
|
|
128
|
-
}
|
|
129
|
-
return Raw((alias) => `${alias} && ARRAY[:...overlapValues]::text[]`, { overlapValues: values })
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
91
|
|
|
133
|
-
|
|
92
|
+
const allowRaw = yn(process.env.VOLCANIC_CUSTOM_QUERY_OPERATORS, false)
|
|
93
|
+
if (allowRaw) {
|
|
134
94
|
log.warn('Volcanic-TypeORM: Custom query operators (:raw) enabled. SECURITY RISK!')
|
|
135
|
-
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const reservedOperators = buildReservedOperators({
|
|
98
|
+
isTargetMongo,
|
|
99
|
+
caseInsensitive: caseInsensitiveByDefault,
|
|
100
|
+
allowRaw
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Operator NAMES are case-insensitive: `:isEmpty`, `:isempty`, `:ISEMPTY` all
|
|
104
|
+
// resolve to the same handler via this lowercased lookup.
|
|
105
|
+
const operatorLookup: Record<string, (v: any) => any> = {}
|
|
106
|
+
for (const k of Object.keys(reservedOperators)) {
|
|
107
|
+
operatorLookup[k.toLowerCase()] = reservedOperators[k]
|
|
136
108
|
}
|
|
137
109
|
|
|
138
110
|
const reservedWords = Object.keys(reservedOperators).join('|')
|
|
@@ -158,6 +130,7 @@ export const useWhere = (where: any, repo?: any) => {
|
|
|
158
130
|
const m = key.match(new RegExp(`(${reservedWords})\\b`, 'ig'))
|
|
159
131
|
const operator = m?.length ? m[0] : ':eq'
|
|
160
132
|
const fullPath = key.replace(operator, '')
|
|
133
|
+
const handler = operatorLookup[operator.toLowerCase()]
|
|
161
134
|
|
|
162
135
|
if (!isValidIdentifier(fullPath)) {
|
|
163
136
|
log.warn(`Volcanic-TypeORM: Invalid filter field skipped: ${fullPath}`)
|
|
@@ -171,8 +144,8 @@ export const useWhere = (where: any, repo?: any) => {
|
|
|
171
144
|
}
|
|
172
145
|
|
|
173
146
|
let value = where[rawKey]
|
|
174
|
-
if (
|
|
175
|
-
value =
|
|
147
|
+
if (handler) {
|
|
148
|
+
value = handler(value)
|
|
176
149
|
}
|
|
177
150
|
|
|
178
151
|
let condition = {}
|
|
@@ -200,7 +173,9 @@ export const useWhere = (where: any, repo?: any) => {
|
|
|
200
173
|
export function applyQuery(data, extraWhere, repo) {
|
|
201
174
|
const { page: p = 1, pageSize = 25, skip: sk = 0, take: tk = 0, sort: s, _logic, ...where } = data
|
|
202
175
|
const page: number = (p < 1 ? 1 : p) - 1
|
|
203
|
-
|
|
176
|
+
// Clamp the requested page size to maxPageSize (resource-consumption guard).
|
|
177
|
+
const requestedTake: number = tk || pageSize
|
|
178
|
+
const take: number = maxPageSize > 0 ? Math.min(requestedTake, maxPageSize) : requestedTake
|
|
204
179
|
const skip: number = sk || page * pageSize
|
|
205
180
|
const order: string[] = !Array.isArray(s) ? [s] : s
|
|
206
181
|
|
|
@@ -1,51 +1,113 @@
|
|
|
1
1
|
import * as crypto from 'crypto'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
const
|
|
3
|
+
// Authenticated cipher used for all NEW data.
|
|
4
|
+
const ALGORITHM_GCM = 'aes-256-gcm'
|
|
5
|
+
// Legacy, unauthenticated cipher: only ever READ for backward compatibility, never written.
|
|
6
|
+
const ALGORITHM_CBC_LEGACY = 'aes-256-cbc'
|
|
7
|
+
|
|
5
8
|
const SECRET_KEY = process.env.MFA_DB_SECRET || process.env.JWT_SECRET
|
|
6
|
-
const IV_LENGTH_NEW = 12
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
// Current (v2) format parameters.
|
|
11
|
+
const VERSION = 'v2'
|
|
12
|
+
const IV_LENGTH = 12 // GCM standard nonce length
|
|
13
|
+
const SALT_LENGTH = 16 // per-record random salt for the KDF
|
|
14
|
+
const KEY_LENGTH = 32 // AES-256
|
|
15
|
+
// scrypt cost parameters (N must be a power of 2). N=2^15 keeps derivation well under ~50ms.
|
|
16
|
+
const SCRYPT_N = 32768
|
|
17
|
+
const SCRYPT_r = 8
|
|
18
|
+
const SCRYPT_p = 1
|
|
19
|
+
// scrypt memory cost is ~128*N*r bytes (~33.5MB here), above Node's 32MB default → raise maxmem.
|
|
20
|
+
const SCRYPT_MAXMEM = 64 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
function getSecret(): string {
|
|
9
23
|
if (!SECRET_KEY) {
|
|
10
24
|
throw new Error('Secret key is not defined in environment variables.')
|
|
11
25
|
}
|
|
12
|
-
return
|
|
26
|
+
return String(SECRET_KEY)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Robust per-record key derivation: scrypt(secret, random salt). Each record carries its own salt,
|
|
31
|
+
* so identical plaintexts never share a key and the derivation follows a memory-hard standard.
|
|
32
|
+
*/
|
|
33
|
+
function deriveKey(salt: Buffer): Buffer {
|
|
34
|
+
return crypto.scryptSync(getSecret(), salt, KEY_LENGTH, {
|
|
35
|
+
N: SCRYPT_N,
|
|
36
|
+
r: SCRYPT_r,
|
|
37
|
+
p: SCRYPT_p,
|
|
38
|
+
maxmem: SCRYPT_MAXMEM
|
|
39
|
+
})
|
|
13
40
|
}
|
|
14
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Legacy key derivation (weak, no salt): sha256(secret) -> base64 -> first 32 chars used as raw key bytes.
|
|
44
|
+
* Kept ONLY to decrypt records written before the v2 format. Never used to encrypt new data.
|
|
45
|
+
*/
|
|
46
|
+
function legacyKey(): Buffer {
|
|
47
|
+
return Buffer.from(crypto.createHash('sha256').update(getSecret()).digest('base64').substring(0, KEY_LENGTH))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Encrypts a plaintext into the authenticated, versioned format: `v2:salt:iv:authTag:ciphertext` (hex).
|
|
52
|
+
*/
|
|
15
53
|
export function encrypt(text: string): string {
|
|
16
54
|
if (!text) return text
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
55
|
+
|
|
56
|
+
const salt = crypto.randomBytes(SALT_LENGTH)
|
|
57
|
+
const iv = crypto.randomBytes(IV_LENGTH)
|
|
58
|
+
const key = deriveKey(salt)
|
|
59
|
+
|
|
60
|
+
const cipher = crypto.createCipheriv(ALGORITHM_GCM, key, iv)
|
|
61
|
+
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
|
|
21
62
|
const authTag = cipher.getAuthTag()
|
|
22
|
-
|
|
63
|
+
|
|
64
|
+
return [VERSION, salt.toString('hex'), iv.toString('hex'), authTag.toString('hex'), encrypted.toString('hex')].join(
|
|
65
|
+
':'
|
|
66
|
+
)
|
|
23
67
|
}
|
|
24
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Decrypts a value produced by any supported format:
|
|
71
|
+
* - v2 (current): `v2:salt:iv:authTag:ciphertext` -> scrypt key + GCM
|
|
72
|
+
* - legacy GCM: `iv:authTag:ciphertext` -> legacy key + GCM
|
|
73
|
+
* - legacy CBC: `iv:ciphertext` -> legacy key + CBC (unauthenticated, read-only)
|
|
74
|
+
* Tampering with a GCM/v2 record throws (auth tag mismatch).
|
|
75
|
+
*/
|
|
25
76
|
export function decrypt(text: string): string {
|
|
26
77
|
if (!text) return text
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
78
|
+
|
|
79
|
+
const parts = text.split(':')
|
|
80
|
+
|
|
81
|
+
// Current versioned format.
|
|
82
|
+
if (parts.length === 5 && parts[0] === VERSION) {
|
|
83
|
+
const salt = Buffer.from(parts[1], 'hex')
|
|
84
|
+
const iv = Buffer.from(parts[2], 'hex')
|
|
85
|
+
const authTag = Buffer.from(parts[3], 'hex')
|
|
86
|
+
const encryptedText = Buffer.from(parts[4], 'hex')
|
|
87
|
+
|
|
88
|
+
const decipher = crypto.createDecipheriv(ALGORITHM_GCM, deriveKey(salt), iv)
|
|
89
|
+
decipher.setAuthTag(authTag)
|
|
90
|
+
return decipher.update(encryptedText, undefined, 'utf8') + decipher.final('utf8')
|
|
36
91
|
}
|
|
37
92
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
93
|
+
// Legacy authenticated GCM (pre-v2): iv:authTag:ciphertext with the old key derivation.
|
|
94
|
+
if (parts.length === 3) {
|
|
95
|
+
const iv = Buffer.from(parts[0], 'hex')
|
|
96
|
+
const authTag = Buffer.from(parts[1], 'hex')
|
|
97
|
+
const encryptedText = Buffer.from(parts[2], 'hex')
|
|
42
98
|
|
|
43
|
-
const decipher = crypto.createDecipheriv(
|
|
99
|
+
const decipher = crypto.createDecipheriv(ALGORITHM_GCM, legacyKey(), iv)
|
|
44
100
|
decipher.setAuthTag(authTag)
|
|
101
|
+
return decipher.update(encryptedText, undefined, 'utf8') + decipher.final('utf8')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Legacy unauthenticated CBC (oldest records): iv:ciphertext with the old key derivation. Read-only.
|
|
105
|
+
if (parts.length === 2) {
|
|
106
|
+
const iv = Buffer.from(parts[0], 'hex')
|
|
107
|
+
const encryptedText = Buffer.from(parts[1], 'hex')
|
|
45
108
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return decrypted
|
|
109
|
+
const decipher = crypto.createDecipheriv(ALGORITHM_CBC_LEGACY, legacyKey(), iv)
|
|
110
|
+
return Buffer.concat([decipher.update(encryptedText), decipher.final()]).toString()
|
|
49
111
|
}
|
|
50
112
|
|
|
51
113
|
return text
|
package/lib/defaults/managers.ts
CHANGED
|
@@ -182,7 +182,7 @@ export const defaultMfaManager: MfaManagement = {
|
|
|
182
182
|
generateSetup(_appName: string, _email: string) {
|
|
183
183
|
throw new Error('Not implemented.')
|
|
184
184
|
},
|
|
185
|
-
verify(_token: string, _secret: string) {
|
|
185
|
+
verify(_token: string, _secret: string): number | null {
|
|
186
186
|
throw new Error('Not implemented.')
|
|
187
187
|
}
|
|
188
188
|
}
|
package/lib/hooks/onRequest.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { getParams, getData } from '../util/common.js'
|
|
3
|
+
import { httpError } from '../util/httpError.js'
|
|
3
4
|
import type { AuthenticatedUser, AuthenticatedToken, Role, TransferManagement } from '../../types/global.js'
|
|
4
5
|
|
|
5
6
|
const MFA_SETUP_WHITELIST = ['/auth/mfa/setup', '/auth/mfa/enable', '/auth/mfa/verify', '/auth/logout']
|
|
@@ -78,18 +79,10 @@ export default async (req, reply) => {
|
|
|
78
79
|
const { multi_tenant } = global.config?.options || {}
|
|
79
80
|
if (multi_tenant?.enabled && cfg.tenantContext !== false) {
|
|
80
81
|
if (!req.tenant || !tokenData.tid) {
|
|
81
|
-
return reply.status(403).send(
|
|
82
|
-
statusCode: 403,
|
|
83
|
-
code: 'TENANT_NOT_FOUND',
|
|
84
|
-
message: 'Token does not belong to this tenant'
|
|
85
|
-
})
|
|
82
|
+
return reply.status(403).send(httpError(403, 'Token does not belong to this tenant', 'TENANT_NOT_FOUND'))
|
|
86
83
|
}
|
|
87
84
|
if (tokenData.tid !== req.tenant.id) {
|
|
88
|
-
return reply.status(403).send(
|
|
89
|
-
statusCode: 403,
|
|
90
|
-
code: 'TENANT_MISMATCH',
|
|
91
|
-
message: 'Token does not belong to this tenant'
|
|
92
|
-
})
|
|
85
|
+
return reply.status(403).send(httpError(403, 'Token does not belong to this tenant', 'TENANT_MISMATCH'))
|
|
93
86
|
}
|
|
94
87
|
}
|
|
95
88
|
|
|
@@ -100,11 +93,9 @@ export default async (req, reply) => {
|
|
|
100
93
|
|
|
101
94
|
if (!isAllowed) {
|
|
102
95
|
if (log.w) log.warn(`Security Block: User attempted to access ${currentUrl} with pre-auth MFA token`)
|
|
103
|
-
return reply
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
message: 'MFA verification or setup required to access this resource'
|
|
107
|
-
})
|
|
96
|
+
return reply
|
|
97
|
+
.status(403)
|
|
98
|
+
.send(httpError(403, 'MFA verification or setup required to access this resource', 'MFA_REQUIRED'))
|
|
108
99
|
}
|
|
109
100
|
}
|
|
110
101
|
|
|
@@ -122,9 +113,7 @@ export default async (req, reply) => {
|
|
|
122
113
|
if (user) {
|
|
123
114
|
const isValid = await req.server['userManager'].isValidUser(user)
|
|
124
115
|
if (!isValid) {
|
|
125
|
-
return reply
|
|
126
|
-
.status(403)
|
|
127
|
-
.send({ statusCode: 403, code: 'USER_NOT_VALID', message: 'User is not valid or blocked' })
|
|
116
|
+
return reply.status(403).send(httpError(403, 'User is not valid or blocked', 'USER_NOT_VALID'))
|
|
128
117
|
}
|
|
129
118
|
req.user = user
|
|
130
119
|
}
|
|
@@ -135,16 +124,14 @@ export default async (req, reply) => {
|
|
|
135
124
|
if (token) {
|
|
136
125
|
const isValid = await req.server['tokenManager'].isValidToken(token)
|
|
137
126
|
if (!isValid) {
|
|
138
|
-
return reply
|
|
139
|
-
.status(403)
|
|
140
|
-
.send({ statusCode: 403, code: 'TOKEN_NOT_VALID', message: 'Token is not valid or blocked' })
|
|
127
|
+
return reply.status(403).send(httpError(403, 'Token is not valid or blocked', 'TOKEN_NOT_VALID'))
|
|
141
128
|
}
|
|
142
129
|
req.token = token
|
|
143
130
|
}
|
|
144
131
|
}
|
|
145
132
|
|
|
146
133
|
if (!req.user && !req.token) {
|
|
147
|
-
return reply.status(404).send(
|
|
134
|
+
return reply.status(404).send(httpError(404, 'Subject not found', 'SUBJECT_NOT_FOUND'))
|
|
148
135
|
}
|
|
149
136
|
|
|
150
137
|
const freshNormalizedRoles = normalizeRoles(req.user?.roles || req.token?.roles)
|
|
@@ -152,11 +139,7 @@ export default async (req, reply) => {
|
|
|
152
139
|
} catch (error) {
|
|
153
140
|
const isRoutePublic = (cfg.requiredRoles || []).some((role: Role) => role.code === roles.public.code)
|
|
154
141
|
if (!isRoutePublic) {
|
|
155
|
-
return reply.status(401).send(
|
|
156
|
-
statusCode: 401,
|
|
157
|
-
code: 'UNAUTHORIZED',
|
|
158
|
-
message: (error as any)?.message || 'Invalid or expired token'
|
|
159
|
-
})
|
|
142
|
+
return reply.status(401).send(httpError(401, (error as any)?.message || 'Invalid or expired token', 'UNAUTHORIZED'))
|
|
160
143
|
}
|
|
161
144
|
}
|
|
162
145
|
}
|
|
@@ -167,8 +150,13 @@ export default async (req, reply) => {
|
|
|
167
150
|
const hasPermission = requiredRoles.some((r) => authorizedRoles.includes(r.code))
|
|
168
151
|
|
|
169
152
|
if (!hasPermission) {
|
|
170
|
-
|
|
171
|
-
|
|
153
|
+
// 401 when there is no authenticated subject (must log in first); 403 when
|
|
154
|
+
// authenticated but lacking the required role.
|
|
155
|
+
const anonymous = !req.user && !req.token
|
|
156
|
+
if (log.w) log.warn(`Denied: ${req.user?.email || 'anonymous'} cannot call ${method.toUpperCase()} ${url}`)
|
|
157
|
+
return anonymous
|
|
158
|
+
? reply.status(401).send(httpError(401, 'Authentication required', 'UNAUTHORIZED'))
|
|
159
|
+
: reply.status(403).send(httpError(403, 'Authorization denied', 'FORBIDDEN'))
|
|
172
160
|
}
|
|
173
161
|
}
|
|
174
162
|
}
|
package/lib/loader/router.ts
CHANGED
|
@@ -53,16 +53,19 @@ async function loadMiddleware(base: string, middleware: string = '') {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
async function loadMiddlewares(base: string, middlewares: string[] = []) {
|
|
56
|
-
const midds = {}
|
|
56
|
+
const midds: { [key: string]: any[] } = {}
|
|
57
57
|
for (const m of middlewares) {
|
|
58
58
|
const middleware = await loadMiddleware(base, m)
|
|
59
|
-
|
|
59
|
+
for (const name in middleware) {
|
|
60
|
+
if (!midds[name]) midds[name] = []
|
|
61
|
+
midds[name].push(middleware[name])
|
|
62
|
+
}
|
|
60
63
|
}
|
|
61
64
|
return midds
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
|
|
65
|
-
function processRoute(
|
|
68
|
+
export function processRoute(
|
|
66
69
|
route: Route,
|
|
67
70
|
index: number,
|
|
68
71
|
file: string,
|
|
@@ -87,6 +90,9 @@ function processRoute(
|
|
|
87
90
|
let requiredRoles: Role[] = []
|
|
88
91
|
|
|
89
92
|
try {
|
|
93
|
+
// `admin` is a global superuser: it is appended to EVERY route's allowed roles,
|
|
94
|
+
// so an admin can access any endpoint (even ones restricted to e.g. backoffice).
|
|
95
|
+
// This is intentional. Remove this append if admin should NOT be a universal role.
|
|
90
96
|
requiredRoles = rsp.some((r) => r.code === roles.admin.code) ? rsp : [...rsp, roles.admin]
|
|
91
97
|
} catch (err) {
|
|
92
98
|
if (log.e) log.error(`Error in loading roles for ${methodCase} ${pathName} (${handler})`)
|
|
@@ -129,7 +135,10 @@ function processRoute(
|
|
|
129
135
|
if (!methods.includes(method)) errors.push(`Error in [${file}] bad method [${method}] at route n. ${num}`)
|
|
130
136
|
if (handlerParts.length !== 2) errors.push(`Error in [${file}] bad handler [${handler}] at route n. ${num}`)
|
|
131
137
|
|
|
132
|
-
|
|
138
|
+
// Stored routes keep a leading slash in `path` ('/' + endpoint), so the key
|
|
139
|
+
// must include it too — otherwise 'GETusers' never equals 'GET/users' and the
|
|
140
|
+
// duplicate check silently never fires.
|
|
141
|
+
const key = `${method}/${endpoint}${version}`
|
|
133
142
|
if (validRoutes.some((r) => `${r.method}${r.path}${r.doc?.version}` === key)) {
|
|
134
143
|
errors.push(`Error in [${file}] duplicated path [${pathName}] at route n. ${num}`)
|
|
135
144
|
}
|
package/lib/loader/tracking.ts
CHANGED
|
@@ -25,7 +25,16 @@ export async function load() {
|
|
|
25
25
|
|
|
26
26
|
if (enableAll) {
|
|
27
27
|
changes.forEach((change) => {
|
|
28
|
-
|
|
28
|
+
// `enable: true` default must be PERSISTED: the runtime gate
|
|
29
|
+
// (getTrackingConfigIfEnabled) checks `tc.enable`, so a change entry
|
|
30
|
+
// without an explicit `enable` would otherwise never track despite
|
|
31
|
+
// isValid() treating it as enabled.
|
|
32
|
+
const tc: TrackChanges = {
|
|
33
|
+
primaryKey: primaryKey,
|
|
34
|
+
changeEntity: changeEntity,
|
|
35
|
+
enable: true,
|
|
36
|
+
...change
|
|
37
|
+
} as TrackChanges
|
|
29
38
|
const code = getCodeBy(tc.method, tc.path)
|
|
30
39
|
|
|
31
40
|
if (code in trackChangesList) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { FastifyReply, FastifyRequest } from 'fastify'
|
|
3
|
+
import { httpError } from '../util/httpError.js'
|
|
3
4
|
|
|
4
5
|
export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
|
|
5
6
|
try {
|
|
@@ -10,6 +11,7 @@ export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
|
|
|
10
11
|
throw new Error('User without this privilege')
|
|
11
12
|
} catch (err) {
|
|
12
13
|
if (log.e) log.error(`Upps, something just happened ${err}`)
|
|
13
|
-
|
|
14
|
+
// Structured body so the 403 status is preserved (see isAuthenticated note).
|
|
15
|
+
res.code(403).send(httpError(403, 'User without this privilege', 'FORBIDDEN'))
|
|
14
16
|
}
|
|
15
17
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { FastifyReply, FastifyRequest } from 'fastify'
|
|
3
|
+
import { httpError } from '../util/httpError.js'
|
|
3
4
|
|
|
4
5
|
export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
|
|
5
6
|
try {
|
|
@@ -10,6 +11,8 @@ export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
|
|
|
10
11
|
throw new Error('Unauthorized')
|
|
11
12
|
} catch (err) {
|
|
12
13
|
if (log.e) log.error(`Upps, something just happened ${err}`)
|
|
13
|
-
|
|
14
|
+
// Send a structured body (not a raw Error): `reply.code(x).send(new Error())`
|
|
15
|
+
// loses the status in Fastify's async error path and collapses to 500/403.
|
|
16
|
+
return res.code(401).send(httpError(401, 'Unauthorized', 'UNAUTHORIZED')) // must be authorized first
|
|
14
17
|
}
|
|
15
18
|
}
|
package/lib/schemas/user.ts
CHANGED
|
@@ -2,8 +2,15 @@ export const currentUserBodySchema = {
|
|
|
2
2
|
$id: 'currentUserBodySchema',
|
|
3
3
|
type: 'object',
|
|
4
4
|
nullable: true,
|
|
5
|
+
// Self-service profile edit only. `additionalProperties: false` rejects any other
|
|
6
|
+
// field (roles, blocked, confirmed, password, externalId, mfa*) so a user cannot
|
|
7
|
+
// mass-assign their way to privilege escalation. The controller also whitelists
|
|
8
|
+
// these same fields as the real security boundary (defense in depth).
|
|
9
|
+
additionalProperties: false,
|
|
5
10
|
properties: {
|
|
6
|
-
username: { type: 'string' }
|
|
11
|
+
username: { type: 'string' },
|
|
12
|
+
firstName: { type: 'string' },
|
|
13
|
+
lastName: { type: 'string' }
|
|
7
14
|
}
|
|
8
15
|
}
|
|
9
16
|
|
|
@@ -11,9 +18,18 @@ export const userBodySchema = {
|
|
|
11
18
|
$id: 'userBodySchema',
|
|
12
19
|
type: 'object',
|
|
13
20
|
nullable: true,
|
|
21
|
+
// Admin create/update surface. `additionalProperties: false` drops anything not
|
|
22
|
+
// listed (e.g. externalId, mfaSecret, passwordChangedAt). `password` is allowed
|
|
23
|
+
// for CREATE (createUser hashes it); on UPDATE it is dropped in updateUserById so
|
|
24
|
+
// it can never be stored in plaintext — credential changes go through the
|
|
25
|
+
// dedicated hashed flows (change-password / reset-password).
|
|
26
|
+
additionalProperties: false,
|
|
14
27
|
properties: {
|
|
15
28
|
username: { type: 'string' },
|
|
16
29
|
email: { type: 'string' },
|
|
30
|
+
password: { type: 'string' },
|
|
31
|
+
firstName: { type: 'string' },
|
|
32
|
+
lastName: { type: 'string' },
|
|
17
33
|
blocked: { type: 'boolean' },
|
|
18
34
|
blockedReason: { type: 'string' },
|
|
19
35
|
blockedAt: { type: 'string' },
|
package/lib/util/errors.ts
CHANGED
|
@@ -12,8 +12,10 @@ export class TranslatedError extends Error {
|
|
|
12
12
|
this.locale = locale
|
|
13
13
|
this.status = status
|
|
14
14
|
this.translationCode = translationCode
|
|
15
|
-
|
|
16
|
-
this.
|
|
15
|
+
const phrase = translationCode || defaultMessage
|
|
16
|
+
this.translatedMessage = phrase ? global.t.__({ phrase, locale: locale || 'en' }, data) : null
|
|
17
|
+
// fallback: tradotto -> code grezzo -> defaultMessage -> catch-all
|
|
18
|
+
this.message = this.translatedMessage || translationCode || defaultMessage || 'generic error'
|
|
17
19
|
this.data = data
|
|
18
20
|
|
|
19
21
|
Error.captureStackTrace(this, this.constructor || TranslatedError)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Standard error response body for the whole framework.
|
|
2
|
+
//
|
|
3
|
+
// { statusCode, error, code?, message? }
|
|
4
|
+
//
|
|
5
|
+
// statusCode : HTTP status
|
|
6
|
+
// error : HTTP reason phrase ('Bad Request', 'Unauthorized', ...)
|
|
7
|
+
// code : optional machine-readable code ('TENANT_MISMATCH', 'MFA_REQUIRED', ...)
|
|
8
|
+
// message : optional human-readable message
|
|
9
|
+
//
|
|
10
|
+
// Use this instead of `reply.send(new Error(...))`, which loses the status in
|
|
11
|
+
// Fastify's async error path (collapses to 500/403).
|
|
12
|
+
const REASON: Record<number, string> = {
|
|
13
|
+
400: 'Bad Request',
|
|
14
|
+
401: 'Unauthorized',
|
|
15
|
+
403: 'Forbidden',
|
|
16
|
+
404: 'Not Found',
|
|
17
|
+
409: 'Conflict',
|
|
18
|
+
422: 'Unprocessable Entity',
|
|
19
|
+
429: 'Too Many Requests',
|
|
20
|
+
500: 'Internal Server Error'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HttpErrorBody {
|
|
24
|
+
statusCode: number
|
|
25
|
+
error: string
|
|
26
|
+
code?: string
|
|
27
|
+
message?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function httpError(statusCode: number, message?: string, code?: string): HttpErrorBody {
|
|
31
|
+
return {
|
|
32
|
+
statusCode,
|
|
33
|
+
error: REASON[statusCode] || 'Error',
|
|
34
|
+
...(code ? { code } : {}),
|
|
35
|
+
...(message ? { message } : {})
|
|
36
|
+
}
|
|
37
|
+
}
|