Published on

前端不持有 JWT:从"token 存哪"反推一套 Cookie 鉴权与 RBAC 的设计空间

Authors
  • avatar
    Name
    Jack Qin
    Twitter

"JWT 存哪"几乎是每个前端鉴权方案的第一个分叉,而绝大多数答案——localStorage、sessionStorage、内存——都共享一个隐含前提:前端持有 token。一旦你接受这个前提,"防 token 泄漏"就被永久地写进了前端的责任清单,而前端最大的攻击面是 XSS,于是你余生都在和"别让任何一行 JS 把 token 读走"搏斗。

但这个前提可以不接受。如果把 token 换成一个 HttpOnly Cookie,前端的 JS 根本读不到它——"token 泄漏"这一整项就从前端的责任清单里被划掉了。前端没有 token,自然谈不上泄漏。这不是"更难泄漏",是从机制上没有可泄漏的东西

本文以某环境监测平台为样本,但重点不是描述"用了 ASP.NET Core Identity + Cookie",而是拆解这套鉴权的决策空间:为什么放弃 JWT、CSRF 为什么因此成了不可省的代价、权限怎么从"直接授权 + 组继承"算并集、5 分钟缓存为什么失效比缓存本身难、以及前后端缓存时间为什么要对齐。每一条都回到同一个问题——这个选择把风险消除了,还是只是搬到了别处。


这套权限本质上要表达什么

监测平台服务多个矿区站点,用户要按"模块"和"站点"两个维度做细粒度访问控制:某用户能看粉尘读数但不能改告警阈值,能访问 A 站点但看不到 B 站点。这天然是一套 RBAC。

几条约束直接塑造了方案:

  • 前后端同根域*.<平台域>),不存在跨源 Cookie 的麻烦;
  • 用户量不大,可以接受一次性切换 + 邮件邀请重设密码;
  • 长期目标是完全收回后端能力,不再依赖早期那套 BaaS 的鉴权;
  • 安全优先:XSS token 窃取这类风险要从机制上消除,而不是靠"别写 XSS bug"。

鉴权基于 Identity + HttpOnly Cookie,权限在后端算好、前端只拿"有效权限":

sequenceDiagram
    participant B as 浏览器
    participant S as ASP.NET Core Identity
    B->>S: POST /api/v1/auth/login {email, password}
    S->>S: SignInManager 校验凭据
    S-->>B: Set-Cookie 会话 Cookie (HttpOnly; Secure; SameSite=Lax)
    B->>S: GET /api/v1/users/me
    S->>S: CurrentUserMiddleware 读 Cookie → 解析权限 → 填充 ICurrentUser
    S-->>B: CurrentUserDto (角色 + 有效权限)

决策记录写得很清楚,选 Cookie + Identity 而不是 JWT Bearer,理由有四:

  1. 长期目标是完全收回后端,自建 Identity 比续用第三方 token 更可控;
  2. 前后端同根域,Cookie 不存在跨源问题;
  3. HttpOnly Cookie 从机制上防 XSS token 窃取——前端 JS 根本读不到它,这是 JWT 存 localStorage 做不到的;
  4. 用户量小到可以直接切换(邮件邀请重设密码)。

四条里,第三条是真正的轴,其余三条是"没有反对理由"。值得把它和 JWT 摆在一起看清楚:JWT 存 localStorage,任何一次 XSS 都能 localStorage.getItem 把它偷走;HttpOnly Cookie 在浏览器层面就对 JS 不可见,XSS 偷不到。这两者的差别不是"哪个更安全一点",而是风险被消除还是只被缓解——JWT 方案下你永远在缓解(靠不写出 XSS),Cookie 方案下这个特定风险被结构性地拿掉了。

核心理念一句话:前端永远不持有 JWT,鉴权状态完全是 Cookie 驱动的。 Cookie 的配置分开发和生产两套:

// 开发
Cookie.Name = "<auth>";
Cookie.SecurePolicy = CookieSecurePolicy.None;

