登录子系统:OIDC 统一自签 token + 吊销即时生效 + 设备并入会话列表 + 应用内扫码 + 禁则修复

接续 90a3790(扫码 A0+A1+B + step-up 雏形)。蓝本见 AUTH.md。

OIDC 统一到 cdrop 自签 token
- /auth/exchange 先 JWKS 验 id_token 签名(自签 token 的唯一信任锚)再签 cdrop
  自签 HS256 access token(绑 sid),不再把 IdP RS256 下发浏览器;验签失败仅告警
  并回退 RS256(会话仍建、下次 refresh 自愈),登录不中断
- /auth/refresh oidc 路径仍打 IdP 轮换 refresh_token + 探测 IdP 侧吊销,但同样回
  自签 token;createWebSession 改返回行 id
- verify 链保留 RS256 分支仅供桌面端 loopback(其即时吊销留后续)

吊销即时生效 + 被吊销设备自知回退
- verifySelfToken 每请求按 sid 查 web_sessions 行,行删即拒 → 吊销在被吊销设备
  下一次请求即生效(不等 TTL),oidc / self / guest 一致
- 前端仅在「服务端以 401 确证会话失效」时 forceLogout 清登录态、回登录页;短期连接
  失败(5xx / 网络)一律保留登录态(refreshTokens 改三态 refreshed/invalid/transient,
  cookieRefresh 改 discriminated 结果)
- __root 反应式守卫:prod 下失登录态且非认证路由即跳登录页;新增 auth.sessionLost.* 三语

设备列表并入会话列表 + 孤儿自动清除
- GET /api/auth/sessions 统一 web_sessions 与原生客户端设备(桌面 / iOS,自 devices
  登记表呈现,native=true,离线也列);同名不重复列出,排除 shortcut 与无会话 browser
- 吊销网页会话连带删其设备登记(无同名在用会话时);原生「登出」走
  DELETE /api/devices/{name}(同要求 step-up)
- 新增 DeleteOrphanBrowserDevices(browser 型且无存活 web_session),接入设备清扫器(1h)

应用内扫码 + 聚珍崩溃 / CJK 禁则修复
- 登录页内置 getUserMedia + jsqr 扫码,不再外跳系统应用(避免开错浏览器)
- 修聚珍(cjk-autospace)MutationObserver 与 React 重渲染同子树冲突致的 insertBefore
  崩溃:扫码 / 显码 / 批准三状态机页整页跳过聚珍(data-jz-skip)
- 修流动正文未跑 CJK 禁则(jinze 为段落级、须 opt-in):设置 / 快捷指令 / 桌面页一批
  hint 补 data-jz-level="paragraph" + justify,短标签 / 状态仍留文本级

step-up 完善
- auth_time 改可选(Casdoor 不下发,原强制致死循环);stepped_up_at 按会话绑定、
  StepUpMaxAgeSeconds 窗口内不复要求;新增 POST /api/auth/stepup
- 批准页 persist 编进 step-up returnTo 防整页跳转丢失;scope 随档绑定(信任=full /
  仅此次=guest),默认偏安全的「仅此次」

文档与测试
- AUTH.md:折中架构、accounts 薄表、令牌架构、扫码流程、step-up、§4.4 统一会话列表
- 新增 Go 测试:OIDC 自签验证、verifySelfToken 吊销即拒、会话吊销连带删设备、
  统一会话列表(含原生 / 不含 shortcut / 不重复)、孤儿清扫、删设备需 step-up
