723 lines
32 KiB
Markdown
723 lines
32 KiB
Markdown
# 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<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 封装
|
||
|
||
```typescript
|
||
// 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 预刷新逻辑
|
||
|
||
```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 <access>]
|
||
↓
|
||
[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
|