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

723 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Fischer AgentKit — 集中鉴权与 Token 持久化 (Requirements)
**Date:** 2026-06-20
**Branch:** `feat/auth-server-token-persistence`(原 `feat/centralized-auth-token-persistence`
**Status:** Active — 已合并 AuthProvider 抽象层 scope2026-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 鉴权 storeaccess (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 localStoragemacOS: `~/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` Protocolauthenticate / get_user / sync_attributes / revoke_user当前 `LocalAuthProvider` 封装 SQLite + bcrypt未来 `OidcAuthProvider` 接管时路由层admin APISession 表都不需要重写
---
## 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 |
| F13 | 鉴权后端可插拔AuthProvider 抽象 | 配置切换 `local` `oidc-stub`路由/Admin/Session 表零修改 |
| F14 | admin 端点与认证后端解耦 | 未来切 IdPadmin 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 | 兼容旧版本客户端的 JWT7d 滚动至少 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 FKusers | `(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」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
```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 查 useradmin 端点、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**。这意味着
- 未来切到 OIDCadmin 踢人 / 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` 实现 OidcAuthProviderauthenticate / get_user / sync_attributes / revoke_user
- [ ] `auth/oauth_routes.py` `/auth/oauth/{provider}/redirect` `/auth/oauth/{provider}/callback` 端点
- [ ] `auth/state_cache.py` OAuth state 参数防 CSRFRedis 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 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/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` cratehttps://docs.rs/keyring/latest/keyring/
- OWASP JWT 安全备忘单https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
- Auth0 Refresh Token Rotationhttps://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