登录成功之后,服务器必须记住"这个人是谁"。动作只有两个:发一个凭证,下次请求带回来。但凭证存在哪、活多久、能不能被服务端单方面作废,直接决定了这套系统的安全边界和运维代价。
分歧通常在三个问题上爆发:状态放服务端还是客户端、失效能不能由服务端一票否决、跨域与单点登录时凭证如何安全传递。下面按载体、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 点查的代价按实际部署核对,典型量级是亚毫秒到几毫秒;真正的成本不在延迟,而在每个请求都要摸一次共享存储,存储不可用时全站鉴权失败。
2. Cookie 的四个安全属性
| 属性 | 作用 | 漏配后果 |
|---|---|---|
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"}
两条硬限制必须先接受,再谈设计:
- 不能存敏感信息。手机号、身份证、内部权限位一旦进 payload,等于公开。需要保密就用 JWE,或者干脆别放。
- 无法主动失效。签发即生效直到
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. 自测
- 服务端 Session 与 JWT 在"强制下线"这件事上的能力差异,根源是什么?
SameSite=Lax为什么会让 OAuth 回调丢失 cookie?- JWT 的 payload 为什么不能放手机号?它到底被加密了吗?
redirect_uri用前缀匹配校验,攻击者怎么利用?Access-Control-Allow-Origin: *与Allow-Credentials: true为什么不能共存?- 会话固定攻击成立的前提是什么,修复只需哪一行代码?