domma-cms 0.82.0 → 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 (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 +16 -0
  4. package/admin/js/views/login.js +2 -2
  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 +41 -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 +46 -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 +133 -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 +180 -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,112 @@ 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
+ 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)});
123
178
  const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
124
179
  hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
125
- 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);
126
182
  setSessionCookie(request, reply, token);
127
- 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);
128
217
  });
129
218
 
130
219
  // GET /api/auth/permissions - returns the raw permissions array for the authenticated user's role
@@ -183,6 +272,11 @@ export async function authRoutes(fastify) {
183
272
  if (password && password.length < 8) {
184
273
  return reply.status(400).send({error: 'Password must be at least 8 characters'});
185
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
+ }
186
280
 
187
281
  const coreUpdates = {};
188
282
  if (name) coreUpdates.name = name;
@@ -200,11 +294,38 @@ export async function authRoutes(fastify) {
200
294
  if (profile && typeof profile === 'object') {
201
295
  await updateProfile(id, profile);
202
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
+ }
203
302
 
204
303
  const profileEntry = await getProfile(id);
205
304
  return {...user, profile: profileEntry?.data || {}};
206
305
  });
207
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
+
208
329
  // POST /api/auth/me/avatar - your picture: any common image, stored as a 256 px WebP
209
330
  fastify.post('/auth/me/avatar', {preHandler: [authenticate], config: {rateLimit: {max: 20, timeWindow: '10 minutes'}}}, async (request, reply) => {
210
331
  const upload = await request.file({limits: {fileSize: AVATAR_MAX_BYTES, files: 1}}).catch(() => null);
@@ -336,18 +457,32 @@ export async function authRoutes(fastify) {
336
457
  return reply.status(400).send({error: 'Invalid or expired reset link'});
337
458
  }
338
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
+
339
463
  await updateUser(user.id, {password});
340
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)});
341
467
  return {ok: true};
342
468
  });
343
469
 
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
- });
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
+ });
351
486
 
352
487
  // POST /api/auth/refresh
353
488
  fastify.post('/auth/refresh', async (request, reply) => {
@@ -356,10 +491,6 @@ export async function authRoutes(fastify) {
356
491
  return reply.status(400).send({ error: 'refreshToken is required' });
357
492
  }
358
493
 
359
- if (blacklistedRefreshTokens.has(refreshToken)) {
360
- return reply.status(401).send({error: 'Refresh token has been revoked'});
361
- }
362
-
363
494
  let payload;
364
495
  try {
365
496
  payload = fastify.jwt.verify(refreshToken);
@@ -371,13 +502,22 @@ export async function authRoutes(fastify) {
371
502
  return reply.status(401).send({ error: 'Invalid token type' });
372
503
  }
373
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
+
374
514
  const user = await getUserById(payload.id);
375
515
  if (!user || !user.isActive) {
376
516
  return reply.status(401).send({ error: 'User not found or inactive' });
377
517
  }
378
518
 
379
519
  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 });
520
+ const token = fastify.jwt.sign({ ...safeUser, type: 'access', ...(payload.sid && {sid: payload.sid}) }, { expiresIn: accessTokenExpiry });
381
521
  setSessionCookie(request, reply, token);
382
522
  return { token };
383
523
  });
@@ -427,10 +567,11 @@ function parseDuration(str) {
427
567
  *
428
568
  * @param {object} fastify
429
569
  * @param {object} payload
570
+ * @param {string|null} [sid] - the session (services/sessions.js) both tokens belong to
430
571
  * @returns {{ token: string, refreshToken: string }}
431
572
  */
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 });
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 });
435
576
  return { token, refreshToken };
436
577
  }
@@ -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
+ }