// 生产
Cookie.Name = "__Host-<auth>";  // 强制 HTTPS + 不跨子域共享
Cookie.SecurePolicy = CookieSecurePolicy.Always;

// 通用
Cookie.HttpOnly = true;
Cookie.SameSite = SameSiteMode.Lax;       // 允许 OAuth 重定向
ExpireTimeSpan = TimeSpan.FromDays(14);
SlidingExpiration = true;
Events.OnValidatePrincipal = SecurityStampValidator.ValidatePrincipalAsync;  // 改密后立即失效

生产用 __Host- 前缀是细节但关键——它在协议层面强制 HTTPS 且不允许子域共享,不靠应用代码自觉。SameSite=Lax 让 OAuth 重定向能带 Cookie,同时挡住大部分 CSRF。OnValidatePrincipalSecurityStampValidator,意味着用户改密码后旧会话立即失效。


这里要诚实——Cookie 方案没有白拿好处。用 Cookie 就必须配 CSRF 防护,因为浏览器会自动带 Cookie,攻击者可以诱导用户的浏览器发出带 Cookie 的恶意请求。这是 Cookie"自动携带"这个便利的对偶代价:自动携带让前端不用管 token,也让浏览器会替攻击者携带。

机制是双 token:

  • 后端登录时下发一个非 HttpOnlyXSRF-TOKEN Cookie(前端 JS 能读);
  • 前端读出来,在所有变更请求(POST/PUT/PATCH/DELETE)的 X-XSRF-TOKEN 头里带回去;
  • 鉴权端点(/login/forgot-password 等)显式豁免——它们本来就没有会话可被 CSRF。
function getCsrfToken(): string | null {
  const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
  return match ? decodeURIComponent(match[1]) : null
}
headers.set('X-XSRF-TOKEN', csrfToken)

为什么双 token 能挡住 CSRF:攻击者能让浏览器自动带上 HttpOnly 的会话 Cookie,但读不到 XSRF-TOKEN(跨站脚本拿不到目标域的 Cookie 值),因此构造不出正确的 X-XSRF-TOKEN 头。会话 Cookie 自动来、CSRF token 必须主动读才能带——这个不对称就是防线。

把两个方案的代价摆平了看:JWT 方案省掉 CSRF(token 不自动携带),但要管 XSS 窃取;Cookie 方案省掉前端管 token(防住 XSS 窃取),但要管 CSRF。没有免费的方案,只有把复杂度搬到哪一侧的选择。 团队选了 CSRF 这一侧,理由是它有成熟干净的双 token 范式,而 JWT 的 XSS 窃取没有同样干净的解法。这就是"为什么是它而非别的"——不是 Cookie 完美,是它的代价更可控。


权限 = 直接授权 ∪ 所有组授权

权限模型有两个维度(模块、站点)× 两个来源(用户直接、组继承):

flowchart TD
    User[User]
    User --> DM[直接模块权限<br/>user_module_permissions]
    User --> DS[直接站点权限<br/>user_site_permissions]
    User --> GM[组成员关系<br/>user_group_members]
    GM --> GMP[组模块权限<br/>group_module_permissions]
    GM --> GSP[组站点权限<br/>group_site_permissions]

有效权限 = 直接 + 所有组权限的并集。任何一个来源授予了,has_access 就是 true。admin 角色绕过所有检查。解析流程:

  1. role = 'admin' → 全模块访问,短路返回;
  2. 加载直接模块/站点权限(has_access = true 的);
  3. 加载组成员关系(只看激活的组);
  4. 对每个激活的组,加载组的模块/站点权限;
  5. 所有来源求并集(OR);
  6. 缓存到 IMemoryCache,TTL 5 分钟,key 为 permissions:{userId}
  7. 权限/组变更时由 PermissionCacheInvalidator 主动失效;
  8. 合并后的 UserContext(角色 + 是否激活 + 权限)缓存在 user-context:{userId} 下,每次请求由中间件填充一次。

