2513 字
约 8 分钟
20
S27-X16 · 认证与会话:Cookie、Token 与 SSO 的实现取舍

登录成功之后,服务器必须记住"这个人是谁"。动作只有两个:发一个凭证,下次请求带回来。但凭证存在哪、活多久、能不能被服务端单方面作废,直接决定了这套系统的安全边界和运维代价。

分歧通常在三个问题上爆发:状态放服务端还是客户端、失效能不能由服务端一票否决、跨域与单点登录时凭证如何安全传递。下面按载体、Cookie 属性、JWT、SSO、跨域五层拆开。


1. 三种会话载体:状态放在哪,决定你能撤销什么

载体 状态位置 失效控制能力 典型适用
服务端 Session + Cookie 服务端(内存或 Redis) invalidate() 立即生效,可单点踢人 单体应用、管理后台
客户端 JWT token 自身(payload) 只能等 exp,或额外维护黑名单/版本号 无状态横向扩展、多服务验签
集中式 Token 存储(Opaque) Redis / DB,token 本身只是随机串 DEL 即撤销,可轮换、可审计 多端登录、需要强制下线的系统

服务端 Session 的落地写法,集群下必须外置:

// Spring Session + Redis:sessionId 存 Cookie,状态存 Redis
@Bean
public RedisIndexedSessionRepository sessionRepository(RedisTemplate<String, Object> redis) {
    RedisIndexedSessionRepository repo = new RedisIndexedSessionRepository(redis);
    repo.setDefaultMaxInactiveInterval(Duration.ofMinutes(30));
    repo.setRedisFlushMode(RedisIndexedSessionRepository.RedisFlushMode.ON_SAVE);
    return repo;
}

Opaque token 的核心是"每次请求查一次库",换来的是随时可撤销:

String token = UUID.randomUUID().toString().replace("-", "");
String key = "sess:" + token;
redis.opsForValue().set(key, userId, Duration.ofHours(2));

// 校验:查不到就是无效;滑动续期要设绝对上限
String uid = redis.opsForValue().get(key);
if (uid == null) throw new UnauthorizedException("session expired");
redis.expire(key, Duration.ofHours(2));

一次 Redis 点查的代价按实际部署核对,典型量级是亚毫秒到几毫秒;真正的成本不在延迟,而在每个请求都要摸一次共享存储,存储不可用时全站鉴权失败。


属性 作用 漏配后果
HttpOnly JS 读不到 document.cookie XSS 可一行代码偷走会话(但仍可代发请求)
Secure 只在 HTTPS 下发送 明文链路或降级请求中泄露
SameSite 控制跨站请求是否携带 默认值随浏览器版本变化,回调场景易丢 cookie
Domain / Path 作用域 Domain 设成父域 → 任一子域被攻破即共享会话

SameSite 三个值的实际行为差异比文档描述的更值得记:

取值 顶层导航 跨站 POST / iframe / fetch 备注
Strict 带 不带 从外站点链接进来是未登录态,体验伤
Lax 仅 GET 带 不带 Chrome 80 起为默认值,按浏览器版本核对
None 带 带 必须同时有 Secure,否则被浏览器丢弃

一条完整的响应头应该长这样:

Set-Cookie: SESSION=8f3c...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=7200

Cookie 与 CSRF 的关系:CSRF 成立的前提是"浏览器会自动带上凭证"。SameSite=Lax 挡住了绝大多数跨站表单提交,但它不挡站内的 GET 型副作用接口,也不挡同站子域。所以 CSRF Token 仍是兜底,而不是可选项:

<!-- Synchronizer Token:服务端下发,表单回传,服务端比对 -->
<input type="hidden" name="_csrf" value="${_csrf.token}">
// Spring Security 默认开启 CSRF,前后端分离时用 CookieCsrfTokenRepository
http.csrf(c -> c.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
               .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()));

3. JWT 的结构与两条硬限制

三段式:header.payload.signature,前两段是 base64url,不是加密。

// header
{"alg":"RS256","typ":"JWT","kid":"2026-11"}
// payload(任何人 base64 解码即可读)
{"sub":"10086","role":"user","iat":1762500000,"exp":1762500900,"jti":"a1b2c3"}

两条硬限制必须先接受,再谈设计:

  1. 不能存敏感信息。手机号、身份证、内部权限位一旦进 payload,等于公开。需要保密就用 JWE,或者干脆别放。
  2. 无法主动失效。签发即生效直到 exp,服务端没有"收回"这个动作。想收回只有三条路:
续期/失效方案 机制 代价
短 access + 长 refresh access 分钟级,refresh 存服务端可撤销 多一次换票请求;refresh 泄露风险更高
滑动过期 每次请求续 exp 必须同时设绝对上限,否则永不失效
token 版本号 用户表存 tokenVersion,改密码/踢人时 +1 每次请求要读一次版本号,等于半有状态
// 校验时同时比对版本号,实现"改密码即全端下线"
Claims claims = Jwts.parserBuilder().setSigningKey(publicKey).build()
        .parseClaimsJws(token).getBody();
int ver = claims.get("ver", Integer.class);
if (ver != userService.currentTokenVersion(claims.getSubject())) {
    throw new UnauthorizedException("token revoked");
}

