☰
OpenChamber UI 认证模块深度解析:密码会话、WebAuthn 通行密钥与可信设备凭据体系
2026/9/25 4:06:16 网站建设 项目流程
  • AI Agent
  • 人工智能
  • 代码智能体
  • 交互助手

【免费下载链接】openchamber

Agentic Development Environment based on OpenCode AI agent

项目地址:https://gitcode.com/gh_mirrors/op/openchamber
点击查看免费下载

导读

本文以 OpenChamber 服务端 UI 认证模块(packages/web/server/lib/ui-auth/及其配套的lib/client-auth/)为对象,系统讲解浏览器端访问控制的三层能力:密码会话认证(password session auth)、WebAuthn 通行密钥(passkeys)与可信设备(trusted-device)会话。读者读完本文,将掌握 OpenChamber 会话 Cookie 的 host:port 作用域设计、JWT 会话令牌的签发与校验流程、登录限流参数、通行密钥的注册/认证协议细节,以及远程客户端 Bearer Token(oc_client_...)与 Pairing v2 配对流程的完整实现链路。

模块定位:单一凭据模型,三种签发方式

该模块的架构核心是一条清晰的原则:可信设备访问只有一种持久化凭据——由packages/web/server/lib/client-auth/remote-clients.js存储的远程客户端 Bearer Token。密码(password)、WebAuthn 通行密钥(passkey)和 Pairing v2 都只是这种凭据的签发方式,而不是彼此独立的凭据系统。

签发方式 凭据形态 ───────────────────────────── ────────────────────────────── UI 密码登录 ───────────▶ oc_client_xxx Bearer Token 浏览器通行密钥登录 ───────────▶ oc_client_xxx Bearer Token Pairing v2 配对 ───────────▶ oc_client_xxx Bearer Token

