domma-cms 0.82.0 → 0.84.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 (36) hide show
  1. package/admin/css/admin.css +1 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/templates/login.html +24 -0
  4. package/admin/js/views/login.js +7 -7
  5. package/bin/update.js +7 -3
  6. package/config/plugins.json +5 -0
  7. package/package.json +2 -1
  8. package/plugins/free-tier.lock.json +25 -1
  9. package/plugins/security/CLAUDE.md +46 -0
  10. package/plugins/security/admin/lib/blocks.js +46 -0
  11. package/plugins/security/admin/lib/health.js +148 -0
  12. package/plugins/security/admin/lib/ip.js +44 -0
  13. package/plugins/security/admin/lib/lockout.js +78 -0
  14. package/plugins/security/admin/lib/policy.js +32 -0
  15. package/plugins/security/admin/lib/probes.js +39 -0
  16. package/plugins/security/admin/lib/settings.js +86 -0
  17. package/plugins/security/admin/templates/security.html +170 -0
  18. package/plugins/security/admin/views/security.js +529 -0
  19. package/plugins/security/bin/unlock.js +81 -0
  20. package/plugins/security/config.js +7 -0
  21. package/plugins/security/plugin.js +303 -0
  22. package/plugins/security/plugin.json +64 -0
  23. package/plugins/security/server/common-passwords.NOTICE +3 -0
  24. package/plugins/security/server/common-passwords.txt +10001 -0
  25. package/plugins/security/server/facts.js +135 -0
  26. package/plugins/security/server/state.js +106 -0
  27. package/plugins/security/tests/security.test.js +134 -0
  28. package/server/routes/api/auth.js +184 -39
  29. package/server/routes/api/users.js +20 -0
  30. package/server/server.js +25 -7
  31. package/server/services/audit.js +63 -0
  32. package/server/services/authChecks.js +128 -0
  33. package/server/services/network.js +49 -0
  34. package/server/services/plugins.js +5 -1
  35. package/server/services/sessions.js +171 -0
  36. package/server/services/siteGitignore.js +1 -0
@@ -6,8 +6,14 @@
6
6
  * GET /api/auth/me - return current user from token
7
7
  * POST /api/auth/me/avatar - upload your picture (multipart `file`); DELETE removes it
8
8
  * GET /api/auth/avatar/:name - an avatar image (public: an <img> cannot send the token)
9
- * POST /api/auth/refresh - { refreshToken } → { token }
10
- * POST /api/auth/logout - { refreshToken } → { ok: true } - blacklists the refresh 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).
11
17
  */
12
18
  import crypto from 'node:crypto';
13
19
  import {config, getConfig} from '../../config.js';
@@ -37,31 +43,29 @@ import nodePath from 'node:path';
37
43
  import {buildClearedSessionCookie, buildSessionCookie} from '../../services/viewerSession.js';
38
44
  import {createTransport, sendEmail} from '../../services/email.js';
39
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';
40
48
 
41
49
  const { accessTokenExpiry, refreshTokenExpiry } = config.auth;
42
50
 
43
- /** In-memory blacklist of invalidated refresh tokens (cleared on server restart). */
44
- const blacklistedRefreshTokens = new Set();
45
-
46
51
  /** Guards the setup endpoint against concurrent first-admin creation requests. */
47
52
  let setupInProgress = false;
48
53
 
