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.
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/templates/login.html +16 -0
- package/admin/js/views/login.js +2 -2
- package/bin/update.js +7 -3
- package/config/plugins.json +5 -0
- package/package.json +2 -1
- package/plugins/free-tier.lock.json +25 -1
- package/plugins/security/CLAUDE.md +41 -0
- package/plugins/security/admin/lib/blocks.js +46 -0
- package/plugins/security/admin/lib/health.js +148 -0
- package/plugins/security/admin/lib/ip.js +44 -0
- package/plugins/security/admin/lib/lockout.js +78 -0
- package/plugins/security/admin/lib/policy.js +32 -0
- package/plugins/security/admin/lib/probes.js +39 -0
- package/plugins/security/admin/lib/settings.js +86 -0
- package/plugins/security/admin/templates/security.html +170 -0
- package/plugins/security/admin/views/security.js +529 -0
- package/plugins/security/bin/unlock.js +81 -0
- package/plugins/security/config.js +7 -0
- package/plugins/security/plugin.js +303 -0
- package/plugins/security/plugin.json +46 -0
- package/plugins/security/server/common-passwords.NOTICE +3 -0
- package/plugins/security/server/common-passwords.txt +10001 -0
- package/plugins/security/server/facts.js +133 -0
- package/plugins/security/server/state.js +106 -0
- package/plugins/security/tests/security.test.js +134 -0
- package/server/routes/api/auth.js +180 -39
- package/server/routes/api/users.js +20 -0
- package/server/server.js +25 -7
- package/server/services/audit.js +63 -0
- package/server/services/authChecks.js +128 -0
- package/server/services/network.js +49 -0
- package/server/services/plugins.js +5 -1
- package/server/services/sessions.js +171 -0
- package/server/services/siteGitignore.js +1 -0
|
@@ -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/
|
|
10
|
-
* POST /api/auth/
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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 {
|
|
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) => {
|
|
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
|
-
|
|
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
|
-
|
|
141
|
+
failed('password', {userId: user.id});
|
|
118
142
|
return reply.status(401).send({ error: 'Invalid credentials' });
|
|
119
143
|
}
|
|
120
144
|
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
87
|
+
// Behind a reverse proxy (Apache, domma-cms-manager), trust the proxy so
|
|
88
|
+
// request.ip is the visitor, not the proxy's loopback. TRUST_PROXY=loopback
|
|
89
|
+
// is the right value (right-most X-Forwarded-For entry a local proxy did not
|
|
90
|
+
// add); 1 / true still trusts every hop - see services/network.js.
|
|
91
|
+
trustProxy: parseTrustProxy(process.env.TRUST_PROXY),
|
|
90
92
|
});
|
|
91
93
|
|
|
92
94
|
// Register process error handlers immediately so startup errors are captured
|
|
@@ -148,9 +150,10 @@ await app.register(rateLimit, {
|
|
|
148
150
|
global: true, // apply default limit to all routes; stricter per-route limits override this
|
|
149
151
|
max: 500,
|
|
150
152
|
timeWindow: '1 minute',
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
153
|
+
// Only a genuinely local call is exempt (loopback socket, no X-Forwarded-For).
|
|
154
|
+
// A static loopback list exempted every visitor relayed by a proxy that is
|
|
155
|
+
// not trusted - on dcm, everyone, the login included. See services/network.js.
|
|
156
|
+
allowList: (request) => isGenuinelyLocal(request),
|
|
154
157
|
});
|
|
155
158
|
|
|
156
159
|
// Negotiates brotli/gzip/deflate per Accept-Encoding. Skips already-compressed
|
|
@@ -367,6 +370,21 @@ app.addHook('onResponse', async (request, reply) => {
|
|
|
367
370
|
app.log.warn(`[cache] Admin change did not clear the cache: ${err.message}`);
|
|
368
371
|
}
|
|
369
372
|
});
|
|
373
|
+
|
|
374
|
+
// Audit: every successful signed-in change through the API becomes an `audit`
|
|
375
|
+
// event (services/audit.js). Core keeps nothing; Security Pro stores the trail.
|
|
376
|
+
{
|
|
377
|
+
const {auditEventFor} = await import('./services/audit.js');
|
|
378
|
+
const {hooks: auditHooks} = await import('./services/hooks.js');
|
|
379
|
+
app.addHook('onResponse', async (request, reply) => {
|
|
380
|
+
try {
|
|
381
|
+
const event = auditEventFor(request, reply);
|
|
382
|
+
if (event) auditHooks.emit('audit', event);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
app.log.warn(`[audit] event not raised: ${err.message}`);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
370
388
|
console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disabled'}`);
|
|
371
389
|
|
|
372
390
|
// Docs are rendered at request time from the shipped admin templates; clear any
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit events - every change a signed-in person makes through the API.
|
|
3
|
+
*
|
|
4
|
+
* One `onResponse` hook (server.js) turns each successful POST / PUT / PATCH /
|
|
5
|
+
* DELETE under /api/ by a signed-in user into `hooks.emit('audit', event)`.
|
|
6
|
+
* Core stores nothing; Security Pro keeps the trail. Done once, here, rather
|
|
7
|
+
* than route by route, so a new route - or a plugin's - is covered without
|
|
8
|
+
* anyone remembering to.
|
|
9
|
+
*
|
|
10
|
+
* An event carries the NAMES of the fields sent, never their values: bodies hold
|
|
11
|
+
* passwords, SMTP credentials and API keys.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
15
|
+
|
|
16
|
+
/** Routes with events of their own (auth:*), or that are not someone changing something. */
|
|
17
|
+
const SKIP = [
|
|
18
|
+
/^\/api\/auth\/(login|login\/verify|refresh|logout|forgot-password|reset-password|setup)$/,
|
|
19
|
+
/^\/api\/analytics\//,
|
|
20
|
+
/^\/api\/notifications\/(read|seen|dismiss)/,
|
|
21
|
+
/\/(preview|render|validate|search)$/
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const VERB = {POST: 'created', PUT: 'updated', PATCH: 'updated', DELETE: 'deleted'};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The event for a finished request, or null when it is not one.
|
|
28
|
+
*
|
|
29
|
+
* @param {{method: string, url: string, routeOptions?: {url?: string}, params?: object, body?: any, user?: object, ip?: string, headers?: object}} request
|
|
30
|
+
* @param {{statusCode: number}} reply
|
|
31
|
+
* @returns {null | {at: string, actor: {id: string, name: string, email: string, role: string}, action: string,
|
|
32
|
+
* method: string, route: string, path: string, target: object, fields: string[], status: number, ip: string}}
|
|
33
|
+
*/
|
|
34
|
+
export function auditEventFor(request, reply) {
|
|
35
|
+
if (!MUTATING.has(request.method)) return null;
|
|
36
|
+
const status = reply.statusCode;
|
|
37
|
+
if (status < 200 || status >= 300) return null;
|
|
38
|
+
const path = String(request.url || '').split('?')[0];
|
|
39
|
+
if (!path.startsWith('/api/')) return null;
|
|
40
|
+
const route = String(request.routeOptions?.url || path);
|
|
41
|
+
if (SKIP.some(re => re.test(route))) return null;
|
|
42
|
+
const u = request.user;
|
|
43
|
+
if (!u?.id) return null;
|
|
44
|
+
|
|
45
|
+
// "/api/plugins/blog/posts/:id" -> resource "plugins/blog/posts".
|
|
46
|
+
const resource = route.replace(/^\/api\//, '').split('/').filter(p => p && !p.startsWith(':')).join('/') || 'api';
|
|
47
|
+
const body = request.body;
|
|
48
|
+
const fields = body && typeof body === 'object' && !Array.isArray(body) && !Buffer.isBuffer(body)
|
|
49
|
+
? Object.keys(body).slice(0, 40)
|
|
50
|
+
: [];
|
|
51
|
+
return {
|
|
52
|
+
at: new Date().toISOString(),
|
|
53
|
+
actor: {id: String(u.id), name: String(u.name || ''), email: String(u.email || ''), role: String(u.role || '')},
|
|
54
|
+
action: `${resource} ${VERB[request.method]}`,
|
|
55
|
+
method: request.method,
|
|
56
|
+
route,
|
|
57
|
+
path: path.slice(0, 300),
|
|
58
|
+
target: {...(request.params || {})},
|
|
59
|
+
fields,
|
|
60
|
+
status,
|
|
61
|
+
ip: String(request.ip || '')
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -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
|
+
}
|