有一个简化决策值得记:早期模型有 can_view + can_edit 两个布尔,后来合并成单个 has_accessAppModule 枚举也换成了常量字符串,由 AppModules.IsValid() 校验。简化的动机是双布尔在实际权限语义里区分度不够,反而制造组合爆炸——两个布尔在两个维度上的笛卡尔积,绝大多数组合在业务里根本无意义。这是一个常被忽视的判断:一个维度只有在它真的对应不同的业务行为时才值得保留,否则它只是在制造无意义的状态空间。


缓存失效为什么比缓存本身难

PermissionResolver.ComputePermissions() 一次解析要跑多条 DB 查询(直接权限、组成员、组权限)。每个请求都重算太贵,所以缓存。但缓存的真正难点从来不是"存",而是"什么时候失效"——存只需要把结果放进字典,失效却要在系统的某个角落发生变更时,准确地知道哪些缓存条目受了影响

  • permissions:{userId}EffectivePermissionsDto,TTL 5 分钟;
  • user-context:{userId}UserContext 记录,TTL 5 分钟;
  • 主动失效InvalidateUser(userId)(直接权限/角色变更时)、InvalidateGroupMembers(groupId)(组权限/成员变更时,会踢掉该组所有成员的缓存)。

为什么光靠 5 分钟 TTL 不够:管理员刚改完某人权限,不能让他再用旧权限 5 分钟——这是安全语义,不是性能问题。所以改组成员/组权限时,必须主动把受影响的所有成员的缓存都打掉。这里藏着一个容易漏的点:组权限变更影响的不是一个用户,而是整个组的成员InvalidateGroupMembers 必须做扇出失效,漏掉就会出现"改了组权限,部分成员还在用旧的"。缓存失效的难,本质是"变更的影响范围"和"缓存的 key 结构"不是一一对应的——一次组变更映射到多个用户 key,这个多对一关系是 bug 的高发地。


前后端缓存对齐:一个被忽视的一致性细节

后端权限缓存是 5 分钟,前端 TanStack Query 里 /users/mestaleTime特意设成 5 分钟对齐:

export const AUTH_ME_STALE_TIME = 5 * 60 * 1000 // 与后端权限缓存对齐

这条对齐为什么重要,得看两个方向都错的后果:前端缓存比后端,会出现"前端去问、后端返回的还是它自己的旧缓存"的无效往返——白白发请求,拿回同样的旧数据;前端缓存比后端,会出现"后端权限已更新、前端还在用旧的"的窗口。两层缓存各有各的 TTL,只有让它们的失效节奏一致,整条链路的"权限新鲜度"才有一个统一的、可推理的上界。当一份数据被多层各自缓存时,这些 TTL 不是独立参数,而是必须一起设计的一组约束。


实现要点

后端:ICurrentUser 抽象

权限检查在端点里通过 BuildingBlocks 的接口完成,模块不直接碰 Identity:

public interface ICurrentUser
{
    Guid UserId { get; }
    string Role { get; }                              // "admin" 或 "user"
    bool IsAdmin { get; }
    IReadOnlySet<string> ModulePermissions { get; }   // 模块 key 集合
    IReadOnlySet<Guid> SitePermissions { get; }       // 站点 ID 集合

    bool CanAccess(string moduleKey);   // admin 直接放行,否则查集合
    bool CanAccessSite(Guid siteId);    // 同上
}

后端:Data Protection 持久化

Cookie 鉴权依赖 Data Protection 密钥保护 Cookie。这些密钥持久化到 PostgreSQL,而不是放内存:

services.AddDataProtection()
    .SetApplicationName("<platform>")
    .PersistKeysToDbContext<IdentityDbContext>();

理由又是"状态放哪决定一切":密钥若只在内存,每次容器重启所有用户都得重新登录,多实例也没法共享。把密钥落库,重启和横向扩展都不掉登录。

前端:me() 把 401 转成 null

查当前用户时,401 不抛异常而是返回 null,因为"未登录"是预期状态而非错误:

