fischer-agentkit/docs/brainstorms/2026-06-20-centralized-auth...

23 KiB
Raw Blame History

Fischer AgentKit — 集中鉴权与 Token 持久化 (Requirements)

Date: 2026-06-20 Branch: feat/centralized-auth-token-persistence Status: Draft Scope: 服务端签发 JWT + 客户端安全持久化 + 服务端 Session 表 + Refresh Token 轮换 + 「记住我」 + 启动态区分 Out of scope: 企业 IdP 对接OIDC / SAML / LDAP、多租户、密码强度策略、2FA、SSO 跳转


1. Context & Problem

1.1 现状

已经实现(但不够健壮)

暴露的问题

  1. Token 持久化安全弱 — access + refresh + user 明文存 WebView localStoragemacOS: ~/Library/WebKit/.../LocalStorage/),被其他进程或恶意浏览器扩展可读
  2. 无 refresh token 轮换 — 同一 refresh token 可在 7 天内无限复用,被泄漏后无重置窗口
  3. 无服务端 Session 表 — 改密码 / 踢人 / 看活跃设备 全部做不到;user_sessions 表(auth/models.py:55-66)只存 refresh token 哈希,看不到设备/时间/IP
  4. 无「记住我」开关 — refresh 固定 7 天,没法选「这台设备记 30 天」
  5. 无预刷新 — access 还有效时不主动续,依赖 401 触发;批量请求高峰可能集中刷新
  6. 启动态不区分 — 有 token 但服务端 401 时直接清掉,看不出「我刚登录过 / 现在连不上」的区别
  7. 集团统一管理缺位 — admin 没法在「企业员工离职 / 设备丢失 / 异常登录」场景下强制踢出

1.2 目标

  • 首次登录后,冷启动应用直接进主界面,不显示登录页(除非主动登出 / token 失效)
  • 鉴权逻辑 100% 在服务端完成(已经是这样,但需要加强)
  • Token 走 OS Keychain 加密存储Tauri 桌面Web 端 fallback 到 localStorage
  • 服务端 Session 表支持:滑动过期 / 主动踢出 / 改密码失效 / 活跃设备列表
  • Refresh token 每次刷新轮换,旧 token 立即失效
  • 支持 「记住我」refresh 7d / 30d
  • 客户端 access token 预刷新(剩余 <2min 主动 refresh
  • 启动时区分 no_token / token_invalid / token_valid 三态

1.3 设计哲学

服务端权威 + 客户端最小信任 + 加密落盘 + 可观测可治理

  • 服务端权威:所有 token 校验、续期、撤销由服务端说了算,客户端不可绕过
  • 客户端最小信任客户端不存密码、access token 不持久化仅内存、refresh token 进 Keychain
  • 加密落盘refresh token 走 OS 级加密Tauri: macOS Keychain / Windows Credential Manager / Linux Secret Service
  • 可观测可治理admin 能看 / 踢任意 session集团统一管理的基础设施准备好

2. Goals

2.1 Functional Goals

ID 目标 用户感知
F1 首次登录后冷启动直接进应用 第二次开 App 看不到登录页
F2 「记住我」延长 refresh 到 30 天 登录页 checkbox不勾选时 refresh 7 天
F3 Tauri 端 refresh token 存 OS Keychain 进程外 / 其他用户 / 恶意扩展都拿不到
F4 Web 端 refresh token 仍可存 localStoragefallback 浏览器用户也能用,只是降级安全
F5 Refresh token 每次使用后轮换 旧 token 调 /refresh 立即被拒
F6 服务端 Session 表记录每次登录的设备/IP/时间 登录日志可审计
F7 admin 可看任意用户的活跃 session 列表 「谁在哪里登录」可见
F8 admin / 用户本人可踢出指定 session 设备丢失立即失效
F9 用户改密码立即使所有 session 失效 全设备强踢
F10 客户端 access token 剩余 <2min 主动 refresh 不依赖 401 触发
F11 启动区分 no_token / token_invalid / token_valid 错误态有「重试」按钮而不是直接清空
F12 多个 Tauri / Web 客户端可同时登录同一账号 互不干扰,独立 session

2.2 Non-Functional Goals

ID 目标 度量
N1 Token 校验 P99 < 5ms 加上 session 表后用 Redis cache
N2 Keychain 读写失败 fallback 到 localStorage 不阻塞登录
N3 Session 表 + 索引不阻塞 1k RPS 登录 索引 (user_id, revoked, expires_at)
N4 旧 refresh token 泄漏检测 每次 refresh 检测「被轮换过的 token 再用」→ 全 session 失效
N5 所有鉴权代码通过单测 + 集成测 pytest tests/unit/auth/ + tests/integration/auth/
N6 兼容旧版本客户端的 JWT7d 滚动)至少 1 个 minor 版本 灰度期间双轨

