AI 助手 06:Spring Boot 多租户系统如何设计登录态和权限上下文
基于 ai-study V1.5 已实现基线,拆解 JWT access token、refresh token 轮换、请求上下文与 RBAC 如何共同替代浏览器伪造的租户身份。
所属项目:AI-assistantAI 应用做到会话、知识库或 Agent 时,最先不能相信的往往是浏览器传来的租户和角色。早期开发为了调试方便,可以给请求默认的 demo 身份;一旦进入多用户、多租户场景,这种方式必须退出主链路。否则用户只要改一个 Header,就可能读到不属于自己的会话或文档。
ai-study 的 V1.5 做的不是完整 IAM 平台,而是先建立可信登录态:Java 通过 JWT 解析用户、租户和角色,前端不再直接决定这些身份字段;Python 只能接收 Java 已认证后透传的上下文。
把身份信息从浏览器手里收回来
当前匿名入口只保留健康检查、登录和刷新接口。用户登录成功后,Java 返回短期 access token;前端把它保存在 Pinia 的内存状态里,并在受保护请求中放入:
Authorization: Bearer <access token>
令牌内包含用户 ID、tenantId、角色和过期时间。RequestContextFilter 负责验签并构建 RequestContext,业务代码只从这里取得租户、用户和角色。会话、文档等查询因此可以在服务层固定带上 tenant_id + user_id 条件,而不是接受前端传来的范围。
这是一条很重要的约束:前端能表达“我想访问什么”,但不能表达“我是谁”。当前 Java 会忽略浏览器传入的 x-tenant-id、x-user-id 和角色 Header;这些值只有通过签名令牌才会进入业务上下文。
为什么 access token 和 refresh token 要拆开
access token 默认有效 15 分钟,目的是让被盗取后的有效窗口可控;它不落库,页面刷新后会丢失。为了避免用户频繁登录,服务端再发一个 7 天有效的 refresh token,放入 HttpOnly、SameSite=Lax、路径为 /api/auth 的 Cookie。
refresh token 不保存明文。代码生成 32 字节随机值,只把 SHA-256 哈希写入 PostgreSQL。每次 /api/auth/refresh 都撤销旧记录、签发新 token,因此旧 Cookie 即使被重放,也无法持续刷新会话。登录、刷新和退出同时写入安全审计记录。
这套设计比“把长效 JWT 放 localStorage”多了服务端撤销能力,也减少了脚本直接读取 refresh token 的风险。它仍不是万能方案:本地 HTTP 环境暂未启用 Secure Cookie,生产 HTTPS 部署时必须打开;access token 也尚未有 jti denylist,只能依赖其 15 分钟自然过期。
RBAC 不应散落在 Controller 里
认证解决“是谁”,授权解决“能做什么”。当前 Chat 和文档接口必须登录,/api/admin/** 和运行日志接口额外要求 ADMIN。授权判断由统一的上下文和 AdminAccessService 完成,而不是在每个页面或 SQL 中各写一段字符串判断。
这样做的收益在 V2 很明显:文档上传、列表和删除均使用当前 tenantId + userId;下一步做知识库检索时,只需沿用同一份身份上下文,并把它变成检索过滤条件。认证不是 RAG 的附属功能,而是权限检索能成立的前提。
当前验收与未完成项
可通过登录、刷新、退出与 /api/auth/me 验证基本闭环;未登录访问 Chat、租户后台或运行日志应得到 401,非管理员访问管理接口应得到 403。页面刷新时,前端用 refresh Cookie 恢复内存里的 access token,路由守卫再决定是否跳转登录页。
这版还没有登录限流、账户锁定、验证码或 MFA,也没有企业 SSO/LDAP/OAuth。它适合当前项目把“可信主体”接进 AI 主流程,不等同于已经具备完整企业身份平台。后续安全加固会继续补齐登录防护、HTTPS、Cookie 安全属性和更细的运维角色范围。