kczx-user-management 1.0.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/CUTOVER-RUNBOOK.md +73 -0
- package/MERGED-DEPLOYMENT.md +100 -0
- package/README.md +158 -0
- package/client/bundle.js +1799 -0
- package/client/index.js +1785 -0
- package/client/package.json +3 -0
- package/cordis.patch.yml +13 -0
- package/docs/FAQ.md +95 -0
- package/docs/demo-totp-dark.gif +0 -0
- package/docs/demo-totp-light.gif +0 -0
- package/docs/demo.gif +0 -0
- package/package.json +80 -0
- package/scripts/build-client.mjs +42 -0
- package/scripts/ensure-deps.mjs +50 -0
- package/scripts/import-from-dsh-passwords.mjs +111 -0
- package/src/certs.js +89 -0
- package/src/gate.js +162 -0
- package/src/gateway-core.js +769 -0
- package/src/index.js +1358 -0
- package/src/login-page.js +184 -0
- package/src/permissions.js +477 -0
- package/src/proxy.js +474 -0
- package/src/store.js +1072 -0
- package/src/totp.js +136 -0
- package/src/trusted-identity.js +143 -0
- package/src/vendor/qrcode-generator.js +2289 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* user-management store — users / sessions / activity ledger.
|
|
5
|
+
*
|
|
6
|
+
* Everything lives under `$DSH_HOME/user-management/`:
|
|
7
|
+
* - users.json accounts (scrypt password hashes), mode 0600, atomic writes
|
|
8
|
+
* - sessions.json issued session tokens (survive restarts), same protection
|
|
9
|
+
* - activity.jsonl append-only event ledger (logins, admin actions, page
|
|
10
|
+
* accesses), rolled over to the tail MAX_LEDGER_LINES
|
|
11
|
+
*
|
|
12
|
+
* No third-party dependencies: hashing is crypto.scrypt, tokens are
|
|
13
|
+
* crypto.randomBytes. All mutation paths are async and serialize through
|
|
14
|
+
* per-file write chains so concurrent requests cannot interleave writes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fsP from 'node:fs/promises'
|
|
18
|
+
import net from 'node:net'
|
|
19
|
+
import { randomBytes, scrypt as scryptCb, scryptSync, timingSafeEqual, createHash } from 'node:crypto'
|
|
20
|
+
import { promisify } from 'node:util'
|
|
21
|
+
import { join, resolve } from 'node:path'
|
|
22
|
+
import { homedir } from 'node:os'
|
|
23
|
+
import { generateSecret, verifyTotp } from './totp.js'
|
|
24
|
+
import { DENY_ALL_WORKSPACES, isWorkspaceRestricted, normalizePath } from './permissions.js'
|
|
25
|
+
// Only used to verify hashes imported from dsh-passwords: its accounts are
|
|
26
|
+
// bcrypt and a hash cannot be converted, so the record keeps its algorithm
|
|
27
|
+
// marker and is transparently re-hashed to scrypt on the next sign-in.
|
|
28
|
+
import bcrypt from 'bcryptjs'
|
|
29
|
+
|
|
30
|
+
const scrypt = promisify(scryptCb)
|
|
31
|
+
|
|
32
|
+
const USERS_FILE = 'users.json'
|
|
33
|
+
const SESSIONS_FILE = 'sessions.json'
|
|
34
|
+
const ACTIVITY_FILE = 'activity.jsonl'
|
|
35
|
+
const AUDIT_FILE = 'audit.jsonl'
|
|
36
|
+
const BANS_FILE = 'bans.json'
|
|
37
|
+
const USAGE_FILE = 'usage.json'
|
|
38
|
+
const WORKSPACES_FILE = 'workspaces.json'
|
|
39
|
+
|
|
40
|
+
/** scrypt key length / cost — modest defaults, login is not a hot path. */
|
|
41
|
+
const KEY_LEN = 32
|
|
42
|
+
const SCRYPT_COST = 16384
|
|
43
|
+
/** Token quota window: tokens are counted per rolling hour. */
|
|
44
|
+
const HOURLY_WINDOW_MS = 60 * 60 * 1000
|
|
45
|
+
|
|
46
|
+
/** Session lifetime; sliding — each authenticated check past half-life renews. */
|
|
47
|
+
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
48
|
+
const SESSION_RENEW_MS = SESSION_TTL_MS / 2
|
|
49
|
+
/** Ledger rollover: trim to the tail once the file grows past 2× the cap. */
|
|
50
|
+
const MAX_LEDGER_LINES = 2000
|
|
51
|
+
|
|
52
|
+
// ── per-user permissions & quotas ───────────────────────────────────────────
|
|
53
|
+
//
|
|
54
|
+
// Ported from dsh-passwords' user_permissions row so this plugin can own
|
|
55
|
+
// accounts, workspace scope and usage limits on its own. Defaults are
|
|
56
|
+
// permissive: an empty folder list means "every workspace" (the same reading
|
|
57
|
+
// dsh-passwords gives it), a null limit means "no cap". A record written
|
|
58
|
+
// before this model existed has no permissions block and reads exactly as
|
|
59
|
+
// these defaults, so migration is a no-op for existing users.
|
|
60
|
+
|
|
61
|
+
/** Sandbox floors the gateway may pin an account to (dsh-passwords' ranking). */
|
|
62
|
+
const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']
|
|
63
|
+
|
|
64
|
+
/** Marker for a hash imported from dsh-passwords (bcrypt, not convertible). */
|
|
65
|
+
const BCRYPT_ALGO = 'bcrypt'
|
|
66
|
+
|
|
67
|
+
/** A broken bcrypt dependency is reported once, not once per login attempt. */
|
|
68
|
+
let bcryptFailureReported = false
|
|
69
|
+
|
|
70
|
+
const DEFAULT_PERMISSIONS = Object.freeze({
|
|
71
|
+
/** Absolute folders this account may touch; empty = unrestricted. */
|
|
72
|
+
allowedFolders: [],
|
|
73
|
+
/** Tokens per rolling hour; null = unlimited. */
|
|
74
|
+
hourlyTokenLimit: null,
|
|
75
|
+
/** Minutes of agent time per day; null = unlimited. */
|
|
76
|
+
dailyMinuteLimit: null,
|
|
77
|
+
/** Floor this account's sandbox to a mode; null = leave the choice to it. */
|
|
78
|
+
sandboxMode: null,
|
|
79
|
+
/** Allow uploads / git operations through the gateway. dsh-passwords ships
|
|
80
|
+
* these asymmetric (upload on, git download off) and that asymmetry is kept:
|
|
81
|
+
* a git clone can pull an entire repository out of the host. */
|
|
82
|
+
allowUpload: true,
|
|
83
|
+
allowGit: false,
|
|
84
|
+
/** Allow creating new workspaces, not just using existing ones (dp default:
|
|
85
|
+
* off — a workspace is a place an agent may write to). */
|
|
86
|
+
allowWorkspaceCreate: false,
|
|
87
|
+
/** Gate-level deny (sessions dropped, sign-in refused): unlike `disabled`
|
|
88
|
+
* this is a policy state the enforcement layer reads on every request. */
|
|
89
|
+
banned: false,
|
|
90
|
+
/** Free-form admin note. */
|
|
91
|
+
note: '',
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
function normalizeFolders(raw) {
|
|
95
|
+
if (!Array.isArray(raw)) return []
|
|
96
|
+
const out = []
|
|
97
|
+
for (const entry of raw) {
|
|
98
|
+
const value = String(entry ?? '').trim()
|
|
99
|
+
if (value !== '' && !out.includes(value)) out.push(value)
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Accept only a non-negative finite number; anything else means "no cap". */
|
|
105
|
+
function normalizeLimit(raw) {
|
|
106
|
+
if (raw === null || raw === undefined || raw === '') return null
|
|
107
|
+
const value = Number(raw)
|
|
108
|
+
if (!Number.isFinite(value) || value < 0) return null
|
|
109
|
+
return Math.floor(value)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Coerce anything (a fresh record, an old record, an API patch) into a
|
|
114
|
+
* complete, valid permissions block. Every field is validated here rather than
|
|
115
|
+
* at each call site: an invalid sandbox mode or a negative limit must never
|
|
116
|
+
* reach the enforcement layer.
|
|
117
|
+
*/
|
|
118
|
+
function normalizePermissions(raw) {
|
|
119
|
+
const source = raw && typeof raw === 'object' ? raw : {}
|
|
120
|
+
return {
|
|
121
|
+
allowedFolders: normalizeFolders(source.allowedFolders),
|
|
122
|
+
hourlyTokenLimit: normalizeLimit(source.hourlyTokenLimit),
|
|
123
|
+
dailyMinuteLimit: normalizeLimit(source.dailyMinuteLimit),
|
|
124
|
+
sandboxMode: SANDBOX_MODES.includes(source.sandboxMode) ? source.sandboxMode : null,
|
|
125
|
+
// Only an explicit true enables these: absent means the safe default, both
|
|
126
|
+
// for a fresh record and for one written before a field existed.
|
|
127
|
+
allowUpload: source.allowUpload !== false,
|
|
128
|
+
allowGit: source.allowGit === true,
|
|
129
|
+
allowWorkspaceCreate: source.allowWorkspaceCreate === true,
|
|
130
|
+
banned: source.banned === true,
|
|
131
|
+
note: typeof source.note === 'string' ? source.note.slice(0, 200) : '',
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Workspace-scope semantics live in ./permissions.js and are re-exported here:
|
|
136
|
+
// one implementation for the store, the API and the enforcement layer.
|
|
137
|
+
/** Operation audit ledger (separate file — must not be rolled away by
|
|
138
|
+
* security events or vice versa). Same 2× trim rule. */
|
|
139
|
+
const MAX_AUDIT_LINES = 5000
|
|
140
|
+
|
|
141
|
+
const USERNAME_RE = /^[a-zA-Z0-9_-]{2,32}$/
|
|
142
|
+
const MIN_PASSWORD_LEN = 6
|
|
143
|
+
const ACTIVITY_LIMIT_DEFAULT = 200
|
|
144
|
+
|
|
145
|
+
class StoreError extends Error {
|
|
146
|
+
constructor(code, message) {
|
|
147
|
+
super(message)
|
|
148
|
+
this.code = code
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** dsh data root — mirrors the host ($DSH_HOME, default ~/.dsh). */
|
|
153
|
+
function dshHome() {
|
|
154
|
+
return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), '.dsh')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isValidUsername(name) {
|
|
158
|
+
return typeof name === 'string' && USERNAME_RE.test(name)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isValidPassword(pw) {
|
|
162
|
+
return typeof pw === 'string' && pw.length >= MIN_PASSWORD_LEN && pw.length <= 256
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function hashPassword(password, salt) {
|
|
166
|
+
const useSalt = salt || randomBytes(16).toString('hex')
|
|
167
|
+
const derived = await scrypt(password, useSalt, KEY_LEN, { N: SCRYPT_COST, r: 8, p: 1 })
|
|
168
|
+
return { salt: useSalt, passHash: derived.toString('hex') }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Constant-time password check against a stored record.
|
|
173
|
+
*
|
|
174
|
+
* Native records are scrypt ({ salt, passHash }). Records imported from
|
|
175
|
+
* dsh-passwords carry { algo: 'bcrypt', passHash } instead: a bcrypt hash
|
|
176
|
+
* cannot be converted, so it is verified as-is and upgraded on the next
|
|
177
|
+
* successful sign-in (see checkLogin).
|
|
178
|
+
*/
|
|
179
|
+
function verifyPassword(record, password) {
|
|
180
|
+
if (!record || !record.passHash || typeof password !== 'string') return false
|
|
181
|
+
if (record.algo === BCRYPT_ALGO) {
|
|
182
|
+
if (typeof record.passHash !== 'string' || record.passHash === '') return false
|
|
183
|
+
try {
|
|
184
|
+
return bcrypt.compareSync(password, record.passHash)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// Swallowing this silently would make "the dependency is missing or the
|
|
187
|
+
// hash is malformed" indistinguishable from "wrong password" — the account
|
|
188
|
+
// would simply never be able to sign in, with nothing said about it.
|
|
189
|
+
if (!bcryptFailureReported) {
|
|
190
|
+
bcryptFailureReported = true
|
|
191
|
+
console.error('[user-management] bcrypt verification failed; imported accounts cannot sign in:', error && error.message)
|
|
192
|
+
}
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!record.salt) return false
|
|
197
|
+
const derived = scryptSync(password, record.salt, KEY_LEN, { N: SCRYPT_COST, r: 8, p: 1 })
|
|
198
|
+
const expected = Buffer.from(record.passHash, 'hex')
|
|
199
|
+
if (derived.length !== expected.length) return false
|
|
200
|
+
return timingSafeEqual(derived, expected)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Random URL-safe token (session ids, one-time reset passwords). */
|
|
204
|
+
function randomToken(bytes = 32) {
|
|
205
|
+
return randomBytes(bytes).toString('base64url')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Temps are 12 chars but human-typeable: groups of 4. */
|
|
209
|
+
function tempPassword() {
|
|
210
|
+
const raw = randomToken(9) // 12 base64url chars
|
|
211
|
+
return raw.slice(0, 4) + '-' + raw.slice(4, 8) + '-' + raw.slice(8, 12)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Stable token fingerprint for ledger entries — never log the token itself. */
|
|
215
|
+
function tokenFingerprint(token) {
|
|
216
|
+
return createHash('sha256').update(String(token)).digest('hex').slice(0, 12)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeIp(raw) {
|
|
220
|
+
if (!raw) return ''
|
|
221
|
+
let ip = String(raw)
|
|
222
|
+
if (ip.startsWith('::ffff:')) ip = ip.slice(7)
|
|
223
|
+
if (ip === '::1') ip = '127.0.0.1'
|
|
224
|
+
return ip
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Create a store rooted at `<home>/user-management`. All state is cached in
|
|
229
|
+
* memory after load; writes persist through serialized atomic replacements.
|
|
230
|
+
*/
|
|
231
|
+
function createStore({ home, now = () => Date.now() } = {}) {
|
|
232
|
+
const dir = join(home, 'user-management')
|
|
233
|
+
const usersFile = join(dir, USERS_FILE)
|
|
234
|
+
const sessionsFile = join(dir, SESSIONS_FILE)
|
|
235
|
+
const activityFile = join(dir, ACTIVITY_FILE)
|
|
236
|
+
const auditFile = join(dir, AUDIT_FILE)
|
|
237
|
+
const bansFile = join(dir, BANS_FILE)
|
|
238
|
+
const usageFile = join(dir, USAGE_FILE)
|
|
239
|
+
const workspacesFile = join(dir, WORKSPACES_FILE)
|
|
240
|
+
|
|
241
|
+
/** @type {{seq:number, users:Array}} */
|
|
242
|
+
let usersDoc = { seq: 0, users: [] }
|
|
243
|
+
/** @type {{tokens:Object<string,{userId,username,createdAt,expiresAt}>}} */
|
|
244
|
+
let sessionsDoc = { tokens: {} }
|
|
245
|
+
/** @type {{bans:Array<{ip,note,createdAt,createdBy}>}} */
|
|
246
|
+
let bansDoc = { bans: [] }
|
|
247
|
+
/**
|
|
248
|
+
* Per-account quota counters, keyed by lower-cased username.
|
|
249
|
+
*
|
|
250
|
+
* Keyed by username rather than user id so the same accounting works for a
|
|
251
|
+
* store session and for a signed identity arriving from a login gateway
|
|
252
|
+
* (whose id is synthetic). A rename therefore starts a fresh window — an
|
|
253
|
+
* acceptable trade for a quota, which is a rate limit rather than an audit
|
|
254
|
+
* record.
|
|
255
|
+
*
|
|
256
|
+
* @type {Record<string, {day:string, activeSeconds:number, firstSeenAt:number, lastActiveAt:number, hourlyWindowStart:number, hourlyTokens:number}>}
|
|
257
|
+
*/
|
|
258
|
+
let usageDoc = { users: {} }
|
|
259
|
+
/**
|
|
260
|
+
* Workspace ownership: normalized path → owning username.
|
|
261
|
+
*
|
|
262
|
+
* Ported from dsh-passwords' user_workspaces table. It answers one question
|
|
263
|
+
* the permission switches cannot: this folder is inside your allow-list, but
|
|
264
|
+
* it is somebody else's workspace — so it is not yours to see, rename or
|
|
265
|
+
* delete. The owning account is exempt, and so is an administrator.
|
|
266
|
+
*
|
|
267
|
+
* @type {{owners: Record<string, string>}}
|
|
268
|
+
*/
|
|
269
|
+
let workspacesDoc = { owners: {} }
|
|
270
|
+
|
|
271
|
+
/** Lookup used during load, before the public helpers are meaningful. */
|
|
272
|
+
function findUserByUsernameRaw(username) {
|
|
273
|
+
const needle = String(username || '').toLowerCase()
|
|
274
|
+
return usersDoc.users.find((u) => String(u.username).toLowerCase() === needle) || null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Local-time day key, matching how "daily" is understood by a human. */
|
|
278
|
+
function todayKey(ts) {
|
|
279
|
+
const d = new Date(ts)
|
|
280
|
+
const month = String(d.getMonth() + 1).padStart(2, '0')
|
|
281
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
282
|
+
return `${d.getFullYear()}-${month}-${day}`
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Roll a record over when the local day changed. */
|
|
286
|
+
function rollDay(entry, ts) {
|
|
287
|
+
const day = todayKey(ts)
|
|
288
|
+
if (entry.day !== day) {
|
|
289
|
+
entry.day = day
|
|
290
|
+
entry.activeSeconds = 0
|
|
291
|
+
entry.hourlyTokens = 0
|
|
292
|
+
entry.hourlyWindowStart = ts
|
|
293
|
+
}
|
|
294
|
+
return entry
|
|
295
|
+
}
|
|
296
|
+
let ledgerLines = 0
|
|
297
|
+
let auditLines = 0
|
|
298
|
+
let auditSeq = 0
|
|
299
|
+
const writeChain = { users: Promise.resolve(), sessions: Promise.resolve(), ledger: Promise.resolve(), audit: Promise.resolve() }
|
|
300
|
+
|
|
301
|
+
function atomicWrite(file, content) {
|
|
302
|
+
return fsP.mkdir(dir, { recursive: true }).then(() => {
|
|
303
|
+
const temp = join(dir, `.${randomBytes(6).toString('hex')}.tmp`)
|
|
304
|
+
return fsP.writeFile(temp, content, { encoding: 'utf8', mode: 0o600 })
|
|
305
|
+
.then(() => fsP.rename(temp, file))
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function persistUsers() {
|
|
310
|
+
const run = writeChain.users.then(() => atomicWrite(usersFile, JSON.stringify(usersDoc, null, 2)))
|
|
311
|
+
writeChain.users = run.catch(() => {})
|
|
312
|
+
return run
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function persistWorkspaces() {
|
|
316
|
+
const run = writeChain.users.then(() => atomicWrite(workspacesFile, JSON.stringify(workspacesDoc, null, 2)))
|
|
317
|
+
writeChain.users = run.catch(() => {})
|
|
318
|
+
return run
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function persistUsage() {
|
|
322
|
+
const run = writeChain.users.then(() => atomicWrite(usageFile, JSON.stringify(usageDoc, null, 2)))
|
|
323
|
+
writeChain.users = run.catch(() => {})
|
|
324
|
+
return run
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function persistBans() {
|
|
328
|
+
const run = writeChain.users.then(() => atomicWrite(bansFile, JSON.stringify(bansDoc, null, 2)))
|
|
329
|
+
writeChain.users = run.catch(() => {})
|
|
330
|
+
return run
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function persistSessions() {
|
|
334
|
+
const run = writeChain.sessions.then(() => atomicWrite(sessionsFile, JSON.stringify(sessionsDoc)))
|
|
335
|
+
writeChain.sessions = run.catch(() => {})
|
|
336
|
+
return run
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function load() {
|
|
340
|
+
await fsP.mkdir(dir, { recursive: true })
|
|
341
|
+
for (const [file, assign] of [
|
|
342
|
+
[usersFile, (data) => { usersDoc = data }],
|
|
343
|
+
[sessionsFile, (data) => { sessionsDoc = data }],
|
|
344
|
+
[bansFile, (data) => { bansDoc = data }],
|
|
345
|
+
[usageFile, (data) => { usageDoc = data }],
|
|
346
|
+
[workspacesFile, (data) => { workspacesDoc = data }],
|
|
347
|
+
]) {
|
|
348
|
+
try {
|
|
349
|
+
const raw = await fsP.readFile(file, 'utf8')
|
|
350
|
+
const parsed = JSON.parse(raw)
|
|
351
|
+
if (parsed && typeof parsed === 'object') assign(parsed)
|
|
352
|
+
} catch (e) {
|
|
353
|
+
if (e && e.code !== 'ENOENT') throw e
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (!Array.isArray(usersDoc.users)) usersDoc.users = []
|
|
357
|
+
if (!Number.isFinite(usersDoc.seq)) usersDoc.seq = usersDoc.users.length
|
|
358
|
+
// Records written before the permission model existed carry no block: fill
|
|
359
|
+
// in the defaults in place so every reader sees one shape.
|
|
360
|
+
for (const user of usersDoc.users) user.permissions = normalizePermissions(user.permissions)
|
|
361
|
+
if (!sessionsDoc.tokens || typeof sessionsDoc.tokens !== 'object') sessionsDoc.tokens = {}
|
|
362
|
+
if (!Array.isArray(bansDoc.bans)) bansDoc.bans = []
|
|
363
|
+
if (!usageDoc || typeof usageDoc.users !== 'object' || usageDoc.users === null) usageDoc = { users: {} }
|
|
364
|
+
if (!workspacesDoc || typeof workspacesDoc.owners !== 'object' || workspacesDoc.owners === null) workspacesDoc = { owners: {} }
|
|
365
|
+
// A claim whose account no longer exists would hide that folder from
|
|
366
|
+
// everyone but an administrator, so drop those on load.
|
|
367
|
+
for (const [path, owner] of Object.entries(workspacesDoc.owners)) {
|
|
368
|
+
if (findUserByUsernameRaw(owner) === null) delete workspacesDoc.owners[path]
|
|
369
|
+
}
|
|
370
|
+
// Usage is a rolling counter, not a ledger: anything older than a couple of
|
|
371
|
+
// days can never affect a quota again, so it is dropped rather than kept
|
|
372
|
+
// forever in a file that gets rewritten on every touch.
|
|
373
|
+
for (const [name, entry] of Object.entries(usageDoc.users)) {
|
|
374
|
+
if (!entry || typeof entry !== 'object' || entry.day !== todayKey(now()) || entry.day !== todayKey(entry.lastActiveAt || 0)) {
|
|
375
|
+
delete usageDoc.users[name]
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// expired tokens are dropped on boot; ledger line count for rollover
|
|
379
|
+
const ts = now()
|
|
380
|
+
for (const token of Object.keys(sessionsDoc.tokens)) {
|
|
381
|
+
if (!sessionsDoc.tokens[token] || sessionsDoc.tokens[token].expiresAt <= ts) delete sessionsDoc.tokens[token]
|
|
382
|
+
}
|
|
383
|
+
try { ledgerLines = (await fsP.readFile(activityFile, 'utf8')).split('\n').filter((l) => l.trim() !== '').length } catch { ledgerLines = 0 }
|
|
384
|
+
try {
|
|
385
|
+
const raw = await fsP.readFile(auditFile, 'utf8')
|
|
386
|
+
const lines = raw.split('\n').filter((l) => l.trim() !== '')
|
|
387
|
+
auditLines = lines.length
|
|
388
|
+
for (const line of lines) {
|
|
389
|
+
try {
|
|
390
|
+
const id = JSON.parse(line).id
|
|
391
|
+
const match = typeof id === 'string' ? /^a(\d+)$/.exec(id) : null
|
|
392
|
+
if (match) auditSeq = Math.max(auditSeq, Number(match[1]))
|
|
393
|
+
} catch { /* skip malformed line */ }
|
|
394
|
+
}
|
|
395
|
+
} catch { auditLines = 0 }
|
|
396
|
+
return { users: usersDoc.users.length, sessions: Object.keys(sessionsDoc.tokens).length }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── users ────────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
function publicUser(user) {
|
|
402
|
+
if (!user) return null
|
|
403
|
+
return {
|
|
404
|
+
id: user.id, username: user.username, role: user.role,
|
|
405
|
+
disabled: !!user.disabled,
|
|
406
|
+
totpEnabled: !!user.totpSecret,
|
|
407
|
+
createdAt: user.createdAt, lastLoginAt: user.lastLoginAt || null,
|
|
408
|
+
permissions: normalizePermissions(user.permissions),
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function findUser(id) {
|
|
413
|
+
return usersDoc.users.find((u) => u.id === id) || null
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function findUserByUsername(username) {
|
|
417
|
+
const needle = String(username || '').toLowerCase()
|
|
418
|
+
return usersDoc.users.find((u) => u.username.toLowerCase() === needle) || null
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function countAdmins() {
|
|
422
|
+
return usersDoc.users.filter((u) => u.role === 'admin').length
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function createUser({ username, password, role = 'user', disabled = false, permissions }) {
|
|
426
|
+
if (!isValidUsername(username)) throw new StoreError('bad_username', '用户名需 2-32 位,仅限字母/数字/下划线/连字符')
|
|
427
|
+
if (!isValidPassword(password)) throw new StoreError('bad_password', `密码至少 ${MIN_PASSWORD_LEN} 位`)
|
|
428
|
+
if (findUserByUsername(username)) throw new StoreError('duplicate', '用户名已存在')
|
|
429
|
+
if (role !== 'admin' && role !== 'user') throw new StoreError('bad_role', 'role 必须是 admin 或 user')
|
|
430
|
+
const { salt, passHash } = await hashPassword(password)
|
|
431
|
+
const user = {
|
|
432
|
+
id: `u_${++usersDoc.seq}_${randomToken(6)}`,
|
|
433
|
+
username: String(username),
|
|
434
|
+
role,
|
|
435
|
+
salt,
|
|
436
|
+
passHash,
|
|
437
|
+
createdAt: now(),
|
|
438
|
+
lastLoginAt: null,
|
|
439
|
+
permissions: normalizePermissions(permissions),
|
|
440
|
+
}
|
|
441
|
+
// Approval-mode registrations land disabled (the admin enable button is
|
|
442
|
+
// the approval action); admin-created accounts are always active.
|
|
443
|
+
if (disabled) user.disabled = true
|
|
444
|
+
usersDoc.users.push(user)
|
|
445
|
+
await persistUsers()
|
|
446
|
+
return publicUser(user)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Merge a partial permissions patch onto a user (admin action). Absent keys
|
|
451
|
+
* keep their current value; an explicit null clears a limit. Everything goes
|
|
452
|
+
* through normalizePermissions, so the stored shape is always complete.
|
|
453
|
+
*/
|
|
454
|
+
async function setPermissions(user, patch) {
|
|
455
|
+
if (!user) throw new StoreError('not_found', '用户不存在')
|
|
456
|
+
const next = { ...normalizePermissions(user.permissions) }
|
|
457
|
+
for (const [key, value] of Object.entries(patch || {})) {
|
|
458
|
+
if (value !== undefined) next[key] = value
|
|
459
|
+
}
|
|
460
|
+
user.permissions = normalizePermissions(next)
|
|
461
|
+
await persistUsers()
|
|
462
|
+
return publicUser(user)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Create an account from an exported record, preserving its existing password
|
|
467
|
+
* hash verbatim (see verifyPassword for how a foreign algorithm is handled).
|
|
468
|
+
*
|
|
469
|
+
* Used by the dsh-passwords migration: re-hashing is impossible without the
|
|
470
|
+
* plaintext password, and asking every user to reset would make the move
|
|
471
|
+
* disruptive enough that nobody does it.
|
|
472
|
+
*
|
|
473
|
+
* @param options.replaceCredentials - also adopt the imported password hash
|
|
474
|
+
* when the username already exists here (see the collision note below).
|
|
475
|
+
* @returns {{ user: object, created: boolean, credentialsReplaced?: boolean }}
|
|
476
|
+
* whether the account was newly created; an existing username keeps
|
|
477
|
+
* its own hash unless `replaceCredentials` asks otherwise, so
|
|
478
|
+
* re-running a migration is safe by default.
|
|
479
|
+
*/
|
|
480
|
+
async function importUser({ username, role = 'user', passHash, algo, salt, createdAt, lastLoginAt, disabled = false, permissions, replaceCredentials = false }) {
|
|
481
|
+
if (!isValidUsername(username)) throw new StoreError('bad_username', '用户名需 2-32 位,仅限字母/数字/下划线/连字符')
|
|
482
|
+
if (typeof passHash !== 'string' || passHash === '') throw new StoreError('bad_password', '导入记录缺少密码哈希')
|
|
483
|
+
if (role !== 'admin' && role !== 'user') throw new StoreError('bad_role', 'role 必须是 admin 或 user')
|
|
484
|
+
|
|
485
|
+
const existing = findUserByUsername(username)
|
|
486
|
+
if (existing) {
|
|
487
|
+
existing.permissions = normalizePermissions(permissions)
|
|
488
|
+
if (typeof role === 'string') existing.role = role
|
|
489
|
+
// A name collision means the two systems each had an account under this
|
|
490
|
+
// username with unrelated password hashes. The caller decides which side
|
|
491
|
+
// wins: replacing is the right call when the imported system is the one
|
|
492
|
+
// people actually sign in to today, but it must be asked for explicitly
|
|
493
|
+
// (silently swapping someone's credentials is not a migration's job).
|
|
494
|
+
let credentialsReplaced = false
|
|
495
|
+
if (replaceCredentials) {
|
|
496
|
+
existing.passHash = passHash
|
|
497
|
+
if (typeof algo === 'string' && algo !== '') existing.algo = algo
|
|
498
|
+
else delete existing.algo
|
|
499
|
+
if (typeof salt === 'string' && salt !== '') existing.salt = salt
|
|
500
|
+
else delete existing.salt
|
|
501
|
+
credentialsReplaced = true
|
|
502
|
+
}
|
|
503
|
+
await persistUsers()
|
|
504
|
+
return { user: publicUser(existing), created: false, credentialsReplaced }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const user = {
|
|
508
|
+
id: `u_${++usersDoc.seq}_${randomToken(6)}`,
|
|
509
|
+
username: String(username),
|
|
510
|
+
role,
|
|
511
|
+
passHash,
|
|
512
|
+
createdAt: Number.isFinite(createdAt) ? createdAt : now(),
|
|
513
|
+
lastLoginAt: Number.isFinite(lastLoginAt) ? lastLoginAt : null,
|
|
514
|
+
permissions: normalizePermissions(permissions),
|
|
515
|
+
}
|
|
516
|
+
// A native (scrypt) hash keeps its salt; an imported foreign hash keeps its
|
|
517
|
+
// algorithm marker so verification knows which path to take.
|
|
518
|
+
if (typeof salt === 'string' && salt !== '') user.salt = salt
|
|
519
|
+
if (typeof algo === 'string' && algo !== '') user.algo = algo
|
|
520
|
+
if (disabled) user.disabled = true
|
|
521
|
+
usersDoc.users.push(user)
|
|
522
|
+
await persistUsers()
|
|
523
|
+
return { user: publicUser(user), created: true }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── workspace ownership ──────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
/** path → owner map (a copy; callers must not mutate store state). */
|
|
529
|
+
function workspaceOwners() {
|
|
530
|
+
return { ...workspacesDoc.owners }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Who owns this folder, or null when nobody has claimed it. */
|
|
534
|
+
function workspaceOwnerOf(path) {
|
|
535
|
+
return workspacesDoc.owners[normalizePath(path)] || null
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Claim a folder for an account (a no-op when someone already owns it). */
|
|
539
|
+
async function claimWorkspace(path, username) {
|
|
540
|
+
const key = normalizePath(path)
|
|
541
|
+
if (key === '' || key === '.' || key === '/') return null
|
|
542
|
+
if (workspacesDoc.owners[key] !== undefined) return workspacesDoc.owners[key]
|
|
543
|
+
workspacesDoc.owners[key] = String(username)
|
|
544
|
+
await persistWorkspaces()
|
|
545
|
+
return workspacesDoc.owners[key]
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Release a claim (the folder is gone). */
|
|
549
|
+
async function releaseWorkspace(path) {
|
|
550
|
+
const key = normalizePath(path)
|
|
551
|
+
if (workspacesDoc.owners[key] === undefined) return false
|
|
552
|
+
delete workspacesDoc.owners[key]
|
|
553
|
+
await persistWorkspaces()
|
|
554
|
+
return true
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Move a claim with its folder. */
|
|
558
|
+
async function renameWorkspace(oldPath, newPath) {
|
|
559
|
+
const from = normalizePath(oldPath)
|
|
560
|
+
const to = normalizePath(newPath)
|
|
561
|
+
if (workspacesDoc.owners[from] === undefined) return false
|
|
562
|
+
workspacesDoc.owners[to] = workspacesDoc.owners[from]
|
|
563
|
+
delete workspacesDoc.owners[from]
|
|
564
|
+
await persistWorkspaces()
|
|
565
|
+
return true
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ── usage / quota counters ───────────────────────────────────────────────
|
|
569
|
+
|
|
570
|
+
/** Counters for an account, or null when it has no record for today. */
|
|
571
|
+
function getUsage(username) {
|
|
572
|
+
const key = String(username || '').toLowerCase()
|
|
573
|
+
if (key === '') return null
|
|
574
|
+
const entry = usageDoc.users[key]
|
|
575
|
+
if (!entry) return null
|
|
576
|
+
rollDay(entry, now())
|
|
577
|
+
return { ...entry }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Record activity against an account's daily allowance.
|
|
582
|
+
*
|
|
583
|
+
* Called from the request path, so it stays cheap: the caller throttles by
|
|
584
|
+
* wall clock (a request every few seconds must not become a file write every
|
|
585
|
+
* few seconds), and this only persists when the caller passed real time.
|
|
586
|
+
*/
|
|
587
|
+
async function touchUsage(username, seconds = 0) {
|
|
588
|
+
const key = String(username || '').toLowerCase()
|
|
589
|
+
if (key === '') return null
|
|
590
|
+
const ts = now()
|
|
591
|
+
const entry = rollDay(usageDoc.users[key] || {
|
|
592
|
+
day: todayKey(ts),
|
|
593
|
+
activeSeconds: 0,
|
|
594
|
+
firstSeenAt: ts,
|
|
595
|
+
lastActiveAt: ts,
|
|
596
|
+
hourlyWindowStart: ts,
|
|
597
|
+
hourlyTokens: 0,
|
|
598
|
+
}, ts)
|
|
599
|
+
entry.lastActiveAt = ts
|
|
600
|
+
if (Number.isFinite(seconds) && seconds > 0) entry.activeSeconds += Math.round(seconds)
|
|
601
|
+
usageDoc.users[key] = entry
|
|
602
|
+
await persistUsage()
|
|
603
|
+
return { ...entry }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Add reported tokens to the rolling hourly window.
|
|
608
|
+
*
|
|
609
|
+
* The client reports deltas of dsh's own token projection: the gateway cannot
|
|
610
|
+
* see provider accounting, and reconstructing it from response bodies was
|
|
611
|
+
* abandoned upstream as unreliable.
|
|
612
|
+
*/
|
|
613
|
+
async function addTokens(username, tokens) {
|
|
614
|
+
const key = String(username || '').toLowerCase()
|
|
615
|
+
const amount = Math.round(Number(tokens))
|
|
616
|
+
if (key === '' || !Number.isFinite(amount) || amount <= 0) return getUsage(username)
|
|
617
|
+
const ts = now()
|
|
618
|
+
const entry = rollDay(usageDoc.users[key] || {
|
|
619
|
+
day: todayKey(ts),
|
|
620
|
+
activeSeconds: 0,
|
|
621
|
+
firstSeenAt: ts,
|
|
622
|
+
lastActiveAt: ts,
|
|
623
|
+
hourlyWindowStart: ts,
|
|
624
|
+
hourlyTokens: 0,
|
|
625
|
+
}, ts)
|
|
626
|
+
if (ts - entry.hourlyWindowStart >= HOURLY_WINDOW_MS) {
|
|
627
|
+
entry.hourlyWindowStart = ts
|
|
628
|
+
entry.hourlyTokens = 0
|
|
629
|
+
}
|
|
630
|
+
entry.hourlyTokens += amount
|
|
631
|
+
entry.lastActiveAt = ts
|
|
632
|
+
usageDoc.users[key] = entry
|
|
633
|
+
await persistUsage()
|
|
634
|
+
return { ...entry }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** First account in an empty system becomes admin (see README). */
|
|
638
|
+
function roleForNextRegistration() {
|
|
639
|
+
return usersDoc.users.length === 0 ? 'admin' : 'user'
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function verifyLogin(username, password) {
|
|
643
|
+
const outcome = await checkLogin(username, password)
|
|
644
|
+
return outcome.result === 'ok' || outcome.result === 'disabled' ? outcome.user : null
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Full login outcome: 'ok' | 'invalid' | 'disabled' (correct credentials
|
|
649
|
+
* but the account is disabled). The invalid path burns hash time so
|
|
650
|
+
* missing users are not distinguishable; the disabled path only exists
|
|
651
|
+
* behind correct credentials, so revealing it is safe.
|
|
652
|
+
*/
|
|
653
|
+
async function checkLogin(username, password) {
|
|
654
|
+
const user = findUserByUsername(username)
|
|
655
|
+
if (!user) {
|
|
656
|
+
await hashPassword(password || '', '00000000000000000000000000000000')
|
|
657
|
+
return { result: 'invalid' }
|
|
658
|
+
}
|
|
659
|
+
if (!verifyPassword(user, password)) return { result: 'invalid' }
|
|
660
|
+
// Migrated accounts: the bcrypt hash verified, so this is the moment to move
|
|
661
|
+
// them onto the native scheme. Best effort — a failed re-hash must not fail
|
|
662
|
+
// a correct sign-in (the old hash still works next time).
|
|
663
|
+
if (user.algo === BCRYPT_ALGO) {
|
|
664
|
+
try {
|
|
665
|
+
const { salt, passHash } = await hashPassword(password)
|
|
666
|
+
user.salt = salt
|
|
667
|
+
user.passHash = passHash
|
|
668
|
+
delete user.algo
|
|
669
|
+
await persistUsers()
|
|
670
|
+
} catch { /* keep the imported hash */ }
|
|
671
|
+
}
|
|
672
|
+
return { result: user.disabled ? 'disabled' : 'ok', user }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Disable / enable an account. Disabled users fail login and lose all
|
|
676
|
+
* live sessions (resolveSession also re-checks the flag defensively). */
|
|
677
|
+
async function setDisabled(user, disabled) {
|
|
678
|
+
if (typeof disabled !== 'boolean') throw new StoreError('bad_request', 'disabled 必须是布尔值')
|
|
679
|
+
if (disabled && user.role === 'admin' && countAdmins() <= 1) {
|
|
680
|
+
throw new StoreError('last_admin', '不能禁用最后一个管理员')
|
|
681
|
+
}
|
|
682
|
+
if (disabled) user.disabled = true
|
|
683
|
+
else delete user.disabled
|
|
684
|
+
await persistUsers()
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function setPassword(user, password) {
|
|
688
|
+
if (!isValidPassword(password)) throw new StoreError('bad_password', `密码至少 ${MIN_PASSWORD_LEN} 位`)
|
|
689
|
+
const { salt, passHash } = await hashPassword(password)
|
|
690
|
+
user.salt = salt
|
|
691
|
+
user.passHash = passHash
|
|
692
|
+
await persistUsers()
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function setRole(user, role) {
|
|
696
|
+
if (role !== 'admin' && role !== 'user') throw new StoreError('bad_role', 'role 必须是 admin 或 user')
|
|
697
|
+
if (user.role === 'admin' && role !== 'admin' && countAdmins() <= 1) {
|
|
698
|
+
throw new StoreError('last_admin', '不能降级最后一个管理员')
|
|
699
|
+
}
|
|
700
|
+
user.role = role
|
|
701
|
+
await persistUsers()
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ── TOTP two-step verification ───────────────────────────────────────────
|
|
705
|
+
//
|
|
706
|
+
// Enrollment is two-phase so a typo in the secret can never lock the user
|
|
707
|
+
// out: startTotpSetup parks the fresh secret in totpPendingSecret, and only
|
|
708
|
+
// activateTotp — which demands a code the authenticator generated FROM that
|
|
709
|
+
// secret — promotes it to totpSecret (the enabled flag is `totpSecret`
|
|
710
|
+
// itself). verifyLoginOtp enforces single use by persisting the matched
|
|
711
|
+
// step counter; older/equal counters are rejected as replays.
|
|
712
|
+
|
|
713
|
+
/** Generate a pending secret; the API layer builds the otpauth URI + QR. */
|
|
714
|
+
async function startTotpSetup(user) {
|
|
715
|
+
if (user.totpSecret) throw new StoreError('totp_enabled', '两步验证已开启,请先关闭再重新绑定')
|
|
716
|
+
user.totpPendingSecret = generateSecret()
|
|
717
|
+
await persistUsers()
|
|
718
|
+
return { secret: user.totpPendingSecret }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Promote the pending secret to enabled after the user proves possession. */
|
|
722
|
+
async function activateTotp(user, code) {
|
|
723
|
+
if (!user.totpPendingSecret) throw new StoreError('no_pending_totp', '请先开始两步验证绑定')
|
|
724
|
+
const verdict = verifyTotp(user.totpPendingSecret, code, { nowMs: now() })
|
|
725
|
+
if (!verdict.ok) return false
|
|
726
|
+
user.totpSecret = user.totpPendingSecret
|
|
727
|
+
user.totpLastCounter = verdict.counter
|
|
728
|
+
user.totpEnabledAt = now()
|
|
729
|
+
delete user.totpPendingSecret
|
|
730
|
+
await persistUsers()
|
|
731
|
+
return true
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Drop every TOTP field (self-service disable and admin reset share this). */
|
|
735
|
+
async function disableTotp(user) {
|
|
736
|
+
if (!user.totpSecret) throw new StoreError('totp_not_enabled', '该账号未开启两步验证')
|
|
737
|
+
delete user.totpSecret
|
|
738
|
+
delete user.totpPendingSecret
|
|
739
|
+
delete user.totpLastCounter
|
|
740
|
+
delete user.totpEnabledAt
|
|
741
|
+
await persistUsers()
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Login-time OTP check. `notEnabled` lets the caller skip the OTP branch
|
|
746
|
+
* entirely; a successful check persists the matched counter so the same
|
|
747
|
+
* code can never be accepted twice.
|
|
748
|
+
*/
|
|
749
|
+
async function verifyLoginOtp(user, code) {
|
|
750
|
+
if (!user.totpSecret) return { ok: false, notEnabled: true }
|
|
751
|
+
const verdict = verifyTotp(user.totpSecret, code, { nowMs: now(), lastCounter: user.totpLastCounter || 0 })
|
|
752
|
+
if (verdict.ok) {
|
|
753
|
+
user.totpLastCounter = verdict.counter
|
|
754
|
+
await persistUsers()
|
|
755
|
+
}
|
|
756
|
+
return verdict
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function removeUser(user) {
|
|
760
|
+
const index = usersDoc.users.indexOf(user)
|
|
761
|
+
if (index === -1) throw new StoreError('not_found', '用户不存在')
|
|
762
|
+
if (user.role === 'admin' && countAdmins() <= 1) {
|
|
763
|
+
throw new StoreError('last_admin', '不能删除最后一个管理员')
|
|
764
|
+
}
|
|
765
|
+
usersDoc.users.splice(index, 1)
|
|
766
|
+
for (const token of Object.keys(sessionsDoc.tokens)) {
|
|
767
|
+
if (sessionsDoc.tokens[token].userId === user.id) delete sessionsDoc.tokens[token]
|
|
768
|
+
}
|
|
769
|
+
await persistUsers()
|
|
770
|
+
return persistSessions()
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function touchLogin(user) {
|
|
774
|
+
user.lastLoginAt = now()
|
|
775
|
+
await persistUsers()
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function listUsers() {
|
|
779
|
+
return usersDoc.users.map(publicUser)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ── sessions ─────────────────────────────────────────────────────────────
|
|
783
|
+
|
|
784
|
+
async function createSession(user) {
|
|
785
|
+
const token = randomToken()
|
|
786
|
+
const ts = now()
|
|
787
|
+
sessionsDoc.tokens[token] = { userId: user.id, username: user.username, createdAt: ts, expiresAt: ts + SESSION_TTL_MS }
|
|
788
|
+
await pruneSessions()
|
|
789
|
+
return { token, expiresAt: ts + SESSION_TTL_MS }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function pruneSessions() {
|
|
793
|
+
const ts = now()
|
|
794
|
+
for (const token of Object.keys(sessionsDoc.tokens)) {
|
|
795
|
+
const session = sessionsDoc.tokens[token]
|
|
796
|
+
if (!session || session.expiresAt <= ts) delete sessionsDoc.tokens[token]
|
|
797
|
+
}
|
|
798
|
+
return persistSessions()
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Resolve a token → {user, session, token}. Sliding renewal past half-life. */
|
|
802
|
+
async function resolveSession(token) {
|
|
803
|
+
if (!token || typeof token !== 'string') return null
|
|
804
|
+
const session = sessionsDoc.tokens[token]
|
|
805
|
+
if (!session) return null
|
|
806
|
+
const ts = now()
|
|
807
|
+
if (session.expiresAt <= ts) {
|
|
808
|
+
delete sessionsDoc.tokens[token]
|
|
809
|
+
await persistSessions()
|
|
810
|
+
return null
|
|
811
|
+
}
|
|
812
|
+
const user = findUser(session.userId)
|
|
813
|
+
if (!user) {
|
|
814
|
+
delete sessionsDoc.tokens[token]
|
|
815
|
+
await persistSessions()
|
|
816
|
+
return null
|
|
817
|
+
}
|
|
818
|
+
if (user.disabled) {
|
|
819
|
+
delete sessionsDoc.tokens[token]
|
|
820
|
+
await persistSessions()
|
|
821
|
+
return null
|
|
822
|
+
}
|
|
823
|
+
if (session.expiresAt - ts < SESSION_RENEW_MS) {
|
|
824
|
+
session.expiresAt = ts + SESSION_TTL_MS
|
|
825
|
+
await persistSessions()
|
|
826
|
+
}
|
|
827
|
+
return { user, session, token }
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function dropSession(token) {
|
|
831
|
+
if (token && sessionsDoc.tokens[token]) {
|
|
832
|
+
delete sessionsDoc.tokens[token]
|
|
833
|
+
await persistSessions()
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Kill every session of a user (password reset / role change / delete). */
|
|
838
|
+
async function dropUserSessions(userId, exceptToken) {
|
|
839
|
+
let dropped = false
|
|
840
|
+
for (const token of Object.keys(sessionsDoc.tokens)) {
|
|
841
|
+
if (sessionsDoc.tokens[token].userId !== userId) continue
|
|
842
|
+
if (token === exceptToken) continue
|
|
843
|
+
delete sessionsDoc.tokens[token]
|
|
844
|
+
dropped = true
|
|
845
|
+
}
|
|
846
|
+
if (dropped) await persistSessions()
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// ── IP bans ──────────────────────────────────────────────────────────────
|
|
850
|
+
|
|
851
|
+
function isBanned(ip) {
|
|
852
|
+
if (!ip) return false
|
|
853
|
+
return bansDoc.bans.some((b) => b.ip === ip)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function listBans() {
|
|
857
|
+
return bansDoc.bans.slice().sort((a, b) => b.createdAt - a.createdAt)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Ban an IP (validated). Loopback addresses are rejected outright and
|
|
861
|
+
* the self-lockout guard lives in the API layer — it needs the
|
|
862
|
+
* requester's live IP, the store does not. */
|
|
863
|
+
async function banIp(ip, { note = '', by = null } = {}) {
|
|
864
|
+
const normalized = String(ip || '').trim()
|
|
865
|
+
if (net.isIP(normalized) === 0) throw new StoreError('bad_ip', '不是合法的 IP 地址')
|
|
866
|
+
if (/^127\./.test(normalized) || normalized === '::1') throw new StoreError('protected_ip', '不能封禁回环地址')
|
|
867
|
+
if (isBanned(normalized)) throw new StoreError('duplicate', '该 IP 已在封禁列表')
|
|
868
|
+
bansDoc.bans.push({ ip: normalized, note: String(note || '').slice(0, 140), createdAt: now(), createdBy: by })
|
|
869
|
+
return persistBans()
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function unbanIp(ip) {
|
|
873
|
+
const before = bansDoc.bans.length
|
|
874
|
+
bansDoc.bans = bansDoc.bans.filter((b) => b.ip !== ip)
|
|
875
|
+
if (bansDoc.bans.length === before) throw new StoreError('not_found', '该 IP 不在封禁列表')
|
|
876
|
+
return persistBans()
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ── activity + audit ledgers ─────────────────────────────────────────────
|
|
880
|
+
|
|
881
|
+
function ledgerLine(entry) {
|
|
882
|
+
return JSON.stringify({
|
|
883
|
+
ts: now(),
|
|
884
|
+
type: entry.type,
|
|
885
|
+
username: entry.username || null,
|
|
886
|
+
userId: entry.userId || null,
|
|
887
|
+
ip: entry.ip || '',
|
|
888
|
+
detail: entry.detail || '',
|
|
889
|
+
})
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** Serialized append to a JSONL ledger with 2×-cap tail rollover. */
|
|
893
|
+
function appendJsonl({ file, line, getLines, setLines, cap }) {
|
|
894
|
+
const run = writeChain.ledger
|
|
895
|
+
.then(() => fsP.appendFile(file, line + '\n', { encoding: 'utf8', mode: 0o600 }))
|
|
896
|
+
.then(async () => {
|
|
897
|
+
setLines(getLines() + 1)
|
|
898
|
+
if (getLines() > cap * 2) {
|
|
899
|
+
const raw = await fsP.readFile(file, 'utf8')
|
|
900
|
+
const lines = raw.split('\n').filter((l) => l.trim() !== '')
|
|
901
|
+
const keep = lines.slice(-cap)
|
|
902
|
+
setLines(keep.length)
|
|
903
|
+
await atomicWrite(file, keep.join('\n') + '\n')
|
|
904
|
+
}
|
|
905
|
+
})
|
|
906
|
+
writeChain.ledger = run.catch(() => {})
|
|
907
|
+
return run
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function appendActivity(entry) {
|
|
911
|
+
return appendJsonl({
|
|
912
|
+
file: activityFile,
|
|
913
|
+
line: ledgerLine(entry),
|
|
914
|
+
getLines: () => ledgerLines,
|
|
915
|
+
setLines: (n) => { ledgerLines = n },
|
|
916
|
+
cap: MAX_LEDGER_LINES,
|
|
917
|
+
})
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** Operation audit: every gated API/WebSocket action of a signed-in user. */
|
|
921
|
+
async function appendAudit(entry) {
|
|
922
|
+
const id = `a${++auditSeq}`
|
|
923
|
+
return appendJsonl({
|
|
924
|
+
file: auditFile,
|
|
925
|
+
line: JSON.stringify({
|
|
926
|
+
id,
|
|
927
|
+
ts: now(),
|
|
928
|
+
type: entry.type || 'api',
|
|
929
|
+
username: entry.username || null,
|
|
930
|
+
userId: entry.userId || null,
|
|
931
|
+
ip: entry.ip || '',
|
|
932
|
+
method: entry.method || '',
|
|
933
|
+
path: entry.path || '',
|
|
934
|
+
status: entry.status || null,
|
|
935
|
+
}),
|
|
936
|
+
getLines: () => auditLines,
|
|
937
|
+
setLines: (n) => { auditLines = n },
|
|
938
|
+
cap: MAX_AUDIT_LINES,
|
|
939
|
+
})
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Remove one audit entry by id (admin manual cleanup). */
|
|
943
|
+
async function removeAuditEntry(id) {
|
|
944
|
+
const run = writeChain.ledger.then(async () => {
|
|
945
|
+
let raw = ''
|
|
946
|
+
try { raw = await fsP.readFile(auditFile, 'utf8') } catch { return false }
|
|
947
|
+
const lines = raw.split('\n').filter((l) => l.trim() !== '')
|
|
948
|
+
const kept = []
|
|
949
|
+
let removed = false
|
|
950
|
+
for (const line of lines) {
|
|
951
|
+
try {
|
|
952
|
+
if (JSON.parse(line).id === id) { removed = true; continue }
|
|
953
|
+
} catch { /* keep malformed lines as-is */ }
|
|
954
|
+
kept.push(line)
|
|
955
|
+
}
|
|
956
|
+
if (!removed) return false
|
|
957
|
+
auditLines = kept.length
|
|
958
|
+
await atomicWrite(auditFile, kept.length === 0 ? '' : kept.join('\n') + '\n')
|
|
959
|
+
return true
|
|
960
|
+
})
|
|
961
|
+
writeChain.ledger = run.catch(() => {})
|
|
962
|
+
return run
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** Drop the entire audit ledger (admin manual cleanup). */
|
|
966
|
+
async function clearAudit() {
|
|
967
|
+
const run = writeChain.ledger.then(async () => {
|
|
968
|
+
auditLines = 0
|
|
969
|
+
await atomicWrite(auditFile, '')
|
|
970
|
+
})
|
|
971
|
+
writeChain.ledger = run.catch(() => {})
|
|
972
|
+
return run
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Read ledger entries newest-first. Filters: type (exact), userId, and a
|
|
977
|
+
* set of types (`types`). `limit` caps the scan window from the tail.
|
|
978
|
+
*/
|
|
979
|
+
async function listActivity({ type, types, userId, limit = ACTIVITY_LIMIT_DEFAULT } = {}) {
|
|
980
|
+
return listJsonl(activityFile, (entry) => {
|
|
981
|
+
if (type && entry.type !== type) return false
|
|
982
|
+
if (types) {
|
|
983
|
+
const set = Array.isArray(types) ? new Set(types) : types
|
|
984
|
+
if (!set.has(entry.type)) return false
|
|
985
|
+
}
|
|
986
|
+
if (userId && entry.userId !== userId) return false
|
|
987
|
+
return true
|
|
988
|
+
}, limit)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Read the operation audit newest-first. Filters: username, method
|
|
993
|
+
* (exact), path substring, and a response-status class ('2'|'3'|'4'|'5').
|
|
994
|
+
*/
|
|
995
|
+
async function listAudit({ username, method, path, statusClass, limit = ACTIVITY_LIMIT_DEFAULT } = {}) {
|
|
996
|
+
return listJsonl(auditFile, (entry) => {
|
|
997
|
+
if (username && entry.username !== username) return false
|
|
998
|
+
if (method && entry.method !== method) return false
|
|
999
|
+
if (path && !String(entry.path || '').includes(path)) return false
|
|
1000
|
+
if (statusClass && String(entry.status || '').charAt(0) !== statusClass) return false
|
|
1001
|
+
return true
|
|
1002
|
+
}, limit)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function listJsonl(file, predicate, limit) {
|
|
1006
|
+
let raw = ''
|
|
1007
|
+
try { raw = await fsP.readFile(file, 'utf8') } catch { return [] }
|
|
1008
|
+
const lines = raw.split('\n').filter((l) => l.trim() !== '')
|
|
1009
|
+
const cap = Math.max(1, Math.min(Number(limit) || ACTIVITY_LIMIT_DEFAULT, MAX_AUDIT_LINES))
|
|
1010
|
+
const out = []
|
|
1011
|
+
for (let i = lines.length - 1; i >= 0 && out.length < cap; i -= 1) {
|
|
1012
|
+
let entry
|
|
1013
|
+
try { entry = JSON.parse(lines[i]) } catch { continue }
|
|
1014
|
+
if (predicate(entry)) out.push(entry)
|
|
1015
|
+
}
|
|
1016
|
+
return out
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
return {
|
|
1020
|
+
// lifecycle
|
|
1021
|
+
load,
|
|
1022
|
+
// users
|
|
1023
|
+
createUser, verifyLogin, checkLogin, setPassword, setRole, setDisabled, removeUser, touchLogin,
|
|
1024
|
+
listUsers, findUser, findUserByUsername, countAdmins, roleForNextRegistration, publicUser,
|
|
1025
|
+
setPermissions, normalizePermissions, isWorkspaceRestricted, DENY_ALL_WORKSPACES, importUser,
|
|
1026
|
+
// totp
|
|
1027
|
+
startTotpSetup, activateTotp, disableTotp, verifyLoginOtp,
|
|
1028
|
+
// sessions
|
|
1029
|
+
createSession, resolveSession, dropSession, dropUserSessions, pruneSessions,
|
|
1030
|
+
// ip bans
|
|
1031
|
+
isBanned, listBans, banIp, unbanIp,
|
|
1032
|
+
// usage / quotas
|
|
1033
|
+
getUsage, touchUsage, addTokens,
|
|
1034
|
+
// workspace ownership
|
|
1035
|
+
workspaceOwners, workspaceOwnerOf, claimWorkspace, releaseWorkspace, renameWorkspace,
|
|
1036
|
+
// ledger
|
|
1037
|
+
appendActivity, listActivity, appendAudit, listAudit, removeAuditEntry, clearAudit,
|
|
1038
|
+
// internals for tests
|
|
1039
|
+
DEFAULT_PERMISSIONS,
|
|
1040
|
+
SANDBOX_MODES,
|
|
1041
|
+
HOURLY_WINDOW_MS,
|
|
1042
|
+
__files: { dir, usersFile, sessionsFile, activityFile, auditFile, bansFile, usageFile, workspacesFile },
|
|
1043
|
+
__state: () => ({ users: usersDoc.users, sessions: sessionsDoc.tokens, ledgerLines, auditLines, bans: bansDoc.bans, usage: usageDoc.users }),
|
|
1044
|
+
StoreError,
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
export {
|
|
1049
|
+
createStore,
|
|
1050
|
+
normalizePermissions,
|
|
1051
|
+
isWorkspaceRestricted,
|
|
1052
|
+
DENY_ALL_WORKSPACES,
|
|
1053
|
+
DEFAULT_PERMISSIONS,
|
|
1054
|
+
SANDBOX_MODES,
|
|
1055
|
+
dshHome,
|
|
1056
|
+
isValidUsername,
|
|
1057
|
+
isValidPassword,
|
|
1058
|
+
verifyPassword,
|
|
1059
|
+
hashPassword,
|
|
1060
|
+
randomToken,
|
|
1061
|
+
tempPassword,
|
|
1062
|
+
tokenFingerprint,
|
|
1063
|
+
normalizeIp,
|
|
1064
|
+
StoreError,
|
|
1065
|
+
SESSION_TTL_MS,
|
|
1066
|
+
SESSION_RENEW_MS,
|
|
1067
|
+
MAX_LEDGER_LINES,
|
|
1068
|
+
MAX_AUDIT_LINES,
|
|
1069
|
+
USERNAME_RE,
|
|
1070
|
+
MIN_PASSWORD_LEN,
|
|
1071
|
+
ACTIVITY_LIMIT_DEFAULT,
|
|
1072
|
+
}
|