SpringBoot整合SpringSecurity与JWT实现无状态权限认证实战指南
2026/9/9 4:24:46 网站建设 项目流程

1. 为什么要用SpringSecurity+JWT做权限认证

如果你做过几年Java后端,一定遇到过这样的场景:项目里有一个后台管理系统,用户登录之后要区分管理员、运营、普通用户,不同角色能访问的接口不一样。早期做法是Session+拦截器,登录成功往session里塞一个userId,接口前加个拦截器判断一下是否登录。小项目没问题,但一旦前后端分离、接口要被多端调用,或者要做分布式部署,session那套就开始难受了。

首先得说清楚SpringSecurity和JWT分别解决什么问题。SpringSecurity是Spring生态里一个成熟的安全框架,负责“认证”和“授权”这两件事。认证就是你是谁,授权就是你能干什么。它默认提供了表单登录、Session管理、CSRF防护、密码加密等一整套安全机制,但默认行为是基于Session的,前后端分离场景下需要改造。JWT(JSON Web Token)则是一种无状态的令牌方案,服务端不保存登录状态,用户登录成功后发一个签过名的token,后续每次请求把token带上,服务端验签通过就知道是谁了。

把两者结合起来,标准的做法是:SpringSecurity负责整体安全框架和过滤器链路,JWT负责生成和校验用户身份令牌,在SpringSecurity的过滤器链里插入一个JWT过滤器,把token解析出来的用户信息交给SecurityContext,让框架继续走它自己的授权逻辑。这套组合拳打出来,既保留了SpringSecurity的优点——权限模型、方法级注解、密码加密、过滤器链,又解决了Session在前后端分离场景下的跨域、扩展问题。

这套方案适合谁?适合前端用Vue/React等SPA框架、后端用SpringBoot做接口服务的团队,也适合你正在开发的小型系统但已经预料到将来要横向扩展的场景。接下来我会按实际开发顺序,把SpringBoot+SpringSecurity+JWT的完整搭建过程、关键代码、踩坑点位全部过一遍。

2. 整体设计思路与方案选型拆解

2.1 Session认证与JWT认证的核心区别

先花一点时间讲清楚为什么选JWT,不然很多新手做完了也不知道自己在干嘛。传统Session认证的流程是:用户登录成功后,服务端创建一条Session记录,把sessionId通过Cookie返回给浏览器,浏览器后续请求自动带上Cookie,服务端每次查Session表确认用户身份。

这个流程在单体应用里没毛病,但前后端分离后问题很突出:前端项目可能跑在8080端口,后端接口跑在9090端口,跨域请求时Cookie的携带会变得复杂,而且一旦做负载均衡,Session同步就成了麻烦。当然有SpringSession + Redis这种方案可以解决,但引入中间件意味着增加维护成本和学习成本。

JWT的思路完全不同。登录成功后,服务端把用户ID、角色、过期时间等信息用密钥签名生成一段唯一的字符串token返回给前端。前端把它存在localStorage里,每次请求塞到Authorization请求头。服务端拿到token后验签,如果签名合法且没过期,就认为请求来自这个用户。

一个很重要的区别要提一下:Session是“服务端状态”,你可以随时把某个用户踢下线;JWT是“客户端状态”,只要token没过期,服务端无法主动让它失效。所以JWT方案里,token有效期不能设置太长,这就引出了后面要讲的续签方案。

2.2 SpringSecurity的过滤器链机制

SpringSecurity能如此灵活,核心原因是它整个认证授权过程是由一条过滤器链完成的。请求进来后,依次经过多个过滤器:SecurityContextPersistenceFilter、UsernamePasswordAuthenticationFilter、FilterSecurityInterceptor等。每个过滤器只做一件事,过滤器的顺序是固定的,但你可以把自己写的过滤器插到指定位置。

