ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot前后端分离登录校验实战:JWT、Filter与Interceptor完整指南

Spring Boot前后端分离登录校验实战:JWT、Filter与Interceptor完整指南 前阵子帮一个朋友排查线上事故他们的管理后台被人用脚本批量调接口数据被扒了个干净。我上去一看登录功能确实做了登录成功之后前端也存了token可后端Controller和Service层一点校验都没有所谓登录校验只是前端路由里的跳转判断。这不是个例很多从单体项目转前后端分离的团队都会踩这个坑把登录校验当成前端的事后端接口直接裸奔在公网上。这篇文章我想把登录校验这条链路完整梳理一遍覆盖JWT令牌的设计与原理、Filter和Interceptor各管哪一段、token续签怎么做、登录信息更新后如何让旧token失效以及那些容易被忽视的安全漏洞。内容主要面向用Spring Boot做前后端分离项目、又被登录逻辑反复折磨的同学看完之后你至少能独立设计出一套能上生产的登录校验方案。1. 为什么说登录校验是前后端分离项目的第一道生死线1.1 从一次接口裸奔事故说起前面提到的那个事故根因说出来很心酸前端确实做了登录页、做了路由守卫用户没登录就跳转到登录页看起来一切正常。但后端的/api/user/list接口没有任何拦截任何人只要拿到接口地址和参数用Postman或者一段Python脚本就能直接请求。前端路由守卫只是用户体验层面的东西它不是安全边界。我见过不少项目的登录校验逻辑是这样的登录成功后后端返回一个随机字符串存到数据库前端每次请求把字符串带回来后端查库比对。这个方案在单体应用里完全够用但一旦涉及集群部署、第三方登录、多端同步session的同步和存储就变成了一根刺。后来大家开始用JWT把用户信息塞进一个自包含的令牌里服务端不用存session理论上无状态了看似一劳永逸。但JWT不是银弹如果你不了解它的边界落地时照样踩坑。1.2 会话方案演进从Session到Token再到JWT先把这个演进脉络理清楚后面做技术选型的时候才有依据。方案核心思路服务端压力典型问题Cookie Session用户ID存服务端内存/Redis客户端只存SessionId每个请求都要查会话状态内存或Redis开销大集群要共享Session移动端不好处理CSRF风险Token随机字符串登录后生成一串随机数存Redis返回给客户端每次请求查Redis本质上还是有状态存储成本仍在只是把会话从内存挪到了RedisJWT用户信息加密签名后直接放在令牌里服务端验签即可验签是CPU计算但不需要集中存储无法主动踢人续签麻烦密钥管理要小心这个表格是很多文章都会列的但我想额外强调一点JWT最大的价值不是无状态而是服务端不持有会话。它把会话的所有权完全交给了客户端代价就是你失去了对会话的控制权。用户被禁用、角色变更、强制下线这些需求在JWT体系里都变得比Session方案麻烦。所以在选型时先问自己一个问题我的业务需不需要立即让某个token失效如果需要JWT必须搭配Redis黑名单或版本号机制否则不要硬上。1.3 什么场景下别硬上JWT根据我自己的经验下面这三类场景谨慎使用JWT纯服务端渲染的传统单体应用直接用Spring Session Redis用户会话交给框架管理简单可靠没必要引入JWT的密钥管理成本。需要严格强制下线的金融/后台管理系统比如管理员被撤销权限、账号被异地登录踢下线这些场景要求实时生效纯JWT做不到必须配合Redis做可吊销设计。短生命周期、一次性凭证场景比如短信验证码、邮件找回密码链接用UUID或随机数就够了没必要上JWT。JWT更适合的其实是API服务、前后端分离项目、以及需要携带少量公开信息如用户ID、昵称频繁访问的移动端场景。理解了这个边界你再看后面的Filter和Interceptor的搭配思路会清晰很多。2. JWT令牌的核心原理与拆解2.1 三段式结构header.payload.signatureJWT长这样eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDAxIiwidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsImV4cCI6MTcxMDAwMDAwMH0.8fGxH9kYjHf2a5qXsL1N0cZfMVpIY8xQY9bOY8vIRLg用.拆开就是三段我用一个简单的小例子拆给你看。Header头部声明令牌类型和签名算法。Base64Url解码后长这样{ alg: HS256, typ: JWT }Payload负载真正要携带的业务数据。标准字段有sub主体、iss签发者、aud受众、exp过期时间、iat签发时间、jtiJWT ID你也可以自定义字段{ sub: 1001, username: zhangsan, role: admin, exp: 1710000000, iat: 1709996400 }Signature签名把前两段用算法计算出的签名结果用来防篡改。这里注意一个关键点——这段内容不是加密后的密文而是编码后的明文。也就是说任何人拿到JWT都可以直接Base64Url解码出header和payload看到里面的全部信息。真正被保护的只有签名段它保证的是内容没被篡改过而不是内容没人看得见。2.2 签名算法HS256与RS256到底怎么选签名算法的选择直接影响安全性和使用场景我挑两个最常用的详细说。HS256HMAC-SHA256对称密钥。签发的服务端和校验的服务端共同持有同一个密钥签名和验签用的是同一个secret。优点是计算快、实现简单缺点是密钥分发困难——如果你有多个微服务都要验签你必须把同一个密钥发给它们任何一个服务密钥泄露整个体系就崩了。RS256RSA-SHA256非对称密钥。私钥负责签发token公钥负责验签。签发方认证服务持有私钥验签方业务服务只持有公钥公钥泄露了也不影响安全性。这在微服务多个服务验签的场景下非常合适。缺点是非对称运算比HMAC慢不过对于一般业务量的登录校验性能完全不是瓶颈。我用jjwt库写的实际签发代码HS256版本大概长这样import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import javax.crypto.SecretKey; import java.util.Date; public class JwtUtil { // 生产环境密钥要从配置中心读取不要硬编码在代码里 private static final SecretKey KEY Keys.hmacShaKeyFor( 你的密钥至少32字节随机字符串.getBytes() ); private static final long EXPIRE_MILLIS 30 * 60 * 1000L; public static String createToken(String userId, String username, String role) { return Jwts.builder() .setSubject(userId) .claim(username, username) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_MILLIS)) .signWith(KEY, SignatureAlgorithm.HS256) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); } }如果你用的是jjwt 0.12.xAPI略有变化parserBuilder()变成了parser()SignatureAlgorithm枚举也废除了直接用Jwts.SIG.HS256指定算法。升级版本时这个坑很多人踩过。2.3 一个常被误解的点JWT是签名不是加密我在代码评审里经常要纠正一个认知JWT的核心机制是签名防篡改不是加密防偷看。哪怕你把payload里的手机号、身份证号写进去它也只是Base64Url编码任何人copy出token一解码就能看到明文。要真加密得用JWEJSON Web Encryption但那通常是另一个话题了99%的场景用不上。所以在设计payload时我给自己定的规矩是只放能公开的非敏感字段比如用户ID、昵称、头像地址、角色标识。手机号、邮箱、身份证、密码这些一律不塞进JWT即使用户ID这种标识理论上也不希望你被别人知道后用来伪造关联查询但相比之下风险可控。另外token里不要塞太多东西。JWT是每次请求都要携带的如果你把它放在Authorization请求头里payload越大每次请求的带宽开销就越大。我见过有人把一整份用户配置对象塞进去的那token膨胀到好几KB纯属给自己找麻烦。3. Filter与Interceptor的职责边界校验到底该放哪一层3.1 两者的出身不同执行时机也不同Filter和Interceptor看起来都能做登录校验但它们的出身和底层机制完全不同搞不清楚这个你会发现同样的代码在这层生效、在那层不生效。Filter是Servlet规范里的东西由Servlet容器Tomcat管理作用在Servlet之前和之后。它拦截的是请求到达Servlet之前所以你可以在里面做字符编码、CORS跨域、日志记录、JWT验签这种很管道的操作。Filter不关心你的Controller是谁它只面对HttpServletRequest和HttpServletResponse。Interceptor是Spring MVC框架的机制它在DispatcherServlet分发请求给具体HandlerMethod可以理解为Controller方法之前和之后执行。因为它属于Spring MVC层所以能够拿到HandlerMethod对象从而读取方法上的注解、类的元信息做更精细的权限控制。一个请求的执行链路是这样的请求进入 - FilterChain多个Filter依次执行 - DispatchServlet - HandlerInterceptor.preHandle() - Controller方法 - HandlerInterceptor.postHandle() - HandlerInterceptor.afterCompletion() - 响应返回客户端这个顺序决定了Filter比Interceptor更早执行。如果你的Filter里直接return了后面的Interceptor和Controller都不会执行。3.2 各自擅长干什么我把两者的能力边界整理成一张表你在做技术方案时直接对着选维度FilterInterceptor所属规范Servlet规范Spring MVC框架执行时机进入Servlet容器后请求到达DispatcherServlet之前DispatcherServlet分发请求之前/之后能拿到什么HttpServletRequest、HttpServletResponseHandlerMethod对象Controller方法元信息典型用途编码过滤、CORS、JWT验签、请求日志、黑白名单权限注解解析、参数校验、操作审计、Controller前后增强能否作用于静态资源能默认情况下也拦截静态资源取决于配置通常在静态资源映射之外能否读取请求体Body能但读取后要重写流否则Controller拿不到参数能通过流读取但同样要注意请求体只能读一次3.3 我的选型原则Filter管有没有效Interceptor管能不能用踩过几次坑之后我现在给自己的分工原则很明确Filter只做最底层的认证Authentication校验JWT签名是否合法、是否过期解析出用户ID后塞进请求上下文。这个阶段不关心你的角色是什么、有没有权限调这个接口只解决你是不是一个合法登录用户的问题。Interceptor做授权Authorization和业务前置处理从请求上下文里取用户信息配合Controller方法上的RequiresRole这类注解判断角色权限或者做参数包装、Controller执行后的统一处理。为什么JWT校验要放Filter而不是只放Interceptor因为我希望所有的请求包括那些没有对应Controller的静态资源、错误路径都能被认证层覆盖。Filter默认会拦截所有请求而Interceptor默认不拦截静态资源映射Spring Boot里/static/**这类路径如果你把JWT校验放在Interceptor很可能出现某个静态资源路径绕过校验的情况。反过来为什么权限判断要放Interceptor因为权限判断往往和方法上的注解有关只有Interceptor能拿到HandlerMethod你才能在代码里优雅地写RequireRole(admin)让框架统一处理。Filter拿到的是Servlet请求想解析方法注解非常别扭。4. 从零搭建一套登录校验链路登录发榜、请求验票、登出销毁4.1 引入依赖准备JWT工具我用的是Spring Boot 2.7 jjwt 0.11.5的组合这是目前社区最常用的一套JDK 8及以上都能用。先加依赖dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency注意一个细节jjwt-api是编译期依赖jjwt-impl和jjwt-jackson必须用runtime范围。有人把三个都写成compile也能跑但严格来说不符合规范而且可能把impl里的类暴露给业务代码不推荐。4.2 登录接口校验密码后签发token登录接口本身很简单但有几个点一定不能省验证码校验、密码错误次数限制、登录日志。验证码不仅在登录页防机器人还在后续的JWT体系里承担人机识别的作用后面我单独讲。登录成功后用前面写的JwtUtil.createToken生成token返回给前端。这里我额外做了一个实践把token的jti唯一ID同时存进Redisvalue是userId过期时间和token保持一致。这样做的目的是为了后面做主动下线和续签时有据可依。纯JWT无状态看起来很爽但真出了问题用户说你没让我登录但其实有人用他的token在刷接口你连最基本的会话审计都做不了。Service public class AuthService { public LoginResponse login(String username, String password, String captcha, String captchaKey) { // 1. 校验验证码验证码存在Redis中用完即删 String savedCaptcha redisTemplate.opsForValue().get(captcha: captchaKey); if (savedCaptcha null || !savedCaptcha.equalsIgnoreCase(captcha)) { throw new BizException(验证码错误或已过期); } redisTemplate.delete(captcha: captchaKey); // 2. 校验用户名密码这里要用BCrypt绝对不要用MD5 User user userMapper.selectByUsername(username); if (user null || !BCrypt.checkpw(password, user.getPassword())) { throw new BizException(用户名或密码错误); } // 3. 签发JWT同时把jti存入Redis String jti UUID.randomUUID().toString().replace(-, ); String token JwtUtil.createToken(jti, String.valueOf(user.getId()), user.getUsername(), user.getRole()); redisTemplate.opsForValue().set(login:jti: jti, String.valueOf(user.getId()), Duration.ofMillis(JwtUtil.getExpireMillis())); // 4. 返回token和用户基本信息 LoginResponse res new LoginResponse(); res.setToken(token); res.setUserInfo(buildUserInfo(user)); return res; } }4.3 自定义JwtAuthFilter统一验签入口Filter是认证的第一道关卡。我建议继承OncePerRequestFilter而不是直接实现Filter接口原因在于OncePerRequestFilter保证了一次请求只被过滤一次避免在转发、异步处理等场景下Filter被重复执行。Component public class JwtAuthFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 从Header取出token String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims JwtUtil.parseToken(token); // 解析成功把用户信息放入当前请求上下文 String userId claims.getSubject(); String username claims.get(username, String.class); String role claims.get(role, String.class); UserContext.set(userId, username, role); } catch (Exception e) { // 验签失败不设置上下文也不会直接在这里拦截 // 真正的拦截逻辑交给Interceptor判断因为有的接口是白名单 } } // Filter链继续往下走交给Spring MVC处理 filterChain.doFilter(request, response); } }这里有个设计细节容易被忽略Filter里我只负责解析成功则放行标记不负责解析失败则拦截。因为登录接口本身是不需要JWT的直接把非法token拦死在Filter里会导致登录接口也进不来。所以更稳妥的做法是Filter把解析结果放进上下文后续由Interceptor根据路径判断是否需要校验或者Controller方法上加NoAuth注解显式声明不需要登录。白名单路径在Filter之外做一次判断也可以但那样会重复维护一份路径列表我不推荐。4.4 自定义AuthInterceptor判断谁能进有了Filter解析出的用户上下文Interceptor的工作就轻松了。我关注的几件事判断handler是否是HandlerMethod不是就直接放行比如静态资源、错误页。从UserContext取当前用户为空则说明没带token或token无效返回401。检查方法上是否有RequireRole注解如果用户角色不匹配返回403。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 不是Controller方法放行比如静态资源、错误映射 if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } // 方法或类上标注了NoAuth跳过登录校验 if (handlerMethod.hasMethodAnnotation(NoAuth.class)) { return true; } // 从上下文取用户没取到说明之前Filter没解析出有效token UserContext.User currentUser UserContext.get(); if (currentUser null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或token已过期\}); return false; } // 权限校验优先看方法注解其次看类注解 RequireRole requireRole handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole null) { requireRole handlerMethod.getBeanType().getAnnotation(RequireRole.class); } if (requireRole ! null) { String[] requiredRoles requireRole.value(); if (!Arrays.asList(requiredRoles).contains(currentUser.getRole())) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\msg\:\无权限访问\}); return false; } } return true; } }注意Filter里不要把用户信息直接塞进request.setAttribute用ThreadLocal更顺手但千万要记得在请求结束后清理否则Tomcat线程池复用时会出现串号事故——下一个请求没登录却拿到了上一个用户的信息。4.5 注册Filter和Interceptor配置放行路径Filter和Interceptor的注册方式不同。Filter我建议用FilterRegistrationBean注册而不是直接加Component因为前者可以精确控制URL映射和顺序。Interceptor则在WebMvcConfigurer里注册。Configuration public class WebSecurityConfig implements WebMvcConfigurer { Bean public FilterRegistrationBeanJwtAuthFilter jwtAuthFilterRegistration(JwtAuthFilter filter) { FilterRegistrationBeanJwtAuthFilter registration new FilterRegistrationBean(); registration.setFilter(filter); registration.addUrlPatterns(/*); registration.setOrder(1); return registration; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/**) .excludePathPatterns( /api/auth/login, /api/auth/captcha, /api/auth/refresh, /static/** ); } }放行路径这块我要多说两句白名单越少越好。很多人习惯把所有/api/开头的接口都交给登录拦截器或JWT校验再把个别公开接口排除掉这个思路是对的。但要注意/static/**如果映射了前端打包产物那么理论上是可以放行的如果是API服务没有静态资源就别放行这个路径避免有人通过静态资源路径探测绕过。另外错误页面/error建议也放行否则未登录访问不存在路径时会返回一堆奇奇怪怪的错。4.6 用户上下文ThreadLocal的安全设计上面代码里用到的UserContext我通常这样设计public class UserContext { private static final ThreadLocalUser HOLDER new ThreadLocal(); public static void set(String userId, String username, String role) { User user new User(userId, username, role); HOLDER.set(user); } public static User get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }clear()的调用时机非常关键。Filter里通过chain.doFilter(request, response)让请求继续往下走最终afterCompletion阶段或finally块里必须执行UserContext.clear()。我一般把清理动作放在Filter的finally里这样即使Controller抛异常也能确保线程不残留用户信息。Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { // 解析token、设置UserContext ... } finally { UserContext.clear(); } }这里还有个隐藏的坑如果你在Filter里解析token后又调用了其他服务异步执行任务ThreadLocal的数据并不会传给子线程。需要传参的话要么显式传userId要么用TransmittableThreadLocal普通项目中显式传参就够用了别过度设计。5. Token续签与登录信息更新别让用户用着用着被踢下线5.1 固定过期时间的痛点很多团队第一次上线JWT登录token过期时间直接写了个30分钟。用户正在写一篇长表单、看一份长文档突然请求接口返回401前端把用户一脚踢回登录页用户一刷新刚才填的内容全没了。这是最经典的用户体验事故。问题的本质在于JWT是无状态的服务端没法感知用户还在活跃。Session方案里Session只要一直在被访问就会被续期用户不会觉得过期JWT里token就是一个死规则30分钟就是30分钟不管你怎么活跃它都会到期。要解决这个问题得在无状态和用户体验之间找一个平衡点。5.2 三种主流续签方案对比方案一Refresh Token双Token机制登录时同时发两个tokenAccess Token有效期短比如30分钟只用来访问业务接口。Refresh Token有效期长比如7天只用来换取新的Access Token。Access Token过期了前端拿着Refresh Token去调刷新接口后端验证Refresh Token有效后签发一个新的Access Token。Refresh Token也过期了才需要重新登录。这个方案的好处是Access Token泄露了影响窗口只有30分钟缺点是Refresh Token如果泄露等于拿到7天免密门票。所以我建议Refresh Token存到HttpOnly Cookie里而不是localStorage。方案二滑动过期Sliding Expiration仍然只用Access Token但每次请求时Redis里的login:jti:{jti}过期时间会被续期。比如30分钟不活跃才真正失效用户只要在30分钟内有任意一次请求token就永远续命下去。这个方案的优点是前端几乎不用改逻辑缺点是永不粘着下线——理论上一个人挂着不关页面token可以无限续期下去而且Redis的key会一直续期内存压力比方案一大。方案三Redis存储jti 手动续期这就是我在第4节里做的实践登录时把jti存进Redis每次请求到Interceptor时检查Redis里login:jti:{jti}是否还存在存在则更新过期时间。等于方案二的精细化版本。我把三种方案放到一起对比方案前端复杂度服务端存储用户体验安全性Refresh Token双token高要处理刷新逻辑低中高访问token暴露窗口短滑动过期低无感知中每个活跃用户一条Redis记录好中token长期有效但可主动删Redis jti手动续期低中好中高可控性更强我个人的选择是前端项目用方案一后端管理后台用方案三。原因很简单前端产品用户量大Refresh Token的短命访问token 长命刷新token可以显著缩小token泄露的损失范围而管理后台用户量小更看重随时可控Redis jti方案让我能一键踢人直接删掉Redis里的jtitoken立刻就废了。5.3 更新用户登录信息后如何重新生成JWT纯JWT有一个很难受的缺陷用户改了昵称、头像、角色旧token里的payload仍然是老数据。如果前端一直用旧token请求服务端读到的是旧昵称如果业务代码里有根据token里role判断管理员权限的逻辑那用户角色刚被降权旧token还能以管理员身份继续调用接口直到token过期。我的解决思路有两个按需选用思路ARedis里存用户状态版本号登录时生成一个user:state:{userId}value初始为1存进去。JWT的payload里带上这个版本号。每次请求时Interceptor对比token里的版本号和Redis里的当前版本号不一致就让前端刷新token或强制重新登录。用户改密码、被禁用、角色变更时把这个版本号加一所有旧token立刻失效。思路B直接用Redis jti标记状态用户在关键信息变更时把Redis里他的旧token jti全部删除再让前端重新登录或者调刷新接口。这个方案简单粗暴适合不允许信息变更后旧token存活的场景。我自己的实践是头像、昵称这种非敏感信息的变更不主动销毁token但要求前端在收到新用户信息后重新调用刷新token接口让新token携带最新字段角色、密码这种敏感变更直接走版本号方案强制失效。这样兼顾了体验和安全。5.4 登出与黑名单JWT无状态注销的正确姿势JWT无状态意味着服务端不知道某个token应该失效除非你主动记录。登出场景必须处理否则登出就是个摆设。我见过最粗暴的处理是前端把token删掉就算登出了这其实只处理了客户端token在服务端依然有效。如果攻击者提前偷到了token登出根本拦不住他。正确做法是维护一个JWT黑名单。登录时把jti存Redis登出时把jti加入黑名单key例如logout:jti:{jti}过期时间设置成原token的剩余有效期。每次JWT验签通过后还要多查一次黑名单黑名单里有这个jti就直接拒绝。public void logout(String token) { Claims claims JwtUtil.parseToken(token); String jti claims.getId(); long remain claims.getExpiration().getTime() - System.currentTimeMillis(); if (remain 0) { redisTemplate.opsForValue().set(logout:jti: jti, 1, Duration.ofMillis(remain)); } // 同时删除登录态的jti记录 redisTemplate.delete(login:jti: jti); }这样处理之后登出才真正做到了服务端层面的立即失效。6. JWT安全漏洞盘点那些容易踩进去的坑与防护手段6.1 算法混淆攻击这是JWT最经典的攻击方式之一。攻击者把token的header里alg字段改成none然后调库的时候如果解析器没有严格校验算法就会跳过签名验证直接接受payload。有的库甚至允许你提交一个alg: HS256的token但签名随便填只要服务端用的是RS256验签逻辑就有可能把用户控制的公钥当作HS256的对称密钥去验签。防护手段很简单服务端解析token时强制指定允许的算法白名单我直接在JwtParserBuilder里配置setSigningKey并固定算法。永远不要信任token里的alg字段解析时不能根据header的alg动态选择验签方式。升级到jjwt 0.12.x后官方已经把none算法从默认支持中禁掉了这本身就是一道防线。6.2 弱密钥爆破HS256是对称算法密钥如果太弱比如secret、123456这种攻击者拿到几个历史token后离线用字典碰撞签名就能爆破出密钥。爆破出密钥之后他可以伪造任意用户的token整个认证体系直接沦陷。防护手段密钥长度至少32字节且必须随机生成不要用固定短语。定期轮换密钥轮换时要考虑旧token兼容期一般我会做一个密钥版本管理新token用新密钥签发旧密钥保留一段时间用于验签过期后全部切换到新密钥。用RS256时私钥更要加强保护避免放在代码仓库里。6.3 Token泄露与存储位置这是前端侧最大的隐患。很多SPA项目习惯把token存到localStorage一旦这个页面被注入了一段XSS脚本脚本能直接读取localStorage.getItem(token)把token发到攻击者的服务器上。相比之下sessionStorage伴随标签页关闭而清空风险略小HttpOnly Cookie不能被document.cookie读取是最稳妥的存储位置但使用HttpOnly Cookie时要处理CSRF攻击的问题因为Cookie会自动随请求发送。我的建议是不要用localStorage存token改为HttpOnly Cookie CSRF Token的配合或者至少给token加一个指纹指纹指用户登录时浏览器的唯一标识绑定比如登录时返回一个随机指纹每次请求时校验token里的指纹是否与请求头携带的指纹一致这样就算token泄露离开当前浏览器环境也无法直接使用。6.4 过期校验缺失或exp设置不当有一种情况是解析JWT后忘了检查exp或者签发时把exp设置成了0。一些老版本的库在解析token时并不会主动校验过期时间需要开发者手动检查。如果你在Filter里只调了parseClaimsJws拿到Claims没检查getExpiration()是否大于当前时间那么一个早就过期的token依然能通过验签。稳妥的做法是在签发和解析两层都明确设置并检查过期时间同时还要检查iat签发时间不能在未来、nbf生效时间某些场景会用到。6.5 Payload敏感信息泄露前面说过JWT的payload是明文编码不是加密。如果你把手机号、身份证号、密码哈希放进去任何能拿到token的人都能看到。这里我再多补一句日志里也不要打印token。很多项目排问题时习惯把请求头打出来token直接进日志文件日志泄露就等同于token泄露这个坑比payload藏敏感信息更隐蔽。6.6 验证码在JWT体系里的位置最后说回验证码。登录接口的验证码不只是防机器人自动注册它在JWT体系里还承担一个职责防止撞库和暴力破解token签发接口。我在实际项目里会把验证码设计成登录前置必过的环节配合Redis做验证码一次性使用并且增加连续输错N次后必须等待冷却时间的规则。这一步不是在拦截JWT攻击而是在削减攻击者调用登录接口的频率。写到这里登录校验这条链路基本完整了。最后分享一点个人体会技术选型没有绝对的对错JWT也好、Filter也好、Interceptor也好关键是想清楚每一层到底在解决什么问题再动手写代码。我在实际项目里最常看到的翻车点不是某个组件不会用而是把前端跳转当成了登录校验把无状态当成了不存Redis。你把这套链路梳理清楚之后以后再遇到多端登录、token续签、权限升级这些需求心里就有底了。
返回列表