# 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