3. Non-Goals

  • 企业 IdP 对接OIDC / SAML / LDAP / 飞书 / 钉钉 / 企微)— 下一迭代单独 brainstorm
  • 多租户 / 集团多组织隔离 — 当前单租户架构不动
  • 密码强度策略 / 密码过期 / 密码历史 — 单独的 IAM 改造
  • 2FA / TOTP / WebAuthn / Passkey — 单独 brainstorm
  • 前端账号注册流程 — 当前只支持 admin 创建用户
  • 登录失败锁定 / 滑窗限流 — 单独的安全加固
  • 审计日志的全文检索 / 导出 — 只记录关键事件到 session 表,复杂审计后续做

4. User Scenarios

4.1 普通员工「每天开应用」

  1. 早上打开 Tauri 客户端
  2. 启动页 → 后端健康检查 → /api/v1/auth/whoami 携带 Keychain 里的 refresh token 调一次
  3. 服务端校验通过 → 返回新 access token + user → 进主界面
  4. 看不到登录页

4.2 设备丢失「管理员踢人」

  1. 员工报设备丢失
  2. 管理员在「用户管理 → 会话列表」看到该员工所有活跃 session
  3. 点击「踢出」 → 服务端 UPDATE auth_sessions SET revoked=1 WHERE id=?
  4. 该设备下一次请求 401 → 客户端清空 → 跳登录页

4.3 改密码「全设备强踢」

  1. 用户在「设置 → 修改密码」提交新密码
  2. 服务端更新密码哈希 + UPDATE auth_sessions SET revoked=1 WHERE user_id=? AND id != ?(保留当前 session
  3. 其他设备下次请求 401 → 自动跳登录

4.4 「记住我」开关

  1. 登录页有「记住我30 天」checkbox
  2. 不勾选 → refresh 7 天;勾选 → refresh 30 天
  3. 用户可在「设置 → 安全」随时查看/撤销当前 session

4.5 Refresh Token 泄漏检测

  1. 攻击者拿到旧的 refresh token
  2. /api/v1/auth/refresh → 服务端检测「此 token 已被轮换过」
  3. 整个 user 的所有 session 立即撤销(防扩散)
  4. 推送通知到用户「检测到异常登录」

5. Functional Requirements

5.1 服务端 — Session 表 + API

5.1.1 新增表 auth_sessions

字段 类型 索引 说明
id TEXT PK (uuid) session 标识JWT sub claim 引用
user_id INTEGER FK→users (user_id, revoked, expires_at) 拥有者
device_fingerprint TEXT User-Agent + platform hash
device_label TEXT 「macOS Chrome 119」/「Windows Tauri 1.0」
ip TEXT 登录 IP
user_agent TEXT 原始 UA
created_at INTEGER 登录时间
last_active_at INTEGER 最近一次 refresh 验证时间
expires_at INTEGER (expires_at) refresh 过期时间(绝对)
revoked INTEGER (bool) 是否被踢
revoked_reason TEXT NULL user_terminated / password_changed / admin_revoked / reuse_detected
previous_session_id TEXT NULL refresh 轮换的上一跳(用于审计)

5.1.2 JWT payload 扩展

{
  "sub": "user_id",
  "sid": "session_id",     // 新增:关联 auth_sessions.id
  "type": "access",
  "exp": ...,
  "iat": ...,
  "jti": "..."              // 新增:单次 token id用于黑名单
}

Refresh token 同样携带 sid,校验时必须查 auth_sessions 表确认未 revoked。

5.1.3 新增 / 修改 API

Method Path 鉴权 行为
POST /api/v1/auth/login 校验密码 → 创建 session → 返回 access+refresh+JWT(sid)
POST /api/v1/auth/refresh refresh 校验 session 未 revoked → 轮换 refresh → 返回新 pair旧 refresh 入黑名单 30s
POST /api/v1/auth/logout access revoke 当前 session
GET /api/v1/auth/whoami access 返回 user + 当前 session 元数据(设备/IP/时间)
GET /api/v1/auth/sessions access 当前用户的活跃 session 列表
DELETE /api/v1/auth/sessions/{id} access 踢出指定 session自己踢自己admin 踢任意)
POST /api/v1/auth/logout-others access 踢出除当前外的所有 session
GET /api/v1/admin/users/{id}/sessions admin admin 看任意用户的 session
DELETE /api/v1/admin/users/{id}/sessions/{sid} admin admin 踢任意 session
POST /api/v1/auth/change-password access 改密码 + revoke 其他 session

