做到登录认证这一块的时候,我正好在重构手上的一个前后端分离项目。老项目用的还是Session那一套,用户在A服务登录了,请求打到B服务就查不到登录态。改了两周,踩了无数坑,我才真正理解课程里把JWT令牌和Filter放在一起讲的原因:这俩东西看着是一个生成令牌、一个拦截校验,合在一起才是完整的登录认证链路。这篇文章就是把我在实践中的理解、落地代码和踩坑记录整理出来,给同样在学Java Web后端、正在做登录认证的朋友做个参考。
如果你还没搞清楚Session和JWT的差别,或者写完Filter发现注入Redis是null、前端一直报跨域错误,这篇文章应该能帮上忙。我会先从“为什么不用Session”讲起,再拆JWT的生成与解析,然后是Filter拦截里最容易出问题的几个细节,最后补充一些生产环境才会遇到的问题,比如token刷新、主动失效和安全隐患。
1. 从Session到JWT:这一步到底绕开了哪些坑
1.1 面试常背的Session方案,在前后端分离下为什么不够用
传统的Session方案,大家应该都很熟:用户登录成功后,服务器在内存或者Redis里存一份用户信息,然后生成一个Session ID,通过Set-Cookie: JSESSIONID=xxx返回给浏览器。浏览器之后的每次请求都会自动带上这个Cookie,服务器拿着Session ID去查对应的用户信息。
这套机制在单体应用、前后端不分离的年代没有任何问题,但在现在的开发模式下,痛点一个比一个明显。
第一,App端没有Cookie机制。小程序、安卓、iOS这些客户端压根不是浏览器,Cookie的自动携带行为对它们并不友好。你让App开发者手动维护Session ID,还要处理过期刷新,纯属给自己找麻烦。
第二,分布式环境下Session不共享。一个请求先打到服务器A,登录态存在A的内存里,下一个请求被负载均衡转发到服务器B,B那边根本没有这个Session。用Spring Session把Session集中存到Redis能解决,但这是个额外的架构组件,也带来序列化、网络开销等一系列新问题。
第三,跨域场景下Cookie的处理非常棘手。前后端分离之后,前端页面在localhost:8080,后端接口在localhost:9090,浏览器发起跨域请求,Cookie默认不会携带,需要配置Access-Control-Allow-Credentials和withCredentials。一旦涉及SameSite策略,很多浏览器默认拦截跨站Cookie,排查起来头大。
所以你会发现,凡是做前后端分离的项目,但凡多部署几个节点,Session方案的维护成本就会指数级上升。JWT就是在这样的背景下成为登录认证的主流方案的。
1.2 无状态到底是省事还是添麻烦
JWT的思想其实非常直白:服务端不存登录态,把用户信息经过签名后生成一串令牌,返回给前端。前端后续请求把令牌放到请求头里,服务端只需要验证令牌的签名是否合法、是否过期,就知道请求来自谁。
你可以把它想象成游乐场的盖章通行证。游客买票进场时,工作人员在手背上盖一个荧光章,之后你去玩过山车、鬼屋,工作人员只需要扫一下章,就知道你是买过票的,不需要挨个翻台账核对。
这个方案天然适合分布式,因为校验逻辑只需要密钥,不需要共享Session存储。每个服务都能独立完成校验,微服务网关做统一鉴权也变得很自然。
但无状态不等于没有成本。服务端不存登录态,意味着服务端没法主动把某个token作废。用户改了密码,老token在有效期内照样能用;管理员封了某个账号,封禁操作并不能立刻让已经发出去的token失效。这些“后悔药”问题,后面我会单独讲怎么处理。
1.3 别把JWT当银弹:四个短板要提前知道
JWT虽然好用,但它不是万能的。在你决定把登录认证全部改成JWT之前,下面这几个短板最好先有数。
第一,Token没法主动失效。 这是无状态方案最大的痛点。用户点退出登录,前端把token删了,但token本身还是有效的,被别人截获了照样能访问接口。后面要讲的黑名单和版本号方案,本质上都是在JWT之外“补状态”。
第二,Payload只是Base64Url编码,不是加密。 任何拿到token的人都能直接解码看内容。你把手机号、身份证号写进payload,等于把这些敏感信息明文暴露给了客户端。Payload里只放非敏感的必要信息,比如用户ID、昵称、角色,密码之类的绝对不要放。
第三,Token体积偏大。 Header加Payload加签名,一个简单token大概300到500字节。放HTTP Header里还好,如果请求头里还带着其他业务数据,有些网关对请求头大小有上限,超过4KB或8KB就会出问题。权限多、角色多的时候,payload很容易膨胀。
第四,密钥管理不容小觑。 HS256算法是对称密钥,签发和校验用的同一个Secret。一旦Secret泄露,任何人都能伪造合法token,直接登入系统。生产环境要把密钥放到配置中心或环境变量里,代码仓库里不能出现明文密钥。
| 维度 | Session方案 | JWT方案 |
|---|---|---|
| 存储位置 | 服务端内存/Redis | 客户端持有 |
| 扩展性 | 分布式需集中存储 | 天然支持分布式 |
| 主动失效 | 删除Session即可 | 需要黑名单/版本号 |
| 跨端支持 | App端不友好 | 任意客户端 |
| 安全性 | CSRF风险较高 | Header携带可降低CSRF风险 |
| 性能开销 | 每次请求查存储 | 验签计算 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JWT令牌长什么样:从三段结构到手写解析
2.1 Header、Payload、Signature分别存什么
JWT由三个部分组成,中间用英文句点分隔。一个真实token长这样:
code复制eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDAxIiwidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxMDAwMDAwMH0.8Wfq3l9P8rKx5L9HcF4y6dDgF2yVbQ
第一部分是Header,声明签名算法和令牌类型。Base64Url解码后大概是这样的:
json复制{
"alg": "HS256",
"typ": "JWT"
}
第二部分是Payload,承载业务数据。它包含标准字段(Registered Claims)和自定义字段(Custom Claims)。标准字段有iss(签发者)、sub(面向的用户)、exp(过期时间)、iat(签发时间)、jti(令牌唯一ID)等;自定义字段就随便定义了,username、role之类的都可以放。
第三部分是Signature,签名。以HS256为例,签名的计算方式是:
code复制HMACSHA256(base64url(Header) + "." + base64url(Payload), secret)
把前两部分的字符串拼接起来,用密钥做HMAC-SHA256运算,得到的结果再Base64Url编码,就是签名。签名的作用是防止token被篡改,Payload在Base64Url编码下是明文可读的,用户可以直接解码修改用户名,但是改完之后没有密钥,无法算出新的合法签名。服务端校验签名不匹配,直接拒绝。
2.2 签名算法怎么选:HS256还是RS256
很多人写JWT的时候不关注签名算法,看到一个教程用HS256就照着敲。实际项目里HS256和RS256的区别值得认真选一下。
HS256是对称签名,服务端用同一个Secret既签发又校验。好处是简单,只要一个密钥;坏处是密钥必须保密,而且所有需要校验token的服务都必须共享这个密钥。微服务场景下,密钥分发范围越大,泄露风险越高。
RS256是非对称签名,用私钥签发、公钥校验。认证中心持有私钥,其他服务只拿到公钥,各自独立校验token,不需要共享密钥。某个服务被攻破,攻击者拿到公钥也伪造不了token。代价是计算开销更大、密钥对管理更复杂。
| 对比项 | HS256 | RS256 |
|---|---|---|
| 密钥类型 | 对称密钥 | 私钥签名、公钥验签 |
| 计算开销 | 小 | 大 |
| 适合场景 | 单体/内部服务 | 微服务、多个调用方独立校验 |
| 密钥泄露风险 | 共享范围大 | 公钥泄露无影响 |
| 实现复杂度 | 低 | 中 |
我的建议是:单体项目用HS256问题不大,毕竟没有跨服务共享密钥的场景;但如果是微服务架构,REST接口要被多个服务调用,直接用RS256,省得后面重构换算法。
2.3 用jjwt生成与解析的完整代码
Java生态里操作JWT,用得最多的就是jjwt库。以0.11.5版本为例,Maven依赖这样加:
xml复制<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
生成token的方法:
java复制public class JwtUtil {
private static final SecretKey KEY = Keys.hmacShaKeyFor(
"你的256位密钥字符串,至少32个字符".getBytes(StandardCharsets.UTF_8)
);
public static String createToken(Long userId, String username, String role) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("username", username)
.claim("role", role)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000))
.signWith(KEY, SignatureAlgorithm.HS256)
.compact();
}
public static Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(KEY)
.build()
.parseClaimsJws(token)
.getBody();
}
}
setSubject建议统一放用户ID,自定义信息放claim,过期时间通过setExpiration控制。解析token的时候,parseClaimsJws方法内部会自己完成签名校验和过期校验,签名不对、token过期都会抛异常。
实际调用的时候,要针对不同的异常类型做区分处理。比如ExpiredJwtException表示token过期,你可以提示前端重新登录;SignatureException表示签名非法,大概率是token被篡改或者来源异常,这种要重点记录日志。
java复制try {
Claims claims = JwtUtil.parseToken(token);
// 校验通过,继续业务
} catch (ExpiredJwtException e) {
// token过期,返回401,前端跳转登录页
} catch (SignatureException e) {
// 签名非法,记录安全日志
} catch (Exception e) {
// 其他异常,按通用错误处理
}
2.4 写Payload的四个经验
Payload里放什么,直接关系到安全和扩展性。我在项目里踩过几轮之后,总结了几个经验。
经验一:只放必要信息。 用户ID、用户名、角色,这些接口鉴权经常要用到的可以放。手机号、邮箱、头像地址这些业务字段,尽量不放。token在客户端手里,Base64Url解码毫无门槛,放敏感信息就等于裸奔。
经验二:sub统一放用户ID。 sub字段是JWT标准推荐的主题字段,用来标识token归属者。你如果自己定义了一个userId字段,和Spring Security之类框架的解析习惯会不一致。规规矩矩用sub,后续集成框架会少踩很多坑。
经验三:过期时间别设太长。 我见过有人把token有效期设成7天,甚至30天。有效期内token被截获,攻击者的操作窗口就很长。业务系统建议30分钟到2小时,配合refresh token刷新机制,体验和安全都能兼顾。
经验四:预留一个版本号字段。 在Payload里加一个ver或者jti,值是用户登录时生成的一个随机字符串,存在Redis里。下次校验token的时候,比对Redis中的版本号。用户改密码、被踢下线时,把Redis里的版本号换掉,老token立刻失效。这就是后面要讲的主动失效方案的基础。
3. Filter拦截登录校验:从放行规则到用户信息传递
3.1 为什么选Filter而不选Interceptor
登录认证的核心环节不是生成token,而是怎么拦截请求、校验token。Java Web里可用的拦截机制有好几个,Servlet的Filter、SpringMVC的Interceptor、Spring AOP,都能达到目的。实际项目里,登录认证这块我优先选Filter。
原因在于Filter处于Servlet容器这一层,执行时机比Interceptor和AOP都早。请求到达Tomcat之后,会先经过FilterChain,然后才进入DispatcherServlet、再走到Interceptor。也就是说,Filter能拦截的资源范围最广,包括静态资源、Servlet层面的请求,甚至一些Interceptor根本处理不到的情况。
还有一点,你在Spring Security中接触的UsernamePasswordAuthenticationFilter、OncePerRequestFilter,本质上也是一堆Filter。Filter是Servlet规范的标准机制,不管底层的Web框架怎么变,Filter都存在,具备天然的通用性。
Interceptor更偏向于SpringMVC体系内的业务拦截,适合做权限校验、日志记录这类和Controller交互紧密的事。但如果你做的是网关统一鉴权、静态资源保护、或者和第三方Servlet集成,Filter是更可靠的选择。
3.2 登录放行名单与Token获取的细节设计
一个完整的登录认证Filter,第一步不是校验token,而是先判断这个请求需不需要校验。登录接口本身肯定是放行的,你不可能让人没登录就叫你去登录。常见的白名单包括:
- 登录接口:
/api/user/login - 注册接口:
/api/user/register - 图片验证码、短信验证码接口
- Swagger/Knife4j文档地址:
/doc.html、/webjars/** - 静态资源:
/favicon.ico、前端页面入口
白名单的匹配要注意写法。我见过有人用request.getRequestURI().equals("/api/user/login")做精确匹配,结果前端传了个登录接口的路径参数,直接就404了。建议用startsWith做前缀匹配,或者用Spring的PathMatcher做Ant风格匹配。
java复制private static final List<String> WHITE_LIST = Arrays.asList(
"/api/user/login",
"/api/user/register",
"/api/user/captcha",
"/doc.html"
);
if (WHITE_LIST.stream().anyMatch(uri::startsWith)) {
filterChain.doFilter(request, response);
return;
}
Token从哪拿也是个细节。最规范的做法是放在请求头的Authorization字段里,按OAuth2的惯例以`Bearer 开头:
code复制Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
服务端取token的时候,先判断Header是否存在,再判断是不是以Bearer 开头,最后截取有效部分。有的人会把token直接放到URL参数上,比如/api/user/info?token=xxx。这种方式有个坏处:URL会被Web服务器记录到访问日志里,token跟着泄漏,而且很多代理服务器、浏览器插件也会抓取URL,极不安全。不要用。
java复制String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
// 认证失败处理
return;
}
String token = authHeader.substring(7);
3.3 ThreadLocal传递当前用户:代码与线程安全问题
Filter校验token通过后,拿到的Claims里带着用户ID、用户名等信息。这些信息后续的Controller和Service都要用,总不能每个接口都让前端把token解一遍再传一次。常规做法是用ThreadLocal把当前登录用户放进去。
ThreadLocal的原理不复杂:它为每个线程维护一个独立的变量副本。Java Web里一次请求从头到尾通常由同一个线程处理,在Filter里把用户信息塞进ThreadLocal,Controller和Service里就能直接取出来。
java复制public class UserHolder {
private static final ThreadLocal<LoginUser> TL = new ThreadLocal<>();
public static void set(LoginUser loginUser) {
TL.set(loginUser);
}
public static LoginUser get() {
return TL.get();
}
public static void clear() {
TL.remove();
}
}
Controller里直接取:
java复制@GetMapping("/user/info")
public Result<UserInfo> getInfo() {
LoginUser loginUser = UserHolder.get();
// 根据 loginUser.getUserId() 查业务数据
return Result.success(info);
}
这里有一个必须养成的习惯:Filter执行完业务链路后,在finally块里调用UserHolder.clear()。Tomcat的线程是池化复用的,这个线程处理完你的请求后不会销毁,而是回到线程池等待下一个请求。如果ThreadLocal里的用户信息不清除,下一个请求复用这个线程时,就可能读到上一个用户的数据,轻则数据错乱,重则越权访问,属于严重的安全事故。
java复制try {
Claims claims = JwtUtil.parseToken(token);
UserHolder.set(LoginUser.from(claims));
filterChain.doFilter(request, response);
} finally {
UserHolder.clear();
}
3.4 Filter执行顺序与FilterChain
一个项目里不可能只有一个Filter。跨域CORS要一个Filter,登录认证要一个Filter,XSS过滤要一个Filter,接口耗时统计可能还要一个Filter。多个Filter的执行顺序和chain.doFilter的调用时机,决定了一次请求的完整处理路径。
Filter的执行顺序由注册顺序决定,前面的Filter调用filterChain.doFilter,请求才继续向下传递,经过后续的Filter,最终到达DispatcherServlet。响应返回时,方向反过来,后面的Filter先处理响应,前面的Filter后处理。这种结构很像几个嵌套的箱子,进的时候一层一层往里走,出的时候一层一层往外走。
登录认证和跨域这两个Filter的先后顺序特别关键。跨域Filter必须放在登录认证Filter的前面,因为浏览器发出跨域请求时,真实请求之前会有一个OPTIONS预检请求。预检请求不带业务数据也不带Authorization头,如果认证Filter先执行,OPTIONS请求直接被拦截,返回401,跨域就失败了。后面专门讲这个坑。
用@Component声明Filter并配合@Order注解就能控制执行顺序,@Order(1)最先执行,数字越小优先级越高。
java复制@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsFilter extends OncePerRequestFilter {
// 处理跨域
}
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class JwtAuthFilter extends OncePerRequestFilter {
// 登录认证
}
4. 实战中必踩的四个坑:跨域、Redis注入为null、异常捕获与过滤器顺序
4.1 过滤器里注入Redis为null:生命周期差异与解决方案
这个坑是我在做“Redis记录token状态”的时候踩的。Filter里声明了@Autowired StringRedisTemplate stringRedisTemplate,一启动就发现是null,空指针异常直接甩脸。
问题的根源在于Filter的创建方式和Spring Bean不一样。如果你用@WebFilter注解加@ServletComponentScan扫描的方式声明Filter,这个Filter是由Servlet容器(Tomcat)创建和管理的,不在Spring容器里。Spring根本不知道这个Filter的存在,自然也谈不上给它做依赖注入,@Autowired理所当然失效。
解决办法有三个。
方案一:把Filter声明为Spring Bean。 用@Component注解注册Filter,或者写一个FilterRegistrationBean手动注册。这样Filter由Spring容器管理,依赖注入就能正常工作。
java复制@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 注入正常,可以放心使用
}
}
选择OncePerRequestFilter而不是Filter,是因为一个请求在转发、异步处理后可能被Filter多次执行,OncePerRequestFilter保证了每个请求只执行一次,避免token重复校验。
方案二:用DelegatingFilterProxy。 Spring提供了一个代理器,把Servlet容器里的Filter转发给Spring容器中同名的Bean执行。这种方式适用于不想让Filter直接变成Spring Bean的场景,比如某些第三方框架自带的Filter。
方案三:手动从ApplicationContext获取Bean。 在Filter里持有ApplicationContext,需要哪个Bean就手动getBean。这种方式比较笨,但能解决历史遗留问题。
注意:面试里常考“Filter和Spring容器的加载顺序”,其实就是这个知识点。Filter的
init方法先于Spring容器完成对管理Bean的初始化,所以Filter直接getBean时机不对也会空指针。
4.2 预检请求OPTIONS被认证拦截:跨域的前端报错
前后端分离项目中,前端浏览器和后端接口域名不同,必然触发跨域。当请求头包含Authorization等自定义Header,或者请求方法是PUT、DELETE时,浏览器会先发一个OPTIONS请求做预检。如果服务端对该预检请求返回成功响应,并声明允许跨域,浏览器才会发真实请求。
问题在于,OPTIONS预检请求不携带业务数据,也没有Authorization头。如果认证Filter对所有请求一视同仁,拿到空的Authorization头就判定未登录,返回401,浏览器看到的跨域请求就全部失败了。
解决办法是在认证Filter里放行OPTIONS请求:
java复制if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
filterChain.doFilter(request, response);
return;
}
同时需要一个跨域Filter或者Spring MVC的CorsFilter来处理允许的源、请求头和方法。比较省事的做法是使用Spring提供的CorsFilter注册Bean,统一管理CORS策略。
java复制@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
强调一遍顺序:跨域Filter必须放在认证Filter前面。否则即使你放行了OPTIONS,其他Filter先拦截也白搭。
4.3 Filter里的异常为什么@RestControllerAdvice接不住
Spring MVC的@RestControllerAdvice全局异常处理器,作用范围是Controller层的方法调用。它通过AOP机制拦截Controller抛出的异常,转换成统一的错误响应。
但是Filter的执行时机在进入SpringMVC之前。Filter抛出的异常,比如token解析失败、Redis连接失败,根本进不到Controller的调用链中,@RestControllerAdvice自然接不住。异常会一路抛到Servlet容器,容器返回一个默认的错误页,前端拿到的往往是HTML格式的404或者500,接口的JSON结构完全被打乱。
解决思路是在Filter内部自己捕获异常,手动写出JSON格式的错误响应。
java复制try {
Claims claims = JwtUtil.parseToken(token);
UserHolder.set(LoginUser.from(claims));
filterChain.doFilter(request, response);
} catch (ExpiredJwtException e) {
writeErrorResponse(response, 401, "登录已过期,请重新登录");
} catch (Exception e) {
writeErrorResponse(response, 401, "认证失败");
} finally {
UserHolder.clear();
}
private void writeErrorResponse(HttpServletResponse response, int code, String message) throws IOException {
response.setStatus(code);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":" + code + ",\"message\":\"" + message + "\"}");
}
如果你用的是Spring Security,情况会好一些,它有专门的AuthenticationEntryPoint统一处理认证异常。但自己实现Filter的时候,一定要记得Filter层的异常和Controller层的异常不在同一个处理体系里。
4.4 多个Filter的注册顺序问题
Filter顺序的坑比想象中隐蔽。用@Component声明Filter时,Spring对多个同类型Bean的顺序并不是完全确定的,你要是依赖类的字母顺序来保证执行顺序,早晚会被坑一次。
实际操作中,我建议直接用FilterRegistrationBean显式注册,精确控制顺序:
java复制@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<CorsFilter> corsFilterRegistration() {
FilterRegistrationBean<CorsFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new CorsFilter());
registration.addUrlPatterns("/*");
registration.setOrder(1);
return registration;
}
@Bean
public FilterRegistrationBean<JwtAuthFilter> jwtFilterRegistration() {
FilterRegistrationBean<JwtAuthFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new JwtAuthFilter());
registration.addUrlPatterns("/*");
registration.setOrder(2);
return registration;
}
}
@WebFilter注解的方式虽然方便,但没有原生指定顺序的属性,规范里也没规定多个Filter按什么顺序排列,不同容器的行为可能不一致。生产环境为了可维护性和确定性,宁可用注册Bean多写几行代码,也别指望玄学顺序。
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| Filter里注入Redis为null | Filter由Servlet容器创建,不在Spring容器 | 改用@Component注册Filter |
| 跨域请求报401 | OPTIONS预检被认证Filter拦截 | 放行OPTIONS,CorsFilter放前面 |
| 全局异常处理器不生效 | 异常发生在DispatcherServlet之前 | Filter内try-catch手动写响应 |
| Filter执行顺序不确定 | @WebFilter或@Component顺序不稳定 | 用FilterRegistrationBean显式控制 |
5. 登录认证再进一步:token刷新、主动失效与安全边界
5.1 无状态令牌的“后悔药”:黑名单与版本号
前面说过,JWT最大的软肋是服务端不能主动让token失效。但在真实项目里,“改密码后让其他设备下线”、“管理员踢人”、“封禁账号”这些需求是硬性的,无状态令牌不能原地躺平,得额外补状态。
方案一:黑名单。 用一个Redis集合保存被吊销的token,token被吊销时把它的jti(或者token本身)加入黑名单,设置过期时间等于token的剩余有效期。每次校验token时,先去Redis查一下黑名单,命中就直接拒绝。缺点是每次请求多一次Redis查询,黑名单越来越大,需要定期清理。
方案二:版本号。 在用户表或者Redis里维护一个tokenVersion。签发token时把版本号写进payload。校验token时,把payload里的版本号和Redis里存的版本号比对,不一致就拒绝。用户改密码、被踢下线时,只需要把这个版本号加1,整个用户以前签发的token全部失效。
java复制// 登录成功
String version = UUID.randomUUID().toString();
stringRedisTemplate.opsForValue().set("login:token:version:" + userId, version, 7, TimeUnit.DAYS);
// 生成token时带上version
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("ver", version)
.setExpiration(...)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// 校验token时
Claims claims = JwtUtil.parseToken(token);
String currentVersion = stringRedisTemplate.opsForValue().get("login:token:version:" + claims.getSubject());
if (!claims.get("ver").equals(currentVersion)) {
throw new TokenInvalidException("token已失效");
}
这种方案比纯黑名单更简洁,一次登录一个版本号,不管这个用户签发过多少个token,版本号一变全部失效。
5.2 双token方案:accessToken加refreshToken
把token的有效期设得很短,比如30分钟,能显著降低token泄露的风险。但有效期设得太短,用户体验就很差,用户在系统里填了一半表单,token过期了,刷新页面直接跳登录页,很让人抓狂。
生产环境更通用的做法是双token:一个有效期短的accessToken负责正常业务请求,一个有效期长的refreshToken负责续签。
设计思路是这样的:
accessToken有效期30分钟,正常用户使用这个token访问接口。refreshToken有效期7天,只能访问一个特定的刷新接口。- 前端axios封装一个响应拦截器,当收到401错误码时,不直接跳登录,而是先拿着refreshToken去请求
/api/user/refresh,拿到新的accessToken,然后自动重放刚才失败的请求。 - refreshToken也过期了,才真正跳转登录页。
这个方案兼顾了安全性和体验。即使accessToken被截获,攻击者能操作的时间窗口只有30分钟,而且这个token通常没有权限做敏感操作。refreshToken虽然有效期长,但只能用来换accessToken,不能直接访问业务接口,风险面小很多。
实现refreshToken时要注意:刷新接口也要做一次认证,校验refreshToken的合法性,同时可以判断版本号,做到刷新即失效,每次刷新后旧的refreshToken立即作废,防止refreshToken被重放。
5.3 JWT不是万能安全:XSS、CSRF与传输安全
搞定了功能,还得聊聊安全。JWT在安全层面不能一劳永逸,它有自己的边界。
先看XSS攻击。 常见的做法是把token存在localStorage里。这样做的隐患是:localStorage对JavaScript完全透明,页面里任何一个XSS漏洞被利用,攻击者都能通过脚本直接读取token,然后冒充用户调接口。相对安全一点的做法是把token放在HttpOnly的Cookie里,JavaScript读不到,XSS攻击就偷不走token。但是Cookie方案又引入了CSRF问题——浏览器会自动携带Cookie,攻击者可以诱导用户发起伪造请求。
再看CSRF攻击。 如果你坚持用Authorization头带token,那么CSRF攻击的难度会大大增加,因为攻击者无法跨域设置请求头。配合CORS策略严格限制允许的来源,CSRF的威胁基本可控。这是JWT在前后端分离架构中的额外好处。
最后但同样重要的是传输安全。JWT这种明文编码的令牌,无论如何都要走HTTPS,不能在HTTP上裸奔。用抓包工具在HTTP环境下抓一个请求,token直接就暴露了,一切加密签名全部白搭。
登录接口还要考虑防爆破。可以结合Redis实现登录失败次数限制:连续输错5次密码,锁定账号15分钟;连续错误次数太多,要求输入验证码人机校验。这块和JWT本身无关,但属于登录认证体系不可分割的一部分,很容易被忽略。
最后说一点个人体会。JWT和Filter这套组合,学的时候觉得无非就是一个工具加一个拦截器,真正落地的时候才发现处处是细节。Filter的执行时机、Spring容器生命周期、跨域预检请求、异常处理边界,任何一个环节没想清楚,线上就会出幺蛾子。如果你正在折腾登录认证,建议先按文章里的思路把Filter的注册方式统一成FilterRegistrationBean,把token放Header并新增refreshToken续签机制,再去处理Redis版本号这些进阶需求。这套链路完整跑通之后,Java Web后端的登录认证这块,基本就稳了。
