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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,13 @@
1
+ # user-management bundle patch: contributes the plugin row into the
2
+ # host composition when this package is installed via `dsh plugin add`.
3
+ #
4
+ # The plugin is HOST-PLANE: it spins up its own node:https gateway listener
5
+ # (TLS + self-signed certs + Host allow-list) that reverse-proxies to the
6
+ # loopback dsh webserver, with its own user store as the auth (login/register
7
+ # page + /user-management/api/* served locally; everything else proxied). It
8
+ # also serves a settings-page management UI from its client half. It must land
9
+ # in the host composition (this insert), never inside an agent preset.
10
+
11
+ - insert:
12
+ - id: user-management
13
+ name: 'kczx-user-management'
package/docs/FAQ.md ADDED
@@ -0,0 +1,95 @@
1
+ # 常见问题(FAQ)
2
+
3
+ > 所有文件级操作的第一原则:**先停 dsh web 再改文件**。user-management 把数据整份缓存在内存里,插件运行期间任何一次写盘(哪怕只是某人登录了一次)都会用内存副本覆盖你手改的文件。停法:`kill -9 $(lsof -tiTCP:19080 -sTCP:LISTEN)`(launchd 会自动拉起新进程),改完文件即已完成——新进程启动时读取的就是你改过的版本。
4
+
5
+ ## 忘记管理员密码 / 用户名怎么办
6
+
7
+ 按代价从小到大三级处理:
8
+
9
+ ### 情况 A:还有一个可用的管理员
10
+
11
+ 让 TA 在 **设置 → 用户管理 → 用户** 里对你「重置密码」——生成随机临时密码(仅展示一次,立即保存),你用临时密码登录后尽快改成自己的密码。忘记的只是用户名的话,问 TA 在用户列表里看一眼即可(用户名是明文存的)。
12
+
13
+ ### 情况 B:只有一个管理员(就是你),账号还能登录但忘了密码
14
+
15
+ 无法找回(密码是 scrypt 加盐哈希,不可逆),只能"重建账号":
16
+
17
+ ```bash
18
+ # 1. 停 dsh web(见顶部原则)
19
+ kill -9 $(lsof -tiTCP:19080 -sTCP:LISTEN)
20
+ # 2. 编辑 ~/.dsh/user-management/users.json,删掉你这个用户的整个 { ... } 条目
21
+ # (先看清 username 字段确认删的是自己)
22
+ # 3. 启动后用原用户名重新注册
23
+ ```
24
+
25
+ 注意:**重建后角色是普通用户**——继续情况 C 把 role 改回 admin。
26
+
27
+ ### 情况 C:把某个账号(重新)提升为管理员
28
+
29
+ ```bash
30
+ kill -9 $(lsof -tiTCP:19080 -sTCP:LISTEN)
31
+ # 编辑 ~/.dsh/user-management/users.json,找到目标用户,把
32
+ # "role": "user"
33
+ # 改成
34
+ # "role": "admin"
35
+ # 保存,重启完成
36
+ ```
37
+
38
+ ### 情况 D:什么都不记得了,推倒重来(清空所有用户)
39
+
40
+ ```bash
41
+ kill -9 $(lsof -tiTCP:19080 -sTCP:LISTEN)
42
+ rm ~/.dsh/user-management/users.json ~/.dsh/user-management/sessions.json
43
+ # 重启后系统回到零用户状态,第一个注册的账号成为管理员
44
+ ```
45
+
46
+ 副作用:所有用户消失、所有人被登出;封禁列表、审计记录不受影响。
47
+
48
+ ## 把 IP 封错了 / 需要解封
49
+
50
+ - 界面路径:**设置 → 用户管理 → IP 封禁** → 该行「解封」。
51
+ - 完全进不来时的兜底:停 dsh web → 编辑(或直接删除)`~/.dsh/user-management/bans.json` → 重启。
52
+ - 插件自带两道防误封:回环地址(127.x、::1)服务端直接拒绝;封禁"你当前请求所用的 IP"会被拒绝。所以正常操作不会把自己锁死。
53
+
54
+ ## 浏览器一直弹证书警告
55
+
56
+ 自签证书的警告**只能靠导入信任链消除**,SAN 再全也一样弹。路径:**设置 → 用户管理 → HTTPS 证书** → 下载 PEM(Windows 用 DER)→ 核对指纹 → 复制对应系统的导入命令执行。导入一次永久有效。临时用可以 `curl -k` 跳过校验。公网 IP 部署想要零警告,可用 Let's Encrypt 给 `<ip>.sslip.io` 签真证书(配置 `sites[].cert/key`);Tailscale 用户优先 `tailscale cert` + ts.net 域名。
57
+
58
+ ## 为什么直连 dsh web 端口(19080/3080)没有登录墙
59
+
60
+ v0.4 起门禁由本插件**自带的 HTTPS 网关**(默认 `https://<IP>:19843`)承担,不是宿主的 19080。直连 19080 等于绕过网关——**请保证 dsh web 只监听 loopback**(127.0.0.1),对外只暴露 19843。本机 `dsh web` 若被改绑到 0.0.0.0:19080,等于没有认证裸奔。
61
+
62
+ ## 登录后很快又被要求登录
63
+
64
+ - 必须通过网关的 HTTPS 地址访问(cookie 带 `Secure` 标志,浏览器只在 HTTPS 下收发)——用 `http://` 访问根本登录不上
65
+ - 改密码、被重置密码、被降级角色都会踢掉你的**其他**会话(当前浏览器不受影响)
66
+ - 会话 7 天滑动过期;dsh 重启不掉线(会话落盘在 `sessions.json`)
67
+
68
+ ## 重置密码的临时密码没记下来
69
+
70
+ 临时密码只在弹窗里显示一次,丢了就再点一次「重置密码」生成新的——每次重置都会作废该用户全部旧会话,旧临时密码同时失效。
71
+
72
+ ## 其他插件怎么知道当前请求是哪个用户
73
+
74
+ 消费 cordis 服务 `user-management`(`resolveRequest(req)` / `resolveToken(token)`),浏览器端直接 `fetch('/user-management/api/session')`。完整示例见 README 的「给其他插件:解析请求的用户身份」章节。
75
+
76
+ ## 操作日志 / 审计文件太大了
77
+
78
+ 自动滚动:登录记录保留最近 2000 条、操作日志 5000 条,超限自动裁掉最旧的。手动清空:管理员在「操作日志」页点「清空」(或逐条删除),该操作本身也会留痕。紧急瘦身可停 dsh web 后删除 `activity.jsonl` / `audit.jsonl`。
79
+
80
+ ## 网关没起来 / 端口被占用
81
+
82
+ ```bash
83
+ # 谁占着 19843:
84
+ lsof -iTCP:19843
85
+ # 启动日志(用户管理所有 [user-management] 前缀的输出都在这里):
86
+ grep user-management ~/.dsh/logs/web.out.log ~/.dsh/logs/web.err.log
87
+ ```
88
+
89
+ - `EADDRINUSE`:19843 被别的进程(或另一个 dsh 实例)占了——杀掉占用者,或在 `~/.dsh/settings.yaml` 的 `user-management:` 段改 `port`
90
+ - 配置了 `enabled: false` 网关不会启动
91
+ - 改了 hosts/证书配置不生效:删除 `~/.dsh/user-management/certs/` 下的旧证书文件再重启(旧证书按指纹稳定复用,不会自动重签)
92
+
93
+ ## 升级 dsh 后网关行为异常
94
+
95
+ 网关是独立监听器,不依赖宿主内部结构,升级宿主一般无感。若异常:先看 `~/.dsh/logs/web.err.log` 里 `[user-management]` 的报错,再到 [GitHub Issues](https://github.com/weibaohui/user-management/issues) 反馈(附日志)。
Binary file
Binary file
package/docs/demo.gif ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "kczx-user-management",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "dsh 插件 · 登录门 + 账号 / 权限 / 配额 / 审计台账(dsh-passwords 的功能已并入):HTTPS 登录网关与自签证书、用户增删改查、工作区白名单与归属跟踪、每小时 token 与每日时长配额、沙盒档位下限、上传/git 开关、三本审计台账(登录/访问/操作)、IP 封禁、TOTP 两步验证。",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "dsh",
9
+ "deepseek-harness",
10
+ "cordis",
11
+ "plugin",
12
+ "auth",
13
+ "login",
14
+ "gateway",
15
+ "user-management",
16
+ "permissions",
17
+ "quota",
18
+ "audit",
19
+ "dsh-plugin"
20
+ ],
21
+ "main": "src/index.js",
22
+ "exports": {
23
+ ".": "./src/index.js",
24
+ "./client": "./client/bundle.js",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "platform": "web"
33
+ }
34
+ },
35
+ "files": [
36
+ "src",
37
+ "client",
38
+ "scripts",
39
+ "docs",
40
+ "cordis.patch.yml",
41
+ "README.md",
42
+ "MERGED-DEPLOYMENT.md",
43
+ "CUTOVER-RUNBOOK.md"
44
+ ],
45
+ "scripts": {
46
+ "check": "node --check src/index.js && node --check src/gate.js && node --check src/store.js && node --check src/totp.js && node --check src/login-page.js && node --check src/proxy.js && node --check src/certs.js && node --check src/gateway-core.js && node --check src/permissions.js && node --check src/trusted-identity.js && node --check client/index.js",
47
+ "pack:tgz": "npm run build:client && npm pack",
48
+ "test": "node --test test/*.test.mjs",
49
+ "build:client": "node scripts/build-client.mjs",
50
+ "ensure:deps": "node scripts/ensure-deps.mjs",
51
+ "prepublishOnly": "npm run build:client && npm run check && npm test",
52
+ "prepack": "npm run build:client"
53
+ },
54
+ "engines": {
55
+ "node": ">=22.5"
56
+ },
57
+ "peerDependencies": {
58
+ "@deepseek-ai/cordis": "^4.0.1"
59
+ },
60
+ "dependencies": {
61
+ "@deepseek-ai/schemastery": "^3.18.1",
62
+ "bcryptjs": "^3.0.3",
63
+ "selfsigned": "^2.4.1"
64
+ },
65
+ "devDependencies": {
66
+ "@deepseek-ai/cordis": "^4.0.1",
67
+ "@deepseek-ai/schemastery": "^3.18.1"
68
+ },
69
+ "repository": {
70
+ "type": "git",
71
+ "url": "git+https://github.com/weibaohui/user-management.git"
72
+ },
73
+ "homepage": "https://github.com/weibaohui/user-management#readme",
74
+ "bugs": {
75
+ "url": "https://github.com/weibaohui/user-management/issues"
76
+ },
77
+ "publishConfig": {
78
+ "access": "public"
79
+ }
80
+ }
@@ -0,0 +1,42 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Generate client/bundle.js from client/index.js.
5
+ *
6
+ * The upstream package shipped this generated file but not the generator, which
7
+ * made the browser half unbuildable from source. This reproduces the wrapper
8
+ * exactly (verified byte-for-byte against the upstream artifact): the source
9
+ * indented four spaces inside a window.__ModuleLoader__.load({...}) factory,
10
+ * with the module's package name as the loader id.
11
+ *
12
+ * Usage: node scripts/build-client.mjs (or npm run build:client)
13
+ */
14
+ import { readFileSync, writeFileSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
19
+ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
20
+ const sourcePath = join(root, 'client', 'index.js')
21
+ const outputPath = join(root, 'client', 'bundle.js')
22
+
23
+ const source = readFileSync(sourcePath, 'utf8').replace(/\r\n/g, '\n').replace(/\n$/, '')
24
+ const body = source.split('\n').map((line) => (line.length === 0 ? line : ' ' + line)).join('\n')
25
+
26
+ const header = [
27
+ '/* Generated from client/index.js by scripts/build-client.mjs — do not edit by hand.',
28
+ ' * Regenerate with: npm run build:client',
29
+ ' */',
30
+ 'window.__ModuleLoader__.load({',
31
+ ` id: ${JSON.stringify(manifest.name)},`,
32
+ ' factory: (require) => {',
33
+ ' var module = { exports: {} }',
34
+ ' var exports = module.exports',
35
+ ' Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" })',
36
+ ' var React = require("react")',
37
+ '',
38
+ ].join('\n')
39
+
40
+ const footer = '\n\n return module.exports\n }\n})\n'
41
+ writeFileSync(outputPath, header + body + footer, 'utf8')
42
+ console.log('client/bundle.js written from client/index.js')
@@ -0,0 +1,50 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Ensure the one dsh-provided library this plugin needs is present.
5
+ *
6
+ * @deepseek-ai/schemastery is part of the harness, not of npm's public graph for
7
+ * this package: npm either skips it or prunes it on the next install (it is also
8
+ * declared as a peer of the harness), which would leave the plugin unable to
9
+ * load at all. Rather than depend on a hand-copied folder surviving, this runs
10
+ * after install and restores it from the dsh installation that will host the
11
+ * plugin — the same source the harness itself resolves it from.
12
+ *
13
+ * Idempotent: it does nothing when the import already resolves.
14
+ */
15
+ import { existsSync, mkdirSync, cpSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { dirname, join } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+
20
+ const here = dirname(fileURLToPath(import.meta.url))
21
+ const root = join(here, '..')
22
+ const target = join(root, 'node_modules', '@deepseek-ai', 'schemastery')
23
+ if (existsSync(join(target, 'package.json'))) {
24
+ console.log('[ensure-deps] @deepseek-ai/schemastery already present')
25
+ process.exit(0)
26
+ }
27
+
28
+ const home = homedir()
29
+ const candidates = [
30
+ // dsh's shared package anchor: every profile resolves from here.
31
+ join(home, '.dsh', 'profiles', 'node_modules', '@deepseek-ai', 'schemastery'),
32
+ // a checkout's own copy (development machines)
33
+ 'E:/code/dsh/deepseek-harness-master_1/node_modules/@deepseek-ai/schemastery',
34
+ join(process.env.APPDATA || '', 'npm', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai', 'schemastery'),
35
+ ]
36
+
37
+ const source = candidates.find((candidate) => candidate && existsSync(join(candidate, 'package.json')))
38
+ if (source === undefined) {
39
+ console.error('[ensure-deps] could not find @deepseek-ai/schemastery.')
40
+ console.error('[ensure-deps] Looked in:')
41
+ for (const candidate of candidates) console.error(' ' + candidate)
42
+ console.error('[ensure-deps] Install or copy it next to this plugin; without it the plugin cannot load.')
43
+ process.exit(1)
44
+ }
45
+
46
+ mkdirSync(dirname(target), { recursive: true })
47
+ // dereference: the source is a pnpm install full of symlinks, and creating
48
+ // symlinks on Windows needs privileges this script must not assume.
49
+ cpSync(source, target, { recursive: true, dereference: true })
50
+ console.log('[ensure-deps] restored @deepseek-ai/schemastery from ' + source)
@@ -0,0 +1,111 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * One-shot account migration: dsh-passwords → this plugin.
5
+ *
6
+ * Reads the source with dsh-passwords' OWN modules. Its username column is
7
+ * AES-256-GCM ciphertext with a separate HMAC index for lookups, so decoding it
8
+ * here by hand would be a second implementation of that format — one that drifts
9
+ * the moment dsh-passwords changes it. Reusing its reader keeps the migration
10
+ * correct by construction.
11
+ *
12
+ * Password hashes are carried over verbatim (bcrypt) and NOT re-hashed: the
13
+ * plaintext is unavailable. The store verifies them as-is and upgrades each
14
+ * account to the native scrypt scheme on its next successful sign-in.
15
+ *
16
+ * Usage:
17
+ * node scripts/import-from-dsh-passwords.mjs --from <dsh-passwords dir> [--dry-run]
18
+ *
19
+ * Run it with dsh stopped if you can: the running plugin caches users in memory,
20
+ * so it will not see imported accounts until it reloads (restart dsh afterwards).
21
+ */
22
+ import { join } from 'node:path'
23
+ import { pathToFileURL } from 'node:url'
24
+ import { createStore, dshHome } from '../src/store.js'
25
+
26
+ const argv = process.argv.slice(2)
27
+ const fromIndex = argv.indexOf('--from')
28
+ const source = fromIndex >= 0 ? argv[fromIndex + 1] : ''
29
+ const dryRun = argv.includes('--dry-run')
30
+ // A username that exists in BOTH systems has two unrelated password hashes.
31
+ // Without this flag the imported one is ignored and the existing account keeps
32
+ // the password it already had here — which is wrong when the point of the move
33
+ // is that people keep signing in with the credentials they use today.
34
+ const replaceCredentials = argv.includes('--overwrite-credentials')
35
+ if (!source) {
36
+ console.error('usage: node scripts/import-from-dsh-passwords.mjs --from <dsh-passwords dir> [--dry-run]')
37
+ process.exit(2)
38
+ }
39
+
40
+ // dsh-passwords reads its .env through this variable.
41
+ process.env.DSH_PASSWORDS_ENV_FILE = join(source, '.env')
42
+ const load = (name) => import(pathToFileURL(join(source, 'dist', name)).href)
43
+ const [{ loadConfig }, { Database }, { createFieldCrypto }] = await Promise.all([
44
+ load('config.js'),
45
+ load('db.js'),
46
+ load('encrypt.js'),
47
+ ])
48
+
49
+ const config = loadConfig()
50
+ const db = new Database(config.dbPath, createFieldCrypto(config.dbEncKey, config.setupKey))
51
+
52
+ /** SQLite datetime → epoch ms (the source stores 'YYYY-MM-DD HH:MM:SS' in UTC). */
53
+ function toMs(value) {
54
+ if (typeof value !== 'string' || value === '') return undefined
55
+ const iso = value.includes('T') ? value : value.replace(' ', 'T') + 'Z'
56
+ const t = Date.parse(iso)
57
+ return Number.isFinite(t) ? t : undefined
58
+ }
59
+
60
+ const store = createStore({ home: dshHome() })
61
+ await store.load()
62
+
63
+ const rows = db.listUsers()
64
+ let imported = 0
65
+ let updated = 0
66
+ let skipped = 0
67
+
68
+ for (const row of rows) {
69
+ const full = db.getUserById(row.id)
70
+ if (!full || typeof full.password_hash !== 'string' || full.password_hash === '') {
71
+ skipped += 1
72
+ console.log(' skip ' + row.username + ': no password hash')
73
+ continue
74
+ }
75
+ const p = db.getPermissions(row.id)
76
+ const permissions = p ? {
77
+ allowedFolders: p.allowed_folders,
78
+ hourlyTokenLimit: p.hourly_token_limit,
79
+ dailyMinuteLimit: p.daily_minutes_limit,
80
+ allowUpload: p.allow_upload,
81
+ allowGit: p.allow_git_download,
82
+ allowWorkspaceCreate: p.allow_workspace_create,
83
+ banned: p.banned,
84
+ sandboxMode: p.sandbox_mode,
85
+ } : undefined
86
+ const clash = store.findUserByUsername(row.username) !== null
87
+ if (dryRun) {
88
+ const mark = clash ? (replaceCredentials ? ' [exists here → credentials REPLACED]' : ' [exists here → keeps its current password]') : ''
89
+ console.log(' would import ' + row.username + ' (' + row.role + ')' + (p ? ' — folders=' + JSON.stringify(p.allowed_folders) + ' tokens=' + String(p.hourly_token_limit) + ' sandbox=' + String(p.sandbox_mode) : ' — default permissions') + mark)
90
+ continue
91
+ }
92
+ const result = await store.importUser({
93
+ username: row.username,
94
+ role: row.role,
95
+ passHash: full.password_hash,
96
+ algo: 'bcrypt',
97
+ createdAt: toMs(row.created_at),
98
+ lastLoginAt: toMs(row.last_login_at),
99
+ permissions,
100
+ replaceCredentials,
101
+ })
102
+ if (result.created) imported += 1
103
+ else updated += 1
104
+ const note = result.credentialsReplaced === true ? ' — credentials replaced' : clash && !replaceCredentials ? ' — kept its existing password' : ''
105
+ console.log(' ' + (result.created ? 'imported' : 'updated ') + ' ' + row.username + ' (' + row.role + ')' + note)
106
+ }
107
+
108
+ console.log('')
109
+ console.log((dryRun ? '[dry run] ' : '') + 'source users: ' + rows.length + ' imported: ' + imported + ' updated: ' + updated + ' skipped: ' + skipped)
110
+ if (!dryRun) console.log('Now restart dsh so the running plugin reloads the store.')
111
+ db.close()
package/src/certs.js ADDED
@@ -0,0 +1,89 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * TLS material for one gateway site: loads a supplied certificate pair, or
5
+ * generates a self-signed one (persisted under the certs dir so the
6
+ * fingerprint stays stable across restarts — regenerating every boot would
7
+ * break any client that pinned or trusted the previous cert). Self-signed
8
+ * certs are issued for 100 years (days 36500). Persisted certs are NOT
9
+ * regenerated automatically: to pick up a new expiry or a changed SAN set,
10
+ * delete the cert/key files under the certs dir and restart.
11
+ *
12
+ * Ported from dsh-gateway (clarknu/dsh-gateway) lib/certs.js, adapted to
13
+ * CommonJS. SANs cover every host the site declares, as DNS or IP entries
14
+ * (IPv4 + IPv6), so modern browsers (which validate against the SAN list,
15
+ * not the CN) accept the self-signed certificate for each configured name.
16
+ */
17
+
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
19
+ import { join } from 'node:path'
20
+ import selfsigned from 'selfsigned'
21
+
22
+ const IPV4_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/
23
+ // IPv6 literal: hex digits + colons only, and must contain at least one colon
24
+ // (so a hex-only hostname like "cafe" is not misread as an IPv6 address).
25
+ const IPV6_RE = /^[0-9a-fA-F:]+$/
26
+
27
+ /** Load the configured PEM pair, or generate + persist a self-signed one. */
28
+ function loadOrCreateSiteCert({ cert, key, hosts }, certsDir, log) {
29
+ if (cert && key) {
30
+ const crt = readFileSync(cert, 'utf8')
31
+ const prv = readFileSync(key, 'utf8')
32
+ return { cert: crt, key: prv, auto: false }
33
+ }
34
+ if (!certsDir) {
35
+ throw new Error('user-management: certsDir is required to generate self-signed certificates')
36
+ }
37
+ const primary = (hosts || []).find((h) => h && h !== '*') || 'localhost'
38
+ const safe = primary.replace(/[^A-Za-z0-9._-]/g, '_')
39
+ mkdirSync(certsDir, { recursive: true })
40
+ const crtPath = join(certsDir, `${safe}.crt`)
41
+ const keyPath = join(certsDir, `${safe}.key`)
42
+ if (existsSync(crtPath) && existsSync(keyPath)) {
43
+ return {
44
+ cert: readFileSync(crtPath, 'utf8'),
45
+ key: readFileSync(keyPath, 'utf8'),
46
+ auto: true,
47
+ certPath: crtPath,
48
+ keyPath: keyPath,
49
+ }
50
+ }
51
+ const pems = selfsigned.generate(
52
+ [{ name: 'commonName', value: primary }],
53
+ {
54
+ days: 36500,
55
+ keySize: 2048,
56
+ algorithm: 'sha256',
57
+ extensions: [
58
+ {
59
+ name: 'subjectAltName',
60
+ altNames: sanEntries(hosts, primary),
61
+ },
62
+ { name: 'basicConstraints', cA: false },
63
+ { name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
64
+ { name: 'extKeyUsage', serverAuth: true },
65
+ ],
66
+ },
67
+ )
68
+ writeFileSync(crtPath, pems.cert, 'utf8')
69
+ writeFileSync(keyPath, pems.private, { encoding: 'utf8', mode: 0o600 })
70
+ if (typeof log === 'function') {
71
+ log(
72
+ `user-management: generated a self-signed certificate for ${JSON.stringify(hosts || [primary])} ` +
73
+ `(${crtPath}) — point cert/key at a CA-signed pair in settings for public hostnames`,
74
+ )
75
+ }
76
+ return { cert: pems.cert, key: pems.private, auto: true, certPath: crtPath, keyPath: keyPath }
77
+ }
78
+
79
+ /** node-forge SAN entries: type 2 = DNS, type 7 = iPAddress (IPv4 or IPv6 literal). */
80
+ function sanEntries(hosts, primary) {
81
+ const names = [...new Set([...(hosts || []), primary].filter((h) => h && h !== '*'))]
82
+ return names.map((h) => {
83
+ if (IPV4_RE.test(h)) return { type: 7, ip: h }
84
+ if (IPV6_RE.test(h) && h.includes(':')) return { type: 7, ip: h }
85
+ return { type: 2, value: h }
86
+ })
87
+ }
88
+
89
+ export { loadOrCreateSiteCert, sanEntries }
package/src/gate.js ADDED
@@ -0,0 +1,162 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure auth-gate decision core for the user-management gateway.
5
+ *
6
+ * The gateway listener (src/gateway-core.js) calls `createDecider(...)` on
7
+ * every request: it checks IP bans first (403, login page included), resolves
8
+ * the um_session cookie against the store, and returns the gate decision
9
+ * (allow / redirect-to-login / unauthorized / forbidden). The gateway then
10
+ * routes accordingly (local API/login page vs. reverse-proxy) and wires the
11
+ * audit hooks on response completion.
12
+ *
13
+ * Previously this module also installed a gate by re-ordering listeners on the
14
+ * shared dsh node:http server (attachGate / installFallbackGate / installGate).
15
+ * That shared-server gate is gone — the gateway listener IS the gate now, so
16
+ * the auth cannot be bypassed by hitting the loopback dsh web directly. Only
17
+ * the pure, testable decision core remains here.
18
+ */
19
+
20
+ const LOGIN_PAGE_PATH = '/login'
21
+ const API_PREFIX = '/user-management/api'
22
+ /** Paths reachable without a session (login page + auth API itself). */
23
+ const PUBLIC_PATHS = [
24
+ LOGIN_PAGE_PATH,
25
+ `${API_PREFIX}/login`,
26
+ `${API_PREFIX}/register`,
27
+ `${API_PREFIX}/session`,
28
+ `${API_PREFIX}/logout`,
29
+ // Signing-gateway session events carry no browser session by definition: the
30
+ // handler authenticates them with the identity signature instead of a cookie.
31
+ `${API_PREFIX}/internal/session-event`,
32
+ ]
33
+
34
+ function isPublicPath(path) {
35
+ return PUBLIC_PATHS.some((entry) => path === entry || path.startsWith(`${entry}/`) || path.startsWith(`${entry}?`))
36
+ }
37
+
38
+ /** Document request = a browser navigation (GET/HEAD asking for text/html). */
39
+ function isDocumentRequest(method, acceptHeader) {
40
+ if (method !== 'GET' && method !== 'HEAD') return false
41
+ const accept = String(acceptHeader || '')
42
+ return accept.includes('text/html')
43
+ }
44
+
45
+ const STATIC_SUFFIX_RE = /\.(?:js|mjs|css|map|woff2?|ttf|otf|png|jpe?g|gif|svg|ico|webp|avif|mp4|webmanifest|txt)$/i
46
+
47
+ /** Static asset (bundle files, fonts, images) — never audit-logged. */
48
+ function isStaticAsset(path) {
49
+ return STATIC_SUFFIX_RE.test(String(path || ''))
50
+ }
51
+
52
+ /** API-ish request worth auditing: neither a page navigation nor an asset. */
53
+ function isAuditableRequest(method, acceptHeader, path) {
54
+ return !isDocumentRequest(method, acceptHeader) && !isStaticAsset(path)
55
+ }
56
+
57
+ function parseCookies(header) {
58
+ const out = {}
59
+ const raw = String(header || '')
60
+ if (raw === '') return out
61
+ for (const pair of raw.split(';')) {
62
+ const eq = pair.indexOf('=')
63
+ if (eq === -1) continue
64
+ const key = pair.slice(0, eq).trim()
65
+ if (key === '') continue
66
+ out[key] = decodeURIComponent(pair.slice(eq + 1).trim())
67
+ }
68
+ return out
69
+ }
70
+
71
+ /**
72
+ * Pure gate decision.
73
+ * @param {object} input
74
+ * @param {string} input.method
75
+ * @param {string} input.path pathname only (no query)
76
+ * @param {string} input.accept raw Accept header
77
+ * @param {boolean} input.sessionValid whether the session cookie resolves
78
+ * @returns {{action:'allow'} | {action:'redirect', location:string} | {action:'unauthorized'}}
79
+ */
80
+ function gateDecision({ method, path, accept, sessionValid }) {
81
+ if (sessionValid) return { action: 'allow' }
82
+ if (isPublicPath(path)) return { action: 'allow' }
83
+ if (isDocumentRequest(method, accept)) return { action: 'redirect', location: LOGIN_PAGE_PATH }
84
+ return { action: 'unauthorized' }
85
+ }
86
+
87
+ /**
88
+ * Decide from a live request. `resolveSession(req)` → truthy when the
89
+ * request carries a valid session. `isBanned(ip)` runs BEFORE any session
90
+ * work: a banned source IP is denied everything (403), login page included.
91
+ * Two audit hooks:
92
+ * - `onAccess(req, session, path)` fires for document navigations that pass
93
+ * the gate (the "access ledger");
94
+ * - allow decisions carry `{ session, path }` so the gateway can record the
95
+ * operation audit (API/WebSocket) once the response completes.
96
+ *
97
+ * `resolveTrusted(req)` is consulted FIRST when present: in the merged
98
+ * deployment the co-located dsh-passwords gateway is the only login door and
99
+ * signs the authenticated user onto every request (see trusted-identity.js),
100
+ * so this layer lets those requests through without a second login of its own
101
+ * — while still running the ban check and feeding both ledgers.
102
+ */
103
+ function createDecider({ resolveSession, resolveTrusted, onAccess, getClientIp, isBanned }) {
104
+ return async function decide(req) {
105
+ let url
106
+ try {
107
+ url = new URL(req.url || '/', 'http://dsh.local')
108
+ } catch {
109
+ return { action: 'unauthorized' }
110
+ }
111
+ const method = (req.method || 'GET').toUpperCase()
112
+ const ip = getClientIp ? getClientIp(req) : ''
113
+ if (isBanned && ip && isBanned(ip)) return { action: 'forbidden', path: url.pathname }
114
+ const cookies = parseCookies(req.headers && req.headers.cookie)
115
+ // Signature first: a trusted request carries no um_session cookie and must
116
+ // never be bounced to the local login page.
117
+ const trusted = typeof resolveTrusted === 'function' ? resolveTrusted(req) : null
118
+ const resolved = trusted || await resolveSession(cookies[SESSION_COOKIE])
119
+ const decision = gateDecision({
120
+ method,
121
+ path: url.pathname,
122
+ accept: req.headers && req.headers.accept,
123
+ sessionValid: !!resolved,
124
+ })
125
+ if (decision.action === 'allow') {
126
+ decision.session = resolved
127
+ decision.path = url.pathname
128
+ if (onAccess && isDocumentRequest(method, req.headers && req.headers.accept) && !isPublicPath(url.pathname)) {
129
+ try { onAccess(req, resolved, url.pathname) } catch { /* ledger must never break the gate */ }
130
+ }
131
+ }
132
+ return decision
133
+ }
134
+ }
135
+
136
+ const SESSION_COOKIE = 'um_session'
137
+
138
+ function sendUnauthorized(res) {
139
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
140
+ res.end(JSON.stringify({ error: 'unauthorized' }))
141
+ }
142
+
143
+ function sendForbidden(res, message) {
144
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
145
+ res.end(JSON.stringify({ error: message || 'forbidden' }))
146
+ }
147
+
148
+ export {
149
+ SESSION_COOKIE,
150
+ LOGIN_PAGE_PATH,
151
+ API_PREFIX,
152
+ PUBLIC_PATHS,
153
+ gateDecision,
154
+ createDecider,
155
+ parseCookies,
156
+ isDocumentRequest,
157
+ isStaticAsset,
158
+ isAuditableRequest,
159
+ isPublicPath,
160
+ sendUnauthorized,
161
+ sendForbidden,
162
+ }