5.1.4 Refresh 轮换检测逻辑

async def rotate_refresh(old_refresh: str) -> TokenPair:
    payload = jwt.decode(old_refresh)
    session = await db.get_session(payload["sid"])
    if not session or session.revoked:
        raise TokenReuseDetected()  # 触发全用户 session 撤销
    if session.refresh_token_hash != sha256(old_refresh):
        raise TokenReuseDetected()
    # 生成新的 refresh + access旧 refresh 入「recently_revoked」set30s
    new_refresh = sign_jwt({...payload, "jti": uuid4()})
    session.refresh_token_hash = sha256(new_refresh)
    session.last_active_at = now()
    session.expires_at = now() + remember_me_ttl
    await db.commit()
    return TokenPair(access=..., refresh=new_refresh)

5.2 客户端 — Tauri Keychain 集成

5.2.1 新增 Tauri 命令Rust

// src-tauri/src/auth.rs
#[tauri::command]
async fn store_refresh_token(token: String) -> Result<(), String> {
    let entry = keyring::Entry::new("agentkit", "refresh_token")?;
    entry.set_password(&token)?;
    Ok(())
}

#[tauri::command]
async fn load_refresh_token() -> Result<Option<String>, String> {
    let entry = keyring::Entry::new("agentkit", "refresh_token")?;
    match entry.get_password() {
        Ok(t) => Ok(Some(t)),
        Err(keyring::Error::NoEntry) => Ok(None),
        Err(e) => Err(e.to_string()),
    }
}

#[tauri::command]
async fn clear_refresh_token() -> Result<(), String> {
    let entry = keyring::Entry::new("agentkit", "refresh_token")?;
    let _ = entry.delete_credential();
    Ok(())
}

依赖keyring = "3"macOS Keychain / Windows Credential Manager / Linux Secret Service 统一 API

5.2.2 前端 Tauri API 封装

// src/api/tauri-auth.ts
const isTauri = (): boolean => '__TAURI__' in window

export const tauriAuthStorage = {
  async setRefreshToken(token: string): Promise<void> {
    if (isTauri()) {
      try {
        await invoke('store_refresh_token', { token })
        return
      } catch (e) {
        console.warn('Keychain 写入失败fallback 到 localStorage', e)
      }
    }
    localStorage.setItem('agentkit.refresh_token', token)
  },
  async getRefreshToken(): Promise<string | null> {
    if (isTauri()) {
      try {
        return await invoke<string | null>('load_refresh_token')
      } catch (e) {
        console.warn('Keychain 读取失败fallback', e)
      }
    }
    return localStorage.getItem('agentkit.refresh_token')
  },
  async clearRefreshToken(): Promise<void> {
    if (isTauri()) {
      try { await invoke('clear_refresh_token') } catch {}
    }
    localStorage.removeItem('agentkit.refresh_token')
  },
}

5.2.3 auth store 改造

  • 初始化时异步从 Keychain 读 refresh token不再从 localStorage 同步读
  • 启动时调 whoami,分三态:
    • valid → 直接进应用
    • invalid → 显示「会话已过期,请重新登录」+「重试」按钮
    • error(网络) → 显示「无法连接服务器」+「重试」按钮
  • access token 只存内存(不写 localStorage / Keychain
  • user 缓存到 localStorage明文可接受仅用于启动时先显示头像 / 角色)

5.2.4 预刷新逻辑

// src/api/base.ts 拦截器
apiClient.interceptors.request.use(async (config) => {
  const auth = useAuthStore()
  if (auth.accessToken && auth.shouldRefresh()) {  // < 2min
    await auth.silentRefresh()
  }
  if (auth.accessToken) {
    config.headers.Authorization = `Bearer ${auth.accessToken}`
  }
  return config
})

