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.
Files changed (43) hide show
  1. package/admin/css/admin.css +1 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +5 -4
  4. package/admin/js/lib/avatar.js +1 -0
  5. package/admin/js/templates/login.html +16 -0
  6. package/admin/js/templates/my-profile.html +23 -3
  7. package/admin/js/views/login.js +2 -2
  8. package/admin/js/views/my-profile.js +1 -1
  9. package/bin/update.js +7 -3
  10. package/config/plugins.json +5 -0
  11. package/package.json +2 -1
  12. package/plugins/free-tier.lock.json +25 -1
  13. package/plugins/security/CLAUDE.md +41 -0
  14. package/plugins/security/admin/lib/blocks.js +46 -0
  15. package/plugins/security/admin/lib/health.js +148 -0
  16. package/plugins/security/admin/lib/ip.js +44 -0
  17. package/plugins/security/admin/lib/lockout.js +78 -0
  18. package/plugins/security/admin/lib/policy.js +32 -0
  19. package/plugins/security/admin/lib/probes.js +39 -0
  20. package/plugins/security/admin/lib/settings.js +86 -0
  21. package/plugins/security/admin/templates/security.html +170 -0
  22. package/plugins/security/admin/views/security.js +529 -0
  23. package/plugins/security/bin/unlock.js +81 -0
  24. package/plugins/security/config.js +7 -0
  25. package/plugins/security/plugin.js +303 -0
  26. package/plugins/security/plugin.json +46 -0
  27. package/plugins/security/server/common-passwords.NOTICE +3 -0
  28. package/plugins/security/server/common-passwords.txt +10001 -0
  29. package/plugins/security/server/facts.js +133 -0
  30. package/plugins/security/server/state.js +106 -0
  31. package/plugins/security/tests/security.test.js +134 -0
  32. package/server/routes/api/auth.js +241 -39
  33. package/server/routes/api/forms.js +3 -1
  34. package/server/routes/api/users.js +20 -0
  35. package/server/server.js +25 -7
  36. package/server/services/audit.js +63 -0
  37. package/server/services/authChecks.js +128 -0
  38. package/server/services/avatars.js +77 -0
  39. package/server/services/network.js +49 -0
  40. package/server/services/plugins.js +5 -1
  41. package/server/services/sessions.js +171 -0
  42. package/server/services/siteGitignore.js +1 -0
  43. 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/refresh - { refreshToken } → { token }
8
- * POST /api/auth/logout - { refreshToken } → { ok: true } - blacklists the refresh token
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
- // Purge expired tokens every 15 minutes to avoid unbounded growth.
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
- for (const token of blacklistedRefreshTokens) {
47
- try {
48
- fastifyRef.jwt.verify(token);
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
- /** Holds the fastify instance once routes are registered (needed by the cleanup interval). */
56
- let fastifyRef;
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 {token, refreshToken} = signTokens(fastify, user);
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) => { loginRateLimited(req.ip); } } } }, async (request, reply) => {
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
- loginFailed(email, request.ip);
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
- loginFailed(email, request.ip);
141
+ failed('password', {userId: user.id});
113
142
  return reply.status(401).send({ error: 'Invalid credentials' });
114
143
  }
115
144
 
116
- await touchLastLogin(user.id);
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
- const { token, refreshToken } = signTokens(fastify, safeUser);
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
- // POST /api/auth/logout - blacklists the refresh token (fire-and-forget safe: no auth required)
284
- fastify.post('/auth/logout', async (request, reply) => {
285
- const {refreshToken} = request.body || {};
286
- if (refreshToken) blacklistedRefreshTokens.add(refreshToken);
287
- reply.header('set-cookie', buildClearedSessionCookie({secure: request.protocol === 'https'}));
288
- return {ok: true};
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
- return reply.status(400).send({ error: `${parts.join('. ')}.` });
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
- // When running behind a reverse proxy (e.g. domma-cms-manager), trust the
87
- // X-Forwarded-For header so @fastify/rate-limit keys on the real client IP
88
- // rather than the proxy's loopback address.
89
- trustProxy: !!process.env.TRUST_PROXY,
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
- // Loopback is always exempt - local admin use should never be rate-limited.
152
- // When behind a reverse proxy (TRUST_PROXY), the proxy's loopback is also exempt.
153
- allowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
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
+ }