# Fischer AgentKit — 集中鉴权与 Token 持久化 (Requirements) **Date:** 2026-06-20 **Branch:** `feat/auth-server-token-persistence`(原 `feat/centralized-auth-token-persistence`) **Status:** Active — 已合并 AuthProvider 抽象层 scope(2026-06-20 更新) **Scope:** 服务端签发 JWT + 客户端安全持久化 + 服务端 Session 表 + Refresh Token 轮换 + 「记住我」 + 启动态区分 + **AuthProvider 抽象层(为未来对接集团 IdP 留扩展点)** **Out of scope:** 实现具体企业 IdP 适配(OIDC / SAML / LDAP / 飞书 / 钉钉 / 企微)、多租户、密码强度策略、2FA、SSO 跳转 --- ## 1. Context & Problem ### 1.1 现状 **已经实现(但不够健壮)**: - [auth/models.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/auth/models.py) SQLite 存用户 + bcrypt 哈希(cost=12)+ session hash - [auth/password.py](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/auth/password.py) bcrypt 哈希 / 验证 - [routes/auth.py](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/routes/auth.py) `/api/v1/auth/login` `/refresh` `/logout` 接口 - [stores/auth.ts](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/frontend/src/stores/auth.ts) Pinia 鉴权 store,access (15min) + refresh (7d) + user 存 localStorage - [router/index.ts](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/frontend/src/router/index.ts) 路由守卫读 store,已有 token 时自动放行 - [views/LoginView.vue](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/frontend/src/views/LoginView.vue) `onMounted` 检查 `isAuthenticated` 自动跳走 - [api/base.ts](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/frontend/src/api/base.ts) 401 → `refreshIfPossible` → 失败清空 **暴露的问题**: 1. **Token 持久化安全弱** — access + refresh + user **明文**存 WebView localStorage(macOS: `~/Library/WebKit/.../LocalStorage/`),被其他进程或恶意浏览器扩展可读 2. **无 refresh token 轮换** — 同一 refresh token 可在 7 天内无限复用,被泄漏后无重置窗口 3. **无服务端 Session 表** — 改密码 / 踢人 / 看活跃设备 全部做不到;`user_sessions` 表([auth/models.py:55-66](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/auth/models.py#L55-L66))只存 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` 三态 - 鉴权后端 **可插拔**(AuthProvider 抽象):当前 Local,未来 OIDC / SAML / LDAP 只需新增 adapter - admin 端点 **与认证后端解耦**(统一通过 user_id 操作),未来切换 IdP 不影响 session 管理 - 审计日志记录登录来源(`auth_provider` 字段),集团接管时可溯源 ### 1.3 设计哲学 > **服务端权威 + 客户端最小信任 + 加密落盘 + 可观测可治理 + 认证后端可插拔** - **服务端权威**:所有 token 校验、续期、撤销由服务端说了算,客户端不可绕过 - **客户端最小信任**:客户端不存密码、access token 不持久化(仅内存)、refresh token 进 Keychain - **加密落盘**:refresh token 走 OS 级加密(Tauri: macOS Keychain / Windows Credential Manager / Linux Secret Service) - **可观测可治理**:admin 能看 / 踢任意 session,集团统一管理的基础设施准备好 - **认证后端可插拔**:所有用户认证逻辑走 `AuthProvider` Protocol(authenticate / get_user / sync_attributes / revoke_user),当前 `LocalAuthProvider` 封装 SQLite + bcrypt;未来 `OidcAuthProvider` 接管时,路由层、admin API、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 仍可存 localStorage(fallback) | 浏览器用户也能用,只是降级安全 | | 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 | | F13 | 鉴权后端可插拔(AuthProvider 抽象) | 配置切换 `local` ↔ `oidc-stub`,路由/Admin/Session 表零修改 | | F14 | admin 端点与认证后端解耦 | 未来切 IdP,admin 看 session 列表 / 踢人功能不变 | | F15 | 审计日志记录 `auth_provider` 字段 | 登录来源可溯源(local / oidc / saml) | ### 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 | 兼容旧版本客户端的 JWT(7d 滚动)至少 1 个 minor 版本 | 灰度期间双轨 | --- ## 3. Non-Goals - ❌ **实现具体企业 IdP 适配**(OIDC / SAML / LDAP / 飞书 / 钉钉 / 企微)— 下一迭代单独 brainstorm;本次只预留 AuthProvider 抽象层 - ❌ **多租户 / 集团多组织隔离** — 当前单租户架构不动 - ❌ **密码强度策略 / 密码过期 / 密码历史** — 单独的 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 轮换的上一跳(用于审计) | | `auth_provider` | TEXT | — | 登录来源:`local` / `oidc-stub` / `saml`(未来扩展) | #### 5.1.2 JWT payload 扩展 ```json { "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 轮换检测逻辑 ```python 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」set(30s) 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) ```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, 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 封装 ```typescript // src/api/tauri-auth.ts const isTauri = (): boolean => '__TAURI__' in window export const tauriAuthStorage = { async setRefreshToken(token: string): Promise { 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 { if (isTauri()) { try { return await invoke('load_refresh_token') } catch (e) { console.warn('Keychain 读取失败,fallback', e) } } return localStorage.getItem('agentkit.refresh_token') }, async clearRefreshToken(): Promise { 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 预刷新逻辑 ```typescript // 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 migration:`add_auth_sessions_table` - 旧 `user_sessions` 表(refresh 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 天灰度) ### 5.5 鉴权后端可插拔 — AuthProvider 抽象层 > **设计动机**:当前用本地 users 表 + bcrypt 校验密码。未来集团对接 OIDC / SAML / LDAP 时,路由层、admin API、Session 表都不应重写。通过 `AuthProvider` Protocol 把"用户存在哪里 / 密码怎么校验 / 属性怎么同步"封装在 adapter 内部。 #### 5.5.1 AuthProvider Protocol ```python # auth/providers/base.py from typing import Protocol from ..models import User class AuthProvider(Protocol): """所有鉴权后端必须实现的能力。 路由层只调用以下方法,不感知具体实现是 SQLite / OIDC / LDAP。 """ name: str # 标识当前 provider,写入 session.auth_provider async def authenticate(self, *, username: str, password: str) -> User: """校验用户名 + 密码,返回 User 对象。失败抛 InvalidCredentials。""" ... async def get_user_by_id(self, user_id: int) -> User | None: """按 id 查 user(admin 端点、session 校验、whoami 都用这个)。""" ... async def sync_user_attributes(self, user_id: int) -> None: """同步用户属性(部门/邮箱/职位等)。 LocalAuthProvider: no-op OidcAuthProvider: 从 IdP 拉最新 profile 写回本地 users 表 """ ... async def revoke_user(self, user_id: int) -> None: """禁用用户(离职 / 锁定场景)。 LocalAuthProvider: UPDATE users SET is_active=0 OidcAuthProvider: 调 IdP 的 disable API(未来) """ ... ``` #### 5.5.2 默认实现 LocalAuthProvider ```python # auth/providers/local.py class LocalAuthProvider: name = "local" def __init__(self, db: aiosqlite.Connection): self._db = db async def authenticate(self, *, username: str, password: str) -> User: # 封装现有 routes/auth.py:201-213 的 password 校验逻辑 row = await self._db.execute( "SELECT id, username, password_hash, is_active FROM users WHERE username = ?", (username,), ) row = await row.fetchone() if not row or not row["is_active"]: raise InvalidCredentials("user not found or inactive") if not verify_password(password, row["password_hash"]): raise InvalidCredentials("invalid password") return await load_user(row["id"]) async def get_user_by_id(self, user_id: int) -> User | None: return await load_user(user_id) async def sync_user_attributes(self, user_id: int) -> None: return # local provider: no-op async def revoke_user(self, user_id: int) -> None: await self._db.execute( "UPDATE users SET is_active = 0 WHERE id = ?", (user_id,) ) await self._db.commit() ``` #### 5.5.3 占位实现 StubOIDCProvider ```python # auth/providers/oidc_stub.py class StubOIDCProvider: """OIDC 对接的接口占位。 当前阶段只定义接口契约,不做实际 IdP 通讯。下一迭代实现时, 重写 authenticate / sync_user_attributes / revoke_user 即可, 路由层、admin API、Session 表零修改。 """ name = "oidc-stub" async def authenticate(self, *, username: str, password: str) -> User: raise NotImplementedError( "OIDC provider not implemented. " "Use 'local' provider in agentkit.yaml: auth.provider: local" ) async def get_user_by_id(self, user_id: int) -> User | None: raise NotImplementedError async def sync_user_attributes(self, user_id: int) -> None: raise NotImplementedError async def revoke_user(self, user_id: int) -> None: raise NotImplementedError ``` #### 5.5.4 Provider 切换配置 ```yaml # agentkit.yaml auth: provider: local # local | oidc-stub (未来: oidc-keycloak, oidc-feishu, ...) session: table: auth_sessions access_ttl_seconds: 900 refresh_ttl_seconds: 604800 refresh_ttl_remember_me_seconds: 2592000 jwt: secret_env: AGENTKIT_JWT_SECRET algorithm: HS256 ``` #### 5.5.5 路由层 DI 注入 ```python # auth/providers/__init__.py from functools import lru_cache from ..config import get_settings from .base import AuthProvider from .local import LocalAuthProvider from .oidc_stub import StubOIDCProvider @lru_cache def get_auth_provider() -> AuthProvider: settings = get_settings() db = await get_auth_db() # 现有 aiosqlite 连接 if settings.auth.provider == "local": return LocalAuthProvider(db) elif settings.auth.provider == "oidc-stub": return StubOIDCProvider() else: raise ValueError(f"unknown auth provider: {settings.auth.provider}") ``` ```python # routes/auth.py 改造点 from fastapi import Depends from ..auth.providers import get_auth_provider, AuthProvider @router.post("/login") async def login( body: LoginRequest, provider: AuthProvider = Depends(get_auth_provider), ) -> LoginResponse: user = await provider.authenticate(username=body.username, password=body.password) # ... 后续 session 创建逻辑不变 ``` #### 5.5.6 admin 端点与 Provider 解耦 所有 admin API(`/admin/users/{id}/sessions` 等)都通过 `user_id` 操作,**不直接调用 provider.authenticate**。这意味着: - 未来切到 OIDC,admin 踢人 / 看 session 列表功能不变 - LocalAuthProvider.revoke_user 和 OidcAuthProvider.revoke_user 实现不同,但 admin 端点统一调 `provider.revoke_user(user_id)` - 审计日志记录 `auth_provider`,未来切 IdP 后可溯源"哪个 session 是本地建的、哪个是 IdP 建的" #### 5.5.7 未来 IdP 对接清单(下一迭代参考) 下一迭代实现 OIDC 时,按此 checklist 推进: - [ ] `auth/providers/oidc.py` — 实现 OidcAuthProvider(authenticate / get_user / sync_attributes / revoke_user) - [ ] `auth/oauth_routes.py` — `/auth/oauth/{provider}/redirect` 和 `/auth/oauth/{provider}/callback` 端点 - [ ] `auth/state_cache.py` — OAuth state 参数防 CSRF(Redis TTL 5min) - [ ] 用户首次从 IdP 登录时的「本地账号创建」策略(justeer / 拒绝 / 邀请制) - [ ] IdP 端的 session 同步(IdP 登出时本地 session 也撤销) - [ ] 集团部门 / 职位属性映射到本地 users 表 本次迭代只做 1-3 项的占位(接口 + stub 实现),其余列入下一迭代的独立 brainstorm。 --- ## 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 15min,refresh 7d / 30d(remember_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 | < 5ms(Redis cache session 元数据) | | Session 列表查询 P99 | < 50ms(user_id 索引) | | Redis cache TTL | 60s(session 元数据) | | Keychain 读写 | < 10ms(macOS Keychain 实际 1-3ms) | ### 6.3 兼容 | 项 | 要求 | |---|------| | 旧客户端 | 至少 1 个 minor 版本仍可登录(带 sid 字段的 JWT 也认) | | Web 端 | localStorage fallback,Keychain 不可用时降级 | | 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/auth/providers/base.py` — `AuthProvider` Protocol 接口契约 - `src/agentkit/server/auth/providers/local.py` — `LocalAuthProvider` 默认实现(封装 SQLite + bcrypt) - `src/agentkit/server/auth/providers/oidc_stub.py` — `StubOIDCProvider` 占位实现(NotImplementedError + 文档) - `src/agentkit/server/auth/providers/__init__.py` — `get_auth_provider()` DI 工厂 - `src/agentkit/server/routes/auth.py` — 修改 login/refresh/logout,新增 whoami/sessions/change-password;通过 `Depends(get_auth_provider)` 注入 provider - `src/agentkit/server/routes/admin.py`(或 auth 内部)— admin session 管理(按 user_id 操作,与 provider 解耦) - `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.py` — `get_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 ] ↓ [API 请求] ├── 200 → 返回 └── 401 → refreshIfPossible(最后一次兜底) ├── 成功 → 重试原请求 └── 失败 → 清状态,路由跳 /login ``` #### 启动流程(Web 浏览器) ``` [同 Tauri,但 refresh_token 走 localStorage] [其余逻辑完全一致] ``` --- ## 8. Open Questions / Assumptions ### 8.1 假设 - **A1**: 本次迭代只预留 AuthProvider 抽象层(接口 + Local + OIDC stub),不实现具体 IdP 适配;集团对接需求在下一迭代独立 brainstorm - **A2**: 单租户架构不变(`auth.db` 全局共享) - **A3**: Tauri 仅支持桌面平台(macOS / Windows / Linux),不规划移动端 - **A4**: Keychain 失败时降级到 localStorage 可接受(dev 环境 / Linux 无 keyring daemon) - **A5**: Web 端仅作为开发/降级用途,不追求 localStorage 加密 - **A6**: 旧客户端灰度期 ≤ 1 个 minor 版本(约 30 天) - **A7**: AuthProvider 的 Local 实现保留 bcrypt 成本 = 12(已实现),未来 IdP 接管时 Local 仍可用作「本地应急账号」 - **A8**: admin API 鉴权走现有 RBAC(不依赖 provider),未来切 IdP 时 admin 角色定义保持不变 ### 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 / SSO 适配**(OIDC / SAML / LDAP / 飞书 / 钉钉 / 企微)— 下一迭代单独 brainstorm;本次只预留 AuthProvider 抽象层 - ❌ OAuth / SAML 跳转流程、state cache、用户属性同步等 IdP 集成细节 — 见 5.5.7 checklist - ❌ 2FA / TOTP / WebAuthn / Passkey — 后续 - ❌ 多租户 — 后续 - ❌ 密码强度策略 / 密码过期 — 后续 - ❌ 登录失败锁定 / 滑窗限流 — 后续安全加固 - ❌ 邮件 / 短信通知 — 需要先有通知服务 - ❌ 完整审计日志 / 全文检索 / 导出 — 后续 --- ## 11. References - 现状代码: - [auth/models.py](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/auth/models.py) - [auth/password.py](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/auth/password.py) - [routes/auth.py](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/routes/auth.py) - [stores/auth.ts](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/frontend/src/stores/auth.ts) - [router/index.ts](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/frontend/src/router/index.ts) - [views/LoginView.vue](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/frontend/src/views/LoginView.vue) - [api/base.ts](file:///Users/chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/frontend/src/api/base.ts) - 外部参考: - `keyring` crate:https://docs.rs/keyring/latest/keyring/ - OWASP JWT 安全备忘单:https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html - Auth0 Refresh Token Rotation:https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation - OIDC Core 1.0(未来对接参考):https://openid.net/specs/openid-connect-core-1_0.html - OAuth 2.0 Authorization Framework(未来对接参考):https://www.rfc-editor.org/rfc/rfc6749