5.2.5 「记住我」 UI

  • LoginView 加 checkbox「记住我30 天)」
  • 登录请求带 ?remember_me=true query或 body 字段)
  • 服务端根据这个决定 refresh TTL

5.3 数据库迁移

  • 新增 Alembic migrationadd_auth_sessions_table
  • user_sessionsrefresh token hash废弃保留 1 个 minor 版本做兼容
  • Migration 自动回填:现存有效 refresh token → 创建对应 session 行

5.4 灰度兼容

  • 新 JWT 携带 sid claim
  • 旧 JWT 没有 sid → 服务端 fallback 到 user_sessions 表校验
  • 客户端版本检查:Authorization header 带 X-Client-Version header
  • 服务端支持 1 个 minor 版本的旧客户端(~30 天灰度)

6. Non-Functional Requirements

6.1 安全

要求
Token 存储 Tauri: OS Keychain明文只存内存中临时 access
Token 传输 强制 HTTPS生产dev 允许 HTTP localhost
Token 签名 HS256密钥 256 bit.env AGENTKIT_JWT_SECRET
Token 算法 access 15minrefresh 7d / 30dremember_me
Refresh 轮换 每次 refresh 必轮换,旧 token 入短窗口黑名单30s
密码哈希 bcrypt cost=12已实现
常量时间比较 hmac.compare_digest(已实现)
审计日志 session 表 + last_active_at + revoked_reason
异常检测 refresh token reuse → 全用户 session 撤销 + 写审计

6.2 性能

指标
Token 校验 P99 < 5msRedis cache session 元数据)
Session 列表查询 P99 < 50msuser_id 索引)
Redis cache TTL 60ssession 元数据)
Keychain 读写 < 10msmacOS Keychain 实际 1-3ms

6.3 兼容

要求
旧客户端 至少 1 个 minor 版本仍可登录(带 sid 字段的 JWT 也认)
Web 端 localStorage fallbackKeychain 不可用时降级
Tauri 平台 macOS / Windows / Linux 三端 keychain 都支持keyring crate

6.4 可观测

做法
登录成功 logger.info + 计数 auth.login.success
登录失败 logger.warn + 计数 auth.login.failed
Refresh 成功 logger.debug + 计数 auth.refresh.success
Refresh reuse 检测 logger.error + 计数 auth.refresh.reuse_detected + 推 audit event
Session 撤销 logger.info + 写入 revoked_reason

7. Architecture Changes

7.1 改动文件清单(实现时确认)

Backend新增

  • src/agentkit/server/auth/session.py — session CRUD + 轮换逻辑
  • src/agentkit/server/auth/jwt_utils.py — 扩展 JWT payload加 sid / jti
  • src/agentkit/server/auth/keychain_audit.py — refresh reuse 检测
  • src/agentkit/server/routes/auth.py — 修改 login/refresh/logout新增 whoami/sessions/change-password
  • src/agentkit/server/routes/admin.py(或 auth 内部)— admin session 管理
  • migrations/versions/xxx_add_auth_sessions.py — Alembic migration
  • src/agentkit/server/auth/cache.py — Redis session 元数据 cache

Backend修改

  • src/agentkit/server/auth/models.py — 新增 AuthSessionModel
  • src/agentkit/server/auth/password.py — 改密码时联动撤销 session
  • src/agentkit/server/dependencies.pyget_current_user 校验 sid
  • src/agentkit/server/app.py — 注册新路由

Frontend新增

  • src/agentkit/server/frontend/src/api/tauri-auth.ts — Tauri Keychain 桥接
  • src/agentkit/server/frontend/src/api/sessions.ts — sessions API 客户端
  • src/agentkit/server/frontend/src/views/SettingsView.vue(或扩展) — 「我的设备 / 修改密码」

Frontend修改

  • src/agentkit/server/frontend/src/stores/auth.ts — 三态启动、Keychain 集成、预刷新、remember_me
  • src/agentkit/server/frontend/src/api/base.ts — 预刷新拦截器、refresh reuse 失败处理
  • src/agentkit/server/frontend/src/views/LoginView.vue — 「记住我」checkbox
  • src/agentkit/server/frontend/src/App.vue — 启动三态路由

Tauri新增

  • src/agentkit/server/frontend/src-tauri/Cargo.toml — 加 keyring = "3"
  • src/agentkit/server/frontend/src-tauri/src/auth.rs — Keychain commands
  • src/agentkit/server/frontend/src-tauri/src/lib.rs — 注册 commands
  • src/agentkit/server/frontend/src-tauri/capabilities/default.json — 加 permissions