49
- // 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;
50
60
  setInterval(() => {
51
- for (const token of blacklistedRefreshTokens) {
52
- try {
53
- fastifyRef.jwt.verify(token);
54
- } catch {
55
- blacklistedRefreshTokens.delete(token);
56
- }
57
- }
58
- }, 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?.();
59
64
 
60
- /** Holds the fastify instance once routes are registered (needed by the cleanup interval). */
61
- let fastifyRef;
65
+ const uaOf = (request) => String(request.headers?.['user-agent'] ?? '').slice(0, 300);
66
+ const passwordProblems = (problems) => problems.join(' ');
62
67
 
63
68
  export async function authRoutes(fastify) {
64
- fastifyRef = fastify;
65
69
  // GET /api/auth/setup-status
66
70
  fastify.get('/auth/setup-status', async () => {
67
71
  const count = await countUsers();
@@ -89,10 +93,14 @@ export async function authRoutes(fastify) {
89
93
  return reply.status(400).send({ error: 'Password must be at least 8 characters' });
90
94
  }
91
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
+
92
99
  setupInProgress = true;
93
100
  try {
94
101
  const user = await createUser({name, email, password, role: 'super-admin'});
95
- 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);
96
104
  return reply.status(201).send({token, refreshToken, user});
97
105
  } finally {
98
106
  setupInProgress = false;
@@ -100,31 +108,116 @@ export async function authRoutes(fastify) {
100
108
  });
101
109
 
102
110
  // POST /api/auth/login
103
- 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) => {
104
115
  const { email, password } = request.body || {};
105
116
  if (!email || !password) {
106
117
  return reply.status(400).send({ error: 'email and password are required' });
107
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
+ };
108
125
 
109
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
+
110
134
  if (!user || !user.isActive) {
111
- loginFailed(email, request.ip);
135
+ failed(user ? 'inactive' : 'unknown');
112
136
  return reply.status(401).send({ error: 'Invalid credentials' });
113
137
  }
114
138
 
115
139
  const valid = await validatePassword(password, user.password);
116
140
  if (!valid) {
117
- loginFailed(email, request.ip);
141
+ failed('password', {userId: user.id});
118
142
  return reply.status(401).send({ error: 'Invalid credentials' });
119
143
  }
120
144
 
121
- await touchLastLogin(user.id);
145
+ return continueLogin(user, request, reply, 0);
146
+ });
122
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
+ // An enrolment step (two-factor set up at sign-in) may show a QR image and a key to type in:
167
+ // only a base64 SVG/PNG data URL is passed on - the screen puts it in an <img>, never in markup.
168
+ const image = typeof c.image === 'string' && c.image.length < 200_000 && /^data:image\/(svg\+xml|png);base64,[A-Za-z0-9+/=]+$/.test(c.image) ? c.image : '';
169
+ return {challenge: {kind: String(c.kind || 'code'), title: String(c.title || '').slice(0, 80), label: String(c.label || 'Code'), help: String(c.help || ''),
170
+ inputMode: c.inputMode === 'text' ? 'text' : 'numeric', recovery: c.recovery === true, trust: c.trust === true,
171
+ image, secret: String(c.secret || '').slice(0, 120)}, ticket};
172
+ }
173
+ return finishLogin(user, request, reply);
174
+ }
175
+
176
+ /** Signed in: a session, tokens, the events. */
177
+ async function finishLogin(user, request, reply) {
178
+ const ip = request.ip;
179
+ const ua = uaOf(request);
180
+ await touchLastLogin(user.id);
181
+ const sid = await createSession({userId: user.id, ip, ua, ttlMs: parseDuration(refreshTokenExpiry)});
123
182
  const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
124
183
  hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
125
- const { token, refreshToken } = signTokens(fastify, safeUser);
184
+ hooks.emit('auth:loginSucceeded', {userId: user.id, email: user.email, role: user.role, ip, ua, sid});
185
+ const { token, refreshToken } = signTokens(fastify, safeUser, sid);
126
186
  setSessionCookie(request, reply, token);
127
- return { token, refreshToken, user: safeUser };
187
+ return { token, refreshToken, user: {...safeUser, ...(user.avatar && {avatar: user.avatar})} };
188
+ }
189
+
190
+ // POST /api/auth/login/verify - the answer to a sign-in challenge (two-factor)
191
+ fastify.post('/auth/login/verify', {config: {rateLimit: {max: 10, timeWindow: '1 minute'}}}, async (request, reply) => {
192
+ const {ticket, response, trust, mode} = request.body || {};
193
+ let payload;
194
+ try {
195
+ payload = fastify.jwt.verify(String(ticket || ''));
196
+ } catch {
197
+ return reply.status(401).send({error: 'This sign-in has timed out. Sign in again.'});
198
+ }
199
+ const t = payload?.type === 'login-ticket' ? tickets.get(payload.jti) : null;
200
+ if (!t || t.userId !== payload.id) return reply.status(401).send({error: 'This sign-in has timed out. Sign in again.'});
201
+ const user = await getUserById(t.userId);
202
+ if (!user || !user.isActive) {
203
+ tickets.delete(payload.jti);
204
+ return reply.status(401).send({error: 'Invalid credentials'});
205
+ }
206
+ 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)});
207
+ if (!out.ok) {
208
+ t.tries++;
209
+ loginFailed(user.email, request.ip);
210
+ hooks.emit('auth:loginFailed', {email: user.email, ip: request.ip, ua: uaOf(request), reason: 'challenge', by: t.by, userId: user.id});
211
+ if (t.tries >= TICKET_TRIES) {
212
+ tickets.delete(payload.jti);
213
+ return reply.status(401).send({error: 'Too many tries. Sign in again.'});
214
+ }
215
+ return reply.status(401).send({error: out.message, triesLeft: TICKET_TRIES - t.tries});
216
+ }
217
+ tickets.delete(payload.jti);
218
+ const next = afterIndexOf(t.by);
219
+ if (out.trust) reply.header('set-cookie', out.trust);
220
+ return continueLogin(user, request, reply, next < 0 ? 0 : next + 1);
128
221
  });
