underpost 2.8.871 → 2.8.873
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/.env.development +2 -1
- package/.env.production +2 -1
- package/.env.test +2 -1
- package/.github/workflows/ghpkg.ci.yml +1 -1
- package/.github/workflows/npmpkg.ci.yml +1 -1
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -1
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/.github/workflows/release.cd.yml +2 -2
- package/README.md +66 -36
- package/bin/build.js +4 -0
- package/bin/deploy.js +4 -0
- package/cli.md +88 -87
- package/conf.js +2 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +138 -0
- package/manifests/deployment/dd-test-development/proxy.yaml +26 -0
- package/package.json +6 -3
- package/src/api/core/core.router.js +2 -1
- package/src/api/default/default.controller.js +6 -1
- package/src/api/default/default.router.js +6 -2
- package/src/api/default/default.service.js +10 -1
- package/src/api/file/file.router.js +2 -1
- package/src/api/test/test.router.js +1 -1
- package/src/api/user/postman_collection.json +216 -0
- package/src/api/user/user.controller.js +25 -60
- package/src/api/user/user.model.js +29 -7
- package/src/api/user/user.router.js +6 -3
- package/src/api/user/user.service.js +80 -32
- package/src/cli/baremetal.js +33 -3
- package/src/cli/cloud-init.js +11 -0
- package/src/cli/deploy.js +5 -2
- package/src/cli/index.js +1 -0
- package/src/cli/lxd.js +7 -0
- package/src/cli/repository.js +1 -0
- package/src/cli/run.js +18 -5
- package/src/cli/ssh.js +20 -6
- package/src/client/components/core/Account.js +2 -1
- package/src/client/components/core/AgGrid.js +30 -8
- package/src/client/components/core/Auth.js +98 -55
- package/src/client/components/core/CalendarCore.js +3 -4
- package/src/client/components/core/CommonJs.js +1 -2
- package/src/client/components/core/Content.js +2 -1
- package/src/client/components/core/Css.js +2 -1
- package/src/client/components/core/CssCore.js +2 -1
- package/src/client/components/core/Docs.js +4 -4
- package/src/client/components/core/FileExplorer.js +3 -3
- package/src/client/components/core/JoyStick.js +2 -2
- package/src/client/components/core/LoadingAnimation.js +2 -2
- package/src/client/components/core/LogIn.js +16 -23
- package/src/client/components/core/LogOut.js +5 -1
- package/src/client/components/core/Logger.js +4 -1
- package/src/client/components/core/Modal.js +17 -27
- package/src/client/components/core/ObjectLayerEngineModal.js +2 -1
- package/src/client/components/core/Pagination.js +207 -0
- package/src/client/components/core/Panel.js +3 -11
- package/src/client/components/core/PanelForm.js +6 -15
- package/src/client/components/core/Recover.js +2 -2
- package/src/client/components/core/Router.js +205 -33
- package/src/client/components/core/SignUp.js +1 -2
- package/src/client/components/core/Stream.js +1 -1
- package/src/client/components/core/VanillaJs.js +0 -83
- package/src/client/components/core/Worker.js +2 -2
- package/src/client/components/default/LogInDefault.js +0 -6
- package/src/client/components/default/LogOutDefault.js +0 -16
- package/src/client/components/default/MenuDefault.js +4 -3
- package/src/client/components/default/RoutesDefault.js +3 -2
- package/src/client/services/core/core.service.js +6 -2
- package/src/client/services/default/default.management.js +115 -18
- package/src/client/services/default/default.service.js +9 -4
- package/src/client/services/user/user.management.js +6 -0
- package/src/client/services/user/user.service.js +11 -4
- package/src/client/ssr/head/DefaultScripts.js +1 -0
- package/src/index.js +24 -2
- package/src/runtime/lampp/Lampp.js +89 -2
- package/src/runtime/xampp/Xampp.js +48 -1
- package/src/server/auth.js +518 -155
- package/src/server/conf.js +19 -1
- package/src/server/runtime.js +62 -221
- package/src/server/ssl.js +1 -2
- package/src/server/ssr.js +85 -0
- package/src/server/valkey.js +2 -1
package/src/server/auth.js
CHANGED
|
@@ -8,228 +8,591 @@ import dotenv from 'dotenv';
|
|
|
8
8
|
import jwt from 'jsonwebtoken';
|
|
9
9
|
import { loggerFactory } from './logger.js';
|
|
10
10
|
import crypto from 'crypto';
|
|
11
|
-
import {
|
|
11
|
+
import { promisify } from 'util';
|
|
12
|
+
import { UserDto } from '../api/user/user.model.js';
|
|
12
13
|
import { commonAdminGuard, commonModeratorGuard, validatePassword } from '../client/components/core/CommonJs.js';
|
|
14
|
+
import helmet from 'helmet';
|
|
15
|
+
import rateLimit from 'express-rate-limit';
|
|
16
|
+
import slowDown from 'express-slow-down';
|
|
17
|
+
import cors from 'cors';
|
|
18
|
+
import cookieParser from 'cookie-parser';
|
|
19
|
+
import { DataBaseProvider } from '../db/DataBaseProvider.js';
|
|
13
20
|
|
|
14
21
|
dotenv.config();
|
|
15
|
-
|
|
16
22
|
const logger = loggerFactory(import.meta);
|
|
17
23
|
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
// Promisified crypto functions
|
|
25
|
+
const pbkdf2 = promisify(crypto.pbkdf2);
|
|
26
|
+
|
|
27
|
+
// Config with sane defaults and parsing
|
|
20
28
|
const config = {
|
|
21
|
-
hashBytes: 32,
|
|
22
|
-
saltBytes: 16,
|
|
23
|
-
iterations:
|
|
24
|
-
digest: 'sha512',
|
|
29
|
+
hashBytes: Number(process.env.PBKDF2_HASH_BYTES) || 32,
|
|
30
|
+
saltBytes: Number(process.env.PBKDF2_SALT_BYTES) || 16,
|
|
31
|
+
iterations: Number(process.env.PBKDF2_ITERATIONS) || 150_000,
|
|
32
|
+
digest: process.env.PBKDF2_DIGEST || 'sha512',
|
|
33
|
+
refreshTokenBytes: Number(process.env.REFRESH_TOKEN_BYTES) || 48,
|
|
34
|
+
jwtAlgorithm: process.env.JWT_ALGORITHM || 'HS512', // consider RS256 with keys
|
|
25
35
|
};
|
|
26
36
|
|
|
37
|
+
// ---------- Password hashing (async) ----------
|
|
27
38
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
39
|
+
* Hash password asynchronously using PBKDF2.
|
|
40
|
+
* Stored format: iterations$salt$hash
|
|
41
|
+
* @param {string} password The password to hash.
|
|
42
|
+
* @returns {Promise<string>} The hashed password string.
|
|
30
43
|
* @memberof Auth
|
|
31
44
|
*/
|
|
32
|
-
function hashPassword(password) {
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
return [salt, hash].join('$');
|
|
45
|
+
async function hashPassword(password) {
|
|
46
|
+
const salt = crypto.randomBytes(config.saltBytes).toString('hex');
|
|
47
|
+
const derived = await pbkdf2(password, salt, config.iterations, config.hashBytes, config.digest);
|
|
48
|
+
return `${config.iterations}$${salt}$${derived.toString('hex')}`;
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
/**
|
|
40
|
-
*
|
|
41
|
-
* @param {
|
|
42
|
-
* @
|
|
52
|
+
* Verify password using constant-time comparison
|
|
53
|
+
* @param {string} password The password to verify.
|
|
54
|
+
* @param {string} combined The stored hashed password string (iterations$salt$hash).
|
|
55
|
+
* @returns {Promise<boolean>} True if the password is valid, false otherwise.
|
|
43
56
|
* @memberof Auth
|
|
44
57
|
*/
|
|
45
|
-
function verifyPassword(password, combined) {
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
async function verifyPassword(password, combined) {
|
|
59
|
+
if (!combined) return false;
|
|
60
|
+
const parts = combined.split('$');
|
|
61
|
+
if (parts.length !== 3) return false;
|
|
62
|
+
const [itersStr, salt, originalHex] = parts;
|
|
63
|
+
const iterations = parseInt(itersStr, 10);
|
|
64
|
+
const derived = await pbkdf2(password, salt, iterations, Buffer.from(originalHex, 'hex').length, config.digest);
|
|
65
|
+
const original = Buffer.from(originalHex, 'hex');
|
|
66
|
+
const ok = crypto.timingSafeEqual(derived, original);
|
|
67
|
+
return ok;
|
|
50
68
|
}
|
|
51
69
|
|
|
52
|
-
//
|
|
53
|
-
|
|
70
|
+
// ---------- Token hashing & utilities ----------
|
|
54
71
|
/**
|
|
55
|
-
*
|
|
56
|
-
* @param
|
|
57
|
-
*
|
|
58
|
-
* relevant data that you want to securely transmit.
|
|
59
|
-
* @param expire - The `expire` parameter in the `hashJWT` function is used to specify the expiration
|
|
60
|
-
* time for the JSON Web Token (JWT) being generated. If a value is provided for `expire`, it will be
|
|
61
|
-
* used as the expiration time. If `expire` is not provided (i.e., it
|
|
72
|
+
* Hashes a token using SHA256.
|
|
73
|
+
* @param {string} token The token to hash.
|
|
74
|
+
* @returns {string|null} The hashed token as a hex string, or null if token is falsy.
|
|
62
75
|
* @memberof Auth
|
|
63
76
|
*/
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
function hashToken(token) {
|
|
78
|
+
if (!token) return null;
|
|
79
|
+
return crypto.createHash('sha256').update(token).digest('hex');
|
|
80
|
+
}
|
|
66
81
|
|
|
67
82
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* @
|
|
71
|
-
* function for verification.
|
|
83
|
+
* Generates a cryptographically secure random hex string.
|
|
84
|
+
* @param {number} [bytes=config.refreshTokenBytes] The number of bytes to generate.
|
|
85
|
+
* @returns {string} The random hex string.
|
|
72
86
|
* @memberof Auth
|
|
73
87
|
*/
|
|
74
|
-
|
|
88
|
+
function generateRandomHex(bytes = config.refreshTokenBytes) {
|
|
89
|
+
return crypto.randomBytes(bytes).toString('hex');
|
|
90
|
+
}
|
|
75
91
|
|
|
76
92
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* @param
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* headers, body, parameters, and more. In
|
|
83
|
-
* @returns {String} The function `getBearerToken` is returning the Bearer token extracted from the
|
|
84
|
-
* Authorization header in the request object. If the Authorization header starts with 'Bearer ', it
|
|
85
|
-
* will return the token portion of the header (excluding 'Bearer ').
|
|
93
|
+
* Generates a JWT issuer and audience based on the host and path.
|
|
94
|
+
* @param {object} options The options object.
|
|
95
|
+
* @param {string} options.host The host name.
|
|
96
|
+
* @param {string} options.path The path name.
|
|
97
|
+
* @returns {object} The issuer and audience.
|
|
86
98
|
* @memberof Auth
|
|
87
99
|
*/
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
return
|
|
92
|
-
}
|
|
100
|
+
function jwtIssuerAudienceFactory(options = { host: '', path: '' }) {
|
|
101
|
+
const audience = `${options.host}${options.path === '/' ? '' : options.path}`;
|
|
102
|
+
const issuer = `${audience}/api`;
|
|
103
|
+
return { issuer, audience };
|
|
104
|
+
}
|
|
93
105
|
|
|
106
|
+
// ---------- JWT helpers ----------
|
|
94
107
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* @param
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* @returns {
|
|
108
|
+
* Signs a JWT payload.
|
|
109
|
+
* @param {object} payload The payload to sign.
|
|
110
|
+
* @param {object} [options={}] Additional JWT sign options.
|
|
111
|
+
* @param {string} options.host The host name.
|
|
112
|
+
* @param {string} options.path The path name.
|
|
113
|
+
* @param {number} expireMinutes The token expiration in minutes.
|
|
114
|
+
* @returns {string} The signed JWT.
|
|
115
|
+
* @throws {Error} If JWT key is not configured.
|
|
102
116
|
* @memberof Auth
|
|
103
117
|
*/
|
|
104
|
-
|
|
118
|
+
function jwtSign(payload, options = { host: '', path: '' }, expireMinutes = process.env.ACCESS_EXPIRE_MINUTES) {
|
|
119
|
+
const { issuer, audience } = jwtIssuerAudienceFactory(options);
|
|
120
|
+
const signOptions = {
|
|
121
|
+
algorithm: config.jwtAlgorithm,
|
|
122
|
+
expiresIn: `${expireMinutes}m`,
|
|
123
|
+
issuer,
|
|
124
|
+
audience,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if (!payload.jwtid) signOptions.jwtid = generateRandomHex();
|
|
128
|
+
|
|
129
|
+
if (!process.env.JWT_SECRET) throw new Error('JWT key not configured');
|
|
130
|
+
|
|
131
|
+
logger.info('JWT signed', { payload, signOptions, expireMinutes });
|
|
132
|
+
|
|
133
|
+
return jwt.sign(payload, process.env.JWT_SECRET, signOptions);
|
|
134
|
+
}
|
|
105
135
|
|
|
106
136
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* @param
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* @
|
|
114
|
-
* represents the HTTP response that an Express.js server sends when it receives an HTTP request. It is
|
|
115
|
-
* used to send a response back to the client with status codes, headers, and data.
|
|
116
|
-
* @param next - The `next` parameter in the `authMiddleware` function is a callback function that is
|
|
117
|
-
* used to pass control to the next middleware function in the stack. When called, it invokes the next
|
|
118
|
-
* middleware function in the chain. This is a common pattern in Express.js middleware functions to
|
|
119
|
-
* move to the next middleware
|
|
120
|
-
* @returns {Object} The `req.auth` included JWT payload in request authorization
|
|
137
|
+
* Verifies a JWT.
|
|
138
|
+
* @param {string} token The JWT to verify.
|
|
139
|
+
* @param {object} [options={}] Additional JWT verify options.
|
|
140
|
+
* @param {string} options.host The host name.
|
|
141
|
+
* @param {string} options.path The path name.
|
|
142
|
+
* @returns {object} The decoded payload.
|
|
143
|
+
* @throws {jwt.JsonWebTokenError} If the token is invalid or expired.
|
|
121
144
|
* @memberof Auth
|
|
122
145
|
*/
|
|
123
|
-
|
|
146
|
+
function jwtVerify(token, options = { host: '', path: '' }) {
|
|
124
147
|
try {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
});
|
|
135
|
-
} catch (error) {
|
|
136
|
-
logger.error(error, error.stack);
|
|
137
|
-
return res.status(400).json({
|
|
138
|
-
status: 'error',
|
|
139
|
-
message: error.message,
|
|
140
|
-
});
|
|
148
|
+
const { issuer, audience } = jwtIssuerAudienceFactory(options);
|
|
149
|
+
const verifyOptions = {
|
|
150
|
+
algorithms: [config.jwtAlgorithm],
|
|
151
|
+
issuer,
|
|
152
|
+
audience,
|
|
153
|
+
};
|
|
154
|
+
return jwt.verify(token, process.env.JWT_SECRET, verifyOptions);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
throw err;
|
|
141
157
|
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------- Request helpers ----------
|
|
161
|
+
/**
|
|
162
|
+
* Extracts the Bearer token from the request headers.
|
|
163
|
+
* @param {import('express').Request} req The Express request object.
|
|
164
|
+
* @returns {string} The token, or an empty string if not found.
|
|
165
|
+
* @memberof Auth
|
|
166
|
+
*/
|
|
167
|
+
const getBearerToken = (req) => {
|
|
168
|
+
const header = String(req.headers['authorization'] || req.headers['Authorization'] || '');
|
|
169
|
+
if (header.startsWith('Bearer ')) return header.slice(7).trim();
|
|
170
|
+
return '';
|
|
142
171
|
};
|
|
143
172
|
|
|
173
|
+
// ---------- Middleware ----------
|
|
144
174
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* @param
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
175
|
+
* Creates a middleware to authenticate requests using a JWT Bearer token.
|
|
176
|
+
* @param {object} options The options object.
|
|
177
|
+
* @param {string} options.host The host name.
|
|
178
|
+
* @param {string} options.path The path name.
|
|
179
|
+
* @returns {function} The middleware function.
|
|
180
|
+
* @memberof Auth
|
|
181
|
+
*/
|
|
182
|
+
const authMiddlewareFactory = (options = { host: '', path: '' }) => {
|
|
183
|
+
/**
|
|
184
|
+
* Express middleware to authenticate requests using a JWT Bearer token.
|
|
185
|
+
* @param {import('express').Request} req The Express request object.
|
|
186
|
+
* @param {import('express').Response} res The Express response object.
|
|
187
|
+
* @param {import('express').NextFunction} next The next middleware function.
|
|
188
|
+
* @memberof Auth
|
|
189
|
+
*/
|
|
190
|
+
const authMiddleware = async (req, res, next) => {
|
|
191
|
+
try {
|
|
192
|
+
const token = getBearerToken(req);
|
|
193
|
+
if (!token) return res.status(401).json({ status: 'error', message: 'unauthorized: token missing' });
|
|
194
|
+
|
|
195
|
+
const payload = jwtVerify(token, options);
|
|
196
|
+
|
|
197
|
+
// Validate IP and User-Agent to mitigate token theft
|
|
198
|
+
if (payload.ip && payload.ip !== req.ip) {
|
|
199
|
+
logger.warn(`IP mismatch for ${payload._id}: jwt(${payload.ip}) !== req(${req.ip})`);
|
|
200
|
+
return res.status(401).json({ status: 'error', message: 'unauthorized: ip mismatch' });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (payload.userAgent && payload.userAgent !== req.headers['user-agent']) {
|
|
204
|
+
logger.warn(`UA mismatch for ${payload._id}`);
|
|
205
|
+
return res.status(401).json({ status: 'error', message: 'unauthorized: user-agent mismatch' });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Non-guest verify session exists
|
|
209
|
+
if (payload.jwtid && payload.role !== 'guest') {
|
|
210
|
+
const User = DataBaseProvider.instance[`${payload.host}${payload.path}`].mongoose.models.User;
|
|
211
|
+
const user = await User.findOne({ _id: payload._id, 'activeSessions._id': payload.jwtid }).lean();
|
|
212
|
+
|
|
213
|
+
if (!user) {
|
|
214
|
+
return res.status(401).json({ status: 'error', message: 'unauthorized: invalid session' });
|
|
215
|
+
}
|
|
216
|
+
const session = user.activeSessions.find((s) => s._id.toString() === payload.jwtid);
|
|
217
|
+
|
|
218
|
+
if (!session) {
|
|
219
|
+
return res.status(401).json({ status: 'error', message: 'unauthorized: invalid session' });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// check session ip
|
|
223
|
+
if (session.ip !== req.ip) {
|
|
224
|
+
logger.warn(`IP mismatch for ${payload._id}: jwt(${session.ip}) !== req(${req.ip})`);
|
|
225
|
+
return res.status(401).json({ status: 'error', message: 'unauthorized: ip mismatch' });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// check session userAgent
|
|
229
|
+
if (session.userAgent !== req.headers['user-agent']) {
|
|
230
|
+
logger.warn(`UA mismatch for ${payload._id}`);
|
|
231
|
+
return res.status(401).json({ status: 'error', message: 'unauthorized: user-agent mismatch' });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// compare payload host and path with session host and path
|
|
235
|
+
if (payload.host !== session.host || payload.path !== session.path) {
|
|
236
|
+
logger.warn(`Host or path mismatch for ${payload._id}`);
|
|
237
|
+
return res.status(401).json({ status: 'error', message: 'unauthorized: host or path mismatch' });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// check session expiresAt
|
|
241
|
+
const isRefreshTokenReq = req.method === 'GET' && req.params.id === 'auth';
|
|
242
|
+
|
|
243
|
+
if (!isRefreshTokenReq && session.expiresAt < new Date()) {
|
|
244
|
+
return res.status(401).json({ status: 'error', message: 'unauthorized: session expired' });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
req.auth = { user: payload };
|
|
249
|
+
return next();
|
|
250
|
+
} catch (err) {
|
|
251
|
+
logger.warn('authMiddleware error', err && err.message);
|
|
252
|
+
return res.status(401).json({ status: 'error', message: 'unauthorized' });
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
return authMiddleware;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Express middleware to guard routes for admin users.
|
|
260
|
+
* @param {import('express').Request} req The Express request object.
|
|
261
|
+
* @param {import('express').Response} res The Express response object.
|
|
262
|
+
* @param {import('express').NextFunction} next The next middleware function.
|
|
161
263
|
* @memberof Auth
|
|
162
264
|
*/
|
|
163
265
|
const adminGuard = (req, res, next) => {
|
|
164
266
|
try {
|
|
165
|
-
if (!commonAdminGuard(req.auth.user.role))
|
|
267
|
+
if (!req.auth || !commonAdminGuard(req.auth.user.role))
|
|
166
268
|
return res.status(403).json({ status: 'error', message: 'Insufficient permission' });
|
|
167
269
|
return next();
|
|
168
|
-
} catch (
|
|
169
|
-
logger.error(
|
|
170
|
-
return res.status(400).json({
|
|
171
|
-
status: 'error',
|
|
172
|
-
message: error.message,
|
|
173
|
-
});
|
|
270
|
+
} catch (err) {
|
|
271
|
+
logger.error(err);
|
|
272
|
+
return res.status(400).json({ status: 'error', message: 'bad request' });
|
|
174
273
|
}
|
|
175
274
|
};
|
|
176
|
-
|
|
177
275
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* @param
|
|
181
|
-
*
|
|
182
|
-
* body, etc. It is commonly used to access data sent from the client to the server.
|
|
183
|
-
* @param res - The `res` parameter in the `moderatorGuard` function is the response object in
|
|
184
|
-
* Express.js. It is used to send a response back to the client making the HTTP request.
|
|
185
|
-
* @param next - The `next` parameter in the `moderatorGuard` function is a callback function that is
|
|
186
|
-
* used to pass control to the next middleware function in the stack. When called, it will execute the
|
|
187
|
-
* next middleware function. In the context of Express.js middleware, `next` is typically called to
|
|
188
|
-
* move to
|
|
189
|
-
* @returns In the `moderatorGuard` function, if the user's role is not a moderator or higher, a 403
|
|
190
|
-
* status with an error message "Insufficient permission" is returned. If there is an error during the
|
|
191
|
-
* process, a 400 status with the error message is returned. If everything is successful, the `next()`
|
|
192
|
-
* function is called to proceed to the next middleware in the chain.
|
|
276
|
+
* Express middleware to guard routes for moderator or admin users.
|
|
277
|
+
* @param {import('express').Request} req The Express request object.
|
|
278
|
+
* @param {import('express').Response} res The Express response object.
|
|
279
|
+
* @param {import('express').NextFunction} next The next middleware function.
|
|
193
280
|
* @memberof Auth
|
|
194
281
|
*/
|
|
195
282
|
const moderatorGuard = (req, res, next) => {
|
|
196
283
|
try {
|
|
197
|
-
if (!commonModeratorGuard(req.auth.user.role))
|
|
284
|
+
if (!req.auth || !commonModeratorGuard(req.auth.user.role))
|
|
198
285
|
return res.status(403).json({ status: 'error', message: 'Insufficient permission' });
|
|
199
286
|
return next();
|
|
200
|
-
} catch (
|
|
201
|
-
logger.error(
|
|
202
|
-
return res.status(400).json({
|
|
203
|
-
status: 'error',
|
|
204
|
-
message: error.message,
|
|
205
|
-
});
|
|
287
|
+
} catch (err) {
|
|
288
|
+
logger.error(err);
|
|
289
|
+
return res.status(400).json({ status: 'error', message: 'bad request' });
|
|
206
290
|
}
|
|
207
291
|
};
|
|
208
292
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return {
|
|
220
|
-
|
|
221
|
-
|
|
293
|
+
// ---------- Password validation middleware (server-side) ----------
|
|
294
|
+
/**
|
|
295
|
+
* Validates the password from the request body.
|
|
296
|
+
* @param {import('express').Request} req The Express request object.
|
|
297
|
+
* @returns {{status: 'success'}|{status: 'error', message: string}} Validation result.
|
|
298
|
+
* @memberof Auth
|
|
299
|
+
*/
|
|
300
|
+
const validatePasswordMiddleware = (req) => {
|
|
301
|
+
const errors = req.body && 'password' in req.body ? validatePassword(req.body.password) : [];
|
|
302
|
+
if (errors.length) {
|
|
303
|
+
return { status: 'error', message: 'Password: ' + errors.map((e) => e[req.lang] || e.en || e).join(', ') };
|
|
304
|
+
}
|
|
305
|
+
return { status: 'success' };
|
|
222
306
|
};
|
|
223
307
|
|
|
308
|
+
// ---------- Session & Refresh token management ----------
|
|
309
|
+
/**
|
|
310
|
+
* Create session and set refresh cookie. Rotating and hashed stored token.
|
|
311
|
+
* @param {object} user The user object.
|
|
312
|
+
* @param {import('mongoose').Model} User The Mongoose User model.
|
|
313
|
+
* @param {import('express').Request} req The Express request object.
|
|
314
|
+
* @param {import('express').Response} res The Express response object.
|
|
315
|
+
* @param {object} options Additional options.
|
|
316
|
+
* @param {string} options.host The host name.
|
|
317
|
+
* @param {string} options.path The path name.
|
|
318
|
+
* @returns {Promise<{jwtid: string}>} The session ID.
|
|
319
|
+
* @memberof Auth
|
|
320
|
+
*/
|
|
321
|
+
async function createSessionAndUserToken(user, User, req, res, options = { host: '', path: '' }) {
|
|
322
|
+
const refreshToken = hashToken(generateRandomHex());
|
|
323
|
+
const now = Date.now();
|
|
324
|
+
const expiresAt = new Date(now + parseInt(process.env.REFRESH_EXPIRE_MINUTES) * 60 * 1000);
|
|
325
|
+
|
|
326
|
+
const newSession = {
|
|
327
|
+
tokenHash: refreshToken,
|
|
328
|
+
ip: req.ip,
|
|
329
|
+
userAgent: req.headers['user-agent'],
|
|
330
|
+
host: options.host,
|
|
331
|
+
path: options.path,
|
|
332
|
+
createdAt: new Date(now),
|
|
333
|
+
expiresAt,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// push session
|
|
337
|
+
const updatedUser = await User.findByIdAndUpdate(user._id, { $push: { activeSessions: newSession } }, { new: true });
|
|
338
|
+
const session = updatedUser.activeSessions[updatedUser.activeSessions.length - 1];
|
|
339
|
+
const jwtid = session._id.toString();
|
|
340
|
+
|
|
341
|
+
// Secure cookie settings
|
|
342
|
+
res.cookie('refreshToken', refreshToken, {
|
|
343
|
+
httpOnly: true,
|
|
344
|
+
secure: process.env.NODE_ENV === 'production',
|
|
345
|
+
sameSite: 'Lax',
|
|
346
|
+
maxAge: parseInt(process.env.REFRESH_EXPIRE_MINUTES) * 60 * 1000,
|
|
347
|
+
path: '/',
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
return { jwtid };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Create user and immediate session + access token
|
|
355
|
+
* @param {import('express').Request} req The Express request object.
|
|
356
|
+
* @param {import('express').Response} res The Express response object.
|
|
357
|
+
* @param {import('mongoose').Model} User The Mongoose User model.
|
|
358
|
+
* @param {import('mongoose').Model} File The Mongoose File model.
|
|
359
|
+
* @param {object} [options={}] Additional options.
|
|
360
|
+
* @param {Function} options.getDefaultProfileImageId Function to get the default profile image ID.
|
|
361
|
+
* @param {string} options.host The host name.
|
|
362
|
+
* @param {string} options.path The path name.
|
|
363
|
+
* @returns {Promise<{token: string, user: object}>} The access token and user object.
|
|
364
|
+
* @throws {Error} If password validation fails.
|
|
365
|
+
* @memberof Auth
|
|
366
|
+
*/
|
|
367
|
+
async function createUserAndSession(req, res, User, File, options = { host: '', path: '' }) {
|
|
368
|
+
const pwdCheck = validatePasswordMiddleware(req);
|
|
369
|
+
if (pwdCheck.status === 'error') throw new Error(pwdCheck.message);
|
|
370
|
+
|
|
371
|
+
req.body.password = await hashPassword(req.body.password);
|
|
372
|
+
req.body.role = req.body.role === 'guest' ? 'guest' : 'user';
|
|
373
|
+
req.body.profileImageId = await options.getDefaultProfileImageId(File);
|
|
374
|
+
|
|
375
|
+
const saved = await new User(req.body).save();
|
|
376
|
+
const user = await User.findOne({ _id: saved._id }).select(UserDto.select.get());
|
|
377
|
+
|
|
378
|
+
const { jwtid } = await createSessionAndUserToken(user, User, req, res, options);
|
|
379
|
+
const token = jwtSign(
|
|
380
|
+
UserDto.auth.payload(user, jwtid, req.ip, req.headers['user-agent'], options.host, options.path),
|
|
381
|
+
options,
|
|
382
|
+
);
|
|
383
|
+
return { token, user };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Refresh session and rotate refresh token.
|
|
388
|
+
* Detect token reuse: if a refresh token is presented but not found, consider
|
|
389
|
+
* it a possible theft and revoke all sessions for that user.
|
|
390
|
+
* @param {import('express').Request} req The Express request object.
|
|
391
|
+
* @param {import('express').Response} res The Express response object.
|
|
392
|
+
* @param {import('mongoose').Model} User The Mongoose User model.
|
|
393
|
+
* @param {object} options Additional options.
|
|
394
|
+
* @param {string} options.host The host name.
|
|
395
|
+
* @param {string} options.path The path name.
|
|
396
|
+
* @returns {Promise<{token: string}>} The new access token.
|
|
397
|
+
* @throws {Error} If the refresh token is missing, invalid, or expired.
|
|
398
|
+
* @memberof Auth
|
|
399
|
+
*/
|
|
400
|
+
async function refreshSessionAndToken(req, res, User, options = { host: '', path: '' }) {
|
|
401
|
+
const currentRefreshToken = req.cookies.refreshToken;
|
|
402
|
+
if (!currentRefreshToken) throw new Error('Refresh token missing');
|
|
403
|
+
|
|
404
|
+
// Find user owning that token
|
|
405
|
+
const user = await User.findOne({ 'activeSessions.tokenHash': currentRefreshToken });
|
|
406
|
+
|
|
407
|
+
if (!user) {
|
|
408
|
+
// Possible token reuse: look up user by some other signals? If not possible, log and throw.
|
|
409
|
+
logger.warn('Refresh token reuse or invalid token detected');
|
|
410
|
+
// Optional: revoke by clearing cookie and returning unauthorized
|
|
411
|
+
res.clearCookie('refreshToken', { path: '/' });
|
|
412
|
+
throw new Error('Invalid refresh token');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Locate session
|
|
416
|
+
const session = user.activeSessions.find((s) => s.tokenHash === currentRefreshToken);
|
|
417
|
+
if (!session) {
|
|
418
|
+
// Shouldn't happen, but safe-guard
|
|
419
|
+
res.clearCookie('refreshToken', { path: '/' });
|
|
420
|
+
throw new Error('Session not found');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Check expiry
|
|
424
|
+
if (session.expiresAt && session.expiresAt < new Date()) {
|
|
425
|
+
// remove expired session
|
|
426
|
+
user.activeSessions.id(session._id).remove();
|
|
427
|
+
await user.save({ validateBeforeSave: false });
|
|
428
|
+
res.clearCookie('refreshToken', { path: '/' });
|
|
429
|
+
throw new Error('Refresh token expired');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Rotate: generate new token, update stored hash and metadata
|
|
433
|
+
const refreshToken = hashToken(generateRandomHex());
|
|
434
|
+
session.tokenHash = refreshToken;
|
|
435
|
+
session.expiresAt = new Date(Date.now() + parseInt(process.env.REFRESH_EXPIRE_MINUTES) * 60 * 1000);
|
|
436
|
+
session.ip = req.ip;
|
|
437
|
+
session.userAgent = req.headers['user-agent'];
|
|
438
|
+
await user.save({ validateBeforeSave: false });
|
|
439
|
+
|
|
440
|
+
logger.warn('Refreshed session for user ' + user.email);
|
|
441
|
+
|
|
442
|
+
res.cookie('refreshToken', refreshToken, {
|
|
443
|
+
httpOnly: true,
|
|
444
|
+
secure: process.env.NODE_ENV === 'production',
|
|
445
|
+
sameSite: 'Lax',
|
|
446
|
+
maxAge: parseInt(process.env.REFRESH_EXPIRE_MINUTES) * 60 * 1000,
|
|
447
|
+
path: '/',
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
return jwtSign(
|
|
451
|
+
UserDto.auth.payload(user, session._id.toString(), req.ip, req.headers['user-agent'], options.host, options.path),
|
|
452
|
+
options,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------- Security middleware composition ----------
|
|
457
|
+
/**
|
|
458
|
+
* Applies a set of security-related middleware to an Express app.
|
|
459
|
+
* @param {import('express').Application} app The Express application.
|
|
460
|
+
* @param {object} [opts={}] Options for security middleware.
|
|
461
|
+
* @param {string[]} opts.origin Allowed origins for CORS.
|
|
462
|
+
* @param {object} opts.rate Rate limiting options for `express-rate-limit`.
|
|
463
|
+
* @param {object} opts.slowdown Slow down options for `express-slow-down`.
|
|
464
|
+
* @param {object} opts.cookie Cookie options.
|
|
465
|
+
* @param {string[]} opts.frameAncestors Allowed frame ancestors for CSP.
|
|
466
|
+
* @memberof Auth
|
|
467
|
+
*/
|
|
468
|
+
function applySecurity(app, opts = {}) {
|
|
469
|
+
const {
|
|
470
|
+
origin,
|
|
471
|
+
rate = { windowMs: 15 * 60 * 1000, max: 500 },
|
|
472
|
+
slowdown = { windowMs: 15 * 60 * 1000, delayAfter: 50, delayMs: () => 500 },
|
|
473
|
+
cookie = { secure: process.env.NODE_ENV === 'production', sameSite: 'Strict' },
|
|
474
|
+
frameAncestors = ["'self'"],
|
|
475
|
+
} = opts;
|
|
476
|
+
|
|
477
|
+
app.disable('x-powered-by');
|
|
478
|
+
|
|
479
|
+
// Generate nonce per request and attach to res.locals for templates
|
|
480
|
+
app.use((req, res, next) => {
|
|
481
|
+
res.locals.nonce = crypto.randomBytes(16).toString('base64');
|
|
482
|
+
next();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Basic header hardening with Helmet
|
|
486
|
+
app.use(
|
|
487
|
+
helmet({
|
|
488
|
+
// We'll customize CSP below because many apps need tailored directives
|
|
489
|
+
crossOriginEmbedderPolicy: true,
|
|
490
|
+
crossOriginOpenerPolicy: { policy: 'same-origin' },
|
|
491
|
+
crossOriginResourcePolicy: { policy: 'same-origin' },
|
|
492
|
+
originAgentCluster: false,
|
|
493
|
+
// Permissions-Policy (formerly Feature-Policy) — limit powerful features
|
|
494
|
+
permissionsPolicy: {
|
|
495
|
+
// example: disable geolocation, camera, microphone, payment
|
|
496
|
+
features: {
|
|
497
|
+
fullscreen: ["'self'"],
|
|
498
|
+
geolocation: [],
|
|
499
|
+
camera: [],
|
|
500
|
+
microphone: [],
|
|
501
|
+
payment: [],
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
}),
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
// Strict HSTS (only enable in production over TLS)
|
|
508
|
+
// maxAge in seconds (e.g. 31536000 = 1 year). Use includeSubDomains and preload carefully.
|
|
509
|
+
if (process.env.NODE_ENV === 'production') {
|
|
510
|
+
app.use(
|
|
511
|
+
helmet.hsts({
|
|
512
|
+
maxAge: 31536000,
|
|
513
|
+
includeSubDomains: true,
|
|
514
|
+
preload: true,
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Other helpful Helmet policies
|
|
520
|
+
app.use(helmet.noSniff()); // X-Content-Type-Options: nosniff
|
|
521
|
+
app.use(helmet.frameguard({ action: 'deny' })); // X-Frame-Options: DENY
|
|
522
|
+
app.use(helmet.referrerPolicy({ policy: 'no-referrer-when-downgrade' }));
|
|
523
|
+
|
|
524
|
+
// Content-Security-Policy: include nonce from res.locals
|
|
525
|
+
// Note: We avoid 'unsafe-inline' on script/style. Use nonces or hashes.
|
|
526
|
+
app.use(
|
|
527
|
+
helmet.contentSecurityPolicy({
|
|
528
|
+
useDefaults: true,
|
|
529
|
+
directives: {
|
|
530
|
+
defaultSrc: ["'self'"],
|
|
531
|
+
baseUri: ["'self'"],
|
|
532
|
+
blockAllMixedContent: [],
|
|
533
|
+
fontSrc: ["'self'", 'https:', 'data:'],
|
|
534
|
+
frameAncestors: frameAncestors,
|
|
535
|
+
imgSrc: ["'self'", 'data:', 'https:'],
|
|
536
|
+
objectSrc: ["'none'"],
|
|
537
|
+
// script-src and script-src-elem include dynamic nonce
|
|
538
|
+
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
|
|
539
|
+
scriptSrcElem: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
|
|
540
|
+
// style-src: avoid 'unsafe-inline' when possible; if you must inline styles,
|
|
541
|
+
// use a nonce for them too (or hash).
|
|
542
|
+
styleSrc: ["'self'", 'https:', (req, res) => `'nonce-${res.locals.nonce}'`],
|
|
543
|
+
// deny plugins
|
|
544
|
+
objectSrc: ["'none'"],
|
|
545
|
+
},
|
|
546
|
+
}),
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
// CORS - be explicit. In production, pass allowed origin(s) as opts.origin
|
|
550
|
+
app.use(
|
|
551
|
+
cors({
|
|
552
|
+
origin: origin || false,
|
|
553
|
+
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
554
|
+
credentials: true,
|
|
555
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept'],
|
|
556
|
+
maxAge: 600,
|
|
557
|
+
}),
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
// Rate limiting + slow down
|
|
561
|
+
const limiter = rateLimit({
|
|
562
|
+
windowMs: rate.windowMs,
|
|
563
|
+
max: rate.max,
|
|
564
|
+
standardHeaders: true,
|
|
565
|
+
legacyHeaders: false,
|
|
566
|
+
message: { error: 'Too many requests, please try again later.' },
|
|
567
|
+
});
|
|
568
|
+
app.use(limiter);
|
|
569
|
+
|
|
570
|
+
const speedLimiter = slowDown({
|
|
571
|
+
windowMs: slowdown.windowMs,
|
|
572
|
+
delayAfter: slowdown.delayAfter,
|
|
573
|
+
delayMs: () => slowdown.delayMs,
|
|
574
|
+
});
|
|
575
|
+
app.use(speedLimiter);
|
|
576
|
+
|
|
577
|
+
// Cookie parsing
|
|
578
|
+
app.use(cookieParser(process.env.JWT_SECRET));
|
|
579
|
+
}
|
|
580
|
+
|
|
224
581
|
export {
|
|
225
|
-
|
|
582
|
+
authMiddlewareFactory,
|
|
226
583
|
hashPassword,
|
|
227
584
|
verifyPassword,
|
|
228
|
-
|
|
585
|
+
hashToken,
|
|
586
|
+
jwtSign,
|
|
587
|
+
jwtVerify,
|
|
588
|
+
jwtSign as hashJWT,
|
|
589
|
+
jwtVerify as verifyJWT,
|
|
229
590
|
adminGuard,
|
|
230
591
|
moderatorGuard,
|
|
231
|
-
verifyJWT,
|
|
232
592
|
validatePasswordMiddleware,
|
|
233
593
|
getBearerToken,
|
|
234
|
-
|
|
594
|
+
createSessionAndUserToken,
|
|
595
|
+
createUserAndSession,
|
|
596
|
+
refreshSessionAndToken,
|
|
597
|
+
applySecurity,
|
|
235
598
|
};
|