我们需要理解这条链的几个关键节点:

  • 请求进来,SecurityContextPersistenceFilter从Session或请求头里恢复SecurityContext,把用户认证信息放到SecurityContextHolder中。
  • 如果我们插入了JWT认证过滤器,它负责在UsernamePasswordAuthenticationFilter之前解析token,把用户信息构建成一个Authentication对象放进去。
  • 过滤器链的最后一环是FilterSecurityInterceptor,它根据你配置的URL规则或方法注解,决定当前用户能不能访问某个资源。

用生活中的话类比:过滤器链就像一个机场安检流程。你走到值机柜台(登录接口),出示证件(账号密码),工作人员确认身份后给你登机牌(JWT)。每个登机口(接口)前都有安检员(过滤器),你出示登机牌(携带token),安检员核验你的登机牌真伪(验签),再根据登机牌上的舱位等级(角色权限)决定你能不能进这个舱位。

2.3 技术选型的几个关键决策点

在选型时有几个点值得说明,这些也是我在实际开发中被问得最多的地方。

第一,不用SpringSecurity原生表单登录,而是写自定义登录接口。原生表单登录适合服务端渲染的老项目,前后端分离项目需要前端把用户名密码用JSON格式传过来,所以我们要自己写一个登录接口,手动调用AuthenticationManager进行认证。

第二,密码加密一定要用BCryptPasswordEncoder,不要用MD5。MD5是不可逆的,但彩虹表已经能破解常见弱密码。BCrypt是加盐哈希,同一个密码每次加密结果不同,而且可以通过参数控制计算强度,暴力破解成本会高很多。

第三,Session策略设置为STATELESS。既然用了JWT,就不需要服务端创建Session了,设置SessionCreationPolicy.STATELESS可以避免SpringSecurity默认往Session里塞东西。

第四,关闭CSRF防护。CSRF防护是防止Cookie自动携带这种跨站请求伪造攻击的,但JWT模式不使用Cookie,所以可以关掉。如果你还开着CSRF,那么POST请求会被403拦截,这是新手很容易踩的坑。

3. 项目搭建与基础依赖引入

3.1 SpringBoot项目初始化

我使用的是SpringBoot 2.7.x版本,JDK 1.8。为什么选2.7而不是3.x?因为3.x最低要求JDK17,而且SpringSecurity的配置方式有所调整,如果你的团队还在用JDK8,2.7就是最稳妥的选择。如果你已经在用JDK17+,那直接用3.x也完全没问题,核心思路不变,只是配置类写法上略有差异。

在pom.xml中引入以下核心依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <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>

这里要重点说一下JWT库的选择。市面上Java的JWT库有好几个,比如java-jwt(Auth0出品)、nimbus-jose-jwt、jjwt等。我推荐用jjwt,因为它的API设计比较简洁,文档齐全,而且0.11.x版本把api、impl、jackson拆成三个模块,实现了接口与实现的分离,编译期只依赖api,运行时才需要impl。

3.2 配置application.yml

server: port: 8080 spring: application: name: security-jwt-demo # 自定义JWT配置 jwt: # 密钥,实际项目中不要写死在配置里,建议放到环境变量或配置中心 secret: your-secret-key-please-change-it-to-a-long-random-string-at-least-256-bits # token过期时间,单位毫秒,这里配置为2小时 expiration: 7200000 # 请求头名称 header: Authorization # 请求头前缀 prefix: "Bearer "

密钥长度要特别注意:HS256算法要求密钥至少256位(32字节)。如果你随便写一个短字符串,jjwt会直接抛WeakKeyException异常。我一开始就被这个坑过,以为密钥随便填就行。

3.3 前置了解:JWT结构

在写代码之前,先把JWT的内部结构看懂。一个JWT由三部分组成,用点号分隔:

xxxxx.yyyyy.zzzzz
  • Header:JSON格式,声明算法和令牌类型,Base64URL编码。通常是{"alg":"HS256","typ":"JWT"}。
  • Payload:JSON格式,存放自定义声明,比如userId、username、roles、过期时间exp、签发时间iat。Base64URL编码。
  • Signature:签名,把Header和Payload用密钥通过指定算法生成。任何对Header或Payload的篡改都会导致签名验证失败。

