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

- Name
- Jack Qin
"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 而不是 JWT:四条理由背后是同一笔账
决策记录写得很清楚,选 Cookie + Identity 而不是 JWT Bearer,理由有四:
- 长期目标是完全收回后端,自建 Identity 比续用第三方 token 更可控;
- 前后端同根域,Cookie 不存在跨源问题;
- HttpOnly Cookie 从机制上防 XSS token 窃取——前端 JS 根本读不到它,这是 JWT 存 localStorage 做不到的;
- 用户量小到可以直接切换(邮件邀请重设密码)。
四条里,第三条是真正的轴,其余三条是"没有反对理由"。值得把它和 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。OnValidatePrincipal 接 SecurityStampValidator,意味着用户改密码后旧会话立即失效。
CSRF:不是可以省的麻烦,是 Cookie 鉴权的必然代价
这里要诚实——Cookie 方案没有白拿好处。用 Cookie 就必须配 CSRF 防护,因为浏览器会自动带 Cookie,攻击者可以诱导用户的浏览器发出带 Cookie 的恶意请求。这是 Cookie"自动携带"这个便利的对偶代价:自动携带让前端不用管 token,也让浏览器会替攻击者携带。
机制是双 token:
- 后端登录时下发一个非 HttpOnly 的
XSRF-TOKENCookie(前端 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 角色绕过所有检查。解析流程:
role = 'admin'→ 全模块访问,短路返回;- 加载直接模块/站点权限(
has_access = true的); - 加载组成员关系(只看激活的组);
- 对每个激活的组,加载组的模块/站点权限;
- 所有来源求并集(OR);
- 缓存到
IMemoryCache,TTL 5 分钟,key 为permissions:{userId}; - 权限/组变更时由
PermissionCacheInvalidator主动失效; - 合并后的
UserContext(角色 + 是否激活 + 权限)缓存在user-context:{userId}下,每次请求由中间件填充一次。
有一个简化决策值得记:早期模型有 can_view + can_edit 两个布尔,后来合并成单个 has_access;AppModule 枚举也换成了常量字符串,由 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/me 的 staleTime 也特意设成 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。没有方案能同时甩掉两者——你只能选择和哪一类风险共处。面对任何"凭证存哪"的问题,先问:这个选择是消除了风险,还是只是把它搬到了一个我更有办法对付的地方?