Tauri修改

  • 无(仅新增)

7.2 数据流

启动流程Tauri 桌面)

[Window opens]
    ↓
[WebView 加载 → App.vue 启动]
    ↓
[bootstrapBackend]  → start_backend → check_health
    ↓
[auth.startupCheck()]
    ├── 1. 从 Keychain 读 refresh_token
    ├── 2. 调 GET /api/v1/auth/whoami 携带 refresh_token
    │      ├─ 200 → valid 状态user 写入内存 + localStorage 缓存
    │      ├─ 401 → invalid 状态,显示「会话过期」+ 重试按钮
    │      └─ 网络错 → error 状态,显示「无法连接」+ 重试按钮
    └── 3. 跳到 /agent 或 /login

正常 API 请求Tauri 桌面)

[Pinia store 请求拦截器]
    ↓
[检查 accessToken 剩余有效期]
    ├── < 2min → silentRefresh()
    │             ├── 调 POST /api/v1/auth/refresh
    │             ├── 成功 → 新 access 进内存,新 refresh 进 Keychain
    │             └── 失败 → 清内存,路由跳 /login
    └── > 2min → 直接放行
    ↓
[加 Authorization: Bearer <access>]
    ↓
[API 请求]
    ├── 200 → 返回
    └── 401 → refreshIfPossible最后一次兜底
              ├── 成功 → 重试原请求
              └── 失败 → 清状态,路由跳 /login

启动流程Web 浏览器)

[同 Tauri但 refresh_token 走 localStorage]
[其余逻辑完全一致]

8. Open Questions / Assumptions

8.1 假设

  • A1: 用户当前没有 IdP 集成需求,集团统一管理仅靠本系统自带的 session 管理 + admin 端点满足
  • A2: 单租户架构不变(auth.db 全局共享)
  • A3: Tauri 仅支持桌面平台macOS / Windows / Linux不规划移动端
  • A4: Keychain 失败时降级到 localStorage 可接受dev 环境 / Linux 无 keyring daemon
  • A5: Web 端仅作为开发/降级用途,不追求 localStorage 加密
  • A6: 旧客户端灰度期 ≤ 1 个 minor 版本(约 30 天)

8.2 待澄清(实施前确认)

  • Q1: 「记住我」延长到 30 天还是更长30 天 vs 90 天
  • Q2: admin 端点路径用 /api/v1/admin/users/{id}/sessions 还是嵌在 /api/v1/auth/admin/...
  • Q3: session 列表 UI 放在「设置 → 安全」还是单独页面?
  • Q4: 是否需要 session 数量上限(同一用户最多 N 个活跃 session默认 10
  • Q5: 改密码时是「全踢」还是「踢其他保留当前」?当前设计保留当前
  • Q6: refresh reuse 检测触发全踢后,是否给用户发邮件 / 站内通知?当前无邮件能力,先只写审计日志

9. Success Criteria

  • 首次登录后Tauri 客户端冷启动直接进主界面,不再显示登录页(除非主动登出或 token 失效)
  • Tauri 端 refresh token 不再出现在 ~/Library/WebKit/.../LocalStorage/
  • admin 端能在「用户管理」看任意用户的活跃 session 列表
  • 改密码后其他设备立即失效(≤ 1s 内下次请求 401
  • refresh token 被轮换后再用 → 全用户 session 撤销 + 审计记录
  • 「记住我」勾选后 refresh 30 天,不勾选 7 天
  • access token 剩余 < 2min 时主动 refresh批量请求不出现 401 风暴
  • 旧客户端1 个 minor 版本内)仍可登录
  • 所有新增代码测试覆盖率 ≥ 80%
  • pytest 全部通过(包含新增 auth 集成测试)

10. Out of Scope (Explicit)

  • 企业 IdP / SSOOIDC / SAML / LDAP / 飞书 / 钉钉 / 企微)— 下一迭代
  • 2FA / TOTP / WebAuthn / Passkey — 后续
  • 多租户 — 后续
  • 密码强度策略 / 密码过期 — 后续
  • 登录失败锁定 / 滑窗限流 — 后续安全加固
  • 邮件 / 短信通知 — 需要先有通知服务
  • 完整审计日志 / 全文检索 / 导出 — 后续

11. References