做后端这些年我见过太多把登录校验和权限控制写成一坨 if/else、拦截器加 ThreadLocal 到处传参的项目。刚开始接触 Spring Security 的时候我也会觉得这框架“反人类”配置多、概念抽象、文档读着读着就绕晕了。后来真正在一个某后台管理系统里把它完整落地才逐渐理解 Spring Security 的过滤器链和认证抽象反而是让权限体系在业务膨胀之后依然能保持干净的关键。今天这篇就以 Spring Boot 3 整合 Spring Security 为主线完整走一遍登录校验与权限认证的实现过程。内容偏实战代码都是可以直接拿回去改的。这套东西适合什么场景主要就是前后端分离项目、Java 后端服务尤其是需要做用户登录、接口鉴权、角色权限管理的项目。建议有一点 Spring Boot 基础的同学阅读如果你之前只写过拦截器做登录校验那这篇能帮你把思路从“手动挡”切换到“自动挡”。1. 整体设计思路Security 到底在管什么1.1 认证和授权是两件事很多人把登录校验和权限认证混在一起说其实这是两层完全不同的东西。认证Authentication解决的是“你是谁”的问题。用户提交用户名和密码系统校验身份是否合法这就是 AuthenticationManager 干的事。它不关心你是普通用户还是管理员只关心你“有没有这号人”以及“提交的凭证对不对”。授权Authorization解决的是“你能干什么”的问题。身份确认之后系统要判断你能不能访问某个接口、能不能执行某个操作比如普通用户能不能调删除接口、游客能不能看订单数据。如果你只用拦截器做过登录校验大概率会遇到这个问题登录校验做完了接口权限还是要到处写判断或者干脆写在业务代码里。而 Spring Security 的设计思路是把这两层完全解耦认证阶段只负责往 SecurityContext 里塞一个“你是谁”的身份信息授权阶段再基于这个身份去做规则判断。这样业务代码里基本不需要出现“当前用户是不是管理员”这种判断权限规则全部集中管理。1.2 过滤器链就是一条安检通道Spring Security 的核心模型是过滤器链。可以把一次 HTTP 请求理解为一个人走进机场安检通道链路上的每个过滤器就是一个安检环节SecurityContextPersistenceFilter先看看有没有已登录的上下文相当于检查身份证信息有没有在系统里登记过。UsernamePasswordAuthenticationFilter如果是表单登录请求就在这里解析用户名密码转交给认证管理器。FilterSecurityInterceptor最后一步检查权限相当于登机前的二次核验没有权限就拦下来。你不需要背下所有过滤器的名字但一定要理解这个模型。因为后面做 JWT 的时候我们要把自己的过滤器插到这条链表中间时机错了整个链路就乱了。1.3 Spring Boot 3 Spring Security 6 的关键变化如果你之前用过 Spring Boot 2.x 版本这里有几个必须注意的变更点。第一Spring Boot 3 基于 Spring Framework 6最低要求 JDK 17javax 包名全部换成了 jakarta。这意味着老项目中javax.servlet.*的 import 都要改掉很多第三方库也必须升级到支持 jakarta 的版本。第二WebSecurityConfigurerAdapter这个类已经被删除了。以前写 Spring Security 配置常见的是继承这个类然后重写configure(HttpSecurity http)方法现在这套完全不行了。正确做法是声明一个SecurityFilterChain的 Bean用函数式配置。第三Security 6 的 lambda DSL 风格。比如以前写.and().csrf().disable()现在推荐用.csrf(csrf - csrf.disable())这种方式。语法更清晰每个配置项都对应一个 lambda 参数不会出现.and()链式调用的顺序问题。还有一个比较容易踩的坑antMatchers()方法被废弃了统一改成requestMatchers()。如果你从网上复制旧代码过来经常会看到antMatchers直接编译报错。2. 环境准备与依赖引入先跑通最小配置2.1 JDK 17 与 Spring Boot 3 基础环境开始敲代码之前把环境确认好。我这边用的版本组合是 JDK 17 Spring Boot 3.2.x Spring Security 6.2.x。如果你用 JDK 8 或者 11建议先升级否则项目都起不来更别说调试。Maven 里最核心的依赖就两个一个spring-boot-starter-web一个spring-boot-starter-security。如果还要操作数据库加上对应的数据库依赖就行。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-data-jpa/artifactId /dependency如果你项目里用的是 MyBatis不用强上 JPA。Spring Security 不关心你用什么持久层框架它需要的是你自己提供UserDetailsService来加载用户。后面我会用 JPA 演示换成 MyBatis 就是多写一个 mapper 的事。2.2 引入 security 之后发生了什么依赖加进来之后什么都不用写直接启动项目访问任意接口你会发现浏览器里跳出了一个默认的登录页用户名是user密码在启动日志里。很多新手看到这个默认登录页就懵了以为项目废了。其实这是 Spring Security 的默认保护机制引入依赖后所有的请求默认要被认证所以它给你生成了一个临时用户。这时候你只要在配置类里声明一个SecurityFilterChainBean把想放行的接口列出来整个安全策略就由你来控制了。2.3 第一版可用的安全配置这里给出一版最干净的基础配置也是后面所有扩展的起点。Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // 前后端分离项目关闭 CSRF 防护后面单独解释 .csrf(csrf - csrf.disable()) // 无状态会话不创建 Session .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 请求授权规则 .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login).permitAll() .requestMatchers(/api/public/**).permitAll() .anyRequest().authenticated() ); return http.build(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }这一版配置解决了两个问题第一指定了哪些接口可以匿名访问第二密码加密方式统一用 BCrypt。permitAll()表示不拦截authenticated()表示必须登录后才能访问。到这里Spring Security 已经接入项目了但是登录接口还没有真正的用户数据来源默认还是内存用户。接下来要做的就是让登录校验走数据库。3. 登录校验实现用户加载、密码加密与认证管理器3.1 用户表与角色表设计登录校验的底层逻辑很简单用户名密码来了之后系统拿着用户名去数据库查用户查到之后把数据库里存的密码和用户提交的密码做比对。所以表结构至少要有用户表如果要做权限还要有角色表和关联表。CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, 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 PRIMARY KEY AUTO_INCREMENT, code VARCHAR(50) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL ); CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) );注意用户名要做唯一索引否则系统里出现同名用户登录时查库就分不清该认证哪一个了。密码字段建议长度设到 100BCrypt 加密后的字符串是 60 位左右但以后换算法可能变长给点余量。实体类方面如果只用 Spring Security 自带的User类可以不建自定义 UserDetails 实现直接在UserDetailsService里把数据库用户转换成框架的User对象。这样代码量少很多也不容易出错。Entity Table(name sys_user) public class SysUser { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String username; private String password; private Integer status; ManyToMany(fetch FetchType.EAGER) JoinTable(name sys_user_role, joinColumns JoinColumn(name user_id), inverseJoinColumns JoinColumn(name role_id)) private ListSysRole roles; // 省略 getter / setter } Entity Table(name sys_role) public class SysRole { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String code; private String name; // 省略 getter / setter }这里用ManyToMany(fetch FetchType.EAGER)是为了演示时加载用户直接带出角色实际项目如果角色很多大量用户查询会引发性能问题建议用懒加载或者在 service 层手动组装权限。3.2 UserDetailsService框架如何拿到用户数据Spring Security 里有一个核心接口叫UserDetailsService它只有一个方法loadUserByUsername(String username)。这个方法的职责就是根据用户名返回一个UserDetails框架拿到它之后就会自动帮你做密码比对了。Service public class UserDetailsServiceImpl implements UserDetailsService { private final SysUserRepository userRepository; public UserDetailsServiceImpl(SysUserRepository userRepository) { this.userRepository userRepository; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser userRepository.findByUsername(username) .orElseThrow(() - new UsernameNotFoundException(用户不存在)); ListGrantedAuthority authorities sysUser.getRoles().stream() .map(role - new SimpleGrantedAuthority(ROLE_ role.getCode())) .collect(Collectors.toList()); return User.builder() .username(sysUser.getUsername()) .password(sysUser.getPassword()) .disabled(sysUser.getStatus() ! 1) .authorities(authorities) .build(); } }有几处细节值得说。第一为什么角色名前要拼ROLE_前缀这是 Spring Security 的一个约定。后面用hasRole(ADMIN)的时候框架会自己补成hasAuthority(ROLE_ADMIN)去比对。如果你数据库里存的就是ADMIN不拼前缀后面权限判断永远返回 false。第二User.builder()是 Spring Security 提供的标准实现。它要求你至少提供用户名、密码、权限列表。如果用户被禁用了可以用.disabled(true)标记这样即使密码正确也无法通过认证。第三查不到用户时抛出的异常一定要是UsernameNotFoundException不要随意抛一个RuntimeException。因为框架对这个异常有专门的语义处理你抛别的异常DaoAuthenticationProvider可能直接把它当成“系统内部错误”向前端抛 500 而不是“用户名或密码错误”。3.3 密码加密为什么不能用明文最开始的配置里已经声明了BCryptPasswordEncoder。Spring Security 在比对密码时会调用这个编码器的matches(rawPassword, encodedPassword)方法。BCrypt 的特点是每次加密同一个密码生成的字符串都不一样但校验时能判断“原始密码是否匹配加密后的结果”。这是因为它内部掺入了随机盐即使两个用户密码相同数据库里存放的密文也是不同的。这个特性很重要可以有效对抗彩虹表攻击。如果你接手老项目数据库里存了一堆明文密码想临时跑通可以先这么做Bean public PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); }这个工厂方法创建的是DelegatingPasswordEncoder它支持多种编码前缀。比如数据库里可以是{noop}123456明文策略、{bcrypt}$2a$10$...BCrypt 策略框架会自动根据前缀选择对应的编码器去校验。但注意这只是过渡方案。生产环境千万不要用{noop}明文密码过不了等保要求。正常的做法是写一个用户初始化接口或者 SQL 脚本把初始密码统一用BCryptPasswordEncoder加密后覆盖进去。3.4 登录接口与 AuthenticationManager 接入现在用户数据有了密码校验器也有了但还缺一个关键的入口登录接口。Spring Security 认证的核心入口是AuthenticationManager。我们不需要手动实现这个接口直接在配置类里声明一个 Bean从AuthenticationConfiguration里拿默认实现Configuration EnableWebSecurity public class SecurityConfig { private final UserDetailsService userDetailsService; public SecurityConfig(UserDetailsService userDetailsService) { this.userDetailsService userDetailsService; } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider new DaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); provider.setPasswordEncoder(passwordEncoder()); return provider; } // 省略其余配置 }登录接口就简单了RestController RequestMapping(/api/auth) public class AuthController { private final AuthenticationManager authenticationManager; public AuthController(AuthenticationManager authenticationManager) { this.authenticationManager authenticationManager; } PostMapping(/login) public ResultString login(RequestBody LoginRequest request) { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword() ) ); // 到这里说明认证通过 return Result.ok(登录成功); } }这里有一个常见误区很多人认为authenticationManager.authenticate()返回 non-null 就是登录成功。其实这个方法内部会依次调用DaoAuthenticationProvider去加载用户、比对密码、检查用户状态。如果密码错误或者用户被禁用它会直接抛出AuthenticationException根本不会返回 null。所以不用自己去判空直接调用没抛异常就是认证成功。到这一步登录校验已经走通数据库用户和密码加密。但这套方案的默认流程跟前后端分离并不完全匹配因为默认情况下 Security 会在会话里保存认证状态。接下来引入 JWT把登录状态从 Session 改为 Token 形式。4. 引入 JWT无状态登录的完整落地4.1 为什么前后端分离项目推荐 JWT传统的 Session 方案里用户登录成功后服务器在内存里保存一份 Session 信息然后把 SessionId 下发到浏览器 Cookie。这套方案在单体架构没问题但到了前后端分离、多端接入、服务多实例部署的时候就会尴尬用户请求被负载均衡打到另一台服务器那台机器没有这个 Session还得额外做 Session 同步或者引入 Redis 共享 Session。JWT 的思路是反过来用户登录成功后服务器生成一个自包含的 Token 返回给前端后续请求前端把 Token 放在请求头里带过来服务器解签 Token 就能确认身份不需要在服务端保存任何会话信息。简单说Token 本身就是一个“可验证的凭证”。服务器不记状态每次请求来了之后验签就可以。4.2 Token 生成与解析工具JWT 依赖目前我用的是jjwt这个库。这里要特别提醒一个版本问题0.12.x 版本和旧版的 API 差距非常大网上很多老博客用的是 0.9.x 的setSubject、setExpiration照搬过来可能编译不过。pom.xml 依赖dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.12.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.12.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.12.5/version scoperuntime/scope /dependency工具类核心代码Component public class JwtTokenUtil { Value(${jwt.secret}) private String secret; private SecretKey key; PostConstruct public void init() { byte[] keyBytes Decoders.BASE64.decode(secret); this.key Keys.hmacShaKeyFor(keyBytes); } public String generateToken(String username, Collection? extends GrantedAuthority authorities) { MapString, Object claims new HashMap(); claims.put(authorities, authorities.stream() .map(GrantedAuthority::getAuthority) .toList()); return Jwts.builder() .subject(username) .claims(claims) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() 86400000L)) .signWith(key) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) .getPayload(); } }关键点有两个。第一jwt.secret不能太短HS256 算法要求密钥至少 256 位也就是 32 字节。如果配的密钥不够长运行时会抛WeakKeyException。我一般用一段 Base64 编码的随机字符串顶替而不是直接写字面量密钥。第二不要把 userId、角色列表全部塞进 token。token 里只放 username 和权限编码就够了其他信息比如用户昵称、头像需要的时候再查库。token 是可以被解签看到的塞太多敏感信息等于裸奔。生成 token 后登录接口改成PostMapping(/login) public ResultMapString, Object login(RequestBody LoginRequest request) { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); String token jwtTokenUtil.generateToken(authentication.getName(), authentication.getAuthorities()); MapString, Object data new HashMap(); data.put(token, token); data.put(expiresIn, 86400); return Result.ok(data); }4.3 JWT 校验过滤器插在链路中段的“代刷机”Token 生成之后前端每次请求会把它放在Authorization: Bearer token头里。服务器需要有一个过滤器在请求进入接口之前把 token 解析出来构建认证信息放进SecurityContextHolder。这个过滤器要继承OncePerRequestFilter保证同一个请求只执行一次。Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenUtil jwtTokenUtil; private final UserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtTokenUtil jwtTokenUtil, UserDetailsService userDetailsService) { this.jwtTokenUtil jwtTokenUtil; this.userDetailsService userDetailsService; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims jwtTokenUtil.parseToken(token); String username claims.getSubject(); if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } }这段代码解决的核心问题就是让 Spring Security 相信“这个请求已经是登录过的”。为什么需要重新查一次用户token 里虽然有 username但我们要的是框架能识别的UserDetails包括角色权限。直接 loadUserByUsername 查一次库相当于每次请求都刷一次用户的权限列表。如果用户角色被后台改了下一次请求立即生效不需要等 token 过期。当然代价就是多一次查库。如果对性能要求高可以把权限也放进 token解析出来直接用但那样权限变更就不能实时生效了。4.4 过滤器注册与完整安全配置有了过滤器还必须在安全配置里把它插进过滤器链。这里的时间点很讲究要在UsernamePasswordAuthenticationFilter之前因为如果密码认证过滤器先执行了可能直接把它当成默认登录请求处理。Configuration EnableWebSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { this.jwtAuthenticationFilter jwtAuthenticationFilter; } Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login).permitAll() .requestMatchers(/api/public/**).permitAll() .anyRequest().authenticated() ) .authenticationProvider(authenticationProvider()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }到这里登录校验已经完整走通登录接口发 token、JWT 过滤器验 token、SecurityContext 持有认证信息、受保护接口开始生效。5. 权限认证URL 级与方法级双层控制5.1 基于请求路径的权限配置登录校验解决的是“能不能进系统”权限认证解决的是“进了系统能去哪”。最简单的授权方式是在authorizeHttpRequests里按路径配置。.authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**).permitAll() .requestMatchers(HttpMethod.GET, /api/articles/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .requestMatchers(/api/order/**).hasAnyRole(ADMIN, OPERATOR) .requestMatchers(/api/report/**).hasAuthority(sys:report:view) .anyRequest().authenticated() )这里有个容易出错的地方规则是按声明顺序从上往下匹配的先匹配到的规则优先生效。比如你把/api/**的规则写在/api/admin/**前面那后面的管理员限制就白写了因为/api/admin/**的请求已经被/api/**规则接住了。另一个细节是hasRole和hasAuthority的区别。hasRole(ADMIN)内部实际上比对的是ROLE_ADMINhasAuthority(sys:report:view)比对的是数据库里存的原样权限码。项目里我建议角色用hasRole细粒度权限码用hasAuthority两者可以混用。5.2 方法级注解把权限控制放到接口上URL 级别配置适合粗粒度的安全策略但真实业务里经常出现同一路径下不同的操作需要不同权限。比如用户管理接口GET /users可能需要ADMINDELETE /users/{id}还需要更高的权限。如果都写在authorizeHttpRequests里路径规则会变得非常繁琐可读性也差。方法级注解更适合这种场景。首先在安全配置类上开启注解支持Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { }然后在 Controller 方法上标注RestController RequestMapping(/api/users) public class UserController { GetMapping PreAuthorize(hasRole(ADMIN)) public ResultListUserVO listUsers() { // 只能管理员查看 } DeleteMapping(/{id}) PreAuthorize(hasRole(ADMIN) and hasAuthority(sys:user:delete)) public ResultVoid deleteUser(PathVariable Long id) { // 管理员 且 拥有删除权限码 } PutMapping(/{id}/status) PreAuthorize(permissionService.checkUserStatus(authentication.principal.username)) public ResultVoid updateStatus(PathVariable Long id) { // 自定义 SpEL 表达式校验 } }PreAuthorize支持 SpEL 表达式可以在注解里写各种组合逻辑。EnableMethodSecurity相当于是把这套 AOP 拦截器注入到项目里不加这个注解你写再多PreAuthorize都不会生效。这里还有一个值得记的技巧复杂权限判断可以写一个 Service然后在 SpEL 里通过beanName.method()调用避免注解里写太长太难读的表达式。5.3 获取当前登录用户信息的几种方式权限控制最终还是要落到“当前用户是谁”这个点上Controller 里经常需要拿当前用户信息。安全的做法是从SecurityContextHolder获取不要自己从 token 里解析。GetMapping(/me) public ResultUserProfile me() { Authentication authentication SecurityContextHolder.getContext().getAuthentication(); String username authentication.getName(); // 按 username 查库返回用户信息 }也可以用 Spring 的解析器直接注入Authentication参数GetMapping(/me) public ResultUserProfile me(Authentication authentication) { UserDetails userDetails (UserDetails) authentication.getPrincipal(); return Result.ok(userService.getProfile(userDetails.getUsername())); }还有一种更省事的方式定义CurrentUser注解加一个参数解析器把当前用户直接注入到 Controller 参数里。这种方式适合项目里大量接口都要拿用户信息的场景能省掉很多重复代码。核心思路还是从SecurityContextHolder里取只是做了一层封装。6. 异常处理让前端能看懂 401 和 4036.1 401 与 403 到底有什么区别接口报错的时候前端最迷惑的就是 401 和 403 分不清。这里先理清楚401 Unauthorized表示未认证也就是没有提供有效的身份凭证。比如没有带 token、token 过期、token 解析失败都应该返回 401。意思是“你是哪位请先登录”。403 Forbidden表示已经认证但权限不足。比如普通用户访问管理员接口token 是有效的但没有对应的角色或权限返回 403。意思是“知道你是谁但你没资格”。Spring Security 默认会返回一套 HTML 错误页前后端分离项目肯定不能接受。正确做法是通过exceptionHandling配置自定义的返回逻辑。6.2 自定义认证入口和拒绝处理器Spring Security 提供了两个钩子AuthenticationEntryPoint处理未认证的情况。AccessDeniedHandler处理已认证但无权限的情况。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,\msg\:\未登录或登录已过期\}); } } 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,\msg\:\没有权限访问该资源\}); } }然后在安全配置里挂上.exceptionHandling(ex - ex .authenticationEntryPoint(restAuthenticationEntryPoint) .accessDeniedHandler(restAccessDeniedHandler) )这里有个坑要单独提醒AuthenticationEntryPoint只处理在过滤器链中抛出的AuthenticationException。如果你在 Controller 里手动 try/catch 把异常吞掉了或者自定义了全局异常处理器返回了 200那 Spring Security 的入口点根本不会触发。所以不要让全局异常处理器把安全异常又“加工”一遍最好让安全框架走自己的出口。6.3 过滤器里 token 解析失败的最终归宿JWT 过滤器里捕获了 token 解析异常后只做了SecurityContextHolder.clearContext()没有直接返回 401。那异常最终是怎么变成 401 的流程是这样的过滤器清空上下文之后请求继续往下走进入授权过滤器。授权过滤器发现当前没有认证信息就会抛出一个AccessDeniedException或触发AuthenticationEntryPoint。因为是匿名访问受保护资源ExceptionTranslationFilter会捕获这个情况并转交给AuthenticationEntryPoint最终输出我们定义的 JSON。这里有一个容易被忽略的顺序问题如果 JWT 过滤器里验签失败后没有清空上下文SecurityContext 里可能还残留上一个请求的认证信息这样匿名请求反而被你误当成已登录用户放过去了。所以clearContext()这一行不是可有可无它是防止上下文串号的关键。7. 常见问题与踩坑实录7.1 旧项目迁移antMatchers 与 WebSecurityConfigurerAdapter问为什么我的antMatchers编译报错答Security 6 已经完全去掉antMatchers统一使用requestMatchers。不仅是方法名语义也有差异。requestMatchers支持更灵活的匹配器比如按方法、按正则、按路径模式。迁移旧配置时直接把antMatchers全局替换成requestMatchers基本就可以了。还有同学问网上很多教程会写extends WebSecurityConfigurerAdapter我在 Spring Boot 3 里这么写为什么类没了因为从 Spring Security 5.7 开始就弃用了到 6.0 正式删除。现在统一用组件化配置也就是定义一个SecurityFilterChainBean 的形式。7.2 密码校验失败排查登录接口一直报 “用户名或密码错误”但数据库里的用户明明存在。最常见的三个原因第一密码没经过BCryptPasswordEncoder加密。数据库里存的是明文而DaoAuthenticationProvider比对时走的是 BCrypt 解密逻辑明文根本无法匹配。解决办法是用你声明的PasswordEncoder重新加密密码后更新库。第二UserDetailsService没有被安全配置关联。如果你同时声明了AuthenticationManager却没有把DaoAuthenticationProvider注入进去Security 会使用默认的InMemoryUserDetailsManager查不到数据库用户自然一直报错。确保配置里加了authenticationProvider(authenticationProvider())。第三角色列表为空导致权限为空的用户无法认证。有些项目业务比较特殊用户没有任何角色默认情况下认证是可以通过的但如果你在UserDetailsService里返回的 authorities 为空某些匿名配置场景下会误判为匿名用户。这个要根据具体日志看。7.3 CSRF 开关与静态资源 403引入 Spring Security 之后控制台一直提示 403而且跳转不到接口——先检查 CSRF 配置。Spring Boot 2 及以上版本默认开启 CSRF 防护对于浏览器表单提交场景这是必要的但前后端分离用 token 认证时CSRF 校验意义不大反而会拦截掉所有 POST、PUT、DELETE 请求。所以配置里加了csrf(csrf - csrf.disable())。还有个情况是放行了/doc.html和静态资源但 Swagger 页面依然被拦截。需要把/v3/api-docs/**、/swagger-ui/**、/webjars/**也加进permitAll()。而且注意如果项目配置了 context-path路径要带上前缀一起匹配。7.4 PreAuthorize 注解不生效注解写好之后权限完全没有拦截普通用户照样访问管理员接口。这个问题的原因非常直接配置类上少了EnableMethodSecurity。Spring Security 的注解功能默认是不开启的必须用这个注解激活。还有一个隐蔽点如果项目中引入了多个配置类可能同事把EnableMethodSecurity写在了别的配置类里而那个类没有生效。排查的时候直接全局搜一下就清楚了。7.5 一个快速排查表现象排查点启动报错找不到类检查 JDK 版本是否为 17依赖是否全部 Boot 3 兼容登录一直提示用户名或密码错误查库里的密码是否 BCrypt 加密查 UserDetailsService 是否被注入登录成功后访问接口还是 401确认前端请求头是否带了Authorization: Bearer tokenJWT 过滤器是否注册普通用户能访问管理员接口检查PreAuthorize是否写对EnableMethodSecurity是否开启POST 请求全部 403检查 CSRF 是否关闭或者请求头有没有带 CSRF Token接口返回 500 而不是 401/403查看全局异常处理器是否把安全异常吞掉检查自定义 EntryPoint 是否配置正确7.6 关于 JWT 密钥和过期时间的经验最后补一个我在实际项目中遇到的教训。jwt.secret一定要单独配置不要写在代码里硬编码更不要提交到仓库。项目里我会把这类配置放到application.yml里区分开发、测试、生产环境。密钥长度和强度也要注意开发环境可以用一个固定的 Base64 字符串但生产环境建议用配置中心下发定期轮换。过期时间别设太长。很多项目图省事设置 7 天甚至 30 天结果用户账号被冒用的风险变大。我常用的方案是 accessToken 有效期 2 小时refreshToken 有效期 7 天登录接口返回两个 token。刷新 token 的接口单独放行这样用户在 token 过期后不用反复登录同时 accessToken 泄露的窗口也控制在了 2 小时内。关于 JWT 的还有一个容易犯的错不要在 token 里的用户名做模糊匹配服务端拿claims.getSubject()之后一定要去调loadUserByUsername直接用 token 里的数据构建 Authentication 对象会有用户被删了但 token 还能用的隐患。写到这里登录校验和权限认证的闭环已经完整串起来了。这套链路我前前后后在新老项目里跑了不下四五次每次重写的时候都会发现一些细节可以做优化比如把PreAuthorize的重复表达式提取成自定义注解、把权限码做成常量类统一管理、在网关层再压一层粗粒度校验。每个人项目的规模不一样但底层的思路是一致的认证负责确认身份授权负责划分边界过滤器链是承载它们的通道。把这三件事想清楚后面加什么新功能都不至于把代码写乱。