129
222
 
130
223
  // GET /api/auth/permissions - returns the raw permissions array for the authenticated user's role
@@ -183,6 +276,11 @@ export async function authRoutes(fastify) {
183
276
  if (password && password.length < 8) {
184
277
  return reply.status(400).send({error: 'Password must be at least 8 characters'});
185
278
  }
279
+ if (password) {
280
+ const me = await getUserById(id);
281
+ const problems = await checkPassword(password, {email: email || me?.email, name: name || me?.name, userId: id, request}, {log: fastify.log});
282
+ if (problems.length) return reply.status(400).send({error: passwordProblems(problems), problems});
283
+ }
186
284
 
187
285
  const coreUpdates = {};
188
286
  if (name) coreUpdates.name = name;
@@ -200,11 +298,38 @@ export async function authRoutes(fastify) {
200
298
  if (profile && typeof profile === 'object') {
201
299
  await updateProfile(id, profile);
202
300
  }
301
+ if (password) {
302
+ // A new password ends every other session - the old one may be why it changed.
303
+ await revokeUserSessions(id, {except: request.user.sid || null});
304
+ hooks.emit('auth:passwordChanged', {userId: id, by: id, ip: request.ip, ua: uaOf(request)});
305
+ }
203
306
 
204
307
  const profileEntry = await getProfile(id);
205
308
  return {...user, profile: profileEntry?.data || {}};
206
309
  });
207
310
 
311
+ // GET /api/auth/sessions - your sessions (the one you are using marked)
312
+ fastify.get('/auth/sessions', {preHandler: [authenticate]}, async (request) => {
313
+ const list = await listSessions({userId: request.user.id});
314
+ return list.map(s => ({sid: s.sid, createdAt: s.createdAt, lastUsedAt: s.lastUsedAt, expiresAt: s.expiresAt,
315
+ ip: s.ip, ua: s.ua, current: s.sid === request.user.sid}));
316
+ });
317
+
318
+ // DELETE /api/auth/sessions/:sid - end one of your sessions
319
+ fastify.delete('/auth/sessions/:sid', {preHandler: [authenticate]}, async (request, reply) => {
320
+ const ok = await revokeSession(String(request.params.sid), {userId: request.user.id});
321
+ if (!ok) return reply.status(404).send({error: 'Session not found'});
322
+ hooks.emit('auth:loggedOut', {userId: request.user.id, sid: request.params.sid, ip: request.ip, by: request.user.id});
323
+ return {ok: true};
324
+ });
325
+
326
+ // POST /api/auth/sessions/revoke-others - sign out everywhere else
327
+ fastify.post('/auth/sessions/revoke-others', {preHandler: [authenticate]}, async (request) => {
328
+ const n = await revokeUserSessions(request.user.id, {except: request.user.sid || null});
329
+ hooks.emit('auth:loggedOut', {userId: request.user.id, all: true, ip: request.ip, by: request.user.id});
330
+ return {ok: true, ended: n};
331
+ });
332
+
208
333
  // POST /api/auth/me/avatar - your picture: any common image, stored as a 256 px WebP