你可以在jwt.io这样的在线解析网站看到token的载荷信息,但要注意,Base64URL是编码不是加密,payload里的信息任何人都能读出来。所以敏感信息,比如密码、手机号,千万别往JWT里放。

4. 核心编码实战:JWT工具类与安全配置

4.1 JWT工具类实现

工具类的职责很明确:生成token、解析token、校验token。我把代码贴在下面,每一段后面跟着说明。

@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long expiration; private SecretKey getSigningKey() { byte[] keyBytes = Decoders.BASE64.decode(secret); return Keys.hmacShaKeyFor(keyBytes); } /** * 生成token */ public String generateToken(String userId, String username, List<String> roles) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); return Jwts.builder() .setSubject(userId) .claim("username", username) .claim("roles", roles) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } /** * 从token中解析出Claims */ public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } /** * 判断token是否过期 */ public boolean isTokenExpired(String token) { try { Claims claims = parseToken(token); return claims.getExpiration().before(new Date()); } catch (ExpiredJwtException e) { return true; } } /** * 从token中获取userId */ public String getUserIdFromToken(String token) { Claims claims = parseToken(token); return claims.getSubject(); } /** * 从token中获取角色列表 */ @SuppressWarnings("unchecked") public List<String> getRolesFromToken(String token) { Claims claims = parseToken(token); return (List<String>) claims.get("roles"); } }

几个要注意的点:getSigningKey()方法里的Decoders.BASE64.decode,意味着配置文件里的secret应该是一个Base64编码后的字符串。你可以在线生成一个Base64字符串,也可以用一个简单的方法:echo -n "你的随机字符串" | base64

claim里存roles的时候,我用的是一个List。JWT的payload可以存放对象或数组,jjwt会自动序列化为JSON。如果角色字段是空的,这里不要传null,否则解析时强转会报错。我习惯在调用处保证roles至少是一个空集合。

4.2 SpringSecurity核心配置类

配置类是整条过滤器链的装配中心。下面是完整的SecurityConfig类,我逐段解释。