This commit is contained in:
2026-06-22 11:55:02 +08:00
parent 90a3790a98
commit 018a77b13a
45 changed files with 2235 additions and 299 deletions
+14 -5
View File
@@ -25,7 +25,7 @@
- **决策 B — `user_id` 继续 = OIDC `sub`,不引内部 id**。新增薄 `accounts` 表(键在 `sub`),其中存一个**稳定匹配键**(默认取 JWT 的 `email` claim)。日常切换 OAuth provider 即账号失效——**运维须知此前提**。仅当管理员显式启用“后端迁移标记”时,cdrop 才在新源登录时按该匹配键自动关联回同一账号(受控窗口,非日常自动 link,规避账号接管)。 - **决策 B — `user_id` 继续 = OIDC `sub`,不引内部 id**。新增薄 `accounts` 表(键在 `sub`),其中存一个**稳定匹配键**(默认取 JWT 的 `email` claim)。日常切换 OAuth provider 即账号失效——**运维须知此前提**。仅当管理员显式启用“后端迁移标记”时,cdrop 才在新源登录时按该匹配键自动关联回同一账号(受控窗口,非日常自动 link,规避账号接管)。
- **决策 C — 2FA 委派给 provider**。cdrop 不自存任何第二因子密钥,仅保留敏感动作的 **step-up 钩子**(要求 provider 再认证),默认关闭。 - **决策 C — 2FA 委派给 provider**。cdrop 不自存任何第二因子密钥,仅保留敏感动作的 **step-up 钩子**(要求 provider 再认证),默认关闭。
- **决策 D — 扫码方向 = 陌生设备显码、已登录手机扫码批准**(微信 / WhatsApp 网页版模型)。陌生设备常无摄像头但有屏,最贴合“临时借设备传文件”。 - **决策 D — 扫码方向 = 陌生设备显码、已登录手机扫码批准**(微信 / WhatsApp 网页版模型)。陌生设备常无摄像头但有屏,最贴合“临时借设备传文件”。
- **决策 E — 扫码会话 = 受限访客会话,持久性可选**。批准时“信任此设备(7 天滑动)”或“仅此次(1 小时)”;访客会话能收发文件,但不能改账号、不能再批准别的设备、不能签发长效 token - **决策 E — 扫码会话两档,scope 与持久性绑定**。批准时二选一:“信任此设备(完整会话,7 天滑动)”——授予 scope=full、kind=self,可完整使用本账号(自有设备用);“仅此次(受限访客,1 小时)”——授予 scope=guest、kind=guest能收发文件与消息,但不能改账号、不能再批准别的设备、不能签发长效 token(陌生/借用设备用)。默认偏向更安全的“仅此次”。(原设计两档皆 guest、仅持久性不同;2026-06-21 实测后改为 scope 随档绑定,消除“信任却受限”的语义矛盾。)
> 本文取代 `.scratch/auth-inversion-diff.html` 中按“全反转”写的 §四 大半——那五个“凭证保管者”决策(无邮件重置 / 开放注册 / argon2id 等)在折中下随委派 provider 而消散。 > 本文取代 `.scratch/auth-inversion-diff.html` 中按“全反转”写的 §四 大半——那五个“凭证保管者”决策(无邮件重置 / 开放注册 / argon2id 等)在折中下随委派 provider 而消散。
@@ -129,9 +129,10 @@ CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires
- **签名密钥**:从 `SESSION_SECRET` 派生独立 HMAC 密钥(`DeriveSessionTokenKey(SESSION_SECRET)`,与既有 `DeriveHS256Key(HS256Secret)` 区分密钥域)。`SESSION_SECRET` 在 prod 本就强制非空,无新增必填密钥。 - **签名密钥**:从 `SESSION_SECRET` 派生独立 HMAC 密钥(`DeriveSessionTokenKey(SESSION_SECRET)`,与既有 `DeriveHS256Key(HS256Secret)` 区分密钥域)。`SESSION_SECRET` 在 prod 本就强制非空,无新增必填密钥。
- **载荷**`{ sub: user_id, sid: session_id, typ: "session", scope: "full"|"guest", iat, exp }`。短 TTL(默认 900s)。 - **载荷**`{ sub: user_id, sid: session_id, typ: "session", scope: "full"|"guest", iat, exp }`。短 TTL(默认 900s)。
- **校验**:中间件新增 `verifySelfToken` 分支(§9)。靠 `typ:"session"` + 独立签名密钥与 scoped shortcut token`typ` 缺省、`scope:"clipboard"`、走 `shortcut_tokens` 查吊销)区分,互不串验。 - **校验**:中间件新增 `verifySelfToken` 分支(§9)。靠 `typ:"session"` + 独立签名密钥与 scoped shortcut token`typ` 缺省、`scope:"clipboard"`、走 `shortcut_tokens` 查吊销)区分,互不串验。
- **吊销 / 撤销**access token 无状态、短命;长效凭证是 `web_sessions``/auth/refresh` 每次(≤TTL 周期)查 session 行存活与未吊销,故吊销在一个 TTL 内生效`oidc` 会话 refresh 仍打 IdP,保留 IdP 侧登出 / 吊销传播。 - **吊销 / 撤销(即时)**:长效凭证是 `web_sessions`access token 短命且**绑定 `sid`**。`verifySelfToken` 每请求按 `sid` 查 session 行——行被删除(吊销)即当场拒绝,故吊销在被吊销设备的**下一次请求**即生效,而非等一个 TTL。`oidc` 会话 refresh 仍打 IdP,保留 IdP 侧登出 / 吊销传播。
- **被吊销设备的感知与回退**:服务端拒绝之外,前端把“访问被拒且续期也被拒”视作会话不可恢复 → 就地清空登录态并回到登录页(`net/api.ts``forceLogout` + `__root` 反应式守卫)。**离线设备**于下一次使用(请求 401 或开机 cookie 续期失败)得知;**在线设备**经 `Hub.Kick` 关流 → 约 1s 后重连撞 401 得知。两路殊途同归到 `verifySelfToken``sid` 查行。
> RS256IdP access token)退出每请求热路径,仅在 OIDC exchange 时验 `id_token` 签名一次(§5。中间件保留 RS256 分支作兼容(桌面端等仍直接带 IdP token 的客户端),待后续统一迁移到自签后再清理——避免 flag day > RS256IdP access token退出浏览器每请求热路径:浏览器登录(含 OIDC)一律持自签 tokenOIDC exchange 时验 `id_token` 签名一次(§5作唯一信任锚,refresh 仍打 IdP 轮换 refresh_token 但同样回自签 token。中间件保留 RS256 分支仅供**桌面端**(自跑 loopback PKCE、直带 IdP token);桌面端即时吊销留后续
### 3.2 受限能力门(guest 会话) ### 3.2 受限能力门(guest 会话)
@@ -179,6 +180,14 @@ CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires
前端:新设备“显码页”+ 手机“批准页”+ 设备 / 会话管理(列出 `granted_by` 借用设备、可吊销)。 前端:新设备“显码页”+ 手机“批准页”+ 设备 / 会话管理(列出 `granted_by` 借用设备、可吊销)。
### 4.4 登录会话列表 = 全部设备(统一视图 + 自动清除)
「登录会话」面板收敛到“一台设备 = 一条会话”:列表即当前登录此账号的全部设备。既往“手动清除设备”入口已撤,导致被吊销设备长期滞留——本节即为此消除。
- **统一列表**`GET /api/auth/sessions`):`web_sessions`(浏览器 oidc / self / guest、扫码)**并入**原生客户端设备(桌面 / iOS,`type ∈ {macos, windows, linux, ios}`)。原生客户端用 IdP 令牌、不入 `web_sessions`,故从设备登记表 `devices`(即决策里说的“等价表”)呈现:`id` 形如 `device:<设备名>``kind` 取设备类型、`native=true`。同名已有会话的设备不重复列出;无会话的 `browser` 型(孤儿)与 `shortcut` 型(自有面板)不在此列;过期会话已隐藏。这满足“原生设备也在会话列表登记”,且离线原生设备照常显示(`devices` 留有 `last_seen`)。
- **吊销即清设备**:吊销网页会话(`DELETE /api/auth/sessions/{id}`)在删会话行后**连带删除其设备登记**(除非另有同名在用会话),再 Kick + 广播 presence → 被吊销设备即时从所有列表消失。原生客户端的“登出”改走 `DELETE /api/devices/{name}`(同样要求 step-up),删设备 + Kick;该设备若仍在线,会在下次请求经中间件重新登记(原生**即时**吊销需客户端配合,留后续)。
- **孤儿自动清除**:设备清扫器(每 1h)除按 `last_seen` TTL 清陈旧设备外,新增清除“`browser` 型且无存活 `web_session`”的孤儿(会话已撤 / 过期但设备行滞留);`DeleteOrphanBrowserDevices` 一条 SQL 完成,原生 / shortcut 设备无 `web_session` 属正常、仅受 TTL 清扫。即时路径(吊销)+ 周期路径(清扫)合起来让设备列表与会话列表持续对齐。
--- ---
## 5. 账号密码 / Passkey 登录(委派 provider ## 5. 账号密码 / Passkey 登录(委派 provider
@@ -196,7 +205,7 @@ CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires
## 6. 2FA(委派 + step-up 钩子,已实装) ## 6. 2FA(委派 + step-up 钩子,已实装)
- **默认**2FA 在 provider 完成(Casdoor TOTP / passkey-as-2FA),cdrop 不感知、不存密钥。 - **默认**2FA 在 provider 完成(Casdoor TOTP / passkey-as-2FA),cdrop 不感知、不存密钥。
- **step-up 钩子**(决策 C,默认关,`CDROP_STEP_UP_ENABLED`;新鲜度窗口 `CDROP_STEP_UP_MAX_AGE_SECONDS`,默认 300):开启后,批准扫码设备(`qr/approve`)这类敏感动作要求一次**现场再认证**。机制:前端先跑一次带 `prompt=login` / `max_age=0` 的新鲜 PKCE,把拿到的授权码 + verifier 交给 `qr/approve`;后端**就地**用它向 IdP 换 `id_token`JWKS 验签 + 校验 `sub` 等于调用者 + `auth_time` 在窗口内,方才放行(provider 若开了 2FA,即在这次 prompt=login 往返中强制)。失败返回 `403 step_up_required`。授权码一次性、不可重放;cdrop 不存任何第二因子,也不留 step-up 服务端状态——证明随这一次批准请求过期。 - **step-up 钩子**(决策 C,默认关,`CDROP_STEP_UP_ENABLED`;新鲜度窗口 `CDROP_STEP_UP_MAX_AGE_SECONDS`,默认 300):开启后,批准扫码设备(`qr/approve`)这类敏感动作要求一次**现场再认证**。机制:前端先跑一次带 `prompt=login` / `max_age=0` 的新鲜 PKCE,把拿到的授权码 + verifier 交给 `qr/approve`;后端**就地**用它向 IdP 换 `id_token`JWKS 验签 + 校验 `sub` 等于调用者,方才放行(provider 若开了 2FA,即在这次 prompt=login 往返中强制)。`auth_time` 为**可选**——OIDC 仅在 IdP 选择遵循 `max_age` 时才要求它,**Casdoor 不下发**;有则强制 `StepUpMaxAgeSeconds` 窗口,无则以「单次短寿授权码刚换出」作为新鲜度界(否则会因缺 `auth_time` 而恒拒、陷死循环,2026-06-21 实测修正)。失败返回 `403 step_up_required`。授权码一次性、不可重放;cdrop 不存任何第二因子,也不留 step-up 服务端状态——证明随这一次批准请求过期。
- `qr/request` 响应回带 `step_up` 标志,供批准页判断是否需先再认证。 - `qr/request` 响应回带 `step_up` 标志,供批准页判断是否需先再认证。
- passkey 作为“快速登录”的补充:陌生设备场景下扫码优于 passkey(passkey 设备绑定,陌生设备上没有),故 passkey 由 provider 承担、不进 cdrop 自建范围。 - passkey 作为“快速登录”的补充:陌生设备场景下扫码优于 passkey(passkey 设备绑定,陌生设备上没有),故 passkey 由 provider 承担、不进 cdrop 自建范围。
@@ -268,7 +277,7 @@ verify(token):
## 11. 安全要点 ## 11. 安全要点
- **`id_token` 必须 exchange 时验签**——自签 access token 后,原“每请求 JWKS 兜底”消失,OIDC 信任收敛到这唯一一刻。 - **`id_token` 必须 exchange 时验签**——自签 access token 后,原“每请求 JWKS 兜底”消失,OIDC 信任收敛到这唯一一刻。
- **自签 access token 短 TTL**(默认 900s,承载吊销时延上界;长效凭证只在 `web_sessions`,可吊销 - **吊销即时**`verifySelfToken` 每请求按 `sid``web_sessions` 行,删行即当场拒绝,被吊销设备下一次请求即失效(不依赖 TTL);前端据 401 + 续期失败回退登录页(§3.1)。自签 access token 短 TTL(默认 900s只是续期节律,长效凭证只在 `web_sessions` 行。
- **扫码三密钥分离**:QR 仅含批准信息,会话只投私有 `poll_secret` 持有者;偷拍 QR 双重失败。 - **扫码三密钥分离**:QR 仅含批准信息,会话只投私有 `poll_secret` 持有者;偷拍 QR 双重失败。
- **访客会话能力门**`scope=guest` 服务端强制受限(§3.2),不依赖前端自律。 - **访客会话能力门**`scope=guest` 服务端强制受限(§3.2),不依赖前端自律。
- **迁移标记受控**:跨源 `match_key` 关联仅在管理员显式开启时生效,非日常自动 link,规避 email 接管。 - **迁移标记受控**:跨源 `match_key` 关联仅在管理员显式开启时生效,非日常自动 link,规避 email 接管。
+4
View File
@@ -74,6 +74,10 @@ func Bootstrap(ctx context.Context, d *sql.DB) error {
"ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''"); err != nil { "ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''"); err != nil {
return fmt.Errorf("migrate web_sessions.granted_by: %w", err) return fmt.Errorf("migrate web_sessions.granted_by: %w", err)
} }
if err := ensureColumn(ctx, tx, "web_sessions", "stepped_up_at",
"ALTER TABLE web_sessions ADD COLUMN stepped_up_at INTEGER NOT NULL DEFAULT 0"); err != nil {
return fmt.Errorf("migrate web_sessions.stepped_up_at: %w", err)
}
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return fmt.Errorf("commit bootstrap: %w", err) return fmt.Errorf("commit bootstrap: %w", err)
} }
+23
View File
@@ -27,6 +27,29 @@ func (q *Queries) DeleteDevice(ctx context.Context, arg DeleteDeviceParams) (int
return result.RowsAffected() return result.RowsAffected()
} }
const deleteOrphanBrowserDevices = `-- name: DeleteOrphanBrowserDevices :execrows
DELETE FROM devices
WHERE type = 'browser'
AND NOT EXISTS (
SELECT 1 FROM web_sessions ws
WHERE ws.user_id = devices.user_id
AND ws.device_name = devices.name
AND ws.expires_at > ?
)
`
// DeleteOrphanBrowserDevices removes browser device rows with no live web_session
// (the session was revoked or expired), keeping the device list aligned with the
// session list. The ? is the current epoch second. Native (macos/windows/linux/ios)
// and shortcut devices are left alone: they legitimately keep no web_session.
func (q *Queries) DeleteOrphanBrowserDevices(ctx context.Context, expiresAt int64) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteOrphanBrowserDevices, expiresAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteStaleDevices = `-- name: DeleteStaleDevices :exec const deleteStaleDevices = `-- name: DeleteStaleDevices :exec
DELETE FROM devices DELETE FROM devices
WHERE last_seen < ? WHERE last_seen < ?
+4 -1
View File
@@ -73,7 +73,10 @@ CREATE TABLE IF NOT EXISTS web_sessions (
-- 但不能改账号、不能再批准别的设备、不能签发长效 token(路由层 requireFullSession 守门)。 -- 但不能改账号、不能再批准别的设备、不能签发长效 token(路由层 requireFullSession 守门)。
scope TEXT NOT NULL DEFAULT 'full', scope TEXT NOT NULL DEFAULT 'full',
-- granted_by=扫码批准者的 session id,供审计与「吊销我批准过的借用设备」连带吊销。 -- granted_by=扫码批准者的 session id,供审计与「吊销我批准过的借用设备」连带吊销。
granted_by TEXT NOT NULL DEFAULT '' granted_by TEXT NOT NULL DEFAULT '',
-- stepped_up_at=本会话最近一次通过 step-up 再认证的 Unix 秒(AUTH.md §6)。在
-- StepUpMaxAgeSeconds 窗口内,敏感动作不再重复要求再认证(避免每次都弹再登录)。
stepped_up_at INTEGER NOT NULL DEFAULT 0
); );
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at); CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at);
+1
View File
@@ -101,4 +101,5 @@ type WebSession struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Scope string `json:"scope"` Scope string `json:"scope"`
GrantedBy string `json:"granted_by"` GrantedBy string `json:"granted_by"`
SteppedUpAt int64 `json:"stepped_up_at"`
} }
+14
View File
@@ -15,6 +15,20 @@ ORDER BY name;
DELETE FROM devices DELETE FROM devices
WHERE last_seen < ?; WHERE last_seen < ?;
-- DeleteOrphanBrowserDevices removes browser device rows with no live web_session
-- (the session was revoked or expired), keeping the device list aligned with the
-- session list. The ? is the current epoch second. Native (macos/windows/linux/ios)
-- and shortcut devices are left alone: they legitimately keep no web_session.
-- name: DeleteOrphanBrowserDevices :execrows
DELETE FROM devices
WHERE type = 'browser'
AND NOT EXISTS (
SELECT 1 FROM web_sessions ws
WHERE ws.user_id = devices.user_id
AND ws.device_name = devices.name
AND ws.expires_at > ?
);
-- name: DeleteDevice :execrows -- name: DeleteDevice :execrows
DELETE FROM devices DELETE FROM devices
WHERE user_id = ? AND name = ?; WHERE user_id = ? AND name = ?;
+8 -1
View File
@@ -9,10 +9,17 @@ INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, c
VALUES (?, ?, ?, ?, ?, ?, ?, ?); VALUES (?, ?, ?, ?, ?, ?, ?, ?);
-- name: GetWebSession :one -- name: GetWebSession :one
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by, stepped_up_at
FROM web_sessions FROM web_sessions
WHERE id = ?; WHERE id = ?;
-- SetSessionSteppedUp stamps the moment this session last passed step-up re-auth;
-- sensitive actions within the freshness window then skip re-auth (AUTH.md 6).
-- name: SetSessionSteppedUp :exec
UPDATE web_sessions
SET stepped_up_at = ?
WHERE id = ?;
-- name: RotateWebSession :exec -- name: RotateWebSession :exec
UPDATE web_sessions UPDATE web_sessions
SET refresh_token = ?, last_used_at = ?, expires_at = ? SET refresh_token = ?, last_used_at = ?, expires_at = ?
+20 -1
View File
@@ -126,7 +126,7 @@ func (q *Queries) DeleteWebSessionForUser(ctx context.Context, arg DeleteWebSess
} }
const getWebSession = `-- name: GetWebSession :one const getWebSession = `-- name: GetWebSession :one
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by, stepped_up_at
FROM web_sessions FROM web_sessions
WHERE id = ? WHERE id = ?
` `
@@ -146,6 +146,7 @@ func (q *Queries) GetWebSession(ctx context.Context, id string) (WebSession, err
&i.Kind, &i.Kind,
&i.Scope, &i.Scope,
&i.GrantedBy, &i.GrantedBy,
&i.SteppedUpAt,
) )
return i, err return i, err
} }
@@ -230,6 +231,24 @@ func (q *Queries) RotateWebSession(ctx context.Context, arg RotateWebSessionPara
return err return err
} }
const setSessionSteppedUp = `-- name: SetSessionSteppedUp :exec
UPDATE web_sessions
SET stepped_up_at = ?
WHERE id = ?
`
type SetSessionSteppedUpParams struct {
SteppedUpAt int64 `json:"stepped_up_at"`
ID string `json:"id"`
}
// SetSessionSteppedUp stamps the moment this session last passed step-up re-auth;
// sensitive actions within the freshness window then skip re-auth (AUTH.md 6).
func (q *Queries) SetSessionSteppedUp(ctx context.Context, arg SetSessionSteppedUpParams) error {
_, err := q.db.ExecContext(ctx, setSessionSteppedUp, arg.SteppedUpAt, arg.ID)
return err
}
const setWebSessionDevice = `-- name: SetWebSessionDevice :exec const setWebSessionDevice = `-- name: SetWebSessionDevice :exec
UPDATE web_sessions UPDATE web_sessions
SET device_name = ? SET device_name = ?
+63 -21
View File
@@ -19,7 +19,9 @@ import (
// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend // PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC // proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC
// provider; the access_token still flows back to the browser unchanged. // provider. The browser is then handed a cdrop self-signed access token (sid-bound
// to its web_sessions row), not the IdP's RS256 token, so OIDC web sessions verify
// statefully and revoke immediately — unified with scan-login (AUTH.md §5).
type authConfigResp struct { type authConfigResp struct {
AuthMode string `json:"auth_mode"` AuthMode string `json:"auth_mode"`
@@ -120,33 +122,58 @@ func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
// Refresh the thin accounts row (display name / avatar / roles / match_key). // Refresh the thin accounts row (display name / avatar / roles / match_key).
s.upsertAccount(r.Context(), identity) s.upsertAccount(r.Context(), identity)
// Stash the refresh_token server-side and hand the browser an opaque, // By default the browser holds a cdrop self-signed access token (sid-bound),
// HttpOnly cookie instead. Guarded so a deploy without the encryption key (or // not the IdP's RS256 token — so the OIDC web session is verified statefully on
// an IdP that returned no refresh_token) still logs the user in — they just // every request (verifySelfToken → web_sessions lookup) and revoking the row
// don't get passwordless re-login and re-auth on the next access-token expiry. // logs this device out on its very next call, exactly like scan-login. We mint
// it only after stashing the refresh_token server-side (encrypted, behind an
// HttpOnly cookie) so the session is durable. The IdP RS256 token stays the
// fallback for degraded configs (no encryption key / no refresh_token / an
// id_token that fails verification) — it is still independently verified per
// request via JWKS (verifyRS256), and the next refresh upgrades the device to
// the unified self-token model. (AUTH.md §5; desktop keeps its RS256 token.)
accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn
if len(s.sessionKey) > 0 && tr.RefreshToken != "" { if len(s.sessionKey) > 0 && tr.RefreshToken != "" {
if err := s.createWebSession(r, w, user.ID, tr.RefreshToken); err != nil { id, cerr := s.createWebSession(r, w, user.ID, tr.RefreshToken)
slog.Error("create web session failed", "err", err) if cerr != nil {
slog.Error("create web session failed", "err", cerr)
} else {
// The id_token is the SOLE trust anchor for the minted self token (the
// IdP RS256 token no longer reaches the browser to be re-verified), so
// its signature MUST validate here and its subject must match. On
// failure we keep the durable session but hand back the per-request-
// verified RS256 token; the next refresh upgrades this device to the
// self-token model. (Should not happen with a healthy IdP — warn ops.)
sub, _, verr := s.auth.VerifyIDToken(r.Context(), tr.IDToken)
if verr != nil || sub != user.ID {
slog.Warn("oidc id_token verification failed; using RS256 fallback",
"err", verr, "sub_match", sub == user.ID)
} else if tok, ein, merr := s.mintSessionToken(user.ID, id, "full"); merr != nil {
slog.Error("mint session token failed", "err", merr)
} else {
accessToken, expiresIn = tok, ein
}
} }
} }
writeJSON(w, http.StatusOK, exchangeResp{ writeJSON(w, http.StatusOK, exchangeResp{
AccessToken: tr.AccessToken, AccessToken: accessToken,
ExpiresIn: tr.ExpiresIn, ExpiresIn: expiresIn,
User: user, User: user,
}) })
} }
// createWebSession encrypts the refresh_token, persists a new session row, and // createWebSession encrypts the refresh_token, persists a new session row, and
// sets the session cookie on the response. // sets the session cookie on the response. Returns the session row id so the
func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) error { // caller can bind a cdrop self-signed access token to it (sid).
func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) (string, error) {
raw, id, err := newSessionToken() raw, id, err := newSessionToken()
if err != nil { if err != nil {
return err return "", err
} }
enc, err := s.encryptRefresh(refreshToken) enc, err := s.encryptRefresh(refreshToken)
if err != nil { if err != nil {
return err return "", err
} }
now := time.Now() now := time.Now()
if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{ if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{
@@ -159,10 +186,10 @@ func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID
LastUsedAt: now.Unix(), LastUsedAt: now.Unix(),
ExpiresAt: now.Add(webSessionTTL).Unix(), ExpiresAt: now.Add(webSessionTTL).Unix(),
}); err != nil { }); err != nil {
return err return "", err
} }
setSessionCookie(w, raw) setSessionCookie(w, raw)
return nil return id, nil
} }
// refreshResp is what the browser sees: a fresh access_token plus the identity // refreshResp is what the browser sees: a fresh access_token plus the identity
@@ -282,9 +309,23 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
} }
setSessionCookie(w, c.Value) setSessionCookie(w, c.Value)
// Hand back a cdrop self-signed access token (sid-bound), not the IdP RS256
// token: the browser holds one uniform token type and revoking the session row
// logs this device out on its next request. The IdP round-trip above still ran
// — it rotated the refresh_token and surfaced IdP-side revocation (a rejected
// refresh deletes the session above) — its access_token simply no longer ships.
// Mint from the row's canonical user_id; degrade to the IdP token only if the
// HS256 key is unconfigured. (AUTH.md §5.)
accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn
if tok, ein, mErr := s.mintSessionToken(sess.UserID, id, "full"); mErr != nil {
slog.Error("mint session token failed", "err", mErr)
} else {
accessToken, expiresIn = tok, ein
}
writeJSON(w, http.StatusOK, refreshResp{ writeJSON(w, http.StatusOK, refreshResp{
AccessToken: tr.AccessToken, AccessToken: accessToken,
ExpiresIn: tr.ExpiresIn, ExpiresIn: expiresIn,
User: user, User: user,
DeviceName: sess.DeviceName, DeviceName: sess.DeviceName,
}) })
@@ -369,10 +410,11 @@ type tokenIdentity struct {
// extractIdentity parses {sub, preferred_username | name | email, avatar | picture, // extractIdentity parses {sub, preferred_username | name | email, avatar | picture,
// matchClaim, groups} from the id_token if available, else the access_token. The // matchClaim, groups} from the id_token if available, else the access_token. The
// signature is NOT verified here — for OIDC sessions the auth middleware verifies // signature is NOT verified here — callers verify separately around it: at login
// the access_token on every API call via JWKS, so any tamper surfaces there. (Once // (handleAuthExchange) the id_token signature is checked via VerifyIDToken before
// OIDC sessions migrate to cdrop self-signed access tokens, this becomes the only // the extracted subject is trusted to mint a self token; at refresh the IdP just
// trust point and MUST then verify the id_token signature — AUTH.md §5.) // authenticated the refresh_token over a TLS back-channel, so the token it returns
// is trusted by transport. Either way the extracted fields are sound. (AUTH.md §5.)
func extractIdentity(idToken, accessToken, matchClaim string) (tokenIdentity, error) { func extractIdentity(idToken, accessToken, matchClaim string) (tokenIdentity, error) {
pick := idToken pick := idToken
if pick == "" { if pick == "" {
+8
View File
@@ -44,7 +44,15 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
// handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播 // handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播
// 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则 // 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则
// 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。 // 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。
//
// 这是登录会话列表里「原生客户端(桌面 / iOS)」一栏的登出路径——它们用 IdP 令牌、
// 不入 web_sessions,故按设备名吊销(AUTH.md §4)。注销设备属敏感动作,与吊销网页
// 会话同口径要求最近一次 step-up403 step_up_required,前端据此发起再认证)。
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context()) claims, _ := jwtauth.ClaimsFromContext(r.Context())
name := chi.URLParam(r, "name") name := chi.URLParam(r, "name")
if name == "" { if name == "" {
+67 -9
View File
@@ -285,7 +285,9 @@ func (s *Server) handleQRRequest(w http.ResponseWriter, r *http.Request) {
RequestIP: req.RequestIp, RequestIP: req.RequestIp,
ExpiresAt: req.ExpiresAt, ExpiresAt: req.ExpiresAt,
Status: req.Status, Status: req.Status,
StepUp: s.cfg.StepUpEnabled, // Tell the approver UI to re-auth only if step-up is on AND this session
// hasn't recently stepped up (within the window) — no repeated prompts (#3).
StepUp: s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r),
}) })
} }
@@ -309,10 +311,14 @@ func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) {
// Step-up: approving a new device into your account is sensitive. When enabled, // Step-up: approving a new device into your account is sensitive. When enabled,
// require a fresh prompt=login re-auth (the provider's 2FA, if configured, is // require a fresh prompt=login re-auth (the provider's 2FA, if configured, is
// enforced during that exchange) within the freshness window (AUTH.md §6). // enforced during that exchange) — UNLESS this session already stepped up within
if s.cfg.StepUpEnabled && !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) { // the freshness window, so the same session isn't re-prompted repeatedly (#3).
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"}) if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
return if !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
return
}
s.recordStepUp(r)
} }
scope := "guest" // this milestone grants restricted guest sessions only scope := "guest" // this milestone grants restricted guest sessions only
@@ -367,6 +373,50 @@ func (s *Server) handleQRDeny(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// sessionFromCookie resolves the caller's web_session row from the cdrop_session
// cookie. The scan-approval endpoints live under /api/auth/*, which the cookie's
// Path covers, so both OIDC and self sessions can be located here.
func (s *Server) sessionFromCookie(r *http.Request) (db.WebSession, bool) {
c, err := r.Cookie(sessionCookieName)
if err != nil || c.Value == "" {
return db.WebSession{}, false
}
sess, err := s.queries.GetWebSession(r.Context(), sessionID(c.Value))
if err != nil {
return db.WebSession{}, false
}
return sess, true
}
// sessionRecentlySteppedUp reports whether the caller's session passed step-up
// within StepUpMaxAgeSeconds — so a sensitive action skips re-auth and the same
// session isn't re-prompted repeatedly (#3, AUTH.md §6).
func (s *Server) sessionRecentlySteppedUp(r *http.Request) bool {
sess, ok := s.sessionFromCookie(r)
if !ok || sess.SteppedUpAt == 0 {
return false
}
window := int64(s.cfg.StepUpMaxAgeSeconds)
if window <= 0 {
window = 300
}
return time.Now().Unix()-sess.SteppedUpAt <= window
}
// recordStepUp stamps the caller's session as freshly stepped-up.
func (s *Server) recordStepUp(r *http.Request) {
sess, ok := s.sessionFromCookie(r)
if !ok {
return
}
if err := s.queries.SetSessionSteppedUp(r.Context(), db.SetSessionSteppedUpParams{
SteppedUpAt: time.Now().Unix(),
ID: sess.ID,
}); err != nil {
slog.Warn("record step-up failed", "err", err)
}
}
// verifyStepUp exchanges a fresh prompt=login authorization code and confirms it // verifyStepUp exchanges a fresh prompt=login authorization code and confirms it
// proves a recent interactive re-authentication by the same user: the id_token's // proves a recent interactive re-authentication by the same user: the id_token's
// signature validates (JWKS), its sub matches the caller, and its auth_time is // signature validates (JWKS), its sub matches the caller, and its auth_time is
@@ -401,11 +451,19 @@ func (s *Server) verifyStepUp(r *http.Request, userID, code, verifier string) bo
if sub != userID { if sub != userID {
return false return false
} }
maxAge := int64(s.cfg.StepUpMaxAgeSeconds) // Enforce the freshness window only when the IdP supplied auth_time (Casdoor
if maxAge <= 0 { // omits it). When absent, the fresh single-use prompt=login code — exchanged
maxAge = 300 // once, just now — is itself the bound on how recent the re-auth was.
if authTime > 0 {
maxAge := int64(s.cfg.StepUpMaxAgeSeconds)
if maxAge <= 0 {
maxAge = 300
}
if time.Now().Unix()-authTime > maxAge {
return false
}
} }
return time.Now().Unix()-authTime <= maxAge return true
} }
// lookupQRRequest fetches a request and verifies the approval_code in constant // lookupQRRequest fetches a request and verifies the approval_code in constant
+294 -1
View File
@@ -12,11 +12,13 @@ import (
"testing" "testing"
"time" "time"
"github.com/go-chi/chi/v5"
"github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt" "github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/config" "commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth" "commilitia.net/cdrop/internal/jwtauth"
) )
@@ -35,6 +37,7 @@ func newQRTestServer(t *testing.T) *Server {
if err := db.Bootstrap(context.Background(), conn); err != nil { if err := db.Bootstrap(context.Background(), conn); err != nil {
t.Fatalf("bootstrap: %v", err) t.Fatalf("bootstrap: %v", err)
} }
q := db.New(conn)
return &Server{ return &Server{
cfg: &config.Config{ cfg: &config.Config{
AuthMode: "prod", SessionSecret: qrTestSecret, AuthMode: "prod", SessionSecret: qrTestSecret,
@@ -42,7 +45,8 @@ func newQRTestServer(t *testing.T) *Server {
QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168, QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168,
SessionTokenTTLSeconds: 900, SessionTokenTTLSeconds: 900,
}, },
queries: db.New(conn), queries: q,
hub: hub.New(q),
sessionKey: deriveSessionKey(qrTestSecret), sessionKey: deriveSessionKey(qrTestSecret),
sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret), sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret),
siteOrigin: "https://drop.example.net", siteOrigin: "https://drop.example.net",
@@ -222,6 +226,106 @@ func TestQRFlow_PendingLongPoll(t *testing.T) {
} }
} }
// noopAuthStore satisfies jwtauth.Store for middleware wiring in tests (no device
// upsert happens without an X-Device-Name header).
type noopAuthStore struct{}
func (noopAuthStore) UpsertDevice(context.Context, db.UpsertDeviceParams) error { return nil }
func (noopAuthStore) GetShortcutToken(context.Context, string) (db.ShortcutToken, error) {
return db.ShortcutToken{}, fmt.Errorf("none")
}
func (noopAuthStore) TouchShortcutTokenUsed(context.Context, db.TouchShortcutTokenUsedParams) error {
return nil
}
func (noopAuthStore) GetWebSession(_ context.Context, id string) (db.WebSession, error) {
return db.WebSession{ID: id}, nil // session always live in these tests
}
// End-to-end through the REAL chain (auth.Middleware → requireFullSession): a
// guest session token is rejected 403 on an account-management route, a full one
// passes. This is the backend enforcement behind AUTH.md §3.2 (guest can't approve
// devices / remove devices / mint tokens).
func TestRequireFullSession_GuestBlockedFullPasses(t *testing.T) {
secret := "test-session-secret-at-least-32-bytes-ok"
srv := &Server{
cfg: &config.Config{SessionTokenTTLSeconds: 900},
sessionTokenKey: jwtauth.DeriveSessionTokenKey(secret),
}
guest, _, err := srv.mintSessionToken("u", "sid", "guest")
if err != nil {
t.Fatalf("mint guest: %v", err)
}
full, _, err := srv.mintSessionToken("u", "sid", "full")
if err != nil {
t.Fatalf("mint full: %v", err)
}
a := jwtauth.New(&config.Config{AuthMode: "prod", SessionSecret: secret}, noopAuthStore{})
handler := a.Middleware(requireFullSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})))
probe := func(tok string) int {
r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", nil)
r.Header.Set("Authorization", "Bearer "+tok)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w.Code
}
if c := probe(guest); c != http.StatusForbidden {
t.Errorf("guest token on requireFullSession route: got %d, want 403", c)
}
if c := probe(full); c != http.StatusOK {
t.Errorf("full token on requireFullSession route: got %d, want 200", c)
}
}
// Revoking a session is sensitive: with step-up enabled it is refused (403
// step_up_required) until the caller's session has recently stepped up, then it
// proceeds (here to 400 missing-id, i.e. past the gate). Step-up state is keyed on
// the cookie session, so it is per-device (AUTH.md §1/§6).
func TestSessionRevoke_RequiresStepUp(t *testing.T) {
s := newQRTestServer(t)
s.cfg.StepUpEnabled = true
s.cfg.StepUpMaxAgeSeconds = 300
raw, id, err := newSessionToken()
if err != nil {
t.Fatalf("token: %v", err)
}
now := time.Now().Unix()
if err := s.queries.CreateSelfSession(context.Background(), db.CreateSelfSessionParams{
ID: id, UserID: "u", DeviceName: "", UserAgent: "", Kind: "oidc", Scope: "full",
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
}); err != nil {
t.Fatalf("create session: %v", err)
}
revoke := func() int {
r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/x", nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: raw})
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(),
&jwtauth.Claims{UserID: "u", SessionScope: "full"}))
w := httptest.NewRecorder()
s.handleSessionRevoke(w, r)
return w.Code
}
// Not stepped up → blocked before any deletion.
if c := revoke(); c != http.StatusForbidden {
t.Fatalf("revoke without step-up: got %d, want 403", c)
}
// Record step-up on this (cookie) session → gate now passes (400 = missing id,
// reached past the step-up check).
if err := s.queries.SetSessionSteppedUp(context.Background(), db.SetSessionSteppedUpParams{
SteppedUpAt: now, ID: id,
}); err != nil {
t.Fatalf("set stepped up: %v", err)
}
if c := revoke(); c == http.StatusForbidden {
t.Errorf("revoke after step-up still 403; gate did not honour stepped_up_at")
}
}
// selfTokenScope verifies a self-signed session token under the test's session // selfTokenScope verifies a self-signed session token under the test's session
// key (proving the signature) and returns its scope claim. // key (proving the signature) and returns its scope claim.
func selfTokenScope(t *testing.T, token string) string { func selfTokenScope(t *testing.T, token string) string {
@@ -241,3 +345,192 @@ func selfTokenScope(t *testing.T, token string) string {
scope, _ := custom["scope"].(string) scope, _ := custom["scope"].(string)
return scope return scope
} }
// revokeByID drives handleSessionRevoke with the chi {id} param and full claims,
// step-up off (the gate is exercised separately).
func revokeByID(s *Server, id, userID string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/"+id, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", id)
ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: userID, SessionScope: "full"})
w := httptest.NewRecorder()
s.handleSessionRevoke(w, r.WithContext(ctx))
return w
}
// Revoking a web session removes its device registration too, so a revoked device
// disappears from the device list at once (the user can no longer remove devices
// by hand). AUTH.md §4.
func TestSessionRevoke_DeletesDevice(t *testing.T) {
s := newQRTestServer(t) // step-up off by default
ctx := context.Background()
now := time.Now().Unix()
_, id, err := newSessionToken()
if err != nil {
t.Fatalf("token: %v", err)
}
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
ID: id, UserID: "u", DeviceName: "Laptop", UserAgent: "", Kind: "oidc", Scope: "full",
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
}); err != nil {
t.Fatalf("create session: %v", err)
}
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
UserID: "u", Name: "Laptop", Type: "browser", LastSeen: now,
}); err != nil {
t.Fatalf("upsert device: %v", err)
}
if w := revokeByID(s, id, "u"); w.Code != http.StatusNoContent {
t.Fatalf("revoke: got %d %s, want 204", w.Code, w.Body.String())
}
if _, err := s.queries.GetWebSession(ctx, id); err == nil {
t.Errorf("session still present after revoke")
}
devs, _ := s.queries.ListDevicesByUser(ctx, "u")
for _, d := range devs {
if d.Name == "Laptop" {
t.Errorf("device 'Laptop' not deleted on session revoke")
}
}
}
// The session list unifies web_sessions with native client devices (desktop / iOS)
// that have no web_session, while never double-listing a device that already has a
// session and excluding shortcut-token devices. AUTH.md §4.
func TestSessionsList_IncludesNativeDevices(t *testing.T) {
s := newQRTestServer(t)
ctx := context.Background()
now := time.Now().Unix()
_, id, err := newSessionToken()
if err != nil {
t.Fatalf("token: %v", err)
}
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
ID: id, UserID: "u", DeviceName: "Web", UserAgent: "", Kind: "oidc", Scope: "full",
GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
}); err != nil {
t.Fatalf("create session: %v", err)
}
for _, d := range []struct{ name, typ string }{
{"Web", "browser"}, // has a web_session — must not be double-listed
{"Desk", "macos"}, // native, no session — must appear as native
{"iShortcut", "shortcut"}, // scoped token — excluded from session list
} {
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
UserID: "u", Name: d.name, Type: d.typ, LastSeen: now,
}); err != nil {
t.Fatalf("upsert device %s: %v", d.name, err)
}
}
r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil)
r = r.WithContext(jwtauth.ContextWithClaims(r.Context(),
&jwtauth.Claims{UserID: "u", SessionScope: "full"}))
w := httptest.NewRecorder()
s.handleSessionsList(w, r)
if w.Code != http.StatusOK {
t.Fatalf("sessions list: got %d %s", w.Code, w.Body.String())
}
var resp struct {
Sessions []sessionView `json:"sessions"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
var web, webNativeDup, desk, shortcut bool
for _, v := range resp.Sessions {
switch {
case v.DeviceName == "Web" && !v.Native && v.Kind == "oidc":
web = true
case v.DeviceName == "Web" && v.Native:
webNativeDup = true
case v.DeviceName == "Desk" && v.Native && v.Kind == "macos":
desk = true
case v.DeviceName == "iShortcut":
shortcut = true
}
}
if !web {
t.Errorf("web session for 'Web' missing")
}
if webNativeDup {
t.Errorf("'Web' double-listed as a native device")
}
if !desk {
t.Errorf("native device 'Desk' missing from session list")
}
if shortcut {
t.Errorf("shortcut device leaked into session list")
}
}
// The sweeper drops browser devices whose web_session is gone (orphans) while
// keeping browser devices that still have a live session and all native devices.
func TestDeleteOrphanBrowserDevices(t *testing.T) {
s := newQRTestServer(t)
ctx := context.Background()
now := time.Now().Unix()
// Browser "Kept" has a live session; browser "Orphan" has none; native "Desk"
// (macos) legitimately has no session and must survive.
_, id, err := newSessionToken()
if err != nil {
t.Fatalf("token: %v", err)
}
if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{
ID: id, UserID: "u", DeviceName: "Kept", Kind: "oidc", Scope: "full",
CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600,
}); err != nil {
t.Fatalf("create session: %v", err)
}
for _, d := range []struct{ name, typ string }{
{"Kept", "browser"}, {"Orphan", "browser"}, {"Desk", "macos"},
} {
if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{
UserID: "u", Name: d.name, Type: d.typ, LastSeen: now,
}); err != nil {
t.Fatalf("upsert %s: %v", d.name, err)
}
}
if _, err := s.queries.DeleteOrphanBrowserDevices(ctx, now); err != nil {
t.Fatalf("sweep: %v", err)
}
devs, _ := s.queries.ListDevicesByUser(ctx, "u")
got := map[string]bool{}
for _, d := range devs {
got[d.Name] = true
}
if !got["Kept"] {
t.Errorf("browser 'Kept' with a live session was wrongly swept")
}
if got["Orphan"] {
t.Errorf("orphan browser 'Orphan' was not swept")
}
if !got["Desk"] {
t.Errorf("native 'Desk' was wrongly swept (it keeps no web_session)")
}
}
// Removing a native device (the session list's logout path for desktop / iOS) is
// sensitive and requires a recent step-up, mirroring web-session revocation.
func TestDeleteDevice_RequiresStepUp(t *testing.T) {
s := newQRTestServer(t)
s.cfg.StepUpEnabled = true
s.cfg.StepUpMaxAgeSeconds = 300
r := httptest.NewRequest(http.MethodDelete, "/api/devices/Desk", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("name", "Desk")
ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx)
ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: "u", SessionScope: "full"})
w := httptest.NewRecorder()
s.handleDeleteDevice(w, r.WithContext(ctx))
if w.Code != http.StatusForbidden {
t.Fatalf("delete device without step-up: got %d, want 403", w.Code)
}
}
+16
View File
@@ -176,6 +176,13 @@ func (s *Server) routes() {
r.Get("/auth/qr/request", s.handleQRRequest) r.Get("/auth/qr/request", s.handleQRRequest)
r.Post("/auth/qr/approve", s.handleQRApprove) r.Post("/auth/qr/approve", s.handleQRApprove)
r.Post("/auth/qr/deny", s.handleQRDeny) r.Post("/auth/qr/deny", s.handleQRDeny)
// Session management: list logins with their permission level,
// really revoke one (logout), and the standalone step-up the
// revoke flow re-uses. All under /api/auth so the session cookie
// (Path=/api/auth) is available for step-up state.
r.Post("/auth/stepup", s.handleStepUp)
r.Get("/auth/sessions", s.handleSessionsList)
r.Delete("/auth/sessions/{id}", s.handleSessionRevoke)
}) })
r.Route("/transfer", func(r chi.Router) { r.Route("/transfer", func(r chi.Router) {
@@ -219,6 +226,10 @@ type meResp struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
Groups []string `json:"groups"` Groups []string `json:"groups"`
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
// Scope is the session's capability level: "guest" (scan-login borrow,
// capability-limited) or "full" (normal login). The UI hides account-management
// affordances on guest sessions (AUTH.md §3.2).
Scope string `json:"scope"`
} }
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
@@ -228,10 +239,15 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
return return
} }
device, _ := jwtauth.DeviceNameFromContext(r.Context()) device, _ := jwtauth.DeviceNameFromContext(r.Context())
scope := "full"
if claims.Guest() {
scope = "guest"
}
writeJSON(w, http.StatusOK, meResp{ writeJSON(w, http.StatusOK, meResp{
UserID: claims.UserID, UserID: claims.UserID,
Groups: claims.Groups, Groups: claims.Groups,
DeviceName: device, DeviceName: device,
Scope: scope,
}) })
} }
+218
View File
@@ -0,0 +1,218 @@
package httpapi
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
// Session management (AUTH.md §1, §6): list the caller's logins with their
// capability level, and really revoke one (delete the row → its access token can
// no longer refresh and dies within its short TTL). Revocation is a sensitive
// action, so it requires a recent step-up. A standalone step-up endpoint lets the
// UI establish that re-auth once (per session/device) and reuse it across actions
// within the window — the same session isn't re-prompted repeatedly (#3).
type stepUpReq struct {
Code string `json:"code"`
Verifier string `json:"verifier"`
}
// handleStepUp exchanges a fresh prompt=login code and, on success, stamps the
// caller's session as stepped-up (per-session, cookie-keyed). Sensitive actions
// within StepUpMaxAgeSeconds then skip re-auth. 403 on failure; 204 when step-up
// is disabled (nothing to establish).
func (s *Server) handleStepUp(w http.ResponseWriter, r *http.Request) {
if !s.cfg.StepUpEnabled {
w.WriteHeader(http.StatusNoContent)
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
var body stepUpReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if !s.verifyStepUp(r, claims.UserID, body.Code, body.Verifier) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
return
}
s.recordStepUp(r)
w.WriteHeader(http.StatusNoContent)
}
type sessionView struct {
ID string `json:"id"`
DeviceName string `json:"device_name"`
Kind string `json:"kind"` // oidc | self | guest | macos | windows | linux | ios
Scope string `json:"scope"` // full | guest
Current bool `json:"current"`
Online bool `json:"online"` // device currently connected (SSE) — UI sorts online first
// Native marks a device that authenticates with an IdP token and keeps no
// web_session row (desktop / iOS). It is surfaced from the devices registry so
// the session list covers every device; its "logout" routes to the device
// endpoint rather than the web-session one.
Native bool `json:"native"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
}
// isNativeDeviceType reports whether a device type is a real native client (one
// that authenticates via the IdP and keeps no web_session). browser devices are
// expected to carry a web_session; "shortcut" tokens have their own panel.
func isNativeDeviceType(t string) bool {
switch t {
case "macos", "windows", "linux", "ios":
return true
default:
return false
}
}
// handleSessionsList returns the caller's login sessions with their scope, so the
// UI can show the permission level (完整 / 受限访客) and offer real logout. The list
// is unified: web_sessions (browser / scan-login) PLUS native client devices
// (desktop / iOS) that have no web_session of their own — so every logged-in
// device appears in one place and orphaned device rows fall away (AUTH.md §4).
func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) {
claims, _ := jwtauth.ClaimsFromContext(r.Context())
now := time.Now().Unix()
rows, err := s.queries.ListWebSessionsByUser(r.Context(), claims.UserID)
if err != nil {
slog.Error("list sessions failed", "err", err, "user", claims.UserID)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
current := ""
if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
current = sessionID(c.Value)
}
// Cookieless callers (native apps) can't match by cookie; fall back to their
// own device name to mark the "current" row.
callerDevice, _ := jwtauth.DeviceNameFromContext(r.Context())
out := make([]sessionView, 0, len(rows))
named := make(map[string]struct{}, len(rows))
for _, row := range rows {
if row.ExpiresAt < now {
continue // hide expired sessions (reaped separately)
}
if row.DeviceName != "" {
named[row.DeviceName] = struct{}{}
}
out = append(out, sessionView{
ID: row.ID,
DeviceName: row.DeviceName,
Kind: row.Kind,
Scope: row.Scope,
Current: row.ID == current || (current == "" && row.DeviceName != "" && row.DeviceName == callerDevice),
Online: row.DeviceName != "" && s.hub.Online(claims.UserID, row.DeviceName),
CreatedAt: row.CreatedAt,
LastUsedAt: row.LastUsedAt,
ExpiresAt: row.ExpiresAt,
})
}
// Native clients keep no web_session — surface them from the devices registry,
// skipping any whose name already has a web_session (no double-listing).
if devs, derr := s.queries.ListDevicesByUser(r.Context(), claims.UserID); derr == nil {
for _, d := range devs {
if !isNativeDeviceType(d.Type) {
continue
}
if _, ok := named[d.Name]; ok {
continue
}
out = append(out, sessionView{
ID: "device:" + d.Name,
DeviceName: d.Name,
Kind: d.Type,
Scope: "full",
Native: true,
Current: current == "" && d.Name != "" && d.Name == callerDevice,
Online: s.hub.Online(claims.UserID, d.Name),
CreatedAt: d.LastSeen,
LastUsedAt: d.LastSeen,
ExpiresAt: 0,
})
}
}
writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
}
// deviceNameHasLiveSession reports whether the user still has a non-expired
// web_session bound to deviceName — used to avoid deleting a device that another
// live session (e.g. a re-login that left the old row) still depends on.
func (s *Server) deviceNameHasLiveSession(ctx context.Context, userID, deviceName string) bool {
rows, err := s.queries.ListWebSessionsByUser(ctx, userID)
if err != nil {
return false
}
now := time.Now().Unix()
for _, ws := range rows {
if ws.DeviceName == deviceName && ws.ExpiresAt >= now {
return true
}
}
return false
}
// handleSessionRevoke deletes a session row (scoped to its owner), really
// invalidating that login. Requires a recent step-up (403 step_up_required
// otherwise). Kicks the device's live SSE so it drops immediately.
func (s *Server) handleSessionRevoke(w http.ResponseWriter, r *http.Request) {
if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"})
return
}
claims, _ := jwtauth.ClaimsFromContext(r.Context())
id := chi.URLParam(r, "id")
if id == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"})
return
}
// Read the device name before deletion so we can kick its SSE.
deviceName := ""
if sess, err := s.queries.GetWebSession(r.Context(), id); err == nil && sess.UserID == claims.UserID {
deviceName = sess.DeviceName
}
n, err := s.queries.DeleteWebSessionForUser(r.Context(), db.DeleteWebSessionForUserParams{
ID: id,
UserID: claims.UserID,
})
if err != nil {
slog.Error("revoke session failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
return
}
if n == 0 {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
if deviceName != "" {
// Drop the device registration too, so a revoked device disappears from the
// device list at once (we no longer remove devices by hand) — unless another
// live session still uses this name.
if !s.deviceNameHasLiveSession(r.Context(), claims.UserID, deviceName) {
if _, derr := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{
UserID: claims.UserID,
Name: deviceName,
}); derr != nil {
slog.Warn("delete device on session revoke failed", "err", derr)
}
}
s.hub.Kick(claims.UserID, deviceName)
s.hub.PublishPresence(r.Context(), claims.UserID)
}
w.WriteHeader(http.StatusNoContent)
}
+13 -5
View File
@@ -11,10 +11,14 @@ import (
// DeviceSweepInterval is how often the device sweeper wakes up. // DeviceSweepInterval is how often the device sweeper wakes up.
const DeviceSweepInterval = 1 * time.Hour const DeviceSweepInterval = 1 * time.Hour
// RunDeviceSweeper deletes devices whose last_seen is older than ttl. // RunDeviceSweeper prunes the devices table on a timer so it stays aligned with
// Per user requirement: device registration must not outlive the longest // the session list (AUTH.md §4): it drops (1) devices whose last_seen is older
// valid refresh_token (brief §2: 196h sliding window). Anything ≤ TTL is fine; // than ttl — registration must not outlive the longest valid refresh_token
// 0 disables the sweeper. // (brief §2: 196h sliding window) — and (2) browser devices whose web_session is
// gone (revoked or expired), which would otherwise linger until the stale cutoff
// now that users can no longer remove devices by hand. Native and shortcut
// devices keep no web_session and are pruned by the stale cutoff only. ttl ≤ 0
// disables the sweeper.
func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duration) { func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duration) {
if ttl <= 0 { if ttl <= 0 {
slog.Info("device sweeper disabled (ttl <= 0)") slog.Info("device sweeper disabled (ttl <= 0)")
@@ -29,10 +33,14 @@ func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duratio
case <-ctx.Done(): case <-ctx.Done():
return return
case <-t.C: case <-t.C:
cutoff := time.Now().Add(-ttl).Unix() now := time.Now()
cutoff := now.Add(-ttl).Unix()
if err := queries.DeleteStaleDevices(ctx, cutoff); err != nil { if err := queries.DeleteStaleDevices(ctx, cutoff); err != nil {
slog.Error("device sweeper failed", "err", err) slog.Error("device sweeper failed", "err", err)
} }
if _, err := queries.DeleteOrphanBrowserDevices(ctx, now.Unix()); err != nil {
slog.Error("device sweeper: orphan browser cleanup failed", "err", err)
}
} }
} }
} }
+31 -13
View File
@@ -19,13 +19,16 @@ import (
"commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/db"
) )
// Store is the subset of *db.Queries the auth middleware uses: device upserts // Store is the subset of *db.Queries the auth middleware uses: device upserts,
// plus the shortcut-token lookups needed to honour revocation. Declared as an // the shortcut-token lookups needed to honour revocation, and the web_session
// interface so tests can swap in a fake. // lookup that makes a self-signed session token's revocation take effect at once
// (a deleted row → the token is rejected on its next request, not after TTL).
// Declared as an interface so tests can swap in a fake.
type Store interface { type Store interface {
UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error
GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error) GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error)
TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error
GetWebSession(ctx context.Context, id string) (db.WebSession, error)
} }
type Authenticator struct { type Authenticator struct {
@@ -108,7 +111,7 @@ func (a *Authenticator) verify(ctx context.Context, token string, r *http.Reques
// cdrop self-signed session tokens first: distinct key + typ=session, so a // cdrop self-signed session tokens first: distinct key + typ=session, so a
// shortcut token (different key, requires jti) never validates here and a // shortcut token (different key, requires jti) never validates here and a
// session token never falls through to the shortcut path's DB lookup. // session token never falls through to the shortcut path's DB lookup.
if c, err := a.verifySelfToken(token); err == nil { if c, err := a.verifySelfToken(ctx, token); err == nil {
return c, nil return c, nil
} }
if c, err := a.verifyHS256(ctx, token); err == nil { if c, err := a.verifyHS256(ctx, token); err == nil {
@@ -119,9 +122,13 @@ func (a *Authenticator) verify(ctx context.Context, token string, r *http.Reques
// verifySelfToken validates a cdrop self-signed session access token (AUTH.md // verifySelfToken validates a cdrop self-signed session access token (AUTH.md
// §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest // §3.1): HS256 over DeriveSessionTokenKey, carrying typ=session and a full/guest
// scope. Stateless by design — no DB lookup, so it stays cheap on every request; // scope. This is the unified browser token — scan-login (self/guest) AND OIDC web
// revocation rides the short TTL (the session row is re-checked at /auth/refresh). // logins both ride it now, so the browser holds one token type. After the cheap
func (a *Authenticator) verifySelfToken(token string) (*Claims, error) { // signature/exp/scope checks it does ONE indexed lookup of the token's sid against
// web_sessions: a deleted row means the session was revoked, and the token is
// rejected on its very next request (immediate "log out this device"). The IdP
// RS256 path (verifyRS256) remains only for the desktop client's loopback tokens.
func (a *Authenticator) verifySelfToken(ctx context.Context, token string) (*Claims, error) {
if len(a.sessionTokenKey) == 0 { if len(a.sessionTokenKey) == 0 {
return nil, errors.New("session token key not configured") return nil, errors.New("session token key not configured")
} }
@@ -147,6 +154,16 @@ func (a *Authenticator) verifySelfToken(token string) (*Claims, error) {
if scope != "full" && scope != "guest" { if scope != "full" && scope != "guest" {
return nil, errors.New("session token invalid scope") return nil, errors.New("session token invalid scope")
} }
// Bind the token to its live web_session row (sid): once that row is revoked
// (deleted), the token is rejected on its very next request — "log out this
// device" takes effect immediately rather than after the access token's TTL.
sid, _ := custom["sid"].(string)
if sid == "" {
return nil, errors.New("session token missing sid")
}
if _, err := a.store.GetWebSession(ctx, sid); err != nil {
return nil, errors.New("session revoked")
}
return &Claims{UserID: std.Subject, SessionScope: scope}, nil return &Claims{UserID: std.Subject, SessionScope: scope}, nil
} }
@@ -265,8 +282,9 @@ func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims,
// VerifyIDToken validates an OIDC id_token (RS256 via JWKS, issuer + audience) // VerifyIDToken validates an OIDC id_token (RS256 via JWKS, issuer + audience)
// from a fresh prompt=login exchange and returns its subject and auth_time (the // from a fresh prompt=login exchange and returns its subject and auth_time (the
// epoch second of the actual end-user authentication). Used for step-up re-auth // epoch second of the actual end-user authentication, or 0 when the IdP omits the
// (AUTH.md §6): the caller checks sub matches and auth_time is recent enough. // claim — Casdoor does). Used for step-up re-auth (AUTH.md §6): the caller checks
// sub matches and, only when auth_time is present, that it is recent enough.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subject string, authTime int64, err error) { func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subject string, authTime int64, err error) {
if a.jwks == nil { if a.jwks == nil {
return "", 0, errors.New("JWKS not configured") return "", 0, errors.New("JWKS not configured")
@@ -300,10 +318,10 @@ func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subjec
if std.Subject == "" { if std.Subject == "" {
return "", 0, errors.New("missing subject claim") return "", 0, errors.New("missing subject claim")
} }
at, ok := numericClaim(custom["auth_time"]) // auth_time is OPTIONAL: required by OIDC only when the IdP chooses to honour
if !ok { // max_age, and Casdoor omits it entirely. Absent → 0; the caller treats the
return "", 0, errors.New("missing auth_time claim") // fresh single-use prompt=login code as the freshness bound instead.
} at, _ := numericClaim(custom["auth_time"])
return std.Subject, at, nil return std.Subject, at, nil
} }
+34 -4
View File
@@ -22,9 +22,10 @@ import (
// shortcut-token lookups from an in-memory map (empty → "not found", so plain // shortcut-token lookups from an in-memory map (empty → "not found", so plain
// device-only tests are unaffected). // device-only tests are unaffected).
type fakeDeviceUpserter struct { type fakeDeviceUpserter struct {
calls []db.UpsertDeviceParams calls []db.UpsertDeviceParams
err error err error
tokens map[string]db.ShortcutToken tokens map[string]db.ShortcutToken
revokedSIDs map[string]bool
} }
func (f *fakeDeviceUpserter) UpsertDevice(_ context.Context, arg db.UpsertDeviceParams) error { func (f *fakeDeviceUpserter) UpsertDevice(_ context.Context, arg db.UpsertDeviceParams) error {
@@ -45,6 +46,15 @@ func (f *fakeDeviceUpserter) TouchShortcutTokenUsed(_ context.Context, _ db.Touc
return nil return nil
} }
// GetWebSession backs the self-token revocation check. revokedSIDs lets a test
// simulate a revoked session (deleted row); any other sid resolves to a live row.
func (f *fakeDeviceUpserter) GetWebSession(_ context.Context, id string) (db.WebSession, error) {
if f.revokedSIDs[id] {
return db.WebSession{}, sql.ErrNoRows
}
return db.WebSession{ID: id, UserID: "user-x"}, nil
}
// echo handler that responds with the claims+device discovered in context. // echo handler that responds with the claims+device discovered in context.
func echoMe(w http.ResponseWriter, r *http.Request) { func echoMe(w http.ResponseWriter, r *http.Request) {
claims, _ := ClaimsFromContext(r.Context()) claims, _ := ClaimsFromContext(r.Context())
@@ -489,6 +499,26 @@ func TestSanitizeDeviceName(t *testing.T) {
} }
} }
// A self-signed session token is bound to its web_session row: once the row is
// revoked (deleted), the token is rejected on its next request even though it is
// still cryptographically valid and unexpired — "log out this device" is immediate
// (AUTH.md §1/§3.1).
func TestSelfToken_RejectedAfterSessionRevoked(t *testing.T) {
secret := "test-session-secret-at-least-32-bytes-long"
exp := time.Now().Add(time.Hour)
live := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{})
if c, _ := serveBearer(live, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusOK {
t.Fatalf("token with a live session: got %d, want 200", c)
}
revoked := New(&config.Config{AuthMode: "prod", SessionSecret: secret},
&fakeDeviceUpserter{revokedSIDs: map[string]bool{"test-sid": true}})
if c, _ := serveBearer(revoked, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized {
t.Errorf("token whose session was revoked must be rejected, got %d", c)
}
}
// signSelfToken mints a cdrop self-signed session token the way httpapi.mintSessionToken // signSelfToken mints a cdrop self-signed session token the way httpapi.mintSessionToken
// does: HS256 over DeriveSessionTokenKey, with typ + scope custom claims and no jti. // does: HS256 over DeriveSessionTokenKey, with typ + scope custom claims and no jti.
func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time) string { func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time) string {
@@ -505,7 +535,7 @@ func signSelfToken(t *testing.T, secret, sub, typ, scope string, exp time.Time)
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
Expiry: jwt.NewNumericDate(exp), Expiry: jwt.NewNumericDate(exp),
} }
tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope}).Serialize() tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope, "sid": "test-sid"}).Serialize()
if err != nil { if err != nil {
t.Fatalf("serialize: %v", err) t.Fatalf("serialize: %v", err)
} }
+7
View File
@@ -14,6 +14,7 @@
"@mantine/notifications": "^7.13.5", "@mantine/notifications": "^7.13.5",
"@tanstack/react-router": "^1.82.0", "@tanstack/react-router": "^1.82.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"jsqr": "^1.4.0",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
@@ -2433,6 +2434,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+1
View File
@@ -16,6 +16,7 @@
"@mantine/notifications": "^7.13.5", "@mantine/notifications": "^7.13.5",
"@tanstack/react-router": "^1.82.0", "@tanstack/react-router": "^1.82.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"jsqr": "^1.4.0",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
+8 -2
View File
@@ -20,13 +20,19 @@ export interface AuthShellProps
* 跳转到主界面,跳转帧里顶栏 logo 会与主界面 header 的 logo 重叠成“两个 * 跳转到主界面,跳转帧里顶栏 logo 会与主界面 header 的 logo 重叠成“两个
* logo”,隐藏即可消除。登录页是整页跳 IdP、无此重叠,保留 logo。 */ * logo”,隐藏即可消除。登录页是整页跳 IdP、无此重叠,保留 logo。 */
hideBrand?: boolean; hideBrand?: boolean;
/** 整页跳过聚珍 CJK 处理(在 shell 根加 data-jz-skip)。状态机页面(扫码 /
* 批准 / 显码:mount 后即 getUserMedia/fetch → setState 频繁重渲染)必须用:
* 聚珍是渲染后 MutationObserver 改 DOM 的库,与 React 重渲染同一子树本质冲突,
* 会触发 insertBefore 崩溃。这些页以短 chrome 文本为主,跳过聚珍仅损失些微
* 中西间距,远胜崩溃。 */
skipAutospace?: boolean;
} }
// 全屏外壳:登录、首次设置、OAuth 回调共用。带轻量品牌 + 主题切换, // 全屏外壳:登录、首次设置、OAuth 回调共用。带轻量品牌 + 主题切换,
// 让用户在登录前也能调主题。 // 让用户在登录前也能调主题。
export function AuthShell(props: AuthShellProps) export function AuthShell(props: AuthShellProps)
{ {
const { title, subtitle, children, footer, cardBrand, hideBrand } = props; const { title, subtitle, children, footer, cardBrand, hideBrand, skipAutospace } = props;
const theme = useAppStore((s) => s.theme); const theme = useAppStore((s) => s.theme);
const setTheme = useAppStore((s) => s.setTheme); const setTheme = useAppStore((s) => s.setTheme);
const next: ThemeMode = theme === "system" ? "light" : theme === "light" ? "dark" : "system"; const next: ThemeMode = theme === "system" ? "light" : theme === "light" ? "dark" : "system";
@@ -38,7 +44,7 @@ export function AuthShell(props: AuthShellProps)
const brandNode = cardBrand === undefined ? null : cardBrand; const brandNode = cardBrand === undefined ? null : cardBrand;
return ( return (
<div className={s.shell}> <div className={s.shell} data-jz-skip={skipAutospace ? "" : undefined}>
<div className={s.topbar}> <div className={s.topbar}>
{/* 空 span 占位保持 space-between,主题切换仍靠右。 */} {/* 空 span 占位保持 space-between,主题切换仍靠右。 */}
{hideBrand ? <span /> : <span className={s.brand}><Wordmark /></span>} {hideBrand ? <span /> : <span className={s.brand}><Wordmark /></span>}
+73 -15
View File
@@ -2,7 +2,9 @@ import { useAppStore, type User } from "../../store";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { apiFetch } from "../../net/api"; import { apiFetch } from "../../net/api";
import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop"; import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop";
import { fetchMeScope } from "../../net/sessions";
import { stashStepUpPending } from "../qr/stepUp"; import { stashStepUpPending } from "../qr/stepUp";
import { toast } from "../../ui/feedback";
// ---- dev mode ------------------------------------------------------------- // ---- dev mode -------------------------------------------------------------
@@ -46,6 +48,29 @@ export function logout()
if (isDesktop()) { clearDesktopSession(); } if (isDesktop()) { clearDesktopSession(); }
} }
// forceLogout 在「服务端确证会话已失效」时清掉本地登录态——触发点仅一处:
// access token 401 后续期也被服务端以 401 拒绝(refreshTokens 返回 "invalid"
// 即被远程吊销或已过期)。短期连接失败(网络抖动 / 5xx)绝不走到这里(见
// net/api.ts)。与 logout() 不同,它不发任何服务端请求(会话已死,调用只会再次
// 401),只就地清空 + 提示,让 __root 的反应式守卫把界面送回登录页
// (离线设备「下一次使用即得知」)。
//
// 幂等:靠「已登出即跳过」实现——首个 401 同步清空 user/accessToken 后,并发涌入
// 的其余 401 看到已为空便直接返回,不会重复弹 toast、不重复清桌面 session。
export function forceLogout(): void
{
const st = useAppStore.getState();
if (!st.user && !st.accessToken)
{
// 已被并发的某个 401 清空,或本就处于登出态:不重复处理(避免 toast 刷屏)。
return;
}
st.clearAuth();
// 桌面端:删除 Go 侧持久化 session,否则下次启动仍注入这枚已失效的登录态。
if (isDesktop()) { clearDesktopSession(); }
toast.error(t("auth.sessionLost.title"), t("auth.sessionLost.body"));
}
// ---- prod (OIDC PKCE) ----------------------------------------------------- // ---- prod (OIDC PKCE) -----------------------------------------------------
interface AuthConfig interface AuthConfig
@@ -242,48 +267,69 @@ interface CookieRefreshResp
device_name?: string; device_name?: string;
} }
// CookieRefreshResult 区分「服务端确证会话失效」与「短期失败」,使上层只在前者
// 清空登录态:
// ok —— 换到新 access token
// invalid —— /api/auth/refresh 返回 401:服务端查证会话已失效 / 被吊销 / 过期
// (其处理器同时清掉 cookie),凭据确凿不可恢复
// transient —— 5xx / 同源校验 403 / 响应残缺等非确证失败(网络层抛错则根本不进此
// 函数、照常向上抛)——一律保留登录态,按短期错误处理
type CookieRefreshResult =
| { kind: "ok"; accessToken: string; user: User; deviceName: string }
| { kind: "invalid" }
| { kind: "transient" };
// cookieRefresh 用 HttpOnly session cookie 静默换一枚新 access_token。无 body—— // cookieRefresh 用 HttpOnly session cookie 静默换一枚新 access_token。无 body——
// cookie 自动随同源请求发出(Path=/api/auth)。成功返回 {accessToken, user, // cookie 自动随同源请求发出(Path=/api/auth)。返回见 CookieRefreshResult:只有
// deviceName}cookie 缺失 / 过期 / 被 IdP 拒绝 → null(调用方据此落到登录页) // 服务端明确以 401 拒绝才算 invalid,短期失败一律 transient,不误伤登录态
async function cookieRefresh(): Promise<{ accessToken: string; user: User; deviceName: string } | null> async function cookieRefresh(): Promise<CookieRefreshResult>
{ {
const r = await fetch("/api/auth/refresh", { method: "POST" }); const r = await fetch("/api/auth/refresh", { method: "POST" });
if (!r.ok) { return null; } if (!r.ok)
{
return { kind: r.status === 401 ? "invalid" : "transient" };
}
const data = await r.json().catch(() => null) as CookieRefreshResp | null; const data = await r.json().catch(() => null) as CookieRefreshResp | null;
if (!data?.access_token || !data.user?.id) { return null; } if (!data?.access_token || !data.user?.id) { return { kind: "transient" }; }
return { return {
kind: "ok",
accessToken: data.access_token, accessToken: data.access_token,
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar }, user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
deviceName: data.device_name ?? "", deviceName: data.device_name ?? "",
}; };
} }
// refreshTokens 换新 access_tokenapi.ts 在请求拿到 401 时惰性调用。返回是否成功。 // RefreshOutcome 是续期对上层的三态结论;只有 "invalid"(服务端确证失效)才允许
export async function refreshTokens(): Promise<boolean> // 上层 forceLogout"transient" 必须保留登录态(短期连接失败不清状态)。
export type RefreshOutcome = "refreshed" | "invalid" | "transient";
// refreshTokens 换新 access_tokenapi.ts 在请求拿到 401 时惰性调用。
export async function refreshTokens(): Promise<RefreshOutcome>
{ {
const store = useAppStore.getState(); const store = useAppStore.getState();
if (store.authMode !== "prod") { return false; } if (store.authMode !== "prod") { return "transient"; }
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 GoJS 不持有), // 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 GoJS 不持有),
// 直接无参调用。 // 直接无参调用。Go 侧暂不区分「被吊销」与「短期失败」,故失败一律按 transient
// 处理(不清空登录态)——桌面端即时吊销留后续(AUTH.md §3.1)。
if (isDesktop()) if (isDesktop())
{ {
const res = await desktopRefresh(); const res = await desktopRefresh();
if (!res?.access_token) { return false; } if (!res?.access_token) { return "transient"; }
const cur = useAppStore.getState(); const cur = useAppStore.getState();
if (!cur.user) { return false; } if (!cur.user) { return "transient"; }
cur.setAuth({ accessToken: res.access_token, user: cur.user }); cur.setAuth({ accessToken: res.access_token, user: cur.user });
return true; return "refreshed";
} }
// 浏览器端:走 HttpOnly cookie sessionrefresh_token 在服务端,JS 不持有)。 // 浏览器端:走 HttpOnly cookie sessionrefresh_token 在服务端,JS 不持有)。
const res = await cookieRefresh(); const res = await cookieRefresh();
if (!res) { return false; } if (res.kind !== "ok") { return res.kind; } // "invalid" | "transient",透传给上层
const cur = useAppStore.getState(); const cur = useAppStore.getState();
cur.setAuth({ accessToken: res.accessToken, user: res.user }); cur.setAuth({ accessToken: res.accessToken, user: res.user });
// 设备名以服务端为权威;本地缺失(PWA 存储被清)时回填。 // 设备名以服务端为权威;本地缺失(PWA 存储被清)时回填。
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); } if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
return true; return "refreshed";
} }
// bootstrapAuth 在 App 挂载前尝试「免重登」:浏览器端若本会话尚无 access_token // bootstrapAuth 在 App 挂载前尝试「免重登」:浏览器端若本会话尚无 access_token
@@ -297,12 +343,24 @@ export async function bootstrapAuth(): Promise<void>
if (store.accessToken && store.user) { return; } // 同会话已登录(sessionStorage 命中) if (store.accessToken && store.user) { return; } // 同会话已登录(sessionStorage 命中)
const res = await cookieRefresh(); const res = await cookieRefresh();
if (!res) { return; } if (res.kind !== "ok") { return; } // 开机水合失败(含 invalid / transient)→ 照常落登录页
const cur = useAppStore.getState(); const cur = useAppStore.getState();
cur.setAuth({ accessToken: res.accessToken, user: res.user }); cur.setAuth({ accessToken: res.accessToken, user: res.user });
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); } if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
} }
// refreshSessionScope 拉一次 /api/me 校正本会话权限级别(full / guest)并写进
// store。在登录成功 / 开机水合后调用一次,使受限访客的账号管理入口被隐藏。失败
// 静默回退 "full"——只多展示入口,后端仍 403 兜底。dev 模式无 guest 概念,跳过。
export async function refreshSessionScope(): Promise<void>
{
const store = useAppStore.getState();
if (store.authMode !== "prod") { return; }
if (!store.user || !store.accessToken) { return; }
const scope = await fetchMeScope();
useAppStore.getState().setSessionScope(scope);
}
// syncWebDeviceName 把本机设备名推到服务端 session(cookie 鉴权),让 PWA 存储被清 // syncWebDeviceName 把本机设备名推到服务端 session(cookie 鉴权),让 PWA 存储被清
// 后开机仍能从 cookie session 恢复设备名、不再误跳 /setup。仅浏览器 prod;桌面端走 // 后开机仍能从 cookie session 恢复设备名、不再误跳 /setup。仅浏览器 prod;桌面端走
// persistDesktopDeviceNameGo 持久化),dev 无 session。失败静默——只是体验降级。 // persistDesktopDeviceNameGo 持久化),dev 无 session。失败静默——只是体验降级。
+3 -3
View File
@@ -87,7 +87,7 @@ export function DesktopSettings()
<Group justify="space-between" wrap="nowrap" align="flex-start"> <Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={2} style={{ minWidth: 0 }}> <Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500}>{t("settings.desktop.downloadDir")}</Text> <Text size="sm" fw={500}>{t("settings.desktop.downloadDir")}</Text>
<Text size="xs" c="dimmed">{t("settings.desktop.downloadDirHint")}</Text> <Text size="xs" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.desktop.downloadDirHint")}</Text>
<Text <Text
size="xs" size="xs"
style={{ fontFamily: "var(--font-mono, ui-monospace, monospace)", wordBreak: "break-all" }} style={{ fontFamily: "var(--font-mono, ui-monospace, monospace)", wordBreak: "break-all" }}
@@ -107,7 +107,7 @@ export function DesktopSettings()
</Stack> </Stack>
</Group> </Group>
<div style={{ height: 1, background: "var(--divider)" }} /> <div style={{ height: 1, background: "var(--divider)" }} />
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed" className="justify" data-jz-level="paragraph">
{t("settings.desktop.ttlNote")} {t("settings.desktop.ttlNote")}
</Text> </Text>
</Stack> </Stack>
@@ -128,7 +128,7 @@ function ToggleRow(props: {
<Group justify="space-between" wrap="nowrap" align="flex-start"> <Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={2} style={{ minWidth: 0 }}> <Stack gap={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500}>{title}</Text> <Text size="sm" fw={500}>{title}</Text>
<Text size="xs" c="dimmed">{hint}</Text> <Text size="xs" c="dimmed" className="justify" data-jz-level="paragraph">{hint}</Text>
</Stack> </Stack>
<Switch <Switch
checked={checked} checked={checked}
@@ -160,8 +160,20 @@
.headerCount { color: var(--text-muted); font-size: var(--fs-12); margin-left: 4px; } .headerCount { color: var(--text-muted); font-size: var(--fs-12); margin-left: 4px; }
/* header 右侧动作组:「显示/隐藏离线」与「扫码登录新设备」并排,同款文本按钮语汇。 */
.headerActions
{
display: inline-flex;
align-items: center;
gap: var(--space-1);
flex-shrink: 0;
}
.headerSwitch .headerSwitch
{ {
display: inline-flex;
align-items: center;
gap: 5px;
background: transparent; background: transparent;
border: 0; border: 0;
color: var(--text-muted); color: var(--text-muted);
@@ -173,3 +185,4 @@
transition: background-color var(--dur-fast) var(--ease-out); transition: background-color var(--dur-fast) var(--ease-out);
} }
.headerSwitch:hover { background: var(--accent-soft); color: var(--accent); } .headerSwitch:hover { background: var(--accent-soft); color: var(--accent); }
.headerSwitch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+27 -13
View File
@@ -1,5 +1,5 @@
import { useMemo, useState, type DragEvent } from "react"; import { useMemo, useState, type DragEvent } from "react";
import { Check } from "lucide-react"; import { Check, ScanLine } from "lucide-react";
import { DeviceTypeIcon, StateDot } from "../../ui/glyphs"; import { DeviceTypeIcon, StateDot } from "../../ui/glyphs";
import type { DeviceKind } from "../../ui/glyphs"; import type { DeviceKind } from "../../ui/glyphs";
import type { DeviceInfo } from "../../store"; import type { DeviceInfo } from "../../store";
@@ -15,11 +15,13 @@ export interface DeviceStripProps
onSelect: (name: string | null) => void; onSelect: (name: string | null) => void;
/** 拖拽文件落到设备卡上,跳过 composer 直接发送。 */ /** 拖拽文件落到设备卡上,跳过 composer 直接发送。 */
onDropFile?: (deviceName: string, file: File) => void; onDropFile?: (deviceName: string, file: File) => void;
/** 「扫码登录新设备」入口:应用内相机扫码把另一台设备接入账号。 */
onLinkDevice?: () => void;
} }
export function DeviceStrip(props: DeviceStripProps) export function DeviceStrip(props: DeviceStripProps)
{ {
const { devices, selfDeviceName, selectedDevice, onSelect, onDropFile } = props; const { devices, selfDeviceName, selectedDevice, onSelect, onDropFile, onLinkDevice } = props;
const [ showOffline, setShowOffline ] = useState(false); const [ showOffline, setShowOffline ] = useState(false);
const peers = useMemo( const peers = useMemo(
@@ -42,17 +44,29 @@ export function DeviceStrip(props: DeviceStripProps)
{t("home.deviceList.onlineSuffix", { count: onlineCount })} {t("home.deviceList.onlineSuffix", { count: onlineCount })}
</span> </span>
</div> </div>
{offlineCount > 0 && ( <div className={s.headerActions}>
<button {offlineCount > 0 && (
type="button" <button
className={s.headerSwitch} type="button"
onClick={() => setShowOffline((v) => !v)} className={s.headerSwitch}
> onClick={() => setShowOffline((v) => !v)}
{showOffline >
? "隐藏离线" {showOffline
: t("home.deviceList.showOffline", { count: offlineCount })} ? t("home.deviceList.hideOffline")
</button> : t("home.deviceList.showOffline", { count: offlineCount })}
)} </button>
)}
{onLinkDevice && (
<button
type="button"
className={s.headerSwitch}
onClick={onLinkDevice}
>
<ScanLine size={13} aria-hidden />
{t("qr.entry.scanNewDevice")}
</button>
)}
</div>
</div> </div>
{visible.length === 0 ? ( {visible.length === 0 ? (
+36 -6
View File
@@ -15,12 +15,24 @@
const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo } const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo }
const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier } const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier }
// 登录会话登出的 step-up:跨整页跳转记住「要登出的是哪条会话」。回到设置页后取出,
// 重试那条 DELETE。批准页的 step-up 不用它(要批准的 request 已编在 returnTo 里)。
const REVOKE_KEY = "cdrop.qr.stepup_revoke"; // sessionIdstring
// 仅允许站内 /link 路径回流,杜绝 open-redirect(外部 URL / 协议相对地址)。 // 仅允许两个站内回流目标,杜绝 open-redirect(外部 URL / 协议相对地址 / 任意路径):
// 与 returnTo.ts 同口径——step-up 往返回来后的整页跳转目标只能是批准页。 // - /link[?…] ——批准页(扫码登录的 step-up);
function isSafeLinkPath(path: string): boolean // - /settings ——设置页(登录会话登出的 step-up)。
// step-up 往返回来后的整页跳转目标只能是这两处之一;其余一律拒。校验 path 必须以
// "/link" 或 "/settings" 起头且后随串只能是空或 "?…",挡住 "/linkmalicious" /
// "//evil.com"(协议相对)/ "/settings/../x" 之类绕过。
function isSafeReturnPath(path: string): boolean
{ {
return path.startsWith("/link?") || path === "/link"; for (const base of [ "/link", "/settings" ])
{
if (path === base) { return true; }
if (path.startsWith(base + "?")) { return true; }
}
return false;
} }
export interface StepUpPending export interface StepUpPending
@@ -40,7 +52,7 @@ export interface StepUpStash
export function stashStepUpPending(pending: StepUpPending): boolean export function stashStepUpPending(pending: StepUpPending): boolean
{ {
if (typeof window === "undefined") { return false; } if (typeof window === "undefined") { return false; }
if (!pending.verifier || !isSafeLinkPath(pending.returnTo)) { return false; } if (!pending.verifier || !isSafeReturnPath(pending.returnTo)) { return false; }
window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending)); window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
return true; return true;
} }
@@ -53,7 +65,7 @@ export function peekStepUpPending(): StepUpPending | null
const raw = window.sessionStorage.getItem(PENDING_KEY); const raw = window.sessionStorage.getItem(PENDING_KEY);
if (!raw) { return null; } if (!raw) { return null; }
const p = safeParse<StepUpPending>(raw); const p = safeParse<StepUpPending>(raw);
if (!p?.verifier || !isSafeLinkPath(p.returnTo)) { return null; } if (!p?.verifier || !isSafeReturnPath(p.returnTo)) { return null; }
return p; return p;
} }
@@ -91,6 +103,24 @@ export function clearStepUpStash(): void
window.sessionStorage.removeItem(STASH_KEY); window.sessionStorage.removeItem(STASH_KEY);
} }
// stashPendingRevoke 在发起会话登出的 step-up 再认证前记下待登出的 sessionId,
// 跨整页跳转留到回到设置页后取出重试。
export function stashPendingRevoke(sessionId: string): void
{
if (typeof window === "undefined") { return; }
if (!sessionId) { return; }
window.sessionStorage.setItem(REVOKE_KEY, sessionId);
}
// consumePendingRevoke 取出并清除待登出的 sessionId(一次性,用完即清)。
export function consumePendingRevoke(): string | null
{
if (typeof window === "undefined") { return null; }
const id = window.sessionStorage.getItem(REVOKE_KEY);
window.sessionStorage.removeItem(REVOKE_KEY);
return id || null;
}
function safeParse<T>(raw: string): T | null function safeParse<T>(raw: string): T | null
{ {
try { return JSON.parse(raw) as T; } try { return JSON.parse(raw) as T; }
+10 -10
View File
@@ -142,7 +142,7 @@ export function ShortcutTokens()
return ( return (
<Panel title={t("settings.shortcut.title")}> <Panel title={t("settings.shortcut.title")}>
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" c="dimmed">{t("settings.shortcut.intro")}</Text> <Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.shortcut.intro")}</Text>
<Group align="flex-end" gap="sm" wrap="nowrap"> <Group align="flex-end" gap="sm" wrap="nowrap">
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -273,24 +273,24 @@ function IssuedReveal(props: { issued: IssueResp; base: string; onDismiss: () =>
<Stack gap={6}> <Stack gap={6}>
<Text size="sm" fw={600}>{t("settings.shortcut.guideTitle")}</Text> <Text size="sm" fw={600}>{t("settings.shortcut.guideTitle")}</Text>
<Text size="sm" c="dimmed">{t("settings.shortcut.guideIntro")}</Text> <Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.shortcut.guideIntro")}</Text>
<Text size="sm" c="dimmed">{t("settings.shortcut.guideDeviceName")}</Text> <Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.shortcut.guideDeviceName")}</Text>
<Text size="sm" fw={500}>{t("settings.shortcut.guidePullTitle")}</Text> <Text size="sm" fw={500}>{t("settings.shortcut.guidePullTitle")}</Text>
<Stack gap={2}> <Stack gap={2}>
<Text size="sm">1. {t("settings.shortcut.guidePull1")}</Text> <Text size="sm" className="justify" data-jz-level="paragraph">1. {t("settings.shortcut.guidePull1")}</Text>
<Text size="sm">2. {t("settings.shortcut.guidePull2")}</Text> <Text size="sm" className="justify" data-jz-level="paragraph">2. {t("settings.shortcut.guidePull2")}</Text>
<Text size="sm">3. {t("settings.shortcut.guidePull3")}</Text> <Text size="sm" className="justify" data-jz-level="paragraph">3. {t("settings.shortcut.guidePull3")}</Text>
</Stack> </Stack>
<Text size="sm" fw={500}>{t("settings.shortcut.guidePushTitle")}</Text> <Text size="sm" fw={500}>{t("settings.shortcut.guidePushTitle")}</Text>
<Stack gap={2}> <Stack gap={2}>
<Text size="sm">1. {t("settings.shortcut.guidePush1")}</Text> <Text size="sm" className="justify" data-jz-level="paragraph">1. {t("settings.shortcut.guidePush1")}</Text>
<Text size="sm">2. {t("settings.shortcut.guidePush2")}</Text> <Text size="sm" className="justify" data-jz-level="paragraph">2. {t("settings.shortcut.guidePush2")}</Text>
<Text size="sm">3. {t("settings.shortcut.guidePush3")}</Text> <Text size="sm" className="justify" data-jz-level="paragraph">3. {t("settings.shortcut.guidePush3")}</Text>
</Stack> </Stack>
<Text size="xs" c="dimmed">{t("settings.shortcut.guideToleranceNote")}</Text> <Text size="xs" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.shortcut.guideToleranceNote")}</Text>
</Stack> </Stack>
<Group justify="flex-end"> <Group justify="flex-end">
+54 -9
View File
@@ -16,6 +16,11 @@ export const enUS: Partial<TranslationDict> = {
"app.disconnectedHint": "app.disconnectedHint":
"Lost connection to the server — retrying automatically. No need to refresh.", "Lost connection to the server — retrying automatically. No need to refresh.",
// ---- session lost (revoked remotely / expired) ----------------------
"auth.sessionLost.title": "Signed out",
"auth.sessionLost.body":
"This device's login was revoked or expired. Please sign in again.",
// ---- header / user menu --------------------------------------------- // ---- header / user menu ---------------------------------------------
"nav.accountMenu": "Account menu", "nav.accountMenu": "Account menu",
"nav.thisDeviceLabel": "This device: ", "nav.thisDeviceLabel": "This device: ",
@@ -34,6 +39,7 @@ export const enUS: Partial<TranslationDict> = {
"home.deviceList.title": "Devices", "home.deviceList.title": "Devices",
"home.deviceList.onlineSuffix": "({{count}} online)", "home.deviceList.onlineSuffix": "({{count}} online)",
"home.deviceList.showOffline": "Show offline ({{count}})", "home.deviceList.showOffline": "Show offline ({{count}})",
"home.deviceList.hideOffline": "Hide offline",
"home.deviceList.empty": "home.deviceList.empty":
"No peer devices yet. Open the app in another tab with a different device name (Settings → Device name) to see it here.", "No peer devices yet. Open the app in another tab with a different device name (Settings → Device name) to see it here.",
"home.tabs.file": "File", "home.tabs.file": "File",
@@ -95,6 +101,20 @@ export const enUS: Partial<TranslationDict> = {
// ---- QR sign-in · entry -------------------------------------------- // ---- QR sign-in · entry --------------------------------------------
"qr.entry.fromLogin": "Sign in with a QR code", "qr.entry.fromLogin": "Sign in with a QR code",
"qr.entry.scanNewDevice": "Scan to link a device",
// In-app scanner: scan a new device's QR code from this signed-in device to link it.
"qr.scan.title": "Scan to link a device",
"qr.scan.subtitle": "Point the code shown on the other device at the frame. Approval stays on this signed-in device.",
"qr.scan.starting": "Starting the camera…",
"qr.scan.aiming": "Line the code up inside the frame",
"qr.scan.foreign": "That isn't a sign-in code for this site. Aim at the code from “Sign in with a QR code” on the new device.",
"qr.scan.noCamera": "This device has no camera available. Use a phone or tablet with a camera to scan instead.",
"qr.scan.denied": "Camera access was blocked. Allow the camera in your browser's site settings, then reopen this page.",
"qr.scan.error": "Couldn't open the camera. Try again, or scan from a device with a camera.",
"qr.scan.howto1": "On the new device, open “Sign in with a QR code” to show a code.",
"qr.scan.howto2": "Line that code up in the frame above — approval starts automatically.",
"qr.scan.back": "Back",
// ---- QR sign-in · show page (new device) --------------------------- // ---- QR sign-in · show page (new device) ---------------------------
"qr.show.title": "Sign in with a QR code", "qr.show.title": "Sign in with a QR code",
@@ -109,6 +129,8 @@ export const enUS: Partial<TranslationDict> = {
"qr.show.expired": "This code has expired. Generate a new one and scan again.", "qr.show.expired": "This code has expired. Generate a new one and scan again.",
"qr.show.denied": "This request was declined. If that was you, generate a new code and try again.", "qr.show.denied": "This request was declined. If that was you, generate a new code and try again.",
"qr.show.regenerate": "Generate a new code", "qr.show.regenerate": "Generate a new code",
"qr.show.back": "Back to home",
"qr.show.backToName": "Back",
// ---- QR sign-in · approve page (phone) ----------------------------- // ---- QR sign-in · approve page (phone) -----------------------------
"qr.approve.title": "Approve a new device", "qr.approve.title": "Approve a new device",
@@ -117,11 +139,12 @@ export const enUS: Partial<TranslationDict> = {
"qr.approve.badLink": "This approval link is incomplete. Generate a new code on the device and scan it again.", "qr.approve.badLink": "This approval link is incomplete. Generate a new code on the device and scan it again.",
"qr.approve.signInFirst": "Sign in to your account first, then approve this device.", "qr.approve.signInFirst": "Sign in to your account first, then approve this device.",
"qr.approve.trustLabel": "Trust duration", "qr.approve.trustLabel": "Trust duration",
"qr.approve.trust.title": "Trust this device", "qr.approve.trust.title": "Trust this device (7 days)",
"qr.approve.trust.hint": "Skip approval for 7 days, renewed on each use.", "qr.approve.trust.hint": "Skip approval for 7 days, renewed on each use.",
"qr.approve.once.title": "Just this once", "qr.approve.once.title": "Just this once (1 hour)",
"qr.approve.once.hint": "Signed in for 1 hour, then scan again.", "qr.approve.once.hint": "Signed in for 1 hour, then scan again.",
"qr.approve.scopeGuest": "This device signs in as a limited guest: it can send and receive files and messages, but can't change your account or approve other devices.", "qr.approve.scopeFull": "This device signs fully into your account for 7 days: it can send and receive files and messages, and manage your account and devices.",
"qr.approve.scopeGuest": "Signs in as a limited guest — it can send and receive files and messages, but can't change your account or approve other devices; expires after 1 hour.",
"qr.approve.approve": "Approve sign-in", "qr.approve.approve": "Approve sign-in",
"qr.approve.stepUp.notice": "For security, verify your identity before approving — the button below takes you through a fresh sign-in.", "qr.approve.stepUp.notice": "For security, verify your identity before approving — the button below takes you through a fresh sign-in.",
"qr.approve.stepUp.button": "Verify and approve", "qr.approve.stepUp.button": "Verify and approve",
@@ -151,16 +174,38 @@ export const enUS: Partial<TranslationDict> = {
"Removes this device's registration and signs you out.", "Removes this device's registration and signs you out.",
"settings.unregister.currentConfirm": "settings.unregister.currentConfirm":
"Unregistering this device will remove its registration and sign you out. Continue?", "Unregistering this device will remove its registration and sign you out. Continue?",
"settings.unregister.peerConfirm": "Remove device \"{{name}}\"?",
"settings.peers.title": "Other devices",
"settings.peers.empty": "No other devices.",
"settings.peers.remove": "Remove",
"settings.peers.removing": "Removing…",
"settings.error.delete": "Action failed: {{message}}", "settings.error.delete": "Action failed: {{message}}",
// ---- Limited-guest badge --------------------------------------------
"settings.guest.badge": "Limited guest",
"settings.guest.title": "This device is a limited guest",
"settings.guest.body": "This device is signed in as a limited guest: it can send and receive files, messages, and clipboard, but cannot manage the account, remove devices, or authorize new ones. To get full access, sign in again on your main device.",
// ---- Login sessions / device management -----------------------------
"settings.sessions.title": "Login sessions",
"settings.sessions.hint": "Every device currently signed in to this account — browser, desktop, and mobile clients; online ones are listed first. Signing a device out ends its session immediately; it must sign in again on that device to continue.",
"settings.sessions.guestNote": "Limited guests can't manage login sessions. To manage them, sign in fully on your main device.",
"settings.sessions.empty": "No other login sessions.",
"settings.sessions.loadError": "Failed to load login sessions.",
"settings.sessions.retry": "Retry",
"settings.sessions.current": "This device",
"settings.sessions.scopeFull": "Full",
"settings.sessions.scopeGuest": "Limited guest",
"settings.sessions.kind.oidc": "Account login",
"settings.sessions.kind.self": "Local account",
"settings.sessions.kind.guest": "Scanned guest",
"settings.sessions.lastUsed": "Last active {{time}}",
"settings.sessions.neverUsed": "Not active yet",
"settings.sessions.signOut": "Sign out",
"settings.sessions.signingOut": "Signing out…",
"settings.sessions.signOutCurrent": "Sign out this device",
"settings.sessions.confirmOther": "Sign out device \"{{name}}\"? Its session will be invalidated immediately and it must sign in again.",
"settings.sessions.confirmCurrent": "Signing out this device is the same as logging out. Continue?",
"settings.sessions.revoked": "Signed that device out",
"settings.sessions.stepUpRejected": "Identity verification failed or expired. Please try again.",
"settings.online": "Online", "settings.online": "Online",
"settings.offline": "Offline", "settings.offline": "Offline",
"settings.lastSeen": "Last seen {{time}}", "settings.lastSeen": "Last seen {{time}}",
"settings.lastSeenNever": "Never connected",
"settings.push.title": "Push notifications", "settings.push.title": "Push notifications",
"settings.push.hint": "When this page is closed, new files, messages, and transfer results arrive as system notifications; while it's open, you'll see in-app alerts instead.", "settings.push.hint": "When this page is closed, new files, messages, and transfer results arrive as system notifications; while it's open, you'll see in-app alerts instead.",
"settings.push.enable": "Enable push", "settings.push.enable": "Enable push",
+56 -12
View File
@@ -14,6 +14,10 @@ export const zhCN = {
"app.disconnectedTitle": "正在重连", "app.disconnectedTitle": "正在重连",
"app.disconnectedHint": "与服务器的连接已断开,正在自动重连,无需刷新页面。", "app.disconnectedHint": "与服务器的连接已断开,正在自动重连,无需刷新页面。",
// ---- 会话失效(被远程吊销 / 过期)------------------------------------
"auth.sessionLost.title": "登录已失效",
"auth.sessionLost.body": "此设备的登录凭据已被注销或过期,请重新登录。",
// ---- 顶部栏 / 用户菜单 ------------------------------------------------ // ---- 顶部栏 / 用户菜单 ------------------------------------------------
"nav.accountMenu": "账号菜单", "nav.accountMenu": "账号菜单",
"nav.thisDeviceLabel": "本机:", "nav.thisDeviceLabel": "本机:",
@@ -32,6 +36,7 @@ export const zhCN = {
"home.deviceList.title": "设备", "home.deviceList.title": "设备",
"home.deviceList.onlineSuffix": "{{count}} 台在线)", "home.deviceList.onlineSuffix": "{{count}} 台在线)",
"home.deviceList.showOffline": "显示离线设备({{count}}", "home.deviceList.showOffline": "显示离线设备({{count}}",
"home.deviceList.hideOffline": "隐藏离线设备",
"home.deviceList.empty": "home.deviceList.empty":
"暂无其他设备。在另一标签页或设备上以相同账户登录,并设置一个不同的设备名(设置 → 设备名称),即可在此处看到它。", "暂无其他设备。在另一标签页或设备上以相同账户登录,并设置一个不同的设备名(设置 → 设备名称),即可在此处看到它。",
"home.tabs.file": "文件", "home.tabs.file": "文件",
@@ -93,6 +98,20 @@ export const zhCN = {
// ---- 扫码登录 · 入口 ------------------------------------------------- // ---- 扫码登录 · 入口 -------------------------------------------------
"qr.entry.fromLogin": "扫码登录此设备", "qr.entry.fromLogin": "扫码登录此设备",
"qr.entry.scanNewDevice": "扫码登录新设备",
// 应用内扫码器:在已登录设备上扫新设备的二维码,把它接入账号。
"qr.scan.title": "扫码登录新设备",
"qr.scan.subtitle": "把另一台设备显示的二维码对准取景框。批准全程留在这台已登录设备上。",
"qr.scan.starting": "正在启动相机…",
"qr.scan.aiming": "把二维码对准取景框",
"qr.scan.foreign": "这不是本站的登录二维码。请对准新设备上“扫码登录此设备”生成的码。",
"qr.scan.noCamera": "这台设备没有可用的摄像头。请改用带相机的手机或平板扫码。",
"qr.scan.denied": "相机权限被拒绝。请在浏览器站点设置里允许使用相机,再重新打开此页。",
"qr.scan.error": "无法打开相机。请重试,或改用带相机的设备扫码。",
"qr.scan.howto1": "在新设备上打开“扫码登录此设备”,生成一张二维码。",
"qr.scan.howto2": "把那张二维码对准上方取景框,识别后会自动进入批准。",
"qr.scan.back": "返回",
// ---- 扫码登录 · 显码页(新设备)------------------------------------- // ---- 扫码登录 · 显码页(新设备)-------------------------------------
"qr.show.title": "扫码登录此设备", "qr.show.title": "扫码登录此设备",
@@ -107,20 +126,23 @@ export const zhCN = {
"qr.show.expired": "二维码已过期。重新生成一张再扫。", "qr.show.expired": "二维码已过期。重新生成一张再扫。",
"qr.show.denied": "这次接入被拒绝。如确为你本人操作,重新生成二维码再试。", "qr.show.denied": "这次接入被拒绝。如确为你本人操作,重新生成二维码再试。",
"qr.show.regenerate": "重新生成二维码", "qr.show.regenerate": "重新生成二维码",
"qr.show.back": "返回主页",
"qr.show.backToName": "返回上一步",
// ---- 扫码登录 · 批准页(手机)------------------------------------- // ---- 扫码登录 · 批准页(手机)-------------------------------------
"qr.approve.title": "批准新设备", "qr.approve.title": "批准新设备",
"qr.approve.subtitle": "确认让这台设备登你的账号。请核对设备与来源无误。", "qr.approve.subtitle": "确认让这台设备登你的账号。请核对设备与来源无误。",
"qr.approve.loading": "正在读取设备信息…", "qr.approve.loading": "正在读取设备信息…",
"qr.approve.badLink": "这个批准链接不完整。请在新设备上重新生成二维码再扫。", "qr.approve.badLink": "这个批准链接不完整。请在新设备上重新生成二维码再扫。",
"qr.approve.signInFirst": "先登录你的账号,再批准这台设备接入。", "qr.approve.signInFirst": "先登录你的账号,再批准这台设备接入。",
"qr.approve.trustLabel": "信任时长", "qr.approve.trustLabel": "信任时长",
"qr.approve.trust.title": "信任此设备", "qr.approve.trust.title": "信任此设备7 天)",
"qr.approve.trust.hint": "7 天内免再次批准,每次使用自动续期。", "qr.approve.trust.hint": "7 天内免再次批准,每次使用自动续期。",
"qr.approve.once.title": "仅此一次", "qr.approve.once.title": "仅此一次1 小时)",
"qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。", "qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。",
"qr.approve.scopeGuest": "这台设备将以受限访客身份登入:可收发文件与消息,但不能更改账号、不能批准其他设备。", "qr.approve.scopeFull": "这台设备将完整登录你的账号、7 天内有效:可正常收发文件与消息,并能管理账号与设备。",
"qr.approve.approve": "批准登入", "qr.approve.scopeGuest": "以受限访客身份登录——可收发文件与消息,但不能更改账号、不能批准其他设备;1 小时后失效。",
"qr.approve.approve": "批准登录",
"qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。", "qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。",
"qr.approve.stepUp.button": "验证并批准", "qr.approve.stepUp.button": "验证并批准",
"qr.approve.stepUp.rejected": "身份验证未通过或已过期。请再验证一次,确认是你本人后即可批准。", "qr.approve.stepUp.rejected": "身份验证未通过或已过期。请再验证一次,确认是你本人后即可批准。",
@@ -128,7 +150,7 @@ export const zhCN = {
"qr.approve.doneApprovedTitle": "已批准", "qr.approve.doneApprovedTitle": "已批准",
"qr.approve.doneApproved": "这台设备已接入你的账号,现在即可在它上面收发文件。", "qr.approve.doneApproved": "这台设备已接入你的账号,现在即可在它上面收发文件。",
"qr.approve.doneDeniedTitle": "已拒绝", "qr.approve.doneDeniedTitle": "已拒绝",
"qr.approve.doneDenied": "这次接入已被拒绝,那台设备不会登你的账号。", "qr.approve.doneDenied": "这次接入已被拒绝,那台设备不会登你的账号。",
"qr.approve.expiredTitle": "二维码已过期", "qr.approve.expiredTitle": "二维码已过期",
"qr.approve.expired": "这张二维码已失效。请在新设备上重新生成再扫。", "qr.approve.expired": "这张二维码已失效。请在新设备上重新生成再扫。",
"qr.approve.errorTitle": "出错了", "qr.approve.errorTitle": "出错了",
@@ -148,16 +170,38 @@ export const zhCN = {
"settings.unregister.currentHint": "将退出登录并移除此设备的注册。", "settings.unregister.currentHint": "将退出登录并移除此设备的注册。",
"settings.unregister.currentConfirm": "settings.unregister.currentConfirm":
"注销当前设备会移除此设备的注册并退出登录,确认继续?", "注销当前设备会移除此设备的注册并退出登录,确认继续?",
"settings.unregister.peerConfirm": "确认要移除设备“{{name}}”?",
"settings.peers.title": "其他设备",
"settings.peers.empty": "暂无其他设备。",
"settings.peers.remove": "移除",
"settings.peers.removing": "正在移除…",
"settings.error.delete": "操作失败:{{message}}", "settings.error.delete": "操作失败:{{message}}",
// ---- 受限访客标识 ----------------------------------------------------
"settings.guest.badge": "受限访客",
"settings.guest.title": "这台设备是受限访客",
"settings.guest.body": "本设备以受限访客身份登录:可正常收发文件、消息与剪贴板,但不能管理账号、移除设备或授权新设备。需要完整权限,请在你的主设备上重新登录。",
// ---- 登录会话 / 设备管理 ---------------------------------------------
"settings.sessions.title": "登录会话",
"settings.sessions.hint": "这里列出当前登录此账号的所有设备,含浏览器、桌面与移动客户端;在线的排在上方。登出某台设备会立即结束它的登录,需在该设备上重新登录才能继续使用。",
"settings.sessions.guestNote": "受限访客无法管理登录会话;如需管理,请在你的主设备上完整登录。",
"settings.sessions.empty": "暂无其他登录会话。",
"settings.sessions.loadError": "登录会话加载失败。",
"settings.sessions.retry": "重试",
"settings.sessions.current": "本机",
"settings.sessions.scopeFull": "完整",
"settings.sessions.scopeGuest": "受限访客",
"settings.sessions.kind.oidc": "账号登录",
"settings.sessions.kind.self": "本地账号",
"settings.sessions.kind.guest": "扫码访客",
"settings.sessions.lastUsed": "最近活跃 {{time}}",
"settings.sessions.neverUsed": "尚未活跃",
"settings.sessions.signOut": "登出",
"settings.sessions.signingOut": "正在登出…",
"settings.sessions.signOutCurrent": "登出本机",
"settings.sessions.confirmOther": "确认登出设备“{{name}}”?该会话将立即失效,需重新登录。",
"settings.sessions.confirmCurrent": "登出本机会等同于退出登录,确认继续?",
"settings.sessions.revoked": "已登出该设备",
"settings.sessions.stepUpRejected": "身份验证未通过或已过期,请再试一次。",
"settings.online": "在线", "settings.online": "在线",
"settings.offline": "离线", "settings.offline": "离线",
"settings.lastSeen": "上次活跃 {{time}}", "settings.lastSeen": "上次活跃 {{time}}",
"settings.lastSeenNever": "尚未上线",
"settings.push.title": "推送通知", "settings.push.title": "推送通知",
"settings.push.hint": "页面关闭时,新文件、消息和传输结果会以系统通知提醒;页面打开时仍走应用内提示。", "settings.push.hint": "页面关闭时,新文件、消息和传输结果会以系统通知提醒;页面打开时仍走应用内提示。",
"settings.push.enable": "启用推送", "settings.push.enable": "启用推送",
+53 -9
View File
@@ -18,6 +18,10 @@ export const zhTW: Partial<TranslationDict> = {
"app.disconnectedTitle": "正在重新連線", "app.disconnectedTitle": "正在重新連線",
"app.disconnectedHint": "與伺服器的連線已中斷,正在自動重新連線,無需重新整理頁面。", "app.disconnectedHint": "與伺服器的連線已中斷,正在自動重新連線,無需重新整理頁面。",
// ---- 工作階段失效(被遠端撤銷 / 逾期)-------------------------------
"auth.sessionLost.title": "登入已失效",
"auth.sessionLost.body": "此裝置的登入憑證已被撤銷或逾期,請重新登入。",
// ---- 頂部列 / 使用者選單 --------------------------------------------- // ---- 頂部列 / 使用者選單 ---------------------------------------------
"nav.accountMenu": "帳號選單", "nav.accountMenu": "帳號選單",
"nav.thisDeviceLabel": "本機:", "nav.thisDeviceLabel": "本機:",
@@ -36,6 +40,7 @@ export const zhTW: Partial<TranslationDict> = {
"home.deviceList.title": "裝置", "home.deviceList.title": "裝置",
"home.deviceList.onlineSuffix": "{{count}} 部線上)", "home.deviceList.onlineSuffix": "{{count}} 部線上)",
"home.deviceList.showOffline": "顯示離線裝置({{count}}", "home.deviceList.showOffline": "顯示離線裝置({{count}}",
"home.deviceList.hideOffline": "隱藏離線裝置",
"home.deviceList.empty": "home.deviceList.empty":
"尚無其他裝置。在另一個分頁或裝置上以同一帳號登入,並設定一個不同的裝置名稱(設定 → 裝置名稱),即可在此處看到它。", "尚無其他裝置。在另一個分頁或裝置上以同一帳號登入,並設定一個不同的裝置名稱(設定 → 裝置名稱),即可在此處看到它。",
"home.tabs.file": "檔案", "home.tabs.file": "檔案",
@@ -97,6 +102,20 @@ export const zhTW: Partial<TranslationDict> = {
// ---- 掃碼登入 · 入口 ------------------------------------------------- // ---- 掃碼登入 · 入口 -------------------------------------------------
"qr.entry.fromLogin": "掃碼登入此裝置", "qr.entry.fromLogin": "掃碼登入此裝置",
"qr.entry.scanNewDevice": "掃碼登入新裝置",
// 應用內掃碼器:在已登入裝置上掃新裝置的 QR 碼,把它接入帳號。
"qr.scan.title": "掃碼登入新裝置",
"qr.scan.subtitle": "把另一部裝置顯示的 QR 碼對準取景框。批准全程留在這部已登入裝置上。",
"qr.scan.starting": "正在啟動相機…",
"qr.scan.aiming": "把 QR 碼對準取景框",
"qr.scan.foreign": "這不是本站的登入 QR 碼。請對準新裝置上「掃碼登入此裝置」產生的碼。",
"qr.scan.noCamera": "這部裝置沒有可用的相機。請改用有相機的手機或平板掃碼。",
"qr.scan.denied": "相機權限被拒絕。請在瀏覽器網站設定裡允許使用相機,再重新開啟此頁。",
"qr.scan.error": "無法開啟相機。請重試,或改用有相機的裝置掃碼。",
"qr.scan.howto1": "在新裝置上開啟「掃碼登入此裝置」,產生一張 QR 碼。",
"qr.scan.howto2": "把那張 QR 碼對準上方取景框,辨識後會自動進入批准。",
"qr.scan.back": "返回",
// ---- 掃碼登入 · 顯碼頁(新裝置)------------------------------------- // ---- 掃碼登入 · 顯碼頁(新裝置)-------------------------------------
"qr.show.title": "掃碼登入此裝置", "qr.show.title": "掃碼登入此裝置",
@@ -111,6 +130,8 @@ export const zhTW: Partial<TranslationDict> = {
"qr.show.expired": "QR 碼已逾時。重新產生一張再掃。", "qr.show.expired": "QR 碼已逾時。重新產生一張再掃。",
"qr.show.denied": "這次接入被拒絕。如確為你本人操作,重新產生 QR 碼再試。", "qr.show.denied": "這次接入被拒絕。如確為你本人操作,重新產生 QR 碼再試。",
"qr.show.regenerate": "重新產生 QR 碼", "qr.show.regenerate": "重新產生 QR 碼",
"qr.show.back": "返回首頁",
"qr.show.backToName": "返回上一步",
// ---- 掃碼登入 · 批准頁(手機)------------------------------------- // ---- 掃碼登入 · 批准頁(手機)-------------------------------------
"qr.approve.title": "批准新裝置", "qr.approve.title": "批准新裝置",
@@ -119,11 +140,12 @@ export const zhTW: Partial<TranslationDict> = {
"qr.approve.badLink": "這個批准連結不完整。請在新裝置上重新產生 QR 碼再掃。", "qr.approve.badLink": "這個批准連結不完整。請在新裝置上重新產生 QR 碼再掃。",
"qr.approve.signInFirst": "先登入你的帳號,再批准這部裝置接入。", "qr.approve.signInFirst": "先登入你的帳號,再批准這部裝置接入。",
"qr.approve.trustLabel": "信任時長", "qr.approve.trustLabel": "信任時長",
"qr.approve.trust.title": "信任此裝置", "qr.approve.trust.title": "信任此裝置7 天)",
"qr.approve.trust.hint": "7 天內免再次批准,每次使用自動續期。", "qr.approve.trust.hint": "7 天內免再次批准,每次使用自動續期。",
"qr.approve.once.title": "僅此一次", "qr.approve.once.title": "僅此一次1 小時)",
"qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。", "qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。",
"qr.approve.scopeGuest": "這部裝置將以受限訪客身分登入:可收發檔案與訊息,但不能變更帳號、不能批准其他裝置。", "qr.approve.scopeFull": "這部裝置將完整登入你的帳號、7 天內有效:可正常收發檔案與訊息,並能管理帳號與裝置。",
"qr.approve.scopeGuest": "以受限訪客身分登入——可收發檔案與訊息,但不能變更帳號、不能批准其他裝置;1 小時後失效。",
"qr.approve.approve": "批准登入", "qr.approve.approve": "批准登入",
"qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。", "qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。",
"qr.approve.stepUp.button": "驗證並批准", "qr.approve.stepUp.button": "驗證並批准",
@@ -152,16 +174,38 @@ export const zhTW: Partial<TranslationDict> = {
"settings.unregister.currentHint": "將登出並移除本裝置的註冊。", "settings.unregister.currentHint": "將登出並移除本裝置的註冊。",
"settings.unregister.currentConfirm": "settings.unregister.currentConfirm":
"解除註冊本裝置會移除其註冊並登出,確認繼續?", "解除註冊本裝置會移除其註冊並登出,確認繼續?",
"settings.unregister.peerConfirm": "確認要移除裝置「{{name}}」?",
"settings.peers.title": "其他裝置",
"settings.peers.empty": "尚無其他裝置。",
"settings.peers.remove": "移除",
"settings.peers.removing": "正在移除…",
"settings.error.delete": "操作失敗:{{message}}", "settings.error.delete": "操作失敗:{{message}}",
// ---- 受限訪客標識 ----------------------------------------------------
"settings.guest.badge": "受限訪客",
"settings.guest.title": "這部裝置是受限訪客",
"settings.guest.body": "本裝置以受限訪客身分登入:可正常收發檔案、訊息與剪貼簿,但不能管理帳號、移除裝置或授權新裝置。需要完整權限,請在你的主裝置上重新登入。",
// ---- 登入工作階段 / 裝置管理 ------------------------------------------
"settings.sessions.title": "登入工作階段",
"settings.sessions.hint": "這裡列出目前登入此帳號的所有裝置,含瀏覽器、桌面與行動用戶端;線上的排在上方。登出某台裝置會立即結束它的登入,需在該裝置上重新登入才能繼續使用。",
"settings.sessions.guestNote": "受限訪客無法管理登入工作階段;如需管理,請在你的主裝置上完整登入。",
"settings.sessions.empty": "尚無其他登入工作階段。",
"settings.sessions.loadError": "登入工作階段載入失敗。",
"settings.sessions.retry": "重試",
"settings.sessions.current": "本機",
"settings.sessions.scopeFull": "完整",
"settings.sessions.scopeGuest": "受限訪客",
"settings.sessions.kind.oidc": "帳號登入",
"settings.sessions.kind.self": "本機帳號",
"settings.sessions.kind.guest": "掃碼訪客",
"settings.sessions.lastUsed": "最近活躍 {{time}}",
"settings.sessions.neverUsed": "尚未活躍",
"settings.sessions.signOut": "登出",
"settings.sessions.signingOut": "正在登出…",
"settings.sessions.signOutCurrent": "登出本機",
"settings.sessions.confirmOther": "確認登出裝置「{{name}}」?該工作階段將立即失效,需重新登入。",
"settings.sessions.confirmCurrent": "登出本機等同於登出帳號,確認繼續?",
"settings.sessions.revoked": "已登出該裝置",
"settings.sessions.stepUpRejected": "身分驗證未通過或已過期,請再試一次。",
"settings.online": "線上", "settings.online": "線上",
"settings.offline": "離線", "settings.offline": "離線",
"settings.lastSeen": "上次活躍 {{time}}", "settings.lastSeen": "上次活躍 {{time}}",
"settings.lastSeenNever": "尚未上線",
"settings.push.title": "推播通知", "settings.push.title": "推播通知",
"settings.push.hint": "頁面關閉時,新檔案、訊息與傳輸結果會以系統通知提醒;頁面開啟時仍走應用內提示。", "settings.push.hint": "頁面關閉時,新檔案、訊息與傳輸結果會以系統通知提醒;頁面開啟時仍走應用內提示。",
"settings.push.enable": "啟用推播", "settings.push.enable": "啟用推播",
+19 -3
View File
@@ -1,5 +1,5 @@
import { useAppStore } from "../store"; import { useAppStore } from "../store";
import { refreshTokens } from "../features/auth/auth"; import { refreshTokens, forceLogout } from "../features/auth/auth";
import { readInjectedApiBase, readInjectedDeviceType } from "../store/helpers"; import { readInjectedApiBase, readInjectedDeviceType } from "../store/helpers";
import { toAsciiDeviceName } from "../utils/format"; import { toAsciiDeviceName } from "../utils/format";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -26,19 +26,35 @@ function withBase(input: RequestInfo): RequestInfo
// - always sets X-Device-Name from the store // - always sets X-Device-Name from the store
// - in prod, on a 401 response transparently refreshes the access token via // - in prod, on a 401 response transparently refreshes the access token via
// /api/auth/refresh and retries once // /api/auth/refresh and retries once
// - in prod, ONLY if the server authoritatively confirms the session is gone
// (refresh → HTTP 401), forces a local logout so the app returns to login
// //
// Returns the fetch Response untouched so callers can drive streaming bodies // Returns the fetch Response untouched so callers can drive streaming bodies
// (SSE, relay GET stream) themselves. // (SSE, relay GET stream) themselves.
//
// State is cleared ONLY on server-confirmed credential invalidation. Short-term
// failures never clear it: a transient refresh response (5xx / same-origin 403)
// yields "transient", and a network error during refresh THROWS out of here —
// both keep the session, leaving the original 401 for the caller as a retryable
// error. So a flaky network or a server blip can't log the user out; only a
// genuine revoke/expire (refresh → 401) does.
export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response> export async function apiFetch(input: RequestInfo, init?: RequestInit): Promise<Response>
{ {
let r = await doFetch(input, init); let r = await doFetch(input, init);
if (r.status === 401 && useAppStore.getState().authMode === "prod") if (r.status === 401 && useAppStore.getState().authMode === "prod")
{ {
const ok = await refreshTokens(); const outcome = await refreshTokens();
if (ok) if (outcome === "refreshed")
{ {
r = await doFetch(input, init); r = await doFetch(input, init);
} }
else if (outcome === "invalid")
{
// 服务端确证会话已失效(/auth/refresh 返回 401,已被吊销 / 过期)→
// 清空本地登录态,由 __root 守卫把界面送回登录页。返回这次 401 让调用
// 方照常收尾。"transient"5xx / 网络抖动)不进此分支:保留登录态。
forceLogout();
}
} }
return r; return r;
} }
+34 -2
View File
@@ -217,8 +217,9 @@ export class StepUpRequiredError extends Error
} }
} }
// qrApprove 批准该设备登入本账号。scope 本期固定 guest(受限访客);persist 决定 // qrApprove 批准该设备登入本账号。scope 决定授予的会话级别:full=完整登录、
// 信任时长(persist=7 天滑动 / once=1 小时)。成功 204。 // guest=受限访客;与 persist 时长一一对应(full+persist=7 天滑动、guest+once=1 小时)。
// 成功 204。
// //
// step_up 开启时须带上一次新鲜 prompt=login 再认证拿到的 stepUpCode + stepUpVerifier // step_up 开启时须带上一次新鲜 prompt=login 再认证拿到的 stepUpCode + stepUpVerifier
// 后端就地向 IdP 换 id_token、验签 + 校 auth_time 新鲜 + sub 匹配;缺失 / 过期 → // 后端就地向 IdP 换 id_token、验签 + 校 auth_time 新鲜 + sub 匹配;缺失 / 过期 →
@@ -278,3 +279,34 @@ function sleep(ms: number): Promise<void>
{ {
return new Promise((resolve) => { window.setTimeout(resolve, ms); }); return new Promise((resolve) => { window.setTimeout(resolve, ms); });
} }
// ---- 应用内扫码:批准链接解析 --------------------------------------------------
export interface LinkParams
{
requestId: string;
code: string;
}
// parseLinkApprovalUrl 把扫到的二维码内容解析成批准页参数,严格校验后才放行——
// 这是应用内扫码器的安全闸门,决定扫到的码能否在已登录会话内被导航。
// 只接受「本站 origin + /link 路径 + 同时带 r 与 c」的 URL:
// - 同源(origin 严格等于当前页 origin)——挡开放重定向 / 钓鱼站二维码;
// - 路径恰为 /link——挡指向本站其他页的码;
// - r/c 皆非空——挡残缺码。
// 任一不满足返回 null(调用方据此提示「继续对准」,不导航)。
export function parseLinkApprovalUrl(raw: string): LinkParams | null
{
let url: URL;
try { url = new URL(raw); }
catch { return null; }
if (url.origin !== window.location.origin) { return null; }
if (url.pathname !== "/link") { return null; }
const requestId = url.searchParams.get("r");
const code = url.searchParams.get("c");
if (!requestId || !code) { return null; }
return { requestId, code };
}
+156
View File
@@ -0,0 +1,156 @@
import { apiFetch, apiJSON } from "./api";
// 登录会话 / 设备的网络封装。全部走 apiFetch(带 Bearer + 401 自动续期);
// refresh_token 永不进 JS——吊销由后端令短 TTL 的 access_token 随之失效完成。
// ---- /api/me:本会话权限级别 ------------------------------------------------
export type SessionScopeValue = "full" | "guest";
interface MeRaw
{
// /api/me 现回传本会话的 scopefull=完整登录、guest=受限访客)。旧后端 / 异常
// 缺字段时按 "full" 解析(只多展示入口,后端仍 403 兜底)。
scope?: string;
}
// fetchMeScope 拉取 /api/me 并解析本会话权限级别。失败 / 缺字段回退 "full"。
export async function fetchMeScope(): Promise<SessionScopeValue>
{
try
{
const data = await apiJSON<MeRaw>("/api/me");
return data.scope === "guest" ? "guest" : "full";
}
catch
{
return "full";
}
}
// ---- 登录会话列表 / 吊销 / step-up ------------------------------------------
// 会话「种类」:oidc / self / guest 为网页 / 扫码会话;macos / windows / linux / ios
// 为原生客户端(桌面 / 移动)——它们用 IdP 令牌、不入 web_sessions,由设备登记表呈现。
export type SessionKind =
| "oidc" | "self" | "guest"
| "macos" | "windows" | "linux" | "ios";
const KNOWN_KINDS: readonly SessionKind[] = [
"oidc", "self", "guest", "macos", "windows", "linux", "ios",
];
export interface AuthSession
{
id: string;
deviceName: string;
kind: SessionKind;
scope: SessionScopeValue;
// current=true:这条就是本机当前会话(登出它等价于退出登录)。
current: boolean;
// online=true:该设备当前在线(有活跃 SSE 连接)。用于列表排序与在线指示。
online: boolean;
// native=true:原生客户端(无 web_session),登出走设备登记端点而非会话端点。
native: boolean;
createdAt: number;
lastUsedAt: number;
expiresAt: number;
}
interface AuthSessionRaw
{
id: string;
device_name?: string;
kind?: string;
scope?: string;
current?: boolean;
online?: boolean;
native?: boolean;
created_at?: number;
last_used_at?: number;
expires_at?: number;
}
interface SessionsResp
{
sessions: AuthSessionRaw[];
}
function normalizeKind(kind: string | undefined): SessionKind
{
return KNOWN_KINDS.includes(kind as SessionKind) ? (kind as SessionKind) : "oidc";
}
// listSessions 拉取当前账号下的全部登录会话(含本机),每条带 online(当前是否在线)。
// 受限访客无权管理登录会话、后端会 403,故调用方(SessionsPanel)在 guest 时根本不发
// 此请求、直接展示克制说明,不会走到这里。
export async function listSessions(): Promise<AuthSession[]>
{
const data = await apiJSON<SessionsResp>("/api/auth/sessions");
const rows = Array.isArray(data.sessions) ? data.sessions : [];
return rows.map((r) => (
{
id: r.id,
deviceName: r.device_name ?? "",
kind: normalizeKind(r.kind),
scope: r.scope === "guest" ? "guest" : "full",
current: r.current === true,
online: r.online === true,
native: r.native === true,
createdAt: r.created_at ?? 0,
lastUsedAt: r.last_used_at ?? 0,
expiresAt: r.expires_at ?? 0,
}));
}
// StepUpRequiredErrorDELETE /api/auth/sessions/{id} 因再认证缺失 / 过期被后端拒
// 403 {error:"step_up_required"})。调用方据此发起一次 step-up 再认证而非当成
// 普通错误。
export class StepUpRequiredError extends Error
{
constructor()
{
super("step_up_required");
this.name = "StepUpRequiredError";
}
}
// revokeSession 真正吊销一条登录会话。网页 / 扫码会话走 /auth/sessions/{id}(删会话
// 行 + 连带删设备);原生客户端(id 形如 "device:设备名")无 web_session,改走
// /devices/{name} 按设备名注销。两者均:成功 204;后端要求再认证时抛
// StepUpRequiredError403 step_up_required),其余非 2xx 抛普通错误。
const NATIVE_ID_PREFIX = "device:";
export async function revokeSession(id: string): Promise<void>
{
const path = id.startsWith(NATIVE_ID_PREFIX)
? `/api/devices/${encodeURIComponent(id.slice(NATIVE_ID_PREFIX.length))}`
: `/api/auth/sessions/${encodeURIComponent(id)}`;
const r = await apiFetch(path, { method: "DELETE" });
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
if (r.status === 403 && /step_up_required/.test(text))
{
throw new StepUpRequiredError();
}
throw new Error(`revoke session ${r.status}: ${text}`);
}
}
// postStepUp 把一次新鲜 prompt=login 再认证拿到的 {code, verifier} 交给后端就地
// 核验,成功后服务端在本会话记下 stepped_up_at(5 分钟窗口内复用,不再反复要求)。
// 成功 204step-up 未开后端亦 204(视作通过);失败 403。
export async function postStepUp(code: string, verifier: string): Promise<void>
{
const r = await apiFetch("/api/auth/stepup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, verifier }),
});
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`step-up ${r.status}: ${text}`);
}
}
+27 -3
View File
@@ -11,14 +11,14 @@ import { Outlet, Link, createRootRoute, useLocation, useNavigate } from "@tansta
import { Check, ChevronDown, LogOut, Monitor, Moon, Settings, Sun, Wifi, WifiOff } from "lucide-react"; import { Check, ChevronDown, LogOut, Monitor, Moon, Settings, Sun, Wifi, WifiOff } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { useAppStore, type ThemeMode } from "../store"; import { useAppStore, type ThemeMode } from "../store";
import { logout } from "../features/auth/auth"; import { logout, refreshSessionScope } from "../features/auth/auth";
import { uploadClipboard } from "../features/clipboard/clipboard"; import { uploadClipboard } from "../features/clipboard/clipboard";
import { onNativeClipboard } from "../net/desktop"; import { onNativeClipboard } from "../net/desktop";
import { startHub } from "../features/transfer/hub"; import { startHub } from "../features/transfer/hub";
import { resyncPush } from "../net/push"; import { resyncPush } from "../net/push";
import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers"; import { refreshICEServers, stopICEServerRefresh } from "../features/transfer/iceServers";
import { t, type Locale } from "../i18n"; import { t, type Locale } from "../i18n";
import { Callout, IconButton, Tooltip } from "../ui/primitives"; import { Badge, Callout, IconButton, Tooltip } from "../ui/primitives";
import { Wordmark } from "../ui/brand"; import { Wordmark } from "../ui/brand";
export const Route = createRootRoute({ component: RootLayout }); export const Route = createRootRoute({ component: RootLayout });
@@ -26,6 +26,8 @@ export const Route = createRootRoute({ component: RootLayout });
function RootLayout() function RootLayout()
{ {
const user = useAppStore((s) => s.user); const user = useAppStore((s) => s.user);
const authMode = useAppStore((s) => s.authMode);
const sessionScope = useAppStore((s) => s.sessionScope);
const selfDeviceName = useAppStore((s) => s.selfDeviceName); const selfDeviceName = useAppStore((s) => s.selfDeviceName);
const sseConnected = useAppStore((s) => s.sseConnected); const sseConnected = useAppStore((s) => s.sseConnected);
const sseReconnecting = useAppStore((s) => s.sseReconnecting); const sseReconnecting = useAppStore((s) => s.sseReconnecting);
@@ -39,6 +41,19 @@ function RootLayout()
// 这里只渲染 <Outlet/>,不包 AppShell,从而隐去顶栏。 // 这里只渲染 <Outlet/>,不包 AppShell,从而隐去顶栏。
const fullscreen = isAuthRoute(location.pathname); const fullscreen = isAuthRoute(location.pathname);
// 反应式登出守卫:当登录态在运行中被清空(access token 被远程吊销 / 过期 →
// apiFetch 触发 forceLogout,见 net/api.ts),把界面送回登录页。路由的
// beforeLoad 只在导航时跑,停在当前页时不会自动重定向,故这里补一道反应式跳转。
// 仅 prod:dev 无远程吊销概念,登录态由 loginDev 同步建立。已在认证类路由上则
// 不跳(避免与 /login 自身、扫码显码 / 批准页打架)。
useEffect(() =>
{
if (authMode !== "prod") { return; }
if (user) { return; }
if (isAuthRoute(location.pathname)) { return; }
navigate({ to: "/login", search: { dev_user: undefined } });
}, [ authMode, user, location.pathname, navigate ]);
// SSE 在登录后立即开启;user.id 或 selfDeviceName 任一变化即重连 // SSE 在登录后立即开启;user.id 或 selfDeviceName 任一变化即重连
// (改名后会自动以新设备名重新注册)。 // (改名后会自动以新设备名重新注册)。
useEffect(() => useEffect(() =>
@@ -46,6 +61,9 @@ function RootLayout()
if (!user || !selfDeviceName) { return; } if (!user || !selfDeviceName) { return; }
const ctrl = new AbortController(); const ctrl = new AbortController();
startHub(ctrl.signal); startHub(ctrl.signal);
// 校正本会话权限级别(/api/me 的 scope):受限访客据此隐藏账号管理入口。
// 覆盖所有登录路径(OAuth / 扫码新设备 / 开机水合 / dev),一处搞定。
void refreshSessionScope();
// 预取 Cloudflare TURN 凭据(或 STUN 回退),首次 WebRTC 同步可用。 // 预取 Cloudflare TURN 凭据(或 STUN 回退),首次 WebRTC 同步可用。
void refreshICEServers(); void refreshICEServers();
// 已开启推送的浏览器:登录后把订阅重新登记到当前用户名下(自愈换用户 / // 已开启推送的浏览器:登录后把订阅重新登记到当前用户名下(自愈换用户 /
@@ -125,7 +143,12 @@ function RootLayout()
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Stack gap={2} px="sm" pt="xs" pb={6}> <Stack gap={2} px="sm" pt="xs" pb={6}>
<Text size="sm" fw={500} truncate>{user.name}</Text> <Group gap={6} wrap="nowrap" align="center">
<Text size="sm" fw={500} truncate>{user.name}</Text>
{sessionScope === "guest" && (
<Badge tone="warn">{t("settings.guest.badge")}</Badge>
)}
</Group>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t("nav.thisDeviceLabel")} {t("nav.thisDeviceLabel")}
<Text span fw={500}>{selfDeviceName}</Text> <Text span fw={500}>{selfDeviceName}</Text>
@@ -316,6 +339,7 @@ function isAuthRoute(pathname: string): boolean
|| pathname === "/setup" || pathname === "/setup"
|| pathname === "/link" || pathname === "/link"
|| pathname === "/link/new" || pathname === "/link/new"
|| pathname === "/link/scan"
|| pathname.startsWith("/oauth/"); || pathname.startsWith("/oauth/");
} }
+10 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Container } from "@mantine/core"; import { Container } from "@mantine/core";
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { useAppStore } from "../store"; import { useAppStore } from "../store";
import { consumeLinkReturn } from "../features/qr/returnTo"; import { consumeLinkReturn } from "../features/qr/returnTo";
import { DeviceStrip } from "../features/devices/DeviceStrip"; import { DeviceStrip } from "../features/devices/DeviceStrip";
@@ -33,7 +33,9 @@ void CLIPBOARD_MAX_BYTES;
function HomePage() function HomePage()
{ {
const navigate = useNavigate();
const user = useAppStore((s) => s.user); const user = useAppStore((s) => s.user);
const sessionScope = useAppStore((s) => s.sessionScope);
const devices = useAppStore((s) => s.devices); const devices = useAppStore((s) => s.devices);
const selfDeviceName = useAppStore((s) => s.selfDeviceName); const selfDeviceName = useAppStore((s) => s.selfDeviceName);
const activeTransfers = useAppStore((s) => s.activeTransfers); const activeTransfers = useAppStore((s) => s.activeTransfers);
@@ -96,6 +98,13 @@ function HomePage()
selectedDevice={selectedDevice} selectedDevice={selectedDevice}
onSelect={setSelectedDevice} onSelect={setSelectedDevice}
onDropFile={handleDropFile} onDropFile={handleDropFile}
// 受限访客不能授权新设备:不传 onLinkDeviceDeviceStrip 据此
// 隐藏「扫码登录新设备」入口(条件渲染已在组件内)。
onLinkDevice={
sessionScope === "guest"
? undefined
: () => navigate({ to: "/link/scan" })
}
/> />
<div className={s.dualColumn}> <div className={s.dualColumn}>
+20 -7
View File
@@ -77,27 +77,34 @@
width: 100%; width: 100%;
text-align: left; text-align: left;
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
background: var(--surface); /* 未选中态明显更轻:sunken 底 + 细分隔线左条,与选中态拉开对比。 */
background: var(--surface-sunken);
border: 1px solid var(--divider); border: 1px solid var(--divider);
border-left: 3px solid var(--divider); border-left: 3px solid var(--divider);
border-radius: var(--radius-control); border-radius: var(--radius-control);
cursor: pointer; cursor: pointer;
color: var(--text); color: var(--text-muted);
transition: transition:
border-color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out),
background-color var(--dur-fast) var(--ease-out); background-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
} }
.choice:hover { border-color: var(--divider-strong); } .choice:hover { border-color: var(--divider-strong); }
/* 选中态:accent 左色条 + soft 底(同 Callout / Activity 的状态编码语汇)。 */ /* 选中态:实心 accent 边框 + 加粗左色条 + accent 描边环 + soft 底(同
DeviceStrip.portSelected 的强选中语汇),与未选中态形成明显对比。 */
.choiceOn .choiceOn
{ {
background: var(--accent-softer); background: var(--accent-soft);
border-color: var(--accent-ring); border-color: var(--accent);
border-left-color: var(--accent); border-left: 4px solid var(--accent);
color: var(--text);
box-shadow: 0 0 0 1px var(--accent);
} }
.choiceOn:hover { border-color: var(--accent); }
.choice:focus-visible .choice:focus-visible
{ {
outline: 2px solid var(--accent-ring); outline: 2px solid var(--accent-ring);
@@ -130,6 +137,9 @@
font-weight: 600; font-weight: 600;
} }
/* 选中项标题着 accent 色,进一步强化「当前选的是这个」。 */
.choiceOn .choiceTitle { color: var(--accent); }
.choiceHint .choiceHint
{ {
font-size: var(--fs-12); font-size: var(--fs-12);
@@ -137,6 +147,9 @@
color: var(--text-muted); color: var(--text-muted);
} }
/* 选中项说明文字恢复常规对比(未选中时整块被压暗)。 */
.choiceOn .choiceHint { color: var(--text-muted); }
/* 批准 / 拒绝:批准是主色行动,拒绝是安静的 ghost。 */ /* 批准 / 拒绝:批准是主色行动,拒绝是安静的 ghost。 */
.actions .actions
{ {
+36 -11
View File
@@ -23,6 +23,8 @@ export const Route = createFileRoute("/link")({
({ ({
r: typeof search.r === "string" ? search.r : undefined, r: typeof search.r === "string" ? search.r : undefined,
c: typeof search.c === "string" ? search.c : undefined, c: typeof search.c === "string" ? search.c : undefined,
// p:信任时长选择(persist/once)。step-up 整页跳转回来后据此恢复,避免选择丢失。
p: typeof search.p === "string" ? search.p : undefined,
}), }),
}); });
@@ -31,12 +33,14 @@ type Outcome = "approved" | "denied" | "expired" | "error" | null;
function LinkApprovePage() function LinkApprovePage()
{ {
const navigate = useNavigate(); const navigate = useNavigate();
const { r: requestId, c: code } = Route.useSearch(); const { r: requestId, c: code, p: persistParam } = Route.useSearch();
const user = useAppStore((s) => s.user); const user = useAppStore((s) => s.user);
const [ info, setInfo ] = useState<QrRequestInfo | null>(null); const [ info, setInfo ] = useState<QrRequestInfo | null>(null);
const [ loadError, setLoadError ] = useState<string | null>(null); const [ loadError, setLoadError ] = useState<string | null>(null);
const [ persist, setPersist ] = useState<QrPersist>("persist"); // 初值优先取 URL 的 p(step-up 跳转回来后恢复选择),否则默认更安全的「仅此次」
// (受限访客)——避免一路默认到完整会话(full)。
const [ persist, setPersist ] = useState<QrPersist>(persistParam === "persist" ? "persist" : "once");
const [ submitting, setSubmitting ] = useState(false); const [ submitting, setSubmitting ] = useState(false);
const [ outcome, setOutcome ] = useState<Outcome>(null); const [ outcome, setOutcome ] = useState<Outcome>(null);
const [ actionError, setActionError ] = useState<string | null>(null); const [ actionError, setActionError ] = useState<string | null>(null);
@@ -92,7 +96,10 @@ function LinkApprovePage()
setStepUpRejected(false); setStepUpRejected(false);
try try
{ {
await qrApprove(requestId, code, "guest", persist, stash?.code, stash?.verifier); // persist=「信任此设备」→ 完整会话(scope=full);once=「仅此次」→ 受限访客
// scope=guest)。后端按 scope 决定授予的会话级别,二者与 persist 时长一一对应。
const scope = persist === "persist" ? "full" : "guest";
await qrApprove(requestId, code, scope, persist, stash?.code, stash?.verifier);
setOutcome("approved"); setOutcome("approved");
} }
catch (e) catch (e)
@@ -124,7 +131,15 @@ function LinkApprovePage()
setSubmitting(true); setSubmitting(true);
setActionError(null); setActionError(null);
setStepUpRejected(false); setStepUpRejected(false);
try { await startStepUpReauth(window.location.pathname + window.location.search); } try
{
// 把当前「信任时长」选择编进 returnTo:step-up 整页跳转回来后页面重载、
// React state 复位,必须从 URL 恢复 persist,否则会回落默认、令本应受限
// 的设备被建成完整会话(#2 根因)。
const back = new URLSearchParams(window.location.search);
back.set("p", persist);
await startStepUpReauth(window.location.pathname + "?" + back.toString());
}
catch (e) catch (e)
{ {
setActionError(e instanceof Error ? e.message : String(e)); setActionError(e instanceof Error ? e.message : String(e));
@@ -155,10 +170,13 @@ function LinkApprovePage()
if (!requestId || !code) if (!requestId || !code)
{ {
return ( return (
<AuthShell title={t("qr.approve.title")} hideBrand> <AuthShell title={t("qr.approve.title")} hideBrand skipAutospace>
<Callout tone="error" icon={<AlertTriangle size={16} />}> <Callout tone="error" icon={<AlertTriangle size={16} />}>
{t("qr.approve.badLink")} {t("qr.approve.badLink")}
</Callout> </Callout>
<Button variant="secondary" size="lg" block onClick={() => navigate({ to: "/" })}>
{t("qr.approve.backHome")}
</Button>
</AuthShell> </AuthShell>
); );
} }
@@ -170,6 +188,7 @@ function LinkApprovePage()
title={t("qr.approve.title")} title={t("qr.approve.title")}
subtitle={t("qr.approve.signInFirst")} subtitle={t("qr.approve.signInFirst")}
hideBrand hideBrand
skipAutospace
> >
<Button <Button
variant="primary" variant="primary"
@@ -189,8 +208,11 @@ function LinkApprovePage()
if (loadError) if (loadError)
{ {
return ( return (
<AuthShell title={t("qr.approve.title")} hideBrand> <AuthShell title={t("qr.approve.title")} hideBrand skipAutospace>
<Callout tone="error" icon={<AlertTriangle size={16} />}>{loadError}</Callout> <Callout tone="error" icon={<AlertTriangle size={16} />}>{loadError}</Callout>
<Button variant="secondary" size="lg" block onClick={() => navigate({ to: "/" })}>
{t("qr.approve.backHome")}
</Button>
</AuthShell> </AuthShell>
); );
} }
@@ -198,12 +220,12 @@ function LinkApprovePage()
if (!info) if (!info)
{ {
return ( return (
<AuthShell title={t("qr.approve.title")} subtitle={t("qr.approve.loading")} hideBrand /> <AuthShell title={t("qr.approve.title")} subtitle={t("qr.approve.loading")} hideBrand skipAutospace />
); );
} }
return ( return (
<AuthShell title={t("qr.approve.title")} subtitle={t("qr.approve.subtitle")} hideBrand> <AuthShell title={t("qr.approve.title")} subtitle={t("qr.approve.subtitle")} hideBrand skipAutospace>
{/* 待批准设备:单行身份块——图标 + 名字 + 来源 IP(monospace 元数据)。 */} {/* 待批准设备:单行身份块——图标 + 名字 + 来源 IP(monospace 元数据)。 */}
<div className={s.device}> <div className={s.device}>
<span className={s.deviceIcon}> <span className={s.deviceIcon}>
@@ -237,9 +259,12 @@ function LinkApprovePage()
/> />
</div> </div>
{/* 权限范围说明:本期固定受限访客,明确告知边界(非装饰)。 */} {/* 权限范围说明:随选中项切换——信任=完整登录、仅此次=受限访客,明确告知
这台设备将以什么身份登录(非装饰)。 */}
<Callout tone="info" icon={<ShieldCheck size={16} />}> <Callout tone="info" icon={<ShieldCheck size={16} />}>
{t("qr.approve.scopeGuest")} {persist === "persist"
? t("qr.approve.scopeFull")
: t("qr.approve.scopeGuest")}
</Callout> </Callout>
{/* step-up:批准前的明确安全步骤——需先验证身份(provider 2FA 即此处过)。 */} {/* step-up:批准前的明确安全步骤——需先验证身份(provider 2FA 即此处过)。 */}
@@ -332,7 +357,7 @@ function OutcomeView(props: { outcome: Exclude<Outcome, null>; onHome: () => voi
title: t("qr.approve.errorTitle"), body: t("qr.approve.error") }, title: t("qr.approve.errorTitle"), body: t("qr.approve.error") },
}[outcome]; }[outcome];
return ( return (
<AuthShell title={map.title} hideBrand> <AuthShell title={map.title} hideBrand skipAutospace>
<Callout tone={map.tone} icon={map.icon}>{map.body}</Callout> <Callout tone={map.tone} icon={map.icon}>{map.body}</Callout>
<Button variant="secondary" size="lg" block onClick={onHome}> <Button variant="secondary" size="lg" block onClick={onHome}>
{t("qr.approve.backHome")} {t("qr.approve.backHome")}
+35 -2
View File
@@ -1,5 +1,5 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, ArrowRight, RefreshCw, ScanLine } from "lucide-react"; import { AlertTriangle, ArrowLeft, ArrowRight, RefreshCw, ScanLine } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AuthShell } from "../features/auth/AuthShell"; import { AuthShell } from "../features/auth/AuthShell";
import { syncWebDeviceName } from "../features/auth/auth"; import { syncWebDeviceName } from "../features/auth/auth";
@@ -109,6 +109,16 @@ function LinkNewPage()
void begin(name.trim()); void begin(name.trim());
}; };
// 回上一步:弃掉当前二维码会话,退回命名步骤(轮询随 session 清空由 effect 清理)。
const handleBackToName = () =>
{
cancelPollRef.current?.();
cancelPollRef.current = null;
setSession(null);
setError(null);
setStep("name");
};
if (step === "name") if (step === "name")
{ {
return ( return (
@@ -116,6 +126,7 @@ function LinkNewPage()
title={t("qr.show.title")} title={t("qr.show.title")}
subtitle={t("qr.show.subtitle")} subtitle={t("qr.show.subtitle")}
hideBrand hideBrand
skipAutospace
> >
{error && ( {error && (
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout> <Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
@@ -146,12 +157,21 @@ function LinkNewPage()
> >
{t("qr.show.generate")} {t("qr.show.generate")}
</Button> </Button>
<Button
variant="ghost"
size="lg"
block
leftIcon={<ArrowLeft size={16} />}
onClick={() => navigate({ to: "/" })}
>
{t("qr.show.back")}
</Button>
</AuthShell> </AuthShell>
); );
} }
return ( return (
<AuthShell title={t("qr.show.scanTitle")} hideBrand> <AuthShell title={t("qr.show.scanTitle")} hideBrand skipAutospace>
{error && ( {error && (
<Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout> <Callout tone="error" icon={<AlertTriangle size={16} />}>{error}</Callout>
)} )}
@@ -189,6 +209,19 @@ function LinkNewPage()
{t("qr.show.regenerate")} {t("qr.show.regenerate")}
</Button> </Button>
)} )}
{/* 始终给一个明确退出口:回到命名步骤,避免卡在显码 / 终态页。 */}
{phase !== "approved" && (
<Button
variant="ghost"
size="lg"
block
leftIcon={<ArrowLeft size={16} />}
onClick={handleBackToName}
>
{t("qr.show.backToName")}
</Button>
)}
</AuthShell> </AuthShell>
); );
} }
+122
View File
@@ -0,0 +1,122 @@
/* 应用内扫码器:取景框是这一屏的签名时刻——与显码页的 QrCanvas 互为镜像。
显码页把一枚码托在四角定位记号里给人看,扫码页则在同一组记号里「找」一枚码。
同一套 accent L 形角标,同一套 recessed plate 语汇,零新全局样式。 */
.stage
{
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
padding: var(--space-2) 0 var(--space-1);
}
/* 取景框:方形 viewport,下沉 plate 把相机画面托成「一个对准的对象」。 */
.viewport
{
position: relative;
width: 248px;
height: 248px;
max-width: 100%;
overflow: hidden;
background: var(--surface-sunken);
border: 1px solid var(--divider);
border-radius: var(--radius-card);
}
/* 视频铺满取景框,居中裁切。镜像关闭——后置 / 外接相机扫码不应翻转。 */
.video
{
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* 四角定位记号:与 QrCanvas 同款 accent L 形角标,内缩到取景框内沿。 */
.tick
{
position: absolute;
width: 22px;
height: 22px;
border: 2px solid var(--accent);
pointer-events: none;
}
.tl { top: var(--space-3); left: var(--space-3);
border-right: none; border-bottom: none; border-top-left-radius: 4px; }
.tr { top: var(--space-3); right: var(--space-3);
border-left: none; border-bottom: none; border-top-right-radius: 4px; }
.bl { bottom: var(--space-3); left: var(--space-3);
border-right: none; border-top: none; border-bottom-left-radius: 4px; }
.br { bottom: var(--space-3); right: var(--space-3);
border-left: none; border-top: none; border-bottom-right-radius: 4px; }
/* 扫描线:唯一的动效「时刻」,accent 横线在取景框内上下扫动,传达「正在找」。
reduced-motion 下归零(见底部 media query)。 */
.beam
{
position: absolute;
left: var(--space-3);
right: var(--space-3);
top: var(--space-3);
height: 2px;
background: var(--accent);
box-shadow: 0 0 8px 0 var(--accent-ring);
opacity: 0.85;
animation: scan-beam 2.4s var(--ease-in-out) infinite;
pointer-events: none;
}
@keyframes scan-beam
{
0% { transform: translateY(0); }
50% { transform: translateY(202px); }
100% { transform: translateY(0); }
}
/* 命中过渡:解出有效码后取景框整体淡出,承接导航到批准页。 */
.viewportHit
{
border-color: var(--accent-ring);
}
.statusLine
{
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-13);
color: var(--text-muted);
}
/* 两步指引:与显码页同款语义序号列表。 */
.steps
{
margin: 0;
padding-left: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-2);
font-size: var(--fs-13);
line-height: var(--lh-body);
color: var(--text-muted);
}
.steps li::marker
{
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.actions
{
display: flex;
flex-direction: column;
gap: var(--space-2);
}
@media (prefers-reduced-motion: reduce)
{
.beam { animation: none; opacity: 0; }
}
+259
View File
@@ -0,0 +1,259 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, ArrowLeft, Camera, CameraOff, ScanLine } from "lucide-react";
import jsQR from "jsqr";
import { useCallback, useEffect, useRef, useState } from "react";
import { AuthShell } from "../features/auth/AuthShell";
import { parseLinkApprovalUrl } from "../net/qr";
import { useAppStore } from "../store";
import { t } from "../i18n";
import { Button, Callout } from "../ui/primitives";
import s from "./link_.scan.module.css";
// 应用内扫码器:在已登录的 cdrop 里直接调相机扫码,解出 /link?r=&c= 后在同一已鉴权
// 会话内导航到批准页——绕开「系统相机打开链接落到未登录浏览器」的断流。批准走既有
// /link 页(step-up 开启时该页会自动要求再认证),本组件只负责「扫到合法码 → 导航」。
export const Route = createFileRoute("/link_/scan")({
component: LinkScanPage,
// 守卫:受限访客(scope=guest)不能授权新设备(后端 approve 亦 403),故连扫码
// 授权页都不让进——直接弹回首页,避免误触。未登录交由批准页处理,这里只挡访客。
beforeLoad: () =>
{
if (useAppStore.getState().sessionScope === "guest")
{
throw redirect({ to: "/" });
}
},
});
// 扫码器的几种态:请求相机中 / 正在扫 / 命中(淡出过渡)/ 无摄像头 / 权限被拒 / 其他错误。
type ScanState = "starting" | "scanning" | "hit" | "noCamera" | "denied" | "error";
function LinkScanPage()
{
const navigate = useNavigate();
const videoRef = useRef<HTMLVideoElement | null>(null);
// 解码用的离屏 canvas,逐帧把视频画上去再喂给 jsQR。
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const rafRef = useRef<number | null>(null);
// 命中后置位:阻止 rAF 循环继续解码 / 重复导航。
const doneRef = useRef(false);
const [ state, setState ] = useState<ScanState>("starting");
const [ errorMsg, setErrorMsg ] = useState<string | null>(null);
// 扫到无关 / 非本站二维码:提示「继续对准」,但相机继续扫,不打断。
const [ sawForeignCode, setSawForeignCode ] = useState(false);
// stop 释放相机:停轨道 + 取消 rAF。卸载、命中、出错时都调用,确保相机指示灯熄灭。
const stop = useCallback(() =>
{
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; }
const stream = streamRef.current;
if (stream) { stream.getTracks().forEach((tr) => tr.stop()); streamRef.current = null; }
}, []);
// tick:单帧解码循环。把当前视频帧画到离屏 canvas,jsQR 解一次;解出有效本站
// /link 码即停机 + 应用内导航,否则下一帧继续。
const tick = useCallback(() =>
{
if (doneRef.current) { return; }
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas || video.readyState < video.HAVE_ENOUGH_DATA)
{
rafRef.current = requestAnimationFrame(tick);
return;
}
const w = video.videoWidth;
const h = video.videoHeight;
if (!w || !h)
{
rafRef.current = requestAnimationFrame(tick);
return;
}
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx)
{
rafRef.current = requestAnimationFrame(tick);
return;
}
ctx.drawImage(video, 0, 0, w, h);
const image = ctx.getImageData(0, 0, w, h);
const result = jsQR(image.data, w, h, { inversionAttempts: "dontInvert" });
if (result && result.data)
{
const link = parseLinkApprovalUrl(result.data);
if (link)
{
// 命中合法本站码:停机、淡出,应用内导航到批准页(留在已登录 SPA 会话)。
doneRef.current = true;
stop();
setState("hit");
// p 不带:批准页会回落到更安全的默认「仅此次」(受限访客)。
navigate({ to: "/link", search: { r: link.requestId, c: link.code, p: undefined } });
return;
}
// 扫到了码,但不是本站 /link 码(别的二维码 / 钓鱼链接):提示继续对准。
setSawForeignCode(true);
}
rafRef.current = requestAnimationFrame(tick);
}, [ navigate, stop ]);
// 进页面即请求相机并起解码循环。无摄像头 / 权限被拒分别落到清晰的引导态。
useEffect(() =>
{
doneRef.current = false;
let cancelled = false;
const start = async () =>
{
if (!navigator.mediaDevices?.getUserMedia)
{
setState("noCamera");
return;
}
try
{
const stream = await navigator.mediaDevices.getUserMedia({
// 优先后置相机(手机扫码自然朝外);桌面无后置则退到任意可用相机。
video: { facingMode: { ideal: "environment" } },
audio: false,
});
if (cancelled) { stream.getTracks().forEach((tr) => tr.stop()); return; }
streamRef.current = stream;
const video = videoRef.current;
if (video)
{
video.srcObject = stream;
// iOS Safari 要求 playsInline + 显式 play(),否则不出画面。
await video.play().catch(() => { /* 自动播放被拦:画面仍随轨道更新 */ });
}
setState("scanning");
rafRef.current = requestAnimationFrame(tick);
}
catch (e)
{
if (cancelled) { return; }
const name = e instanceof DOMException ? e.name : "";
if (name === "NotAllowedError" || name === "SecurityError")
{
setState("denied");
}
else if (name === "NotFoundError" || name === "OverconstrainedError")
{
setState("noCamera");
}
else
{
setErrorMsg(e instanceof Error ? e.message : String(e));
setState("error");
}
}
};
void start();
return () => { cancelled = true; stop(); };
}, [ tick, stop ]);
const goBack = () => navigate({ to: "/" });
// ---- 渲染分支 --------------------------------------------------------
// 无摄像头(桌面常见):优雅降级,引导改用手机扫,不报错。
if (state === "noCamera")
{
return (
<AuthShell title={t("qr.scan.title")} hideBrand skipAutospace>
<Callout tone="info" icon={<CameraOff size={16} />}>
{t("qr.scan.noCamera")}
</Callout>
<Button variant="secondary" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
{t("qr.scan.back")}
</Button>
</AuthShell>
);
}
// 权限被拒:明确告诉用户去哪儿改回来。
if (state === "denied")
{
return (
<AuthShell title={t("qr.scan.title")} hideBrand skipAutospace>
<Callout tone="warn" icon={<CameraOff size={16} />}>
{t("qr.scan.denied")}
</Callout>
<Button variant="secondary" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
{t("qr.scan.back")}
</Button>
</AuthShell>
);
}
if (state === "error")
{
return (
<AuthShell title={t("qr.scan.title")} hideBrand skipAutospace>
<Callout tone="error" icon={<AlertTriangle size={16} />}>
{errorMsg ?? t("qr.scan.error")}
</Callout>
<Button variant="secondary" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
{t("qr.scan.back")}
</Button>
</AuthShell>
);
}
return (
<AuthShell title={t("qr.scan.title")} subtitle={t("qr.scan.subtitle")} hideBrand skipAutospace>
<div className={s.stage}>
<div className={`${s.viewport} ${state === "hit" ? s.viewportHit : ""}`}>
<video
ref={videoRef}
className={s.video}
muted
playsInline
// 仅装饰性预览,扫码内容不读屏;语义流程在批准页。
aria-hidden="true"
/>
{state === "scanning" && <span className={s.beam} aria-hidden="true" />}
<span className={`${s.tick} ${s.tl}`} aria-hidden="true" />
<span className={`${s.tick} ${s.tr}`} aria-hidden="true" />
<span className={`${s.tick} ${s.bl}`} aria-hidden="true" />
<span className={`${s.tick} ${s.br}`} aria-hidden="true" />
</div>
<canvas ref={canvasRef} style={{ display: "none" }} />
<div className={s.statusLine}>
{state === "starting"
? <><Camera size={14} />{t("qr.scan.starting")}</>
: <><ScanLine size={14} />{t("qr.scan.aiming")}</>}
</div>
</div>
{/* 扫到非本站码:安静提示继续对准,相机不中断。 */}
{sawForeignCode && state === "scanning" && (
<Callout tone="info" icon={<ScanLine size={16} />}>
{t("qr.scan.foreign")}
</Callout>
)}
<ol className={s.steps} data-jz-level="paragraph">
<li>{t("qr.scan.howto1")}</li>
<li>{t("qr.scan.howto2")}</li>
</ol>
<div className={s.actions}>
<Button variant="ghost" size="lg" block leftIcon={<ArrowLeft size={16} />} onClick={goBack}>
{t("qr.scan.back")}
</Button>
</div>
</AuthShell>
);
}
+12 -19
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, LogIn, QrCode } from "lucide-react"; import { AlertTriangle, LogIn, QrCode } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchAuthConfig, loginDev, loginProd } from "../features/auth/auth"; import { fetchAuthConfig, loginDev, loginProd } from "../features/auth/auth";
@@ -131,25 +131,18 @@ function LoginPage()
> >
{t("login.prod.button")} {t("login.prod.button")}
</Button> </Button>
{/* 扫码登录入口:从已登录的另一台设备批准本机接入,无需在此重登。 {/* 扫码登录入口:与 OAuth 登录平级的另一种登录方式——从已登录的
克制——一行带图标的次级链接,不与主 CTA 争重。 */} 另一台设备批准本机接入,无需在此重登。secondary 变体、等宽、
<Link 同 size,与主登录按钮同级视觉重量。 */}
to="/link/new" <Button
search={{}} variant="secondary"
style={{ size="lg"
display: "inline-flex", block
alignItems: "center", leftIcon={<QrCode size={16} />}
justifyContent: "center", onClick={() => navigate({ to: "/link/new" })}
gap: "var(--space-2)",
marginTop: "calc(var(--space-1) * -1)",
color: "var(--text-muted)",
fontSize: "var(--fs-13)",
textDecoration: "none",
}}
> >
<QrCode size={15} /> {t("qr.entry.fromLogin")}
<span>{t("qr.entry.fromLogin")}</span> </Button>
</Link>
</> </>
)} )}
</AuthShell> </AuthShell>
+289 -107
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Anchor, Anchor,
Container, Container,
@@ -8,9 +8,12 @@ import {
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router"; import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { Bell, LogOut, Trash2 } from "lucide-react"; import { AlertTriangle, Bell, LogOut, ShieldAlert, Trash2 } from "lucide-react";
import { logout, syncWebDeviceName } from "../features/auth/auth"; import { logout, startStepUpReauth, syncWebDeviceName } from "../features/auth/auth";
import { DesktopSettings } from "../features/desktop/DesktopSettings"; import { DesktopSettings } from "../features/desktop/DesktopSettings";
import {
consumePendingRevoke, consumeStepUpCode, stashPendingRevoke,
} from "../features/qr/stepUp";
import { isDesktop, persistDesktopDeviceName } from "../net/desktop"; import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
import { apiFetch } from "../net/api"; import { apiFetch } from "../net/api";
import { import {
@@ -20,25 +23,15 @@ import {
pushSupported, pushSupported,
type PushState, type PushState,
} from "../net/push"; } from "../net/push";
import { useAppStore, type DeviceInfo } from "../store"; import {
listSessions, postStepUp, revokeSession, StepUpRequiredError,
type AuthSession,
} from "../net/sessions";
import { useAppStore } from "../store";
import { t } from "../i18n"; import { t } from "../i18n";
import { formatRelative, isAsciiDeviceName } from "../utils/format"; import { formatRelative, isAsciiDeviceName } from "../utils/format";
import { Badge, Button, Panel, TextField } from "../ui/primitives"; import { Badge, Button, Callout, Panel, TextField } from "../ui/primitives";
import { StateDot } from "../ui/glyphs"; import { StateDot } from "../ui/glyphs";
// deviceTypeLabel 把后端存的设备类型本地化展示(浏览器 / macOS 客户端 / …)。
// 容错匹配(与 DeviceStrip 的 mapKind 一致),未知类型回退原值。
function deviceTypeLabel(type: string): string
{
const k = type.toLowerCase();
if (k.includes("mac") || k === "darwin") { return t("deviceType.macos"); }
if (k.includes("win")) { return t("deviceType.windows"); }
if (k.includes("linux")) { return t("deviceType.linux"); }
if (k === "shortcut") { return t("deviceType.shortcut"); }
if (k.includes("ios") || k === "iphone" || k === "ipad") { return t("deviceType.ios"); }
if (k === "browser") { return t("deviceType.browser"); }
return type;
}
import { toast } from "../ui/feedback"; import { toast } from "../ui/feedback";
export const Route = createFileRoute("/settings")({ export const Route = createFileRoute("/settings")({
@@ -55,19 +48,14 @@ function SettingsPage()
{ {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAppStore((s) => s.user); const user = useAppStore((s) => s.user);
const sessionScope = useAppStore((s) => s.sessionScope);
const isGuest = sessionScope === "guest";
const selfDeviceName = useAppStore((s) => s.selfDeviceName); const selfDeviceName = useAppStore((s) => s.selfDeviceName);
const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName); const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName);
const storeDevices = useAppStore((s) => s.devices);
const [ pendingName, setPendingName ] = useState(selfDeviceName ?? ""); const [ pendingName, setPendingName ] = useState(selfDeviceName ?? "");
const [ removing, setRemoving ] = useState<Set<string>>(new Set());
const [ renaming, setRenaming ] = useState(false); const [ renaming, setRenaming ] = useState(false);
const peerDevices = useMemo(
() => storeDevices.filter((d) => d.name !== selfDeviceName),
[ storeDevices, selfDeviceName ],
);
const trimmedName = pendingName.trim(); const trimmedName = pendingName.trim();
const canSaveName = !!trimmedName && trimmedName !== selfDeviceName; const canSaveName = !!trimmedName && trimmedName !== selfDeviceName;
@@ -118,48 +106,6 @@ function SettingsPage()
} }
}; };
const handleRemovePeer = async (name: string) =>
{
if (!window.confirm(t("settings.unregister.peerConfirm", { name }))) { return; }
setRemoving((prev) =>
{
const next = new Set(prev);
next.add(name);
return next;
});
try
{
const r = await apiFetch(
`/api/devices/${encodeURIComponent(name)}`,
{ method: "DELETE" },
);
if (!r.ok)
{
const text = await r.text().catch(() => r.statusText);
throw new Error(`${r.status}: ${text}`);
}
// 服务端的 publishPresence 会通过 SSE 推送新的设备列表,store
// 会随之更新,这里不需要手动 setDevices。
toast.ok("设备已移除", name);
}
catch (e)
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
finally
{
setRemoving((prev) =>
{
const next = new Set(prev);
next.delete(name);
return next;
});
}
};
const handleUnregisterSelf = async () => const handleUnregisterSelf = async () =>
{ {
if (!selfDeviceName) { return; } if (!selfDeviceName) { return; }
@@ -194,12 +140,27 @@ function SettingsPage()
<Container size="sm" py="lg"> <Container size="sm" py="lg">
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Title order={2}>{t("settings.title")}</Title> <Group gap="xs" align="center" wrap="nowrap">
<Title order={2}>{t("settings.title")}</Title>
{isGuest && <Badge tone="warn">{t("settings.guest.badge")}</Badge>}
</Group>
<Anchor component={Link} to="/" size="sm"> <Anchor component={Link} to="/" size="sm">
{t("nav.backToHome")} {t("nav.backToHome")}
</Anchor> </Anchor>
</Group> </Group>
{/* 受限访客标识:说明这台设备不能管理账号 / 授权设备,要完整权限请在
主设备上重新登录。放在最显眼处(标题下第一块)。 */}
{isGuest && (
<Callout
tone="warn"
icon={<ShieldAlert size={16} />}
title={t("settings.guest.title")}
>
{t("settings.guest.body")}
</Callout>
)}
<Panel title={t("settings.currentDevice.title")}> <Panel title={t("settings.currentDevice.title")}>
<Stack gap="sm"> <Stack gap="sm">
<TextField <TextField
@@ -226,7 +187,7 @@ function SettingsPage()
</Button> </Button>
</Group> </Group>
<div style={{ height: 1, background: "var(--divider)" }} /> <div style={{ height: 1, background: "var(--divider)" }} />
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">
{t("settings.unregister.currentHint")} {t("settings.unregister.currentHint")}
</Text> </Text>
<Group> <Group>
@@ -245,22 +206,10 @@ function SettingsPage()
{pushSupported() && <PushPanel />} {pushSupported() && <PushPanel />}
<Panel title={t("settings.peers.title")}> <SessionsPanel
{peerDevices.length === 0 ? ( isGuest={isGuest}
<Text size="sm" c="dimmed">{t("settings.peers.empty")}</Text> onSignedOutSelf={handleSignOut}
) : ( />
<Stack gap="xs">
{peerDevices.map((d) => (
<PeerRow
key={d.name}
device={d}
removing={removing.has(d.name)}
onRemove={() => handleRemovePeer(d.name)}
/>
))}
</Stack>
)}
</Panel>
<Panel title={t("settings.account.title")}> <Panel title={t("settings.account.title")}>
<Stack gap="sm"> <Stack gap="sm">
@@ -335,9 +284,9 @@ function PushPanel()
return ( return (
<Panel title={t("settings.push.title")}> <Panel title={t("settings.push.title")}>
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" c="dimmed">{t("settings.push.hint")}</Text> <Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.push.hint")}</Text>
{denied && !subscribed && ( {denied && !subscribed && (
<Text size="sm" style={{ color: "var(--state-error)" }}> <Text size="sm" className="justify" data-jz-level="paragraph" style={{ color: "var(--state-error)" }}>
{t("settings.push.deniedHint")} {t("settings.push.deniedHint")}
</Text> </Text>
)} )}
@@ -371,37 +320,270 @@ function PushPanel()
); );
} }
function PeerRow(props: { device: DeviceInfo; removing: boolean; onRemove: () => void }) // sortSessions 把会话排成「在线在上、未活跃(offline)在下」,组内按最近活跃时间降序
// (活跃越近越靠前)。不改变原数组,返回新数组。
function sortSessions(list: AuthSession[]): AuthSession[]
{ {
const { device, removing, onRemove } = props; return [ ...list ].sort((a, b) =>
const lastSeenText = device.lastSeen {
? t("settings.lastSeen", { time: formatRelative(device.lastSeen) }) if (a.online !== b.online) { return a.online ? -1 : 1; }
: t("settings.lastSeenNever"); return b.lastUsedAt - a.lastUsedAt;
});
}
// SessionsPanel:登录会话管理——浏览器 / 扫码登录的设备会话。每条显示在线状态点 +
// 设备名 + 权限级别 Badge + 「本机」标记 + 最近活跃,并提供「登出」(真正吊销该登录
// 的访问令牌)。在线会话排在上、未活跃(offline)的排在下;离线会话仍可登出。
//
// step-upDELETE 返回 403 step_up_required 时,把待登出的 sessionId 暂存 → 发起
// 一次 prompt=login 再认证(returnTo=/settings)→ 整页跳走 → 回来后由本组件 mount
// effect 取出暂存的 {code, verifier} + sessionIdPOST /auth/stepup 记录窗口,再
// 重试那条 DELETE。step-up 窗口内(后端 5 分钟)后续登出不再 403、直接成功。
//
// 受限访客(isGuest):无权管理登录会话、后端 GET 会 403,故根本不发该请求,直接展示
// 一句克制的说明 Callout(不出现任何加载 / 失败态)。
function SessionsPanel(props: { isGuest: boolean; onSignedOutSelf: () => void })
{
const { isGuest, onSignedOutSelf } = props;
const [ sessions, setSessions ] = useState<AuthSession[] | null>(null);
const [ loadError, setLoadError ] = useState(false);
const [ busyId, setBusyId ] = useState<string | null>(null);
const [ stepUpRejected, setStepUpRejected ] = useState(false);
// reload 拉取并写入列表,同时把结果回传给调用方(step-up 重试路径需在 revoke
// 前从这份列表里查出 current,不能在 revoke 之后再拉——若登出的正是本机会话,
// 令牌已失效、那次 listSessions 会 401)。在线会话排前、未活跃的排后,组内按最近
// 活跃时间降序。失败回 null。受限访客不会走到这里(调用方在 guest 分支直接返回)。
const reload = useCallback(async (): Promise<AuthSession[] | null> =>
{
setLoadError(false);
try
{
const list = sortSessions(await listSessions());
setSessions(list);
return list;
}
catch { setLoadError(true); setSessions(null); return null; }
}, []);
// doRevoke 真正执行一条登出。current(本机)会话登出后清本地 auth、回登录页
// (等价退出登录);其余刷新列表。403 step_up_required 由调用方分流,不入此处。
const finishRevoke = useCallback((sess: AuthSession) =>
{
if (sess.current) { onSignedOutSelf(); return; }
toast.ok(t("settings.sessions.revoked"));
void reload();
}, [ onSignedOutSelf, reload ]);
// 进页面拉一次。返回时若检测到 step-up 往返暂存({code, verifier} + 待登出 id),
// 先 POST /auth/stepup 记录窗口,再重试那条 DELETE——一次性串起整页跳转两端。
// 受限访客无权管理会话、后端 GET 会 403,故根本不发请求、直接走说明态。
useEffect(() =>
{
if (isGuest) { return; }
let cancelled = false;
const run = async () =>
{
// step-up 往返恢复:取出暂存(一次性)。无暂存则普通进页只拉列表。
const stash = consumeStepUpCode();
const pendingId = consumePendingRevoke();
const list = await reload();
if (cancelled) { return; }
if (stash && pendingId)
{
// 在 revoke 前就从这份列表里查出待登出的那条(尤其 current 标记):
// 若登出的正是本机会话,revoke 后令牌即失效,不能再 listSessions。
const target = list?.find((x) => x.id === pendingId);
setBusyId(pendingId);
try
{
await postStepUp(stash.code, stash.verifier);
await revokeSession(pendingId);
if (cancelled) { return; }
if (target) { finishRevoke(target); }
else { toast.ok(t("settings.sessions.revoked")); await reload(); }
}
catch (e)
{
if (cancelled) { return; }
if (e instanceof StepUpRequiredError) { setStepUpRejected(true); }
else
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
void reload();
}
finally { if (!cancelled) { setBusyId(null); } }
}
};
void run();
return () => { cancelled = true; };
}, [ isGuest, reload, finishRevoke ]);
const handleRevoke = async (sess: AuthSession) =>
{
const confirmMsg = sess.current
? t("settings.sessions.confirmCurrent")
: t("settings.sessions.confirmOther", { name: sess.deviceName });
if (!window.confirm(confirmMsg)) { return; }
setBusyId(sess.id);
setStepUpRejected(false);
try
{
await revokeSession(sess.id);
finishRevoke(sess);
}
catch (e)
{
if (e instanceof StepUpRequiredError)
{
// 需再认证:暂存待登出 id,发起 prompt=login 往返,回到 /settings 后
// 由 mount effect 接力重试。成功则整页跳走、本组件卸载。
stashPendingRevoke(sess.id);
try
{
await startStepUpReauth("/settings");
return; // 跳转去 provider,不再 setBusyId。
}
catch (reauthErr)
{
toast.error(t("settings.error.delete", {
message: reauthErr instanceof Error ? reauthErr.message : String(reauthErr),
}));
}
}
else
{
toast.error(t("settings.error.delete", {
message: e instanceof Error ? e.message : String(e),
}));
}
}
finally { setBusyId(null); }
};
// 受限访客:不展示列表 / 加载 / 失败态,只给一句克制的说明——管理登录会话需在主设备
// 上完整登录。后端 GET 本就 403,前面 effect 也已跳过请求,这里纯文案分支。
if (isGuest)
{
return (
<Panel title={t("settings.sessions.title")}>
<Callout tone="warn" icon={<ShieldAlert size={16} />}>
{t("settings.sessions.guestNote")}
</Callout>
</Panel>
);
}
return (
<Panel title={t("settings.sessions.title")}>
<Stack gap="sm">
<Text size="sm" c="dimmed" className="justify" data-jz-level="paragraph">{t("settings.sessions.hint")}</Text>
{stepUpRejected && (
<Callout tone="warn" icon={<AlertTriangle size={16} />}>
{t("settings.sessions.stepUpRejected")}
</Callout>
)}
{loadError ? (
<Group>
<Text size="sm" style={{ color: "var(--state-error)" }}>
{t("settings.sessions.loadError")}
</Text>
<Button size="sm" variant="ghost" onClick={() => void reload()}>
{t("settings.sessions.retry")}
</Button>
</Group>
) : sessions === null ? (
// 仍在加载(首屏闪一帧):不渲染占位文案,避免「暂无」误闪。
null
) : sessions.length === 0 ? (
<Text size="sm" c="dimmed">{t("settings.sessions.empty")}</Text>
) : (
<Stack gap="xs">
{sessions.map((sess) => (
<SessionRow
key={sess.id}
session={sess}
busy={busyId === sess.id}
// 在线、离线会话都可登出(离线亦走 step-up,流程一致)。
onRevoke={() => handleRevoke(sess)}
/>
))}
</Stack>
)}
</Stack>
</Panel>
);
}
// 会话种类标签:网页 / 扫码会话用 settings.sessions.kind.*;原生客户端
// macos / windows / linux / ios)复用设备类型标签(「macOS 客户端」等)。
function sessionKindLabel(kind: AuthSession["kind"]): string
{
if (kind === "oidc" || kind === "self" || kind === "guest")
{
return t(`settings.sessions.kind.${kind}` as const);
}
return t(`deviceType.${kind}` as const);
}
function SessionRow(props: { session: AuthSession; busy: boolean; onRevoke?: () => void })
{
const { session, busy, onRevoke } = props;
const lastUsedText = session.lastUsedAt
? t("settings.sessions.lastUsed", { time: formatRelative(session.lastUsedAt) })
: t("settings.sessions.neverUsed");
const kindLabel = sessionKindLabel(session.kind);
const onlineLabel = t(session.online ? "settings.online" : "settings.offline");
return ( return (
<Panel variant="sunken" padding="tight"> <Panel variant="sunken" padding="tight">
<Group justify="space-between" wrap="nowrap" align="center"> <Group justify="space-between" wrap="nowrap" align="center">
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}> <Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<StateDot tone={device.online ? "online" : "offline"} /> <StateDot
<Text fw={500} size="sm" truncate>{device.name}</Text> tone={session.online ? "online" : "offline"}
<Badge tone={device.online ? "online" : "neutral"}> title={onlineLabel}
{t(device.online ? "settings.online" : "settings.offline")} />
<Text fw={500} size="sm" truncate>
{session.deviceName || kindLabel}
</Text>
<Badge tone={session.scope === "guest" ? "warn" : "accent"}>
{session.scope === "guest"
? t("settings.sessions.scopeGuest")
: t("settings.sessions.scopeFull")}
</Badge> </Badge>
{session.current && (
<Badge tone="online">{t("settings.sessions.current")}</Badge>
)}
</Group> </Group>
<Text size="xs" c="dimmed" truncate> <Text size="xs" c="dimmed" truncate>
{deviceTypeLabel(device.type)} · {lastSeenText} {onlineLabel} · {kindLabel} · {lastUsedText}
</Text> </Text>
</Stack> </Stack>
<Button {onRevoke && (
size="sm" <Button
variant="ghost" size="sm"
loading={removing} variant="ghost"
onClick={onRemove} loading={busy}
style={{ color: "var(--state-error)" }} leftIcon={<LogOut size={13} />}
> onClick={onRevoke}
{removing ? t("settings.peers.removing") : t("settings.peers.remove")} style={{ color: "var(--state-error)" }}
</Button> >
{busy
? t("settings.sessions.signingOut")
: session.current
? t("settings.sessions.signOutCurrent")
: t("settings.sessions.signOut")}
</Button>
)}
</Group> </Group>
</Panel> </Panel>
); );
} }
+12 -1
View File
@@ -1,4 +1,4 @@
import type { TransferRecord, ThemeMode, User } from "./types"; import type { TransferRecord, ThemeMode, User, SessionScope } from "./types";
// DONE 是"全部数据已交付"的语义终态,把进行时遥测残留归一到这一事实,避免 // DONE 是"全部数据已交付"的语义终态,把进行时遥测残留归一到这一事实,避免
// history 行还显示"传输中字节 / 待发送 X MB"等错觉。FAILED / CANCELLED 不归 // history 行还显示"传输中字节 / 待发送 X MB"等错觉。FAILED / CANCELLED 不归
@@ -23,6 +23,7 @@ export function normalizeTerminal(cur: TransferRecord, finalState: string): Tran
export const ACCESS_TOKEN_KEY = "cdrop.access_token"; export const ACCESS_TOKEN_KEY = "cdrop.access_token";
export const USER_KEY = "cdrop.user"; export const USER_KEY = "cdrop.user";
export const SESSION_SCOPE_KEY = "cdrop.session_scope";
export const SELF_DEVICE_KEY = "cdrop.self_device"; export const SELF_DEVICE_KEY = "cdrop.self_device";
export const THEME_KEY = "cdrop.theme"; export const THEME_KEY = "cdrop.theme";
@@ -125,6 +126,16 @@ export function readSessionUser(): User | null
} }
} }
// 本会话权限级别随 access_token 一并持久化(sessionStorage,同 tab 刷新存活、
// 关标签即清)。缺失 / 非法回退 "full"——/api/me 会在登录 / 水合后校正它,回退到
// 完整态只会多展示入口(后端仍 403),不会把受限态误升级。
export function readSessionScope(): SessionScope
{
if (typeof window === "undefined") { return "full"; }
const raw = window.sessionStorage.getItem(SESSION_SCOPE_KEY);
return raw === "guest" ? "guest" : "full";
}
export function readSelfDevice(): string | null export function readSelfDevice(): string | null
{ {
if (typeof window === "undefined") { return null; } if (typeof window === "undefined") { return null; }
+1
View File
@@ -27,6 +27,7 @@ export const useAppStore = create<AppState>()((...a) =>
export type { export type {
AppState, AppState,
AuthMode, AuthMode,
SessionScope,
ThemeMode, ThemeMode,
User, User,
DeviceInfo, DeviceInfo,
+21 -3
View File
@@ -1,15 +1,18 @@
import type { StateCreator } from "zustand"; import type { StateCreator } from "zustand";
import type { AppState, AuthMode } from "../types"; import type { AppState, AuthMode, SessionScope } from "../types";
import { import {
ACCESS_TOKEN_KEY, ACCESS_TOKEN_KEY,
SESSION_SCOPE_KEY,
USER_KEY, USER_KEY,
readSessionAccess, readSessionAccess,
readSessionScope,
readSessionUser, readSessionUser,
} from "../helpers"; } from "../helpers";
export type AuthSlice = Pick< export type AuthSlice = Pick<
AppState, AppState,
"authMode" | "accessToken" | "user" | "setAuth" | "clearAuth" "authMode" | "accessToken" | "user" | "sessionScope"
| "setAuth" | "setSessionScope" | "clearAuth"
>; >;
const initialAuthMode: AuthMode = import.meta.env.DEV ? "dev" : "prod"; const initialAuthMode: AuthMode = import.meta.env.DEV ? "dev" : "prod";
@@ -23,6 +26,7 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
authMode: initialAuthMode, authMode: initialAuthMode,
accessToken: readSessionAccess(), accessToken: readSessionAccess(),
user: readSessionUser(), user: readSessionUser(),
sessionScope: readSessionScope(),
setAuth: (a) => setAuth: (a) =>
{ {
@@ -37,13 +41,27 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
}); });
}, },
// setSessionScope 由 /api/me 解析结果驱动(登录成功 / 开机水合后调用)。
// 持久化到 sessionStorage,使同 tab 刷新沿用,不必再次往返 /api/me 才能正确
// 隐藏受限入口。
setSessionScope: (scope: SessionScope) =>
{
if (typeof window !== "undefined")
{
window.sessionStorage.setItem(SESSION_SCOPE_KEY, scope);
}
set({ sessionScope: scope });
},
clearAuth: () => clearAuth: () =>
{ {
if (typeof window !== "undefined") if (typeof window !== "undefined")
{ {
window.sessionStorage.removeItem(ACCESS_TOKEN_KEY); window.sessionStorage.removeItem(ACCESS_TOKEN_KEY);
window.sessionStorage.removeItem(USER_KEY); window.sessionStorage.removeItem(USER_KEY);
window.sessionStorage.removeItem(SESSION_SCOPE_KEY);
} }
set({ accessToken: null, user: null }); // scope 回 "full":登出后下一个登录者默认完整态,再由其自己的 /api/me 校正。
set({ accessToken: null, user: null, sessionScope: "full" });
}, },
}); });
+9
View File
@@ -24,6 +24,10 @@ export interface DeviceInfo
export type AuthMode = "dev" | "prod"; export type AuthMode = "dev" | "prod";
// 本会话的权限级别(来自 /api/me 的 scope):full=完整登录、guest=受限访客。
// 受限访客不能管理账号 / 授权设备——前端据此隐藏相关入口(后端亦 403 兜底)。
export type SessionScope = "full" | "guest";
export type ThemeMode = "light" | "dark" | "system"; export type ThemeMode = "light" | "dark" | "system";
// 客户端侧细颗粒度阶段,与服务端的 statePENDING/ACCEPTED/...)正交; // 客户端侧细颗粒度阶段,与服务端的 statePENDING/ACCEPTED/...)正交;
@@ -118,7 +122,12 @@ export interface AppState
authMode: AuthMode; authMode: AuthMode;
accessToken: string | null; accessToken: string | null;
user: User | null; user: User | null;
// 本会话权限级别(/api/me 的 scope)。默认 "full";登录成功 / 开机水合后据
// /api/me 校正为 "guest" 时,UI 隐藏「扫码登录新设备」「移除设备」「登出他人」
// 等账号管理入口,并展示「受限访客」标识。
sessionScope: SessionScope;
setAuth: (a: { accessToken: string; user: User }) => void; setAuth: (a: { accessToken: string; user: User }) => void;
setSessionScope: (scope: SessionScope) => void;
clearAuth: () => void; clearAuth: () => void;
// ---- device slice ---- // ---- device slice ----