1. 从会话到令牌:认证技术的演进之路
记得我刚入行做Java开发那会儿,处理用户登录最常用的还是传统的Cookie-Session方案。每次用户登录后,服务端都会创建一个Session对象,把用户信息存进去,然后通过Cookie把Session ID传给浏览器。这种方案在单体应用时代确实简单好用,但随着业务发展,问题也逐渐暴露出来。
最让我头疼的是集群环境下的Session共享问题。有一次公司业务量激增,需要部署多台服务器做负载均衡。结果发现用户登录后,请求被分配到不同服务器时,Session就失效了。当时我们尝试了各种方案,比如Session复制、Session粘滞,最后用了Redis集中存储Session才解决问题。这让我开始思考:有没有更优雅的解决方案?
2015年左右,我第一次接触到JWT(JSON Web Token)。当时一个做Node.js的朋友向我炫耀他们如何用几行代码就实现了分布式认证,让我这个Java程序员既羡慕又怀疑。直到后来在Spring Boot项目中实际应用JWT,才发现这确实是认证技术的一次重要演进。
2. 传统Cookie-Session的痛点分析
2.1 状态保持的代价
Cookie-Session机制本质上是一种有状态的认证方式。服务端需要维护每个用户的会话状态,这在用户量不大时没问题,但当用户量达到百万级时,服务端的内存压力就会非常大。我曾经维护过一个日活百万的系统,Session存储就占用了近20G内存。
// 传统的Session使用方式 @GetMapping("/profile") public String profile(HttpSession session) { User user = (User) session.getAttribute("currentUser"); return "Hello, " + user.getName(); }2.2 集群环境下的挑战
在微服务架构中,服务实例通常是水平扩展的。使用Session会遇到著名的"Session黏着"问题。虽然可以通过Session复制或集中存储解决,但都增加了系统复杂度。我们曾经因为Session同步不及时导致用户频繁掉线,被客户投诉了好几次。
2.3 移动端适配困难
随着移动互联网兴起,我们的API不仅要服务Web端,还要服务iOS和Android客户端。但移动端对Cookie的支持不完善,很多客户端需要手动处理Cookie,非常容易出错。有一次因为Cookie域名设置问题,导致APP登录状态无法保持,紧急加班到凌晨才修复。
3. JWT令牌技术的崛起
3.1 什么是JWT?
JWT是一种开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息。一个JWT令牌看起来是这样的:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c它由三部分组成,用点号分隔:
- Header:描述令牌类型和签名算法
- Payload:包含用户信息和其他元数据
- Signature:用于验证令牌完整性的签名
3.2 JWT的核心优势
无状态性:服务端不需要存储会话信息,减轻了服务器压力。我在一个高并发项目中改用JWT后,服务器内存使用直接下降了40%。
跨域支持:完美适配移动端和前后端分离架构。现在我们的API可以同时服务Web、iOS、Android和小程序,维护成本大大降低。
安全性:通过数字签名防止篡改。虽然JWT内容可以被解码查看,但如果没有密钥就无法伪造。不过要注意,敏感信息不应该放在JWT中,因为Payload只是Base64编码,并非加密。
4. Spring Boot中的JWT实战
4.1 引入依赖
首先添加JJWT依赖到pom.xml:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>4.2 JWT工具类
我习惯封装一个JWT工具类,包含生成和解析令牌的方法:
public class JwtUtils { private static final String SECRET_KEY = "your-256-bit-secret"; private static final long EXPIRATION_TIME = 86400000; // 24小时 public static String generateToken(Map<String, Object> claims) { return Jwts.builder() .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); } }4.3 登录接口实现
在登录接口中生成并返回JWT令牌:
@PostMapping("/login") public Result login(@RequestBody LoginRequest request) { User user = userService.authenticate(request); Map<String, Object> claims = new HashMap<>(); claims.put("userId", user.getId()); claims.put("username", user.getUsername()); claims.put("roles", user.getRoles()); String token = JwtUtils.generateToken(claims); return Result.success(token); }5. 认证拦截的实现选择
在Spring Boot中,我们通常有两种方式实现请求拦截认证:过滤器和拦截器。
5.1 过滤器(Filter)方案
过滤器是Java EE标准,可以拦截所有请求。我一般会创建一个登录校验过滤器:
@WebFilter("/*") public class JwtFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; String uri = request.getRequestURI(); if (uri.contains("/login")) { chain.doFilter(request, response); return; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { sendError(response, "缺少有效令牌"); return; } try { Claims claims = JwtUtils.parseToken(token.substring(7)); request.setAttribute("userClaims", claims); chain.doFilter(request, response); } catch (Exception e) { sendError(response, "令牌无效或已过期"); } } private void sendError(HttpServletResponse response, String message) throws IOException { response.setContentType("application/json"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"error\":\"" + message + "\"}"); } }记得在启动类加上@ServletComponentScan注解启用过滤器。
5.2 拦截器(Interceptor)方案
拦截器是Spring MVC提供的,配置更灵活:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; if (handlerMethod.hasMethodAnnotation(AllowAnonymous.class)) { return true; } String token = request.getHeader("Authorization"); // 校验逻辑与过滤器类似 // ... return true; } }配置拦截器:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JwtInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/login"); } }5.3 过滤器 vs 拦截器如何选型
根据我的经验,选择主要考虑以下几点:
- 作用范围:过滤器可以拦截所有请求(包括静态资源),拦截器只能拦截Controller请求
- 执行顺序:过滤器在拦截器之前执行
- 依赖注入:拦截器可以使用Spring的依赖注入,过滤器默认不行(需要通过FilterRegistrationBean配置)
- 灵活性:拦截器可以精确控制拦截哪些URL,排除哪些URL
我现在的做法是:简单的认证校验用过滤器,复杂的权限控制用拦截器。对于纯API项目,直接用拦截器更合适。
6. 实战中的经验与坑点
6.1 令牌刷新机制
JWT一旦签发,在过期前无法撤销。为了解决这个问题,我通常采用短期令牌+刷新令牌的方案:
@PostMapping("/refresh") public Result refreshToken(@RequestHeader("Authorization") String refreshToken) { Claims claims = JwtUtils.parseToken(refreshToken); if (!"refresh".equals(claims.get("type"))) { throw new InvalidTokenException("非法的刷新令牌"); } String newToken = JwtUtils.generateToken(claims); return Result.success(newToken); }6.2 安全性最佳实践
- 一定要使用HTTPS传输令牌
- 令牌过期时间不宜过长(我一般设置访问令牌2小时,刷新令牌7天)
- 敏感操作需要二次认证
- 考虑加入IP绑定或设备指纹等防篡改措施
6.3 性能优化
JWT的一个缺点是令牌体积较大,每次请求都要携带。对于内网高并发场景,可以考虑缩短Header中的Authorization字段:
// 生成简化的token ID String tokenId = UUID.randomUUID().toString(); redisTemplate.opsForValue().set(tokenId, userId, 2, TimeUnit.HOURS); return tokenId;7. 技术选型建议
经过多个项目的实践,我总结出以下选型建议:
- 传统Web应用:如果是有状态的Web应用(比如需要服务端渲染),Cookie-Session仍然是不错的选择
- 前后端分离/移动端:JWT是更好的选择,特别是需要支持多端登录的场景
- 高安全要求系统:可以考虑OAuth2.0或结合JWT的增强方案
- 内部微服务:可以考虑更轻量的方案,比如基于HMAC的签名认证
最近我在一个新项目中尝试了Spring Security + JWT的组合,发现它提供了更完善的认证授权解决方案,特别是对于复杂的权限系统。不过学习曲线比较陡,对于简单项目可能有点重。