@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig { @Resource private JwtAuthenticationFilter jwtAuthenticationFilter; @Resource private UserDetailsServiceImpl userDetailsService; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 关闭CSRF .csrf().disable() // 设置无状态会话 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() // 配置请求授权规则 .authorizeRequests() // 放行登录接口和swagger等公共路径 .antMatchers("/api/auth/login", "/api/auth/refresh", "/v3/api-docs/**", "/swagger-ui/**").permitAll() // 其他请求都要认证 .anyRequest().authenticated(); // 把自定义JWT过滤器加到UsernamePasswordAuthenticationFilter之前 http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // 配置异常处理 http.exceptionHandling() .authenticationEntryPoint(unauthorizedHandler) .accessDeniedHandler(accessDeniedHandler); return http.build(); } }

antMatchers里放行的路径,要根据你实际项目的接口来调整。接口文档、静态资源、验证码接口、登录接口、注册接口通常都要放行。记住一个原则:放行的路径越少越安全,不要偷懒把整个/a/api/**都放行。

@EnableGlobalMethodSecurity(prePostEnabled = true)这个注解非常实用,它可以让你在Controller方法上用@PreAuthorize("hasRole('ADMIN')")这样的注解来做细粒度的权限控制。后面我会专门讲。

4.3 自定义JWT认证过滤器

这是整个方案里的桥头堡。它的任务:拦截请求,解析Authorization头里的token,如果有效就把用户信息塞进SecurityContext。

@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Resource private JwtUtil jwtUtil; @Resource private UserDetailsServiceImpl userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = getTokenFromRequest(request); if (StringUtils.hasText(token) && SecurityContextHolder.getContext().getAuthentication() == null) { try { // 解析token并获取用户ID String userId = jwtUtil.getUserIdFromToken(token); // 加载用户信息 UserDetails userDetails = userDetailsService.loadUserByUsername(userId); if (userDetails != null && !jwtUtil.isTokenExpired(token)) { // 构建认证信息 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { logger.error("JWT认证失败: {}", e.getMessage()); // 认证失败不在此处抛出异常,让请求继续往下走,最终会进入authenticationEntryPoint } } filterChain.doFilter(request, response); } private String getTokenFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } }

为什么要用OncePerRequestFilter而不是普通Filter?因为OncePerRequestFilter保证了一个请求只走一次过滤器。在转发等场景下,普通Filter可能被执行多次,加了一次请求只走一遍这个保证,避免重复解析token。

为什么用userId来加载用户信息,而不是直接信任token里的username/roles?这是一个安全决策。JWT的payload是可以被用户读取的,虽然改不了,但如果我们的用户表里有禁用、删除账号等场景,直接从token取角色会导致用户被删了还能继续访问。所以每来一个请求,都用token里的userId去数据库查一次最新用户状态。这增加了一次数据库查询,但换来了安全性和数据一致性,值得。

4.4 UserDetailsService与登录流程

SpringSecurity的用户信息加载接口是UserDetailsService,我们需要实现它来从数据库或缓存中加载用户。

@Service public class UserDetailsServiceImpl implements UserDetailsService { @Resource private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = userMapper.findByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } return new LoginUser(user); } }

LoginUser这个类需要实现UserDetails接口。UserDetails接口有四个关键方法:getAuthorities()返回权限集合,getPassword()返回密码,getUsername()返回用户名,还有isAccountNonExpired等几个状态方法。我习惯把授权逻辑放到LoginUser里,从数据库加载角色表组装成GrantedAuthority集合:

public class LoginUser implements UserDetails { private final SysUser user; private final List<GrantedAuthority> authorities; public LoginUser(SysUser user) { this.user = user; this.authorities = user.getRoles().stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getRoleCode())) .collect(Collectors.toList()); } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return user.getStatus() == 1; } public SysUser getUser() { return user; } }

注意SimpleGrantedAuthority构造时我加了一个"ROLE_"前缀。SpringSecurity的hasRole("ADMIN")实际会判断"ROLE_ADMIN"是否在权限集合里,所以这里要匹配上。

登录接口的写法:

@RestController @RequestMapping("/api/auth") public class AuthController { @Resource private AuthenticationManager authenticationManager; @Resource private JwtUtil jwtUtil; @PostMapping("/login") public Result<?> login(@RequestBody LoginRequest loginRequest) { // 1. 校验参数 if (!StringUtils.hasText(loginRequest.getUsername()) || !StringUtils.hasText(loginRequest.getPassword())) { return Result.fail("用户名和密码不能为空"); } // 2. 调用AuthenticationManager认证 Authentication authentication; try { authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword())); } catch (BadCredentialsException e) { return Result.fail("用户名或密码错误"); } catch (DisabledException e) { return Result.fail("账号已被禁用"); } catch (LockedException e) { return Result.fail("账号已被锁定"); } // 3. 认证通过,获取用户信息并生成token LoginUser loginUser = (LoginUser) authentication.getPrincipal(); String token = jwtUtil.generateToken( String.valueOf(loginUser.getUser().getId()), loginUser.getUsername(), loginUser.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())); return Result.success(new LoginResponse(token)); } }

这里AuthenticationManager.authenticate会调用DaoAuthenticationProvider,它内部调用了UserDetailsService.loadUserByUsername拿到用户信息,然后通过PasswordEncoder校验密码。密码匹配为什么要依赖AuthenticationManager而不是自己比对?因为SpringSecurity的密码校验逻辑里还包含了密码升级、盐值处理等细节,自己比对很容易出漏洞,用框架的机制更稳。

4.5 认证入口点与拒绝处理器

当用户未登录访问受保护接口时,SpringSecurity默认会返回403或重定向到登录页。前后端分离项目里,我们希望返回一个JSON格式的401响应,前端拿到这个状态码后自动跳转登录页。所以需要自定义AuthenticationEntryPoint。

@Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或token已过期\"}"); } }

同理,AccessDeniedHandler处理已登录但权限不足的情况,返回403:

@Component public class RestAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"message\":\"没有权限访问该资源\"}"); } }

这两个处理器很容易被忽略,但它们是前后端联调时能不能正确拿到状态码的关键。如果没有自定义,前端拿到的可能是HTML模板或一个空响应体,很难做统一拦截处理。

5. Token续签方案设计

5.1 为什么需要续签

JWT一个很头疼的问题是“无法主动失效”。假设你给token设置了2小时过期,用户在第1小时50分时还在正常操作,结果第2小时整token突然失效,前端跳回登录页,用户刚填了一堆表单全部丢失,体验非常差。所以业界常见的做法是引入“续签”机制,让活跃用户的token不断被续期。

续签方案有几种,我整理了一下,不同方案的成本和复杂度差别很大。

方案实现方式优点缺点
固定过期+重新登录过期就跳登录页实现最简单,无需额外代码体验差,频繁登录
滑窗续签每次请求校验剩余有效期,低于阈值则签发新token返回给前端无状态,实现简单,不依赖中间件需要前端配合响应拦截处理新token
Redis黑名单登出时把token加入黑名单直到过期可主动失效,安全性高引入Redis,状态由无状态变为有状态
RefreshToken双令牌短时accessToken + 长时refreshToken,accessToken过期后拿refreshToken换新安全性好,accessToken崩溃半径小实现复杂度高,后端需要维护refreshToken

5.2 滑窗续签的落地实现

如果项目没有引入Redis,我优先推荐滑窗续签方案。思路是:在JWT认证过滤器里解析token后,检查剩余有效时间,如果剩余时间不足总时长的四分之一,就生成一个新的token,写进响应头里返回给前端。

在JwtAuthenticationFilter里加一段续签逻辑:

// 解析token后,检查剩余过期时间,触发续签 long remaining = claims.getExpiration().getTime() - System.currentTimeMillis(); if (remaining < expiration / 4) { // 生成新token并放入响应头 String newToken = jwtUtil.generateToken( jwtUtil.getUserIdFromToken(token), claims.get("username", String.class), jwtUtil.getRolesFromToken(token)); response.setHeader("Access-Control-Expose-Headers", "Authorization"); response.setHeader("Authorization", "Bearer " + newToken); }

前端配合的办法是:在axios的响应拦截里判断,如果响应头里有Authorization字段,就用新token替换本地存储中的旧token。这个续签动作是后端的,对前端用户无感知。

用的时候要记得给跨域配置加上Access-Control-Expose-Headers,否则浏览器默认只把Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma这6个简单响应头暴露给前端,你自己加的Authorization是拿不到的。

5.3 登出时的Token处理

前面说过JWT无法主动失效,那用户点退出登录时前端把本地token删掉就完事了吗?从功能上说可以,但安全上还有隐患:如果token被截获,在有效期内它仍然有效。严谨的做法是短token + Redis黑名单,或者缩短token生命周期并依赖前端删除。

我这里给一个折中方案:服务端维护一个登出token的Redis黑名单,登出时把token的jti(JWT ID,唯一标识)写进黑名单,过期时间设为token的剩余有效期。在JWT过滤器里,解析token后先查一下黑名单,如果存在就直接拒绝。这个方案保留了JWT的无状态特性,又解决了登出失效问题,代价是需要引入Redis。如果你已经用了Redis做缓存,这个成本几乎可以忽略。

6. 方法级权限控制与参数校验

6.1 基于注解的细粒度权限控制

启动类或配置类上加上@EnableGlobalMethodSecurity(prePostEnabled = true)后,就可以在Controller方法上用注解控制权限了。我按照实际业务场景列几个高频用法。

// 只有管理员和运营可以访问 @PreAuthorize("hasAnyRole('ADMIN', 'OPERATOR')") @GetMapping("/api/orders/list") public Result<?> listOrders() { ... } // 只有管理员可以访问 @PreAuthorize("hasRole('ADMIN')") @DeleteMapping("/api/orders/{id}") public Result<?> deleteOrder(@PathVariable Long id) { ... } // 使用表达式做更复杂的判断 @PreAuthorize("hasRole('ADMIN') && #id > 0") @GetMapping("/api/orders/{id}") public Result<?> getOrder(@PathVariable Long id) { ... } // 基于权限码控制,适合更细粒度的权限模型 @PreAuthorize("hasAuthority('order:export')") @GetMapping("/api/orders/export") public void exportOrders() { ... }

hasRole和hasAuthority的区别要讲清楚,这两个是最容易让初学者迷惑的。hasRole会在参数前自动加上ROLE_前缀,所以hasRole('ADMIN')实际上判断的是ROLE_ADMIN;hasAuthority不做任何处理,直接判断权限集合里有没有这个值。如果你的权限集合用ROLE_开头,就用hasRole,如果你用了具体权限码比如order:export,就用hasAuthority。混着用也没问题,但要保持一致的命名规范。

6.2 模块化改造:按业务模块分配权限

实际项目中,往token的claims里塞一个完整的权限码列表可能撑到几KB,而HTTP请求头大小是有限制的,JWT越长,每次请求的带宽浪费就越大。我见过一个案例,有人的权限系统特别细,一个用户的权限码多达200多个,token体积膨胀到8KB,接口响应慢了不少。

针对这种情况,可以把权限从token里移出去,改成每次请求从缓存或数据库里加载。但这种做法丢失了JWT的“无状态”优势,属于性能与设计之间的权衡。比较合理的折中方案是:在生成token时只放角色编码,不放操作级权限码;在需要校验操作权限的接口上,通过自定义注解+切面从缓存加载权限码进行判断。这样token短,又不需要每个接口都查库,只需要在涉及精细权限的接口里做一次缓存查询。

6.3 登录接口的参数校验与频率限制

登录接口天然是暴力破解的攻击面。如果你的登录接口没有任何防护,攻击者可以用脚本穷举用户名密码。建议至少做到以下几点:

  • 用@Valid注解做参数校验,防止空值和超长字段。
  • 引入登录失败次数限制,比如连续5次失败后锁定账号15分钟。
  • 对接入来源做限制,同一IP的失败请求频率过高时直接拒绝。
  • 把密码复杂度校验放在注册环节,避免用户设置弱密码。

我见过不少项目做登录接口时忘记做接口限流,结果上线第一周就被扫到了。这个事优先级很高。

7. 常见问题与排查技巧实录

7.1 每次请求都返回401或403,但登录接口能通

这个问题的原因很多,我把排查顺序列出来。

首先,确认授权头是否正确携带。前端请求有没有在Authorization里带上Bearer前缀?没有前缀或者多个空格都会导致解析失败。可以在过滤器里加一行日志,打印原始Authorization头确认。

其次,确认自定义过滤器有没有被Spring容器扫描到。有时候配置类里手动new了过滤器对象,导致@Resource注入失效,过滤器里的jwtUtil是null,执行到一半就抛异常了。建议过滤器都加上@Component注解,在配置类里用@Resource注入。

再次,确认密钥一致。如果生成token和解析token用的密钥不一致,验签必然失败。尤其是在多环境部署时,application-prod.yml和application-dev.yml里的secret不一样,这种情况很隐蔽,报错会显示SignatureException。

最后,确认jwt过滤器插入的位置是否正确。addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)这行代码如果写成了addFilterAt或者加到了别的过滤器后面,认证信息可能还没被设置就被授权过滤器拦截了。

7.2 登录成功后,后续请求还是401

登录接口返回了token,但带上token请求其他接口时仍返回401。这里最常见的原因是:登录接口被放行了,所以执行登录逻辑时确实成功生成了token,但后续请求里的token解析时,从token里取出userId,再去查用户,发现用户不存在或已被禁用,于是认证失败。

另一种可能是过滤器里Authentication对象的创建时机问题。记住SecurityContextHolder默认使用ThreadLocal存储认证信息,一个请求的过滤器链结束后ThreadLocal会被清理。如果我们在过滤器中正确设置了Authentication,但某个环节又把它清除掉了,后续的授权判断就会失效。排查办法是:在授权拦截器前打日志,打印SecurityContextHolder.getContext().getAuthentication()是否为null。

7.3 密码错误,但没有任何报错

这种问题通常是密码加密方式不匹配导致的。比如数据库里存的密码是MD5加密串,但你配置了BCryptPasswordEncoder,BCrypt去校验MD5串时永远不通过,而且不会报异常,只返回false。解决问题就是统一加密方式,或者为老系统写一个自定义PasswordEncoder支持多种加密算法的校验。

7.4 JWT在线解析能看,但代码里解析失败

JWT在线解析网站可以解析token,说明token本身是合法格式的。代码里解析失败最常见的原因是系统时间和签发时间不一致。比如token的iat比服务器当前时间晚1小时,或者系统时间不同步,导致exp的计算出现偏差。另外有些人会把Claims转JSON时遇到LocalDateTime的序列化问题,建议统一使用Date类型或时间戳作为claim值。

7.5 过滤器抛出异常后请求无响应

如果你在过滤器里直接throw new RuntimeException,响应可能直接被容器处理掉了,前端拿到一个空的500页面。正确的做法是:在过滤器里用try-catch包裹解析逻辑,捕获到异常后不要继续抛,而是设置响应状态码和JSON内容后return。这一点很多人踩坑,写代码时要特别注意。

我个人的习惯是:在过滤器里只做“能不能解析出用户信息”这件事,它失败时直接把请求交给后面的filterChain继续执行,再由AuthenticationEntryPoint统一返回401。这样做的好处是整个链路的异常处理逻辑是集中的,不会在过滤器里冒出各种奇怪的错误响应格式。

8. JWT安全加固与常见漏洞修复建议

8.1 密钥管理与算法选择

JWT的签名算法分两大类:对称加密(HS256、HS384、HS512)和非对称加密(RS256、RS384、RS512、ES256)。HS256只有一个密钥,签发和验签都用它,适合内部系统;RS256有一对公钥私钥,私钥签发、公钥验签,适合有多个服务端需要独立验签的场景,比如网关模式。

注意一个经典的安全漏洞:当你不限制算法类型时,攻击者可以把RS256算法改成HS256,用公钥作为密钥来签名,服务端如果用同样方式验签就会中招。修复建议是:在解析token时验证alg字段是否在你的白名单里。jjwt库的parserBuilder()改为:

Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token);

jjwt对算法的选择是自动的,它根据签名验证结果来判断,严格模式下会使用签名密钥的类型约束算法。实际操作中最好的做法是固定使用一种算法,并确保密钥强度满足要求。

8.2 常见攻击方式与防范建议

我把和JWT相关的常见攻击方式整理成表格,方便你在安全评审时对照检查。

攻击方式原理防范建议
算法篡改(alg confusion)把RS256改成HS256,使用公钥做签名密钥固定算法类型,校验alg白名单
暴力破解密钥使用弱密钥(如"secret")时可通过字典攻击还原密钥密钥至少256位随机串,定期轮换
token窃取通过XSS等方式获取localStorage里的token敏感操作走单独token,开启HttpOnly Cookie方案
重放攻击截获token后在有效期内重复使用缩短过期时间,配合jti和黑名单,关键接口做幂等控制
payload信息泄露用户在本地直接base64解码看到token里的数据不放敏感字段,敏感信息只存必要ID

8.3 关于JWT漏洞修复建议的实践经验

关于网络上流传的“JWT令牌身份认证绕过漏洞”相关讨论,我整理了几条务实的修复建议。

第一,严格校验签名。签名必须是优先校对的,任何校验逻辑都应该在确认签名合法之后进行,否则攻击者伪造的token可能绕过身份验证。

第二,声明过期时间必须校验。有些攻击工具利用的是服务端不校验exp字段的漏洞。即使你的代码里调用了parseClaimsJws,如果异常被捕获后没有继续走认证逻辑,而是直接信任了claims,也可能出问题。一个安全的做法是:如果token过期了,返回明确的过期错误,而不是继续放行。

第三,处理algorithm none的情况。某些版本的库在配置不当时,token头里的alg字段为none时也能通过。修复方案是配置解析器时强制要求签名,设置signWith强制算法。

这里我要说得更直白一点:安全是个系统工程,JWT只是其中的一环。即使用了再坚固的JWT方案,如果密码存储明文、接口SQL注入、越权查询没处理,整体安全照样是筛子。所以别把安全寄托在某个框架或工具上,该做的防护一样都不能少。

9. 项目梳理与优化方向

整套SpringSecurity+JWT的权限认证方案做完以后,我推荐你再从下面几个方向做深一层的打磨。

9.1 引入RefreshToken双令牌机制

如果你的需求对安全性要求较高,而不仅仅是“能用就行”,那就往双令牌方向演进。accessToken有效期设为15-30分钟,refreshToken有效期为7天。accessToken用于正常接口鉴权,refreshToken只在一个专用的刷新接口上使用。当accessToken过期时,前端拿refreshToken去刷新接口换新的accessToken。

这个方案的复杂性主要在刷新接口的防重放设计上。客户端并发请求时,会有多个请求同时带着过期token打过来,同时去刷新,导致重复刷新或返回多个新token的问题。常见解法是:

  • 刷新接口要求refreshToken只能使用一次,服务端记录已使用的jti。
  • 前端在响应拦截器里做队列控制,多个失败请求共用一个刷新请求的Promise。

9.2 与网关统一鉴权的配合

如果是微服务架构,通常会在网关层做统一的JWT鉴权。服务内部不再各自验签,而是信任网关传过来的用户信息头。这种方案可以有效减少重复代码,但要注意服务之间的信任边界,不能直接信任客户端传来的头,网关需要过滤掉外部请求伪造的内部头字段。

9.3 分布式会话与Redis缓存用户信息

虽然JWT本身无状态,但如果你要应对“用户被管理员立即禁言”“封号立即生效”等实时控制需求,必须在服务端维护一份用户状态缓存。把Redis引入后,可以在登录时将用户信息缓存在Redis,JWT过滤器里解析出userId后,从缓存取最新用户权限,而不是只依赖token中的claims。

这种方案的好处是权限修改“实时生效”,坏处是每个请求多一次Redis查询。从工程角度看,在用户量不大的业务系统里,这个代价完全可以接受。

9.4 代码结构优化与工程项目化

最后说一点工程化层面的建议。不要把所有认证相关代码都堆在commons模块或util包里。合理的分包结构可以是:

com.example.project ├── auth │ ├── controller # 登录、刷新token、登出接口 │ ├── service # 认证服务、用户详情服务 │ ├── filter # JWT过滤器 │ ├── handler # 认证入口、拒绝处理器 │ ├── util # JWT工具类 │ └── model # 登录请求体、token响应体 ├── user # 用户模块 └── common # 通用类

这样每个模块职责单一,后续扩展或维护也方便。如果项目里已经用了SpringCloud,可以考虑把认证能力下沉到独立的auth服务,做成真正的统一认证中心。

我在实际开发中还会坚持一个原则:认证代码必须要有单元测试。JWT工具类的生成和解析、过滤器的行为、登录接口的异常分支,这些是安全核心逻辑,不能只依赖手工测试。哪怕花半天把核心用例写了,后面的迭代会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询