签发后的客户端 Token 只会原样返回一次,服务端仅保存其 SHA-256 哈希(见 remote-clients.js 中hashToken与tokenHash字段);后续请求通过Authorization: Bearer oc_client_...完成认证。Pairing v2 由 pairing.js 实现:它维护短期有效的一次性配对会话(仅存哈希后的 secret),在/api/client-auth/pairing/*下暴露 create / cancel / redeem 路由,redeem 一个有效配对 secret 后即签发与密码/通行密钥完全相同的远程客户端 Token。

模块入口与文件结构

认证能力分布在五个文件中,职责边界清晰:

文件职责
ui-auth.jsUI 认证控制器运行时:Cookie/会话签发、JWT 校验、登录限流、全部认证路由处理器
ui-passkeys.js通行密钥存储与 WebAuthn 注册/认证验证辅助逻辑
session-cookie.js依据请求Host端口解析会话 Cookie 名;被会话签发/校验与通知身份提取共用
remote-clients.js可信设备客户端 Token 存储、Bearer 认证、最近使用时间跟踪与吊销
pairing.js短期 Pairing v2 会话与一次性 secret 赎回为可信设备 Token

在服务装配层,index.js 将上述运行时实例化并注入路由系统:createRemoteClientAuthRuntime使用数据目录下的remote-clients.json,createClientPairingRuntime使用client-pairing-sessions.json(数据目录默认为~/.config/openchamber,可用OPENCHAMBER_DATA_DIR覆盖,见 index.js)。

会话 Cookie 作用域:按 host:port 隔离

问题背景(issue #2377)

浏览器按 RFC 6265 的规定,Cookie 存储仅以host 为键、不区分端口。因此,同一主机名、不同端口上运行的两个 OpenChamber 实例会共享同一个oc_ui_sessionCookie——在第二个实例登录会覆盖第一个实例的会话 Cookie。该问题对 LAN 地址和本机回环(loopback)地址同样成立,例如localhost:3000与localhost:3001就无法安全共存。

解决方案:把端口折叠进 Cookie 名

sessionCookieNameForRequest(req, base)(session-cookie.js)把请求Host中的端口折进 Cookie 名:

  • Host 携带显式端口(包括来自x-forwarded-host的端口)时,返回oc_ui_session_<port>;
  • Host 不带显式端口时,返回裸名oc_ui_session。

实现细节值得注意(session-cookie.js):

  • forwarded 优先:优先取x-forwarded-host的第一个值(按逗号分隔并 trim),其次才回退到host头;
  • IPv6 支持:正确处理[::1]:3000与[::1]两种形态的 authority 解析;
  • 非法端口兜底:端口非纯数字(如192.168.0.1:notaport)或数值非正数时,返回裸 base 名。

同一解析器被四处共享:密码登录、浏览器通行密钥登录的issueSession签发 Cookie,以及会话校验、会话清除、通知身份提取(request-security.js 使用同一sessionCookieNameForRequest解析身份)。注意身份提取路径不提供 CSRF Token,而 Bearer Token 校验路径保持不变。

测试用例佐证

session-cookie.test.js 用七组用例固化了上述语义,可当作行为规格阅读:

  • 无显式端口的主机(192.168.0.1、example.com、空串、undefined)均返回裸名oc_ui_session;
  • 同一 IP 不同端口(:3000、:3001、:8080)分别得到oc_ui_session_3000/3001/8080,实现实例隔离;
  • IPv6 authority[::1]:3000返回oc_ui_session_3000,[::1]返回裸名;
  • x-forwarded-host端口优先于直接 host(127.0.0.1:3902+x-forwarded-host: 203.0.113.9:8443→oc_ui_session_8443);
  • 非法端口回退裸名;自定义 base 名(my_app)同样参与端口折叠。

升级兼容性

升级行为上,本次改动会为一切显式端口 host重命名 Cookie,因此已登录的浏览器会话需要重新登录一次;磁盘上的数据格式无任何变化(无 on-disk 迁移)。

密码会话认证(ui-auth.js)

控制器创建

createUiAuth({ password, cookieName, sessionTtlMs, readSettingsFromDiskMigrated })创建 UI 认证控制器。当password未配置时,控制器进入 disabled 分支(enabled: false),各路由返回 400/401 占位响应;配置密码后进入完整认证逻辑(enabled: true,见 ui-auth.js)。

会话令牌:HS256 JWT

会话 Cookie 的值是一个 JWT(ui-auth.js):

const token = await new SignJWT({ type: 'ui-session' }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime(ttlMs / 1000 + 's') .sign(jwtSecret);
  • 签名密钥:从OPENCODE_JWT_SECRET环境变量或数据目录jwt-secret文件读取;不存在时用crypto.randomBytes(32)生成并以0o600权限持久化(ui-auth.js)。注意:handleResetAuth(全局登出)在设置了OPENCODE_JWT_SECRET时不可用,会抛出 400——因为无法轮换环境变量来源的密钥。
  • TTL 默认值:普通会话 12 小时(SESSION_TTL_MS),勾选“信任此设备”后 7 天(TRUSTED_DEVICE_SESSION_TTL_MS,见 ui-auth.js)。
  • 校验:isSessionValid使用 jose 的jwtVerify,并延迟加载(import('jose')首次会话检查才发生,避免随服务器启动拖慢冷启动,见 ui-auth.js)。

Cookie 属性

无论签发还是清除,会话 Cookie 均带固定属性(ui-auth.js):

Path=/; HttpOnly; SameSite=Strict; Max-Age=<秒>; Expires=<UTC> # 当请求为 HTTPS(req.secure 或 x-forwarded-proto 为 https)时追加 Secure

SameSite=Strict+HttpOnly的组合有效压制 CSRF 与 XSS 窃取 Cookie 的风险面。

密码校验

密码在createUiAuth时以crypto.scryptSync(password, salt, 64)生成期望哈希;每次登录使用crypto.timingSafeEqual常数时间比较(ui-auth.js),同时normalizePassword会先做 Unicode 归一化(normalize())与 trim。

登录限流与防爆破

handleSessionCreate在验证密码前先执行内存级滑动窗口限流(ui-auth.js),相关常量:

参数默认值说明
OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS10每窗口每 IP 最大失败次数
OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS3无法解析客户端 IP 时的更严格上限
RATE_LIMIT_WINDOW_MS5 分钟计数窗口
RATE_LIMIT_LOCKOUT_MS15 分钟超限后的锁定时长
RATE_LIMIT_CLEANUP_MS1 小时过期/陈旧记录的清理周期

响应头携带X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset;被限流时返回 429 与Retry-After。IP 解析优先x-forwarded-for首值(并剥离::ffff:IPv4-mapped 前缀),其次req.ip/remoteAddress;限流键基于 IP,无 IP 时统一落到rate-limit:no-ip。同一键的计数与锁定通过内存 Promise 队列串行化(acquireRateLimitLock),并通过unref()的定时器做周期清理(ui-auth.js)。

WebAuthn 通行密钥(ui-passkeys.js)

运行时创建

createUiPasskeys({ passwordBinding, readSettingsFromDiskMigrated, storeFile, rpName, challengeTtlMs })创建通行密钥运行时。关键前提是Passkey 依赖 UI 密码保护已启用:assertEnabled()在passwordBinding为空时抛出 400“Passkeys require UI password protection to be enabled”。

存储与绑定

  • 存储文件:数据目录下ui-passkeys.json(默认~/.config/openchamber/ui-passkeys.json),JSON 结构含version(当前 1)、userID(base64url)、passwordBinding、passkeys[](ui-passkeys.js)。
  • 密码绑定:passwordBinding是HMAC-SHA256(jwtSecret, normalizedPassword)的十六进制摘要(ui-auth.js)。加载存储时若passwordBinding不匹配会直接清空全部 passkey 并重写存储——这意味着修改 UI 密码会使既有通行密钥失效,这是刻意设计的绑定语义(ui-passkeys.js)。
  • 用户 ID:首次创建时crypto.randomBytes(32)生成,作为 WebAuthn 的userID与userName: 'openchamber-ui'、userDisplayName: 'OpenChamber UI'一起传给注册选项。

注册流程(beginRegistration / finishRegistration)

  1. beginRegistration(req, { label })先解析当前请求的 rpID 与 origin(优先x-forwarded-proto/x-forwarded-host,其次 socket/host 头),再用@simplewebauthn/server的generateRegistrationOptions生成选项,要求residentKey: 'required'、userVerification: 'required'、attestationType: 'none',并以excludeCredentials排除当前 rpID 下已注册凭据;
  2. 生成的 challenge 连同expectedOrigins(含当前 origin 与磁盘设置里的publicOrigin)、expectedRPIDs、TTL(默认 5 分钟)存入内存registrationChallenges,返回{ requestId, optionsJSON };
  3. finishRegistration(payload)校验请求 ID 与 challenge 存活,verifyRegistrationResponse要求requireUserVerification: true;验证通过后将credential.publicKey、counter、transports、deviceType、backedUp等持久化(ui-passkeys.js)。

认证流程(beginAuthentication / finishAuthentication)

认证选项只allowCredentials列出当前 rpID 下已注册的凭据;finishAuthentication用存储的公钥与counter调用verifyAuthenticationResponse,成功后回写新的counter与lastUsedAt(ui-passkeys.js)。认证完成后handlePasskeyAuthenticationVerify会签发会话 Cookie,且与密码登录一致地支持trustDevice与issueClientToken参数(ui-auth.js)。

按 host 隔离与吊销

  • getStatus/listPasskeys都按当前请求解析出的 rpID 过滤,实现同一实例不同 host 的通行密钥互相不可见(isLocalRpId覆盖localhost、127.0.0.1、::1);
  • revokePasskey(req, id)只删除与当前 rpID 匹配的凭据,找不到返回 404;
  • clearAllPasskeys()清空全部并轮换 userID,配合handleResetAuth的rotateJwtSecret()+ 清除会话 Cookie,实现“全部设备强制下线”。

可信设备凭据:远程客户端 Token(remote-clients.js)

createRemoteClientAuthRuntime({ fsPromises, path, crypto, storePath })管理oc_client_...令牌生命周期:

  • 签发:generateToken()=oc_client_前缀 + 32 字节 base64url 随机串;服务端只存sha256(token)的 hex 哈希,明文仅返回一次(remote-clients.js)。
  • Bearer 认证:authenticateBearerToken(token, req)要求 Token 带oc_client_前缀,用constantTimeEqual常数时间比对哈希,并检查expiresAt是否过期;同时记录lastUsedAt(写入节流 60 秒)与lastTransport——通过请求头x-openchamber-relay-connection判定请求经由 relay 隧道还是直连(remote-clients.js)。
  • 去重语义:createClient支持dedupeKey——同 key 再次签发会替换旧记录,且标签优先级为“显式 label > 被替换记录的 label > 客户端自报 fallback”,保证重配对不丢失人工命名(remote-clients.js)。
  • 吊销与清理:revokeClient(id)标记revokedAt;purgeRevokedClients()物理删除;hasActiveRelayClients()基于usesRelay或实测lastTransport === 'relay'判断 relay 需求,驱动中继生命周期决策。

客户端元数据(clientKind、authMethod、pairingId、deviceName/Platform/Model、appVersion)都会归一化持久化,供设备列表展示。

Pairing v2:一次性配对会话(pairing.js)

createClientPairingRuntime({ ..., storePath, remoteClientAuthRuntime, ttlMs })实现配对流程,默认 TTL 10 分钟:

  • 创建(createPairingSession):生成pair_前缀的会话 ID、32 字节 base64url secret、XXXX-XXXX格式 4 字节指纹;secret 只存 SHA-256 哈希;支持allowedClientKinds(合法值mobile/desktop,默认两者皆可)、createdByClientId、usesRelay(pairing.js)。
  • 取消(cancelPairingSession(id)):置cancelledAt,会话即不可再 redeem。
  • 赎回(redeemPairingSession):按“未取消、未使用、未过期、clientKind 在允许集合内、secret 常数时间比对通过”五重校验;失败统一返回Invalid or expired pairing session通用错误(不泄露存在性)。成功后调用remoteClientAuthRuntime.createClient,dedupeKey默认pairing:<sessionId>,authMethod: 'pairing',并把会话标记为已使用、记录产出的clientId(pairing.js)。
  • 存储:写入client-pairing-sessions.json(目录0o700、文件0o600),withStoreMutation队列串行化全部变更;过期会话由sweepExpiredSessions/sweepExpiredSessionsFromStore清理。

配对链接由此成为“短生命周期、一次性、可取消”的受控入口,与 UI 登录页的二维码/链接配对体验对应。

附加能力:URL 认证令牌(oc_url_)

源码中还存在一类短时 URL 令牌:oc_url_+ 24 字节随机串,TTL 仅 60 秒(ui-auth.js)。通过POST /auth/url-token(携带scope参数)签发,随后在 URL 查询参数oc_url_token或/api/fs/serve/页面子资源的 Referer 中携带:

  • scope 收窄:guest:<id>作用域令牌只允许GET /api/guests/<id>/...路径,防止 iframe 内客脚本越权读取其他包文件;
  • 无 scope:仅允许可读 GET 路径(如/api/event、/api/fs/raw、/api/fs/serve、/api/notifications/stream等)与白名单 WebSocket 路径(如/api/event/ws、/api/terminal/ws、/api/dictation/ws、/api/dev-tunnel);
  • 响应始终带Cache-Control: no-store。

公共 API 一览

createUiAuth 返回(ui-auth.js):

enabled、requireAuth(req,res,next)、requireSessionAuth(req,res,next)、resolveAuthContext(req,res,opts)、handleSessionStatus、handleSessionCreate、handleUrlAuthToken、handlePasskeyStatus、handlePasskeyRegistrationOptions、handlePasskeyRegistrationVerify、handlePasskeyAuthenticationOptions、handlePasskeyAuthenticationVerify、handlePasskeyList、handlePasskeyRevoke、handleResetAuth、ensureSessionToken、dispose()。

createUiPasskeys 返回(ui-passkeys.js):

enabled、getStatus(req)、listPasskeys(req)、revokePasskey(req, passkeyId)、clearAllPasskeys()、beginRegistration(req, { label })、finishRegistration(payload)、beginAuthentication(req)、finishAuthentication(payload)、dispose()、isLocalRpId。

认证优先级与使用建议

从requireAuth与resolveAuthContext的实现可以推断出请求的认证判定顺序:会话 Cookie(JWT)→ URL 令牌(oc_url_)→ Bearer 客户端令牌(oc_client_),任一通过即放行;全部失败时清除 Cookie 并返回 401(JSON API 返回{ error: 'UI authentication required', locked: true },非 API 请求返回纯文本Authentication required)。handleSessionStatus对携带 Bearer 头的探测请求只按令牌本身作答,避免 Cookie 与已吊销令牌互相掩盖(ui-auth.js)。

实践要点总结:

  1. 多实例同 host 部署:依赖自动的oc_ui_session_<port>隔离,无需手工改 Cookie 名;升级后让用户重新登录一次即可;
  2. 修改 UI 密码会同时作废全部通行密钥(passwordBinding 不匹配即清空存储),可视为一次主动的凭据收敛;
  3. 可信设备凭据是唯一的持久授权:密码/通行密钥/配对只是签发途径,运维侧吊销设备应操作remote-clients.json对应记录的 revoke;
  4. Pairing 会话与配对令牌均为一次性:secret 仅存哈希、redeem 即失效,且支持取消与指纹展示,适合作为移动端/桌面端首次连接的安全通道。
  • AI Agent
  • 人工智能
  • 代码智能体
  • 交互助手

【免费下载链接】openchamber

Agentic Development Environment based on OpenCode AI agent

项目地址:https://gitcode.com/gh_mirrors/op/openchamber
点击查看免费下载
上一篇:Xous网络与通信服务:从DNS到USB设备的完整通信栈
下一篇:iOS富文本设计模式:基于TTTAttributedLabel的架构设计

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询