209
334
  fastify.post('/auth/me/avatar', {preHandler: [authenticate], config: {rateLimit: {max: 20, timeWindow: '10 minutes'}}}, async (request, reply) => {
210
335
  const upload = await request.file({limits: {fileSize: AVATAR_MAX_BYTES, files: 1}}).catch(() => null);
@@ -336,18 +461,32 @@ export async function authRoutes(fastify) {
336
461
  return reply.status(400).send({error: 'Invalid or expired reset link'});
337
462
  }
338
463
 
464
+ const problems = await checkPassword(password, {email: user.email, name: user.name, userId: user.id, request}, {log: fastify.log});
465
+ if (problems.length) return reply.status(400).send({error: passwordProblems(problems), problems});
466
+
339
467
  await updateUser(user.id, {password});
340
468
  await clearResetToken(user.id);
469
+ await revokeUserSessions(user.id);
470
+ hooks.emit('auth:passwordChanged', {userId: user.id, by: 'reset', ip: request.ip, ua: uaOf(request)});
341
471
  return {ok: true};
342
472
  });
343
473
 
344
- // POST /api/auth/logout - blacklists the refresh token (fire-and-forget safe: no auth required)
345
- fastify.post('/auth/logout', async (request, reply) => {
346
- const {refreshToken} = request.body || {};
347
- if (refreshToken) blacklistedRefreshTokens.add(refreshToken);
348
- reply.header('set-cookie', buildClearedSessionCookie({secure: request.protocol === 'https'}));
349
- return {ok: true};
350
- });
474
+ // POST /api/auth/logout - ends the session (fire-and-forget safe: no auth required)
475
+ fastify.post('/auth/logout', async (request, reply) => {
476
+ const {refreshToken} = request.body || {};
477
+ if (refreshToken) {
478
+ try {
479
+ const p = fastify.jwt.verify(String(refreshToken));
480
+ if (p?.type === 'refresh') {
481
+ if (p.sid) await revokeSession(p.sid, {userId: p.id});
482
+ else await revokeLegacyToken(refreshToken, p.exp);
483
+ hooks.emit('auth:loggedOut', {userId: p.id, sid: p.sid || null, ip: request.ip, by: p.id});
484
+ }
485
+ } catch { /* expired or not ours: nothing to end */ }
486
+ }
487
+ reply.header('set-cookie', buildClearedSessionCookie({secure: request.protocol === 'https'}));
488
+ return {ok: true};
489
+ });
351
490
 
352
491
  // POST /api/auth/refresh