另外两个常见误判:alg: none 必须在解析器里显式禁用;kid 用来支持密钥轮换,不能拿 payload 里的 kid 去拼文件路径(那是路径穿越)。


4. SSO 的核心流程与 redirect_uri 白名单

标准三步,本质是"票据换会话":

1. 浏览器访问 app.example.com/protected
2. 未登录 → 302 到 id.example.com/authorize?client_id=APP&redirect_uri=...&state=...
3. 认证中心校验通过 → 302 回 redirect_uri?code=CODE&state=...
4. 应用后端用 code + client_secret 换 id_token/access_token(服务端对服务端)
5. 校验签名与 claims → 建立本地会话 → 302 到原始地址

redirect_uri 必须是精确白名单匹配,不能前缀匹配、不能正则模糊匹配:

校验方式 是否安全 说明
全等比对白名单集合 安全 唯一推荐做法
startsWith("https://app.example.com") 不安全 https://app.example.com.evil.com 通过
只校验 state 不安全 state 防 CSRF,不防票据外泄
允许通配子域 有风险 任一子域被控即接管全部回调

code 必须一次性、短时效(分钟级),并且绑定 client_id 与 redirect_uri。授权码在 URL 里出现过,会进浏览器历史、Referer、网关日志。

单点登出的难点在于:认证中心登出了,各业务系统的本地会话还在。三个可选做法:

做法 生效速度 实现成本
back-channel logout(认证中心回调各 RP 的登出端点) 秒级 各 RP 都要暴露端点并互信
前端 iframe 轮询认证中心状态 分钟级 受第三方 Cookie 策略影响,越来越不可靠
压缩本地会话有效期 + 定期回源校验 取决于 TTL 最省事,但存在窗口期

5. 跨域凭证传递与会话固定

前后端分离、跨域部署时,凭证能带过去需要两端同时配合:

// 前端:必须显式 include,默认是 same-origin
fetch("https://api.example.com/me", { credentials: "include" });
# 后端 CORS:Allow-Credentials 为 true 时,Origin 不能是 *
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token
Vary: Origin

Access-Control-Allow-Origin: * 与 Allow-Credentials: true 同时出现会被浏览器直接拒绝——这不是配置建议,是规范强制。多来源时按请求的 Origin 回显并加 Vary: Origin,且回显值必须来自白名单。

会话固定攻击:攻击者先取得一个合法 sessionId,诱导受害者带着它登录;若登录后 ID 不变,攻击者手里的 ID 就是受害者的会话。修复只有一句:

// 登录成功后必须换 ID(Servlet 3.1+ 原生支持)
request.changeSessionId();

同时清理旧属性,别让登录前的匿名数据带进已认证会话。


6. 排错:五个真实踩过的坑

现象 根因 处理
token 存 localStorage 后被 XSS 一次性偷走 localStorage 无 HttpOnly 能力 改 HttpOnly Cookie,或内存保存 access + Cookie 存 refresh
OAuth 回调回来变成未登录 SameSite 默认 Lax,跨站 POST 回调不带 cookie 回调改成顶层 GET 跳转;或 SameSite=None; Secure
JWT 有效期设成 30 天 把"少登录一次"当成需求 access 压到分钟级,长周期交给 refresh
登出只清了前端 store 服务端 session/JWT 仍有效,重放照样通过 服务端删 session 或写黑名单/版本号 +1
CORS 报 credentials 相关错误 Allow-Origin: * 与凭证同用,或漏了 Vary: Origin 回显白名单 Origin,补 Vary

还有一条容易漏:反向代理没转发 Set-Cookie 的 Secure/Domain,或代理层终止 TLS 后应用以为是 HTTP,导致 Secure cookie 根本不写入。查法是在浏览器 Network 面板看响应头,而不是看代码。


7. 边界:什么场景本就不该上全套

场景 建议 理由
单体内网管理后台 Session + Cookie 足够 无跨域、无多端,撤销能力最重要
移动端 App / 小程序 Opaque token + refresh 无 Cookie 语义,需要强制下线能力
对外开放 API OAuth2 / OIDC 标准流程 自研 SSO 的坑比功能多
服务间调用 mTLS 或短期凭证 不应复用用户会话
需要审计到"每一次操作" 集中式 token 存储 + 服务端日志 JWT 无状态意味着无法回溯签发记录

一句话总结:要能随时踢人,就把状态放服务端;要无状态扩展,就接受"只能等过期"并配上短 TTL 与版本号。


8. 自测

  1. 服务端 Session 与 JWT 在"强制下线"这件事上的能力差异,根源是什么?
  2. SameSite=Lax 为什么会让 OAuth 回调丢失 cookie?
  3. JWT 的 payload 为什么不能放手机号?它到底被加密了吗?
  4. redirect_uri 用前缀匹配校验,攻击者怎么利用?
  5. Access-Control-Allow-Origin: * 与 Allow-Credentials: true 为什么不能共存?
  6. 会话固定攻击成立的前提是什么,修复只需哪一行代码?
S27-X16 · 认证与会话:Cookie、Token 与 SSO 的实现取舍
https://www.clxhxhhr.top/posts/5920/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。