dsh-plugin-auth 0.1.2
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/README.md +205 -0
- package/bin/dsh-auth.js +179 -0
- package/cordis.patch.yml +26 -0
- package/package.json +32 -0
- package/src/gate.js +127 -0
- package/src/index.js +267 -0
- package/src/lockout.js +79 -0
- package/src/login-page.js +180 -0
- package/src/passwords.js +112 -0
- package/src/paths.js +41 -0
- package/src/policy.js +103 -0
- package/src/sessions.js +130 -0
- package/src/users.js +129 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* dsh-plugin-auth — the authentication gate for the dsh Web UI.
|
|
4
|
+
*
|
|
5
|
+
* Strategy: subclass-and-reprovide. This module's default export subclasses the
|
|
6
|
+
* stock WebServer and becomes the SOLE provider of the `webServer` service (the
|
|
7
|
+
* bundle patch disables the stock row and inserts this one). Every route a
|
|
8
|
+
* consumer registers — the SPA fallback, /api, /plugins, WebSocket upgrades, and
|
|
9
|
+
* anything added later — is wrapped in an auth gate, so there is one choke point.
|
|
10
|
+
*
|
|
11
|
+
* The plugin's own /__auth/* surface is registered via `super.register`, which
|
|
12
|
+
* bypasses the override and stays UNGATED, so the login page is reachable while
|
|
13
|
+
* logged out. This is the only module that imports the webserver peer.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
import WebServer from '@deepseek-ai/dsh-host-webserver'
|
|
18
|
+
import { resolvePolicy } from './policy.js'
|
|
19
|
+
import { createSessionStore } from './sessions.js'
|
|
20
|
+
import { createLockout } from './lockout.js'
|
|
21
|
+
import { createUserStore } from './users.js'
|
|
22
|
+
import { verifyPassword, DUMMY_RECORD } from './passwords.js'
|
|
23
|
+
import {
|
|
24
|
+
evaluate,
|
|
25
|
+
deny,
|
|
26
|
+
isSameOrigin,
|
|
27
|
+
readSessionToken,
|
|
28
|
+
serializeSessionCookie,
|
|
29
|
+
serializeClearCookie,
|
|
30
|
+
} from './gate.js'
|
|
31
|
+
import { renderLoginPage, sanitizeNext } from './login-page.js'
|
|
32
|
+
import { usersFile, configFile } from './paths.js'
|
|
33
|
+
|
|
34
|
+
const AUTH_PREFIX = '/__auth'
|
|
35
|
+
const MAX_FORM_BYTES = 64 * 1024
|
|
36
|
+
|
|
37
|
+
// NOTE: helper methods below use an underscore-prefixed PLAIN convention rather
|
|
38
|
+
// than #private fields. Cordis hands consumers this service through a tracking
|
|
39
|
+
// Proxy, and a JS #private brand check throws when `this` is that Proxy; plain
|
|
40
|
+
// members resolve through it (this is why the parent WebServer avoids #private).
|
|
41
|
+
|
|
42
|
+
/** Best-effort client IP from the raw socket. */
|
|
43
|
+
function clientIp(req) {
|
|
44
|
+
return req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read and parse a small login form body (urlencoded or JSON). */
|
|
48
|
+
async function readForm(req) {
|
|
49
|
+
const chunks = []
|
|
50
|
+
let size = 0
|
|
51
|
+
for await (const chunk of req) {
|
|
52
|
+
size += chunk.length
|
|
53
|
+
if (size > MAX_FORM_BYTES) throw new Error('payload too large')
|
|
54
|
+
chunks.push(chunk)
|
|
55
|
+
}
|
|
56
|
+
const body = Buffer.concat(chunks).toString('utf8')
|
|
57
|
+
const ct = String(req.headers['content-type'] || '')
|
|
58
|
+
if (ct.includes('application/json')) {
|
|
59
|
+
const obj = JSON.parse(body || '{}')
|
|
60
|
+
return new Map(Object.entries(obj).map(([k, v]) => [k, v == null ? '' : String(v)]))
|
|
61
|
+
}
|
|
62
|
+
return new URLSearchParams(body)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class AuthWebServer extends WebServer {
|
|
66
|
+
constructor(ctx, config) {
|
|
67
|
+
super(ctx, config)
|
|
68
|
+
|
|
69
|
+
// Load optional policy overrides ($DSH_HOME/auth/config.json). ENOENT is the
|
|
70
|
+
// normal case; a malformed file is logged and ignored (defaults hold).
|
|
71
|
+
let overrides = {}
|
|
72
|
+
try {
|
|
73
|
+
overrides = JSON.parse(readFileSync(configFile(), 'utf8'))
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err && /** @type {any} */ (err).code !== 'ENOENT') {
|
|
76
|
+
this.ctx.logger?.warn?.(`auth: ignoring invalid config.json (${/** @type {any} */ (err).message})`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.authPolicy = resolvePolicy(overrides)
|
|
81
|
+
this.sessions = createSessionStore({ policy: this.authPolicy })
|
|
82
|
+
this.lockout = createLockout({ policy: this.authPolicy })
|
|
83
|
+
this.users = createUserStore({ path: usersFile() })
|
|
84
|
+
|
|
85
|
+
// Register the ungated auth surface directly on the parent. super.register
|
|
86
|
+
// bypasses the gate the register() override below installs.
|
|
87
|
+
super.register({ kind: 'prefix', path: AUTH_PREFIX, handler: (req, res) => this._handleAuth(req, res) })
|
|
88
|
+
|
|
89
|
+
// Background sweep of expired sessions; unref so it never holds the process.
|
|
90
|
+
this.ctx.effect(() => {
|
|
91
|
+
const timer = setInterval(() => {
|
|
92
|
+
try {
|
|
93
|
+
this.sessions.sweep()
|
|
94
|
+
} catch {
|
|
95
|
+
/* sweep must never throw into the timer */
|
|
96
|
+
}
|
|
97
|
+
}, this.authPolicy.sweepIntervalMs)
|
|
98
|
+
timer.unref?.()
|
|
99
|
+
return () => clearInterval(timer)
|
|
100
|
+
}, 'authWebServer.sessionSweep')
|
|
101
|
+
|
|
102
|
+
this.ctx.logger?.info?.('auth: web authentication gate active (login required)')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── gate wrappers: every consumer registration is guarded ──────────────────
|
|
106
|
+
|
|
107
|
+
register(route) {
|
|
108
|
+
return super.register({ ...route, handler: this._guard(route.handler) })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
registerFallback(handler) {
|
|
112
|
+
return super.registerFallback(this._guard(handler))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
registerUpgrade(route) {
|
|
116
|
+
const inner = route.handler
|
|
117
|
+
return super.registerUpgrade({
|
|
118
|
+
...route,
|
|
119
|
+
handler: (req, socket, head) => {
|
|
120
|
+
const decision = evaluate(req, this.sessions, this.authPolicy)
|
|
121
|
+
if (decision.authenticated) return inner(req, socket, head)
|
|
122
|
+
this.ctx.logger?.info?.(`auth: rejected unauthenticated upgrade ${req.url}`)
|
|
123
|
+
socket.destroy()
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Wrap an HTTP handler so it runs only for authenticated requests. */
|
|
129
|
+
_guard(handler) {
|
|
130
|
+
return (req, res) => {
|
|
131
|
+
const decision = evaluate(req, this.sessions, this.authPolicy)
|
|
132
|
+
if (decision.authenticated) return handler(req, res)
|
|
133
|
+
return deny(req, res, this.authPolicy)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ── the ungated /__auth/* surface ──────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/** Route the /__auth/* requests. Unknown sub-paths → 404 JSON. */
|
|
139
|
+
async _handleAuth(req, res) {
|
|
140
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
141
|
+
const path = url.pathname
|
|
142
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
143
|
+
if (path === `${AUTH_PREFIX}/login` && method === 'GET') return this._loginPage(req, res, url)
|
|
144
|
+
if (path === `${AUTH_PREFIX}/login` && method === 'POST') return this._login(req, res)
|
|
145
|
+
if (path === `${AUTH_PREFIX}/logout` && method === 'POST') return this._logout(req, res)
|
|
146
|
+
if (path === `${AUTH_PREFIX}/status` && method === 'GET') return this._status(req, res)
|
|
147
|
+
return this._json(res, 404, { error: 'not_found' })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** GET /__auth/login — serve the page, or bounce to '/' if already signed in. */
|
|
151
|
+
_loginPage(req, res, url) {
|
|
152
|
+
if (evaluate(req, this.sessions, this.authPolicy).authenticated) {
|
|
153
|
+
res.writeHead(302, { Location: '/', 'Cache-Control': 'no-store' })
|
|
154
|
+
res.end()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
const next = sanitizeNext(url.searchParams.get('next') || '/')
|
|
158
|
+
res.writeHead(200, {
|
|
159
|
+
'content-type': 'text/html; charset=utf-8',
|
|
160
|
+
'Cache-Control': 'no-store',
|
|
161
|
+
'X-Content-Type-Options': 'nosniff',
|
|
162
|
+
'Referrer-Policy': 'same-origin',
|
|
163
|
+
})
|
|
164
|
+
res.end(renderLoginPage({ next }))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** POST /__auth/login — validate credentials, set the session cookie. */
|
|
168
|
+
async _login(req, res) {
|
|
169
|
+
if (!isSameOrigin(req, this.authPolicy)) {
|
|
170
|
+
this._audit('login-denied-origin', req)
|
|
171
|
+
return this._json(res, 403, { error: 'bad_origin' })
|
|
172
|
+
}
|
|
173
|
+
const ip = clientIp(req)
|
|
174
|
+
let form
|
|
175
|
+
try {
|
|
176
|
+
form = await readForm(req)
|
|
177
|
+
} catch {
|
|
178
|
+
return this._json(res, 400, { error: 'bad_request' })
|
|
179
|
+
}
|
|
180
|
+
const username = String(form.get('username') ?? '').trim()
|
|
181
|
+
const password = String(form.get('password') ?? '')
|
|
182
|
+
const next = sanitizeNext(String(form.get('next') ?? '/'))
|
|
183
|
+
const wantsJson = String(req.headers['accept'] || '').includes('application/json')
|
|
184
|
+
|
|
185
|
+
const lock = this.lockout.check(username, ip)
|
|
186
|
+
if (lock.locked) {
|
|
187
|
+
this._audit('login-locked', req, username)
|
|
188
|
+
return this._loginFail(
|
|
189
|
+
res,
|
|
190
|
+
wantsJson,
|
|
191
|
+
next,
|
|
192
|
+
429,
|
|
193
|
+
'Too many attempts. Try again later.',
|
|
194
|
+
'尝试次数过多,请稍后再试。',
|
|
195
|
+
Math.ceil(lock.retryAfterMs / 1000),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const user = await this.users.getUser(username).catch(() => undefined)
|
|
200
|
+
const record = user && !user.disabled ? user.password : undefined
|
|
201
|
+
// Always run a scrypt verification (dummy record when unknown/disabled) so
|
|
202
|
+
// timing stays flat and there is no user-enumeration oracle.
|
|
203
|
+
const ok = await verifyPassword(password, record ?? DUMMY_RECORD)
|
|
204
|
+
if (!record || !ok) {
|
|
205
|
+
this.lockout.recordFailure(username, ip)
|
|
206
|
+
this._audit('login-failure', req, username)
|
|
207
|
+
return this._loginFail(
|
|
208
|
+
res,
|
|
209
|
+
wantsJson,
|
|
210
|
+
next,
|
|
211
|
+
401,
|
|
212
|
+
'Invalid username or password.',
|
|
213
|
+
'用户名或密码错误。',
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
this.lockout.recordSuccess(username, ip)
|
|
218
|
+
const token = this.sessions.rotate(readSessionToken(req, this.authPolicy), username, { ip })
|
|
219
|
+
this._audit('login-success', req, username)
|
|
220
|
+
res.setHeader('Set-Cookie', serializeSessionCookie(token, this.authPolicy))
|
|
221
|
+
if (wantsJson) return this._json(res, 200, { ok: true, next })
|
|
222
|
+
res.writeHead(303, { Location: next, 'Cache-Control': 'no-store' })
|
|
223
|
+
res.end()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Render (or return as JSON) a login failure with the right status. */
|
|
227
|
+
_loginFail(res, wantsJson, next, status, apiError, pageError, retryAfterSec) {
|
|
228
|
+
if (retryAfterSec) res.setHeader('Retry-After', String(retryAfterSec))
|
|
229
|
+
if (wantsJson) return this._json(res, status, { error: apiError })
|
|
230
|
+
res.writeHead(status, {
|
|
231
|
+
'content-type': 'text/html; charset=utf-8',
|
|
232
|
+
'Cache-Control': 'no-store',
|
|
233
|
+
'X-Content-Type-Options': 'nosniff',
|
|
234
|
+
})
|
|
235
|
+
res.end(renderLoginPage({ next, error: pageError }))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** POST /__auth/logout — revoke the session and clear the cookie. */
|
|
239
|
+
async _logout(req, res) {
|
|
240
|
+
if (!isSameOrigin(req, this.authPolicy)) return this._json(res, 403, { error: 'bad_origin' })
|
|
241
|
+
const token = readSessionToken(req, this.authPolicy)
|
|
242
|
+
if (token) this.sessions.revoke(token)
|
|
243
|
+
this._audit('logout', req)
|
|
244
|
+
res.setHeader('Set-Cookie', serializeClearCookie(this.authPolicy))
|
|
245
|
+
const wantsJson = String(req.headers['accept'] || '').includes('application/json')
|
|
246
|
+
if (wantsJson) return this._json(res, 200, { ok: true })
|
|
247
|
+
res.writeHead(303, { Location: `${AUTH_PREFIX}/login`, 'Cache-Control': 'no-store' })
|
|
248
|
+
res.end()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** GET /__auth/status — report the current auth state as JSON. */
|
|
252
|
+
_status(req, res) {
|
|
253
|
+
const decision = evaluate(req, this.sessions, this.authPolicy)
|
|
254
|
+
this._json(res, 200, { authenticated: decision.authenticated, user: decision.session?.username ?? null })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_json(res, status, obj) {
|
|
258
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
259
|
+
res.end(JSON.stringify(obj))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
_audit(event, req, username) {
|
|
263
|
+
this.ctx.logger?.info?.(`auth: ${event} user=${username ?? '-'} ip=${clientIp(req)}`)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export default AuthWebServer
|
package/src/lockout.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Per-(username, IP) failed-login lockout with exponential backoff. In-memory,
|
|
4
|
+
* injectable clock. Pure module. Keying on username+IP throttles both targeted
|
|
5
|
+
* password guessing against one account and spraying from one source, while a
|
|
6
|
+
* legitimate user on a different IP is unaffected by an attacker's failures.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} opts
|
|
11
|
+
* @param {import('./policy.js').AuthPolicy} opts.policy
|
|
12
|
+
* @param {() => number} [opts.now]
|
|
13
|
+
*/
|
|
14
|
+
export function createLockout({ policy, now = Date.now } = /** @type {any} */ ({})) {
|
|
15
|
+
if (!policy) throw new Error('createLockout: policy required')
|
|
16
|
+
/** @type {Map<string, {fails: number, lockedUntil: number, last: number}>} */
|
|
17
|
+
const state = new Map()
|
|
18
|
+
|
|
19
|
+
/** @param {string} username @param {string} ip */
|
|
20
|
+
function keyOf(username, ip) {
|
|
21
|
+
return `${username ?? ''}\n${ip ?? ''}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Exponential backoff once the threshold is crossed, capped at lockoutMaxMs. */
|
|
25
|
+
function backoff(fails) {
|
|
26
|
+
const over = Math.max(0, fails - policy.lockoutThreshold)
|
|
27
|
+
const ms = policy.lockoutBaseMs * 2 ** over
|
|
28
|
+
return Math.min(ms, policy.lockoutMaxMs)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} username @param {string} ip
|
|
33
|
+
* @returns {{locked: boolean, retryAfterMs: number}}
|
|
34
|
+
*/
|
|
35
|
+
function check(username, ip) {
|
|
36
|
+
const k = keyOf(username, ip)
|
|
37
|
+
const e = state.get(k)
|
|
38
|
+
if (!e) return { locked: false, retryAfterMs: 0 }
|
|
39
|
+
const t = now()
|
|
40
|
+
// A quiet window past any active lock resets the record entirely.
|
|
41
|
+
if (e.lockedUntil <= t && t - e.last > policy.lockoutWindowMs) {
|
|
42
|
+
state.delete(k)
|
|
43
|
+
return { locked: false, retryAfterMs: 0 }
|
|
44
|
+
}
|
|
45
|
+
if (e.lockedUntil > t) return { locked: true, retryAfterMs: e.lockedUntil - t }
|
|
46
|
+
return { locked: false, retryAfterMs: 0 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Record one failed attempt; returns the updated counter and any lock.
|
|
51
|
+
* @param {string} username @param {string} ip
|
|
52
|
+
*/
|
|
53
|
+
function recordFailure(username, ip) {
|
|
54
|
+
const k = keyOf(username, ip)
|
|
55
|
+
const t = now()
|
|
56
|
+
const e = state.get(k) ?? { fails: 0, lockedUntil: 0, last: t }
|
|
57
|
+
// A fresh window (no active lock, long idle) starts the count over.
|
|
58
|
+
if (t - e.last > policy.lockoutWindowMs && e.lockedUntil <= t) e.fails = 0
|
|
59
|
+
e.fails += 1
|
|
60
|
+
e.last = t
|
|
61
|
+
if (e.fails >= policy.lockoutThreshold) e.lockedUntil = t + backoff(e.fails)
|
|
62
|
+
state.set(k, e)
|
|
63
|
+
return { fails: e.fails, lockedUntil: e.lockedUntil, retryAfterMs: Math.max(0, e.lockedUntil - t) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Clear the record on a successful login. */
|
|
67
|
+
function recordSuccess(username, ip) {
|
|
68
|
+
state.delete(keyOf(username, ip))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
check,
|
|
73
|
+
recordFailure,
|
|
74
|
+
recordSuccess,
|
|
75
|
+
get size() {
|
|
76
|
+
return state.size
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Self-contained login page: inline CSS/JS and a data-URI favicon, so it is
|
|
4
|
+
* fully reachable before authentication and never depends on a gated asset.
|
|
5
|
+
* Pure module (no peer imports). The form POSTs same-origin to /__auth/login.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** True if the string contains an ASCII control character (U+0000–U+001F, U+007F). */
|
|
9
|
+
function hasControlChar(s) {
|
|
10
|
+
for (let i = 0; i < s.length; i++) {
|
|
11
|
+
const c = s.charCodeAt(i)
|
|
12
|
+
if (c < 0x20 || c === 0x7f) return true
|
|
13
|
+
}
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reduce a caller-supplied `next` target to a safe local path, defeating open
|
|
19
|
+
* redirects. Only single-slash absolute paths pass; protocol-relative (`//`),
|
|
20
|
+
* backslash, control-char, and auth-namespace targets collapse to '/'.
|
|
21
|
+
* @param {unknown} next
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function sanitizeNext(next) {
|
|
25
|
+
if (typeof next !== 'string' || next.length === 0) return '/'
|
|
26
|
+
if (!next.startsWith('/')) return '/'
|
|
27
|
+
if (next.startsWith('//') || next.startsWith('/\\')) return '/'
|
|
28
|
+
if (hasControlChar(next)) return '/'
|
|
29
|
+
if (next === '/__auth' || next.startsWith('/__auth/')) return '/'
|
|
30
|
+
return next
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** HTML-escape for safe interpolation into text/attribute contexts. */
|
|
34
|
+
function esc(value) {
|
|
35
|
+
return String(value).replace(/[&<>"']/g, (c) => {
|
|
36
|
+
switch (c) {
|
|
37
|
+
case '&': return '&'
|
|
38
|
+
case '<': return '<'
|
|
39
|
+
case '>': return '>'
|
|
40
|
+
case '"': return '"'
|
|
41
|
+
default: return '''
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A tiny lock glyph as a data-URI favicon keeps the page free of gated assets.
|
|
47
|
+
const FAVICON =
|
|
48
|
+
'data:image/svg+xml,' +
|
|
49
|
+
encodeURIComponent(
|
|
50
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">' +
|
|
51
|
+
'<path fill="#444" d="M4 7V5a4 4 0 1 1 8 0v2h1v8H3V7h1zm2 0h4V5a2 2 0 1 0-4 0v2z"/></svg>',
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Render the full login document.
|
|
56
|
+
* @param {{next?: string, error?: string}} [opts]
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function renderLoginPage({ next = '/', error = '' } = {}) {
|
|
60
|
+
const safeNext = esc(sanitizeNext(next))
|
|
61
|
+
const errorHtml = error
|
|
62
|
+
? `<p class="error" role="alert" aria-live="assertive">${esc(error)}</p>`
|
|
63
|
+
: '<p class="error" role="alert" aria-live="assertive" hidden></p>'
|
|
64
|
+
return PAGE.replaceAll('%%FAVICON%%', FAVICON).replace('%%NEXT%%', safeNext).replace('%%ERROR%%', errorHtml)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// PAGE template. Placeholders (%%FAVICON%%, %%NEXT%%, %%ERROR%%) are filled by
|
|
68
|
+
// renderLoginPage; %%NEXT%% and the error text are HTML-escaped before insertion.
|
|
69
|
+
const PAGE = `<!DOCTYPE html>
|
|
70
|
+
<html lang="zh-CN">
|
|
71
|
+
<head>
|
|
72
|
+
<meta charset="utf-8">
|
|
73
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
74
|
+
<meta name="robots" content="noindex, nofollow">
|
|
75
|
+
<meta name="referrer" content="same-origin">
|
|
76
|
+
<meta name="color-scheme" content="light dark">
|
|
77
|
+
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff">
|
|
78
|
+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#151517">
|
|
79
|
+
<title>登录 · dsh</title>
|
|
80
|
+
<link rel="icon" href="%%FAVICON%%">
|
|
81
|
+
<style>
|
|
82
|
+
:root {
|
|
83
|
+
color-scheme: light dark;
|
|
84
|
+
--dsh-bg-base: rgb(255, 255, 255);
|
|
85
|
+
--dsh-bg-layer-2: rgb(255, 255, 255);
|
|
86
|
+
--dsh-input-bg: rgb(249, 250, 251);
|
|
87
|
+
--dsh-label-primary: rgb(15, 17, 21);
|
|
88
|
+
--dsh-label-secondary: rgb(97, 102, 107);
|
|
89
|
+
--dsh-label-tertiary: rgb(129, 133, 140);
|
|
90
|
+
--dsh-border-l2: rgba(0, 0, 0, .1);
|
|
91
|
+
--dsh-border-inverted: rgba(0, 0, 0, 0);
|
|
92
|
+
--dsh-button-fill: rgb(15, 17, 21);
|
|
93
|
+
--dsh-button-hover: rgb(67, 69, 74);
|
|
94
|
+
--dsh-button-label: rgb(255, 255, 255);
|
|
95
|
+
--dsh-error-label: rgb(236, 19, 19);
|
|
96
|
+
--dsh-error-fill: rgb(254, 242, 242);
|
|
97
|
+
--dsh-error-border: rgba(236, 19, 19, .18);
|
|
98
|
+
--dsh-shadow-lv3: 0 0 1px 0 rgba(0, 0, 0, .2),
|
|
99
|
+
0 0 4px 0 rgba(0, 0, 0, .02), 0 12px 32px 0 rgba(0, 0, 0, .08);
|
|
100
|
+
}
|
|
101
|
+
@media (prefers-color-scheme: dark) {
|
|
102
|
+
:root {
|
|
103
|
+
--dsh-bg-base: rgb(21, 21, 23);
|
|
104
|
+
--dsh-bg-layer-2: rgb(44, 44, 46);
|
|
105
|
+
--dsh-input-bg: rgb(27, 27, 28);
|
|
106
|
+
--dsh-label-primary: rgb(249, 250, 251);
|
|
107
|
+
--dsh-label-secondary: rgb(207, 211, 214);
|
|
108
|
+
--dsh-label-tertiary: rgb(173, 178, 184);
|
|
109
|
+
--dsh-border-l2: rgba(255, 255, 255, .12);
|
|
110
|
+
--dsh-border-inverted: rgba(255, 255, 255, .06);
|
|
111
|
+
--dsh-button-fill: rgb(249, 250, 251);
|
|
112
|
+
--dsh-button-hover: rgb(235, 238, 242);
|
|
113
|
+
--dsh-button-label: rgb(15, 17, 21);
|
|
114
|
+
--dsh-error-label: rgb(242, 90, 90);
|
|
115
|
+
--dsh-error-fill: rgba(242, 90, 90, .12);
|
|
116
|
+
--dsh-error-border: rgba(242, 90, 90, .22);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
* { box-sizing: border-box; }
|
|
120
|
+
html, body { min-height: 100%; margin: 0; }
|
|
121
|
+
body { min-height: 100vh; min-height: 100dvh; display: grid; place-items: center; padding: 24px;
|
|
122
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
|
123
|
+
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
124
|
+
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
125
|
+
background: var(--dsh-bg-base); color: var(--dsh-label-primary); }
|
|
126
|
+
.card { width: min(380px, 100%); padding: 28px 24px 24px;
|
|
127
|
+
background: var(--dsh-bg-layer-2); border: 1px solid var(--dsh-border-inverted);
|
|
128
|
+
border-radius: 24px; box-shadow: var(--dsh-shadow-lv3); }
|
|
129
|
+
h1 { margin: 0; font-size: 20px; line-height: 28px; font-weight: 500; letter-spacing: 0; }
|
|
130
|
+
.sub { margin: 4px 0 20px; color: var(--dsh-label-secondary); font-size: 14px; line-height: 22px; }
|
|
131
|
+
form { display: flex; flex-direction: column; }
|
|
132
|
+
label { display: block; margin: 0 0 6px; font-size: 14px; line-height: 22px;
|
|
133
|
+
color: var(--dsh-label-primary); }
|
|
134
|
+
label[for=password] { margin-top: 16px; }
|
|
135
|
+
input[type=text], input[type=password] { width: 100%; height: 32px; padding: 0 8px;
|
|
136
|
+
font-family: inherit; font-size: 14px; line-height: 22px; color: var(--dsh-label-primary);
|
|
137
|
+
background: var(--dsh-input-bg); border: 1px solid var(--dsh-border-l2); border-radius: 8px;
|
|
138
|
+
transition: border-color .2s ease; }
|
|
139
|
+
input:focus { outline: none; border-color: var(--dsh-label-primary); }
|
|
140
|
+
button { display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 36px;
|
|
141
|
+
margin-top: 24px; padding: 0 14px; border: 0; border-radius: 18px; cursor: pointer;
|
|
142
|
+
font-family: inherit; font-size: 14px; line-height: 22px; font-weight: 400;
|
|
143
|
+
color: var(--dsh-button-label); background: var(--dsh-button-fill);
|
|
144
|
+
transition: background .2s ease; }
|
|
145
|
+
button:hover { background: var(--dsh-button-hover); }
|
|
146
|
+
button:focus-visible { outline: 2px solid var(--dsh-label-primary); outline-offset: 2px; }
|
|
147
|
+
.error { margin: 0 0 20px; padding: 9px 11px; border-radius: 8px; font-size: 14px;
|
|
148
|
+
line-height: 22px; color: var(--dsh-error-label); background: var(--dsh-error-fill);
|
|
149
|
+
border: 1px solid var(--dsh-error-border); }
|
|
150
|
+
.error[hidden] { display: none; }
|
|
151
|
+
.foot { margin: 16px 0 0; text-align: center; color: var(--dsh-label-tertiary);
|
|
152
|
+
font-size: 12px; line-height: 18px; }
|
|
153
|
+
@media (prefers-reduced-motion: reduce) {
|
|
154
|
+
input[type=text], input[type=password], button { transition: none; }
|
|
155
|
+
}
|
|
156
|
+
@media (max-width: 480px) {
|
|
157
|
+
body { padding: 16px; }
|
|
158
|
+
.card { padding: 24px; }
|
|
159
|
+
}
|
|
160
|
+
</style>
|
|
161
|
+
</head>
|
|
162
|
+
<body>
|
|
163
|
+
<main class="card" role="main" aria-labelledby="title">
|
|
164
|
+
<h1 id="title">dsh</h1>
|
|
165
|
+
<p class="sub">登录以继续</p>
|
|
166
|
+
%%ERROR%%
|
|
167
|
+
<form method="POST" action="/__auth/login" autocomplete="on">
|
|
168
|
+
<input type="hidden" name="next" value="%%NEXT%%">
|
|
169
|
+
<label for="username">用户名</label>
|
|
170
|
+
<input id="username" name="username" type="text" autocomplete="username" required
|
|
171
|
+
autofocus autocapitalize="none" spellcheck="false" enterkeyhint="next">
|
|
172
|
+
<label for="password">密码</label>
|
|
173
|
+
<input id="password" name="password" type="password" autocomplete="current-password"
|
|
174
|
+
required enterkeyhint="go">
|
|
175
|
+
<button type="submit">登录</button>
|
|
176
|
+
</form>
|
|
177
|
+
<p class="foot">仅限授权用户访问。</p>
|
|
178
|
+
</main>
|
|
179
|
+
</body>
|
|
180
|
+
</html>`
|
package/src/passwords.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Password hashing and verification using node:crypto scrypt only. Pure module
|
|
4
|
+
* (no peer imports) so it runs under `node --test` standalone and is reused by
|
|
5
|
+
* both the runtime login path and the offline `dsh-auth` CLI.
|
|
6
|
+
*
|
|
7
|
+
* Anti-enumeration: an unknown user is verified against {@link DUMMY_RECORD} so
|
|
8
|
+
* the failing path still spends a full scrypt derivation — the dominant cost —
|
|
9
|
+
* leaving no timing oracle that distinguishes "no such user" from "wrong password".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} PasswordRecord
|
|
16
|
+
* @property {'scrypt'} algo
|
|
17
|
+
* @property {string} salt base64-encoded random salt.
|
|
18
|
+
* @property {string} hash base64-encoded derived key.
|
|
19
|
+
* @property {number} N
|
|
20
|
+
* @property {number} r
|
|
21
|
+
* @property {number} p
|
|
22
|
+
* @property {number} keylen
|
|
23
|
+
* @property {number} maxmem
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @type {import('./policy.js').ScryptParams} */
|
|
27
|
+
const FALLBACK = { N: 16384, r: 8, p: 1, keylen: 64, maxmem: 64 * 1024 * 1024 }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Buffer} password
|
|
31
|
+
* @param {Buffer} salt
|
|
32
|
+
* @param {number} keylen
|
|
33
|
+
* @param {{N:number,r:number,p:number,maxmem:number}} options
|
|
34
|
+
* @returns {Promise<Buffer>}
|
|
35
|
+
*/
|
|
36
|
+
function scryptAsync(password, salt, keylen, options) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
scryptCb(password, salt, keylen, options, (err, dk) => (err ? reject(err) : resolve(Buffer.from(dk))))
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Hash a plaintext password into a self-describing record (cost params stored
|
|
44
|
+
* per record so they can be raised later without breaking existing users).
|
|
45
|
+
* @param {string} password
|
|
46
|
+
* @param {import('./policy.js').ScryptParams} [params]
|
|
47
|
+
* @returns {Promise<PasswordRecord>}
|
|
48
|
+
*/
|
|
49
|
+
export async function hashPassword(password, params = FALLBACK) {
|
|
50
|
+
const salt = randomBytes(16)
|
|
51
|
+
const { N, r, p, keylen, maxmem } = params
|
|
52
|
+
const dk = await scryptAsync(Buffer.from(String(password), 'utf8'), salt, keylen, { N, r, p, maxmem })
|
|
53
|
+
return { algo: 'scrypt', salt: salt.toString('base64'), hash: dk.toString('base64'), N, r, p, keylen, maxmem }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Verify a plaintext password against a record with a constant-time compare.
|
|
58
|
+
* Callers pass {@link DUMMY_RECORD} for unknown/disabled users to keep timing flat.
|
|
59
|
+
* @param {string} password
|
|
60
|
+
* @param {PasswordRecord | undefined | null} record
|
|
61
|
+
* @returns {Promise<boolean>}
|
|
62
|
+
*/
|
|
63
|
+
export async function verifyPassword(password, record) {
|
|
64
|
+
if (!record || record.algo !== 'scrypt') return false
|
|
65
|
+
let expected
|
|
66
|
+
try {
|
|
67
|
+
expected = Buffer.from(record.hash, 'base64')
|
|
68
|
+
} catch {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
let actual
|
|
72
|
+
try {
|
|
73
|
+
const salt = Buffer.from(record.salt, 'base64')
|
|
74
|
+
actual = await scryptAsync(Buffer.from(String(password), 'utf8'), salt, record.keylen, {
|
|
75
|
+
N: record.N, r: record.r, p: record.p, maxmem: record.maxmem,
|
|
76
|
+
})
|
|
77
|
+
} catch {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
// timingSafeEqual requires equal lengths; the length branch is reached only
|
|
81
|
+
// after the (dominant) derivation has run, so it leaks no useful timing.
|
|
82
|
+
if (actual.length !== expected.length) return false
|
|
83
|
+
return timingSafeEqual(actual, expected)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A fixed record shaped like a real one, used for the unknown-user branch. The
|
|
88
|
+
* password will never match; the point is to pay the scrypt cost regardless.
|
|
89
|
+
* @type {PasswordRecord}
|
|
90
|
+
*/
|
|
91
|
+
export const DUMMY_RECORD = {
|
|
92
|
+
algo: 'scrypt',
|
|
93
|
+
salt: Buffer.alloc(16).toString('base64'),
|
|
94
|
+
hash: Buffer.alloc(FALLBACK.keylen).toString('base64'),
|
|
95
|
+
N: FALLBACK.N, r: FALLBACK.r, p: FALLBACK.p, keylen: FALLBACK.keylen, maxmem: FALLBACK.maxmem,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Basic password-strength policy: length plus character-class diversity.
|
|
100
|
+
* @param {string} password
|
|
101
|
+
* @param {{minLength?: number}} [opts]
|
|
102
|
+
* @returns {{ok: true} | {ok: false, reason: string}}
|
|
103
|
+
*/
|
|
104
|
+
export function assessPasswordStrength(password, { minLength = 12 } = {}) {
|
|
105
|
+
const pw = String(password ?? '')
|
|
106
|
+
if (pw.length < minLength) return { ok: false, reason: `Password must be at least ${minLength} characters.` }
|
|
107
|
+
const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(pw)).length
|
|
108
|
+
if (classes < 3) {
|
|
109
|
+
return { ok: false, reason: 'Password must include at least 3 of: lowercase, uppercase, digit, symbol.' }
|
|
110
|
+
}
|
|
111
|
+
return { ok: true }
|
|
112
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Locates $DSH_HOME/auth/. Self-contained (no peer import): it replicates the
|
|
4
|
+
* small, stable home-resolution contract of @deepseek-ai/dsh-home-paths
|
|
5
|
+
* ($DSH_HOME env → ~/.dsh). Being peer-free means the offline `dsh-auth` CLI
|
|
6
|
+
* works from the plugin directory without the harness on the module path, and
|
|
7
|
+
* the runtime gate never depends on that package resolving through the profile
|
|
8
|
+
* module fallback.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
const DSH_HOME_ENV = 'DSH_HOME'
|
|
15
|
+
const DSH_HOME_DIR_NAME = '.dsh'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the dsh home directory: $DSH_HOME if set (and non-blank), else ~/.dsh.
|
|
19
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function resolveDshHome(env = process.env) {
|
|
23
|
+
const configured = env[DSH_HOME_ENV]
|
|
24
|
+
if (typeof configured === 'string' && configured.trim().length > 0) return configured
|
|
25
|
+
return join(homedir(), DSH_HOME_DIR_NAME)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {NodeJS.ProcessEnv} [env] @returns {string} $DSH_HOME/auth */
|
|
29
|
+
export function authDir(env) {
|
|
30
|
+
return join(resolveDshHome(env), 'auth')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {NodeJS.ProcessEnv} [env] @returns {string} $DSH_HOME/auth/users.json */
|
|
34
|
+
export function usersFile(env) {
|
|
35
|
+
return join(authDir(env), 'users.json')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {NodeJS.ProcessEnv} [env] @returns {string} $DSH_HOME/auth/config.json */
|
|
39
|
+
export function configFile(env) {
|
|
40
|
+
return join(authDir(env), 'config.json')
|
|
41
|
+
}
|