diff --git a/lib/index.js b/lib/index.js index bcfe433..43a2145 100644 --- a/lib/index.js +++ b/lib/index.js @@ -115,6 +115,15 @@ export function tokenForHost(host) { if (isLanOverrideHost(host)) return getLanToken(); return classifyHost(host) === 'public' ? getAccessToken() : getLanToken(); } + +/** + * 登录页凭据语义:长期有效的 PIN 允许密码管理器保存;Quick Tunnel 自动生成的 + * 公网 PIN 每次隧道开启都会轮换,不应作为长期密码保存。 + */ +export function credentialKindForHost(host) { + if (isLanOverrideHost(host) || classifyHost(host) !== 'public') return 'persistent'; + return pinCustom('public') || tunnelMode() === 'named' ? 'persistent' : 'ephemeral'; +} /** 去掉 Host 的端口(与 classifyHost 一致),用于和手动设置的局域网地址比较。 */ function hostNameOnly(host) { let name = String(host ?? '').trim().toLowerCase(); @@ -313,6 +322,7 @@ export function apply(ctx, config = {}, internals = {}) { // dsh web 重启/更新后 sessionKey 变化 → 手机需重新输入 sessionKey: randomBytes(16).toString('hex'), getToken: (host) => tokenForHost(host), + getCredentialKind: (host) => credentialKindForHost(host), isProtected: (host) => { if (isLanOverrideHost(host)) return lanAuthEnabled(); return classifyHost(host) === 'public' ? true : lanAuthEnabled(); diff --git a/lib/proxy.mjs b/lib/proxy.mjs index 8e29df1..39bc1a4 100644 --- a/lib/proxy.mjs +++ b/lib/proxy.mjs @@ -195,10 +195,18 @@ function parseCookies(header) { return out; } -/** 登录页:按访问来源显示提示(局域网 / 公网);error: false|true|'locked'(locked 带剩余秒数)。 */ -function loginPageHtml(error, isPublic, retryAfter = 0) { +/** + * 登录页:按访问来源显示提示(局域网 / 公网)。 + * credentialKind: 'persistent'(可长期复用)|'ephemeral'(短期轮换,不建议保存)。 + * error: false|true|'locked'(locked 带剩余秒数)。 + */ +function loginPageHtml(error, isPublic, retryAfter = 0, credentialKind = 'ephemeral') { const where = isPublic ? '此公网地址' : '此局域网地址'; const whereEn = isPublic ? 'This public address' : 'This LAN address'; + const autocomplete = credentialKind === 'persistent' ? 'current-password' : 'off'; + const usernameField = credentialKind === 'persistent' + ? '' + : ''; const errMsg = error === 'locked' ? `尝试次数过多,请 ${retryAfter} 秒后再试 | Too many attempts — try again in ${retryAfter}s` : error ? '密码错误,请重试 | Wrong PIN, try again' : ''; @@ -210,18 +218,54 @@ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:c .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 24px;max-width:320px;width:calc(100% - 48px);text-align:center} h1{font-size:16px;margin:0 0 4px;color:#111827} p{font-size:13px;color:#6b7280;margin:0 0 16px} -input{width:100%;box-sizing:border-box;padding:10px 12px;font-size:18px;letter-spacing:6px;text-align:center;border:1px solid #d1d5db;border-radius:8px;outline:none;margin-bottom:12px} -input:focus{border-color:#4f6ef7} -button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;border:none;border-radius:8px;cursor:pointer} +label.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.implicit-username{display:none} +.pin-field{position:relative;margin-bottom:12px} +input{width:100%;box-sizing:border-box;padding:10px 50px;font-size:18px;line-height:24px;letter-spacing:6px;text-align:center;caret-color:#4f6ef7;border:1px solid #d1d5db;border-radius:8px;outline:none} +input::-ms-reveal,input::-ms-clear{display:none} +input:focus{border-color:#4f6ef7;box-shadow:0 0 0 1px #4f6ef7} +.pin-toggle{position:absolute;right:2px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;width:44px;height:44px;padding:0;background:transparent;color:#6b7280;border:0;border-radius:6px;cursor:pointer} +.pin-toggle:hover{color:#374151;background:#f3f4f6} +.pin-toggle:focus-visible{outline:2px solid #4f6ef7;outline-offset:1px} +.pin-toggle svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;pointer-events:none} +.pin-toggle .is-hidden{display:none} +.enter-button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;border:none;border-radius:8px;cursor:pointer} .err{color:#dc2626;font-size:12px;margin-bottom:10px;min-height:16px}

🔐 DSH Pocket

${where}受访问密码保护,请输入访问密码 | ${whereEn} is password-protected — enter the access PIN

${errMsg}
- - +${usernameField} + +
+ + +
+
+
`; } @@ -700,9 +744,10 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 } * @param {string} [opts.host] 监听地址(默认 0.0.0.0:LAN 与隧道都能到) * @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080) * @param {string} [opts.injectHtml] 注入 HTML 的内容(默认 polyfill + 移动端适配;传 '' 关闭) - * @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, getAltTokens?, isProtected, sessionKey } + * @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, getAltTokens?, getCredentialKind?, isProtected, sessionKey } * - getToken(host) → 主 PIN(公网/局域网各一个,按 host 分类) * - getAltTokens?(host) → 替代令牌列表(可选),校验时与主 PIN 任一命中即放行 + * - getCredentialKind?(host) → 'persistent'|'ephemeral',控制密码管理器提示(默认 ephemeral) * @param {object|false} [opts.rateLimit] 登录速率限制参数覆盖(issue #40;测试用短窗口) * @param {object|false} [opts.heartbeat] WebSocket 心跳注入(PR #41):{ intervalMs, missLimit };false 关闭(默认开:30s/容忍 2 个静默周期) * @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。 @@ -738,6 +783,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE const token = protectedHost ? (auth.getToken?.(host) ?? null) : null; // 临时 PIN(issue #69):按 host 同源分发(公网临时 PIN 只在公网入口放行,局域网同理) const altTokens = protectedHost && token && typeof auth.getAltTokens === 'function' ? (auth.getAltTokens(host) ?? []) : []; + const credentialKind = auth.getCredentialKind?.(host) === 'persistent' ? 'persistent' : 'ephemeral'; const sessionKey = auth.sessionKey ?? null; const acceptedTokens = token ? [token, ...altTokens] : []; if (protectedHost && token) { @@ -751,7 +797,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE 'cache-control': 'no-store', 'retry-after': String(rl.retryAfter), }); - res.end(loginPageHtml('locked', isPublic, rl.retryAfter)); + res.end(loginPageHtml('locked', isPublic, rl.retryAfter, credentialKind)); return; } let body = ''; @@ -766,7 +812,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE 'cache-control': 'no-store', 'retry-after': String(finalRl.retryAfter), }); - res.end(loginPageHtml('locked', isPublic, finalRl.retryAfter)); + res.end(loginPageHtml('locked', isPublic, finalRl.retryAfter, credentialKind)); return; } const submitted = String(new URLSearchParams(body).get('token') ?? ''); @@ -788,7 +834,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE limiter?.record(ip); log?.(`dsh-pocket: login failed from ${ip} | 登录失败 IP: ${ip}`); res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); - res.end(loginPageHtml(true, isPublic, 0)); + res.end(loginPageHtml(true, isPublic, 0, credentialKind)); } }); return; @@ -804,7 +850,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE if (rl.locked) { if (isHtmlRequest(req)) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); - res.end(loginPageHtml('locked', isPublic, rl.retryAfter)); + res.end(loginPageHtml('locked', isPublic, rl.retryAfter, credentialKind)); } else { res.writeHead(429, { 'content-type': 'application/json', @@ -826,7 +872,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE // 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝) const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 }; res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); - res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter)); + res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter, credentialKind)); } else { res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end('{"error":"unauthorized"}'); diff --git a/test/auth-routing.test.js b/test/auth-routing.test.js index 21932bf..f0a904a 100644 --- a/test/auth-routing.test.js +++ b/test/auth-routing.test.js @@ -5,8 +5,8 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { tokenForHost, isLanOverrideHost, rotateAccessToken } from '../lib/index.js'; -import { setLanIpOverride, setLanAuthEnabled, lanIpOverride } from '../lib/settings.mjs'; +import { tokenForHost, credentialKindForHost, isLanOverrideHost, rotateAccessToken } from '../lib/index.js'; +import { setLanIpOverride, setLanAuthEnabled, lanIpOverride, setPinCustom, setTunnelMode } from '../lib/settings.mjs'; let tmpHome = null; let savedHome = null; @@ -79,3 +79,22 @@ test('issue #90:访问 PIN 必须用 CSPRNG 生成,不得回退到 Math.rand // 200 次采样在 9×10⁷ 空间里几乎不可能撞车;若大量重复说明随机源退化了。 assert.ok(seen.size >= 199, `200 次采样应基本互不相同,实际 ${seen.size} 个不同值`); })); + +test('登录页凭据语义:LAN、Named 和自定义公网 PIN 可保存,Quick 自动 PIN 不保存', () => withTempHome(async () => { + setLanIpOverride(''); + setTunnelMode('quick'); + setPinCustom('public', false); + + assert.equal(credentialKindForHost('192.168.1.5:3081'), 'persistent', 'LAN 自动 PIN 持久到手动刷新'); + assert.equal(credentialKindForHost('abc.trycloudflare.com'), 'ephemeral', 'Quick 自动 PIN 每次开隧道轮换'); + + setPinCustom('public', true); + assert.equal(credentialKindForHost('abc.trycloudflare.com'), 'persistent', 'Quick 自定义 PIN 长期复用'); + + setPinCustom('public', false); + setTunnelMode('named'); + assert.equal(credentialKindForHost('pocket.example.com'), 'persistent', 'Named Tunnel PIN 不自动轮换'); + + setLanIpOverride('203.0.113.5'); + assert.equal(credentialKindForHost('203.0.113.5:3081'), 'persistent', '手动 LAN 地址覆盖仍按 LAN 持久 PIN 处理'); +})); diff --git a/test/proxy.test.js b/test/proxy.test.js index 77fe8c3..f2db6fc 100644 --- a/test/proxy.test.js +++ b/test/proxy.test.js @@ -598,7 +598,11 @@ test('访问令牌认证(issue #13):公网需登录、cookie 放行、局 const proxy = await createPocketProxy({ port: 0, host: '127.0.0.1', upstream: { host: '127.0.0.1', port: up.address().port }, - auth: { getToken: () => TOKEN, isProtected: () => true }, + auth: { + getToken: () => TOKEN, + getCredentialKind: (host) => String(host).includes('trycloudflare') ? 'ephemeral' : 'persistent', + isProtected: () => true, + }, }); const raw = (headers, method = 'GET', body, path = '/') => new Promise((resolve, reject) => { const req = http.request({ host: '127.0.0.1', port: proxy.port, path, method, headers }, (res) => { @@ -618,6 +622,16 @@ test('访问令牌认证(issue #13):公网需登录、cookie 放行、局 assert.equal(r1.status, 200); assert.ok(r1.body.includes('访问密码'), '返回登录页'); assert.match(r1.body, /minlength="8" maxlength="64"/, '登录页允许输入 8–64 位自定义 PIN'); + assert.match(r1.body, /id="current-password"[^>]+autocomplete="off"/, '短期轮换 PIN 不请求密码管理器保存'); + assert.doesNotMatch(r1.body, /autocomplete="username"/, '短期轮换 PIN 不伪装成可保存的完整登录项'); + assert.match(r1.body, /for="current-password"/, '登录页为密码输入框提供关联 label'); + assert.match(r1.body, /type="button"[^>]+aria-controls="current-password"/, '显示密码按钮不会误提交表单且具备可访问语义'); + assert.match(r1.body, /id="eye-icon"[^>]+aria-hidden="true"/, '显示密码按钮使用装饰性眼睛图标'); + assert.match(r1.body, /id="eye-slash"/, '密码默认隐藏时眼睛带斜线'); + assert.match(r1.body, /eyeSlash\.classList\.toggle\('is-hidden',show\)/, '切换时用 SVG 支持的 classList 改变斜线状态'); + assert.match(r1.body, /padding:10px 50px;font-size:18px;line-height:24px;letter-spacing:6px;text-align:center;caret-color:/, '保留社区 PIN 字距与居中风格,同时为眼睛图标对称留位'); + assert.match(r1.body, /setSelectionRange\(start,end\)/, '显示与隐藏切换时保留光标选区'); + assert.match(r1.body, /pointerdown[^\n]+preventDefault/, '指针切换显示状态时不抢走输入框焦点'); // 2) 公网 API 无 cookie → 401(非 HTML 路径) const r2 = await raw({ ...publicH, Accept: 'application/json' }, 'GET', undefined, '/api/hello'); @@ -643,6 +657,8 @@ test('访问令牌认证(issue #13):公网需登录、cookie 放行、局 const r6 = await raw(lanH); assert.equal(r6.status, 200); assert.ok(r6.body.includes('访问密码'), '局域网也需要密码(登录页)'); + assert.match(r6.body, /id="current-password"[^>]+autocomplete="current-password"/, '长期有效的 LAN PIN 允许密码管理器填充'); + assert.match(r6.body, /id="pocket-username"[^>]+autocomplete="username"[^>]+value="dsh-pocket"/, '固定 PIN 提供稳定的隐式账号供密码管理器关联'); // 局域网带 cookie → 放行 const r6b = await raw({ ...lanH, Cookie: 'dsh_pocket_token=' + TOKEN }); assert.equal(r6b.status, 200, '局域网带 cookie 放行');