From 90a3790a980e8bc406b7b43ef014d7c39480eaf0 Mon Sep 17 00:00:00 2001 From: commilitia Date: Sun, 21 Jun 2026 22:43:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=AB=E7=A0=81=E7=99=BB=E5=BD=95=EF=BC=9Acd?= =?UTF-8?q?rop=20=E8=87=AA=E7=AD=BE=E4=BC=9A=E8=AF=9D=E5=8E=9F=E8=AF=AD=20?= =?UTF-8?q?+=20=E8=96=84=E8=B4=A6=E6=88=B7=E5=B1=82=20+=20=E5=8F=97?= =?UTF-8?q?=E9=99=90=E8=AE=BF=E5=AE=A2=20+=202FA=20step-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 身份仍源自 OAuth provider(user_id = OIDC sub),cdrop 在其上自维护一薄层: 自签会话令牌 + accounts 表,并新增扫码快速登录。实施蓝本见 AUTH.md。 后端 · 自签会话原语 - web_sessions 加 kind/scope/granted_by 列(bootstrap 幂等迁移);既有 oidc 会话行为不变,新增 self(滑动续期)/ guest(受限·不续)两类 - cdrop 自签 HS256 会话 access token,密钥派生自 SESSION_SECRET,与落盘 AES、 shortcut HS256 三密钥域隔离;jwtauth.verifySelfToken 无状态校验、靠 typ 区分 - requireFullSession 守卫:guest 会话不得改账号 / 再批准设备 / 签长效 token - /auth/refresh 按 kind 分流:self/guest 纯自签、不触 IdP 后端 · 扫码登录(internal/httpapi/qr.go) - qr/start・status・request・approve・deny 五端点 + login_requests 表 + reaper - 三密钥分离:QR 仅含批准信息,会话只投递给持私有 poll_secret 的原设备 (偷拍 QR 者无 poll_secret 领不到会话、未登录批不了准) - 会话在新设备侧领取(cookie 不经手机)、单次消费、短 TTL 后端 · 薄账户层与 2FA step-up - accounts 表(键 sub,不含任何凭证):exchange/refresh upsert match_key / 显示名 / 头像 / roles,供显示与未来管理员开启迁移标记时跨源关联 - step-up(默认关):开启后 qr/approve 要求新鲜 prompt=login 授权码,后端就地 换 id_token、JWKS 验签 + auth_time 窗口 + sub 匹配,provider 2FA 于此往返强制 前端(web/) - 显码页 /link/new + 批准页 /link + net/qr.ts,对齐 Theme B、复用 AuthShell 与聚珍排版管线、零新全局样式 - step-up 再认证流:批准前跑 prompt=login PKCE,回调分叉(不消费一次性 code、 独立 state key)后带 step_up_code/verifier 调 approve - 三语 i18n qr.*;新增 qrcode 依赖 修复 · clipboard sweeper(早已提交的损坏) - ClearExpiredClipboards 因 clipboard.sql 全角注释触发 sqlc 1.31 多字节偏移 bug,生成 SQL 被截断为「UPDATE clipboard_state SET content =」,sweeper 运行 期报「incomplete input」、过期剪贴板内容从未清除(短 TTL 暴露保护失效) - 注释改纯 ASCII 并加 bug 警告,重生成得完整 SQL;prod 已验证 sweeper 由 ERROR 转为正常清理(cleared count=1) 构建 - .dockerignore:排除本地 node_modules 等,避免宿主原生二进制污染镜像内 vite 构建 - Dockerfile.base:GOPROXY 改为可经 --build-arg 覆盖(默认仍官方代理,受限网络 构建时传区域镜像即可,仓库不固化区域值) 文档 - 新增 AUTH.md(账户与登录实施蓝本);README 特性;.env.example / compose.snippet 增配置项(QR / 自签会话 TTL / step-up / match_claim) 测试 - 自签 token 密钥域隔离、扫码端到端(领取 / 单次 / poll_secret 校验 / deny / step-up 门)、extractIdentity、web_sessions 升级迁移 --- .dockerignore | 29 ++ .env.example | 11 + AUTH.md | 282 ++++++++++++++ README.md | 1 + docker/Dockerfile.base | 5 +- docker/compose.snippet.yaml | 9 + internal/config/config.go | 33 ++ internal/db/accounts.sql.go | 76 ++++ internal/db/bootstrap.go | 15 + internal/db/bootstrap_test.go | 44 ++- internal/db/clipboard.sql.go | 12 +- internal/db/login_requests.sql.go | 151 ++++++++ internal/db/migrations/0001_init.sql | 52 ++- internal/db/models.go | 31 ++ internal/db/queries/accounts.sql | 22 ++ internal/db/queries/clipboard.sql | 9 +- internal/db/queries/login_requests.sql | 39 ++ internal/db/queries/web_sessions.sql | 32 +- internal/db/web_sessions.sql.go | 139 ++++++- internal/httpapi/auth.go | 134 ++++++- internal/httpapi/auth_test.go | 57 +++ internal/httpapi/qr.go | 474 ++++++++++++++++++++++++ internal/httpapi/qr_test.go | 243 ++++++++++++ internal/httpapi/selftoken.go | 39 ++ internal/httpapi/server.go | 38 +- internal/httpapi/session.go | 10 +- internal/httpapi/shortcut.go | 20 + internal/jwtauth/claims.go | 17 + internal/jwtauth/middleware.go | 125 ++++++- internal/jwtauth/middleware_test.go | 77 ++++ web/package-lock.json | 331 +++++++++++++++++ web/package.json | 2 + web/src/features/auth/AuthShell.tsx | 3 +- web/src/features/auth/auth.ts | 67 ++++ web/src/features/qr/QrCanvas.module.css | 42 +++ web/src/features/qr/QrCanvas.tsx | 68 ++++ web/src/features/qr/returnTo.ts | 30 ++ web/src/features/qr/stepUp.ts | 98 +++++ web/src/i18n/locales/en-US.ts | 44 +++ web/src/i18n/locales/zh-CN.ts | 44 +++ web/src/i18n/locales/zh-TW.ts | 44 +++ web/src/net/qr.ts | 280 ++++++++++++++ web/src/routes/__root.tsx | 6 +- web/src/routes/index.tsx | 4 + web/src/routes/link.module.css | 146 ++++++++ web/src/routes/link.tsx | 368 ++++++++++++++++++ web/src/routes/link_.new.module.css | 81 ++++ web/src/routes/link_.new.tsx | 237 ++++++++++++ web/src/routes/login.tsx | 23 +- web/src/routes/oauth.callback.tsx | 29 +- 50 files changed, 4130 insertions(+), 43 deletions(-) create mode 100644 .dockerignore create mode 100644 AUTH.md create mode 100644 internal/db/accounts.sql.go create mode 100644 internal/db/login_requests.sql.go create mode 100644 internal/db/queries/accounts.sql create mode 100644 internal/db/queries/login_requests.sql create mode 100644 internal/httpapi/auth_test.go create mode 100644 internal/httpapi/qr.go create mode 100644 internal/httpapi/qr_test.go create mode 100644 internal/httpapi/selftoken.go create mode 100644 web/src/features/qr/QrCanvas.module.css create mode 100644 web/src/features/qr/QrCanvas.tsx create mode 100644 web/src/features/qr/returnTo.ts create mode 100644 web/src/features/qr/stepUp.ts create mode 100644 web/src/net/qr.ts create mode 100644 web/src/routes/link.module.css create mode 100644 web/src/routes/link.tsx create mode 100644 web/src/routes/link_.new.module.css create mode 100644 web/src/routes/link_.new.tsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..01762c1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# 构建上下文裁剪:镜像构建只需源码 + go.mod/go.sum + web 源 + package*.json。 +# node_modules 尤其关键——本地是宿主(macOS)原生二进制(esbuild/rollup),若被 +# COPY 进 linux 构建层会覆盖 cdrop-base 烤好的 linux node_modules,使 vite 构建 +# 因平台不符而失败。依赖一律由 cdrop-base 的 npm ci / go mod download 提供。 + +.git +.gitignore +.gitmodules +.dockerignore + +# 依赖与构建产物(由镜像内重新安装 / 生成) +**/node_modules +vendor +web/dist +dist +desktop/build + +# 本机产物 / 草稿 / 数据库 +.scratch +*.db +*.db-shm +*.db-wal +.env +**/.DS_Store +.DS_Store + +# 服务端镜像用不到的子项目 +ios +docs diff --git a/.env.example b/.env.example index 1e642c4..49d73b7 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,17 @@ CDROP_DEV_TOKEN=replace-with-32-byte-random-base64 # CDROP_VAPID_PRIVATE_KEY= # CDROP_VAPID_SUBJECT=mailto:you@your-domain.example +# 可选:扫码登录(QR)与 cdrop 自签会话。SESSION_SECRET 已设时默认开启;下列均有默认值, +# 不配即用默认,各项可单独调整 / 关闭对应行为。 +# CDROP_QR_LOGIN_ENABLED=true # 关闭则 /api/auth/qr/* 返回 503 +# CDROP_QR_REQUEST_TTL_SECONDS=120 # 二维码 / 登录请求有效期(秒) +# CDROP_QR_GUEST_TTL_SECONDS=3600 # 仅此次(受限访客)会话 TTL(秒) +# CDROP_QR_PERSIST_TTL_HOURS=168 # 信任此设备(持久)会话 TTL(小时) +# CDROP_SESSION_TOKEN_TTL_SECONDS=900 # cdrop 自签 access token TTL(秒) +# CDROP_STEP_UP_ENABLED=false # 批准扫码设备等敏感动作要求 provider 再认证 +# CDROP_STEP_UP_MAX_AGE_SECONDS=300 # step-up 的 auth_time 新鲜度窗口(秒) +# CDROP_ACCOUNT_MATCH_CLAIM=email # 写入 accounts.match_key 的 JWT claim + # 通用 CDROP_DB_PATH=./cdrop.db CDROP_LISTEN=:8080 diff --git a/AUTH.md b/AUTH.md new file mode 100644 index 0000000..3b6ddd3 --- /dev/null +++ b/AUTH.md @@ -0,0 +1,282 @@ +# cdrop 账户与登录实施计划 + +> 内置账户层 · OAuth 为账户来源与凭证提供者 · cdrop 自维护薄账户数据与自签会话 · 扫码快登优先 +> 状态:**规划中**。本文是折中架构定稿后的实施蓝本——把边界、数据模型、令牌架构、扫码流程、里程碑讲清楚,按里程碑分批落地。 +> 关联:根 `README.md`;`internal/jwtauth`(认证中间件 / verify 链 / JWKS)、`internal/httpapi/session.go`(浏览器免重登 session)、`internal/httpapi/shortcut.go`(HS256 scoped token)、`internal/db/migrations`。记忆 `cdrop-state` / `cdrop-next-phase` / `cdrop-security-scan`。 + +--- + +## 0. 定位、折中架构与关键决策 + +### 折中:OAuth 为体的凭证、cdrop 为体的会话 + +三方在 2026-06-21 收敛到一个折中位置,介于“OIDC 锚定身份”(原方案)与“内置账号系统取代 OIDC”(全反转)之间: + +1. **身份来源仍是 OAuth provider**(当前自建 Casdoor)。`user_id` 继续等于 OIDC `sub`,cdrop 不引入内部 id 间接层。 +2. **凭证机制全部委派给 provider**——账号密码、Passkey、2FA、密码重置、注册,cdrop 一律**不实现**。Casdoor 本就提供这些,cdrop 发起 OAuth 跳转即可复用。 +3. **cdrop 只在其上自维护薄薄一层**:一张 `accounts` 表(cdrop 侧账户数据,不含任何凭证)+ 一套**自签会话令牌**(脱离 IdP refresh 流程,供扫码 / 访客 / 持久设备会话使用)。 +4. **产品化时加一个内建、可启用的 OAuth 后端**。cdrop 消费侧保持“只认一个 OIDC provider”,该后端是 drop-in:今天 provider 是外部 Casdoor,将来翻开关变成内建的。账号密码 / Passkey / 2FA 那些机制届时才进 cdrop 代码库,但被隔离在 provider 模块里。 + +一句话:**凭证是 provider 的事,会话是 cdrop 的事,账户数据两边各持一薄层。** + +### 决策记录 + +- **决策 A — 折中架构(已定)**:见上。否决“全反转”,因其令 cdrop 成为凭证保管者(argon2id / 防爆破 / 锁定 / 枚举防御 / 无邮件密码重置),攻击面与维护成本过高;这些 Casdoor 已成熟承担。 +- **决策 B — `user_id` 继续 = OIDC `sub`,不引内部 id**。新增薄 `accounts` 表(键在 `sub`),其中存一个**稳定匹配键**(默认取 JWT 的 `email` claim)。日常切换 OAuth provider 即账号失效——**运维须知此前提**。仅当管理员显式启用“后端迁移标记”时,cdrop 才在新源登录时按该匹配键自动关联回同一账号(受控窗口,非日常自动 link,规避账号接管)。 +- **决策 C — 2FA 委派给 provider**。cdrop 不自存任何第二因子密钥,仅保留敏感动作的 **step-up 钩子**(要求 provider 再认证),默认关闭。 +- **决策 D — 扫码方向 = 陌生设备显码、已登录手机扫码批准**(微信 / WhatsApp 网页版模型)。陌生设备常无摄像头但有屏,最贴合“临时借设备传文件”。 +- **决策 E — 扫码会话 = 受限访客会话,持久性可选**。批准时选“信任此设备(7 天滑动)”或“仅此次(1 小时)”;访客会话能收发文件,但不能改账号、不能再批准别的设备、不能签发长效 token。 + +> 本文取代 `.scratch/auth-inversion-diff.html` 中按“全反转”写的 §四 大半——那五个“凭证保管者”决策(无邮件重置 / 开放注册 / argon2id 等)在折中下随委派 provider 而消散。 + +--- + +## 1. 设计边界:cdrop 建什么 / 委派什么 + +| 能力 | 归属 | 说明 | +|---|---|---| +| 账号密码登录 | **provider** | Casdoor 登录页提供;cdrop 只发起 OIDC PKCE 跳转 | +| Passkey / WebAuthn 登录 | **provider** | 同上,provider 登录页内完成 | +| 2FA(TOTP / 短信 / passkey-as-2FA) | **provider** | cdrop 仅保留 step-up 钩子(决策 C) | +| 密码重置 / 找回 | **provider** | Casdoor 邮件流;cdrop 无 SMTP 设施,不介入 | +| 注册 / 开放注册策略 | **provider** | Casdoor 注册配置 | +| 账号枚举 / 防爆破 / 锁定 | **provider** | 凭证侧防护全在 provider | +| **扫码快速登录** | **cdrop** | 唯一的纯新增功能(§4) | +| **自签会话令牌** | **cdrop** | 脱离 IdP refresh,供扫码 / 访客 / 持久会话(§3) | +| **cdrop 侧账户数据** | **cdrop** | `accounts` 薄表:匹配键 / 显示名 / 头像 / 角色缓存(§2) | +| 设备列表 / 在线状态 | **cdrop** | 已有,沿用,键在 `user_id` | +| 会话 / 设备管理与吊销 | **cdrop** | 管理 cdrop 自签会话(§3、§4) | + +**Provider 抽象边界**:cdrop 认证消费侧(`/api/auth/config`、PKCE exchange、JWKS 验签)只依赖“一个 OIDC provider”的标准面,不内嵌任何 provider 专有逻辑。这是内建后端(§7)能非破坏性接入的前提。 + +--- + +## 2. 数据模型 + +### 2.1 新增 `accounts`(薄账户数据层) + +```sql +CREATE TABLE IF NOT EXISTS accounts ( + user_id TEXT PRIMARY KEY, -- = OIDC sub,与既有各表的 user_id 一致 + match_key TEXT NOT NULL DEFAULT '', -- 稳定匹配键(默认 email claim),供迁移标记关联 + display_name TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + roles TEXT NOT NULL DEFAULT '', -- OIDC groups claim 缓存(逗号分隔),admin 判定用 + provider TEXT NOT NULL DEFAULT '', -- 来源标识(如 "casdoor"),迁移时区分新旧源 + created_at INTEGER NOT NULL, + last_login_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_accounts_match_key ON accounts (match_key); +``` + +- **写入时机**:OIDC exchange 成功后 upsert(捕获 `match_key` / 显示名 / 头像 / `roles`,刷新 `last_login_at`)。 +- **不含任何凭证**——密码 / 第二因子全在 provider。 +- `match_key` 仅在“迁移标记”开启时被读作 join key(§7),日常不参与登录判定。 + +### 2.2 泛化 `web_sessions` → 支持自签会话 + +既有 `web_sessions` 只承载“绑定 IdP refresh_token 的浏览器会话”。扫码 / 访客会话拿不到 IdP refresh_token,需泛化。新增列(`bootstrap.go` 幂等 ALTER 补列,既有行视为 `kind='oidc'`、`scope='full'`): + +```sql +ALTER TABLE web_sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'oidc'; -- oidc | self | guest +ALTER TABLE web_sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'; -- full | guest +ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''; -- 扫码批准者的 session id(审计 / 连带吊销) +-- refresh_token 改为“可空语义”:self / guest 会话无 IdP refresh_token,存空串 +``` + +| kind | refresh_token | refresh 行为 | 用途 | +|---|---|---|---| +| `oidc` | AES-GCM 密文 | 向 IdP 续 + 自签 access token;IdP 拒绝则作废(保留 IdP 吊销传播) | 正常浏览器登录 | +| `self` | 空 | 仅按 session 行自签 access token,不触 IdP | 桌面 / 持久设备(未来)、扫码“信任” | +| `guest` | 空 | 同 `self`,但 `scope='guest'` 受限 | 扫码“仅此次” / 受限借用设备 | + +### 2.3 新增 `login_requests`(扫码) + +```sql +CREATE TABLE IF NOT EXISTS login_requests ( + id TEXT PRIMARY KEY, -- request_id,QR 与批准用 + poll_secret TEXT NOT NULL, -- SHA-256(新设备私有轮询密钥),QR 不含,仅新设备持有 + approval_code TEXT NOT NULL, -- 短码,随 QR 给批准方 + status TEXT NOT NULL, -- pending | approved | denied | consumed | expired + new_device_name TEXT NOT NULL, + new_device_type TEXT NOT NULL, + user_agent TEXT NOT NULL DEFAULT '', + request_ip TEXT NOT NULL DEFAULT '', + approver_user_id TEXT NOT NULL DEFAULT '', -- 批准后填 + grant_scope TEXT NOT NULL DEFAULT '', -- full | guest + grant_persist TEXT NOT NULL DEFAULT '', -- once | persist + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, -- 短 TTL(默认 120s) + approved_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires_at); +``` + +后台 reaper 清 `expires_at < now`(复用 `RunWebSessionReaper` 同款 goroutine)。 + +### 2.4 不动的表 + +`devices` / `shortcut_tokens` / `transfer_sessions` / `clipboard_state` / `push_subscriptions` 全部只键在 `user_id`,**一字不改**。这是折中改造面可控的根本原因——应用主体被 `user_id` 抽象隔离。 + +--- + +## 3. 令牌与会话架构 + +### 3.1 cdrop 自签 access token + +现有架构:cookie(`web_sessions`,窄 `Path=/api/auth`)→ `/auth/refresh` 签发 access token → JS 内存持有 → 作 Bearer 打 `/api/*`。折中改动**只在“access token 由谁签”**:从“IdP 签的 RS256”改为“**cdrop 自签的 HS256**”。 + +- **签名密钥**:从 `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)。 +- **校验**:中间件新增 `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 侧登出 / 吊销传播。 + +> RS256(IdP access token)退出每请求热路径,仅在 OIDC exchange 时验 `id_token` 签名一次(§5)。中间件保留 RS256 分支作兼容(桌面端等仍直接带 IdP token 的客户端),待后续统一迁移到自签后再清理——避免 flag day。 + +### 3.2 受限能力门(guest 会话) + +复用既有 `rejectScoped`(挡 scoped shortcut token 进非 clipboard 路由)的同款思路,新增 `requireFullSession` 守卫。`scope:"guest"` 的会话: + +- **放行**:`/clipboard`、`/transfer/*`、`/relay/*`、`/message`、`/hub/*`、`/devices`(看列表)、`/me`、`/push/*`。 +- **拒绝**:`/auth/qr/approve`(再批准设备)、`/shortcut/*`(签长效 token)、`/devices/{name}` DELETE 别的设备、未来账号设置 / 会话管理。 + +能力矩阵随路由分组实现,集中在 `server.go` 中间件层(§9)。 + +--- + +## 4. 扫码登录(优先里程碑 B) + +陌生设备显码、已登录手机扫码批准。**三方密钥分离**是安全核心:QR 可被偷拍,故 QR 里只放“能发起批准请求”的信息,而会话只投递给持有私有轮询密钥的原始新设备。 + +### 4.1 流程 + +1. **新设备发起**:`POST /api/auth/qr/start { device_name, device_type }`(公开、限流)→ 服务端建 `login_requests` 行,返回 `{ request_id, poll_secret, qr_payload }`。 + - `poll_secret`:新设备**私有**,仅此响应返回一次,QR 不含。 + - `qr_payload`:`request_id` + `approval_code`,编码进二维码展示。 +2. **新设备轮询**:`GET /api/auth/qr/status?request_id=…`,HTTP header 带 `poll_secret`(公开、限流)。`pending` 时长轮询 / SSE 挂起等待。 +3. **手机扫码**:扫得 `qr_payload` → 打开 `/link?request_id=…&code=…`。**须已登录**(否则先走正常 OAuth 登录)。页面显示“批准 <设备名>·<类型>· 登入你的账号?”,可选“信任此设备 7 天”或“仅此次 1 小时”,再点【批准】/【拒绝】。 +4. **手机批准**:`POST /api/auth/qr/approve { request_id, approval_code, scope, persist }`(**完整会话鉴权 + `requireFullSession`**,可选 step-up)。服务端校验请求 `pending` 且未过期、`approval_code` 匹配 → 建 `web_sessions` 行(`user_id=批准者`、`kind=self|guest`、`scope`、`expires_at` 按 `persist`、`granted_by=批准者 sid`)→ 标 `login_requests` 为 `approved`。 +5. **新设备领取**:下一次 `qr/status` 轮询(凭 `poll_secret`)见 `approved` → 响应 `Set-Cookie`(新设备自己的 TLS 连接)下发 session cookie + 返回首枚 access token → 标 `consumed`(单次)。 + +**手机全程只发送批准、从不接触新设备的 session 密钥**;新设备的 cookie 只经其自身轮询连接下发。 + +### 4.2 安全要点 + +- `approval_code` / `poll_secret` 高熵随机;`login_requests` 短 TTL(默认 120s),`consumed` / `expired` 后不可复用。 +- 偷拍 QR 者:无 `poll_secret`(领不到会话)、未登录(批不了准),双重失败。 +- 访客会话受 §3.2 能力门约束;`granted_by` 支持“吊销我批准过的借用设备”与连带吊销。 +- 批准动作可挂 step-up(§6)。 + +### 4.3 端点小结 + +| 方法 路径 | 鉴权 | 作用 | +|---|---|---| +| `POST /api/auth/qr/start` | 公开 + 限流 | 新设备建请求,返 `request_id` / `poll_secret` / `qr_payload` | +| `GET /api/auth/qr/status` | `poll_secret`(`X-Poll-Secret` 头)+ 限流 | 新设备长轮询,批准后**在本响应** Set-Cookie + 返回 access token,并标 `consumed`(单次) | +| `GET /api/auth/qr/request` | 完整会话 + `approval_code` | 批准方读新设备信息(名 / 类型 / 来源 IP)以渲染确认页 | +| `POST /api/auth/qr/approve` | 完整会话 + `requireFullSession` | 手机批准,记录 approver / scope / persist(会话在新设备领取时才建) | +| `POST /api/auth/qr/deny` | 完整会话 | 手机拒绝 | + +前端:新设备“显码页”+ 手机“批准页”+ 设备 / 会话管理(列出 `granted_by` 借用设备、可吊销)。 + +--- + +## 5. 账号密码 / Passkey 登录(委派 provider) + +几乎零 cdrop 新增——复用既有 OIDC PKCE: + +- cdrop `/api/auth/config` 已暴露 `authorize_url` 等;前端“登录”按钮发起跳转,**Casdoor 登录页**呈现账号密码 / Passkey / 2FA 表单。 +- 回调 → `/api/auth/exchange`(既有)→ 解析 token。**折中下的唯一安全强化**:`id_token` 必须在此**验签**(JWKS)——这是 cdrop 信任 OIDC 的唯一时刻(原方案故意不验、靠后续每请求 JWKS 兜底;自签 access token 后那层兜底消失)。 +- exchange 成功 → upsert `accounts`(§2.1,捕获 `match_key` 等)→ 建 `kind='oidc'` 会话 → 自签首枚 access token。 + +“确保 Casdoor 启用了账号密码 / Passkey / 2FA”是部署配置项,非 cdrop 代码。 + +--- + +## 6. 2FA(委派 + step-up 钩子,已实装) + +- **默认**: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 服务端状态——证明随这一次批准请求过期。 +- `qr/request` 响应回带 `step_up` 标志,供批准页判断是否需先再认证。 +- passkey 作为“快速登录”的补充:陌生设备场景下扫码优于 passkey(passkey 设备绑定,陌生设备上没有),故 passkey 由 provider 承担、不进 cdrop 自建范围。 + +--- + +## 7. 内建 OAuth 后端(产品化,远期) + +目标:自托管者不必跑外部 Casdoor。设计为 **drop-in provider**,不改 cdrop 消费侧。 + +- **形态**:cdrop 内嵌(或同容器旁挂)一个最小 OIDC provider 模块,提供账号密码 / Passkey / 2FA / 注册 / 重置。凭证机制届时才进 cdrop 代码库,但**隔离在 provider 模块**,主体仍只认标准 OIDC 面。 +- **启用**:`CDROP_BUILTIN_OAUTH_ENABLED=true`,`OIDC_*` 指向内建端点。 +- **迁移标记**(决策 B):管理员置 `CDROP_OIDC_MIGRATION_MODE=true` 后,新源登录时按 `accounts.match_key`(默认 email)匹配既有账号并**自动改键关联**(把旧 `sub` 的数据迁到新 `sub`,或登记别名)。受控、可关闭,非日常自动 link。 +- 本里程碑不在此展开内建后端内部设计——仅锁定“provider 抽象边界”这一前提,确保现在的实现不堵死将来。 + +--- + +## 8. 配置项(“均可配置关闭”) + +| 键 | 默认 | 作用 | +|---|---|---| +| `CDROP_QR_LOGIN_ENABLED` | `true` | 扫码登录总开关(关闭则 `qr/*` 返 404 / 503) | +| `CDROP_QR_REQUEST_TTL_SECONDS` | `120` | 二维码 / 登录请求有效期 | +| `CDROP_QR_GUEST_TTL_SECONDS` | `3600` | “仅此次”访客会话 TTL | +| `CDROP_QR_PERSIST_TTL_HOURS` | `168` | “信任此设备”持久会话 TTL(与设备 TTL 一致) | +| `CDROP_SESSION_TOKEN_TTL_SECONDS` | `900` | cdrop 自签 access token TTL | +| `CDROP_SESSION_SECRET` | (prod 必填) | 既有;现额外派生自签会话签名密钥 | +| `CDROP_STEP_UP_ENABLED` | `false` | 批准扫码设备等敏感动作要求 provider 再认证 | +| `CDROP_STEP_UP_MAX_AGE_SECONDS` | `300` | step-up 的 `auth_time` 新鲜度窗口(秒) | +| `CDROP_ACCOUNT_MATCH_CLAIM` | `email` | `accounts.match_key` 取哪个 JWT claim | +| `CDROP_BUILTIN_OAUTH_ENABLED` | `false` | 远期:启用内建 OAuth 后端 | +| `CDROP_OIDC_MIGRATION_MODE` | `false` | 远期:按 `match_key` 关联跨源账号 | + +账号密码 / Passkey / 2FA / 注册 / 重置的开关在 **provider**(Casdoor)侧,非 cdrop env。 + +--- + +## 9. 认证中间件 `verify()` 扩展 + +现链:`dev → HS256(scoped shortcut) → RS256(OIDC)`。折中后: + +``` +verify(token): + dev 模式 → verifyDev + 自签会话 HS256 → verifySelfToken ← 新增(typ=session,SESSION_SECRET 派生密钥) + scoped shortcut HS256 → verifyHS256 ← 既有(typ 缺省,HS256Secret 派生,查 shortcut_tokens) + OIDC RS256 → verifyRS256 ← 既有,保留作兼容(桌面直带 IdP token) +``` + +- 凭据来源新增:cookie(既有,`/auth/refresh` 用)之外,自签 access token 作 Bearer 走 `verifySelfToken`。 +- 路由守卫两层叠加:`requireScope("clipboard")`(既有,scoped token);`requireFullSession`(新增,挡 `scope=guest` 进敏感路由,§3.2)。 +- `accounts.roles` 缓存供 admin 判定(既有“admin 仅 groups claim”语义保留,读 `accounts.roles` 而非每次解 token)。 + +--- + +## 10. 里程碑 + +| 里程碑 | 内容 | 阶段 | +|---|---|---| +| **A0** 自签会话原语 | `SESSION_SECRET` 派生会话签名密钥;自签 access token 签发 / 校验(`verifySelfToken`);`web_sessions` 加 `kind` / `scope` / `granted_by` 列,refresh 按 `kind` 分流;既有 `oidc` 会话**零行为变化** | 阶段一基座 | +| **A1** `accounts` 薄表 | 建表;exchange 时 upsert(`match_key` / 显示名 / 头像 / `roles`);`id_token` exchange 时验签;`/me`、admin 判定改读 `accounts` | 阶段一 | +| **B** 扫码登录(优先) | `login_requests` 表;`qr/start` `qr/status` `qr/approve` `qr/deny`;访客 / 持久会话;`requireFullSession` 能力门;前端显码页 + 批准页 + 设备 / 会话吊销 | 阶段一(端到端,drop.commilitia.net 即可上线) | +| **C** 2FA step-up 钩子 | 敏感动作要求 provider 再认证(`prompt=login` / `max_age`);默认关 | 阶段三 | +| **D** 内建 OAuth 后端 | provider 抽象 drop-in;凭证机制隔离模块;迁移标记按 `match_key` 关联 | 远期 / 产品化 | + +> 折中下“阶段二(账号密码登录)”几乎无 cdrop 工作量——账号密码 / Passkey 本就是 provider 提供,A1 的 `accounts` 落地即覆盖其 cdrop 侧需求。重心在阶段一的 **A0 + A1 + B**:自签会话原语 + 薄账户表 + 扫码,先在现有 Casdoor 部署上端到端可用,且对老用户零行为变化。 + +--- + +## 11. 安全要点 + +- **`id_token` 必须 exchange 时验签**——自签 access token 后,原“每请求 JWKS 兜底”消失,OIDC 信任收敛到这唯一一刻。 +- **自签 access token 短 TTL**(默认 900s),承载吊销时延上界;长效凭证只在 `web_sessions` 行,可吊销。 +- **扫码三密钥分离**:QR 仅含批准信息,会话只投私有 `poll_secret` 持有者;偷拍 QR 双重失败。 +- **访客会话能力门**:`scope=guest` 服务端强制受限(§3.2),不依赖前端自律。 +- **迁移标记受控**:跨源 `match_key` 关联仅在管理员显式开启时生效,非日常自动 link,规避 email 接管。 +- **密钥域分离**:自签会话密钥(`SESSION_SECRET` 派生)与 scoped shortcut 密钥(`HS256Secret` 派生)不同域,互不串验。 +- **限流**:`qr/start` / `qr/status` 纳入既有 `/api/auth` 限流桶(60/min per-IP)或单独更严桶。 + +--- + +## 12. 不变的部分 + +设备模型、SSE Hub、传输状态机、剪贴板、Web Push、cookie 机制、AES-256-GCM 落盘、striped refresh 锁、HS256 scoped shortcut 基建——**全部只认 `user_id` 抽象,毫发无损**。折中被牢牢圈在“身份 / 会话层”,应用主体不动。这是本计划改造面可控、可分里程碑安全落地的根本保证。 diff --git a/README.md b/README.md index debd3c3..a247de7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ MVP(M0–M13)已上线 prod(OIDC PKCE 接入自建 Casdoor,Cloudflare Re - **桌面客户端**:Wails v2(macOS universal `.app` + Windows `.exe`)—— 瘦客户端复用 web,业务全走后端 API;refresh_token 存系统密钥库(信封加密) - **浏览器免重登 + PWA**:可安装为独立 PWA;浏览器侧 refresh_token 不再进 JS,改由服务端 HttpOnly cookie 会话持有(AES-256-GCM 落盘),关闭重开自动免重登(7 天滑动失活),设备名随会话持久化(抗 PWA 存储清除) - **推送通知**:页面 / PWA 关闭时,收到文件、消息、传输完成或失败以系统通知提醒;页面打开时仍走应用内提示。网页走 Web Push(VAPID,覆盖浏览器 / Android / iOS 16.4+ 已安装 PWA),桌面客户端走原生系统通知(仅窗口非前台才弹)。文案按设备语言在服务端渲染(中简 / 中繁 / 英) +- **扫码登录**:临时借用陌生设备时,陌生设备显二维码、已登录手机扫码批准即可登入(微信 / WhatsApp 网页版模型)。批准方选“信任此设备(7 天)”或“仅此次(1 小时)”,授予的是受限访客会话(能收发文件,不能改账号 / 不能再批准别的设备 / 不能签发长效 token)。三密钥分离:二维码只含批准信息,会话只投递给持私有轮询密钥的原设备,偷拍二维码者拿不到会话。身份仍源自 OAuth provider,cdrop 自维护薄账户层 + 自签会话令牌(详见 `AUTH.md`) - **iOS Shortcut**:HS256 scoped token 手动签发路径(网页签发 UI 暂停) - **安全加固**:中继会话防耗尽、HS256 强制 jti、prod 强制 audience、TURN 短 TTL、OAuth 端点限流(详见 CHANGELOG) diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index 0ae912f..79f91ff 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -17,7 +17,10 @@ RUN apk add --no-cache nodejs npm ca-certificates git WORKDIR /src -# Go modules 缓存层 +# Go modules 缓存层。GOPROXY 可经 --build-arg 覆盖——受限网络(如构建 VM 连不上 +# proxy.golang.org)时传入区域镜像即可,默认仍是官方代理,不在仓库内固化任何区域值。 +ARG GOPROXY=https://proxy.golang.org,direct +ENV GOPROXY=${GOPROXY} COPY go.mod go.sum ./ RUN go mod download diff --git a/docker/compose.snippet.yaml b/docker/compose.snippet.yaml index eb46c8f..f89cc9c 100644 --- a/docker/compose.snippet.yaml +++ b/docker/compose.snippet.yaml @@ -40,6 +40,15 @@ NN-cdrop: # CDROP_VAPID_PUBLIC_KEY: # CDROP_VAPID_PRIVATE_KEY: + # ---- 可选:扫码登录(QR)与 cdrop 自签会话(SESSION_SECRET 已设时默认开启)---- + # 均有默认值;下列按需覆盖。关 QR_LOGIN_ENABLED 则 /api/auth/qr/* 返回 503。 + # CDROP_QR_LOGIN_ENABLED: "true" + # CDROP_QR_REQUEST_TTL_SECONDS: "120" # 二维码 / 登录请求有效期 + # CDROP_QR_GUEST_TTL_SECONDS: "3600" # 仅此次(受限访客)会话 TTL + # CDROP_QR_PERSIST_TTL_HOURS: "168" # 信任此设备(持久)会话 TTL + # CDROP_SESSION_TOKEN_TTL_SECONDS: "900" # cdrop 自签 access token TTL + # CDROP_ACCOUNT_MATCH_CLAIM: "email" # 写入 accounts.match_key 的 claim + # ---- 可选:Cloudflare Realtime TURN over TLS(不配则前端 STUN-only 兜底)---- # CDROP_CF_TURN_KEY_ID: # CDROP_CF_TURN_API_TOKEN: <同 App 派发的 API Token> diff --git a/internal/config/config.go b/internal/config/config.go index 5da5a64..29eda35 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -82,6 +82,32 @@ type Config struct { VAPIDPublicKey string `koanf:"vapid_public_key"` VAPIDPrivateKey string `koanf:"vapid_private_key"` VAPIDSubject string `koanf:"vapid_subject"` + + // 扫码登录(AUTH.md §4)+ cdrop 自签会话(§3)。cdrop 为「没有 IdP refresh_token」 + // 的会话(扫码批准的设备、受限访客借用)自签短效 access token(HS256,密钥派生自 + // SessionSecret)。QRLoginEnabled 关闭则 /api/auth/qr/* 返回 503;其余为各 TTL。 + QRLoginEnabled bool `koanf:"qr_login_enabled"` + QRRequestTTLSeconds int `koanf:"qr_request_ttl_seconds"` + QRGuestTTLSeconds int `koanf:"qr_guest_ttl_seconds"` + QRPersistTTLHours int `koanf:"qr_persist_ttl_hours"` + SessionTokenTTLSeconds int `koanf:"session_token_ttl_seconds"` + + // StepUpEnabled 给敏感动作(批准扫码设备)加 provider 再认证门(AUTH.md §6):开启后 + // qr/approve 必须带一份新鲜的 prompt=login 授权码,后端就地换取 id_token、验签并校验 + // auth_time 在窗口内。默认关。StepUpMaxAgeSeconds 是该新鲜度窗口(秒),默认 300。 + StepUpEnabled bool `koanf:"step_up_enabled"` + StepUpMaxAgeSeconds int `koanf:"step_up_max_age_seconds"` + + // AccountMatchClaim 是写入 accounts.match_key 的 JWT claim,供管理员开启「迁移 + // 标记」时跨 provider 关联同一账号(AUTH.md §2.1/§7)。默认 email。 + AccountMatchClaim string `koanf:"account_match_claim"` +} + +// QRLoginOn reports whether scan-login is enabled and the self-signed session +// machinery it relies on is keyable (SessionSecret present). Without the secret +// cdrop can't sign session tokens, so QR stays off regardless of the flag. +func (c *Config) QRLoginOn() bool { + return c.QRLoginEnabled && c.SessionSecret != "" } // Load reads config from optional ./config.yaml then overrides with CDROP_* env. @@ -98,6 +124,13 @@ func Load() (*Config, error) { k.Set("clipboard_debounce_sec", 3) k.Set("shortcut_token_ttl_days", 365) k.Set("shortcut_max_per_user", 10) + k.Set("qr_login_enabled", true) + k.Set("qr_request_ttl_seconds", 120) + k.Set("qr_guest_ttl_seconds", 3600) + k.Set("qr_persist_ttl_hours", 168) + k.Set("session_token_ttl_seconds", 900) + k.Set("account_match_claim", "email") + k.Set("step_up_max_age_seconds", 300) if _, err := os.Stat("config.yaml"); err == nil { if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil { diff --git a/internal/db/accounts.sql.go b/internal/db/accounts.sql.go new file mode 100644 index 0000000..0b5e3ea --- /dev/null +++ b/internal/db/accounts.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: accounts.sql + +package db + +import ( + "context" +) + +const getAccount = `-- name: GetAccount :one +SELECT user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at +FROM accounts +WHERE user_id = ? +` + +func (q *Queries) GetAccount(ctx context.Context, userID string) (Account, error) { + row := q.db.QueryRowContext(ctx, getAccount, userID) + var i Account + err := row.Scan( + &i.UserID, + &i.MatchKey, + &i.DisplayName, + &i.AvatarUrl, + &i.Roles, + &i.Provider, + &i.CreatedAt, + &i.LastLoginAt, + ) + return i, err +} + +const upsertAccount = `-- name: UpsertAccount :exec + +INSERT INTO accounts (user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(user_id) DO UPDATE SET + match_key = excluded.match_key, + display_name = excluded.display_name, + avatar_url = excluded.avatar_url, + roles = excluded.roles, + provider = excluded.provider, + last_login_at = excluded.last_login_at +` + +type UpsertAccountParams struct { + UserID string `json:"user_id"` + MatchKey string `json:"match_key"` + DisplayName string `json:"display_name"` + AvatarUrl string `json:"avatar_url"` + Roles string `json:"roles"` + Provider string `json:"provider"` + CreatedAt int64 `json:"created_at"` + LastLoginAt int64 `json:"last_login_at"` +} + +// accounts: cdrop-side thin account data (AUTH.md 2.1). Keyed on user_id (= OIDC +// sub). No credentials live here; password / second factor stay at the OAuth +// provider. Upserted on every successful OIDC exchange. +// +// ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and +// corrupts every generated SQL const in the file. Keep this file pure ASCII. +func (q *Queries) UpsertAccount(ctx context.Context, arg UpsertAccountParams) error { + _, err := q.db.ExecContext(ctx, upsertAccount, + arg.UserID, + arg.MatchKey, + arg.DisplayName, + arg.AvatarUrl, + arg.Roles, + arg.Provider, + arg.CreatedAt, + arg.LastLoginAt, + ) + return err +} diff --git a/internal/db/bootstrap.go b/internal/db/bootstrap.go index 77cc746..71675bd 100644 --- a/internal/db/bootstrap.go +++ b/internal/db/bootstrap.go @@ -59,6 +59,21 @@ func Bootstrap(ctx context.Context, d *sql.DB) error { "ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil { return fmt.Errorf("migrate clipboard_state.origin_ts: %w", err) } + // web_sessions gained kind/scope/granted_by for cdrop self-signed sessions + // (AUTH.md §2.2). Existing rows predate these columns; backfill them so a + // pre-existing browser session keeps behaving as a normal IdP-backed login. + if err := ensureColumn(ctx, tx, "web_sessions", "kind", + "ALTER TABLE web_sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'oidc'"); err != nil { + return fmt.Errorf("migrate web_sessions.kind: %w", err) + } + if err := ensureColumn(ctx, tx, "web_sessions", "scope", + "ALTER TABLE web_sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'"); err != nil { + return fmt.Errorf("migrate web_sessions.scope: %w", err) + } + if err := ensureColumn(ctx, tx, "web_sessions", "granted_by", + "ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''"); err != nil { + return fmt.Errorf("migrate web_sessions.granted_by: %w", err) + } if err := tx.Commit(); err != nil { return fmt.Errorf("commit bootstrap: %w", err) } diff --git a/internal/db/bootstrap_test.go b/internal/db/bootstrap_test.go index 4d5cc0d..95fcfef 100644 --- a/internal/db/bootstrap_test.go +++ b/internal/db/bootstrap_test.go @@ -19,7 +19,7 @@ func TestBootstrapCreatesAllTables(t *testing.T) { t.Fatalf("bootstrap: %v", err) } - want := []string{"clipboard_state", "devices", "push_subscriptions", "shortcut_tokens", "transfer_sessions", "web_sessions"} + want := []string{"accounts", "clipboard_state", "devices", "login_requests", "push_subscriptions", "shortcut_tokens", "transfer_sessions", "web_sessions"} rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") if err != nil { t.Fatalf("query tables: %v", err) @@ -114,3 +114,45 @@ func TestBootstrapAddsClipboardVersionToLegacyDB(t *testing.T) { t.Fatalf("second bootstrap: %v", err) } } + +// Simulates the prod upgrade for scan-login: a web_sessions table created before +// kind/scope/granted_by existed must gain them, with an existing row defaulting +// to a normal OIDC-backed full session so its behaviour is unchanged (AUTH.md §2.2). +func TestBootstrapAddsWebSessionColumnsToLegacyDB(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "legacy.db") + d, err := Open(tmp) + if err != nil { + t.Fatalf("open: %v", err) + } + defer d.Close() + + _, err = d.Exec(`CREATE TABLE web_sessions ( + id TEXT PRIMARY KEY, user_id TEXT NOT NULL, refresh_token TEXT NOT NULL, + device_name TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`) + if err != nil { + t.Fatalf("legacy schema: %v", err) + } + if _, err := d.Exec(`INSERT INTO web_sessions (id, user_id, refresh_token, created_at, last_used_at, expires_at) + VALUES ('s', 'u', 'enc', 1, 1, 9999999999)`); err != nil { + t.Fatalf("seed row: %v", err) + } + + if err := Bootstrap(context.Background(), d); err != nil { + t.Fatalf("bootstrap: %v", err) + } + + var kind, scope, grantedBy string + if err := d.QueryRow(`SELECT kind, scope, granted_by FROM web_sessions WHERE id='s'`). + Scan(&kind, &scope, &grantedBy); err != nil { + t.Fatalf("select new columns (missing?): %v", err) + } + if kind != "oidc" || scope != "full" || grantedBy != "" { + t.Errorf("legacy row defaults: got kind=%q scope=%q granted_by=%q, want oidc/full/empty", + kind, scope, grantedBy) + } + // Idempotent: second bootstrap must not error on already-present columns. + if err := Bootstrap(context.Background(), d); err != nil { + t.Fatalf("second bootstrap: %v", err) + } +} diff --git a/internal/db/clipboard.sql.go b/internal/db/clipboard.sql.go index 2bad428..55f259c 100644 --- a/internal/db/clipboard.sql.go +++ b/internal/db/clipboard.sql.go @@ -11,11 +11,17 @@ import ( const clearExpiredClipboards = `-- name: ClearExpiredClipboards :execrows UPDATE clipboard_state -SET content = +SET content = NULL, source_device = NULL +WHERE content IS NOT NULL AND updated_at < ? ` -// 清除 updated_at 早于 cutoff 的剪贴板内容(content / source_device 置空), -// 由 sweeper 周期调用实现短 TTL;保留行本身、仅抹掉内容。 +// ClearExpiredClipboards empties content older than the cutoff (content / +// source_device set NULL) for the short-TTL exposure limit; the row is kept. +// Called periodically by the clipboard sweeper. +// +// ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and +// truncates the generated SQL const. This query was silently cut to "SET content =" +// (the sweeper then failed at runtime with "incomplete input"). Keep ASCII. func (q *Queries) ClearExpiredClipboards(ctx context.Context, updatedAt int64) (int64, error) { result, err := q.db.ExecContext(ctx, clearExpiredClipboards, updatedAt) if err != nil { diff --git a/internal/db/login_requests.sql.go b/internal/db/login_requests.sql.go new file mode 100644 index 0000000..fc6d0f1 --- /dev/null +++ b/internal/db/login_requests.sql.go @@ -0,0 +1,151 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: login_requests.sql + +package db + +import ( + "context" +) + +const approveLoginRequest = `-- name: ApproveLoginRequest :execrows +UPDATE login_requests +SET status = 'approved', approver_user_id = ?, grant_scope = ?, grant_persist = ?, approved_at = ? +WHERE id = ? AND status = 'pending' +` + +type ApproveLoginRequestParams struct { + ApproverUserID string `json:"approver_user_id"` + GrantScope string `json:"grant_scope"` + GrantPersist string `json:"grant_persist"` + ApprovedAt *int64 `json:"approved_at"` + ID string `json:"id"` +} + +// ApproveLoginRequest flips pending -> approved and records who approved it plus +// the granted scope/persistence. execrows so the handler can tell whether it +// actually transitioned (0 = already consumed / denied / gone). +func (q *Queries) ApproveLoginRequest(ctx context.Context, arg ApproveLoginRequestParams) (int64, error) { + result, err := q.db.ExecContext(ctx, approveLoginRequest, + arg.ApproverUserID, + arg.GrantScope, + arg.GrantPersist, + arg.ApprovedAt, + arg.ID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const consumeLoginRequest = `-- name: ConsumeLoginRequest :execrows +UPDATE login_requests +SET status = 'consumed' +WHERE id = ? AND status = 'approved' +` + +// ConsumeLoginRequest flips approved -> consumed when the new device collects its +// session, making the request single-use. +func (q *Queries) ConsumeLoginRequest(ctx context.Context, id string) (int64, error) { + result, err := q.db.ExecContext(ctx, consumeLoginRequest, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const createLoginRequest = `-- name: CreateLoginRequest :exec + +INSERT INTO login_requests (id, poll_secret, approval_code, status, new_device_name, new_device_type, user_agent, request_ip, created_at, expires_at) +VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?) +` + +type CreateLoginRequestParams struct { + ID string `json:"id"` + PollSecret string `json:"poll_secret"` + ApprovalCode string `json:"approval_code"` + NewDeviceName string `json:"new_device_name"` + NewDeviceType string `json:"new_device_type"` + UserAgent string `json:"user_agent"` + RequestIp string `json:"request_ip"` + CreatedAt int64 `json:"created_at"` + ExpiresAt int64 `json:"expires_at"` +} + +// login_requests: pending scan-login (QR) approvals (AUTH.md 2.3, 4). poll_secret +// is stored as its SHA-256 (the new device holds the plaintext); approval_code +// travels in the QR. Short-lived; reaped by DeleteExpiredLoginRequests. +// +// ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and +// corrupts every generated SQL const in the file. Keep this file pure ASCII. +func (q *Queries) CreateLoginRequest(ctx context.Context, arg CreateLoginRequestParams) error { + _, err := q.db.ExecContext(ctx, createLoginRequest, + arg.ID, + arg.PollSecret, + arg.ApprovalCode, + arg.NewDeviceName, + arg.NewDeviceType, + arg.UserAgent, + arg.RequestIp, + arg.CreatedAt, + arg.ExpiresAt, + ) + return err +} + +const deleteExpiredLoginRequests = `-- name: DeleteExpiredLoginRequests :execrows +DELETE FROM login_requests +WHERE expires_at < ? +` + +func (q *Queries) DeleteExpiredLoginRequests(ctx context.Context, expiresAt int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteExpiredLoginRequests, expiresAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const denyLoginRequest = `-- name: DenyLoginRequest :execrows +UPDATE login_requests +SET status = 'denied' +WHERE id = ? AND status = 'pending' +` + +func (q *Queries) DenyLoginRequest(ctx context.Context, id string) (int64, error) { + result, err := q.db.ExecContext(ctx, denyLoginRequest, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getLoginRequest = `-- name: GetLoginRequest :one +SELECT id, poll_secret, approval_code, status, new_device_name, new_device_type, user_agent, request_ip, approver_user_id, grant_scope, grant_persist, created_at, expires_at, approved_at +FROM login_requests +WHERE id = ? +` + +func (q *Queries) GetLoginRequest(ctx context.Context, id string) (LoginRequest, error) { + row := q.db.QueryRowContext(ctx, getLoginRequest, id) + var i LoginRequest + err := row.Scan( + &i.ID, + &i.PollSecret, + &i.ApprovalCode, + &i.Status, + &i.NewDeviceName, + &i.NewDeviceType, + &i.UserAgent, + &i.RequestIp, + &i.ApproverUserID, + &i.GrantScope, + &i.GrantPersist, + &i.CreatedAt, + &i.ExpiresAt, + &i.ApprovedAt, + ) + return i, err +} diff --git a/internal/db/migrations/0001_init.sql b/internal/db/migrations/0001_init.sql index e953c6d..22615c3 100644 --- a/internal/db/migrations/0001_init.sql +++ b/internal/db/migrations/0001_init.sql @@ -63,7 +63,17 @@ CREATE TABLE IF NOT EXISTS web_sessions ( user_agent TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL + expires_at INTEGER NOT NULL, + -- kind 区分会话来源(AUTH.md §2.2):oidc=绑定 IdP refresh_token 的正常浏览器 + -- 登录(refresh 时向 IdP 续);self/guest=cdrop 自签会话,无 IdP refresh_token, + -- refresh 时仅按本行自签 access token、不触 IdP。既有行经 bootstrap 的幂等 ALTER + -- 补列后默认视为 oidc/full,行为不变。 + kind TEXT NOT NULL DEFAULT 'oidc', + -- scope=full/guest。guest(扫码受限借用设备)服务端强制受限:能收发文件, + -- 但不能改账号、不能再批准别的设备、不能签发长效 token(路由层 requireFullSession 守门)。 + scope TEXT NOT NULL DEFAULT 'full', + -- granted_by=扫码批准者的 session id,供审计与「吊销我批准过的借用设备」连带吊销。 + granted_by TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at); @@ -91,3 +101,43 @@ CREATE TABLE IF NOT EXISTS push_subscriptions ( CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_device ON push_subscriptions (user_id, device_name); + +-- accounts:cdrop 侧「薄账户数据层」(AUTH.md §2.1)。身份来源仍是 OAuth provider, +-- user_id 继续等于 OIDC sub;本表只存 cdrop 自维护的账户元数据,不含任何凭证(密码 / +-- 第二因子全在 provider)。OIDC exchange 成功后 upsert:捕获 match_key(默认 email +-- claim,仅在管理员开启「迁移标记」时用作跨源关联的 join key)、显示名 / 头像、roles +-- (groups claim 缓存,admin 判定用)。 +CREATE TABLE IF NOT EXISTS accounts ( + user_id TEXT PRIMARY KEY, + match_key TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + roles TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + last_login_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_accounts_match_key ON accounts (match_key); + +-- login_requests:扫码登录的待批准请求(AUTH.md §2.3、§4)。三方密钥分离—— +-- poll_secret 存其 SHA-256(新设备私有、QR 不含、领取会话凭它);approval_code 随 QR +-- 给批准方。短 TTL(默认 120s),consumed/expired 后不可复用,由 reaper 清理。 +CREATE TABLE IF NOT EXISTS login_requests ( + id TEXT PRIMARY KEY, + poll_secret TEXT NOT NULL, + approval_code TEXT NOT NULL, + status TEXT NOT NULL, + new_device_name TEXT NOT NULL, + new_device_type TEXT NOT NULL, + user_agent TEXT NOT NULL DEFAULT '', + request_ip TEXT NOT NULL DEFAULT '', + approver_user_id TEXT NOT NULL DEFAULT '', + grant_scope TEXT NOT NULL DEFAULT '', + grant_persist TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + approved_at INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires_at); diff --git a/internal/db/models.go b/internal/db/models.go index 0d52690..428f0fb 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -4,6 +4,17 @@ package db +type Account struct { + UserID string `json:"user_id"` + MatchKey string `json:"match_key"` + DisplayName string `json:"display_name"` + AvatarUrl string `json:"avatar_url"` + Roles string `json:"roles"` + Provider string `json:"provider"` + CreatedAt int64 `json:"created_at"` + LastLoginAt int64 `json:"last_login_at"` +} + type ClipboardState struct { UserID string `json:"user_id"` ContentType string `json:"content_type"` @@ -21,6 +32,23 @@ type Device struct { LastSeen int64 `json:"last_seen"` } +type LoginRequest struct { + ID string `json:"id"` + PollSecret string `json:"poll_secret"` + ApprovalCode string `json:"approval_code"` + Status string `json:"status"` + NewDeviceName string `json:"new_device_name"` + NewDeviceType string `json:"new_device_type"` + UserAgent string `json:"user_agent"` + RequestIp string `json:"request_ip"` + ApproverUserID string `json:"approver_user_id"` + GrantScope string `json:"grant_scope"` + GrantPersist string `json:"grant_persist"` + CreatedAt int64 `json:"created_at"` + ExpiresAt int64 `json:"expires_at"` + ApprovedAt *int64 `json:"approved_at"` +} + type PushSubscription struct { ID string `json:"id"` UserID string `json:"user_id"` @@ -70,4 +98,7 @@ type WebSession struct { CreatedAt int64 `json:"created_at"` LastUsedAt int64 `json:"last_used_at"` ExpiresAt int64 `json:"expires_at"` + Kind string `json:"kind"` + Scope string `json:"scope"` + GrantedBy string `json:"granted_by"` } diff --git a/internal/db/queries/accounts.sql b/internal/db/queries/accounts.sql new file mode 100644 index 0000000..192ee3c --- /dev/null +++ b/internal/db/queries/accounts.sql @@ -0,0 +1,22 @@ +-- accounts: cdrop-side thin account data (AUTH.md 2.1). Keyed on user_id (= OIDC +-- sub). No credentials live here; password / second factor stay at the OAuth +-- provider. Upserted on every successful OIDC exchange. +-- +-- ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and +-- corrupts every generated SQL const in the file. Keep this file pure ASCII. + +-- name: UpsertAccount :exec +INSERT INTO accounts (user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(user_id) DO UPDATE SET + match_key = excluded.match_key, + display_name = excluded.display_name, + avatar_url = excluded.avatar_url, + roles = excluded.roles, + provider = excluded.provider, + last_login_at = excluded.last_login_at; + +-- name: GetAccount :one +SELECT user_id, match_key, display_name, avatar_url, roles, provider, created_at, last_login_at +FROM accounts +WHERE user_id = ?; diff --git a/internal/db/queries/clipboard.sql b/internal/db/queries/clipboard.sql index c39d8ed..28df375 100644 --- a/internal/db/queries/clipboard.sql +++ b/internal/db/queries/clipboard.sql @@ -20,9 +20,14 @@ SELECT user_id, content_type, content, source_device, updated_at, version, origi FROM clipboard_state WHERE user_id = ?; +-- ClearExpiredClipboards empties content older than the cutoff (content / +-- source_device set NULL) for the short-TTL exposure limit; the row is kept. +-- Called periodically by the clipboard sweeper. +-- +-- ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and +-- truncates the generated SQL const. This query was silently cut to "SET content =" +-- (the sweeper then failed at runtime with "incomplete input"). Keep ASCII. -- name: ClearExpiredClipboards :execrows --- 清除 updated_at 早于 cutoff 的剪贴板内容(content / source_device 置空), --- 由 sweeper 周期调用实现短 TTL;保留行本身、仅抹掉内容。 UPDATE clipboard_state SET content = NULL, source_device = NULL WHERE content IS NOT NULL AND updated_at < ?; diff --git a/internal/db/queries/login_requests.sql b/internal/db/queries/login_requests.sql new file mode 100644 index 0000000..7e5765f --- /dev/null +++ b/internal/db/queries/login_requests.sql @@ -0,0 +1,39 @@ +-- login_requests: pending scan-login (QR) approvals (AUTH.md 2.3, 4). poll_secret +-- is stored as its SHA-256 (the new device holds the plaintext); approval_code +-- travels in the QR. Short-lived; reaped by DeleteExpiredLoginRequests. +-- +-- ASCII only: sqlc 1.31.x miscomputes byte offsets on multibyte comments and +-- corrupts every generated SQL const in the file. Keep this file pure ASCII. + +-- name: CreateLoginRequest :exec +INSERT INTO login_requests (id, poll_secret, approval_code, status, new_device_name, new_device_type, user_agent, request_ip, created_at, expires_at) +VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?); + +-- name: GetLoginRequest :one +SELECT id, poll_secret, approval_code, status, new_device_name, new_device_type, user_agent, request_ip, approver_user_id, grant_scope, grant_persist, created_at, expires_at, approved_at +FROM login_requests +WHERE id = ?; + +-- ApproveLoginRequest flips pending -> approved and records who approved it plus +-- the granted scope/persistence. execrows so the handler can tell whether it +-- actually transitioned (0 = already consumed / denied / gone). +-- name: ApproveLoginRequest :execrows +UPDATE login_requests +SET status = 'approved', approver_user_id = ?, grant_scope = ?, grant_persist = ?, approved_at = ? +WHERE id = ? AND status = 'pending'; + +-- name: DenyLoginRequest :execrows +UPDATE login_requests +SET status = 'denied' +WHERE id = ? AND status = 'pending'; + +-- ConsumeLoginRequest flips approved -> consumed when the new device collects its +-- session, making the request single-use. +-- name: ConsumeLoginRequest :execrows +UPDATE login_requests +SET status = 'consumed' +WHERE id = ? AND status = 'approved'; + +-- name: DeleteExpiredLoginRequests :execrows +DELETE FROM login_requests +WHERE expires_at < ?; diff --git a/internal/db/queries/web_sessions.sql b/internal/db/queries/web_sessions.sql index ad0d7f6..633d3af 100644 --- a/internal/db/queries/web_sessions.sql +++ b/internal/db/queries/web_sessions.sql @@ -9,7 +9,7 @@ INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, c VALUES (?, ?, ?, ?, ?, ?, ?, ?); -- name: GetWebSession :one -SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at +SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by FROM web_sessions WHERE id = ?; @@ -30,3 +30,33 @@ WHERE id = ?; -- name: DeleteExpiredWebSessions :execrows DELETE FROM web_sessions WHERE expires_at < ?; + +-- CreateSelfSession inserts a cdrop self-signed session (kind self/guest) that has +-- no IdP refresh_token (stored empty): refresh mints a fresh access token straight +-- from this row without touching the IdP. Used by QR scan-login (AUTH.md 4). +-- name: CreateSelfSession :exec +INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, kind, scope, granted_by, created_at, last_used_at, expires_at) +VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?); + +-- SlideWebSession pushes a session's sliding window forward without rotating any +-- token. Self/guest sessions use it on refresh (they have no refresh_token to +-- rotate); oidc sessions keep using RotateWebSession. +-- name: SlideWebSession :exec +UPDATE web_sessions +SET last_used_at = ?, expires_at = ? +WHERE id = ?; + +-- ListWebSessionsByUser lists a user's sessions for the management UI (revoke +-- borrowed / guest devices). granted_by ties a scan-approved session to its +-- approver for cascade revocation. +-- name: ListWebSessionsByUser :many +SELECT id, user_id, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by +FROM web_sessions +WHERE user_id = ? +ORDER BY last_used_at DESC; + +-- DeleteWebSessionForUser drops one session scoped to its owner, so a user can +-- only revoke their own. +-- name: DeleteWebSessionForUser :execrows +DELETE FROM web_sessions +WHERE id = ? AND user_id = ?; diff --git a/internal/db/web_sessions.sql.go b/internal/db/web_sessions.sql.go index bbe0b7f..2ccd453 100644 --- a/internal/db/web_sessions.sql.go +++ b/internal/db/web_sessions.sql.go @@ -9,6 +9,43 @@ import ( "context" ) +const createSelfSession = `-- name: CreateSelfSession :exec +INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, kind, scope, granted_by, created_at, last_used_at, expires_at) +VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?) +` + +type CreateSelfSessionParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` + DeviceName string `json:"device_name"` + UserAgent string `json:"user_agent"` + Kind string `json:"kind"` + Scope string `json:"scope"` + GrantedBy string `json:"granted_by"` + CreatedAt int64 `json:"created_at"` + LastUsedAt int64 `json:"last_used_at"` + ExpiresAt int64 `json:"expires_at"` +} + +// CreateSelfSession inserts a cdrop self-signed session (kind self/guest) that has +// no IdP refresh_token (stored empty): refresh mints a fresh access token straight +// from this row without touching the IdP. Used by QR scan-login (AUTH.md 4). +func (q *Queries) CreateSelfSession(ctx context.Context, arg CreateSelfSessionParams) error { + _, err := q.db.ExecContext(ctx, createSelfSession, + arg.ID, + arg.UserID, + arg.DeviceName, + arg.UserAgent, + arg.Kind, + arg.Scope, + arg.GrantedBy, + arg.CreatedAt, + arg.LastUsedAt, + arg.ExpiresAt, + ) + return err +} + const createWebSession = `-- name: CreateWebSession :exec INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at) @@ -68,8 +105,28 @@ func (q *Queries) DeleteWebSession(ctx context.Context, id string) error { return err } +const deleteWebSessionForUser = `-- name: DeleteWebSessionForUser :execrows +DELETE FROM web_sessions +WHERE id = ? AND user_id = ? +` + +type DeleteWebSessionForUserParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` +} + +// DeleteWebSessionForUser drops one session scoped to its owner, so a user can +// only revoke their own. +func (q *Queries) DeleteWebSessionForUser(ctx context.Context, arg DeleteWebSessionForUserParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteWebSessionForUser, arg.ID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const getWebSession = `-- name: GetWebSession :one -SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at +SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by FROM web_sessions WHERE id = ? ` @@ -86,10 +143,70 @@ func (q *Queries) GetWebSession(ctx context.Context, id string) (WebSession, err &i.CreatedAt, &i.LastUsedAt, &i.ExpiresAt, + &i.Kind, + &i.Scope, + &i.GrantedBy, ) return i, err } +const listWebSessionsByUser = `-- name: ListWebSessionsByUser :many +SELECT id, user_id, device_name, user_agent, created_at, last_used_at, expires_at, kind, scope, granted_by +FROM web_sessions +WHERE user_id = ? +ORDER BY last_used_at DESC +` + +type ListWebSessionsByUserRow struct { + ID string `json:"id"` + UserID string `json:"user_id"` + DeviceName string `json:"device_name"` + UserAgent string `json:"user_agent"` + CreatedAt int64 `json:"created_at"` + LastUsedAt int64 `json:"last_used_at"` + ExpiresAt int64 `json:"expires_at"` + Kind string `json:"kind"` + Scope string `json:"scope"` + GrantedBy string `json:"granted_by"` +} + +// ListWebSessionsByUser lists a user's sessions for the management UI (revoke +// borrowed / guest devices). granted_by ties a scan-approved session to its +// approver for cascade revocation. +func (q *Queries) ListWebSessionsByUser(ctx context.Context, userID string) ([]ListWebSessionsByUserRow, error) { + rows, err := q.db.QueryContext(ctx, listWebSessionsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListWebSessionsByUserRow + for rows.Next() { + var i ListWebSessionsByUserRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.DeviceName, + &i.UserAgent, + &i.CreatedAt, + &i.LastUsedAt, + &i.ExpiresAt, + &i.Kind, + &i.Scope, + &i.GrantedBy, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const rotateWebSession = `-- name: RotateWebSession :exec UPDATE web_sessions SET refresh_token = ?, last_used_at = ?, expires_at = ? @@ -128,3 +245,23 @@ func (q *Queries) SetWebSessionDevice(ctx context.Context, arg SetWebSessionDevi _, err := q.db.ExecContext(ctx, setWebSessionDevice, arg.DeviceName, arg.ID) return err } + +const slideWebSession = `-- name: SlideWebSession :exec +UPDATE web_sessions +SET last_used_at = ?, expires_at = ? +WHERE id = ? +` + +type SlideWebSessionParams struct { + LastUsedAt int64 `json:"last_used_at"` + ExpiresAt int64 `json:"expires_at"` + ID string `json:"id"` +} + +// SlideWebSession pushes a session's sliding window forward without rotating any +// token. Self/guest sessions use it on refresh (they have no refresh_token to +// rotate); oidc sessions keep using RotateWebSession. +func (q *Queries) SlideWebSession(ctx context.Context, arg SlideWebSessionParams) error { + _, err := q.db.ExecContext(ctx, slideWebSession, arg.LastUsedAt, arg.ExpiresAt, arg.ID) + return err +} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index 0060e78..0c25fd9 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -1,6 +1,7 @@ package httpapi import ( + "context" "encoding/json" "fmt" "io" @@ -109,12 +110,15 @@ func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) { return } - user, err := extractUser(tr.IDToken, tr.AccessToken) + identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "extract user: " + err.Error()}) return } + user := identity.User + // Refresh the thin accounts row (display name / avatar / roles / match_key). + s.upsertAccount(r.Context(), identity) // Stash the refresh_token server-side and hand the browser an opaque, // HttpOnly cookie instead. Guarded so a deploy without the encryption key (or @@ -211,6 +215,15 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "session expired"}) return } + + // Self-signed sessions (scan-login: kind self/guest) carry no IdP refresh_token. + // Mint a fresh cdrop access token straight from the row, slide the window for + // persistent sessions, re-arm the cookie — never touching the IdP (AUTH.md §3.1). + if sess.Kind != "oidc" { + s.refreshSelfSession(w, r, sess, c.Value, now) + return + } + refreshToken, err := s.decryptRefresh(sess.RefreshToken) if err != nil { // Corrupt ciphertext or rotated key — the session is unusable, drop it. @@ -239,12 +252,15 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { return } - user, err := extractUser(tr.IDToken, tr.AccessToken) + identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "extract user: " + err.Error()}) return } + user := identity.User + // Keep the thin accounts row fresh on every successful refresh too. + s.upsertAccount(r.Context(), identity) // Rotate: Casdoor issues a new refresh_token on each grant; fall back to the // existing one if it didn't. Slide the window forward and re-arm the cookie. @@ -274,6 +290,44 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { }) } +// refreshSelfSession renews a cdrop self-signed session without contacting the +// IdP: it mints a fresh access token from the row. Persistent "self" sessions +// slide their inactivity window; one-time "guest" borrows do not (their fixed +// expires_at caps the borrow at QR_GUEST_TTL). Identity is enriched from the +// accounts row when present, else falls back to the bare user_id. +func (s *Server) refreshSelfSession(w http.ResponseWriter, r *http.Request, sess db.WebSession, rawCookie string, now time.Time) { + token, expiresIn, err := s.mintSessionToken(sess.UserID, sess.ID, sess.Scope) + if err != nil { + slog.Error("mint session token failed", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"}) + return + } + if sess.Kind == "self" { + newExp := now.Add(time.Duration(s.cfg.QRPersistTTLHours) * time.Hour) + if err := s.queries.SlideWebSession(r.Context(), db.SlideWebSessionParams{ + LastUsedAt: now.Unix(), + ExpiresAt: newExp.Unix(), + ID: sess.ID, + }); err != nil { + slog.Error("slide web session failed", "err", err) + } + setSessionCookie(w, rawCookie) + } + user := userResp{ID: sess.UserID, Name: sess.UserID} + if acct, err := s.queries.GetAccount(r.Context(), sess.UserID); err == nil { + if acct.DisplayName != "" { + user.Name = acct.DisplayName + } + user.Avatar = acct.AvatarUrl + } + writeJSON(w, http.StatusOK, refreshResp{ + AccessToken: token, + ExpiresIn: expiresIn, + User: user, + DeviceName: sess.DeviceName, + }) +} + var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second} func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, form url.Values) (*tokenResp, error) { @@ -304,11 +358,22 @@ func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, for return &tr, nil } -// extractUser parses {sub, preferred_username | name} from the id_token if -// available, else falls back to access_token. We DON'T verify the signature -// here — the backend's auth middleware verifies access_token on every API -// call via the JWKS cache, so any tamper would surface there. -func extractUser(idToken, accessToken string) (userResp, error) { +// tokenIdentity is what cdrop reads out of an OIDC id_token at login: the display +// identity plus the thin-account fields — match_key (for the admin-gated migration +// relink) and roles (the groups claim cache). AUTH.md §2.1. +type tokenIdentity struct { + User userResp + MatchKey string + Roles []string +} + +// extractIdentity parses {sub, preferred_username | name | email, avatar | picture, +// 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 +// the access_token on every API call via JWKS, so any tamper surfaces there. (Once +// OIDC sessions migrate to cdrop self-signed access tokens, this becomes the only +// trust point and MUST then verify the id_token signature — AUTH.md §5.) +func extractIdentity(idToken, accessToken, matchClaim string) (tokenIdentity, error) { pick := idToken if pick == "" { pick = accessToken @@ -319,31 +384,66 @@ func extractUser(idToken, accessToken string) (userResp, error) { jose.HS256, }) if err != nil { - return userResp{}, err + return tokenIdentity{}, err } var std jwt.Claims custom := map[string]any{} if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil { - return userResp{}, err + return tokenIdentity{}, err } - u := userResp{ID: std.Subject} + id := tokenIdentity{User: userResp{ID: std.Subject}} if v, ok := custom["preferred_username"].(string); ok && v != "" { - u.Name = v + id.User.Name = v } else if v, ok := custom["name"].(string); ok && v != "" { - u.Name = v + id.User.Name = v } else if v, ok := custom["email"].(string); ok && v != "" { - u.Name = v + id.User.Name = v } else { - u.Name = u.ID + id.User.Name = id.User.ID } // Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段; // 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。 if v, ok := custom["avatar"].(string); ok && v != "" { - u.Avatar = v + id.User.Avatar = v } else if v, ok := custom["picture"].(string); ok && v != "" { - u.Avatar = v + id.User.Avatar = v + } + // match_key feeds the admin-gated cross-provider relink; pick the configured + // claim (default email). Absent / non-string → empty, which simply never matches. + if matchClaim != "" { + if v, ok := custom[matchClaim].(string); ok { + id.MatchKey = v + } + } + if g, ok := custom["groups"].([]any); ok { + for _, item := range g { + if r, ok := item.(string); ok && r != "" { + id.Roles = append(id.Roles, r) + } + } + } + return id, nil +} + +// upsertAccount refreshes the caller's thin accounts row from their OIDC identity +// (AUTH.md §2.1). Best-effort — a write failure must never fail the login itself. +func (s *Server) upsertAccount(ctx context.Context, id tokenIdentity) { + if id.User.ID == "" { + return + } + now := time.Now().Unix() + if err := s.queries.UpsertAccount(ctx, db.UpsertAccountParams{ + UserID: id.User.ID, + MatchKey: id.MatchKey, + DisplayName: id.User.Name, + AvatarUrl: id.User.Avatar, + Roles: strings.Join(id.Roles, ","), + Provider: "oidc", + CreatedAt: now, + LastLoginAt: now, + }); err != nil { + slog.Error("upsert account failed", "err", err, "user", id.User.ID) } - return u, nil } func truncate(s string, n int) string { diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go new file mode 100644 index 0000000..2469336 --- /dev/null +++ b/internal/httpapi/auth_test.go @@ -0,0 +1,57 @@ +package httpapi + +import ( + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" +) + +// extractIdentity pulls the display identity plus the thin-account fields +// (match_key from the configured claim, roles from groups) out of an id_token. +// It does not verify the signature, so a throwaway HS256 key suffices here. +func TestExtractIdentity(t *testing.T) { + key := []byte("any-throwaway-signing-key-32-bytes!!") + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT")) + if err != nil { + t.Fatalf("signer: %v", err) + } + tok, err := jwt.Signed(sig). + Claims(jwt.Claims{Subject: "sub-123", Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour))}). + Claims(map[string]any{ + "preferred_username": "alice", + "email": "alice@example.net", + "avatar": "https://a/av.png", + "groups": []any{"admins", "staff"}, + }).Serialize() + if err != nil { + t.Fatalf("sign: %v", err) + } + + id, err := extractIdentity(tok, "", "email") + if err != nil { + t.Fatalf("extract: %v", err) + } + if id.User.ID != "sub-123" || id.User.Name != "alice" || id.User.Avatar != "https://a/av.png" { + t.Errorf("user wrong: %+v", id.User) + } + if id.MatchKey != "alice@example.net" { + t.Errorf("match_key: got %q, want alice@example.net", id.MatchKey) + } + if len(id.Roles) != 2 || id.Roles[0] != "admins" || id.Roles[1] != "staff" { + t.Errorf("roles: got %v, want [admins staff]", id.Roles) + } + + // No name claim → falls back to sub; absent match claim → empty (never matches). + tok2, _ := jwt.Signed(sig). + Claims(jwt.Claims{Subject: "sub-x", Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour))}). + Serialize() + id2, err := extractIdentity(tok2, "", "email") + if err != nil { + t.Fatalf("extract2: %v", err) + } + if id2.User.Name != "sub-x" || id2.MatchKey != "" || len(id2.Roles) != 0 { + t.Errorf("fallback wrong: %+v", id2) + } +} diff --git a/internal/httpapi/qr.go b/internal/httpapi/qr.go new file mode 100644 index 0000000..4d181a2 --- /dev/null +++ b/internal/httpapi/qr.go @@ -0,0 +1,474 @@ +package httpapi + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "log/slog" + "net" + "net/http" + "net/url" + "strings" + "time" + + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/jwtauth" +) + +// Scan-login (QR), AUTH.md §4. A new device shows a QR; an already-logged-in +// phone scans and approves it into the user's account as a restricted guest +// session. Three secrets are kept apart so the QR — which can be photographed — +// only lets someone *request* approval, never collect the session: +// +// - request_id : public handle, in the QR. +// - approval_code : in the QR; binds the authed approver to this request. +// - poll_secret : returned ONCE to the new device, never in the QR; only its +// SHA-256 is stored. The session is delivered solely on the connection that +// proves the plaintext, so a QR photographer (no poll_secret, not logged in) +// fails twice. +// +// The session row is created when the new device collects it (handleQRStatus), +// not at approve time, so the cookie secret is generated on and returned to the +// new device's own TLS connection and never travels through the phone. + +// qrStatusPollWindow caps how long one status long-poll blocks before returning +// "pending" for the client to re-poll. var (not const) so tests can shrink it. +var qrStatusPollWindow = 25 * time.Second + +type qrStartReq struct { + DeviceName string `json:"device_name"` + DeviceType string `json:"device_type"` +} + +type qrStartResp struct { + RequestID string `json:"request_id"` + PollSecret string `json:"poll_secret"` + QRPayload string `json:"qr_payload"` + ExpiresAt int64 `json:"expires_at"` +} + +type qrStatusResp struct { + Status string `json:"status"` // pending | approved | denied | expired + AccessToken string `json:"access_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + User *userResp `json:"user,omitempty"` + DeviceName string `json:"device_name,omitempty"` +} + +type qrRequestResp struct { + DeviceName string `json:"device_name"` + DeviceType string `json:"device_type"` + RequestIP string `json:"request_ip"` + ExpiresAt int64 `json:"expires_at"` + Status string `json:"status"` + // StepUp tells the approver UI a fresh re-auth is required before approving. + StepUp bool `json:"step_up"` +} + +type qrApproveReq struct { + RequestID string `json:"request_id"` + ApprovalCode string `json:"approval_code"` + Scope string `json:"scope"` // full | guest (this milestone always guest) + Persist string `json:"persist"` // once | persist + // Step-up (when CDROP_STEP_UP_ENABLED): a fresh prompt=login PKCE code+verifier + // the backend exchanges and checks for a recent auth_time (AUTH.md §6). + StepUpCode string `json:"step_up_code"` + StepUpVerifier string `json:"step_up_verifier"` +} + +type qrDenyReq struct { + RequestID string `json:"request_id"` + ApprovalCode string `json:"approval_code"` +} + +// handleQRStart (public, rate-limited) opens a scan-login request for a new +// device and returns the QR payload plus the device-private poll secret. +func (s *Server) handleQRStart(w http.ResponseWriter, r *http.Request) { + if !s.cfg.QRLoginOn() { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) + return + } + var req qrStartReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + deviceName := jwtauth.SanitizeDeviceName(req.DeviceName) + if deviceName == "" { + deviceName = "New device" + } + + requestID, err1 := randB64(16) + approvalCode, err2 := randB64(9) + pollSecret, err3 := randB64(32) + if err := errors.Join(err1, err2, err3); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) + return + } + + now := time.Now() + expires := now.Add(time.Duration(s.cfg.QRRequestTTLSeconds) * time.Second) + if err := s.queries.CreateLoginRequest(r.Context(), db.CreateLoginRequestParams{ + ID: requestID, + PollSecret: sessionID(pollSecret), // store only the hash + ApprovalCode: approvalCode, + NewDeviceName: deviceName, + NewDeviceType: qrDeviceType(req.DeviceType), + UserAgent: truncate(r.UserAgent(), 256), + RequestIp: clientIP(r), + CreatedAt: now.Unix(), + ExpiresAt: expires.Unix(), + }); err != nil { + slog.Error("create login request failed", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + + writeJSON(w, http.StatusOK, qrStartResp{ + RequestID: requestID, + PollSecret: pollSecret, + QRPayload: s.qrPayload(r, requestID, approvalCode), + ExpiresAt: expires.Unix(), + }) +} + +// handleQRStatus (public, rate-limited) is the new device's long-poll. It proves +// the poll_secret, then reports pending until the request is approved/denied/ +// expired. On approval it creates the session, sets the cookie on THIS response, +// mints the first access token, and marks the request consumed (single use). +func (s *Server) handleQRStatus(w http.ResponseWriter, r *http.Request) { + if !s.cfg.QRLoginOn() { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) + return + } + requestID := r.URL.Query().Get("request_id") + pollSecret := r.Header.Get("X-Poll-Secret") + if requestID == "" || pollSecret == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing request_id or poll secret"}) + return + } + pollHash := sessionID(pollSecret) + + deadline := time.Now().Add(qrStatusPollWindow) + for { + req, err := s.queries.GetLoginRequest(r.Context(), requestID) + if err != nil { + // Unknown / reaped → treat as expired without leaking existence. + writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) + return + } + if subtle.ConstantTimeCompare([]byte(req.PollSecret), []byte(pollHash)) != 1 { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad poll secret"}) + return + } + if req.ExpiresAt < time.Now().Unix() { + writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) + return + } + switch req.Status { + case "approved": + s.collectQRSession(w, r, req) + return + case "denied": + writeJSON(w, http.StatusOK, qrStatusResp{Status: "denied"}) + return + case "consumed": + writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) + return + default: // pending + if time.Now().After(deadline) { + writeJSON(w, http.StatusOK, qrStatusResp{Status: "pending"}) + return + } + select { + case <-r.Context().Done(): + return + case <-time.After(time.Second): + } + } + } +} + +// collectQRSession turns an approved request into a live session for the new +// device. ConsumeLoginRequest is the single-use guard: only the poll that flips +// approved→consumed proceeds, so a duplicated/raced poll can't mint a second +// session. +func (s *Server) collectQRSession(w http.ResponseWriter, r *http.Request, req db.LoginRequest) { + n, err := s.queries.ConsumeLoginRequest(r.Context(), req.ID) + if err != nil { + slog.Error("consume login request failed", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + if n == 0 { + // Lost the race to another poll; the winner already has the session. + writeJSON(w, http.StatusOK, qrStatusResp{Status: "expired"}) + return + } + + raw, id, err := newSessionToken() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) + return + } + scope := req.GrantScope + if scope != "full" { + scope = "guest" + } + // Persistent borrows slide their window (kind self); one-time borrows are kind + // guest with a fixed short expiry that refresh never extends (AUTH.md §3.1). + now := time.Now() + kind := "guest" + exp := now.Add(time.Duration(s.cfg.QRGuestTTLSeconds) * time.Second) + if req.GrantPersist == "persist" { + kind = "self" + exp = now.Add(time.Duration(s.cfg.QRPersistTTLHours) * time.Hour) + } + if err := s.queries.CreateSelfSession(r.Context(), db.CreateSelfSessionParams{ + ID: id, + UserID: req.ApproverUserID, + DeviceName: req.NewDeviceName, + UserAgent: req.UserAgent, + Kind: kind, + Scope: scope, + GrantedBy: "qr", + CreatedAt: now.Unix(), + LastUsedAt: now.Unix(), + ExpiresAt: exp.Unix(), + }); err != nil { + slog.Error("create self session failed", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + + token, expiresIn, err := s.mintSessionToken(req.ApproverUserID, id, scope) + if err != nil { + slog.Error("mint session token failed", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"}) + return + } + setSessionCookie(w, raw) + + user := userResp{ID: req.ApproverUserID, Name: req.ApproverUserID} + if acct, err := s.queries.GetAccount(r.Context(), req.ApproverUserID); err == nil { + if acct.DisplayName != "" { + user.Name = acct.DisplayName + } + user.Avatar = acct.AvatarUrl + } + writeJSON(w, http.StatusOK, qrStatusResp{ + Status: "approved", + AccessToken: token, + ExpiresIn: expiresIn, + User: &user, + DeviceName: req.NewDeviceName, + }) +} + +// handleQRRequest (full session) lets the approver see what they are about to +// authorise. The approval_code (from the QR) gates the lookup, so only someone +// who scanned the code can read the request's device info. +func (s *Server) handleQRRequest(w http.ResponseWriter, r *http.Request) { + if !s.cfg.QRLoginOn() { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) + return + } + req, ok := s.lookupQRRequest(w, r, r.URL.Query().Get("request_id"), r.URL.Query().Get("code")) + if !ok { + return + } + writeJSON(w, http.StatusOK, qrRequestResp{ + DeviceName: req.NewDeviceName, + DeviceType: req.NewDeviceType, + RequestIP: req.RequestIp, + ExpiresAt: req.ExpiresAt, + Status: req.Status, + StepUp: s.cfg.StepUpEnabled, + }) +} + +// handleQRApprove (full session) binds the request to the caller and records the +// granted scope/persistence. The new device's poll then collects the session. +func (s *Server) handleQRApprove(w http.ResponseWriter, r *http.Request) { + if !s.cfg.QRLoginOn() { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) + return + } + var body qrApproveReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + req, ok := s.lookupQRRequest(w, r, body.RequestID, body.ApprovalCode) + if !ok { + return + } + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + + // 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 + // enforced during that exchange) within the freshness window (AUTH.md §6). + if s.cfg.StepUpEnabled && !s.verifyStepUp(r, claims.UserID, body.StepUpCode, body.StepUpVerifier) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"}) + return + } + + scope := "guest" // this milestone grants restricted guest sessions only + if body.Scope == "full" { + scope = "full" + } + persist := "once" + if body.Persist == "persist" { + persist = "persist" + } + now := time.Now() + n, err := s.queries.ApproveLoginRequest(r.Context(), db.ApproveLoginRequestParams{ + ApproverUserID: claims.UserID, + GrantScope: scope, + GrantPersist: persist, + ApprovedAt: ptrInt64(now.Unix()), + ID: req.ID, + }) + if err != nil { + slog.Error("approve login request failed", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + if n == 0 { + // Raced to denied/consumed/expired between lookup and update. + writeJSON(w, http.StatusConflict, map[string]string{"error": "request no longer pending"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleQRDeny (full session) rejects a pending request. +func (s *Server) handleQRDeny(w http.ResponseWriter, r *http.Request) { + if !s.cfg.QRLoginOn() { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan login disabled"}) + return + } + var body qrDenyReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + req, ok := s.lookupQRRequest(w, r, body.RequestID, body.ApprovalCode) + if !ok { + return + } + if _, err := s.queries.DenyLoginRequest(r.Context(), req.ID); err != nil { + slog.Error("deny login request failed", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// 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 +// signature validates (JWKS), its sub matches the caller, and its auth_time is +// within StepUpMaxAgeSeconds. Any failure → false (the caller answers 403). The +// code is single-use at the IdP, so it can't be replayed (AUTH.md §6). +func (s *Server) verifyStepUp(r *http.Request, userID, code, verifier string) bool { + if code == "" || verifier == "" || s.cfg.OIDCTokenURL == "" { + return false + } + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("code_verifier", verifier) + form.Set("client_id", s.cfg.OIDCClientID) + form.Set("redirect_uri", s.cfg.OIDCRedirectURI) + if s.cfg.OIDCClientSecret != "" { + form.Set("client_secret", s.cfg.OIDCClientSecret) + } + tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form) + if err != nil { + slog.Warn("step-up exchange failed", "err", err) + return false + } + if tr.IDToken == "" { + return false + } + sub, authTime, err := s.auth.VerifyIDToken(r.Context(), tr.IDToken) + if err != nil { + slog.Warn("step-up id_token invalid", "err", err) + return false + } + if sub != userID { + return false + } + maxAge := int64(s.cfg.StepUpMaxAgeSeconds) + if maxAge <= 0 { + maxAge = 300 + } + return time.Now().Unix()-authTime <= maxAge +} + +// lookupQRRequest fetches a request and verifies the approval_code in constant +// time. It writes the error response itself and returns ok=false on any failure +// (missing args, unknown request, bad code, expired), so callers just early-return. +func (s *Server) lookupQRRequest(w http.ResponseWriter, r *http.Request, requestID, code string) (db.LoginRequest, bool) { + if requestID == "" || code == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing request_id or code"}) + return db.LoginRequest{}, false + } + req, err := s.queries.GetLoginRequest(r.Context(), requestID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "request not found"}) + return db.LoginRequest{}, false + } + if subtle.ConstantTimeCompare([]byte(req.ApprovalCode), []byte(code)) != 1 { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad approval code"}) + return db.LoginRequest{}, false + } + if req.ExpiresAt < time.Now().Unix() { + writeJSON(w, http.StatusGone, map[string]string{"error": "request expired"}) + return db.LoginRequest{}, false + } + return req, true +} + +// qrPayload builds the URL encoded into the QR: the approver opens it on their +// phone. Prefers the configured site origin; falls back to the request's own +// scheme/host for dev. +func (s *Server) qrPayload(r *http.Request, requestID, approvalCode string) string { + origin := s.siteOrigin + if origin == "" { + scheme := "https" + if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" { + scheme = "http" + } + origin = scheme + "://" + r.Host + } + return origin + "/link?r=" + requestID + "&c=" + approvalCode +} + +func qrDeviceType(raw string) string { + switch t := strings.ToLower(strings.TrimSpace(raw)); t { + case "macos", "windows", "linux", "ios", "browser": + return t + default: + return "browser" + } +} + +func clientIP(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +func ptrInt64(v int64) *int64 { return &v } + +func randB64(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/httpapi/qr_test.go b/internal/httpapi/qr_test.go new file mode 100644 index 0000000..ceb76d5 --- /dev/null +++ b/internal/httpapi/qr_test.go @@ -0,0 +1,243 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + + "commilitia.net/cdrop/internal/config" + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/jwtauth" +) + +const qrTestSecret = "qr-test-session-secret-at-least-32-bytes" + +// newQRTestServer builds a Server with only the fields the scan-login handlers +// touch, backed by a fresh file-based sqlite (an in-memory DB would give each +// pooled connection its own empty schema). +func newQRTestServer(t *testing.T) *Server { + t.Helper() + conn, err := db.Open(filepath.Join(t.TempDir(), "qr.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := db.Bootstrap(context.Background(), conn); err != nil { + t.Fatalf("bootstrap: %v", err) + } + return &Server{ + cfg: &config.Config{ + AuthMode: "prod", SessionSecret: qrTestSecret, + QRLoginEnabled: true, QRRequestTTLSeconds: 120, + QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168, + SessionTokenTTLSeconds: 900, + }, + queries: db.New(conn), + sessionKey: deriveSessionKey(qrTestSecret), + sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret), + siteOrigin: "https://drop.example.net", + } +} + +func qrStart(t *testing.T, s *Server, deviceName string) (qrStartResp, string) { + t.Helper() + body := fmt.Sprintf(`{"device_name":%q,"device_type":"browser"}`, deviceName) + r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/start", strings.NewReader(body)) + w := httptest.NewRecorder() + s.handleQRStart(w, r) + if w.Code != http.StatusOK { + t.Fatalf("qr/start: %d %s", w.Code, w.Body.String()) + } + var resp qrStartResp + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode start: %v", err) + } + u, err := url.Parse(resp.QRPayload) + if err != nil { + t.Fatalf("qr_payload not a url: %v", err) + } + return resp, u.Query().Get("c") +} + +func qrApprove(t *testing.T, s *Server, requestID, code, persist, approver string) int { + t.Helper() + body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q,"scope":"guest","persist":%q}`, requestID, code, persist) + r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", strings.NewReader(body)) + r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: approver, SessionScope: "full"})) + w := httptest.NewRecorder() + s.handleQRApprove(w, r) + return w.Code +} + +func qrStatus(s *Server, requestID, pollSecret string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "/api/auth/qr/status?request_id="+url.QueryEscape(requestID), nil) + r.Header.Set("X-Poll-Secret", pollSecret) + w := httptest.NewRecorder() + s.handleQRStatus(w, r) + return w +} + +func decodeStatus(t *testing.T, w *httptest.ResponseRecorder) qrStatusResp { + t.Helper() + var resp qrStatusResp + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode status: %v (%s)", err, w.Body.String()) + } + return resp +} + +// The happy path: a new device starts a request, the approver authorises it as a +// one-time guest, and the new device collects a live guest session — cookie set, +// access token minted, request single-use thereafter. +func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) { + s := newQRTestServer(t) + start, code := qrStart(t, s, "Borrowed Laptop") + + if c := qrApprove(t, s, start.RequestID, code, "once", "approver-1"); c != http.StatusNoContent { + t.Fatalf("approve: got %d, want 204", c) + } + + w := qrStatus(s, start.RequestID, start.PollSecret) + if w.Code != http.StatusOK { + t.Fatalf("status: %d %s", w.Code, w.Body.String()) + } + got := decodeStatus(t, w) + if got.Status != "approved" || got.AccessToken == "" || got.DeviceName != "Borrowed Laptop" { + t.Fatalf("collected status wrong: %+v", got) + } + if got.ExpiresIn != 900 { + t.Errorf("expires_in: got %d, want 900", got.ExpiresIn) + } + var hasCookie bool + for _, ck := range w.Result().Cookies() { + if ck.Name == sessionCookieName && ck.Value != "" { + hasCookie = true + } + } + if !hasCookie { + t.Error("collection must set the session cookie on the new device's response") + } + + // The minted token is a properly signed guest session token. + if scope := selfTokenScope(t, got.AccessToken); scope != "guest" { + t.Fatalf("minted token scope: got %q, want guest", scope) + } + + // A guest (one-time) session row exists for the approver and is non-sliding. + rows, err := s.queries.ListWebSessionsByUser(context.Background(), "approver-1") + if err != nil || len(rows) != 1 { + t.Fatalf("expected one session row, got %d (err=%v)", len(rows), err) + } + if rows[0].Kind != "guest" || rows[0].Scope != "guest" { + t.Errorf("session kind/scope: got %s/%s, want guest/guest", rows[0].Kind, rows[0].Scope) + } + + // Single use: a second collection finds the request consumed. + if got2 := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got2.Status != "expired" { + t.Errorf("second collection: got %q, want expired (consumed)", got2.Status) + } +} + +// A persistent ("trust this device") approval yields a sliding self session. +func TestQRFlow_PersistYieldsSelfSession(t *testing.T) { + s := newQRTestServer(t) + start, code := qrStart(t, s, "Home PC") + if c := qrApprove(t, s, start.RequestID, code, "persist", "approver-2"); c != http.StatusNoContent { + t.Fatalf("approve: got %d, want 204", c) + } + if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" { + t.Fatalf("collect: %+v", got) + } + rows, _ := s.queries.ListWebSessionsByUser(context.Background(), "approver-2") + if len(rows) != 1 || rows[0].Kind != "self" { + t.Fatalf("persistent approval must create a kind=self session, got %+v", rows) + } +} + +// The poll_secret is the new device's only credential: a wrong one is rejected +// even for a real, approved request, so a QR photographer can't collect it. +func TestQRFlow_WrongPollSecretRejected(t *testing.T) { + s := newQRTestServer(t) + start, code := qrStart(t, s, "Laptop") + _ = qrApprove(t, s, start.RequestID, code, "once", "approver-3") + + if w := qrStatus(s, start.RequestID, "not-the-secret"); w.Code != http.StatusForbidden { + t.Errorf("wrong poll secret: got %d, want 403", w.Code) + } + // The real secret still works afterwards (the bad attempt didn't consume it). + if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" { + t.Errorf("real secret after a bad attempt: got %q, want approved", got.Status) + } +} + +// Denial propagates to the polling device. +func TestQRFlow_Deny(t *testing.T) { + s := newQRTestServer(t) + start, code := qrStart(t, s, "Laptop") + body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q}`, start.RequestID, code) + r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/deny", strings.NewReader(body)) + r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "approver-4", SessionScope: "full"})) + w := httptest.NewRecorder() + s.handleQRDeny(w, r) + if w.Code != http.StatusNoContent { + t.Fatalf("deny: got %d, want 204", w.Code) + } + if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "denied" { + t.Errorf("status after deny: got %q, want denied", got.Status) + } +} + +// With step-up enabled, approving without a fresh re-auth is refused (the gate +// rejects an empty step-up proof before any IdP call). AUTH.md §6. +func TestQRFlow_StepUpRequiredRejectsWithoutReauth(t *testing.T) { + s := newQRTestServer(t) + s.cfg.StepUpEnabled = true + start, code := qrStart(t, s, "Laptop") + if c := qrApprove(t, s, start.RequestID, code, "once", "approver-su"); c != http.StatusForbidden { + t.Errorf("approve without step-up proof: got %d, want 403", c) + } +} + +// A pending request long-polls then reports pending for the client to re-poll. +func TestQRFlow_PendingLongPoll(t *testing.T) { + saved := qrStatusPollWindow + qrStatusPollWindow = 50 * time.Millisecond + defer func() { qrStatusPollWindow = saved }() + + s := newQRTestServer(t) + start, _ := qrStart(t, s, "Laptop") + w := qrStatus(s, start.RequestID, start.PollSecret) + if got := decodeStatus(t, w); got.Status != "pending" { + t.Errorf("pending long-poll: got %q, want pending", got.Status) + } +} + +// selfTokenScope verifies a self-signed session token under the test's session +// key (proving the signature) and returns its scope claim. +func selfTokenScope(t *testing.T, token string) string { + t.Helper() + parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256}) + if err != nil { + t.Fatalf("parse token: %v", err) + } + var std jwt.Claims + custom := map[string]any{} + if err := parsed.Claims(jwtauth.DeriveSessionTokenKey(qrTestSecret), &std, &custom); err != nil { + t.Fatalf("verify token signature: %v", err) + } + if typ, _ := custom["typ"].(string); typ != "session" { + t.Fatalf("token typ: got %q, want session", typ) + } + scope, _ := custom["scope"].(string) + return scope +} diff --git a/internal/httpapi/selftoken.go b/internal/httpapi/selftoken.go new file mode 100644 index 0000000..fd1b643 --- /dev/null +++ b/internal/httpapi/selftoken.go @@ -0,0 +1,39 @@ +package httpapi + +import ( + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" +) + +// mintSessionToken signs a short-lived cdrop self-signed session access token +// (AUTH.md §3.1): HS256 over the SessionSecret-derived key, sub=userID, sid=the +// web_sessions row id, typ=session, scope=full|guest. The matching verifier is +// jwtauth.verifySelfToken. Returns the token and its lifetime in seconds (for the +// client's expires_in). Used by scan-login and self/guest session refresh — never +// touches the IdP. +func (s *Server) mintSessionToken(userID, sid, scope string) (string, int, error) { + ttl := time.Duration(s.cfg.SessionTokenTTLSeconds) * time.Second + now := time.Now() + sig, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.HS256, Key: s.sessionTokenKey}, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + return "", 0, err + } + std := jwt.Claims{ + Subject: userID, + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(ttl)), + } + tok, err := jwt.Signed(sig). + Claims(std). + Claims(map[string]any{"typ": "session", "scope": scope, "sid": sid}). + Serialize() + if err != nil { + return "", 0, err + } + return tok, int(ttl / time.Second), nil +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7f4e059..91a4394 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -44,6 +44,11 @@ type Server struct { sessionKey []byte siteOrigin string + // sessionTokenKey signs cdrop's self-signed session access tokens (scan-login; + // AUTH.md §3.1), derived from SessionSecret in a separate domain from sessionKey. + // nil when SessionSecret is unset → minting is unavailable (QR stays off). + sessionTokenKey []byte + // refreshLocks serialise concurrent refreshes of the same web session (all of // a user's browser tabs share one cookie → one refresh_token). Striped so the // lock set stays bounded; collisions just serialise unrelated sessions, which @@ -77,8 +82,9 @@ func New( push: pushSender, mux: chi.NewRouter(), - sessionKey: deriveSessionKey(cfg.SessionSecret), - siteOrigin: deriveSiteOrigin(cfg.OIDCRedirectURI), + sessionKey: deriveSessionKey(cfg.SessionSecret), + siteOrigin: deriveSiteOrigin(cfg.OIDCRedirectURI), + sessionTokenKey: jwtauth.DeriveSessionTokenKey(cfg.SessionSecret), } s.routes() return s @@ -114,6 +120,11 @@ func (s *Server) routes() { // this browser's name into the session for PWA-eviction recovery. r.Post("/auth/logout", s.handleAuthLogout) r.Post("/auth/device", s.handleAuthDevice) + // Scan-login: the new device opens a request and long-polls status. + // Public (no bearer — the device isn't logged in yet); the private + // poll_secret in the X-Poll-Secret header is the only credential. + r.Post("/auth/qr/start", s.handleQRStart) + r.Get("/auth/qr/status", s.handleQRStatus) }) // Protected routes. gzip / compress is intentionally NOT mounted — @@ -144,14 +155,29 @@ func (s *Server) routes() { r.Post("/hub/signal", s.handleSignal) r.Post("/message", s.handleMessage) r.Get("/devices", s.handleDevices) - r.Delete("/devices/{name}", s.handleDeleteDevice) r.Get("/push/vapid-key", s.handlePushVAPIDKey) r.Post("/push/subscribe", s.handlePushSubscribe) r.Delete("/push/subscribe", s.handlePushUnsubscribe) r.Get("/calls/credentials", s.handleCallsCredentials) - r.Post("/shortcut/issue", s.handleShortcutIssue) - r.Get("/shortcut", s.handleShortcutList) - r.Delete("/shortcut/{jti}", s.handleShortcutRevoke) + + // Account-management surface: restricted guest (scan-login borrow) + // sessions are rejected here too — they can transfer files but not + // remove devices, nor mint / list / revoke long-lived shortcut + // tokens. Full / OIDC / dev sessions pass (AUTH.md §3.2). + r.Group(func(r chi.Router) { + r.Use(requireFullSession) + r.Delete("/devices/{name}", s.handleDeleteDevice) + r.Post("/shortcut/issue", s.handleShortcutIssue) + r.Get("/shortcut", s.handleShortcutList) + r.Delete("/shortcut/{jti}", s.handleShortcutRevoke) + // Scan-login approval side: the logged-in approver views and + // authorises the new device. Guest sessions can't reach here, so a + // borrowed device can't approve further devices. + r.Get("/auth/qr/request", s.handleQRRequest) + r.Post("/auth/qr/approve", s.handleQRApprove) + r.Post("/auth/qr/deny", s.handleQRDeny) + }) + r.Route("/transfer", func(r chi.Router) { r.Post("/initiate", s.handleTransferInit) r.Post("/{id}/accept", s.transitionHandler(transfer.StateAccepted, "")) diff --git a/internal/httpapi/session.go b/internal/httpapi/session.go index 9341d3c..f85abec 100644 --- a/internal/httpapi/session.go +++ b/internal/httpapi/session.go @@ -249,12 +249,20 @@ func RunWebSessionReaper(ctx context.Context, q *db.Queries) { case <-ctx.Done(): return case <-ticker.C: - n, err := q.DeleteExpiredWebSessions(ctx, time.Now().Unix()) + now := time.Now().Unix() + n, err := q.DeleteExpiredWebSessions(ctx, now) if err != nil { slog.Warn("web session reaper failed", "err", err) } else if n > 0 { slog.Info("web sessions reaped", "count", n) } + // Scan-login requests are short-lived (default 120s); sweep the + // stragglers here too. Expired rows are already rejected at use time. + if n, err := q.DeleteExpiredLoginRequests(ctx, now); err != nil { + slog.Warn("login request reaper failed", "err", err) + } else if n > 0 { + slog.Info("login requests reaped", "count", n) + } } } } diff --git a/internal/httpapi/shortcut.go b/internal/httpapi/shortcut.go index 68a4553..b37fd19 100644 --- a/internal/httpapi/shortcut.go +++ b/internal/httpapi/shortcut.go @@ -59,6 +59,26 @@ func rejectScoped(next http.Handler) http.Handler { }) } +// requireFullSession rejects restricted guest sessions (scan-login borrow): they +// may transfer files but not reach account-management surfaces — removing devices, +// approving more devices, or minting / listing / revoking long-lived tokens. A +// borrowed device thus can't escalate. Full / OIDC / dev sessions pass; scoped +// shortcut tokens are already blocked upstream by rejectScoped (AUTH.md §3.2). +func requireFullSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := jwtauth.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"}) + return + } + if claims.Guest() { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "full session required"}) + return + } + next.ServeHTTP(w, r) + }) +} + // 这些端点只由登录会话调用(路由层 rejectScoped 守门)——用 token 不能自管 token, // 防止泄漏的快捷指令 token 自我续期或越权。 diff --git a/internal/jwtauth/claims.go b/internal/jwtauth/claims.go index a687c79..07878d9 100644 --- a/internal/jwtauth/claims.go +++ b/internal/jwtauth/claims.go @@ -11,12 +11,22 @@ type Claims struct { // enforces this). This keeps a leaked shortcut token's blast radius minimal. JTI string Scopes []string + // SessionScope is set only for cdrop self-signed session tokens (scan-login): + // "full" or "guest". Empty for OIDC / dev sessions and scoped shortcut tokens. + // A "guest" session is capability-limited (requireFullSession rejects it on + // account-management routes) even though it is not Scoped(). + SessionScope string } // Scoped reports whether these claims came from a scoped shortcut token rather // than a full login session. func (c *Claims) Scoped() bool { return c.JTI != "" } +// Guest reports whether these claims came from a restricted guest session (a +// scan-login borrow). Guest sessions can transfer files but not manage the +// account, approve other devices, or mint long-lived tokens. +func (c *Claims) Guest() bool { return c.SessionScope == "guest" } + // HasScope reports whether the claims grant the named scope. func (c *Claims) HasScope(scope string) bool { for _, s := range c.Scopes { @@ -40,6 +50,13 @@ func ClaimsFromContext(ctx context.Context) (*Claims, bool) { return c, ok } +// ContextWithClaims attaches claims to a context — the inverse of +// ClaimsFromContext. The auth middleware uses this; it is also the seam handlers +// and tests use to inject claims directly. +func ContextWithClaims(ctx context.Context, c *Claims) context.Context { + return context.WithValue(ctx, claimsCtxKey, c) +} + func DeviceNameFromContext(ctx context.Context) (string, bool) { n, ok := ctx.Value(deviceCtxKey).(string) return n, ok diff --git a/internal/jwtauth/middleware.go b/internal/jwtauth/middleware.go index fe2f4c9..6ce5739 100644 --- a/internal/jwtauth/middleware.go +++ b/internal/jwtauth/middleware.go @@ -29,10 +29,11 @@ type Store interface { } type Authenticator struct { - cfg *config.Config - store Store - jwks *jwksCache - hsKey []byte + cfg *config.Config + store Store + jwks *jwksCache + hsKey []byte + sessionTokenKey []byte } func New(cfg *config.Config, store Store) *Authenticator { @@ -43,6 +44,9 @@ func New(cfg *config.Config, store Store) *Authenticator { if cfg.HS256Secret != "" { a.hsKey = DeriveHS256Key(cfg.HS256Secret) } + // Self-signed session tokens (scan-login) are keyed off SessionSecret; nil + // when unset (dev) disables verifySelfToken, matching the minting side. + a.sessionTokenKey = DeriveSessionTokenKey(cfg.SessionSecret) if cfg.AuthMode == "prod" && cfg.OIDCJWKSURL != "" { a.jwks = newJWKSCache(cfg.OIDCJWKSURL, 10*time.Minute) } @@ -101,12 +105,51 @@ func (a *Authenticator) verify(ctx context.Context, token string, r *http.Reques if a.cfg.AuthMode == "dev" { return a.verifyDev(token, r) } + // cdrop self-signed session tokens first: distinct key + typ=session, so a + // shortcut token (different key, requires jti) never validates here and a + // session token never falls through to the shortcut path's DB lookup. + if c, err := a.verifySelfToken(token); err == nil { + return c, nil + } if c, err := a.verifyHS256(ctx, token); err == nil { return c, nil } return a.verifyRS256(ctx, token) } +// verifySelfToken validates a cdrop self-signed session access token (AUTH.md +// §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; +// revocation rides the short TTL (the session row is re-checked at /auth/refresh). +func (a *Authenticator) verifySelfToken(token string) (*Claims, error) { + if len(a.sessionTokenKey) == 0 { + return nil, errors.New("session token key not configured") + } + parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256}) + if err != nil { + return nil, err + } + var std jwt.Claims + custom := map[string]any{} + if err := parsed.Claims(a.sessionTokenKey, &std, &custom); err != nil { + return nil, err + } + if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil { + return nil, err + } + if typ, _ := custom["typ"].(string); typ != "session" { + return nil, errors.New("not a session token") + } + if std.Subject == "" { + return nil, errors.New("session token missing subject") + } + scope, _ := custom["scope"].(string) + if scope != "full" && scope != "guest" { + return nil, errors.New("session token invalid scope") + } + return &Claims{UserID: std.Subject, SessionScope: scope}, nil +} + func (a *Authenticator) verifyDev(token string, r *http.Request) (*Claims, error) { if subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 { return nil, errors.New("invalid dev token") @@ -220,6 +263,66 @@ func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims, return claimsFromJWT(std, custom) } +// 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 +// epoch second of the actual end-user authentication). Used for step-up re-auth +// (AUTH.md §6): the caller checks sub matches and auth_time is recent enough. +func (a *Authenticator) VerifyIDToken(ctx context.Context, token string) (subject string, authTime int64, err error) { + if a.jwks == nil { + return "", 0, errors.New("JWKS not configured") + } + parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256}) + if err != nil { + return "", 0, err + } + if len(parsed.Headers) == 0 { + return "", 0, errors.New("missing token headers") + } + key, err := a.jwks.GetKey(ctx, parsed.Headers[0].KeyID) + if err != nil { + return "", 0, err + } + var std jwt.Claims + custom := map[string]any{} + if err := parsed.Claims(key, &std, &custom); err != nil { + return "", 0, err + } + expected := jwt.Expected{Time: time.Now()} + if a.cfg.OIDCIssuer != "" { + expected.Issuer = a.cfg.OIDCIssuer + } + if a.cfg.OIDCAudience != "" { + expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience) + } + if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil { + return "", 0, err + } + if std.Subject == "" { + return "", 0, errors.New("missing subject claim") + } + at, ok := numericClaim(custom["auth_time"]) + if !ok { + return "", 0, errors.New("missing auth_time claim") + } + return std.Subject, at, nil +} + +// numericClaim coerces a JSON number claim (float64 from stdlib unmarshal, or +// json.Number / int64) to int64. +func numericClaim(v any) (int64, bool) { + switch n := v.(type) { + case float64: + return int64(n), true + case int64: + return n, true + case json.Number: + if i, err := n.Int64(); err == nil { + return i, true + } + } + return 0, false +} + // parseAudiences splits a comma-separated OIDCAudience config into a jwt.Audience // set. Multiple values let one backend accept tokens minted for several OAuth // clients (the web app and the desktop client carry different `aud`); go-jose's @@ -284,6 +387,20 @@ func DeriveHS256Key(secret string) []byte { return sum[:] } +// DeriveSessionTokenKey derives the HMAC key for cdrop's self-signed session +// access tokens from CDROP_SESSION_SECRET. Domain-separated from both the at-rest +// refresh-token AES key (plain sha256(secret), in httpapi) and the shortcut-token +// key (DeriveHS256Key) so one secret yields three independent keys — a session +// token can never validate as a shortcut token, or vice versa. Empty secret → +// nil, which disables both minting and verifying (dev / unconfigured prod). +func DeriveSessionTokenKey(secret string) []byte { + if secret == "" { + return nil + } + sum := sha256.Sum256([]byte("cdrop-session-jwt\x00" + secret)) + return sum[:] +} + // SanitizeDeviceName enforces the global ASCII-only device-name policy. Device // names ride in the X-Device-Name HTTP header, which can't carry non-ASCII // reliably (and browser fetch rejects such header values outright), so the name diff --git a/internal/jwtauth/middleware_test.go b/internal/jwtauth/middleware_test.go index 5b1689f..9f6592c 100644 --- a/internal/jwtauth/middleware_test.go +++ b/internal/jwtauth/middleware_test.go @@ -488,3 +488,80 @@ func TestSanitizeDeviceName(t *testing.T) { } } } + +// signSelfToken mints a cdrop self-signed session token the way httpapi.mintSessionToken +// 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 { + t.Helper() + sig, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.HS256, Key: DeriveSessionTokenKey(secret)}, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + t.Fatalf("signer: %v", err) + } + std := jwt.Claims{ + Subject: sub, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(exp), + } + tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"typ": typ, "scope": scope}).Serialize() + if err != nil { + t.Fatalf("serialize: %v", err) + } + return tok +} + +// A cdrop self-signed session token authenticates as a full or guest session; +// guest is capability-limited (Guest() true). A wrong typ or an unknown scope is +// rejected, and the SessionSecret-derived key is domain-isolated from the HS256 +// shortcut key so the two token families never cross-validate (AUTH.md §3.1). +func TestSelfToken_ScopeAndKeyIsolation(t *testing.T) { + secret := "test-session-secret-at-least-32-bytes-long" + a := New(&config.Config{AuthMode: "prod", SessionSecret: secret}, &fakeDeviceUpserter{}) + exp := time.Now().Add(time.Hour) + + // Full session: authenticates, not scoped, not guest. + code, claims := serveBearer(a, signSelfToken(t, secret, "user-x", "session", "full", exp)) + if code != http.StatusOK { + t.Fatalf("full session token: got %d, want 200", code) + } + if claims == nil || claims.UserID != "user-x" || claims.SessionScope != "full" || claims.Scoped() || claims.Guest() { + t.Fatalf("full session claims wrong: %+v", claims) + } + + // Guest session: authenticates and is marked guest (capability-limited). + code, claims = serveBearer(a, signSelfToken(t, secret, "user-x", "session", "guest", exp)) + if code != http.StatusOK || claims == nil || !claims.Guest() || claims.Scoped() { + t.Fatalf("guest session: code=%d claims=%+v", code, claims) + } + + // Wrong typ (not "session") signed with the session key → rejected. + if c, _ := serveBearer(a, signSelfToken(t, secret, "user-x", "other", "full", exp)); c != http.StatusUnauthorized { + t.Errorf("wrong typ: got %d, want 401", c) + } + // Unknown scope → rejected (no privilege-by-typo). + if c, _ := serveBearer(a, signSelfToken(t, secret, "user-x", "session", "admin", exp)); c != http.StatusUnauthorized { + t.Errorf("invalid scope: got %d, want 401", c) + } + + // Key-domain isolation: with BOTH secrets set, the two families stay disjoint — + // a session token never becomes scoped, a shortcut token never becomes a session. + hsSecret := "test-hs256-secret-at-least-32-bytes-long" + store := &fakeDeviceUpserter{tokens: map[string]db.ShortcutToken{ + "jti-1": {Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", ExpiresAt: exp.Unix()}, + }} + ab := New(&config.Config{AuthMode: "prod", SessionSecret: secret, HS256Secret: hsSecret}, store) + if _, sc := serveBearer(ab, signSelfToken(t, secret, "user-x", "session", "guest", exp)); sc == nil || sc.Scoped() || !sc.Guest() { + t.Errorf("session token leaked into scoped path: %+v", sc) + } + if _, hc := serveBearer(ab, signHS256(t, hsSecret, "user-x", "jti-1", "clipboard", exp)); hc == nil || !hc.Scoped() || hc.SessionScope != "" { + t.Errorf("shortcut token leaked into session path: %+v", hc) + } + + // No SessionSecret on the server → self tokens are unverifiable (key nil). + noSecret := New(&config.Config{AuthMode: "prod", HS256Secret: hsSecret}, &fakeDeviceUpserter{}) + if c, _ := serveBearer(noSecret, signSelfToken(t, secret, "user-x", "session", "full", exp)); c != http.StatusUnauthorized { + t.Errorf("self token without server SessionSecret must be rejected, got %d", c) + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 2166fde..3d26d22 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,7 +13,9 @@ "@mantine/hooks": "^7.13.5", "@mantine/notifications": "^7.13.5", "@tanstack/react-router": "^1.82.0", + "@types/qrcode": "^1.5.6", "lucide-react": "^0.460.0", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "zustand": "^4.5.5" @@ -1595,6 +1597,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1602,6 +1613,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -1644,6 +1664,30 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ansis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", @@ -1805,6 +1849,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1874,6 +1927,17 @@ "node": ">= 6" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1883,6 +1947,24 @@ "node": ">=6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1943,6 +2025,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -1966,6 +2057,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -1990,6 +2087,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -2102,6 +2205,19 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -2151,6 +2267,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -2225,6 +2350,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2319,6 +2453,18 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2458,6 +2604,51 @@ "node": ">= 6" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2512,6 +2703,15 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", @@ -2802,6 +3002,23 @@ "react-is": "^16.13.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3014,6 +3231,21 @@ "node": ">=8.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3156,6 +3388,12 @@ "seroval": "^1.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3166,6 +3404,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3392,6 +3656,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, "node_modules/unplugin": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", @@ -3622,6 +3892,32 @@ "dev": true, "license": "MIT" }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3629,6 +3925,41 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/web/package.json b/web/package.json index 23b11b7..3768ca3 100644 --- a/web/package.json +++ b/web/package.json @@ -15,7 +15,9 @@ "@mantine/hooks": "^7.13.5", "@mantine/notifications": "^7.13.5", "@tanstack/react-router": "^1.82.0", + "@types/qrcode": "^1.5.6", "lucide-react": "^0.460.0", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "zustand": "^4.5.5" diff --git a/web/src/features/auth/AuthShell.tsx b/web/src/features/auth/AuthShell.tsx index e507ea9..0ec9ac2 100644 --- a/web/src/features/auth/AuthShell.tsx +++ b/web/src/features/auth/AuthShell.tsx @@ -10,7 +10,8 @@ export interface AuthShellProps { title: ReactNode; subtitle?: ReactNode; - children: ReactNode; + /** 卡内主体。过渡型屏(如「读取中…」)可只给 title + subtitle,省略 children。 */ + children?: ReactNode; footer?: ReactNode; /** 卡内品牌字标。默认不渲染——顶栏已有 logo,卡内再放会一屏两 logo; * 需要时显式传入一个节点即可。 */ diff --git a/web/src/features/auth/auth.ts b/web/src/features/auth/auth.ts index 0512d6a..21a3ab6 100644 --- a/web/src/features/auth/auth.ts +++ b/web/src/features/auth/auth.ts @@ -2,6 +2,7 @@ import { useAppStore, type User } from "../../store"; import { t } from "../../i18n"; import { apiFetch } from "../../net/api"; import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop"; +import { stashStepUpPending } from "../qr/stepUp"; // ---- dev mode ------------------------------------------------------------- @@ -70,6 +71,12 @@ export async function fetchAuthConfig(): Promise const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier"; const OAUTH_STATE_KEY = "cdrop.oauth_state"; +// step-up 再认证用一对独立的 state / verifier 句柄,绝不复用上面那对常规登录键—— +// 否则 /oauth/callback 在 step-up 往返里会被当成普通登录、误调 /api/auth/exchange。 +// state 走 sessionStorage(仅本次往返、关标签即清,与 step-up 暂存物同生命周期); +// verifier 不单独落键,而是塞进 stepUp.ts 的 PENDING({verifier, returnTo})。 +const STEPUP_STATE_KEY = "cdrop.qr.stepup_state"; + // loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash them // in localStorage, navigate the browser to the provider's /authorize endpoint. // The callback page completes the flow. @@ -110,6 +117,66 @@ export async function loginProd(): Promise window.location.href = `${cfg.authorize_url}?${params}`; } +// startStepUpReauth 发起批准页 step-up 再认证:一次新鲜 PKCE 往返,但追加 +// prompt=login + max_age=0 强制 provider 现场重证(2FA 即在此处过)。复用既有 +// /oauth/callback 作 redirect_uri;跳转前用 sessionStorage 记下「待办」标记 + +// 这次的 verifier + 要回到的批准页 URL(returnTo 走 stepUp.ts 的同款 open-redirect +// 校验,仅允许 /link)。回调检测到「待办」即分叉,不消费 code(见 oauth.callback)。 +// +// 注意:这里不写常规 PKCE_VERIFIER_KEY / OAUTH_STATE_KEY,否则普通登录回调会误判。 +// verifier 进 PENDING、state 进 STEPUP_STATE_KEY,二者均会在回调与批准页里清掉。 +export async function startStepUpReauth(returnTo: string): Promise +{ + const cfg = await fetchAuthConfig(); + if (cfg.auth_mode !== "prod") + { + throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`); + } + if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri) + { + throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)"); + } + + const verifier = generateRandomBase64url(32); + const challenge = await sha256Base64url(verifier); + const state = generateRandomBase64url(16); + + // 先把「待办」落定(含 returnTo 的 open-redirect 校验);非法即拒,不发起跳转。 + if (!stashStepUpPending({ verifier, returnTo })) + { + throw new Error("invalid step-up return path"); + } + sessionStorage.setItem(STEPUP_STATE_KEY, state); + + const params = new URLSearchParams({ + client_id: cfg.client_id, + redirect_uri: cfg.redirect_uri, + response_type: "code", + scope: cfg.scopes || "openid profile email", + state, + code_challenge: challenge, + code_challenge_method: "S256", + prompt: "login", + max_age: "0", + }); + window.location.href = `${cfg.authorize_url}?${params}`; +} + +// verifyStepUpState 在 /oauth/callback 的 step-up 分叉里校 state(防 CSRF,与常规 +// 登录回调同口径)。匹配即消费掉 STEPUP_STATE_KEY 并返回 true;不匹配返回 false。 +export function verifyStepUpState(state: string): boolean +{ + const expected = sessionStorage.getItem(STEPUP_STATE_KEY); + sessionStorage.removeItem(STEPUP_STATE_KEY); + return !!expected && state === expected; +} + +// clearStepUpState 兜底清掉 step-up state(异常路径,避免残留)。 +export function clearStepUpState(): void +{ + sessionStorage.removeItem(STEPUP_STATE_KEY); +} + interface ExchangeResp { access_token: string; diff --git a/web/src/features/qr/QrCanvas.module.css b/web/src/features/qr/QrCanvas.module.css new file mode 100644 index 0000000..ceb2ef0 --- /dev/null +++ b/web/src/features/qr/QrCanvas.module.css @@ -0,0 +1,42 @@ +/* QrCanvas:二维码主体 + 四角定位记号。视觉语言对齐 Theme B—— + recessed(下沉表面)plate 让二维码读作「一个要对准相机的对象」,而非装饰。 */ + +.frame +{ + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + /* 二维码取色令牌:module(深)= 前景文字色,底(浅)= 抬升表面。 + 两者在浅 / 深主题里自然反转,对比始终 >7:1 满足扫码。 */ + --qr-dark: var(--text); + --qr-light: var(--surface-raised); +} + +.canvas +{ + display: block; + border-radius: var(--radius-control); + /* 切到新二维码时淡入,避免位图突变;reduced-motion 下 motion.css 兜底归零。 */ + transition: opacity var(--dur-base) var(--ease-out); +} + +/* 四角定位记号:1.5px 描边的 L 形角标,复用 DeviceTypeIcon 的线稿语汇。 + accent 着色——是整块里唯一允许的「克制的大胆」着色点。 */ +.tick +{ + position: absolute; + width: 14px; + height: 14px; + border: 1.5px solid var(--accent); + pointer-events: none; +} + +.tl { top: calc(var(--space-2) * -1); left: calc(var(--space-2) * -1); + border-right: none; border-bottom: none; border-top-left-radius: 3px; } +.tr { top: calc(var(--space-2) * -1); right: calc(var(--space-2) * -1); + border-left: none; border-bottom: none; border-top-right-radius: 3px; } +.bl { bottom: calc(var(--space-2) * -1); left: calc(var(--space-2) * -1); + border-right: none; border-top: none; border-bottom-left-radius: 3px; } +.br { bottom: calc(var(--space-2) * -1); right: calc(var(--space-2) * -1); + border-left: none; border-top: none; border-bottom-right-radius: 3px; } diff --git a/web/src/features/qr/QrCanvas.tsx b/web/src/features/qr/QrCanvas.tsx new file mode 100644 index 0000000..3e86cae --- /dev/null +++ b/web/src/features/qr/QrCanvas.tsx @@ -0,0 +1,68 @@ +import QRCode from "qrcode"; +import { useEffect, useRef, useState } from "react"; +import s from "./QrCanvas.module.css"; + +export interface QrCanvasProps +{ + /** 编码进二维码的完整 URL(qrPayload)。 */ + value: string; + /** 绘制边长(CSS px);实际位图按 devicePixelRatio 放大以求锐利。默认 220。 */ + size?: number; +} + +// QrCanvas:显码页的签名元素——把 qrPayload 渲染成一枚平静、可信的二维码。 +// +// 取色不写死黑白,而是读当前主题的语义令牌(--surface-raised 作 light module、 +// --text 作 dark module),让二维码在浅 / 深主题里都贴合卡面、不像一块外来贴纸; +// 又保留足够明暗对比满足扫码识别(--text 对 --surface-raised 始终 >7:1)。 +// 四角的定位记号(registration marks)由 CSS 叠加,呼应「对准、扫描」这一动作本身。 +export function QrCanvas(props: QrCanvasProps) +{ + const { value, size = 220 } = props; + const canvasRef = useRef(null); + const [ error, setError ] = useState(false); + + useEffect(() => + { + const canvas = canvasRef.current; + if (!canvas || !value) { return; } + + let cancelled = false; + setError(false); + + // 从计算样式读语义令牌;module 用最深的前景、底用抬升表面,保证对比与贴合。 + const styles = getComputedStyle(canvas); + const dark = styles.getPropertyValue("--qr-dark").trim() || "#1F1F1F"; + const light = styles.getPropertyValue("--qr-light").trim() || "#FFFFFF"; + + QRCode.toCanvas(canvas, value, { + errorCorrectionLevel: "M", + margin: 0, // 留白由外层 CSS 容器负责,位图本身贴满 + width: size, + color: { dark, light }, + }).then( + () => { /* 成功:canvas 已就绪 */ }, + () => { if (!cancelled) { setError(true); } }, + ); + + return () => { cancelled = true; }; + }, [ value, size ]); + + return ( +
+
+ ); +} diff --git a/web/src/features/qr/returnTo.ts b/web/src/features/qr/returnTo.ts new file mode 100644 index 0000000..1be3bfa --- /dev/null +++ b/web/src/features/qr/returnTo.ts @@ -0,0 +1,30 @@ +// 批准页的 redirect-back:/link?r=…&c=… 要求已登录,但既有登录流程(OAuth 整页 +// 跳转 + 回调落 "/")不带返回地址。这里用 sessionStorage 暂存意图返回的 /link URL: +// - /link 在未登录时 stash 当前 URL,再走标准登录; +// - 首页 beforeLoad consume 它——登录后落到 "/" 即被弹回 /link。 +// sessionStorage(非 localStorage):仅本次登录回流需要,关标签即清,且不跨设备。 + +const RETURN_TO_KEY = "cdrop.qr.return_to"; + +// 仅允许站内 /link 路径回流,杜绝 open-redirect(外部 URL / 协议相对地址)。 +function isSafeLinkPath(path: string): boolean +{ + return path.startsWith("/link?") || path === "/link"; +} + +export function stashLinkReturn(path: string): void +{ + if (typeof window === "undefined") { return; } + if (!isSafeLinkPath(path)) { return; } + window.sessionStorage.setItem(RETURN_TO_KEY, path); +} + +// consumeLinkReturn 取出并清除暂存的返回地址(一次性)。无 / 非法返回 null。 +export function consumeLinkReturn(): string | null +{ + if (typeof window === "undefined") { return null; } + const path = window.sessionStorage.getItem(RETURN_TO_KEY); + window.sessionStorage.removeItem(RETURN_TO_KEY); + if (!path || !isSafeLinkPath(path)) { return null; } + return path; +} diff --git a/web/src/features/qr/stepUp.ts b/web/src/features/qr/stepUp.ts new file mode 100644 index 0000000..38908ca --- /dev/null +++ b/web/src/features/qr/stepUp.ts @@ -0,0 +1,98 @@ +// 批准页 step-up 再认证的会话级暂存。step_up 开启时,批准方在批准新设备前必须做 +// 一次新鲜的 prompt=login 再登录(provider 若开了 2FA 即在此处过),把这次拿到的 +// 授权码交给后端就地核验。这段往返跨一次整页跳转(→ provider → /oauth/callback → +// 批准页),故用 sessionStorage 串起三个状态: +// +// 1. 跳转去 provider 前:记下「待办」标记 + 这次的 code_verifier + 要回到的批准页 +// URL(PENDING)。 +// 2. /oauth/callback 落地(检测到 PENDING):不调 /api/auth/exchange(那会在服务端 +// 消费掉 code 并轮换会话),转而把本次回调的 {code, verifier} 暂存进 STASH, +// 清掉 PENDING,整页跳回批准页 URL。 +// 3. 批准页回到后:取出 STASH 的 {code, verifier} → 调 qrApprove 就地核验 → 用完即清。 +// +// sessionStorage(非 localStorage):仅本次再认证往返需要,关标签即清、不跨设备; +// code 是一次性的且与本批准会话强绑定,落 localStorage 反而徒增泄漏面。 + +const PENDING_KEY = "cdrop.qr.stepup_pending"; // { verifier, returnTo } +const STASH_KEY = "cdrop.qr.stepup_stash"; // { code, verifier } + +// 仅允许站内 /link 路径回流,杜绝 open-redirect(外部 URL / 协议相对地址)。 +// 与 returnTo.ts 同口径——step-up 往返回来后的整页跳转目标只能是批准页。 +function isSafeLinkPath(path: string): boolean +{ + return path.startsWith("/link?") || path === "/link"; +} + +export interface StepUpPending +{ + verifier: string; + returnTo: string; +} + +export interface StepUpStash +{ + code: string; + verifier: string; +} + +// stashStepUpPending 在跳转去 provider 前记下「待办」。returnTo 非法(防开放重定向) +// 即不写——调用方据返回值判定是否安全发起跳转。 +export function stashStepUpPending(pending: StepUpPending): boolean +{ + if (typeof window === "undefined") { return false; } + if (!pending.verifier || !isSafeLinkPath(pending.returnTo)) { return false; } + window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending)); + return true; +} + +// peekStepUpPending 只读地探测是否在 step-up 往返中(/oauth/callback 据此分叉, +// 决定不走 /api/auth/exchange)。不消费。 +export function peekStepUpPending(): StepUpPending | null +{ + if (typeof window === "undefined") { return null; } + const raw = window.sessionStorage.getItem(PENDING_KEY); + if (!raw) { return null; } + const p = safeParse(raw); + if (!p?.verifier || !isSafeLinkPath(p.returnTo)) { return null; } + return p; +} + +export function clearStepUpPending(): void +{ + if (typeof window === "undefined") { return; } + window.sessionStorage.removeItem(PENDING_KEY); +} + +// stashStepUpCode 把回调拿到的一次性 code + 配套 verifier 暂存,留给批准页交给 +// 后端 approve 核验。前端绝不自己 POST /exchange 消费它。 +export function stashStepUpCode(stash: StepUpStash): void +{ + if (typeof window === "undefined") { return; } + if (!stash.code || !stash.verifier) { return; } + window.sessionStorage.setItem(STASH_KEY, JSON.stringify(stash)); +} + +// consumeStepUpCode 取出并清除暂存的 {code, verifier}(一次性,用完即清,避免泄漏 +// 与复用)。无 / 非法返回 null。 +export function consumeStepUpCode(): StepUpStash | null +{ + if (typeof window === "undefined") { return null; } + const raw = window.sessionStorage.getItem(STASH_KEY); + window.sessionStorage.removeItem(STASH_KEY); + if (!raw) { return null; } + const s = safeParse(raw); + if (!s?.code || !s.verifier) { return null; } + return s; +} + +export function clearStepUpStash(): void +{ + if (typeof window === "undefined") { return; } + window.sessionStorage.removeItem(STASH_KEY); +} + +function safeParse(raw: string): T | null +{ + try { return JSON.parse(raw) as T; } + catch { return null; } +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 25c9e73..65a7b75 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -93,6 +93,50 @@ export const enUS: Partial = { "oauth.signingIn": "Signing in…", "oauth.missingParams": "Missing 'code' or 'state' query parameter", + // ---- QR sign-in · entry -------------------------------------------- + "qr.entry.fromLogin": "Sign in with a QR code", + + // ---- QR sign-in · show page (new device) --------------------------- + "qr.show.title": "Sign in with a QR code", + "qr.show.subtitle": "Name this device, then scan the code from a signed-in phone to let it into your account.", + "qr.show.nameHint": "Shown to your other devices. ASCII characters only.", + "qr.show.generate": "Generate code", + "qr.show.scanTitle": "Scan to approve", + "qr.show.howto1": "Open the camera or cdrop on another signed-in device.", + "qr.show.howto2": "Scan the code above and approve this device.", + "qr.show.waiting": "Waiting for approval…", + "qr.show.approved": "Approved — signing you in…", + "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.regenerate": "Generate a new code", + + // ---- QR sign-in · approve page (phone) ----------------------------- + "qr.approve.title": "Approve a new device", + "qr.approve.subtitle": "Confirm this device may sign in to your account. Check the device and origin first.", + "qr.approve.loading": "Reading device details…", + "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.trustLabel": "Trust duration", + "qr.approve.trust.title": "Trust this device", + "qr.approve.trust.hint": "Skip approval for 7 days, renewed on each use.", + "qr.approve.once.title": "Just this once", + "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.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.button": "Verify and approve", + "qr.approve.stepUp.rejected": "Identity verification didn't pass or has expired. Verify again to confirm it's you, then approve.", + "qr.approve.deny": "Decline", + "qr.approve.doneApprovedTitle": "Approved", + "qr.approve.doneApproved": "This device is now linked to your account. You can send and receive files on it.", + "qr.approve.doneDeniedTitle": "Declined", + "qr.approve.doneDenied": "The request was declined. That device won't sign in to your account.", + "qr.approve.expiredTitle": "Code expired", + "qr.approve.expired": "This code is no longer valid. Generate a new one on the device and scan again.", + "qr.approve.errorTitle": "Something went wrong", + "qr.approve.error": "That didn't go through. Try again, or generate a new code on the device.", + "qr.approve.backHome": "Back to home", + // ---- settings ------------------------------------------------------- "settings.title": "Settings", "settings.currentDevice.title": "This device", diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index de1f286..51bfd07 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -91,6 +91,50 @@ export const zhCN = { "oauth.signingIn": "正在登录…", "oauth.missingParams": "缺少code或state查询参数", + // ---- 扫码登录 · 入口 ------------------------------------------------- + "qr.entry.fromLogin": "扫码登录此设备", + + // ---- 扫码登录 · 显码页(新设备)------------------------------------- + "qr.show.title": "扫码登录此设备", + "qr.show.subtitle": "先给这台设备起个名字,再用已登录的手机扫码批准它接入你的账号。", + "qr.show.nameHint": "此名称会展示给你的其他设备,仅可使用 ASCII 字符。", + "qr.show.generate": "生成二维码", + "qr.show.scanTitle": "用手机扫码批准", + "qr.show.howto1": "在另一台已登录的设备上打开相机或 cdrop。", + "qr.show.howto2": "扫描上方二维码,按提示批准此设备。", + "qr.show.waiting": "等待批准…", + "qr.show.approved": "已批准,正在进入…", + "qr.show.expired": "二维码已过期。重新生成一张再扫。", + "qr.show.denied": "这次接入被拒绝。如确为你本人操作,重新生成二维码再试。", + "qr.show.regenerate": "重新生成二维码", + + // ---- 扫码登录 · 批准页(手机)------------------------------------- + "qr.approve.title": "批准新设备", + "qr.approve.subtitle": "确认让这台设备登入你的账号。请核对设备与来源无误。", + "qr.approve.loading": "正在读取设备信息…", + "qr.approve.badLink": "这个批准链接不完整。请在新设备上重新生成二维码再扫。", + "qr.approve.signInFirst": "先登录你的账号,再批准这台设备接入。", + "qr.approve.trustLabel": "信任时长", + "qr.approve.trust.title": "信任此设备", + "qr.approve.trust.hint": "7 天内免再次批准,每次使用自动续期。", + "qr.approve.once.title": "仅此一次", + "qr.approve.once.hint": "登录有效 1 小时,到期需重新扫码。", + "qr.approve.scopeGuest": "这台设备将以受限访客身份登入:可收发文件与消息,但不能更改账号、不能批准其他设备。", + "qr.approve.approve": "批准登入", + "qr.approve.stepUp.notice": "为安全起见,批准前需先验证你的身份——点下方按钮会引导你重新登录一次。", + "qr.approve.stepUp.button": "验证并批准", + "qr.approve.stepUp.rejected": "身份验证未通过或已过期。请再验证一次,确认是你本人后即可批准。", + "qr.approve.deny": "拒绝", + "qr.approve.doneApprovedTitle": "已批准", + "qr.approve.doneApproved": "这台设备已接入你的账号,现在即可在它上面收发文件。", + "qr.approve.doneDeniedTitle": "已拒绝", + "qr.approve.doneDenied": "这次接入已被拒绝,那台设备不会登入你的账号。", + "qr.approve.expiredTitle": "二维码已过期", + "qr.approve.expired": "这张二维码已失效。请在新设备上重新生成再扫。", + "qr.approve.errorTitle": "出错了", + "qr.approve.error": "操作没能完成。请重试,或在新设备上重新生成二维码。", + "qr.approve.backHome": "返回主页", + // ---- 设置页 ---------------------------------------------------------- "settings.title": "设置", "settings.currentDevice.title": "当前设备", diff --git a/web/src/i18n/locales/zh-TW.ts b/web/src/i18n/locales/zh-TW.ts index 52aba40..9c2dc41 100644 --- a/web/src/i18n/locales/zh-TW.ts +++ b/web/src/i18n/locales/zh-TW.ts @@ -95,6 +95,50 @@ export const zhTW: Partial = { "oauth.signingIn": "正在登入…", "oauth.missingParams": "缺少code或state查詢參數", + // ---- 掃碼登入 · 入口 ------------------------------------------------- + "qr.entry.fromLogin": "掃碼登入此裝置", + + // ---- 掃碼登入 · 顯碼頁(新裝置)------------------------------------- + "qr.show.title": "掃碼登入此裝置", + "qr.show.subtitle": "先為這部裝置命名,再用已登入的手機掃碼批准它接入你的帳號。", + "qr.show.nameHint": "此名稱會顯示給你的其他裝置,僅可使用 ASCII 字元。", + "qr.show.generate": "產生 QR 碼", + "qr.show.scanTitle": "用手機掃碼批准", + "qr.show.howto1": "在另一部已登入的裝置上開啟相機或 cdrop。", + "qr.show.howto2": "掃描上方 QR 碼,依提示批准此裝置。", + "qr.show.waiting": "等待批准…", + "qr.show.approved": "已批准,正在進入…", + "qr.show.expired": "QR 碼已逾時。重新產生一張再掃。", + "qr.show.denied": "這次接入被拒絕。如確為你本人操作,重新產生 QR 碼再試。", + "qr.show.regenerate": "重新產生 QR 碼", + + // ---- 掃碼登入 · 批准頁(手機)------------------------------------- + "qr.approve.title": "批准新裝置", + "qr.approve.subtitle": "確認讓這部裝置登入你的帳號。請核對裝置與來源無誤。", + "qr.approve.loading": "正在讀取裝置資訊…", + "qr.approve.badLink": "這個批准連結不完整。請在新裝置上重新產生 QR 碼再掃。", + "qr.approve.signInFirst": "先登入你的帳號,再批准這部裝置接入。", + "qr.approve.trustLabel": "信任時長", + "qr.approve.trust.title": "信任此裝置", + "qr.approve.trust.hint": "7 天內免再次批准,每次使用自動續期。", + "qr.approve.once.title": "僅此一次", + "qr.approve.once.hint": "登入有效 1 小時,逾時需重新掃碼。", + "qr.approve.scopeGuest": "這部裝置將以受限訪客身分登入:可收發檔案與訊息,但不能變更帳號、不能批准其他裝置。", + "qr.approve.approve": "批准登入", + "qr.approve.stepUp.notice": "為安全起見,批准前需先驗證你的身分——點下方按鈕會引導你重新登入一次。", + "qr.approve.stepUp.button": "驗證並批准", + "qr.approve.stepUp.rejected": "身分驗證未通過或已逾時。請再驗證一次,確認是你本人後即可批准。", + "qr.approve.deny": "拒絕", + "qr.approve.doneApprovedTitle": "已批准", + "qr.approve.doneApproved": "這部裝置已接入你的帳號,現在即可在它上面收發檔案。", + "qr.approve.doneDeniedTitle": "已拒絕", + "qr.approve.doneDenied": "這次接入已被拒絕,那部裝置不會登入你的帳號。", + "qr.approve.expiredTitle": "QR 碼已逾時", + "qr.approve.expired": "這張 QR 碼已失效。請在新裝置上重新產生再掃。", + "qr.approve.errorTitle": "出錯了", + "qr.approve.error": "操作沒能完成。請重試,或在新裝置上重新產生 QR 碼。", + "qr.approve.backHome": "返回首頁", + // ---- 設定頁 ---------------------------------------------------------- "settings.title": "設定", "settings.currentDevice.title": "目前裝置", diff --git a/web/src/net/qr.ts b/web/src/net/qr.ts new file mode 100644 index 0000000..909e99a --- /dev/null +++ b/web/src/net/qr.ts @@ -0,0 +1,280 @@ +import { apiFetch, apiJSON } from "./api"; + +// 扫码登录的网络封装。两条信任边界: +// - 显码页(新设备)在 start 时还未登录,故 start / status 是公开端点, +// 凭 request_id + 一次性 poll_secret 自证(poll_secret 走 X-Poll-Secret 头, +// 绝不进 URL / 日志)。 +// - 批准页(手机)已完整登录,request / approve / deny 走既有 apiFetch 带 Bearer。 +// refresh_token 永不进 JS——approved 时服务端在响应里 Set-Cookie 下发会话 cookie, +// access_token 由调用方 setAuth 进 sessionStorage,与既有 OAuth 流程同口径。 + +// ---- 显码页(公开端点:无 Bearer,凭 poll_secret 自证)---------------------- + +export interface QrStartReq +{ + deviceName: string; + deviceType: string; +} + +export interface QrStartResp +{ + requestId: string; + pollSecret: string; + qrPayload: string; // 编码进二维码的完整 URL(/link?r=…&c=…) + expiresAt: number; // epoch 秒 +} + +interface QrStartRespRaw +{ + request_id: string; + poll_secret: string; + qr_payload: string; + expires_at: number; +} + +// qrStart 开一次扫码登录会话。公开端点:手动 fetch(不走 apiFetch,避免注入 Bearer +// 与 X-Device-* 头),仅同源 JSON。 +export async function qrStart(req: QrStartReq): Promise +{ + const r = await fetch("/api/auth/qr/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device_name: req.deviceName, device_type: req.deviceType }), + }); + if (!r.ok) + { + const text = await r.text().catch(() => r.statusText); + throw new Error(`qr start ${r.status}: ${text}`); + } + const data = await r.json() as QrStartRespRaw; + return { + requestId: data.request_id, + pollSecret: data.poll_secret, + qrPayload: data.qr_payload, + expiresAt: data.expires_at, + }; +} + +export type QrStatusPhase = "pending" | "approved" | "denied" | "expired"; + +export interface QrApprovedUser +{ + id: string; + name: string; + avatar?: string; +} + +export interface QrStatus +{ + status: QrStatusPhase; + // status === "approved" 时一并带回: + accessToken?: string; + expiresIn?: number; + user?: QrApprovedUser; + deviceName?: string; +} + +interface QrStatusRaw +{ + status: QrStatusPhase; + access_token?: string; + expires_in?: number; + user?: QrApprovedUser; + device_name?: string; +} + +// qrStatusOnce 长轮询一轮 status(服务端可能挂起约 25s 再返回)。公开端点:凭 +// X-Poll-Secret 头自证。signal 可取消(页面卸载 / 重新生成二维码即 abort)。 +async function qrStatusOnce( + requestId: string, + pollSecret: string, + signal: AbortSignal, +): Promise +{ + const url = `/api/auth/qr/status?request_id=${encodeURIComponent(requestId)}`; + const r = await fetch(url, { headers: { "X-Poll-Secret": pollSecret }, signal }); + if (!r.ok) + { + const text = await r.text().catch(() => r.statusText); + throw new Error(`qr status ${r.status}: ${text}`); + } + const data = await r.json() as QrStatusRaw; + return { + status: data.status, + accessToken: data.access_token, + expiresIn: data.expires_in, + user: data.user, + deviceName: data.device_name, + }; +} + +export interface PollHandlers +{ + // 每轮返回非 pending 终态即调用一次。pending 由轮询循环内部静默续轮,不上抛。 + onResolved: (status: QrStatus) => void; + // 单轮网络出错时调用(仍会退避后继续轮询,不终止)。可用于安静地展示「重试中」。 + onError?: (err: unknown) => void; +} + +// pollQrStatus 驱动长轮询循环直到拿到终态(approved / denied / expired)或被取消。 +// 返回一个 cancel 函数;调用即 abort 在途请求并停止循环。单轮异常(含超时)退避 +// 后重试,不终止——长轮询本就预期被服务端 / 代理周期性切断。 +export function pollQrStatus( + requestId: string, + pollSecret: string, + handlers: PollHandlers, +): () => void +{ + const ctrl = new AbortController(); + let stopped = false; + + const loop = async () => + { + while (!stopped) + { + try + { + const status = await qrStatusOnce(requestId, pollSecret, ctrl.signal); + if (stopped) { return; } + if (status.status !== "pending") + { + handlers.onResolved(status); + return; + } + // pending:服务端长轮询已自然超时返回,立即续下一轮(无需退避)。 + } + catch (err) + { + if (stopped || ctrl.signal.aborted) { return; } + handlers.onError?.(err); + // 网络抖动 / 代理切断:短退避后重试,避免忙等打满。 + await sleep(2000); + } + } + }; + + void loop(); + + return () => + { + stopped = true; + ctrl.abort(); + }; +} + +// ---- 批准页(需完整登录:走 apiFetch 带 Bearer)------------------------------ + +export type QrScope = "full" | "guest"; +export type QrPersist = "once" | "persist"; + +export interface QrRequestInfo +{ + deviceName: string; + deviceType: string; + requestIp: string; + expiresAt: number; + status: QrStatusPhase; + // step_up 开启时为 true:批准前必须做一次新鲜的 prompt=login 再认证,把这次 + // 拿到的授权码 + verifier 交给 approve 就地核验(见 features/qr/stepUp.ts)。 + stepUp: boolean; +} + +interface QrRequestInfoRaw +{ + device_name: string; + device_type: string; + request_ip: string; + expires_at: number; + status: QrStatusPhase; + step_up?: boolean; +} + +// qrRequest 取待批准设备的信息,批准页据此渲染安全确认。需完整登录(apiFetch 带 +// Bearer + 401 自动续期)。 +export async function qrRequest(requestId: string, code: string): Promise +{ + const url = `/api/auth/qr/request?request_id=${encodeURIComponent(requestId)}` + + `&code=${encodeURIComponent(code)}`; + const data = await apiJSON(url); + return { + deviceName: data.device_name, + deviceType: data.device_type, + requestIp: data.request_ip, + expiresAt: data.expires_at, + status: data.status, + stepUp: data.step_up === true, + }; +} + +// stepUpRequired 标记 approve 因 step-up 再认证缺失 / 过期被后端拒(403 +// {error:"step_up_required"})。批准页据此把用户带回再认证一遭,而非当成普通错误。 +export class StepUpRequiredError extends Error +{ + constructor() + { + super("step_up_required"); + this.name = "StepUpRequiredError"; + } +} + +// qrApprove 批准该设备登入本账号。scope 本期固定 guest(受限访客);persist 决定 +// 信任时长(persist=7 天滑动 / once=1 小时)。成功 204。 +// +// step_up 开启时须带上一次新鲜 prompt=login 再认证拿到的 stepUpCode + stepUpVerifier, +// 后端就地向 IdP 换 id_token、验签 + 校 auth_time 新鲜 + sub 匹配;缺失 / 过期 → +// 403 step_up_required(抛 StepUpRequiredError)。step_up 关时这俩字段被后端忽略。 +export async function qrApprove( + requestId: string, + code: string, + scope: QrScope, + persist: QrPersist, + stepUpCode?: string, + stepUpVerifier?: string, +): Promise +{ + const body: Record = { + request_id: requestId, + approval_code: code, + scope, + persist, + }; + if (stepUpCode && stepUpVerifier) + { + body.step_up_code = stepUpCode; + body.step_up_verifier = stepUpVerifier; + } + const r = await apiFetch("/api/auth/qr/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + 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(`qr approve ${r.status}: ${text}`); + } +} + +// qrDeny 拒绝该设备。成功 204。 +export async function qrDeny(requestId: string, code: string): Promise +{ + const r = await apiFetch("/api/auth/qr/deny", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ request_id: requestId, approval_code: code }), + }); + if (!r.ok) + { + const text = await r.text().catch(() => r.statusText); + throw new Error(`qr deny ${r.status}: ${text}`); + } +} + +function sleep(ms: number): Promise +{ + return new Promise((resolve) => { window.setTimeout(resolve, ms); }); +} diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 420a232..8a41394 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -307,11 +307,15 @@ function ConnectionIcon(props: { connected: boolean }) ); } -// 这些路由自己撑满 viewport,root layout 不再套 AppShell。 +// 这些路由自己撑满 viewport(各自渲染 AuthShell),root layout 不再套 AppShell, +// 否则会出现 AppShell 顶栏 + AuthShell 全屏壳的双层 chrome。扫码登录的显码页 / +// 批准页同属此列。 function isAuthRoute(pathname: string): boolean { return pathname === "/login" || pathname === "/setup" + || pathname === "/link" + || pathname === "/link/new" || pathname.startsWith("/oauth/"); } diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index 35502f3..6c4acd4 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from "react"; import { Container } from "@mantine/core"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { useAppStore } from "../store"; +import { consumeLinkReturn } from "../features/qr/returnTo"; import { DeviceStrip } from "../features/devices/DeviceStrip"; import { Composer } from "../features/transfer/Composer"; import { ActivityFeed } from "../features/transfer/ActivityFeed"; @@ -21,6 +22,9 @@ export const Route = createFileRoute("/")({ const { user, selfDeviceName } = useAppStore.getState(); if (!user) { throw redirect({ to: "/login", search: { dev_user: undefined } }); } if (!selfDeviceName) { throw redirect({ to: "/setup" }); } + // 批准页 redirect-back:登录后落到 "/" 时若有暂存的 /link 返回地址,弹回去。 + const back = consumeLinkReturn(); + if (back) { throw redirect({ to: back }); } }, }); diff --git a/web/src/routes/link.module.css b/web/src/routes/link.module.css new file mode 100644 index 0000000..7be5267 --- /dev/null +++ b/web/src/routes/link.module.css @@ -0,0 +1,146 @@ +/* 批准页:一次安全确认,deliberate 而明确。 */ + +/* 待批准设备身份行 */ +.device +{ + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--surface-sunken); + border: 1px solid var(--divider); + border-radius: var(--radius-card); +} + +.deviceIcon +{ + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: var(--radius-control); + background: var(--accent-soft); + color: var(--accent); +} + +.deviceMeta +{ + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.deviceName +{ + font-size: var(--fs-15); + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deviceSub +{ + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--fs-12); + color: var(--text-muted); +} + +.dot { color: var(--divider-strong); } + +.ip +{ + font-family: var(--font-mono); + font-size: var(--fs-12); + color: var(--text-muted); +} + +/* 信任时长两选项 */ +.choices +{ + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.choice +{ + display: flex; + align-items: flex-start; + gap: var(--space-3); + width: 100%; + text-align: left; + padding: var(--space-3) var(--space-4); + background: var(--surface); + border: 1px solid var(--divider); + border-left: 3px solid var(--divider); + border-radius: var(--radius-control); + cursor: pointer; + color: var(--text); + transition: + border-color var(--dur-fast) var(--ease-out), + background-color var(--dur-fast) var(--ease-out); +} + +.choice:hover { border-color: var(--divider-strong); } + +/* 选中态:accent 左色条 + soft 底(同 Callout / Activity 的状态编码语汇)。 */ +.choiceOn +{ + background: var(--accent-softer); + border-color: var(--accent-ring); + border-left-color: var(--accent); +} + +.choice:focus-visible +{ + outline: 2px solid var(--accent-ring); + outline-offset: 2px; +} + +.choiceMark +{ + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 1px; + flex-shrink: 0; + color: var(--text-muted); +} + +.choiceOn .choiceMark { color: var(--accent); } + +.choiceText +{ + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.choiceTitle +{ + font-size: var(--fs-13); + font-weight: 600; +} + +.choiceHint +{ + font-size: var(--fs-12); + line-height: var(--lh-body); + color: var(--text-muted); +} + +/* 批准 / 拒绝:批准是主色行动,拒绝是安静的 ghost。 */ +.actions +{ + display: flex; + flex-direction: column; + gap: var(--space-2); +} diff --git a/web/src/routes/link.tsx b/web/src/routes/link.tsx new file mode 100644 index 0000000..19b9abe --- /dev/null +++ b/web/src/routes/link.tsx @@ -0,0 +1,368 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { AlertTriangle, Check, Clock, LogIn, ShieldCheck, X } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { AuthShell } from "../features/auth/AuthShell"; +import { loginProd, startStepUpReauth } from "../features/auth/auth"; +import { stashLinkReturn } from "../features/qr/returnTo"; +import { consumeStepUpCode, type StepUpStash } from "../features/qr/stepUp"; +import { isDesktop, startDesktopLogin } from "../net/desktop"; +import { + qrApprove, qrDeny, qrRequest, StepUpRequiredError, + type QrPersist, type QrRequestInfo, +} from "../net/qr"; +import { useAppStore } from "../store"; +import { t } from "../i18n"; +import { DeviceTypeIcon, type DeviceKind } from "../ui/glyphs"; +import { Button, Callout } from "../ui/primitives"; +import s from "./link.module.css"; + +// 批准页:手机扫码后落地,确认「是哪台设备、从哪来、给什么权限」。要求已登录。 +export const Route = createFileRoute("/link")({ + component: LinkApprovePage, + validateSearch: (search: Record) => + ({ + r: typeof search.r === "string" ? search.r : undefined, + c: typeof search.c === "string" ? search.c : undefined, + }), +}); + +type Outcome = "approved" | "denied" | "expired" | "error" | null; + +function LinkApprovePage() +{ + const navigate = useNavigate(); + const { r: requestId, c: code } = Route.useSearch(); + const user = useAppStore((s) => s.user); + + const [ info, setInfo ] = useState(null); + const [ loadError, setLoadError ] = useState(null); + const [ persist, setPersist ] = useState("persist"); + const [ submitting, setSubmitting ] = useState(false); + const [ outcome, setOutcome ] = useState(null); + const [ actionError, setActionError ] = useState(null); + // step-up:true=后端要求批准前先做新鲜 prompt=login 再认证(provider 2FA 即此处过)。 + const [ stepUpRequired, setStepUpRequired ] = useState(false); + // 再认证往返回来后由后端二次拒(403 step_up_required),文案提示并允许重试。 + const [ stepUpRejected, setStepUpRejected ] = useState(false); + + // 未登录:暂存当前 URL 后走标准登录,登录后由首页 beforeLoad 弹回这里。 + const handleSignIn = useCallback(async () => + { + stashLinkReturn(window.location.pathname + window.location.search); + try + { + if (isDesktop()) + { + await startDesktopLogin(); + navigate({ to: "/" }); + } + else { await loginProd(); } + } + catch (e) { setLoadError(e instanceof Error ? e.message : String(e)); } + }, [ navigate ]); + + // 已登录且参数齐全:拉取待批准设备信息(含 step_up 标记)。 + useEffect(() => + { + if (!user || !requestId || !code) { return; } + let cancelled = false; + qrRequest(requestId, code).then( + (data) => + { + if (cancelled) { return; } + setInfo(data); + setStepUpRequired(data.stepUp); + if (data.status === "expired") { setOutcome("expired"); } + else if (data.status === "denied") { setOutcome("denied"); } + else if (data.status === "approved") { setOutcome("approved"); } + }, + (e) => { if (!cancelled) { setLoadError(e instanceof Error ? e.message : String(e)); } }, + ); + return () => { cancelled = true; }; + }, [ user, requestId, code ]); + + // doApprove 执行批准;stash 非空时把 step-up 再认证拿到的 code+verifier 一并交给 + // 后端就地核验。403 step_up_required(StepUpRequiredError)单独走「需重新验证」态, + // 不当成普通错误。 + const doApprove = useCallback(async (stash?: StepUpStash) => + { + if (!requestId || !code) { return; } + setSubmitting(true); + setActionError(null); + setStepUpRejected(false); + try + { + await qrApprove(requestId, code, "guest", persist, stash?.code, stash?.verifier); + setOutcome("approved"); + } + catch (e) + { + if (e instanceof StepUpRequiredError) { setStepUpRejected(true); } + else { setActionError(e instanceof Error ? e.message : String(e)); } + } + finally { setSubmitting(false); } + }, [ requestId, code, persist ]); + + // 再认证往返回来:检测到暂存的 step-up {code, verifier} → 自动调 approve 完成批准 + // → 用完即清(consumeStepUpCode 取出即清)。仅在已登录、设备信息已就绪后跑一次。 + useEffect(() => + { + if (!user || !info || outcome) { return; } + const stash = consumeStepUpCode(); + if (!stash) { return; } + void doApprove(stash); + }, [ user, info, outcome, doApprove ]); + + // handleApprove:批准按钮。step-up 要求且尚无再认证凭证时,先把用户带去做一次 + // 新鲜 prompt=login 再认证(往返回来后由上面的 effect 自动接力 approve);否则 + // 维持原有直接批准行为不变。 + const handleApprove = async () => + { + if (!requestId || !code) { return; } + if (stepUpRequired) + { + setSubmitting(true); + setActionError(null); + setStepUpRejected(false); + try { await startStepUpReauth(window.location.pathname + window.location.search); } + catch (e) + { + setActionError(e instanceof Error ? e.message : String(e)); + setSubmitting(false); + } + // 成功则整页跳转去 provider,本组件随之卸载,不再 setSubmitting。 + return; + } + await doApprove(); + }; + + const handleDeny = async () => + { + if (!requestId || !code) { return; } + setSubmitting(true); + setActionError(null); + try + { + await qrDeny(requestId, code); + setOutcome("denied"); + } + catch (e) { setActionError(e instanceof Error ? e.message : String(e)); } + finally { setSubmitting(false); } + }; + + // ---- 渲染分支 -------------------------------------------------------- + + if (!requestId || !code) + { + return ( + + }> + {t("qr.approve.badLink")} + + + ); + } + + if (!user) + { + return ( + + + + ); + } + + if (outcome) { return navigate({ to: "/" })} />; } + + if (loadError) + { + return ( + + }>{loadError} + + ); + } + + if (!info) + { + return ( + + ); + } + + return ( + + {/* 待批准设备:单行身份块——图标 + 名字 + 来源 IP(monospace 元数据)。 */} +
+ + + +
+ {info.deviceName} + + {t(`deviceType.${kindKey(info.deviceType)}` as const)} + · + {info.requestIp} + +
+
+ + {/* 信任时长:两选项分段,选中项得 accent 左色条(同 Callout / Activity 语汇)。 */} +
+ + +
+ + {/* 权限范围说明:本期固定受限访客,明确告知边界(非装饰)。 */} + }> + {t("qr.approve.scopeGuest")} + + + {/* step-up:批准前的明确安全步骤——需先验证身份(provider 2FA 即此处过)。 */} + {stepUpRequired && !stepUpRejected && ( + }> + {t("qr.approve.stepUp.notice")} + + )} + + {/* 再认证回来仍被后端拒:给方向(凭证过期 / 验证未通过)并允许重试。 */} + {stepUpRejected && ( + }> + {t("qr.approve.stepUp.rejected")} + + )} + + {actionError && ( + }>{actionError} + )} + +
+ + +
+
+ ); +} + +function TrustOption(props: { + value: QrPersist; + selected: boolean; + onSelect: (v: QrPersist) => void; + title: string; + hint: string; +}) +{ + const { value, selected, onSelect, title, hint } = props; + return ( + + ); +} + +function OutcomeView(props: { outcome: Exclude; onHome: () => void }) +{ + const { outcome, onHome } = props; + const map = { + approved: { tone: "ok" as const, icon: , + title: t("qr.approve.doneApprovedTitle"), body: t("qr.approve.doneApproved") }, + denied: { tone: "warn" as const, icon: , + title: t("qr.approve.doneDeniedTitle"), body: t("qr.approve.doneDenied") }, + expired: { tone: "warn" as const, icon: , + title: t("qr.approve.expiredTitle"), body: t("qr.approve.expired") }, + error: { tone: "error" as const, icon: , + title: t("qr.approve.errorTitle"), body: t("qr.approve.error") }, + }[outcome]; + return ( + + {map.body} + + + ); +} + +// mapDeviceKind:契约 device_type → DeviceTypeIcon 的 DeviceKind(与 DeviceStrip +// 同口径;批准页独立一份,不改动既有组件)。 +function mapDeviceKind(type: string): DeviceKind +{ + const v = type.toLowerCase(); + if (v === "shortcut") { return "shortcut"; } + if (v.includes("mac") || v === "darwin") { return "macos"; } + if (v.includes("win")) { return "windows"; } + if (v.includes("linux")) { return "linux"; } + if (v.includes("ios") || v.includes("iphone") || v.includes("ipad")) { return "ios"; } + if (v.includes("android")) { return "android"; } + return "unknown"; +} + +// kindKey:device_type → deviceType.* i18n key 的已知分支,未知回退 browser。 +function kindKey(type: string): "browser" | "macos" | "windows" | "linux" | "ios" | "shortcut" +{ + const v = type.toLowerCase(); + if (v === "shortcut") { return "shortcut"; } + if (v.includes("mac") || v === "darwin") { return "macos"; } + if (v.includes("win")) { return "windows"; } + if (v.includes("linux")) { return "linux"; } + if (v.includes("ios") || v.includes("iphone") || v.includes("ipad")) { return "ios"; } + return "browser"; +} diff --git a/web/src/routes/link_.new.module.css b/web/src/routes/link_.new.module.css new file mode 100644 index 0000000..8708826 --- /dev/null +++ b/web/src/routes/link_.new.module.css @@ -0,0 +1,81 @@ +/* 显码页:二维码是主角,其余安静。 */ + +.stage +{ + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-4); + padding: var(--space-2) 0 var(--space-1); +} + +/* recessed plate:下沉表面 + 内描边,把二维码托成「一个要对准相机的对象」。 */ +.plate +{ + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-5); + background: var(--surface-sunken); + border: 1px solid var(--divider); + border-radius: var(--radius-card); +} + +.statusLine +{ + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--fs-13); + color: var(--text-muted); +} + +.waitText +{ + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +/* accent 脉冲点:唯一的动效「时刻」,传达「正在等待批准」的活体感。 */ +.pulse +{ + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 0 var(--accent-ring); + animation: qr-pulse 1.8s var(--ease-in-out) infinite; + flex-shrink: 0; +} + +@keyframes qr-pulse +{ + 0% { box-shadow: 0 0 0 0 var(--accent-ring); } + 70% { box-shadow: 0 0 0 7px transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } +} + +/* 两步指引:用真实序号(这是一个有顺序的过程),而非装饰性编号。 */ +.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; +} + +@media (prefers-reduced-motion: reduce) +{ + .pulse { animation: none; } +} diff --git a/web/src/routes/link_.new.tsx b/web/src/routes/link_.new.tsx new file mode 100644 index 0000000..4401b33 --- /dev/null +++ b/web/src/routes/link_.new.tsx @@ -0,0 +1,237 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { AlertTriangle, ArrowRight, RefreshCw, ScanLine } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { AuthShell } from "../features/auth/AuthShell"; +import { syncWebDeviceName } from "../features/auth/auth"; +import { QrCanvas } from "../features/qr/QrCanvas"; +import { persistDesktopDeviceName } from "../net/desktop"; +import { pollQrStatus, qrStart, type QrStartResp, type QrStatus } from "../net/qr"; +import { readInjectedDeviceType } from "../store/helpers"; +import { useAppStore } from "../store"; +import { t } from "../i18n"; +import { isAsciiDeviceName } from "../utils/format"; +import { StateDot } from "../ui/glyphs"; +import { Button, Callout, TextField } from "../ui/primitives"; +import s from "./link_.new.module.css"; + +// 显码页:有人借了一台陌生设备,扫码把它接入自己的 cdrop。它本身公开(start / +// status 无需登录),故不设路由守卫——已登录用户也可经此把另一设备接入。 +export const Route = createFileRoute("/link_/new")({ + component: LinkNewPage, +}); + +type Step = "name" | "show"; + +function LinkNewPage() +{ + const navigate = useNavigate(); + const setAuth = useAppStore((s) => s.setAuth); + const setSelfDeviceName = useAppStore((s) => s.setSelfDeviceName); + + const [ step, setStep ] = useState("name"); + const [ name, setName ] = useState(suggestDeviceName); + const [ session, setSession ] = useState(null); + const [ phase, setPhase ] = useState("pending"); + const [ error, setError ] = useState(null); + const [ starting, setStarting ] = useState(false); + + // 轮询的取消句柄。换二维码 / 卸载时调用即停。 + const cancelPollRef = useRef<(() => void) | null>(null); + + const onApproved = useCallback((st: QrStatus) => + { + if (st.status !== "approved" || !st.accessToken || !st.user) { return; } + setAuth({ + accessToken: st.accessToken, + user: { id: st.user.id, name: st.user.name, avatar: st.user.avatar }, + }); + // 设备名以批准方下发的为权威(服务端登记的名字),回退到本地输入。 + const finalName = st.deviceName?.trim() || name.trim(); + setSelfDeviceName(finalName); + persistDesktopDeviceName(finalName); + syncWebDeviceName(finalName); + navigate({ to: "/" }); + }, [ name, navigate, setAuth, setSelfDeviceName ]); + + const begin = useCallback(async (deviceName: string) => + { + setError(null); + setStarting(true); + try + { + const resp = await qrStart({ + deviceName, + deviceType: readInjectedDeviceType(), + }); + setSession(resp); + setPhase("pending"); + setStep("show"); + } + catch (e) + { + setError(e instanceof Error ? e.message : String(e)); + } + finally { setStarting(false); } + }, []); + + // 拿到 session 即起长轮询;session 变化(重新生成)时换新轮询。 + useEffect(() => + { + if (!session) { return; } + cancelPollRef.current?.(); + const cancel = pollQrStatus(session.requestId, session.pollSecret, { + onResolved: (st) => + { + setPhase(st.status); + if (st.status === "approved") { onApproved(st); } + }, + onError: () => { /* 单轮抖动:轮询循环自重试,UI 保持等待态 */ }, + }); + cancelPollRef.current = cancel; + return () => { cancel(); cancelPollRef.current = null; }; + }, [ session, onApproved ]); + + const handleContinue = () => + { + const trimmed = name.trim(); + if (!trimmed) { return; } + if (!isAsciiDeviceName(trimmed)) + { + setError(t("settings.deviceName.asciiOnly")); + return; + } + void begin(trimmed); + }; + + const handleRegenerate = () => + { + setError(null); + void begin(name.trim()); + }; + + if (step === "name") + { + return ( + + {error && ( + }>{error} + )} + setName(e.currentTarget.value)} + onKeyDown={(e) => + { + if (e.key === "Enter" && name.trim()) + { + e.preventDefault(); + handleContinue(); + } + }} + autoFocus + /> + + + ); + } + + return ( + + {error && ( + }>{error} + )} + +
+
+ {session && } +
+ + +
+ +
    +
  1. {t("qr.show.howto1")}
  2. +
  3. {t("qr.show.howto2")}
  4. +
