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.
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/templates/login.html +24 -0
- package/admin/js/views/login.js +7 -7
- 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 +46 -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 +64 -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 +135 -0
- package/plugins/security/server/state.js +106 -0
- package/plugins/security/tests/security.test.js +134 -0
- package/server/routes/api/auth.js +184 -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,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) => {
|
|
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
|
+
// 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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|