ARTICLE DETAIL

资讯详情

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

Spring Boot 3 + Spring Security 6 整合:JWT登录校验与RBAC权限认证实战

Spring Boot 3 + Spring Security 6 整合:JWT登录校验与RBAC权限认证实战 做后端接口开发登录校验和权限认证这两件事几乎是绕不开的。Spring Boot 3 搭配 Spring Security 6 是目前主流的解决方案之一但网上很多教程要么停留在 Spring Boot 2 时代的写法要么只讲登录不讲授权要么配置文件贴一大堆却没说清为什么要这么配。我最近在项目里完整落地了一套 springboot3 SpringSecurity 的登录校验与权限认证方案从数据库设计、JWT 生成解析、过滤器编写到方法级鉴权全部走了一遍这篇就把整个整合过程掰开揉碎讲清楚适合刚接触 Spring Security 的读者也适合已经被各种报错折磨过、想从原理层面理解 Spring Security 6 的开发者。我先把结论放在最前面Spring Boot 3 对应的 Security 版本是 6.xAPI 相比 5.x 变化很大网上多数旧教程直接搬过来会报错。核心思路是无状态 JWT Security 过滤器链 RBAC 权限模型用户登录成功后签发 token后续请求通过过滤器解析 token 并构建认证信息再配合注解或路径规则做权限校验。整条链路环环相扣想省事只抄配置很容易翻车所以这篇文章会把每个环节的为什么也讲到位。1. 内容整体设计与思路拆解1.1 登录校验和权限认证到底在解决什么问题先厘清概念。登录校验也就是 Authentication认证解决的是你谁啊的问题——用户提交用户名密码系统核对身份通过后给一个凭证。权限认证也就是 Authorization授权解决的是你能干嘛的问题——系统根据用户身份判断他能不能访问某个接口、能不能执行某个操作。这两个概念经常被混在一起说实际写代码时也是同一套链路但设计时必须分开想。比如/api/auth/login是公开接口谁都能访问/api/user/info要求登录用户才能访问/api/admin/deleteUser可能要求拥有管理员角色才能访问。如果只做登录不做权限控制那所有接口都是登录即可这显然不满足真实业务需求。所以 Spring Security 的底层设计也是把认证和授权拆成不同的组件认证通过后把权限信息放进 SecurityContext后续的 authorization 决策全部基于这个上下文。1.2 为什么选择 Spring Security 而不是自己写拦截器很多初学者第一反应是登录校验不就是拦截器里判断一下 token 是否存在吗权限认证不就是用一个注解或者 AOP 切面检查一下角色吗确实能实现但真实项目里你会遇到一堆隐含问题密码加密用什么算法才安全token 过期怎么处理未登录返回什么状态码权限不足返回什么消息接口的匿名访问、认证访问、角色访问怎么灵活配置多套规则之间优先级怎么处理这些问题自己从头造轮子很容易出漏洞。Spring Security 是一套成熟的、经过大规模验证的安全框架它把认证流程、授权模型、会话管理、 CSRF 防护、跨域配置、异常处理全部抽象成了标准组件你只需要往里面填业务实现即可。更关键的是社区生态完善遇到问题搜一下就有答案长期维护成本远低于自研。我遇到过一个项目组自研权限系统最后连改个密码后让其他设备强制下线这种需求都搞了一个月换成 Security Redis 之后两天就搞定了。1.3 技术选型与整体架构本次整合我采用的是几套主流搭配Spring Boot 3.2.x Spring Security 6.2.xMyBatis-Plus 3.5.x 用于数据访问JJWT 0.12.x 用于生成和解析 JWTMySQL 8.0 做数据库Lombok 简化实体代码整体调用链是这样设计的用户请求/api/auth/login携带用户名密码后端通过 AuthenticationManager 调用 UserDetailsService 加载用户信息校验密码通过后生成 JWT 返回给前端前端后续请求在 Header 中携带Authorization: Bearer token一个自定义的 JWT 过滤器拦截请求解析 token把认证信息放入 SecurityContextHolder访问受保护接口时Spring Security 基于 SecurityContext 中的权限信息决定是否放行这个设计的好处是天然的无状态后端不需要保存会话方便横向扩展。会话数据全在 token 里后续做微服务或者网关鉴权时优势很明显。2. 环境准备与依赖引入2.1 Spring Boot 3 与 Spring Security 6 的版本关系先强调版本对应关系Spring Boot 3.0 开始Java 基线是 17对应的 Spring Security 是 6.0 及以上。Spring Boot 3.2 对应 Spring Security 6.2Spring Boot 3.3 对应 Spring Security 6.3。实际开发中直接用 Spring Initializr 创建项目时它会自动引入匹配版本的 Security不需要手动指定版本号但如果你是从旧项目升级或者在 Maven 仓库里显式声明了版本就要特别注意兼容性。还有一个很多人忽略的变更Spring Boot 3 把 Java EE 的 API 全部迁移到了 Jakarta EE之前代码里常见的javax.servlet.Filter全部变成了jakarta.servlet.Filter。很多旧项目升级后报一堆 ClassNotFoundException: javax.servlet 就是因为这个原因。本文下面的代码全部基于 Jakarta 命名空间。2.2 pom.xml 核心依赖配置不少人在 Spring Boot 3 项目里整合 MyBatis-Plus 时踩过坑这里特别说明Spring Boot 3 必须使用mybatis-plus-spring-boot3-starter而不是之前 Boot 2 常用的mybatis-plus-boot-starter否则启动会直接失败或者 Mapper 扫描不到。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- JWT -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.3/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.3/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.3/version scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency这里特别强调一下 JJWT 的版本。老项目里常见的jjwt 0.9.1在 JDK 17/21 上会直接抛NoSuchMethodError因为底层依赖了旧版 javax.xml.bind。我用的 0.12.3 是当前比较稳妥的版本API 也已经更新成链式写法后面的工具类代码会基于这个版本编写。2.3 application.yml 基础配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/security_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto jwt: # 生产环境建议使用更强的密钥至少 32 字节 secret: your-secret-key-please-change-it-to-a-long-random-string-2024 # token 有效期单位毫秒这里设置 24 小时 expire: 86400000jwt.secret是 JWT 签名密钥我这里给的是一个长度足够的字符串实际生产环境建议放到环境变量或者配置中心不要硬编码到配置文件并提交到代码仓库。jwt.expire表示 token 有效期24 小时对于一般后台管理系统比较合适app 类项目通常设置 7 天具体看业务。3. 数据库设计与用户信息加载3.1 标准 RBAC 五表模型权限设计我采用经典的 RBACRole-Based Access Control模型一共五张表用户表、角色表、权限表、用户角色关联表、角色权限关联表。CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, status TINYINT DEFAULT 1 COMMENT 1启用 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role ( id BIGINT AUTO_INCREMENT PRIMARY KEY, role_code VARCHAR(50) NOT NULL UNIQUE COMMENT 角色编码如 ADMIN, role_name VARCHAR(50) NOT NULL COMMENT 角色名称 ); CREATE TABLE sys_permission ( id BIGINT AUTO_INCREMENT PRIMARY KEY, perm_code VARCHAR(100) NOT NULL UNIQUE COMMENT 权限编码如 sys:user:add, perm_name VARCHAR(100) NOT NULL COMMENT 权限描述 ); CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ); CREATE TABLE sys_role_permission ( role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL, PRIMARY KEY (role_id, permission_id) );角色和权限的核心区别一句话就能说清角色是面向业务的一组身份标签比如管理员运营人员权限是面向资源操作的最小单位比如删除用户修改订单。管理员这个角色可以拥有删除用户、修改订单等多个权限。实际授权判断时我们既可以直接判断角色你是不是管理员也可以判断权限你有没有删除用户这个权限Spring Security 两种方式都支持后面会讲到用法。3.2 实体类与 Mapper 实现实体类为了省篇幅省略 getter/setter直接用 Lombok 的Data。这里核心是 SysUser 和用于存放角色权限信息的 LoginUser。Data public class SysUser { private Long id; private String username; private String password; private Integer status; private LocalDateTime createTime; }LoginUser 是 Spring Security 认证时使用的用户主体需要实现UserDetails接口。这个接口是 Security 框架和业务代码之间的桥梁框架通过它获取用户密码、权限列表、账号状态等信息。Data public class LoginUser implements UserDetails { private Long userId; private String username; private String password; private ListString roles; private ListString permissions; Override public Collection? extends GrantedAuthority getAuthorities() { SetString authorities new HashSet(); // 角色加 ROLE_ 前缀这是 Spring Security 的约定 roles.forEach(role - authorities.add(ROLE_ role)); authorities.addAll(permissions); return authorities.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); } Override public String getPassword() { return password; } Override public String getUsername() { return username; } Override public boolean isAccountNonExpired() { return true; } Override public boolean isAccountNonLocked() { return true; } Override public boolean isCredentialsNonExpired() { return true; } Override public boolean isEnabled() { return status ! null status 1; } }这里有一个比较隐蔽的设计点getAuthorities把角色编码加上了ROLE_前缀。这是 Spring Security 的硬性约定hasRole(ADMIN)实际上是在判断ROLE_ADMIN如果不加前缀基于角色的判断永远不生效。权限码则不用加前缀因为hasAuthority(sys:user:add)是精确匹配原始字符串。Mapper 方面用 MyBatis-Plus 的注解方式写关联查询即可。Mapper public interface SysUserMapper extends BaseMapperSysUser { Select(SELECT * FROM sys_user WHERE username #{username}) SysUser findByUsername(String username); Select(SELECT r.role_code FROM sys_role r INNER JOIN sys_user_role ur ON r.id ur.role_id WHERE ur.user_id #{userId}) ListString findRoleCodesByUserId(Long userId); Select(SELECT p.perm_code FROM sys_permission p INNER JOIN sys_role_permission rp ON p.id rp.permission_id INNER JOIN sys_user_role ur ON rp.role_id ur.role_id WHERE ur.user_id #{userId}) ListString findPermCodesByUserId(Long userId); }3.3 UserDetailsService 的加载逻辑光有 UserDetails 还不够还需要一个加载器告诉 Security 怎么根据用户名查人。自定义实现UserDetailsService接口并重写loadUserByUsername方法Service public class UserDetailsServiceImpl implements UserDetailsService { Autowired private SysUserMapper userMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userMapper.findByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } LoginUser loginUser new LoginUser(); loginUser.setUserId(user.getId()); loginUser.setUsername(user.getUsername()); loginUser.setPassword(user.getPassword()); loginUser.setStatus(user.getStatus()); loginUser.setRoles(userMapper.findRoleCodesByUserId(user.getId())); loginUser.setPermissions(userMapper.findPermCodesByUserId(user.getId())); return loginUser; } }为什么不直接在登录 Service 里查数据库而要绕一圈实现 UserDetailsService因为 AuthenticationManager 在认证时会自动调用这个加载器并且会在内部做密码比对、状态检查。你只需要把加载逻辑告诉框架框架负责流程编排。这也是 Spring Security 风格的核心——面向扩展点编程框架做流程业务做实现。4. 登录认证接口的完整实现4.1 JWT 工具类封装JWT 的生成和解析是登录校验的核心。我用 JJWT 0.12.3 封装了一个工具类把生成、解析、过期判断都集中在一处。Component public class JwtUtils { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; private SecretKey getKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(Long userId, String username, ListString authorities) { Date now new Date(); Date expireDate new Date(now.getTime() expire); return Jwts.builder() .subject(username) .claim(userId, userId) .claim(authorities, authorities) .issuedAt(now) .expiration(expireDate) .signWith(getKey()) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .verifyWith(getKey()) .build() .parseSignedClaims(token) .getBody(); } }这里使用Keys.hmacShaKeyFor创建 HMAC-SHA 签名密钥注意密钥长度不能太短否则会抛 WeakKeyException。我在配置里给的示例字符串长度完全够用。claim里存了 userId 和用户拥有的权限字符串列表这样在后续过滤器中解析 token 后不需要再查询数据库就能直接构造认证信息这是性能上的关键优化点。4.2 登录接口与认证流程登录接口设计为公开接口用户传入用户名和密码认证通过后返回 token。RestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthService authService; PostMapping(/login) public ResultString login(RequestBody LoginRequest request) { String token authService.login(request); return Result.success(token); } }Service public class AuthService { Autowired private AuthenticationManager authenticationManager; Autowired private JwtUtils jwtUtils; public String login(LoginRequest request) { // 1. 调用 Spring Security 的认证管理器进行认证 Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword() ) ); // 2. 认证通过后principal 就是前面自定义的 LoginUser LoginUser loginUser (LoginUser) authentication.getPrincipal(); // 3. 提取权限列表生成 JWT ListString authorities loginUser.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); return jwtUtils.generateToken(loginUser.getUserId(), loginUser.getUsername(), authorities); } }第 1 步是整个登录校验的灵魂所在。authenticationManager.authenticate内部干了三件事调用 UserDetailsService 查用户、用 PasswordEncoder 比对密码、检查账号状态。如果用户名不存在或密码错误它会抛出BadCredentialsException如果账号被禁用会抛出DisabledException。这些异常最终会被我们自定义的异常处理器转换为友好的 JSON 返回给前端。4.3 密码加密为什么必须用 BCrypt登录接口不直接对比数据库里的密码而是把编码器交给 Spring Security。推荐使用 BCryptPasswordEncoder而不是 MD5、SHA1 这样的不可逆散列算法。原因很简单MD5 这类算法速度太快而且有彩虹表黑客拿到密文后很容易批量破解BCrypt 算法内部会引入随机盐并且故意设计成计算缓慢同一明文每次加密结果不同安全强度完全不在一个量级。在 SecurityConfig 中注册 PasswordEncoder BeanBean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }给数据库插入测试用户时需要先生成 BCrypt 密文。写个临时方法打印一下public static void main(String[] args) { System.out.println(new BCryptPasswordEncoder().encode(123456)); }把输出的密文填到sys_user表的 password 字段。注意每次输出的密文都不一样但都能通过matches方法校验通过这是 BCrypt 的特性不是 bug。4.4 统一返回结构为了方便前端处理所有接口统一返回 Result 对象Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT fail(Integer code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } }5. Security 配置与权限认证核心5.1 SecurityConfig 完整配置与解读这是整个项目的核心配置文件。Spring Security 6 采用了全新的 Lambda DSL 风格不再推荐继承 WebSecurityConfigurerAdapter。Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { Autowired private UserDetailsServiceImpl userDetailsService; Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; Autowired private RestAuthenticationEntryPoint authenticationEntryPoint; Autowired private RestAccessDeniedHandler accessDeniedHandler; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // 1. 关闭 CSRF因为使用无状态 JWT .csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) // 2. 设置无状态会话 .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 3. URL 级权限规则 .authorizeHttpRequests(auth - auth .requestMatchers( /api/auth/login, /doc.html, /webjars/**, /v3/api-docs/**, /swagger-resources/**, /error ).permitAll() .requestMatchers(/api/user/**).hasAnyRole(USER, ADMIN) .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) // 4. 异常处理返回统一的 JSON .exceptionHandling(ex - ex .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler) ) // 5. 将 JWT 过滤器加入到用户名密码过滤器之前 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }几个关键配置的意图必须说清楚。第一关闭 CSRF。CSRF 防护是防止跨站请求伪造的攻击手段传统做法是给请求加一个随机 token但服务端需要维护会话状态。我们的架构是无状态 JWTtoken 已经承担了身份凭证的职责双击 CSRF 防护反而会影响前后端分离架构的正常调用所以关闭。这个前提是无状态 自定义 Header 携带凭证如果你依然使用 Session 会话且表单登录则不能轻易关闭。第二配置 STATELESS 会话。因为我们不需要在服务端维护 SessionSessionCreationPolicy.ALWAYS 或 IF_REQUIRED 都会创建 HttpSession在前后端分离和集群部署场景下毫无意义。第三权限规则顺序很重要。authorizeHttpRequests规则是按从上到下的顺序匹配的命中了前面的规则就不会再看后面的。所以要把 permitAll 的路径写在最前面再把较具体的路径规则放中间最后用 anyRequest 兜底。第四自定义 JWT 过滤器要放在 UsernamePasswordAuthenticationFilter 之前。因为我们要在框架做授权之前先把 token 解析出来并构造好认证信息如果你的过滤器放在最后Security 已经完成了授权过滤那 token 解析就没有意义了。5.2 JWT 过滤器实现Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtUtils jwtUtils; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header request.getHeader(Authorization); if (StringUtils.hasText(header) header.startsWith(Bearer )) { String token header.substring(7); try { Claims claims jwtUtils.parseToken(token); String username claims.getSubject(); Long userId claims.get(userId, Long.class); ListString authorities (ListString) claims.get(authorities); LoginUser loginUser new LoginUser(); loginUser.setUserId(userId); loginUser.setUsername(username); loginUser.setRoles(Collections.emptyList()); loginUser.setPermissions(authorities); // 这里稍作简化角色和权限统一从 authorities 还原 // 构造认证对象放入 SecurityContext UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( loginUser, null, authorities.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList()) ); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // token 无效、过期、解析失败直接清空上下文 SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } }这里的逻辑很直观请求进来先看有没有 Authorization 头且是 Bearer 前缀没有就放行交给后续过滤器处理——不携带 token 的请求最终会被授权规则拒绝有 token 就解析成功则把认证对象塞进 SecurityContextHolder失败则清空上下文。OncePerRequestFilter是 Spring 提供的一个保证一次请求只执行一次过滤的基类。为什么要用它而不是直接实现 Filter因为 Servlet 容器的过滤器可能因为转发、异步调用等原因被多次执行而 JWT 解析这个动作不应该重复执行。消息头里如果带了无效 token这里就会静默放行最终由 AuthenticationEntryPoint 返回 401如果你希望即使接口公开也要拦截非法 token可以在 catch 中直接返回 401但一般不需要这么激进。5.3 方法级权限校验与常用注解路径规则能解决的场景有限。真实的业务逻辑往往需要更细粒度的控制比如同一个接口管理员可以查看所有订单普通用户只能查看自己的订单。这时推荐使用方法级权限校验。Spring Security 在EnableMethodSecurity开启后支持在 Controller 或 Service 方法上标注注解。RestController RequestMapping(/api/user) public class UserController { PreAuthorize(hasRole(ADMIN)) GetMapping(/listAll) public ResultListSysUser listAll() { // 只有 ADMIN 角色能访问 } PreAuthorize(hasAuthority(sys:user:add)) PostMapping public ResultVoid addUser(RequestBody SysUser user) { // 拥有 sys:user:add 权限的用户才能访问 } PreAuthorize(hasRole(USER)) GetMapping(/info) public ResultString info() { return Result.success(普通用户信息); } }几个常见注解的用法和区别PreAuthorize方法执行前校验最常用PostAuthorize方法执行后校验可用于返回值校验hasRole(ADMIN)判断当前用户是否存在 ROLE_ADMIN 权限hasAuthority(sys:user:add)判断当前用户是否存在对应权限码hasAnyRole(USER, ADMIN)多个角色任一即可hasIpAddress(192.168.1.0/24)限制来源 IP我在实际项目中习惯用hasRole做粗粒度的角色控制用hasAuthority做细粒度的按钮级权限控制。菜单、按钮是否显示通常由前端根据登录后返回的权限列表控制但后端接口必须再做一次强制校验防止有人绕过前端直接调接口。5.4 自定义 401 和 403 返回spring security 默认的未登录返回是 302 跳转登录页或 403 页面这对前后端分离项目非常不友好。必须自定义两个组件AuthenticationEntryPoint 处理未认证401AccessDeniedHandler 处理已认证但权限不足403。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;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); } }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;charsetUTF-8); response.getWriter().write({\code\:403,\message\:\没有权限访问该资源\}); } }如果项目里用了 Jackson直接换成 ObjectMapper 写 Result 对象即可。这两个组件写完后到 SecurityConfig 里注册就会替换掉 Security 默认的错误页面行为。6. 常见问题与排查技巧实录6.1 Spring Security 6 的 API 变化踩坑这是新手最容易栽跟头的地方。Spring Security 5.x 时代的写法中继承WebSecurityConfigurerAdapter、重写configure方法、使用authorizeRequests()、antMatchers()这些 API在 6.x 全部废弃或变化。正确做法是 Bean 化配置 SecurityFilterChain使用authorizeHttpRequests()和requestMatchers()。如果你在 Spring Boot 3 项目中直接搜到旧教程复制配置大概率编不过。编译不过还算好的最坑的是代码没有编译错误但运行时功能不对。比如使用authorizeRequests()在 6.1 中虽然还能编译但会打 warning后续版本直接移除再比如EnableGlobalMethodSecurity已经废弃要改用EnableMethodSecurity。我建议遇到问题先去官网文档核对当前版本的 API而不是凭记忆写。6.2 接口文档 knife4j 被拦截前后端联调时接口文档是刚需。knife4j 是常用的增强版接口文档工具但在 Spring Boot 3 下有几个坑第一依赖坐标要用knife4j-openapi3-jakarta-spring-boot-starter且版本要在 4.4.0 以上才能兼容 Spring Boot 3.2。我用的是 4.4.0。第二Swagger 的静态资源路径必须放入放行列表。如果你使用的是 springdoc-openapi放行路径要包含/v3/api-docs/**、/swagger-ui/**、/swagger-resources/**、/webjars/**和/doc.html。漏掉任何一个都会导致文档页面打不开或者接口测试报 401。第三knife4j 的/doc.html页面在请求里默认不会携带 token所以做Authorize配置成了很多团队忽略的点。在 knife4j 的全局参数配置里加上一个名为Authorization的 Header 参数值填写Bearer token这样联调时才能真正测到带鉴权的接口。6.3 跨域配置导致前端请求异常前后端分离开发时前端端口通常是 5173 或 3000后端是 8080跨域问题无法避免。Spring Security 的过滤链在 CORS 上有自己的处理顺序如果只写CrossOrigin或直接配置 CorsFilter常常会出现预检请求 OPTIONS 被 401/403 拦截的情况。正确做法是定义 CorsConfigurationSource 并用 Spring Security 的.cors()接入过滤链Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOriginPatterns(List.of(*)); config.setAllowedMethods(List.of(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(List.of(*)); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }然后在 SecurityConfig 中加一行.cors(Customizer.withDefaults())即可。这样预检请求也会经过 CORS 过滤器并被正确放行。6.4 常见问题速查表为了方便快速排查我把实际开发中遇到的问题整理成一张表现象可能原因解决方式启动报Failed to bind properties under jwt.secretapplication.yml 中配置项缺失检查配置文件是否包含 jwt.secret 和 jwt.expire登录时任何用户名都返回密码错误PasswordEncoder 与保存的密码不匹配确认注册/初始化时使用了同一个 BCryptPasswordEncoder自定义过滤器没生效没有在 SecurityFilterChain 中注册通过addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)注册PreAuthorize不生效遗漏EnableMethodSecurity在配置类上加该注解请求带 token 仍然 401过滤器解析失败被静默 catch在 catch 中打印日志检查 token 是否以 Bearer 开头权限不足时返回 302未配置 AuthenticationEntryPoint配置自定义入口点返回 JSON前端预检请求失败CORS 没有接入 Security 链使用 CORSConfigurationSource .cors()6.5 一个小技巧调试时查看认证信息排查权限相关问题最直接的方式是在过滤器或 Controller 里打印当前认证信息。一行代码就能看到 Spring Security 内部视角Authentication authentication SecurityContextHolder.getContext().getAuthentication(); System.out.println(authentication);它会输出 principal、credentials、authorities 等完整信息。调试时用这个能快速判断 token 解析是否正确、权限列表是否完整。我在本地开发时习惯在 JwtAuthenticationFilter 最后加一行日志确认哪些请求是经过认证的。7. 扩展建议与实际项目体会7.1 从单机登录到分布式会话管理上面的方案能直接用于中小型项目但有一个明显的短板token 无法主动失效。用户修改密码、管理员封禁账号、用户退出登录这些场景下 JWT 依然有效直到自然过期。解决思路一般是引入 Redis 做 token 白名单或黑名单。登录时把 token 存入 Redis设置过期时间与 JWT 一致每次请求在过滤器里检查 Redis 是否存在退出登录时删除 Redis 记录即可实现强制下线。代价是增加了一次 Redis 查询但换来的是可控性大多数业务系统值得这个成本。另一种思路是 refresh token 双 token 方案。Access Token 有效期短比如 30 分钟Refresh Token 有效期长比如 7 天。Access Token 过期后前端用 Refresh Token 换新的 Access Token用户无感知安全性更高。这个方案配合 Redis 存储 Refresh Token 会更稳妥。7.2 Spring Security 不止能用在后端 Web 接口Spring Security 的能力边界并不局限于 HTTP 接口。你可以在 Service 层方法上使用PreAuthorize做细粒度控制也可以把它嵌入到定时任务、消息监听器中。如果在做物联网类项目考虑 Spring Boot 3 整合 MQTT 的场景消息入口同样需要认证鉴权客户端连接时校验身份订阅/发布时校验主题权限。在 MQTT 消息处理的入口处手动调用 AuthenticationManager或者复用 JWT 过滤器这套思路都是可行的方向。通用原则只有一个先确认你是谁再判断你能不能做这件事。7.3 最后说一点个人建议这套整合方案的每个组件Spring Security 都给了充分的扩展点但也正因如此很多初学者容易陷入配置太多不知道改哪个的困境。我的建议是先跑通最简链路再逐步加东西先做登录拿到 token再加过滤器解析 token再加方法级权限最后补异常处理和跨域配置。每加一块都单独验证这样出了问题定位特别快。我一向不推荐直接把别人的整个配置类复制过来跑一旦报错你连从哪里排查都不知道。按这个顺序自己写一遍你对 Spring Security 的理解会上一个台阶。从实际项目经验来看登录校验和权限认证这件事花半个月深入理解后面每个项目都受益。这个方案里 JWT 无状态、RBAC 权限模型、Security 过滤器链的配合已经能覆盖绝大多数日常需求剩下的问题都是可以按需扩展的比如 OAuth2 第三方登录、短信验证码登录、分布式会话统一管理等等。希望这篇能帮你在 Spring Boot 3 整合 Spring Security 的路上少踩一些坑。
返回列表