+ + {(phase === "expired" || phase === "denied") && ( + } + > + {phase === "denied" ? t("qr.show.denied") : t("qr.show.expired")} + + )} + + {(phase === "expired" || phase === "denied") && ( + + )} +
+ ); +} + +// 显码页的等待态:一行平静的状态,pending 时 accent 脉冲点 + 「等待批准」。 +function StatusLine(props: { phase: QrStatus["status"] }) +{ + const { phase } = props; + if (phase === "approved") + { + return ( +
+ + {t("qr.show.approved")} +
+ ); + } + if (phase === "expired" || phase === "denied") { return null; } + return ( +
+
+ ); +} + +// suggestDeviceName:UA 预填一个可读的默认设备名(与 /setup 同口径),可编辑。 +function suggestDeviceName(): string +{ + const ua = navigator.userAgent; + let browser = "Browser"; + if (ua.includes("Firefox/")) { browser = "Firefox"; } + else if (ua.includes("Edg/")) { browser = "Edge"; } + else if (ua.includes("Chrome/")) { browser = "Chrome"; } + else if (ua.includes("Safari/")) { browser = "Safari"; } + + let os = "Desktop"; + if (ua.includes("Mac")) { os = "macOS"; } + else if (ua.includes("Windows")) { os = "Windows"; } + else if (ua.includes("Linux")) { os = "Linux"; } + + return `${browser}-${os}-${Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, "0")}`; +} diff --git a/web/src/routes/login.tsx b/web/src/routes/login.tsx index 1f1b272..0c80350 100644 --- a/web/src/routes/login.tsx +++ b/web/src/routes/login.tsx @@ -1,5 +1,5 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { AlertTriangle, LogIn } from "lucide-react"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { AlertTriangle, LogIn, QrCode } from "lucide-react"; import { useEffect, useState } from "react"; import { fetchAuthConfig, loginDev, loginProd } from "../features/auth/auth"; import { isDesktop, startDesktopLogin } from "../net/desktop"; @@ -131,6 +131,25 @@ function LoginPage() > {t("login.prod.button")} + {/* 扫码登录入口:从已登录的另一台设备批准本机接入,无需在此重登。 + 克制——一行带图标的次级链接,不与主 CTA 争重。 */} + + + {t("qr.entry.fromLogin")} + )}
diff --git a/web/src/routes/oauth.callback.tsx b/web/src/routes/oauth.callback.tsx index 1bdf078..5303aeb 100644 --- a/web/src/routes/oauth.callback.tsx +++ b/web/src/routes/oauth.callback.tsx @@ -2,8 +2,13 @@ import { Loader } from "@mantine/core"; import { createFileRoute } from "@tanstack/react-router"; import { AlertTriangle } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { completeOAuthLogin } from "../features/auth/auth"; +import { + clearStepUpState, completeOAuthLogin, verifyStepUpState, +} from "../features/auth/auth"; import { AuthShell } from "../features/auth/AuthShell"; +import { + clearStepUpPending, peekStepUpPending, stashStepUpCode, +} from "../features/qr/stepUp"; import { t } from "../i18n"; import { Callout } from "../ui/primitives"; @@ -68,6 +73,28 @@ function CallbackPage() showErrDeferred(t("oauth.missingParams")); return; } + + // step-up 分叉:批准页发起的 prompt=login 再认证回到这里。此时 code 是要 + // 交给后端 approve 去 IdP 换的一次性凭证——前端绝不调 /api/auth/exchange + // 消费它(那会在服务端轮换会话)。只校 state(防 CSRF,同常规登录口径)、 + // 把 {code, verifier} 暂存给批准页、清掉「待办」,整页跳回批准页 URL。 + const pending = peekStepUpPending(); + if (pending) + { + if (!verifyStepUpState(search.state)) + { + clearStepUpPending(); + showErrDeferred("OAuth state mismatch (possible CSRF or stale tab)"); + return; + } + stashStepUpCode({ code: search.code, verifier: pending.verifier }); + clearStepUpPending(); + // 整页跳转回批准页(returnTo 已在 stash 时通过 open-redirect 校验)。 + window.location.replace(pending.returnTo); + return; + } + clearStepUpState(); // 非 step-up 路径兜底:清掉可能残留的 step-up state。 + completeOAuthLogin(search.code, search.state).then( // 整页跳转(window.location.replace),不是路由软导航。Safari 下,从 // Casdoor 回跳后那条客户端导航会复用 OAuth 流程遗留的 HTTP 连接,而这些