32 KiB
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 SQLite 存用户 + bcrypt 哈希(cost=12)+ session hash
- auth/password.py bcrypt 哈希 / 验证
- routes/auth.py
/api/v1/auth/login/refresh/logout接口 - stores/auth.ts Pinia 鉴权 store,access (15min) + refresh (7d) + user 存 localStorage
- router/index.ts 路由守卫读 store,已有 token 时自动放行
- views/LoginView.vue
onMounted检查isAuthenticated自动跳走 - api/base.ts 401 →
refreshIfPossible→ 失败清空
暴露的问题:
- Token 持久化安全弱 — access + refresh + user 明文存 WebView localStorage(macOS:
~/Library/WebKit/.../LocalStorage/),被其他进程或恶意浏览器扩展可读 - 无 refresh token 轮换 — 同一 refresh token 可在 7 天内无限复用,被泄漏后无重置窗口
- 无服务端 Session 表 — 改密码 / 踢人 / 看活跃设备 全部做不到;
user_sessions表(auth/models.py:55-66)只存 refresh token 哈希,看不到设备/时间/IP - 无「记住我」开关 — refresh 固定 7 天,没法选「这台设备记 30 天」
- 无预刷新 — access 还有效时不主动续,依赖 401 触发;批量请求高峰可能集中刷新
- 启动态不区分 — 有 token 但服务端 401 时直接清掉,看不出「我刚登录过 / 现在连不上」的区别
- 集团统一管理缺位 — 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,集团统一管理的基础设施准备好
- 认证后端可插拔:所有用户认证逻辑走
AuthProviderProtocol(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 普通员工「每天开应用」
- 早上打开 Tauri 客户端
- 启动页 → 后端健康检查 →
/api/v1/auth/whoami携带 Keychain 里的 refresh token 调一次 - 服务端校验通过 → 返回新 access token + user → 进主界面
- 看不到登录页
4.2 设备丢失「管理员踢人」
- 员工报设备丢失
- 管理员在「用户管理 → 会话列表」看到该员工所有活跃 session
- 点击「踢出」 → 服务端
UPDATE auth_sessions SET revoked=1 WHERE id=? - 该设备下一次请求 401 → 客户端清空 → 跳登录页
4.3 改密码「全设备强踢」
- 用户在「设置 → 修改密码」提交新密码
- 服务端更新密码哈希 +
UPDATE auth_sessions SET revoked=1 WHERE user_id=? AND id != ?(保留当前 session) - 其他设备下次请求 401 → 自动跳登录
4.4 「记住我」开关
- 登录页有「记住我(30 天)」checkbox
- 不勾选 → refresh 7 天;勾选 → refresh 30 天
- 用户可在「设置 → 安全」随时查看/撤销当前 session
4.5 Refresh Token 泄漏检测
- 攻击者拿到旧的 refresh token
- 调
/api/v1/auth/refresh→ 服务端检测「此 token 已被轮换过」 - 整个 user 的所有 session 立即撤销(防扩散)
- 推送通知到用户「检测到异常登录」
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 扩展
{
"sub": "user_id",
"sid": "session_id", // 新增:关联 auth_sessions.id
"type": "access",
"exp": ...,
"iat": ...,
"jti": "..." // 新增:单次 token id,用于黑名单
}
Refresh token 同样携带 sid,校验时必须查 auth_sessions 表确认未 revoked。
5.1.3 新增 / 修改 API
| Method | Path | 鉴权 | 行为 |
|---|---|---|---|
| POST | /api/v1/auth/login |
无 | 校验密码 → 创建 session → 返回 access+refresh+JWT(sid) |
| POST | /api/v1/auth/refresh |
refresh | 校验 session 未 revoked → 轮换 refresh → 返回新 pair,旧 refresh 入黑名单 30s |
| POST | /api/v1/auth/logout |
access | revoke 当前 session |
| GET | /api/v1/auth/whoami |
access | 返回 user + 当前 session 元数据(设备/IP/时间) |
| GET | /api/v1/auth/sessions |
access | 当前用户的活跃 session 列表 |
| DELETE | /api/v1/auth/sessions/{id} |
access | 踢出指定 session(自己踢自己;admin 踢任意) |
| POST | /api/v1/auth/logout-others |
access | 踢出除当前外的所有 session |
| GET | /api/v1/admin/users/{id}/sessions |
admin | admin 看任意用户的 session |
| DELETE | /api/v1/admin/users/{id}/sessions/{sid} |
admin | admin 踢任意 session |
| POST | /api/v1/auth/change-password |
access | 改密码 + revoke 其他 session |
5.1.4 Refresh 轮换检测逻辑
async def rotate_refresh(old_refresh: str) -> TokenPair:
payload = jwt.decode(old_refresh)
session = await db.get_session(payload["sid"])
if not session or session.revoked:
raise TokenReuseDetected() # 触发全用户 session 撤销
if session.refresh_token_hash != sha256(old_refresh):
raise TokenReuseDetected()
# 生成新的 refresh + access,旧 refresh 入「recently_revoked」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)
// src-tauri/src/auth.rs
#[tauri::command]
async fn store_refresh_token(token: String) -> Result<(), String> {
let entry = keyring::Entry::new("agentkit", "refresh_token")?;
entry.set_password(&token)?;
Ok(())
}
#[tauri::command]
async fn load_refresh_token() -> Result<Option<String>, String> {
let entry = keyring::Entry::new("agentkit", "refresh_token")?;
match entry.get_password() {
Ok(t) => Ok(Some(t)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
async fn clear_refresh_token() -> Result<(), String> {
let entry = keyring::Entry::new("agentkit", "refresh_token")?;
let _ = entry.delete_credential();
Ok(())
}
依赖:keyring = "3"(macOS Keychain / Windows Credential Manager / Linux Secret Service 统一 API)
5.2.2 前端 Tauri API 封装
// src/api/tauri-auth.ts
const isTauri = (): boolean => '__TAURI__' in window
export const tauriAuthStorage = {
async setRefreshToken(token: string): Promise<void> {
if (isTauri()) {
try {
await invoke('store_refresh_token', { token })
return
} catch (e) {
console.warn('Keychain 写入失败,fallback 到 localStorage', e)
}
}
localStorage.setItem('agentkit.refresh_token', token)
},
async getRefreshToken(): Promise<string | null> {
if (isTauri()) {
try {
return await invoke<string | null>('load_refresh_token')
} catch (e) {
console.warn('Keychain 读取失败,fallback', e)
}
}
return localStorage.getItem('agentkit.refresh_token')
},
async clearRefreshToken(): Promise<void> {
if (isTauri()) {
try { await invoke('clear_refresh_token') } catch {}
}
localStorage.removeItem('agentkit.refresh_token')
},
}
5.2.3 auth store 改造
- 初始化时异步从 Keychain 读 refresh token,不再从 localStorage 同步读
- 启动时调
whoami,分三态:valid→ 直接进应用invalid→ 显示「会话已过期,请重新登录」+「重试」按钮error(网络) → 显示「无法连接服务器」+「重试」按钮
- access token 只存内存(不写 localStorage / Keychain)
- user 缓存到 localStorage(明文可接受,仅用于启动时先显示头像 / 角色)
5.2.4 预刷新逻辑
// src/api/base.ts 拦截器
apiClient.interceptors.request.use(async (config) => {
const auth = useAuthStore()
if (auth.accessToken && auth.shouldRefresh()) { // < 2min
await auth.silentRefresh()
}
if (auth.accessToken) {
config.headers.Authorization = `Bearer ${auth.accessToken}`
}
return config
})
5.2.5 「记住我」 UI
- LoginView 加 checkbox「记住我(30 天)」
- 登录请求带
?remember_me=truequery(或 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 携带
sidclaim - 旧 JWT 没有
sid→ 服务端 fallback 到user_sessions表校验 - 客户端版本检查:
Authorizationheader 带X-Client-Versionheader - 服务端支持 1 个 minor 版本的旧客户端(~30 天灰度)
5.5 鉴权后端可插拔 — AuthProvider 抽象层
设计动机:当前用本地 users 表 + bcrypt 校验密码。未来集团对接 OIDC / SAML / LDAP 时,路由层、admin API、Session 表都不应重写。通过
AuthProviderProtocol 把"用户存在哪里 / 密码怎么校验 / 属性怎么同步"封装在 adapter 内部。
5.5.1 AuthProvider Protocol
# 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
# 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
# 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 切换配置
# 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 注入
# 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}")
# 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—AuthProviderProtocol 接口契约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)注入 providersrc/agentkit/server/routes/admin.py(或 auth 内部)— admin session 管理(按 user_id 操作,与 provider 解耦)migrations/versions/xxx_add_auth_sessions.py— Alembic migrationsrc/agentkit/server/auth/cache.py— Redis session 元数据 cache
Backend(修改):
src/agentkit/server/auth/models.py— 新增 AuthSessionModelsrc/agentkit/server/auth/password.py— 改密码时联动撤销 sessionsrc/agentkit/server/dependencies.py—get_current_user校验 sidsrc/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_mesrc/agentkit/server/frontend/src/api/base.ts— 预刷新拦截器、refresh reuse 失败处理src/agentkit/server/frontend/src/views/LoginView.vue— 「记住我」checkboxsrc/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 commandssrc/agentkit/server/frontend/src-tauri/src/lib.rs— 注册 commandssrc/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
-
现状代码:
-
外部参考:
keyringcrate: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