
1. 从javax到jakartaSpring Boot 3与Spring Security的版本适配如果你以前用过Spring Boot 2.x整合Spring Security第一次切换到Spring Boot 3时最直观的感受大概率不是新特性而是包名全变了。javax.servlet变成了jakarta.servletSecurity的配置方式也从WebSecurityConfigurerAdapter继承制变成了基于SecurityFilterChain的Bean注册制。这两个变化几乎是所有旧项目迁移时绕不过去的坎。Spring Boot 3.0在2022年11月正式发布底层基于Spring Framework 6强制要求JDK 17及以上。Spring Security也从5.x升到了6.x并且完全抛弃了旧版的配置方式。我最早迁移一个老项目时直接CtrlC旧配置过来编译结果满屏红色报错一看全是AbstractHttpConfigurer、WebSecurityConfigurerAdapter这些类不存在。后来查官方文档才知道Spring Security 6彻底删除了WebSecurityConfigurerAdapter所有配置必须通过创建SecurityFilterChain Bean来完成。这里先给大家一个快速匹配参考表避免版本搭错Spring Boot版本对应Spring Security版本最低JDK版本javax/jakarta2.7.x5.7.x8javax3.0.x6.0.x17jakarta3.1.x6.1.x17jakarta3.2.x6.2.x17jakarta3.3.x6.3.x17jakarta标题里写过“万字超详细讲解”这一篇我不打算空谈理论直接带大家从零创建一个Spring Boot 3项目把登录校验与权限认证完整做出来。整个过程会覆盖项目初始化、依赖引入、用户角色模型设计、SecurityFilterChain配置、认证链路拆解、方法级权限注解、登出处理、以及实际运行中容易踩的坑。适合正在从Spring Boot 2迁移到3的开发者也适合刚接触Spring Security但想直接上手实战的新手。2. 初始化工程与依赖引入的细节2.1 用IDEA创建一个Spring Boot 3项目如果你用的是IntelliJ IDEA新建项目时在Spring Initializr界面选择Spring Boot 3.x版本注意JDK必须选17或更高版本。有些老机器装的是JDK 8或11这一步会直接卡死所以建议先确认好本机JDK版本。创建项目时的关键设置Type选择Maven如果团队用Gradle也可以但下文代码示例我以Maven为例Language选择JavaPackaging选择JarJava版本选择17或212.2 引入Web、Security与验证相关的依赖在pom.xml中加入以下核心依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- 数据库相关这里用mybatis-plus做示例 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies关于版本兼容有几个容易踩坑的点MyBatis-Plus版本与Spring Boot 3的兼容性。老版的mybatis-plus-boot-starter3.5.3之前对Spring Boot 3支持不完整会出现启动时Bean加载报错。我建议直接使用3.5.5或更高版本或者使用专门的mybatis-plus-spring-boot3-starter。这是不少人在第一步就卡住的原因。mysql-connector-j。Spring Boot 3的依赖管理中已经将MySQL驱动切换到了com.mysql:mysql-connector-j不需要再写mysql-connector-java写了反而可能版本冲突。如果你继承的是spring-boot-starter-parent驱动版本由BOM统一管理不需要写版本号但前提是MySQL 8.x。Lombok的版本也要跟得上。老项目里常用的1.18.24在JDK 17下编译会有警告建议用1.18.30以上版本省得后面编译时出现奇怪的问题。如果你用的是IDEA 2023.x还要检查一下自带的Lombok插件是否已启用。2.3 配置文件的前期准备application.yml里面先做基础配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/security_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0需要说明的是Spring Boot 3对于数据源的自动配置有变化如果只引入spring-boot-starter-jdbc或mybatis-plusHikariCP默认就会被自动配置不用额外写DataSource的Bean。如果配置了url和账号密码还启动失败大概率是驱动类名写错了Spring Boot 3中驱动类名统一用com.mysql.cj.jdbc.Driver。3. 用户角色与权限模型的表结构设计在动手写Security配置之前先把底层的用户、角色、权限模型设计清楚。很多教程上来就写配置类结果到了动态鉴权环节就开始硬编码代码能跑但换到真实项目里完全没法用。我这里用经典的五表模型用户表、角色表、用户角色关联表、菜单权限表、角色菜单关联表。CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, username VARCHAR(64) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(128) NOT NULL COMMENT 密码(BCrypt加密), nickname VARCHAR(64) COMMENT 昵称, status TINYINT DEFAULT 1 COMMENT 状态 1正常 0禁用, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除标记, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT 用户表; CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, role_name VARCHAR(64) NOT NULL UNIQUE COMMENT 角色名称, role_key VARCHAR(64) NOT NULL UNIQUE COMMENT 角色标识, 如admin/user, sort INT DEFAULT 0 COMMENT 显示顺序, status TINYINT DEFAULT 1 COMMENT 状态, deleted TINYINT DEFAULT 0 ) ENGINEInnoDB COMMENT 角色表; CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ) ENGINEInnoDB COMMENT 用户角色关联表; CREATE TABLE sys_menu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT DEFAULT 0 COMMENT 父菜单ID, menu_name VARCHAR(64) NOT NULL COMMENT 菜单名称, perms VARCHAR(128) COMMENT 权限标识, 如system:user:list, path VARCHAR(128) COMMENT 路由地址, menu_type TINYINT COMMENT 1目录 2菜单 3按钮, status TINYINT DEFAULT 1 ) ENGINEInnoDB COMMENT 菜单权限表; CREATE TABLE sys_role_menu ( role_id BIGINT NOT NULL, menu_id BIGINT NOT NULL, PRIMARY KEY (role_id, menu_id) ) ENGINEInnoDB COMMENT 角色菜单关联表;这块表结构看起来简单实际上有一个值得提前想清楚的问题权限标识perms字段到底存什么。我见过很多项目直接把URL路径存进去比如“/user/list”然后在鉴权时做字符串匹配。这样做不是不行但一旦接口路径调整或者有多个接口共用权限代码就会很别扭。更推荐的做法是存抽象的业务权限标识例如“system:user:list”在Controller方法上用PreAuthorize(hasAuthority(system:user:list))指定。这样即使URL变了权限定义不用动。这套模型对应到Spring Security中用户被GrantedAuthority包装每个角色用ROLE_前缀标识权限标识直接用原始字符串。比如角色ROLE_ADMIN、ROLE_USER权限system:user:list、system:user:add这样做的好处是Spring Security的hasRole()默认会拼接ROLE_前缀而hasAuthority()完全按字符串匹配。初始化数据时建议先插入一个管理员账号用于测试。密码一定要用BCrypt加密后的字符串可以后面通过代码生成也可以在测试类中写个临时方法打印。4. SecurityFilterChain核心配置放行策略与过滤链4.1 抛弃WebSecurityConfigurerAdapter改用Bean注册Spring Security 6中不再需要继承WebSecurityConfigurerAdapter取而代之的是直接在配置类中声明SecurityFilterChain Bean。下面是一个最基础的配置Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/captcha).permitAll() .requestMatchers(/v3/api-docs/**, /swagger-ui/**, /doc.html).permitAll() .anyRequest().authenticated() ) .exceptionHandling(exception - exception .authenticationEntryPoint(unauthorizedHandler) .accessDeniedHandler(accessDeniedHandler) ); return http.build(); } }这个配置里有两个细节值得展开说任何请求经过这个FilterChain时都会被Security的过滤器过滤一遍包括permitAll的路径。很多新手以为permitAll等于不经过Security其实不是。permitAll只是说放行而不校验认证状态但后续的过滤器链仍然在跑。如果你自定义了一个OncePerRequestFilter用于解析JWT即使permitAll的路径也会走一遍这个过滤器。所以写JWT过滤器时一定要判断token为空时直接放行不要在过滤器里抛异常否则登录接口自己都会被拦住。SessionCreationPolicy.STATELESS。这是无状态架构的标配。设置成STATELESS后Spring Security不会再创建HttpSession也不会从Session中读取上下文。登录状态完全依赖每次请求携带的Token比如JWT。如果你还在用传统的Session登录方式可以暂时不设置这个策略但我建议直接按无状态模式做更符合前后端分离的趋势。4.2 认证入口与拒绝访问的JSON响应前后端分离的项目里未认证和未授权的默认跳转页面完全不可用必须自定义返回JSON。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(JSON.toJSONString(Result.error(401, 认证失败请重新登录))); } }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(JSON.toJSONString(Result.error(403, 权限不足无法访问))); } }这里用了一个RequestBodyAdvice还是直接JSON.toJSONString取决于你项目里的统一返回结构但核心逻辑都一样让浏览器端的fetch或axios能直接拿到JSON错误信息而不是跳到一个HTML错误页。4.3 无状态登录下如何放行登录接口一个常见的误会是既然用了STATELESS那么登录接口是不是也不需要认证过滤器了从配置上看登录接口确实在permitAll列表里但如果你写了JWT认证过滤器后面会讲那么登录请求到达Controller之前JWT过滤器跑了一遍。此时请求是没有携带Token的过滤器必须判断“token为空则直接放行”放行后才会进入登录Controller。在过滤器里不能因为拿不到token就抛异常因为可能用户本来就在执行登录操作。同理token过期和token无效应该返回不同的错误信息这样前端才能提示用户“重新登录”而不是“登录异常”。5. 认证链路拆解从登录请求到SecurityContextHolder5.1 UserDetailsService的加载逻辑Spring Security做认证时默认会调用UserDetailsService的loadUserByUsername方法从数据库加载用户信息然后由DaoAuthenticationProvider完成密码比对。所以第一步是写一个UserDetailsService实现类Service public class UserDetailsServiceImpl implements UserDetailsService { Autowired private SysUserMapper userMapper; Autowired private SysRoleMapper roleMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } if (user.getStatus() ! 1) { throw new LockedException(账号已被禁用); } ListString roleKeys roleMapper.selectRoleKeysByUserId(user.getId()); ListString perms menuMapper.selectPermsByUserId(user.getId()); ListGrantedAuthority authorities new ArrayList(); for (String roleKey : roleKeys) { authorities.add(new SimpleGrantedAuthority(ROLE_ roleKey)); } for (String perm : perms) { authorities.add(new SimpleGrantedAuthority(perm)); } return new LoginUser(user, authorities); } }注意loadUserByUsername的返回值类型是UserDetails这是Spring Security自己的接口。我们可以写一个LoginUser类实现UserDetails把用户实体和权限列表封装进去。public class LoginUser implements UserDetails { private SysUser user; private ListGrantedAuthority authorities; public LoginUser(SysUser user, ListGrantedAuthority authorities) { this.user user; this.authorities authorities; } public SysUser getUser() { return user; } 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; } }一个容易被忽略的坑如果用户被禁用你是选择在loadUserByUsername里直接抛LockedException还是在isEnabled()里返回false我建议两个都做。loadUserByUsername抛异常可以在登录阶段就给出明确的“账号已禁用”提示而isEnabled()是Spring Security内部的最终检查。不要只依赖一个否则可能出现提示信息不清楚的边界情况。5.2 登录流程的两种实现方式登录接口的实现方式主要有两种方式一使用AuthenticationManagerRestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthenticationManager authenticationManager; Autowired private TokenService tokenService; PostMapping(/login) public Result? login(RequestBody Validated LoginRequest request) { UsernamePasswordAuthenticationToken authenticationToken new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()); Authentication authenticate authenticationManager.authenticate(authenticationToken); LoginUser loginUser (LoginUser) authenticate.getPrincipal(); String token tokenService.createToken(loginUser); return Result.success(token); } }这里需要额外在SecurityConfig中暴露AuthenticationManagerBean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); }方式一的好处是认证过程完全交给Spring Security的ProviderManager它会自动调用我们写的UserDetailsService然后通过PasswordEncoder比对密码。缺点是异常处理比较繁琐需要捕获BadCredentialsException、LockedException等分别返回提示。方式二手动调用UserDetailsServicePasswordEncoderService public class AuthService { Autowired private UserDetailsService userDetailsService; Autowired private PasswordEncoder passwordEncoder; public String login(String username, String password) { UserDetails userDetails userDetailsService.loadUserByUsername(username); if (!passwordEncoder.matches(password, userDetails.getPassword())) { throw new BadCredentialsException(密码错误); } // 生成token LoginUser loginUser (LoginUser) userDetails; return tokenService.createToken(loginUser); } }方式二更直观过程完全透明控制力更强。缺点是自己绕过了AuthenticationManager一些Spring Security的扩展机制比如RememberMe、AuthenticationEventPublisher就不会自动触发。在大多数项目中方式二完全够用而且排查问题更容易我自己的项目现在就用的方式二。如果你没有特别复杂的认证源比如多Provider建议直接用方式二少一层黑盒。5.3 Token生成与校验过滤器登录成功后生成Token这里以JWT为例。引入依赖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 /dependencyTokenService的核心逻辑Component public class TokenService { private static final String SECRET 你的密钥至少32个字符以上用随机字符串; private static final long EXPIRE_TIME 1000 * 60 * 60 * 2; // 2小时 public String createToken(LoginUser loginUser) { MapString, Object claims new HashMap(); claims.put(userId, loginUser.getUser().getId()); claims.put(username, loginUser.getUsername()); return Jwts.builder() .setClaims(claims) .setSubject(loginUser.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(getSecretKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); } private SecretKey getSecretKey() { return Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); } }JWT认证过滤器是整个链路中最关键的部分Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { Autowired private TokenService tokenService; Autowired private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token getTokenFromRequest(request); if (StringUtils.hasText(token)) { try { Claims claims tokenService.parseToken(token); String username claims.getSubject(); if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { UserDetails userDetails userDetailsService.loadUserByUsername(username); if (userDetails ! null) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (ExpiredJwtException e) { request.setAttribute(token_error, token过期); } catch (Exception e) { request.setAttribute(token_error, token无效); } } filterChain.doFilter(request, response); } private String getTokenFromRequest(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (StringUtils.hasText(bearer) bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }注意这个过滤器必须在Spring Security的认证过滤器之前执行。在SecurityConfig中设置http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);这里有个细节需要解释为什么JWT过滤器里拿到token后还要重新loadUserByUsername为什么不直接把token里的用户名塞进SecurityContext就行因为权限可能发生变化而且UserDetails里包含了角色和权限列表。如果只在JWT里存用户名不重新查库用户的权限变更无法实时生效。当然这也有代价——每次请求都会查一次数据库。对于性能要求极高的场景可以把权限列表直接写进JWT claims中但存在权限延迟生效的问题需要根据业务取舍。在我的项目中我选择查库因为权限变更实时生效比那点毫秒级延迟更重要。6. 方法级权限注解与动态鉴权实践6.1 PreAuthorize与EnableMethodSecuritySpring Security 6中方法级的安全注解默认是通过EnableMethodSecurity开启的替代了旧版的EnableGlobalMethodSecurity。用法变化不大RestController RequestMapping(/system/user) public class SysUserController { PreAuthorize(hasAuthority(system:user:list)) GetMapping(/list) public Result? list() { return Result.success(userService.list()); } PreAuthorize(hasRole(ADMIN)) PostMapping public Result? add(RequestBody SysUser user) { return Result.success(userService.add(user)); } }hasAuthority与hasRole的区别刚才提到过hasRole会自动加上ROLE_前缀hasAuthority不会。所以如果你的权限标识是数据库里存的“system:user:list”就用hasAuthority如果是角色建议数据库里存user代码里写hasRole(USER)Spring Security会自动变成ROLE_USER。如果你想让某个接口同时支持多个角色访问可以这样写PreAuthorize(hasAnyRole(ADMIN, USER)) GetMapping(/profile) public Result? profile() { // 返回当前登录用户信息 }6.2 从SecurityContextHolder中获取当前用户在Controller层获取当前登录用户是实际开发中非常高频的需求。可以直接用Authentication authentication SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser (LoginUser) authentication.getPrincipal();还有一种更优雅的方式写一个公共工具类Component public class SecurityUtils { public static LoginUser getLoginUser() { Authentication authentication SecurityContextHolder.getContext().getAuthentication(); if (authentication null || !(authentication.getPrincipal() instanceof LoginUser)) { throw new RuntimeException(当前登录状态已失效); } return (LoginUser) authentication.getPrincipal(); } public static Long getUserId() { return getLoginUser().getUser().getId(); } public static String getUsername() { return getLoginUser().getUsername(); } }这里有一个我在真实项目中踩过的坑SecurityContextHolder.getContext().getAuthentication()拿到的Principal类型不一定是你自定义的LoginUser。原因可能有登录成功后通过AuthenticationManager注入的Principal类型与手动构造的不一致或者框架内部的匿名认证机制给你塞了一个AnonymousAuthenticationToken。所以在强转之前一定要用instanceof判断否则会抛ClassCastException而且是在运行时报排查起来特别突然。6.3 自定义权限校验器如果你觉得PreAuthorize中Spring Security自带的表达式不够满足业务场景可以自定义PermissionEvaluator或使用AuthenticationPrincipal等注解做逻辑处理。更常见的扩展点是写一个自定义的权限注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) PreAuthorize(ss.hasPermi(system:user:export)) public interface RequiresPermission { }然后在Service里定义一个ss的BeanService(ss) public class PermissionService { public boolean hasPermi(String permission) { LoginUser loginUser SecurityUtils.getLoginUser(); if (loginUser null) { return false; } return loginUser.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .anyMatch(permission::equals); } }这样写的好处是注解与具体实现解耦以后要改成从Redis中读取权限只需要改PermissionService内部逻辑Controller代码完全不用动。6.4 资源权限控制从URL到按钮有些需求不只要求方法级控制还要控制前端按钮是否显示。比如一个用户管理页面普通用户看不到“删除”按钮管理员能看到。后端做权限校验的同时前端也需要根据权限标识动态渲染按钮。通常的做法是登录接口返回权限列表前端存进状态管理里然后用v-if或三元表达式控制。登录返回的数据结构可以设计为{ token: eyJhbGciOiJIUzI1NiJ9..., userInfo: { username: admin, roles: [ROLE_ADMIN], permissions: [system:user:list, system:user:add, system:user:delete] } }前端拿到permissions数组后写一个自定义指令v-permission控制按钮显隐。但需要说明的一个道理是前端的隐藏只是优化体验真正的安全边界必须由后端守住。即使前端隐藏了按钮恶意用户直接调接口后端如果没有校验数据照样可能被删掉。所以前后端都要做但信任点永远在后端。7. 登出处理与状态清除7.1 无状态模式下登出到底要做什么如果是传统Session模式登出很简单调用Security的logout接口使Session失效服务端状态就没了。但在JWT无状态模式下服务端不保存Token登出就分成了两种策略。**策略一完全无状态前端丢弃Token。**前端在拿到401响应或用户点登出时直接删除本地存储的Token。后端什么都不用做。这种实现简单但问题是如果Token没被删除比如被复制到另一个设备在过期之前它仍然有效。**策略二服务端维护黑名单。**在Redis中保存一个blacklist:token的集合key是Token的jti或用户名value是过期时间。登出时把当前Token写入黑名单JWT过滤器在读Token时先检查黑名单命中则拒绝认证。这种方式解决了Token立即失效的问题但需要引入Redis逻辑稍微复杂一点。我在生产项目里用的是策略二因为涉及用户敏感操作Token必须能在登出后立即失效。实现很简单Service public class TokenService { Autowired private RedisTemplateString, String redisTemplate; private static final String BLACKLIST_KEY security:blacklist:; public void logout(String token) { String tokenId UUID.randomUUID().toString(); // 实际项目中jti应该在生成token时存进claims redisTemplate.opsForValue().set(BLACKLIST_KEY tokenId, 1, 2, TimeUnit.HOURS); } public boolean isBlacklisted(String tokenId) { return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_KEY tokenId)); } }更稳妥的登出写法是在JWT的claims里加入jtiJWT ID登出时解析出jti再写入黑名单。如果token的过期时间是2小时黑名单的有效期也设置为2小时过了这个时间Token本身就失效了黑名单也没有保留的意义。7.2 自定义登出接口Spring Security内置了logout接口配合CSRF关闭时默认是POST /logout。但在无状态模式下内置的LogoutHandler不一定满足需求尤其是要操作Redis清黑名单时。建议自己写登出接口PostMapping(/logout) public Result? logout(HttpServletRequest request) { String token getTokenFromRequest(request); tokenService.logout(token); return Result.success(登出成功); }一定要用POST、DELETE等非GET方法。如果把登出写成GET接口很容易被CSRF或浏览器预加载触发导致用户被意外登出。8. 实操过程中最常见的坑与解决方案8.1 启动报错循环依赖Spring Boot 3默认禁止循环依赖如果在代码里写了循环依赖比如AuthenticationManager依赖了UserDetailsService而UserDetailsServiceImpl又依赖了某个Service这个Service又反过来依赖了AuthenticationManager启动就会直接报错。解决办法是打破循环不要在一个类中既要AuthenticationManager又要它的上游组件。如果确实需要考虑用Lazy注解延迟注入。8.2 放行了但请求仍被拦截有些同学在requestMatchers中配置了permitAll但访问时发现还是返回401或403。排查方向检查是否有全局拦截器或Filter在Security之前拦截跟Spring Security没有关系。最常见的是登录拦截器。检查JWT过滤器是否对permitAll路径也进行了强制认证。这是最容易被忽略的地方。检查请求的URL与requestMatchers是否完全匹配。比如配置的是/api/auth/login但请求路径带了多余的斜杠或者没有/api前缀。8.3 密码加密方式不匹配数据库里存的是明文密码或MD5加密的密码而代码用的是BCryptPasswordEncoder登录取密码时用passwordEncoder.matches(password, dbPassword)比对会出现结果始终为false。这不是逻辑问题是加密方式不一致。解决方案统一使用BCrypt重新生成密码。Spring Security 5之后的推荐方案就是BCrypt不要再使用MD5、SHA1等不可逆简单哈希BCrypt内部自动加盐安全性高。8.4 同一浏览器多用户登录串号如果你用Session模式多个用户在同一浏览器登录旧Session还没失效就登录了新用户会拿不到正确的登录信息。在无状态模式下这个问题基本不存在因为每个请求都携带自己的Token。如果确实需要用Session可以设置会话并发控制http.sessionManagement(session - session .maximumSessions(1) .expiredUrl(/login?expired) );8.5 跨域配置的冲突前后端分离项目需要配置CORS。Spring Security和Spring MVC的CORS配置如果同时存在可能会冲突或覆盖。建议在Security的FilterChain中统一配置http.cors(cors - cors.configurationSource(corsConfigurationSource()));不要同时在Controller类上加CrossOrigin和写WebMvcConfigurer容易出问题。配置好CORS后对于OPTIONS预检请求要放行否则前端请求会因为预检失败而报CORS错误。9. 扩展思考如果对接了Knife4j或MQTT等组件标题关联热词里出现了knife4j和mqtt这两个在实际项目中确实经常和Spring Security一起出现。9.1 Knife4j文档接口放行Spring Boot 3集成Knife4j后如果没对文档接口做放行访问doc.html会被Security挡住。放行路径通常是.requestMatchers(/doc.html, /webjars/**, /v3/api-docs/**, /swagger-resources/**).permitAll()这里有一个安全提醒如果是生产环境建议不要放行文档接口可以通过配置开关控制。9.2 MQTT等非HTTP内的认证如果项目通过MQTT订阅主题而MQTT Broker也需要认证通常的做法是独立处理不走Spring Security的HTTP过滤器链。如果是EMQX可以在Broker侧配置HTTP认证插件或者使用JWT方式在连接时校验权限。这种情况建议不要混用Security的登录状态而是把Token作为连接凭证传给Broker。10. 写在最后的个人体会这一套从零搭建下来的登录校验与权限认证体系放到生产环境里不能说100%完美但已经覆盖了绝大多数业务系统的核心需求。我个人的体会是Spring Security的学习曲线确实陡峭但一旦理解了SecurityFilterChain的运作逻辑、Authentication与SecurityContextHolder的关系后面配置什么东西都顺了。不要死记硬背配置代码要从过滤器链的角度去理解谁在什么时候校验什么校验通过后把什么放进上下文下一个过滤器又拿什么继续处理。考虑到篇幅关系分布式会话、OAuth2接入、短信验证码登录、微信扫码登录等内容没有展开但底层思路是完全一致的都是在认证Provider与过滤器链上做扩展。有基础后可以再去研究spring-authorization-server那是另一个维度的复杂系统。最后分享一个实践建议给这个配置类加上详细的注释特别是放行路径和过滤器顺序因为你三个月后回来看代码的时候大概率已经忘了为什么当时要放行某个路径。别问我怎么知道的。