domma-cms 0.81.1 → 0.83.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/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +5 -4
- package/admin/js/lib/avatar.js +1 -0
- package/admin/js/templates/login.html +16 -0
- package/admin/js/templates/my-profile.html +23 -3
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/my-profile.js +1 -1
- package/bin/update.js +7 -3
- package/config/plugins.json +5 -0
- package/package.json +2 -1
- package/plugins/free-tier.lock.json +25 -1
- package/plugins/security/CLAUDE.md +41 -0
- package/plugins/security/admin/lib/blocks.js +46 -0
- package/plugins/security/admin/lib/health.js +148 -0
- package/plugins/security/admin/lib/ip.js +44 -0
- package/plugins/security/admin/lib/lockout.js +78 -0
- package/plugins/security/admin/lib/policy.js +32 -0
- package/plugins/security/admin/lib/probes.js +39 -0
- package/plugins/security/admin/lib/settings.js +86 -0
- package/plugins/security/admin/templates/security.html +170 -0
- package/plugins/security/admin/views/security.js +529 -0
- package/plugins/security/bin/unlock.js +81 -0
- package/plugins/security/config.js +7 -0
- package/plugins/security/plugin.js +303 -0
- package/plugins/security/plugin.json +46 -0
- package/plugins/security/server/common-passwords.NOTICE +3 -0
- package/plugins/security/server/common-passwords.txt +10001 -0
- package/plugins/security/server/facts.js +133 -0
- package/plugins/security/server/state.js +106 -0
- package/plugins/security/tests/security.test.js +134 -0
- package/server/routes/api/auth.js +241 -39
- package/server/routes/api/forms.js +3 -1
- package/server/routes/api/users.js +20 -0
- package/server/server.js +25 -7
- package/server/services/audit.js +63 -0
- package/server/services/authChecks.js +128 -0
- package/server/services/avatars.js +77 -0
- package/server/services/network.js +49 -0
- package/server/services/plugins.js +5 -1
- package/server/services/sessions.js +171 -0
- package/server/services/siteGitignore.js +1 -0
- package/server/services/users.js +5 -0
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
* POST /api/auth/setup - create initial admin (only when 0 users)
|
|
5
5
|
* POST /api/auth/login - { email, password } → { token, refreshToken, user }
|
|
6
6
|
* GET /api/auth/me - return current user from token
|
|
7
|
-
* POST /api/auth/
|
|
8
|
-
*
|
|
7
|
+
* POST /api/auth/me/avatar - upload your picture (multipart `file`); DELETE removes it
|
|
8
|
+
* GET /api/auth/avatar/:name - an avatar image (public: an <img> cannot send the token)
|
|
9
|
+
* POST /api/auth/login/verify - { ticket, response } → tokens, when a sign-in check asked a question (2FA)
|
|
10
|
+
* POST /api/auth/refresh - { refreshToken } → { token } - only while its session is live
|
|
11
|
+
* POST /api/auth/logout - { refreshToken } → { ok: true } - ends the session
|
|
12
|
+
* GET /api/auth/sessions - your sessions; DELETE /api/auth/sessions/:sid; POST /api/auth/sessions/revoke-others
|
|
13
|
+
*
|
|
14
|
+
* Sign-in checks and password checks come from plugins (services/authChecks.js);
|
|
15
|
+
* sessions live in services/sessions.js. Events: auth:loginFailed, auth:loginSucceeded,
|
|
16
|
+
* auth:loggedOut, auth:passwordChanged, auth:rateLimited (and user:loggedIn, as before).
|
|
9
17
|
*/
|
|
10
18
|
import crypto from 'node:crypto';
|
|
11
19
|
import {config, getConfig} from '../../config.js';
|
|
@@ -29,34 +37,35 @@ import {
|
|
|
29
37
|
validatePassword
|
|
30
38
|
} from '../../services/users.js';
|
|
31
39
|
import {getProfile, updateProfile} from '../../services/userProfiles.js';
|
|
40
|
+
import {acceptedType, AVATAR_DIR, AVATAR_FILE, AVATAR_MAX_BYTES, removeAvatar, saveAvatar, toAvatar} from '../../services/avatars.js';
|
|
41
|
+
import fsp from 'node:fs/promises';
|
|
42
|
+
import nodePath from 'node:path';
|
|
32
43
|
import {buildClearedSessionCookie, buildSessionCookie} from '../../services/viewerSession.js';
|
|
33
44
|
import {createTransport, sendEmail} from '../../services/email.js';
|
|
34
45
|
import {loginFailed, loginRateLimited} from '../../services/notification-sources.js';
|
|
46
|
+
import {afterIndexOf, checkPassword, runLoginChecks, verifyChallenge} from '../../services/authChecks.js';
|
|
47
|
+
import {createSession, isLegacyTokenRevoked, listSessions, revokeLegacyToken, revokeSession, revokeUserSessions, useSession} from '../../services/sessions.js';
|
|
35
48
|
|
|
36
49
|
const { accessTokenExpiry, refreshTokenExpiry } = config.auth;
|
|
37
50
|
|
|
38
|
-
/** In-memory blacklist of invalidated refresh tokens (cleared on server restart). */
|
|
39
|
-
const blacklistedRefreshTokens = new Set();
|
|
40
|
-
|
|
41
51
|
/** Guards the setup endpoint against concurrent first-admin creation requests. */
|
|
42
52
|
let setupInProgress = false;
|
|
43
53
|
|
|
44
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Sign-in tickets: a sign-in waiting on a challenge (two-factor). Single use,
|
|
56
|
+
* five minutes, five tries. In memory: a restart just means signing in again.
|
|
57
|
+
*/
|
|
58
|
+
const tickets = new Map();
|
|
59
|
+
const TICKET_TRIES = 5;
|
|
45
60
|
setInterval(() => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
} catch {
|
|
50
|
-
blacklistedRefreshTokens.delete(token);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}, 15 * 60 * 1000);
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
for (const [jti, t] of tickets) if (t.expires < now) tickets.delete(jti);
|
|
63
|
+
}, 60_000).unref?.();
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
const uaOf = (request) => String(request.headers?.['user-agent'] ?? '').slice(0, 300);
|
|
66
|
+
const passwordProblems = (problems) => problems.join(' ');
|
|
57
67
|
|
|
58
68
|
export async function authRoutes(fastify) {
|
|
59
|
-
fastifyRef = fastify;
|
|
60
69
|
// GET /api/auth/setup-status
|
|
61
70
|
fastify.get('/auth/setup-status', async () => {
|
|
62
71
|
const count = await countUsers();
|
|
@@ -84,10 +93,14 @@ export async function authRoutes(fastify) {
|
|
|
84
93
|
return reply.status(400).send({ error: 'Password must be at least 8 characters' });
|
|
85
94
|
}
|
|
86
95
|
|
|
96
|
+
const problems = await checkPassword(password, {email, name}, {log: fastify.log});
|
|
97
|
+
if (problems.length) return reply.status(400).send({error: passwordProblems(problems), problems});
|
|
98
|
+
|
|
87
99
|
setupInProgress = true;
|
|
88
100
|
try {
|
|
89
101
|
const user = await createUser({name, email, password, role: 'super-admin'});
|
|
90
|
-
const {
|
|
102
|
+
const sid = await createSession({userId: user.id, ip: request.ip, ua: uaOf(request), ttlMs: parseDuration(refreshTokenExpiry)});
|
|
103
|
+
const {token, refreshToken} = signTokens(fastify, user, sid);
|
|
91
104
|
return reply.status(201).send({token, refreshToken, user});
|
|
92
105
|
} finally {
|
|
93
106
|
setupInProgress = false;
|
|
@@ -95,31 +108,112 @@ export async function authRoutes(fastify) {
|
|
|
95
108
|
});
|
|
96
109
|
|
|
97
110
|
// POST /api/auth/login
|
|
98
|
-
fastify.post('/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute', onExceeded: (req) => {
|
|
111
|
+
fastify.post('/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute', onExceeded: (req) => {
|
|
112
|
+
loginRateLimited(req.ip);
|
|
113
|
+
hooks.emit('auth:rateLimited', {ip: req.ip, ua: uaOf(req), route: '/api/auth/login'});
|
|
114
|
+
} } } }, async (request, reply) => {
|
|
99
115
|
const { email, password } = request.body || {};
|
|
100
116
|
if (!email || !password) {
|
|
101
117
|
return reply.status(400).send({ error: 'email and password are required' });
|
|
102
118
|
}
|
|
119
|
+
const ip = request.ip;
|
|
120
|
+
const ua = uaOf(request);
|
|
121
|
+
const failed = (reason, extra = {}) => {
|
|
122
|
+
loginFailed(email, ip);
|
|
123
|
+
hooks.emit('auth:loginFailed', {email: String(email).toLowerCase().trim(), ip, ua, reason, ...extra});
|
|
124
|
+
};
|
|
103
125
|
|
|
104
126
|
const user = await getUserByEmail(email);
|
|
127
|
+
// Before the password is looked at: a lockout refuses right or wrong alike.
|
|
128
|
+
const pre = await runLoginChecks('before', {email, user: user && user.isActive ? user : null, request, ip, ua}, {log: fastify.log});
|
|
129
|
+
if (pre.deny) {
|
|
130
|
+
failed('denied', {by: pre.by, userId: user?.id ?? null});
|
|
131
|
+
return reply.status(pre.status).send({error: pre.message});
|
|
132
|
+
}
|
|
133
|
+
|
|
105
134
|
if (!user || !user.isActive) {
|
|
106
|
-
|
|
135
|
+
failed(user ? 'inactive' : 'unknown');
|
|
107
136
|
return reply.status(401).send({ error: 'Invalid credentials' });
|
|
108
137
|
}
|
|
109
138
|
|
|
110
139
|
const valid = await validatePassword(password, user.password);
|
|
111
140
|
if (!valid) {
|
|
112
|
-
|
|
141
|
+
failed('password', {userId: user.id});
|
|
113
142
|
return reply.status(401).send({ error: 'Invalid credentials' });
|
|
114
143
|
}
|
|
115
144
|
|
|
116
|
-
|
|
145
|
+
return continueLogin(user, request, reply, 0);
|
|
146
|
+
});
|
|
117
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The password was right: the 'after' checks from `start`, then tokens - or a
|
|
150
|
+
* challenge, answered at /auth/login/verify.
|
|
151
|
+
*/
|
|
152
|
+
async function continueLogin(user, request, reply, start) {
|
|
153
|
+
const ip = request.ip;
|
|
154
|
+
const ua = uaOf(request);
|
|
155
|
+
const after = await runLoginChecks('after', {email: user.email, user, request, ip, ua}, {start, log: fastify.log});
|
|
156
|
+
if (after.deny) {
|
|
157
|
+
loginFailed(user.email, ip);
|
|
158
|
+
hooks.emit('auth:loginFailed', {email: user.email, ip, ua, reason: 'denied', by: after.by, userId: user.id});
|
|
159
|
+
return reply.status(after.status).send({error: after.message});
|
|
160
|
+
}
|
|
161
|
+
if (after.challenge) {
|
|
162
|
+
const jti = crypto.randomBytes(12).toString('hex');
|
|
163
|
+
tickets.set(jti, {userId: user.id, by: after.by, index: after.index, tries: 0, expires: Date.now() + 5 * 60_000});
|
|
164
|
+
const ticket = fastify.jwt.sign({type: 'login-ticket', id: user.id, jti}, {expiresIn: '5m'});
|
|
165
|
+
const c = after.challenge;
|
|
166
|
+
return {challenge: {kind: String(c.kind || 'code'), label: String(c.label || 'Code'), help: String(c.help || ''),
|
|
167
|
+
inputMode: c.inputMode === 'text' ? 'text' : 'numeric', recovery: c.recovery === true, trust: c.trust === true}, ticket};
|
|
168
|
+
}
|
|
169
|
+
return finishLogin(user, request, reply);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Signed in: a session, tokens, the events. */
|
|
173
|
+
async function finishLogin(user, request, reply) {
|
|
174
|
+
const ip = request.ip;
|
|
175
|
+
const ua = uaOf(request);
|
|
176
|
+
await touchLastLogin(user.id);
|
|
177
|
+
const sid = await createSession({userId: user.id, ip, ua, ttlMs: parseDuration(refreshTokenExpiry)});
|
|
118
178
|
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
|
|
119
179
|
hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
|
|
120
|
-
|
|
180
|
+
hooks.emit('auth:loginSucceeded', {userId: user.id, email: user.email, role: user.role, ip, ua, sid});
|
|
181
|
+
const { token, refreshToken } = signTokens(fastify, safeUser, sid);
|
|
121
182
|
setSessionCookie(request, reply, token);
|
|
122
|
-
return { token, refreshToken, user: safeUser };
|
|
183
|
+
return { token, refreshToken, user: {...safeUser, ...(user.avatar && {avatar: user.avatar})} };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// POST /api/auth/login/verify - the answer to a sign-in challenge (two-factor)
|
|
187
|
+
fastify.post('/auth/login/verify', {config: {rateLimit: {max: 10, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
188
|
+
const {ticket, response, trust, mode} = request.body || {};
|
|
189
|
+
let payload;
|
|
190
|
+
try {
|
|
191
|
+
payload = fastify.jwt.verify(String(ticket || ''));
|
|
192
|
+
} catch {
|
|
193
|
+
return reply.status(401).send({error: 'This sign-in has timed out. Sign in again.'});
|
|
194
|
+
}
|
|
195
|
+
const t = payload?.type === 'login-ticket' ? tickets.get(payload.jti) : null;
|
|
196
|
+
if (!t || t.userId !== payload.id) return reply.status(401).send({error: 'This sign-in has timed out. Sign in again.'});
|
|
197
|
+
const user = await getUserById(t.userId);
|
|
198
|
+
if (!user || !user.isActive) {
|
|
199
|
+
tickets.delete(payload.jti);
|
|
200
|
+
return reply.status(401).send({error: 'Invalid credentials'});
|
|
201
|
+
}
|
|
202
|
+
const out = await verifyChallenge(t.by, {user, request, response: String(response ?? '').slice(0, 200), mode: mode === 'recovery' ? 'recovery' : 'code', trust: trust === true, ip: request.ip, ua: uaOf(request)});
|
|
203
|
+
if (!out.ok) {
|
|
204
|
+
t.tries++;
|
|
205
|
+
loginFailed(user.email, request.ip);
|
|
206
|
+
hooks.emit('auth:loginFailed', {email: user.email, ip: request.ip, ua: uaOf(request), reason: 'challenge', by: t.by, userId: user.id});
|
|
207
|
+
if (t.tries >= TICKET_TRIES) {
|
|
208
|
+
tickets.delete(payload.jti);
|
|
209
|
+
return reply.status(401).send({error: 'Too many tries. Sign in again.'});
|
|
210
|
+
}
|
|
211
|
+
return reply.status(401).send({error: out.message, triesLeft: TICKET_TRIES - t.tries});
|
|
212
|
+
}
|
|
213
|
+
tickets.delete(payload.jti);
|
|
214
|
+
const next = afterIndexOf(t.by);
|
|
215
|
+
if (out.trust) reply.header('set-cookie', out.trust);
|
|
216
|
+
return continueLogin(user, request, reply, next < 0 ? 0 : next + 1);
|
|
123
217
|
});
|
|
124
218
|
|
|
125
219
|
// GET /api/auth/permissions - returns the raw permissions array for the authenticated user's role
|
|
@@ -178,6 +272,11 @@ export async function authRoutes(fastify) {
|
|
|
178
272
|
if (password && password.length < 8) {
|
|
179
273
|
return reply.status(400).send({error: 'Password must be at least 8 characters'});
|
|
180
274
|
}
|
|
275
|
+
if (password) {
|
|
276
|
+
const me = await getUserById(id);
|
|
277
|
+
const problems = await checkPassword(password, {email: email || me?.email, name: name || me?.name, userId: id, request}, {log: fastify.log});
|
|
278
|
+
if (problems.length) return reply.status(400).send({error: passwordProblems(problems), problems});
|
|
279
|
+
}
|
|
181
280
|
|
|
182
281
|
const coreUpdates = {};
|
|
183
282
|
if (name) coreUpdates.name = name;
|
|
@@ -195,11 +294,94 @@ export async function authRoutes(fastify) {
|
|
|
195
294
|
if (profile && typeof profile === 'object') {
|
|
196
295
|
await updateProfile(id, profile);
|
|
197
296
|
}
|
|
297
|
+
if (password) {
|
|
298
|
+
// A new password ends every other session - the old one may be why it changed.
|
|
299
|
+
await revokeUserSessions(id, {except: request.user.sid || null});
|
|
300
|
+
hooks.emit('auth:passwordChanged', {userId: id, by: id, ip: request.ip, ua: uaOf(request)});
|
|
301
|
+
}
|
|
198
302
|
|
|
199
303
|
const profileEntry = await getProfile(id);
|
|
200
304
|
return {...user, profile: profileEntry?.data || {}};
|
|
201
305
|
});
|
|
202
306
|
|
|
307
|
+
// GET /api/auth/sessions - your sessions (the one you are using marked)
|
|
308
|
+
fastify.get('/auth/sessions', {preHandler: [authenticate]}, async (request) => {
|
|
309
|
+
const list = await listSessions({userId: request.user.id});
|
|
310
|
+
return list.map(s => ({sid: s.sid, createdAt: s.createdAt, lastUsedAt: s.lastUsedAt, expiresAt: s.expiresAt,
|
|
311
|
+
ip: s.ip, ua: s.ua, current: s.sid === request.user.sid}));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// DELETE /api/auth/sessions/:sid - end one of your sessions
|
|
315
|
+
fastify.delete('/auth/sessions/:sid', {preHandler: [authenticate]}, async (request, reply) => {
|
|
316
|
+
const ok = await revokeSession(String(request.params.sid), {userId: request.user.id});
|
|
317
|
+
if (!ok) return reply.status(404).send({error: 'Session not found'});
|
|
318
|
+
hooks.emit('auth:loggedOut', {userId: request.user.id, sid: request.params.sid, ip: request.ip, by: request.user.id});
|
|
319
|
+
return {ok: true};
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// POST /api/auth/sessions/revoke-others - sign out everywhere else
|
|
323
|
+
fastify.post('/auth/sessions/revoke-others', {preHandler: [authenticate]}, async (request) => {
|
|
324
|
+
const n = await revokeUserSessions(request.user.id, {except: request.user.sid || null});
|
|
325
|
+
hooks.emit('auth:loggedOut', {userId: request.user.id, all: true, ip: request.ip, by: request.user.id});
|
|
326
|
+
return {ok: true, ended: n};
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// POST /api/auth/me/avatar - your picture: any common image, stored as a 256 px WebP
|
|
330
|
+
fastify.post('/auth/me/avatar', {preHandler: [authenticate], config: {rateLimit: {max: 20, timeWindow: '10 minutes'}}}, async (request, reply) => {
|
|
331
|
+
const upload = await request.file({limits: {fileSize: AVATAR_MAX_BYTES, files: 1}}).catch(() => null);
|
|
332
|
+
if (!upload) return reply.status(400).send({error: 'Choose an image to upload.'});
|
|
333
|
+
if (!acceptedType(upload.mimetype)) {
|
|
334
|
+
upload.file.resume();
|
|
335
|
+
return reply.status(415).send({error: 'That is not an image. Use a JPEG, PNG, WebP or GIF.'});
|
|
336
|
+
}
|
|
337
|
+
let input;
|
|
338
|
+
try {
|
|
339
|
+
input = await upload.toBuffer();
|
|
340
|
+
} catch {
|
|
341
|
+
return reply.status(413).send({error: `That image is too large - at most ${Math.round(AVATAR_MAX_BYTES / 1048576)} MB.`});
|
|
342
|
+
}
|
|
343
|
+
let webp;
|
|
344
|
+
try {
|
|
345
|
+
webp = await toAvatar(input);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
return reply.status(400).send({error: err.message});
|
|
348
|
+
}
|
|
349
|
+
const before = await getUserById(request.user.id);
|
|
350
|
+
if (!before) return reply.status(404).send({error: 'User not found'});
|
|
351
|
+
const avatar = await saveAvatar(request.user.id, webp);
|
|
352
|
+
const user = await updateUser(request.user.id, {avatar});
|
|
353
|
+
if (before.avatar && before.avatar !== avatar) await removeAvatar(before.avatar);
|
|
354
|
+
const profileEntry = await getProfile(request.user.id);
|
|
355
|
+
return {...user, profile: profileEntry?.data || {}};
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// DELETE /api/auth/me/avatar - back to initials
|
|
359
|
+
fastify.delete('/auth/me/avatar', {preHandler: [authenticate]}, async (request, reply) => {
|
|
360
|
+
const before = await getUserById(request.user.id);
|
|
361
|
+
if (!before) return reply.status(404).send({error: 'User not found'});
|
|
362
|
+
const user = await updateUser(request.user.id, {avatar: ''});
|
|
363
|
+
if (before.avatar) await removeAvatar(before.avatar);
|
|
364
|
+
const profileEntry = await getProfile(request.user.id);
|
|
365
|
+
return {...user, profile: profileEntry?.data || {}};
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// GET /api/auth/avatar/:name - the image. Names are random per upload, so it never changes: cache it for good.
|
|
369
|
+
fastify.get('/auth/avatar/:name', async (request, reply) => {
|
|
370
|
+
const name = String(request.params.name || '');
|
|
371
|
+
if (!AVATAR_FILE.test(name)) return reply.status(404).send({error: 'Not found'});
|
|
372
|
+
let data;
|
|
373
|
+
try {
|
|
374
|
+
data = await fsp.readFile(nodePath.join(AVATAR_DIR, name));
|
|
375
|
+
} catch {
|
|
376
|
+
return reply.status(404).send({error: 'Not found'});
|
|
377
|
+
}
|
|
378
|
+
return reply
|
|
379
|
+
.header('Cache-Control', 'public, max-age=31536000, immutable')
|
|
380
|
+
.header('X-Content-Type-Options', 'nosniff')
|
|
381
|
+
.type('image/webp')
|
|
382
|
+
.send(data);
|
|
383
|
+
});
|
|
384
|
+
|
|
203
385
|
// POST /api/auth/forgot-password - send a password reset email (no auth)
|
|
204
386
|
fastify.post('/auth/forgot-password', { config: { rateLimit: { max: 3, timeWindow: '1 hour' } } }, async (request) => {
|
|
205
387
|
const {email} = request.body || {};
|
|
@@ -275,18 +457,32 @@ export async function authRoutes(fastify) {
|
|
|
275
457
|
return reply.status(400).send({error: 'Invalid or expired reset link'});
|
|
276
458
|
}
|
|
277
459
|
|
|
460
|
+
const problems = await checkPassword(password, {email: user.email, name: user.name, userId: user.id, request}, {log: fastify.log});
|
|
461
|
+
if (problems.length) return reply.status(400).send({error: passwordProblems(problems), problems});
|
|
462
|
+
|
|
278
463
|
await updateUser(user.id, {password});
|
|
279
464
|
await clearResetToken(user.id);
|
|
465
|
+
await revokeUserSessions(user.id);
|
|
466
|
+
hooks.emit('auth:passwordChanged', {userId: user.id, by: 'reset', ip: request.ip, ua: uaOf(request)});
|
|
280
467
|
return {ok: true};
|
|
281
468
|
});
|
|
282
469
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
470
|
+
// POST /api/auth/logout - ends the session (fire-and-forget safe: no auth required)
|
|
471
|
+
fastify.post('/auth/logout', async (request, reply) => {
|
|
472
|
+
const {refreshToken} = request.body || {};
|
|
473
|
+
if (refreshToken) {
|
|
474
|
+
try {
|
|
475
|
+
const p = fastify.jwt.verify(String(refreshToken));
|
|
476
|
+
if (p?.type === 'refresh') {
|
|
477
|
+
if (p.sid) await revokeSession(p.sid, {userId: p.id});
|
|
478
|
+
else await revokeLegacyToken(refreshToken, p.exp);
|
|
479
|
+
hooks.emit('auth:loggedOut', {userId: p.id, sid: p.sid || null, ip: request.ip, by: p.id});
|
|
480
|
+
}
|
|
481
|
+
} catch { /* expired or not ours: nothing to end */ }
|
|
482
|
+
}
|
|
483
|
+
reply.header('set-cookie', buildClearedSessionCookie({secure: request.protocol === 'https'}));
|
|
484
|
+
return {ok: true};
|
|
485
|
+
});
|
|
290
486
|
|
|
291
487
|
// POST /api/auth/refresh
|
|
292
488
|
fastify.post('/auth/refresh', async (request, reply) => {
|
|
@@ -295,10 +491,6 @@ export async function authRoutes(fastify) {
|
|
|
295
491
|
return reply.status(400).send({ error: 'refreshToken is required' });
|
|
296
492
|
}
|
|
297
493
|
|
|
298
|
-
if (blacklistedRefreshTokens.has(refreshToken)) {
|
|
299
|
-
return reply.status(401).send({error: 'Refresh token has been revoked'});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
494
|
let payload;
|
|
303
495
|
try {
|
|
304
496
|
payload = fastify.jwt.verify(refreshToken);
|
|
@@ -310,13 +502,22 @@ export async function authRoutes(fastify) {
|
|
|
310
502
|
return reply.status(401).send({ error: 'Invalid token type' });
|
|
311
503
|
}
|
|
312
504
|
|
|
505
|
+
// The session behind it must still be live (signed out, "everywhere", a new password end it).
|
|
506
|
+
if (payload.sid) {
|
|
507
|
+
if (!await useSession(payload.sid, {userId: payload.id, ip: request.ip, ua: uaOf(request)})) {
|
|
508
|
+
return reply.status(401).send({error: 'This session has ended. Sign in again.'});
|
|
509
|
+
}
|
|
510
|
+
} else if (await isLegacyTokenRevoked(refreshToken)) {
|
|
511
|
+
return reply.status(401).send({error: 'Refresh token has been revoked'});
|
|
512
|
+
}
|
|
513
|
+
|
|
313
514
|
const user = await getUserById(payload.id);
|
|
314
515
|
if (!user || !user.isActive) {
|
|
315
516
|
return reply.status(401).send({ error: 'User not found or inactive' });
|
|
316
517
|
}
|
|
317
518
|
|
|
318
519
|
const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
|
|
319
|
-
const token = fastify.jwt.sign({ ...safeUser, type: 'access' }, { expiresIn: accessTokenExpiry });
|
|
520
|
+
const token = fastify.jwt.sign({ ...safeUser, type: 'access', ...(payload.sid && {sid: payload.sid}) }, { expiresIn: accessTokenExpiry });
|
|
320
521
|
setSessionCookie(request, reply, token);
|
|
321
522
|
return { token };
|
|
322
523
|
});
|
|
@@ -366,10 +567,11 @@ function parseDuration(str) {
|
|
|
366
567
|
*
|
|
367
568
|
* @param {object} fastify
|
|
368
569
|
* @param {object} payload
|
|
570
|
+
* @param {string|null} [sid] - the session (services/sessions.js) both tokens belong to
|
|
369
571
|
* @returns {{ token: string, refreshToken: string }}
|
|
370
572
|
*/
|
|
371
|
-
function signTokens(fastify, payload) {
|
|
372
|
-
const token = fastify.jwt.sign({ ...payload, type: 'access' }, { expiresIn: accessTokenExpiry });
|
|
373
|
-
const refreshToken = fastify.jwt.sign({ id: payload.id, type: 'refresh' }, { expiresIn: refreshTokenExpiry });
|
|
573
|
+
function signTokens(fastify, payload, sid = null) {
|
|
574
|
+
const token = fastify.jwt.sign({ ...payload, type: 'access', ...(sid && {sid}) }, { expiresIn: accessTokenExpiry });
|
|
575
|
+
const refreshToken = fastify.jwt.sign({ id: payload.id, type: 'refresh', ...(sid && {sid}) }, { expiresIn: refreshTokenExpiry });
|
|
374
576
|
return { token, refreshToken };
|
|
375
577
|
}
|
|
@@ -836,7 +836,9 @@ export async function formsRoutes(fastify) {
|
|
|
836
836
|
const parts = [];
|
|
837
837
|
if (missingFields.length) parts.push(`Required fields missing: ${missingFields.join(', ')}`);
|
|
838
838
|
if (validationErrors.length) parts.push(validationErrors.join('; '));
|
|
839
|
-
|
|
839
|
+
// Messages usually carry their own full stop; add one only where missing.
|
|
840
|
+
const text = parts.join('. ');
|
|
841
|
+
return reply.status(400).send({ error: /[.!?]$/.test(text) ? text : `${text}.` });
|
|
840
842
|
}
|
|
841
843
|
|
|
842
844
|
// Rate limit by IP
|
|
@@ -13,6 +13,9 @@ import {createUser, deleteUser, getUserById, listUsers, updateUser} from '../../
|
|
|
13
13
|
import {getProfile, listProfiles, updateProfile} from '../../services/userProfiles.js';
|
|
14
14
|
import {ago, clip, plural} from '../../services/sidebarBadges.js';
|
|
15
15
|
import * as notices from '../../services/notification-sources.js';
|
|
16
|
+
import {checkPassword} from '../../services/authChecks.js';
|
|
17
|
+
import {revokeUserSessions} from '../../services/sessions.js';
|
|
18
|
+
import {hooks} from '../../services/hooks.js';
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* The sidebar badge on Users (its countUrl, GET /api/users/_count). The number
|
|
@@ -105,6 +108,9 @@ export async function usersRoutes(fastify) {
|
|
|
105
108
|
return reply.status(400).send({ error: 'Password must be at least 8 characters' });
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
const problems = await checkPassword(password, {email, name, request}, {log: request.log});
|
|
112
|
+
if (problems.length) return reply.status(400).send({error: problems.join(' '), problems});
|
|
113
|
+
|
|
108
114
|
const targetRole = role || 'user';
|
|
109
115
|
// Pass the full actor object so multi-role privilege is evaluated;
|
|
110
116
|
// for the prospective target we only have a primary role here.
|
|
@@ -177,6 +183,14 @@ export async function usersRoutes(fastify) {
|
|
|
177
183
|
updates.additionalRoles = updates.additionalRoles.filter(r => r && r !== primary);
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
if (updates.password !== undefined) {
|
|
187
|
+
if (typeof updates.password !== 'string' || updates.password.length < 8) {
|
|
188
|
+
return reply.status(400).send({error: 'Password must be at least 8 characters'});
|
|
189
|
+
}
|
|
190
|
+
const problems = await checkPassword(updates.password, {email: updates.email || target.email, name: updates.name || target.name, userId: id, request}, {log: request.log});
|
|
191
|
+
if (problems.length) return reply.status(400).send({error: problems.join(' '), problems});
|
|
192
|
+
}
|
|
193
|
+
|
|
180
194
|
// Strip profile from core updates and handle separately
|
|
181
195
|
const {profile, ...coreUpdates} = updates;
|
|
182
196
|
let user;
|
|
@@ -191,6 +205,12 @@ export async function usersRoutes(fastify) {
|
|
|
191
205
|
await updateProfile(id, profile);
|
|
192
206
|
}
|
|
193
207
|
|
|
208
|
+
// A new password, or a switched-off account: their sessions end (yours, on a self-edit, stays).
|
|
209
|
+
if (updates.password || (updates.isActive === false && target.isActive !== false)) {
|
|
210
|
+
await revokeUserSessions(id, {except: isSelf ? (actor.sid || null) : null});
|
|
211
|
+
if (updates.password) hooks.emit('auth:passwordChanged', {userId: id, by: actor.id, ip: request.ip});
|
|
212
|
+
}
|
|
213
|
+
|
|
194
214
|
// Admin rights given (not kept): the Super Admins hear about it.
|
|
195
215
|
const adminRoles = (u) => [u?.role, ...(Array.isArray(u?.additionalRoles) ? u.additionalRoles : [])]
|
|
196
216
|
.filter(r => r === 'admin' || r === 'super-admin');
|
package/server/server.js
CHANGED
|
@@ -29,6 +29,7 @@ import {seedDefaultBlocks} from './services/blocks.js';
|
|
|
29
29
|
import {seedDefaultComponents} from './services/components.js';
|
|
30
30
|
import {refreshComponentTagAllowlist} from './services/markdown.js';
|
|
31
31
|
import * as cache from './services/cache/index.js';
|
|
32
|
+
import {isGenuinelyLocal, parseTrustProxy} from './services/network.js';
|
|
32
33
|
|
|
33
34
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
35
|
const ROOT = path.resolve(__dirname, '..');
|
|
@@ -83,10 +84,11 @@ function resolveLogLevel() {
|
|
|
83
84
|
|
|
84
85
|
const app = Fastify({
|
|
85
86
|
logger: {level: resolveLogLevel()},
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
87
|
+
// Behind a reverse proxy (Apache, domma-cms-manager), trust the proxy so
|
|
88
|
+
// request.ip is the visitor, not the proxy's loopback. TRUST_PROXY=loopback
|
|
89
|
+
// is the right value (right-most X-Forwarded-For entry a local proxy did not
|
|
90
|
+
// add); 1 / true still trusts every hop - see services/network.js.
|
|
91
|
+
trustProxy: parseTrustProxy(process.env.TRUST_PROXY),
|
|
90
92
|
});
|
|
91
93
|
|
|
92
94
|
// Register process error handlers immediately so startup errors are captured
|
|
@@ -148,9 +150,10 @@ await app.register(rateLimit, {
|
|
|
148
150
|
global: true, // apply default limit to all routes; stricter per-route limits override this
|
|
149
151
|
max: 500,
|
|
150
152
|
timeWindow: '1 minute',
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
153
|
+
// Only a genuinely local call is exempt (loopback socket, no X-Forwarded-For).
|
|
154
|
+
// A static loopback list exempted every visitor relayed by a proxy that is
|
|
155
|
+
// not trusted - on dcm, everyone, the login included. See services/network.js.
|
|
156
|
+
allowList: (request) => isGenuinelyLocal(request),
|
|
154
157
|
});
|
|
155
158
|
|
|
156
159
|
// Negotiates brotli/gzip/deflate per Accept-Encoding. Skips already-compressed
|
|
@@ -367,6 +370,21 @@ app.addHook('onResponse', async (request, reply) => {
|
|
|
367
370
|
app.log.warn(`[cache] Admin change did not clear the cache: ${err.message}`);
|
|
368
371
|
}
|
|
369
372
|
});
|
|
373
|
+
|
|
374
|
+
// Audit: every successful signed-in change through the API becomes an `audit`
|
|
375
|
+
// event (services/audit.js). Core keeps nothing; Security Pro stores the trail.
|
|
376
|
+
{
|
|
377
|
+
const {auditEventFor} = await import('./services/audit.js');
|
|
378
|
+
const {hooks: auditHooks} = await import('./services/hooks.js');
|
|
379
|
+
app.addHook('onResponse', async (request, reply) => {
|
|
380
|
+
try {
|
|
381
|
+
const event = auditEventFor(request, reply);
|
|
382
|
+
if (event) auditHooks.emit('audit', event);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
app.log.warn(`[audit] event not raised: ${err.message}`);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
370
388
|
console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disabled'}`);
|
|
371
389
|
|
|
372
390
|
// Docs are rendered at request time from the shipped admin templates; clear any
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit events - every change a signed-in person makes through the API.
|
|
3
|
+
*
|
|
4
|
+
* One `onResponse` hook (server.js) turns each successful POST / PUT / PATCH /
|
|
5
|
+
* DELETE under /api/ by a signed-in user into `hooks.emit('audit', event)`.
|
|
6
|
+
* Core stores nothing; Security Pro keeps the trail. Done once, here, rather
|
|
7
|
+
* than route by route, so a new route - or a plugin's - is covered without
|
|
8
|
+
* anyone remembering to.
|
|
9
|
+
*
|
|
10
|
+
* An event carries the NAMES of the fields sent, never their values: bodies hold
|
|
11
|
+
* passwords, SMTP credentials and API keys.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
15
|
+
|
|
16
|
+
/** Routes with events of their own (auth:*), or that are not someone changing something. */
|
|
17
|
+
const SKIP = [
|
|
18
|
+
/^\/api\/auth\/(login|login\/verify|refresh|logout|forgot-password|reset-password|setup)$/,
|
|
19
|
+
/^\/api\/analytics\//,
|
|
20
|
+
/^\/api\/notifications\/(read|seen|dismiss)/,
|
|
21
|
+
/\/(preview|render|validate|search)$/
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const VERB = {POST: 'created', PUT: 'updated', PATCH: 'updated', DELETE: 'deleted'};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The event for a finished request, or null when it is not one.
|
|
28
|
+
*
|
|
29
|
+
* @param {{method: string, url: string, routeOptions?: {url?: string}, params?: object, body?: any, user?: object, ip?: string, headers?: object}} request
|
|
30
|
+
* @param {{statusCode: number}} reply
|
|
31
|
+
* @returns {null | {at: string, actor: {id: string, name: string, email: string, role: string}, action: string,
|
|
32
|
+
* method: string, route: string, path: string, target: object, fields: string[], status: number, ip: string}}
|
|
33
|
+
*/
|
|
34
|
+
export function auditEventFor(request, reply) {
|
|
35
|
+
if (!MUTATING.has(request.method)) return null;
|
|
36
|
+
const status = reply.statusCode;
|
|
37
|
+
if (status < 200 || status >= 300) return null;
|
|
38
|
+
const path = String(request.url || '').split('?')[0];
|
|
39
|
+
if (!path.startsWith('/api/')) return null;
|
|
40
|
+
const route = String(request.routeOptions?.url || path);
|
|
41
|
+
if (SKIP.some(re => re.test(route))) return null;
|
|
42
|
+
const u = request.user;
|
|
43
|
+
if (!u?.id) return null;
|
|
44
|
+
|
|
45
|
+
// "/api/plugins/blog/posts/:id" -> resource "plugins/blog/posts".
|
|
46
|
+
const resource = route.replace(/^\/api\//, '').split('/').filter(p => p && !p.startsWith(':')).join('/') || 'api';
|
|
47
|
+
const body = request.body;
|
|
48
|
+
const fields = body && typeof body === 'object' && !Array.isArray(body) && !Buffer.isBuffer(body)
|
|
49
|
+
? Object.keys(body).slice(0, 40)
|
|
50
|
+
: [];
|
|
51
|
+
return {
|
|
52
|
+
at: new Date().toISOString(),
|
|
53
|
+
actor: {id: String(u.id), name: String(u.name || ''), email: String(u.email || ''), role: String(u.role || '')},
|
|
54
|
+
action: `${resource} ${VERB[request.method]}`,
|
|
55
|
+
method: request.method,
|
|
56
|
+
route,
|
|
57
|
+
path: path.slice(0, 300),
|
|
58
|
+
target: {...(request.params || {})},
|
|
59
|
+
fields,
|
|
60
|
+
status,
|
|
61
|
+
ip: String(request.ip || '')
|
|
62
|
+
};
|
|
63
|
+
}
|