me: async (options?) => {
  try {
    return await apiClient.get<CurrentUserDto>("/api/v1/users/me", { signal: options?.signal });
  } catch (err) {
    if (isApiError(err) && err.code === "unauthorized") return null;
    throw err;
  }
},

前端:单一权限来源

前端只认 GET /api/v1/users/me 一个权限来源——它返回 CurrentUserDto,里面带 EffectivePermissionsDto(已经在后端算好并集的有效权限)。前端不重算继承,只消费结果。这避免了"鉴权上下文"和"权限服务"双来源不一致的经典问题——同一份事实只允许有一个权威来源,前端重算继承等于制造第二个真相,两个真相迟早会分叉。

权限工具函数是无状态纯函数(不依赖 React),方便复用和测试:

isAdmin(user): boolean
canAccessModule(user, module, requireEdit?): boolean   // admin → true
canAccessSite(user, siteId, requireEdit?): boolean

前端:路由级与组件级双重防护

// 路由级:无权限重定向到 NoPermissionPage
<ProtectedRoute requiredModule="dust_level">{children}</ProtectedRoute>
<ProtectedRoute adminOnly>{children}</ProtectedRoute>

// 组件级:无权限直接隐藏(不重定向),用于条件 UI
<PermissionGate module="email_schedules" requireEdit fallback={null}>
  <EditButton />
</PermissionGate>

这里有一条必须钉死的认知:前端的权限检查是用户体验层(隐藏按钮、提前重定向),不是安全边界。 真正的访问控制在后端端点的 CanAccess / CanAccessSite 上。前端校验只是为了不让用户点到注定 403 的操作——它防的是"误操作",不是"恶意绕过"。把前端校验当安全边界,是越权漏洞的经典来源:改改前端就能发出请求,后端若不独立校验就破防。

全局 401 处理 与 OIDC 可选项

任何 apiClient 调用收到 401,触发全局回调:清空所有 TanStack Query 缓存 + 跳登录页。

setOnUnauthorized(() => {
  queryClient.clear()
  window.location.href = '/login'
})

支持企业身份提供方(OIDC)单点登录,但仅当配置了对应租户 ID 时才启用。要求已存在邮箱匹配的账号;OAuth 回调成功后签发的是同一个 HttpOnly Cookie,和密码登录走同一套会话机制。没有开放自注册——所有账号由管理员邀请创建。注意 OIDC 复用同一套 Cookie 会话,意味着上面所有关于 Cookie/CSRF/权限的机制对 OIDC 登录的用户一视同仁——不需要为 SSO 单独设计一套鉴权状态。


适用边界:Cookie 不是普适最优,它的前提很硬

这套方案换来的东西是明确的:XSS token 窃取从机制上消除(前端没 token)、权限计算集中后端杜绝双来源不一致、缓存有主动失效且前后端对齐。但它的前提同样明确——前后端同根域。一旦离开这个前提,天平就翻向 JWT:

  • 真正的跨域 SPA + API(不同根域):Cookie 要 SameSite=None; Secure 且 CORS 配置复杂,JWT 反而更顺;
  • 多客户端(移动 App、第三方集成)共享同一个 API:Cookie 不适合非浏览器客户端,JWT/OAuth token 更自然;
  • 无状态横向扩展到极致、且不想要任何服务端会话密钥共享。

所以结论不是"Cookie 比 JWT 好",而是**"浏览器 + 同根域"这个最常见的场景下,HttpOnly Cookie 在安全性上的优势(防 XSS 窃取)通常压过 JWT 的便利性**。换个场景,结论就翻。

可迁移的那一层:鉴权方案的本质选择,是决定"防 token 泄漏"这项责任落在谁头上。 让前端持有 token,责任就永远挂在前端的 XSS 攻击面上;让 HttpOnly Cookie 持有,这项责任连同它的整个攻击面一起从前端消失,代价是你接手了 CSRF。没有方案能同时甩掉两者——你只能选择和哪一类风险共处。面对任何"凭证存哪"的问题,先问:这个选择是消除了风险,还是只是把它搬到了一个我更有办法对付的地方?