353
492
  fastify.post('/auth/refresh', async (request, reply) => {
@@ -356,10 +495,6 @@ export async function authRoutes(fastify) {
356
495
  return reply.status(400).send({ error: 'refreshToken is required' });
357
496
  }
358
497
 
359
- if (blacklistedRefreshTokens.has(refreshToken)) {
360
- return reply.status(401).send({error: 'Refresh token has been revoked'});
361
- }
362
-
363
498
  let payload;
364
499
  try {
365
500
  payload = fastify.jwt.verify(refreshToken);
@@ -371,13 +506,22 @@ export async function authRoutes(fastify) {
371
506
  return reply.status(401).send({ error: 'Invalid token type' });
372
507
  }
373
508
 
509
+ // The session behind it must still be live (signed out, "everywhere", a new password end it).
510
+ if (payload.sid) {
511
+ if (!await useSession(payload.sid, {userId: payload.id, ip: request.ip, ua: uaOf(request)})) {
512
+ return reply.status(401).send({error: 'This session has ended. Sign in again.'});
513
+ }
514
+ } else if (await isLegacyTokenRevoked(refreshToken)) {
515
+ return reply.status(401).send({error: 'Refresh token has been revoked'});
516
+ }
517
+
374
518
  const user = await getUserById(payload.id);
375
519
  if (!user || !user.isActive) {
376
520
  return reply.status(401).send({ error: 'User not found or inactive' });
377
521
  }
378
522
 
379
523
  const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
380
- const token = fastify.jwt.sign({ ...safeUser, type: 'access' }, { expiresIn: accessTokenExpiry });
524
+ const token = fastify.jwt.sign({ ...safeUser, type: 'access', ...(payload.sid && {sid: payload.sid}) }, { expiresIn: accessTokenExpiry });
381
525
  setSessionCookie(request, reply, token);
382
526
  return { token };
383
527
  });
@@ -427,10 +571,11 @@ function parseDuration(str) {
427
571
  *
428
572
  * @param {object} fastify
429
573
  * @param {object} payload
574
+ * @param {string|null} [sid] - the session (services/sessions.js) both tokens belong to
430
575
  * @returns {{ token: string, refreshToken: string }}
431
576
  */
432
- function signTokens(fastify, payload) {
433
- const token = fastify.jwt.sign({ ...payload, type: 'access' }, { expiresIn: accessTokenExpiry });
434
- const refreshToken = fastify.jwt.sign({ id: payload.id, type: 'refresh' }, { expiresIn: refreshTokenExpiry });
577
+ function signTokens(fastify, payload, sid = null) {
578
+ const token = fastify.jwt.sign({ ...payload, type: 'access', ...(sid && {sid}) }, { expiresIn: accessTokenExpiry });
579
+ const refreshToken = fastify.jwt.sign({ id: payload.id, type: 'refresh', ...(sid && {sid}) }, { expiresIn: refreshTokenExpiry });
435
580
  return { token, refreshToken };
436
581
  }
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Sign-in and password checks that plugins add (Security, Security Pro).
3
+ *
4
+ * Login checks run in two phases:
5
+ * - `before`: before the password is looked at, with the email and request
6
+ * (the user if one exists). A lockout lives here, so a locked-out attacker
7
+ * learns nothing - not even whether the password was right.
8
+ * - `after`: once the password is right, with the user. Two-factor lives here.
9
+ * A check answers:
10
+ * {allow: true} - carry on
11
+ * {deny: true, message, status?} - refuse, with this message (429/403/401)
12
+ * {challenge: {kind, label, help, ...}} - ask for something more ('after' only)
13
+ * A challenge makes the login answer `{challenge, ticket}`; the admin posts
14
+ * `{ticket, response}` to /api/auth/login/verify, the check's `verify()` decides,
15
+ * and the remaining checks run from the next one.
16
+ *
17
+ * Password checks run wherever a password is set (setup, create user, update
18
+ * user, My Profile, reset). Each returns '' or a message; every message is shown.
19
+ *
20
+ * A check that THROWS is logged and skipped (sign-in must not break because a
21
+ * plugin did) - except in `verify()`, where a throw is a refusal.
22
+ */
23
+
24
+ const loginChecks = [];
25
+ const passwordChecks = [];
26
+
27
+ const errOf = (err) => (err && err.message) || String(err);
28
+
29
+ /**
30
+ * @param {string} owner - the plugin's name
31
+ * @param {{id: string, phase?: 'before'|'after', check: Function, verify?: Function}} def
32
+ */
33
+ export function registerLoginCheck(owner, def) {
34
+ if (!def || typeof def.check !== 'function' || !def.id) throw new Error('registerLoginCheck needs {id, check}');
35
+ const id = `${owner}:${def.id}`;
36
+ const at = loginChecks.findIndex(c => c.id === id);
37
+ const entry = {...def, id, owner, phase: def.phase === 'before' ? 'before' : 'after'};
38
+ if (at >= 0) loginChecks[at] = entry;
39
+ else loginChecks.push(entry);
40
+ }
41
+
42
+ /**
43
+ * @param {string} owner
44
+ * @param {{id: string, check: (password: string, ctx: object) => Promise<string>|string}} def
45
+ */
46
+ export function registerPasswordCheck(owner, def) {
47
+ if (!def || typeof def.check !== 'function' || !def.id) throw new Error('registerPasswordCheck needs {id, check}');
48
+ const id = `${owner}:${def.id}`;
49
+ const at = passwordChecks.findIndex(c => c.id === id);
50
+ if (at >= 0) passwordChecks[at] = {...def, id, owner};
51
+ else passwordChecks.push({...def, id, owner});
52
+ }
53
+
54
+ /** Plugins unloading (tests, a disabled plugin on restart): forget their checks. */
55
+ export function clearChecks(owner = null) {
56
+ for (const list of [loginChecks, passwordChecks]) {
57
+ for (let i = list.length - 1; i >= 0; i--) if (!owner || list[i].owner === owner) list.splice(i, 1);
58
+ }
59
+ }
60
+
61
+ export const loginCheckIds = () => loginChecks.map(c => c.id);
62
+
63
+ /**
64
+ * Run the login checks of a phase, from `start` (an index into that phase's list).
65
+ *
66
+ * @param {'before'|'after'} phase
67
+ * @param {object} ctx - {email, user, request, ip, ua}
68
+ * @param {{start?: number, log?: {warn: Function}}} [opts]
69
+ * @returns {Promise<{allow: true} | {deny: true, message: string, status: number, by: string}
70
+ * | {challenge: object, by: string, index: number}>}
71
+ */
72
+ export async function runLoginChecks(phase, ctx, {start = 0, log = console} = {}) {
73
+ const list = loginChecks.filter(c => c.phase === phase);
74
+ for (let i = start; i < list.length; i++) {
75
+ const c = list[i];
76
+ let out;
77
+ try {
78
+ out = await c.check(ctx);
79
+ } catch (err) {
80
+ log.warn?.(`[auth] login check ${c.id} failed, skipped: ${errOf(err)}`);
81
+ continue;
82
+ }
83
+ if (!out || out.allow) continue;
84
+ if (out.deny) return {deny: true, message: String(out.message || 'Sign-in refused.'), status: Number(out.status) || 403, by: c.id};
85
+ if (out.challenge && phase === 'after' && typeof c.verify === 'function') {
86
+ return {challenge: {...out.challenge}, by: c.id, index: i};
87
+ }
88
+ }
89
+ return {allow: true};
90
+ }
91
+
92
+ /**
93
+ * A challenge answered: the check that asked verifies it.
94
+ *
95
+ * @returns {Promise<{ok: boolean, message?: string, trust?: object}>}
96
+ */
97
+ export async function verifyChallenge(by, ctx) {
98
+ const c = loginChecks.find(x => x.id === by && x.phase === 'after');
99
+ if (!c || typeof c.verify !== 'function') return {ok: false, message: 'This sign-in step is no longer available. Sign in again.'};
100
+ try {
101
+ const out = await c.verify(ctx);
102
+ return out && out.ok ? {ok: true, ...(out.trust && {trust: out.trust})} : {ok: false, message: String(out?.message || 'That did not match.')};
103
+ } catch (err) {
104
+ return {ok: false, message: 'That could not be checked. Try again.'};
105
+ }
106
+ }
107
+
108
+ /** The index, among the 'after' checks, of a check by id (-1 when gone). */
109
+ export const afterIndexOf = (by) => loginChecks.filter(c => c.phase === 'after').findIndex(c => c.id === by);
110
+
111
+ /**
112
+ * Every password check's objection, in order ([] = fine).
113
+ *
114
+ * @param {string} password
115
+ * @param {{email?: string, name?: string, userId?: string, request?: object}} ctx
116
+ */
117
+ export async function checkPassword(password, ctx = {}, {log = console} = {}) {
118
+ const problems = [];
119
+ for (const c of passwordChecks) {
120
+ try {
121
+ const msg = await c.check(password, ctx);
122
+ if (msg) problems.push(String(msg));
123
+ } catch (err) {
124
+ log.warn?.(`[auth] password check ${c.id} failed, skipped: ${errOf(err)}`);
125
+ }
126
+ }
127
+ return problems;
128
+ }