- AI Agent
- 人工智能
- 代码智能体
- 交互助手
【免费下载链接】openchamber
Agentic Development Environment based on OpenCode AI agent
导读
本文以 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.js | UI 认证控制器运行时: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)时追加 SecureSameSite=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_ATTEMPTS | 10 | 每窗口每 IP 最大失败次数 |
OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS | 3 | 无法解析客户端 IP 时的更严格上限 |
RATE_LIMIT_WINDOW_MS | 5 分钟 | 计数窗口 |
RATE_LIMIT_LOCKOUT_MS | 15 分钟 | 超限后的锁定时长 |
RATE_LIMIT_CLEANUP_MS | 1 小时 | 过期/陈旧记录的清理周期 |
响应头携带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)
beginRegistration(req, { label })先解析当前请求的 rpID 与 origin(优先x-forwarded-proto/x-forwarded-host,其次 socket/host 头),再用@simplewebauthn/server的generateRegistrationOptions生成选项,要求residentKey: 'required'、userVerification: 'required'、attestationType: 'none',并以excludeCredentials排除当前 rpID 下已注册凭据;- 生成的 challenge 连同
expectedOrigins(含当前 origin 与磁盘设置里的publicOrigin)、expectedRPIDs、TTL(默认 5 分钟)存入内存registrationChallenges,返回{ requestId, optionsJSON }; 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)。
实践要点总结:
- 多实例同 host 部署:依赖自动的
oc_ui_session_<port>隔离,无需手工改 Cookie 名;升级后让用户重新登录一次即可; - 修改 UI 密码会同时作废全部通行密钥(passwordBinding 不匹配即清空存储),可视为一次主动的凭据收敛;
- 可信设备凭据是唯一的持久授权:密码/通行密钥/配对只是签发途径,运维侧吊销设备应操作
remote-clients.json对应记录的 revoke; - Pairing 会话与配对令牌均为一次性:secret 仅存哈希、redeem 即失效,且支持取消与指纹展示,适合作为移动端/桌面端首次连接的安全通道。
- AI Agent
- 人工智能
- 代码智能体
- 交互助手
【免费下载链接】openchamber
Agentic Development Environment based on OpenCode AI agent
相关推荐
LKY Office Tools:从0到可用只需3步,免费的Office一键部署
LKY Office Tools:从0到可用只需3步,免费的Office一键部署 刚装完 Windows,想在 10 分钟内用上 Word 和 Excel,却要
后端API网关MCP 服务dsh-pluginPocket ID通行密钥原理:WebAuthn与FIDO2技术深度解析
Pocket ID通行密钥原理:WebAuthn与FIDO2技术深度解析 Pocket ID是一个简单易用的OIDC身份提供商,专门支持通行密钥认证技术,让用户
后端认证鉴权Meteor Accounts 完全指南:用户模型、会话存储与密码/OAuth 登录认证体系深度解析
Meteor Accounts 完全指南:用户模型、会话存储与密码/OAuth 登录认证体系深度解析 Meteor 的 Accounts 系统是在 userId
后端前端开发工具移动开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考