1. 这不是概念背诵题,是每天都在写的登录逻辑
你写过登录功能吗?哪怕只是个学生作业、内部管理系统、或者自己搭的博客后台——只要用户要“记住我”“保持登录状态”,你就绕不开Cookie、Session、Token这三个词。它们不是教科书里并列出现的抽象名词,而是 HTTP 请求链条上真实存在的三类“身份凭证”,各自承担着不同阶段、不同场景下的信任传递任务。我带过十几期后端开发训练营,90% 的学员第一次调试登录失败,根本原因不是代码写错了,而是压根没搞清:到底是谁在什么时候、用什么方式、把哪段数据塞进了哪条请求里?比如你填完账号密码点登录,浏览器发出去的第一个 POST 请求里,有没有 Cookie?服务端返回的响应头里,Set-Cookie 是怎么写的?前端拿到的 token 字符串,是存在 localStorage 还是 sessionStorage?下次请求时,它又通过哪个字段(Authorization?X-Auth-Token?还是直接拼在 URL 里?)重新交还给后端?这些细节,差一个字母,整个认证流程就卡死。更现实的是,你在用 Postman 测试接口时,看到401 Unauthorized,第一反应是查 token 是否过期;但如果是500 Internal Server Error且日志里报There is no session with id xxx,那问题可能出在 Redis 连接断了、Session 序列化失败、甚至 Tomcat 的 work 目录被误删——这和 JWT 签名密钥配错完全是两套排查路径。所以这篇不讲定义,不列对比表格糊弄人,只讲我在电商中台、政务 SSO、IoT 设备管理平台这三个真实项目里,怎么选、怎么配、怎么调、怎么修。从 Chrome 开发者工具 Network 面板里真实抓到的请求头开始,一帧一帧拆解数据流向。
2. 核心设计思路:为什么不能只用一种方案?
2.1 本质差异:状态存储位置决定架构边界
很多人以为 Cookie/Session/Token 是“替代关系”,其实它们解决的是同一问题的不同切面:如何在无状态的 HTTP 协议上,维持有状态的用户会话。关键分歧点在于:状态存哪儿?谁来验证?
- Cookie + Session 组合:状态存在服务端(内存/Redis/数据库),Cookie 只存一个无意义的 Session ID(比如
JSESSIONID=abc123)。浏览器每次请求自动带上这个 ID,服务端查表确认身份。好处是服务端完全可控——能随时踢掉某个用户、限制并发登录数、做敏感操作二次验证;坏处是服务端必须维护状态,横向扩展时 Session 同步成本高,微服务架构下跨服务共享 Session 得靠 Redis 或 sticky session,运维复杂度陡增。 - Token(尤其是 JWT)方案:状态存在客户端(localStorage/sessionStorage),Token 本身是自包含的凭证(比如
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...),服务端只校验签名和有效期,不查数据库。好处是彻底无状态,API 网关、负载均衡器、后端服务都能独立验证,适合大规模分布式系统;坏处是 Token 一旦签发就无法主动作废(除非加黑名单或缩短有效期),且 Payload 里放太多信息会导致请求头膨胀(HTTP Header 有 8KB 限制),移动端尤其敏感。
提示:JWT 不是 Token 的唯一实现,但它是最典型的“自包含 Token”。OAuth 2.0 的 Access Token 可以是 JWT,也可以是 opaque string( opaque token),后者仍需服务端查库验证,本质还是 Session 思路。
2.2 场景驱动选型:没有银弹,只有权衡
我经手的三个典型项目,选型逻辑完全不同:
- 政务 SSO 平台(对接 37 个委办局系统):强制要求 Session 方案。原因很实际——审计合规要求所有登录行为可追溯、可实时终止。某局领导账号被盗,安全中心必须 5 秒内踢掉其所有终端。JWT 无法做到这点,而 Session 存 Redis,
DEL session:abc123一条命令搞定。我们甚至给每个 Session 加了user_id:dept_id:ip复合键,方便按部门批量清理。 - 电商中台(日活 200 万+,订单/库存/营销服务拆成 12 个微服务):核心链路用 JWT。下单时,购物车服务、库存服务、支付网关都得校验用户身份,如果每个服务都去查 Redis 获取 Session,网络延迟叠加、Redis 成为单点瓶颈。改用 JWT 后,网关统一验签,透传
user_id和role到下游服务,各服务直接读取 Token 中的声明,RT 从 120ms 降到 22ms。但客服后台这种低频、强管控场景,仍用 Session,方便坐席主管随时冻结账号。 - IoT 设备管理平台(设备端用 C 语言 HTTP 库,资源受限):放弃 Cookie 和 JWT。设备端解析 JSON、验签 RSA 耗资源,且固件升级后本地存储的 Token 无法刷新。改用轻量级 Token:服务端生成 32 字节随机字符串(
token=8a3f9b2e1c7d4a5f6b8c9d0e1f2a3b4c),存入 Redis 并设置 7 天过期,设备每次请求带Authorization: Bearer 8a3f9b2e...。服务端只做字符串比对,零计算开销。
注意:所谓 “SPA 项目必须用 Token” 是伪命题。Vue/React 前端完全可以发登录请求,服务端 Set-Cookie,后续请求自动携带 Cookie,配合 CORS 配置即可。是否用 Token,取决于后端架构和安全策略,而非前端框架。
2.3 安全边界:别让 Cookie 成为攻击入口
很多开发者以为 “用了 HTTPS 就安全了”,但 Cookie 的Secure、HttpOnly、SameSite属性才是防攻击的关键防线:
Secure:确保 Cookie 只通过 HTTPS 传输,防止中间人窃取。但注意:开发环境用http://localhost时,浏览器会拒绝设置 Secure Cookie,导致登录失败。解决方案是开发时去掉Secure,或用https://localhost(需本地证书)。HttpOnly:禁止 JavaScript 访问 Cookie,防御 XSS 攻击窃取 Session ID。但这也意味着前端无法读取 Cookie 做自定义逻辑(比如显示登录用户名),需服务端在响应体中额外返回用户信息。SameSite:防 CSRF 的核心。SameSite=Lax(默认值)允许 GET 请求携带 Cookie,但阻止 POST 表单提交时发送;SameSite=Strict更严格,但可能导致用户从第三方链接跳转时登录态丢失;SameSite=None必须搭配Secure,用于跨站嵌入场景(如支付 iframe)。
我踩过的坑:某次上线新版本,前端用fetch发 POST 请求,后端 Spring Security 默认SameSite=Lax,结果 Chrome 98+ 版本拒绝发送 Cookie,所有接口 401。临时方案是后端显式配置SameSite=None,但必须同步开启Secure,否则部署到 HTTP 环境直接失效。
3. 实操细节拆解:从请求头到数据库的一帧帧解析
3.1 Cookie 的真实生命周期:不只是浏览器里的小饼干
Cookie 不是“存在浏览器里的字符串”,而是一套由 RFC 6265 定义的协议机制。它的完整流转包含四个关键环节:
服务端下发:响应头
Set-Cookie: sessionId=abc123; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600Path=/表示该 Cookie 在整个域名路径下有效;若设为Path=/api,则/login接口无法读取。Max-Age=3600是秒数,优先级高于Expires(时间戳),设为 0 或负数表示立即删除。Domain属性常被滥用:Domain=.example.com允许a.example.com和b.example.com共享 Cookie,但现代浏览器要求二级域名必须显式声明(Domain=example.com不合法,必须Domain=.example.com),且主域名不能设Domain(Domain=www.example.com无效)。
浏览器存储与发送:浏览器按域名、Path、Secure 等规则匹配,自动将符合条件的 Cookie 加入请求头
Cookie: sessionId=abc123; theme=dark。注意:Cookie 是键值对集合,多个 Cookie 用分号分隔,但;在值中会被截断,所以值里不能含分号。服务端解析:Java Servlet 中
request.getCookies()返回Cookie[]数组;Node.js Express 中req.cookies.sessionId(需cookie-parser中间件);Python Flask 中request.cookies.get('sessionId')。过期与清理:
Max-Age到期,浏览器自动删除;- 用户手动清除浏览器 Cookie;
- 服务端下发
Set-Cookie: sessionId=; Max-Age=0强制删除(注意:sessionId=后必须有等号,否则部分浏览器不识别)。
实操心得:调试 Cookie 问题,永远先看 Chrome DevTools → Application → Cookies,这里显示的是浏览器当前存储的原始 Cookie,比 Network 面板里看到的
Cookie请求头更真实。曾遇到一次问题:后端代码写了response.addCookie(new Cookie("token", "")),但没设Max-Age=0,结果浏览器认为这是新 Cookie,覆盖了旧值但没删除,导致后续请求带着空字符串 token,服务端解析失败。
3.2 Session 的底层实现:不只是内存里的 HashMap
Session 看似简单,但生产环境必须直面三个硬核问题:
存储介质选择:
- 内存(Tomcat 默认):重启即丢,单机可用,不适合集群;
- Redis:主流选择,支持过期、原子操作、高可用;但要注意序列化方式——Java 默认
java.io.ObjectOutputStream生成的字节流,升级 JDK 版本后可能反序列化失败。我们改用 Jackson 序列化成 JSON,兼容性好,且 Redis 里可直接GET session:abc123查看内容; - 数据库:可靠性高,但 IO 延迟大,仅用于审计日志等非实时场景。
Session ID 生成安全:
Tomcat 默认用java.security.SecureRandom生成 128 位随机数,足够安全。但某些老版本 Tomcat(< 8.5)用Math.random(),熵值不足。检查方法:启动日志中搜索Session ID generator,确认是否使用SecureRandom。粘性 Session(Sticky Session)陷阱:
Nginx 配置ip_hash或hash $cookie_sessionid实现粘性,看似解决了 Session 共享问题,实则埋雷:- 某台机器宕机,其上的 Session 全部丢失;
- 用户 IP 变化(如移动网络切换基站),Session 断连;
- 负载不均,热门节点压力过大。
我们曾因此在双 11 前紧急下线ip_hash,改用 Redis +spring-session-data-redis,性能反而提升 15%,因为避免了单点瓶颈。
3.3 Token(JWT)的构造与验签:签名不是摆设
JWT 由三部分组成:Header.Payload.Signature,用.连接。以常见登录返回为例:
HTTP/1.1 200 OK Content-Type: application/json { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" }- Header:
{"alg":"HS256","typ":"JWT"}→ 指定签名算法(HS256 表示 HMAC-SHA256)和类型; - Payload:
{"sub":"1234567890","name":"John Doe","iat":1516239022}→sub(subject)是用户标识,iat(issued at)是签发时间,还可自定义role、permissions等; - Signature:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)→ 用密钥对前两部分签名,防止篡改。
关键实操点:
- 密钥管理:绝不能硬编码在代码里!我们用 Spring Cloud Config + Vault,启动时从 Vault 拉取密钥,内存中只存引用。曾因测试环境密钥泄露,攻击者伪造管理员 Token,刷单损失 200 万。
- Payload 大小控制:一个 JWT Token 通常 500~1500 字节。若放入用户权限树(JSON 数组含 50 个菜单项),Token 膨胀到 4KB,加上其他请求头,超 HTTP Header 8KB 限制,Nginx 直接返回
400 Bad Request。解决方案:Payload 只存user_id和role,权限列表由前端首次登录后单独请求/api/user/permissions获取。 - 刷新机制(Refresh Token):Access Token 短期(如 30 分钟),Refresh Token 长期(如 7 天),存于 HttpOnly Cookie。用户 Token 过期时,前端用 Refresh Token 换新 Access Token。注意:Refresh Token 必须绑定设备指纹(User-Agent + IP),且每次使用后服务端需更新其值并吊销旧 Token,否则重放攻击风险极高。
4. 全流程实操:从登录接口到登出的每一步代码与配置
4.1 Spring Boot 项目实战:Cookie + Session 方案
步骤 1:基础依赖与配置
<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency># application.yml spring: redis: host: 10.0.1.100 port: 6379 password: ${REDIS_PASSWORD:} # 从环境变量读取 session: store-type: redis timeout: 1800 # 30分钟 cookie: http-only: true secure: true # 生产环境必须 same-site: Lax步骤 2:登录接口实现
@RestController public class AuthController { @PostMapping("/login") public ResponseEntity<Map<String, Object>> login( @RequestBody LoginRequest request, HttpServletResponse response) { // 1. 校验账号密码(省略 DB 查询) User user = userService.findByUsername(request.getUsername()); if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { return ResponseEntity.status(401).build(); } // 2. 创建 Session,存用户信息 HttpSession session = request.getSession(true); session.setAttribute("user_id", user.getId()); session.setAttribute("username", user.getUsername()); session.setAttribute("role", user.getRole()); // 3. 设置 Cookie 属性(Spring Session 自动处理,但需确认配置生效) // 无需手动 setCookie,Spring Session 会生成 JSESSIONID 并设置响应头 Map<String, Object> result = new HashMap<>(); result.put("message", "登录成功"); result.put("user", user); return ResponseEntity.ok(result); } }步骤 3:受保护接口与登出
@RestController public class UserController { @GetMapping("/api/user/profile") public ResponseEntity<User> getProfile(HttpSession session) { // Spring Security 会自动注入 session,此处演示原生用法 Long userId = (Long) session.getAttribute("user_id"); if (userId == null) { return ResponseEntity.status(401).build(); // 未登录 } User user = userService.findById(userId); return ResponseEntity.ok(user); } @PostMapping("/logout") public ResponseEntity<Void> logout(HttpSession session) { session.invalidate(); // 使 Session 失效,Redis 中对应 key 被删除 return ResponseEntity.ok().build(); } }注意:
session.invalidate()不会立即删除 Redis 中的 key,而是标记为过期,Redis 在下次访问时清理。若需立即删除,可注入RedisOperations手动执行delete("spring:session:sessions:" + sessionId)。
4.2 Vue + Spring Security JWT 方案
步骤 1:JWT 工具类(Java)
@Component public class JwtUtil { private static final String SECRET = "your-256-bit-secret-key-here"; // 从 Vault 获取 private static final long EXPIRATION_TIME = 30 * 60 * 1000; // 30分钟 public String generateToken(UserDetails userDetails) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + EXPIRATION_TIME); return Jwts.builder() .setSubject(userDetails.getUsername()) .claim("user_id", ((User) userDetails).getId()) .claim("role", ((User) userDetails).getRole()) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token); return true; } catch (Exception e) { return false; } } public String getUsernameFromToken(String token) { return Jwts.parser().setSigningKey(SECRET) .parseClaimsJws(token).getBody().getSubject(); } }步骤 2:Vue 登录与请求拦截
// api/auth.js export function login(data) { return axios.post('/api/auth/login', data) } // utils/request.js const service = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截:自动添加 token service.interceptors.request.use(config => { const token = localStorage.getItem('access_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, error => Promise.reject(error)) // 响应拦截:token 过期自动刷新 service.interceptors.response.use(response => response, error => { if (error.response?.status === 401 && error.response?.data?.code === 'TOKEN_EXPIRED') { // 调用刷新接口,成功后重发原请求 return refreshToken().then(newToken => { localStorage.setItem('access_token', newToken) error.config.headers.Authorization = `Bearer ${newToken}` return service(error.config) }) } return Promise.reject(error) })步骤 3:Spring Security 配置
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() // JWT 场景下禁用 CSRF(因无 Cookie) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态 .and() .authorizeHttpRequests(authz -> authz .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/user/**").authenticated() .anyRequest().permitAll() ) .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } } // 自定义过滤器:解析 JWT 并设置 SecurityContext public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = resolveToken(request); if (token != null && jwtUtil.validateToken(token)) { String username = jwtUtil.getUsernameFromToken(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (bearerToken != null && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } }5. 常见问题与排查技巧实录:线上故障的 12 个真实现场
5.1 Cookie 相关故障速查表
| 现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 登录后刷新页面,提示未登录 | SameSite配置错误 | Chrome DevTools → Application → Cookies,检查 Cookie 是否存在;Network → Headers → Request Headers,确认Cookie字段是否为空 | Spring Boot 配置server.servlet.session.cookie.same-site=Lax;若需跨站,设为None并启用Secure |
| 登录成功但后续接口 401 | Path不匹配 | 查看登录响应头Set-Cookie的Path值,对比请求 URL 路径 | 统一设为Path=/,或确保 API 路径前缀与 Cookie Path 一致(如 Cookie Path=/api,则请求必须是/api/user) |
| 移动端 App 登录失败 | HttpOnly阻止 JS 读取 | App 使用 WebView,JavaScript 无法获取 Cookie,导致无法提取 token | 改用Authorization: Bearer方案,或服务端在响应体中返回用户信息 |
| 多标签页登录态不同步 | Session ID 覆盖 | 两个标签页同时登录,后一个覆盖前一个的 Cookie | 前端登录成功后,主动刷新页面,或服务端生成新 Session ID(request.changeSessionId()) |
5.2 Session 故障深度排查
There is no session with id错误:
这不是代码 bug,而是 Redis 连接问题。检查点:- Redis 是否存活?
redis-cli -h 10.0.1.100 ping; - Spring Session 配置的 Redis 数据库是否正确?默认是
db=0,若业务也用 db=0,可能被误删; - Session 过期时间是否过短?
spring.session.timeout=1800单位是秒,不是毫秒; - 是否启用了
@EnableSpringHttpSession注解?遗漏此注解,Session 不走 Redis。
- Redis 是否存活?
Agent failed before reply: session file locked:
这是 PHP 的 Session 文件锁问题(非 Java),但原理相通。当一个请求长时间占用 Session(如上传大文件),其他请求会阻塞等待锁释放。解决方案:- 登录成功后立即
session_write_close()(PHP)或session.setAttribute()后调用session.flush()(Java); - 将耗时操作(如发邮件、写日志)放到异步线程,避免阻塞 Session。
- 登录成功后立即
5.3 Token 故障高频场景
| 错误信息 | 根本原因 | 关键证据 | 修复动作 |
|---|---|---|---|
token exchange failed: token endpoint returned status 403 Forbidden | JWT 密钥不匹配 | 用 jwt.io 解析 Token,查看 Header 中kid字段,对比服务端密钥配置 | 确认kid对应的密钥是否加载,检查 Vault 中密钥版本是否更新 |
sign-in could not be completed token exchange failed: error sending request | Token 签发方域名不可达 | 抓包看请求POST https://auth.example.com/oauth/token是否超时 | 检查 DNS 解析、网络策略(安全组/防火墙)、TLS 证书是否过期 |
unexpected status 502 Bad Gateway | 网关转发时 Token 被截断 | Nginx 日志中upstream sent too big header | 增加 Nginx 配置:proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; |
JWT signature does not match local signature | 时间偏差(Skew) | 服务器时间比 NTP 时间快/慢 > 60 秒 | sudo ntpdate -s time.nist.gov同步时间,或 JWT 解析时设置clockSkewSeconds(60) |
5.4 安全漏洞实战复盘:CNVD-2023-17316 的教训
这个 Nacos 默认密钥漏洞,本质是 JWT 的kid注入攻击:
- 漏洞原理:Nacos 1.4.3 之前版本,JWT Header 中
kid字段未校验,攻击者构造{"alg":"HS256","typ":"JWT","kid":"../../../etc/passwd"},服务端用该路径读取密钥文件,导致任意文件读取。 - 我们的修复动作:
- 立即升级 Nacos 至 2.2.0+;
- 所有自研 JWT 签名,禁用
kid字段,或严格白名单校验(只允许kid=rsa256、kid=aes128); - 增加 WAF 规则:拦截
kid字段含../、/etc/等敏感路径的请求。
最后分享一个小技巧:线上环境,永远用
curl -v模拟请求,比 Postman 更透明。比如调试登录:curl -v -X POST http://api.example.com/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123"}' \ -c cookies.txt # 保存 Cookie 到文件 curl -v -X GET http://api.example.com/user \ -b cookies.txt # 使用保存的 Cookie这样你能清晰看到每一行响应头,包括
Set-Cookie和Authorization,比 GUI 工具少一层抽象。