domma-cms 0.81.1 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +5 -4
- package/admin/js/lib/avatar.js +1 -0
- package/admin/js/templates/login.html +16 -0
- package/admin/js/templates/my-profile.html +23 -3
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/my-profile.js +1 -1
- 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 +241 -39
- package/server/routes/api/forms.js +3 -1
- 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/avatars.js +77 -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
- package/server/services/users.js +5 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avatars - a user's picture, uploaded from My Profile.
|
|
3
|
+
*
|
|
4
|
+
* Any image the browser sends is re-encoded here (sharp): turned upright from
|
|
5
|
+
* its EXIF orientation, cropped square on its centre, 256 px, WebP - so what is
|
|
6
|
+
* stored and served is always a small, plain image, never the uploaded bytes
|
|
7
|
+
* (no scripts in an SVG, no metadata, no 20 MB phone photo).
|
|
8
|
+
*
|
|
9
|
+
* Files live in content/avatars/, beside content/users/ - not in the Media
|
|
10
|
+
* library, which is for the site's own images. The name carries a random part,
|
|
11
|
+
* so a new picture is a new address (no stale cache) and one cannot be guessed
|
|
12
|
+
* from a user id. The user record holds `avatar`: the address to show.
|
|
13
|
+
*/
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import sharp from 'sharp';
|
|
18
|
+
import {config} from '../config.js';
|
|
19
|
+
|
|
20
|
+
export const AVATAR_DIR = path.resolve(path.dirname(config.content.usersDir), 'avatars');
|
|
21
|
+
export const AVATAR_PREFIX = '/api/auth/avatar/';
|
|
22
|
+
export const AVATAR_MAX_BYTES = 8 * 1024 * 1024;
|
|
23
|
+
export const AVATAR_SIZE = 256;
|
|
24
|
+
/** A stored avatar's file name: `<user id>-<random>.webp`. */
|
|
25
|
+
export const AVATAR_FILE = /^[A-Za-z0-9_-]{1,80}-[a-f0-9]{16}\.webp$/;
|
|
26
|
+
|
|
27
|
+
const ACCEPTED = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif', 'image/heic', 'image/heif']);
|
|
28
|
+
|
|
29
|
+
/** Is this upload type one we take? (sharp decides for certain.) */
|
|
30
|
+
export const acceptedType = (mime) => ACCEPTED.has(String(mime || '').toLowerCase());
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Re-encode an uploaded image as the avatar.
|
|
34
|
+
*
|
|
35
|
+
* @param {Buffer} input
|
|
36
|
+
* @returns {Promise<Buffer>} a 256x256 WebP
|
|
37
|
+
* @throws {Error} code 'BAD_IMAGE' when it is not an image sharp can read
|
|
38
|
+
*/
|
|
39
|
+
export async function toAvatar(input) {
|
|
40
|
+
try {
|
|
41
|
+
return await sharp(input, {limitInputPixels: 50_000_000, animated: false})
|
|
42
|
+
.rotate()
|
|
43
|
+
.resize(AVATAR_SIZE, AVATAR_SIZE, {fit: 'cover', position: 'attention'})
|
|
44
|
+
.webp({quality: 84})
|
|
45
|
+
.toBuffer();
|
|
46
|
+
} catch {
|
|
47
|
+
const err = new Error('That file is not an image we can read. Try a JPEG, PNG or WebP.');
|
|
48
|
+
err.code = 'BAD_IMAGE';
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The file an avatar address points at, or null for anything that is not one of ours. */
|
|
54
|
+
export function avatarFile(address) {
|
|
55
|
+
const name = String(address || '').startsWith(AVATAR_PREFIX) ? String(address).slice(AVATAR_PREFIX.length).split('?')[0] : '';
|
|
56
|
+
return AVATAR_FILE.test(name) ? path.join(AVATAR_DIR, name) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Store a user's new avatar; returns its address.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} userId
|
|
63
|
+
* @param {Buffer} webp - from toAvatar()
|
|
64
|
+
*/
|
|
65
|
+
export async function saveAvatar(userId, webp) {
|
|
66
|
+
await fs.mkdir(AVATAR_DIR, {recursive: true});
|
|
67
|
+
const safeId = String(userId).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80) || 'user';
|
|
68
|
+
const name = `${safeId}-${crypto.randomBytes(8).toString('hex')}.webp`;
|
|
69
|
+
await fs.writeFile(path.join(AVATAR_DIR, name), webp);
|
|
70
|
+
return `${AVATAR_PREFIX}${name}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Delete the file behind an avatar address (quietly: it may be gone already). */
|
|
74
|
+
export async function removeAvatar(address) {
|
|
75
|
+
const file = avatarFile(address);
|
|
76
|
+
if (file) await fs.rm(file, {force: true}).catch(() => {});
|
|
77
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network trust - who a request is really from.
|
|
3
|
+
*
|
|
4
|
+
* A Domma CMS sits behind proxies: Apache in front of the manager, and the
|
|
5
|
+
* manager in front of each site. Two things follow, and both went wrong:
|
|
6
|
+
*
|
|
7
|
+
* 1. TRUST_PROXY used to be a yes/no. "Yes" trusts EVERY hop, and Fastify then
|
|
8
|
+
* takes the LEFT-most X-Forwarded-For entry - whatever the visitor chose to
|
|
9
|
+
* send. `loopback` trusts only local proxies and takes the right-most entry
|
|
10
|
+
* they did not add, which is the address Apache saw. So TRUST_PROXY now also
|
|
11
|
+
* takes Fastify's own values: `loopback`, a list of addresses/CIDRs, or a hop
|
|
12
|
+
* count. `1` / `true` keep their old meaning.
|
|
13
|
+
*
|
|
14
|
+
* 2. Loopback was exempt from rate limiting ("local admin use"). Behind a proxy
|
|
15
|
+
* that trusts nothing - dcm, until 2026-09 - EVERY visitor is loopback, so
|
|
16
|
+
* nothing was ever limited, the login included. Only a GENUINELY local call
|
|
17
|
+
* (loopback socket, no forwarding header) is exempt now.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const LOOPBACK = /^(127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fastify's `trustProxy` from the TRUST_PROXY environment value.
|
|
24
|
+
*
|
|
25
|
+
* @param {string|undefined} value
|
|
26
|
+
* @returns {boolean|string|number}
|
|
27
|
+
*/
|
|
28
|
+
export function parseTrustProxy(value) {
|
|
29
|
+
const v = String(value ?? '').trim();
|
|
30
|
+
if (!v || /^(0|false|no|off)$/i.test(v)) return false;
|
|
31
|
+
if (/^(1|true|yes|on)$/i.test(v)) return true;
|
|
32
|
+
if (/^\d+$/.test(v)) return Number(v); // a hop count
|
|
33
|
+
return v; // 'loopback', 'uniquelocal', or addresses/CIDRs, comma-separated
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Is this address a loopback one? */
|
|
37
|
+
export const isLoopback = (ip) => LOOPBACK.test(String(ip ?? ''));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A call from this machine itself - a health check, a cron job, the manager
|
|
41
|
+
* asking one of its sites - and NOT a visitor relayed by a local proxy.
|
|
42
|
+
*
|
|
43
|
+
* @param {{socket?: {remoteAddress?: string}, raw?: {socket?: {remoteAddress?: string}}, headers?: object}} request
|
|
44
|
+
*/
|
|
45
|
+
export function isGenuinelyLocal(request) {
|
|
46
|
+
const peer = request?.raw?.socket?.remoteAddress ?? request?.socket?.remoteAddress ?? '';
|
|
47
|
+
const forwarded = request?.headers?.['x-forwarded-for'];
|
|
48
|
+
return isLoopback(peer) && !forwarded;
|
|
49
|
+
}
|
|
@@ -35,6 +35,7 @@ import {createCollection, getCollection} from './collections.js';
|
|
|
35
35
|
import * as defaultRolesService from './roles.js';
|
|
36
36
|
import {normaliseAdminScope} from './roles.js';
|
|
37
37
|
import * as defaultUsersService from './users.js';
|
|
38
|
+
import {registerLoginCheck, registerPasswordCheck} from './authChecks.js';
|
|
38
39
|
|
|
39
40
|
const PLUGINS_DIR = path.resolve('plugins');
|
|
40
41
|
|
|
@@ -486,7 +487,10 @@ export async function registerPlugins(fastify) {
|
|
|
486
487
|
on: hooks.on.bind(hooks), ...pluginRoleHooks(manifest.name),
|
|
487
488
|
// Notifications (0.80) - notify, resolveNotification, registerNotificationSource,
|
|
488
489
|
// stamped with the plugin's name (its source ids are `<plugin>:<id>`).
|
|
489
|
-
...notifierFor(manifest.name, manifest.displayName || manifest.name)
|
|
490
|
+
...notifierFor(manifest.name, manifest.displayName || manifest.name),
|
|
491
|
+
// Sign-in and password checks (0.83) - Security / Security Pro. services/authChecks.js.
|
|
492
|
+
registerLoginCheck: (def) => registerLoginCheck(manifest.name, def),
|
|
493
|
+
registerPasswordCheck: (def) => registerPasswordCheck(manifest.name, def)},
|
|
490
494
|
settings,
|
|
491
495
|
config: {}
|
|
492
496
|
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions - one per sign-in, behind each refresh token.
|
|
3
|
+
*
|
|
4
|
+
* A refresh token carries `sid`, the id of its session here. Signing out,
|
|
5
|
+
* "sign out everywhere", a password change or reset revoke sessions in this
|
|
6
|
+
* store, which is a file - so a signed-out session STAYS signed out across a
|
|
7
|
+
* restart. (Until 0.83 revoked refresh tokens were an in-memory Set: a restart
|
|
8
|
+
* brought every one of them back for the rest of their 7 days.)
|
|
9
|
+
*
|
|
10
|
+
* Each session records who, when it began and was last used, and from where
|
|
11
|
+
* (address, browser) - Security Pro's Sessions screen shows them. Only ids are
|
|
12
|
+
* stored, never a token.
|
|
13
|
+
*
|
|
14
|
+
* Refresh tokens issued before 0.83 have no `sid`. They stay valid until they
|
|
15
|
+
* expire, unless signed out: then their hash is kept here until their expiry.
|
|
16
|
+
*
|
|
17
|
+
* content/sessions/ is runtime state - never committed (siteGitignore.js).
|
|
18
|
+
*/
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import fs from 'node:fs/promises';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import {config} from '../config.js';
|
|
23
|
+
|
|
24
|
+
export const SESSIONS_FILE = path.resolve(path.dirname(config.content.usersDir), 'sessions', 'sessions.json');
|
|
25
|
+
const MAX_PER_USER = 50;
|
|
26
|
+
|
|
27
|
+
let cache = null;
|
|
28
|
+
let queue = Promise.resolve();
|
|
29
|
+
|
|
30
|
+
async function load() {
|
|
31
|
+
if (cache) return cache;
|
|
32
|
+
try {
|
|
33
|
+
const raw = JSON.parse(await fs.readFile(SESSIONS_FILE, 'utf8'));
|
|
34
|
+
cache = {sessions: raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {}, legacy: raw.legacy && typeof raw.legacy === 'object' ? raw.legacy : {}};
|
|
35
|
+
} catch {
|
|
36
|
+
cache = {sessions: {}, legacy: {}};
|
|
37
|
+
}
|
|
38
|
+
return cache;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Expired sessions and legacy revocations go; the oldest go past MAX_PER_USER. */
|
|
42
|
+
function prune(store, now = Date.now()) {
|
|
43
|
+
for (const [sid, s] of Object.entries(store.sessions)) {
|
|
44
|
+
if (Date.parse(s.expiresAt) < now || (s.revokedAt && Date.parse(s.revokedAt) < now - 7 * 86400000)) delete store.sessions[sid];
|
|
45
|
+
}
|
|
46
|
+
for (const [h, exp] of Object.entries(store.legacy)) if (exp < now) delete store.legacy[h];
|
|
47
|
+
const byUser = {};
|
|
48
|
+
for (const [sid, s] of Object.entries(store.sessions)) (byUser[s.userId] ??= []).push([sid, s]);
|
|
49
|
+
for (const list of Object.values(byUser)) {
|
|
50
|
+
if (list.length <= MAX_PER_USER) continue;
|
|
51
|
+
list.sort((a, b) => Date.parse(a[1].lastUsedAt) - Date.parse(b[1].lastUsedAt));
|
|
52
|
+
for (const [sid] of list.slice(0, list.length - MAX_PER_USER)) delete store.sessions[sid];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Serialise writes: two sign-ins at once must not lose one. */
|
|
57
|
+
function change(fn) {
|
|
58
|
+
const run = queue.then(async () => {
|
|
59
|
+
const store = await load();
|
|
60
|
+
const out = await fn(store);
|
|
61
|
+
prune(store);
|
|
62
|
+
await fs.mkdir(path.dirname(SESSIONS_FILE), {recursive: true});
|
|
63
|
+
const tmp = `${SESSIONS_FILE}.${process.pid}.tmp`;
|
|
64
|
+
await fs.writeFile(tmp, JSON.stringify(store), {mode: 0o600});
|
|
65
|
+
await fs.rename(tmp, SESSIONS_FILE);
|
|
66
|
+
return out;
|
|
67
|
+
});
|
|
68
|
+
queue = run.catch(() => {});
|
|
69
|
+
return run;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const clip = (s, n) => String(s ?? '').slice(0, n);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Begin a session.
|
|
76
|
+
*
|
|
77
|
+
* @param {{userId: string, ip?: string, ua?: string, ttlMs: number}} opts
|
|
78
|
+
* @returns {Promise<string>} the session id, for the refresh token's `sid`
|
|
79
|
+
*/
|
|
80
|
+
export function createSession({userId, ip = '', ua = '', ttlMs}) {
|
|
81
|
+
const sid = crypto.randomBytes(16).toString('hex');
|
|
82
|
+
const now = new Date();
|
|
83
|
+
return change((store) => {
|
|
84
|
+
store.sessions[sid] = {
|
|
85
|
+
userId: String(userId),
|
|
86
|
+
createdAt: now.toISOString(),
|
|
87
|
+
lastUsedAt: now.toISOString(),
|
|
88
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
89
|
+
ip: clip(ip, 64), ua: clip(ua, 300)
|
|
90
|
+
};
|
|
91
|
+
return sid;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A refresh: is the session live? Records the use. Null when it is unknown,
|
|
97
|
+
* revoked, expired or someone else's.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} sid
|
|
100
|
+
* @param {{userId: string, ip?: string, ua?: string}} who
|
|
101
|
+
*/
|
|
102
|
+
export async function useSession(sid, {userId, ip = '', ua = ''}) {
|
|
103
|
+
const store = await load();
|
|
104
|
+
const s = store.sessions[sid];
|
|
105
|
+
if (!s || s.revokedAt || s.userId !== String(userId) || Date.parse(s.expiresAt) < Date.now()) return null;
|
|
106
|
+
// Written at most once a minute per session: a refresh every 15 minutes is the normal rate.
|
|
107
|
+
if (Date.now() - Date.parse(s.lastUsedAt) > 60_000 || s.ip !== clip(ip, 64)) {
|
|
108
|
+
await change((st) => {
|
|
109
|
+
const live = st.sessions[sid];
|
|
110
|
+
if (live) Object.assign(live, {lastUsedAt: new Date().toISOString(), ip: clip(ip, 64), ua: clip(ua, 300) || live.ua});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return {...s, sid};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Revoke one session. True when there was a live one. */
|
|
117
|
+
export function revokeSession(sid, {userId = null} = {}) {
|
|
118
|
+
return change((store) => {
|
|
119
|
+
const s = store.sessions[sid];
|
|
120
|
+
if (!s || s.revokedAt || (userId && s.userId !== String(userId))) return false;
|
|
121
|
+
s.revokedAt = new Date().toISOString();
|
|
122
|
+
return true;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Revoke every live session of a user (but `except`). Returns how many. */
|
|
127
|
+
export function revokeUserSessions(userId, {except = null} = {}) {
|
|
128
|
+
return change((store) => {
|
|
129
|
+
let n = 0;
|
|
130
|
+
for (const [sid, s] of Object.entries(store.sessions)) {
|
|
131
|
+
if (s.userId === String(userId) && !s.revokedAt && sid !== except) {
|
|
132
|
+
s.revokedAt = new Date().toISOString();
|
|
133
|
+
n++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return n;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Live sessions, newest use first - one user's, or everyone's.
|
|
142
|
+
*
|
|
143
|
+
* @param {{userId?: string}} [opts]
|
|
144
|
+
* @returns {Promise<{sid: string, userId: string, createdAt: string, lastUsedAt: string, expiresAt: string, ip: string, ua: string}[]>}
|
|
145
|
+
*/
|
|
146
|
+
export async function listSessions({userId = null} = {}) {
|
|
147
|
+
const store = await load();
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
return Object.entries(store.sessions)
|
|
150
|
+
.filter(([, s]) => !s.revokedAt && Date.parse(s.expiresAt) >= now && (!userId || s.userId === String(userId)))
|
|
151
|
+
.map(([sid, s]) => ({sid, ...s}))
|
|
152
|
+
.sort((a, b) => Date.parse(b.lastUsedAt) - Date.parse(a.lastUsedAt));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const tokenHash = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
|
|
156
|
+
|
|
157
|
+
/** A pre-0.83 refresh token (no sid) signed out: remembered until it would have expired. */
|
|
158
|
+
export function revokeLegacyToken(token, expSeconds) {
|
|
159
|
+
const exp = Number(expSeconds) * 1000 || Date.now() + 7 * 86400000;
|
|
160
|
+
return change((store) => { store.legacy[tokenHash(token)] = exp; });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function isLegacyTokenRevoked(token) {
|
|
164
|
+
const store = await load();
|
|
165
|
+
return Boolean(store.legacy[tokenHash(token)]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Tests: forget the cached store (the file stays). */
|
|
169
|
+
export function _resetCache() {
|
|
170
|
+
cache = null;
|
|
171
|
+
}
|
|
@@ -195,6 +195,7 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
|
|
|
195
195
|
for (const c of PRIVATE_DATA) L.push(`/content/collections/${c}/data.json`);
|
|
196
196
|
L.push(
|
|
197
197
|
'/content/users/',
|
|
198
|
+
'/content/sessions/',
|
|
198
199
|
'/content/versions/',
|
|
199
200
|
'/content/analytics/',
|
|
200
201
|
'/content/instances_log.json',
|
package/server/services/users.js
CHANGED
|
@@ -219,7 +219,12 @@ export async function deleteUser(id) {
|
|
|
219
219
|
throw new Error('Invalid user ID');
|
|
220
220
|
}
|
|
221
221
|
const filePath = path.join(USERS_DIR, `${id}.json`);
|
|
222
|
+
const avatar = (await readUserFile(id).catch(() => null))?.avatar;
|
|
222
223
|
await fs.unlink(filePath);
|
|
224
|
+
if (avatar) {
|
|
225
|
+
const {removeAvatar} = await import('./avatars.js');
|
|
226
|
+
await removeAvatar(avatar);
|
|
227
|
+
}
|
|
223
228
|
try {
|
|
224
229
|
await deleteProfile(id);
|
|
225
230
|
} catch {
|