news 2026/10/15 13:37:04

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot 3整合Spring Security 6:登录校验与权限认证实战

做后端这些年,我见过太多把登录校验和权限控制写成一坨 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 17,javax 包名全部换成了 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> <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>org.springframework.boot</groupId> <artifactId>spring-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) );

注意用户名要做唯一索引,否则系统里出现同名用户,登录时查库就分不清该认证哪一个了。密码字段建议长度设到 100,BCrypt 加密后的字符串是 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 List<SysRole> 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("用户不存在")); List<GrantedAuthority> 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 Result<String> 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> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</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) { Map<String, 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 Result<Map<String, Object>> login(@RequestBody LoginRequest request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); String token = jwtTokenUtil.generateToken(authentication.getName(), authentication.getAuthorities()); Map<String, 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_ADMIN;hasAuthority("sys:report:view")比对的是数据库里存的原样权限码。项目里我建议角色用hasRole,细粒度权限码用hasAuthority,两者可以混用。

5.2 方法级注解:把权限控制放到接口上

URL 级别配置适合粗粒度的安全策略,但真实业务里经常出现同一路径下不同的操作需要不同权限。比如用户管理接口,GET /users可能需要ADMIN,DELETE /users/{id}还需要更高的权限。如果都写在authorizeHttpRequests里,路径规则会变得非常繁琐,可读性也差。

方法级注解更适合这种场景。首先在安全配置类上开启注解支持:

@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { }

然后在 Controller 方法上标注:

@RestController @RequestMapping("/api/users") public class UserController { @GetMapping @PreAuthorize("hasRole('ADMIN')") public Result<List<UserVO>> listUsers() { // 只能管理员查看 } @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN') and hasAuthority('sys:user:delete')") public Result<Void> deleteUser(@PathVariable Long id) { // 管理员 且 拥有删除权限码 } @PutMapping("/{id}/status") @PreAuthorize("@permissionService.checkUserStatus(authentication.principal.username)") public Result<Void> updateStatus(@PathVariable Long id) { // 自定义 SpEL 表达式校验 } }

@PreAuthorize支持 SpEL 表达式,可以在注解里写各种组合逻辑。@EnableMethodSecurity相当于是把这套 AOP 拦截器注入到项目里,不加这个注解,你写再多@PreAuthorize都不会生效。

这里还有一个值得记的技巧:复杂权限判断可以写一个 Service,然后在 SpEL 里通过@beanName.method()调用,避免注解里写太长太难读的表达式。

5.3 获取当前登录用户信息的几种方式

权限控制最终还是要落到“当前用户是谁”这个点上,Controller 里经常需要拿当前用户信息。安全的做法是从SecurityContextHolder获取,不要自己从 token 里解析。

@GetMapping("/me") public Result<UserProfile> me() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String username = authentication.getName(); // 按 username 查库返回用户信息 }

也可以用 Spring 的解析器直接注入Authentication参数:

@GetMapping("/me") public Result<UserProfile> me(Authentication authentication) { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); return Result.ok(userService.getProfile(userDetails.getUsername())); }

还有一种更省事的方式,定义@CurrentUser注解加一个参数解析器,把当前用户直接注入到 Controller 参数里。这种方式适合项目里大量接口都要拿用户信息的场景,能省掉很多重复代码。核心思路还是从SecurityContextHolder里取,只是做了一层封装。

6. 异常处理:让前端能看懂 401 和 403

6.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;charset=UTF-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;charset=UTF-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 token,JWT 过滤器是否注册
普通用户能访问管理员接口检查@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的重复表达式提取成自定义注解、把权限码做成常量类统一管理、在网关层再压一层粗粒度校验。每个人项目的规模不一样,但底层的思路是一致的:认证负责确认身份,授权负责划分边界,过滤器链是承载它们的通道。把这三件事想清楚,后面加什么新功能都不至于把代码写乱。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 13:36:48

丝杆支撑座轴承温升过高的危害与排查处理

1. 丝杆支撑座为什么会烫手&#xff1f;先搞清楚温升从哪里来丝杆支撑座这个件&#xff0c;在机床、自动化设备里看着不起眼&#xff0c;但它的作用相当于给滚珠丝杆的两端“扶稳”。丝杆转起来&#xff0c;轴向力全靠支撑座里的轴承扛着&#xff0c;径向定位也靠它。如果哪天你…

作者头像 李华
网站建设 2026/10/15 13:36:34

Moto 3188/3688 CPS R05.05写频软件安装与使用指南

简介&#xff1a;摩托罗拉写频软件Moto_3188_3688_CPS_R05.05是专为GP3188、GP3688、GM3188、GM3688、GM3189、GM3689等系列对讲机开发的官方配置工具&#xff0c;主要面向需要自主调整频率范围、信道参数与通信模式的用户&#xff0c;解决写频过程中的频率规划、功能配置和参数…

作者头像 李华
网站建设 2026/10/15 13:36:18

STM32F407 USB Host驱动EC20:ECM与lwIP数据链路实战

简介&#xff1a;一套基于STM32F407微控制器的USB驱动EC20 4G模块完整工程&#xff0c;面向需要实现4G数据上传、远程通信等场景的嵌入式开发者&#xff0c;提供从USB设备配置、端点管理到AT命令交互的整套可移植代码。压缩包共377个文件&#xff0c;其中160个H头文件与107个C源…

作者头像 李华
网站建设 2026/10/15 13:36:06

hyperframes工作流:用CLI与AI批量处理HTML和MP4视频帧

1. 从“hyperframes”这个关键词说起&#xff1a;它到底指什么第一次看到“hyperframes”这个词&#xff0c;我下意识把它拆成了“hyper”和“frames”两部分。在技术语境里&#xff0c;frames 通常指向两个方向&#xff1a;一个是前端开发里的 iframe / 帧结构&#xff0c;另一…

作者头像 李华
网站建设 2026/10/15 13:35:57

SQLite FTS5实现context-mode上下文感知查询

1. “context-mode”到底是什么&#xff1f;一个被误读但极具实操价值的技术概念“context-mode”这个词最近在开发者社区里频繁出现&#xff0c;尤其和MCP、SQLite、FTS5、BM25这些词绑在一起刷屏。很多人第一反应是——这是某个新框架的配置项&#xff1f;还是IDE里的隐藏开关…

作者头像 李华