Spring Security 6.x 实战:认证、授权与 JWT 集成

目录

  • [1. 引言:为什么 Spring Security 6.x 值得重新学一遍](#1. 引言:为什么 Spring Security 6.x 值得重新学一遍 "#1-%E5%BC%95%E8%A8%80%E4%B8%BA%E4%BB%80%E4%B9%88-spring-security-6x-%E5%80%BC%E5%BE%97%E9%87%8D%E6%96%B0%E5%AD%A6%E4%B8%80%E9%81%8D")
  • [2. 配置模型变化:从 WebSecurityConfigurerAdapter 到 SecurityFilterChain](#2. 配置模型变化:从 WebSecurityConfigurerAdapter 到 SecurityFilterChain "#2-%E9%85%8D%E7%BD%AE%E6%A8%A1%E5%9E%8B%E5%8F%98%E5%8C%96%E4%BB%8E-websecurityconfigureradapter-%E5%88%B0-securityfilterchain")
    • [2.1 旧写法为什么被废弃](#2.1 旧写法为什么被废弃 "#21-%E6%97%A7%E5%86%99%E6%B3%95%E4%B8%BA%E4%BB%80%E4%B9%88%E8%A2%AB%E5%BA%9F%E5%BC%83")
    • [2.2 新写法:SecurityFilterChain Bean](#2.2 新写法:SecurityFilterChain Bean "#22-%E6%96%B0%E5%86%99%E6%B3%95securityfilterchain-bean")
    • [2.3 组件化拆分配置](#2.3 组件化拆分配置 "#23-%E7%BB%84%E4%BB%B6%E5%8C%96%E6%8B%86%E5%88%86%E9%85%8D%E7%BD%AE")
    • [2.4 多链配置的整体结构](#2.4 多链配置的整体结构 "#24-%E5%A4%9A%E9%93%BE%E9%85%8D%E7%BD%AE%E7%9A%84%E6%95%B4%E4%BD%93%E7%BB%93%E6%9E%84")
  • [3. 认证流程解析:AuthenticationManager、UserDetailsService、PasswordEncoder](#3. 认证流程解析:AuthenticationManager、UserDetailsService、PasswordEncoder "#3-%E8%AE%A4%E8%AF%81%E6%B5%81%E7%A8%8B%E8%A7%A3%E6%9E%90authenticationmanageruserdetailsservicepasswordencoder")
    • [3.1 一次登录请求经历了什么](#3.1 一次登录请求经历了什么 "#31-%E4%B8%80%E6%AC%A1%E7%99%BB%E5%BD%95%E8%AF%B7%E6%B1%82%E7%BB%8F%E5%8E%86%E4%BA%86%E4%BB%80%E4%B9%88")
    • [3.2 自定义 UserDetailsService](#3.2 自定义 UserDetailsService "#32-%E8%87%AA%E5%AE%9A%E4%B9%89-userdetailsservice")
    • [3.3 手动触发认证](#3.3 手动触发认证 "#33-%E6%89%8B%E5%8A%A8%E8%A7%A6%E5%8F%91%E8%AE%A4%E8%AF%81")
  • [4. JWT 集成方案:生成、校验、刷新与注销](#4. JWT 集成方案:生成、校验、刷新与注销 "#4-jwt-%E9%9B%86%E6%88%90%E6%96%B9%E6%A1%88%E7%94%9F%E6%88%90%E6%A0%A1%E9%AA%8C%E5%88%B7%E6%96%B0%E4%B8%8E%E6%B3%A8%E9%94%80")
    • [4.1 依赖与密钥配置](#4.1 依赖与密钥配置 "#41-%E4%BE%9D%E8%B5%96%E4%B8%8E%E5%AF%86%E9%92%A5%E9%85%8D%E7%BD%AE")
    • [4.2 Token 生成与解析](#4.2 Token 生成与解析 "#42-token-%E7%94%9F%E6%88%90%E4%B8%8E%E8%A7%A3%E6%9E%90")
    • [4.3 认证过滤器](#4.3 认证过滤器 "#43-%E8%AE%A4%E8%AF%81%E8%BF%87%E6%BB%A4%E5%99%A8")
    • [4.4 刷新与注销](#4.4 刷新与注销 "#44-%E5%88%B7%E6%96%B0%E4%B8%8E%E6%B3%A8%E9%94%80")
    • [4.5 完整 Token 生命周期](#4.5 完整 Token 生命周期 "#45-%E5%AE%8C%E6%95%B4-token-%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F")
  • [5. 基于角色的访问控制:@PreAuthorize、@Secured 与 URL 级授权](#5. 基于角色的访问控制:@PreAuthorize、@Secured 与 URL 级授权 "#5-%E5%9F%BA%E4%BA%8E%E8%A7%92%E8%89%B2%E7%9A%84%E8%AE%BF%E9%97%AE%E6%8E%A7%E5%88%B6preauthorizesecured-%E4%B8%8E-url-%E7%BA%A7%E6%8E%88%E6%9D%83")
    • [5.1 URL 级授权](#5.1 URL 级授权 "#51-url-%E7%BA%A7%E6%8E%88%E6%9D%83")
    • [5.2 方法级注解对比](#5.2 方法级注解对比 "#52-%E6%96%B9%E6%B3%95%E7%BA%A7%E6%B3%A8%E8%A7%A3%E5%AF%B9%E6%AF%94")
    • [5.3 授权决策的执行顺序](#5.3 授权决策的执行顺序 "#53-%E6%8E%88%E6%9D%83%E5%86%B3%E7%AD%96%E7%9A%84%E6%89%A7%E8%A1%8C%E9%A1%BA%E5%BA%8F")
  • [6. CSRF、CORS 与 Session 管理策略](#6. CSRF、CORS 与 Session 管理策略 "#6-csrfcors-%E4%B8%8E-session-%E7%AE%A1%E7%90%86%E7%AD%96%E7%95%A5")
    • [6.1 CSRF](#6.1 CSRF "#61-csrf")
    • [6.2 CORS](#6.2 CORS "#62-cors")
    • [6.3 Session 策略](#6.3 Session 策略 "#63-session-%E7%AD%96%E7%95%A5")
  • [7. OAuth2 授权码流程与第三方登录集成](#7. OAuth2 授权码流程与第三方登录集成 "#7-oauth2-%E6%8E%88%E6%9D%83%E7%A0%81%E6%B5%81%E7%A8%8B%E4%B8%8E%E7%AC%AC%E4%B8%89%E6%96%B9%E7%99%BB%E5%BD%95%E9%9B%86%E6%88%90")
    • [7.1 授权码流程时序](#7.1 授权码流程时序 "#71-%E6%8E%88%E6%9D%83%E7%A0%81%E6%B5%81%E7%A8%8B%E6%97%B6%E5%BA%8F")
    • [7.2 Spring Boot 3.x 配置](#7.2 Spring Boot 3.x 配置 "#72-spring-boot-3x-%E9%85%8D%E7%BD%AE")
  • [8. 方法级安全与自定义 PermissionEvaluator](#8. 方法级安全与自定义 PermissionEvaluator "#8-%E6%96%B9%E6%B3%95%E7%BA%A7%E5%AE%89%E5%85%A8%E4%B8%8E%E8%87%AA%E5%AE%9A%E4%B9%89-permissionevaluator")
    • [8.1 为什么需要 PermissionEvaluator](#8.1 为什么需要 PermissionEvaluator "#81-%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-permissionevaluator")
    • [8.2 自定义实现](#8.2 自定义实现 "#82-%E8%87%AA%E5%AE%9A%E4%B9%89%E5%AE%9E%E7%8E%B0")
    • [8.3 注册与使用](#8.3 注册与使用 "#83-%E6%B3%A8%E5%86%8C%E4%B8%8E%E4%BD%BF%E7%94%A8")
    • [8.4 实例级权限判定流程](#8.4 实例级权限判定流程 "#84-%E5%AE%9E%E4%BE%8B%E7%BA%A7%E6%9D%83%E9%99%90%E5%88%A4%E5%AE%9A%E6%B5%81%E7%A8%8B")
  • [9. 常见坑与最佳实践](#9. 常见坑与最佳实践 "#9-%E5%B8%B8%E8%A7%81%E5%9D%91%E4%B8%8E%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5")
  • [10. 总结](#10. 总结 "#10-%E6%80%BB%E7%BB%93")
    • [10.1 整体落地路线](#10.1 整体落地路线 "#101-%E6%95%B4%E4%BD%93%E8%90%BD%E5%9C%B0%E8%B7%AF%E7%BA%BF")

1. 引言:为什么 Spring Security 6.x 值得重新学一遍

Spring Boot 3.x 把基线抬到了 Java 17 与 Jakarta EE 9+,Spring Security 也随之进入 6.x 时代。这不是一次简单的版本号跳跃:WebSecurityConfigurerAdapter 被彻底移除,javax.servlet 全面换成 jakarta.servlet,方法级安全、OAuth2 客户端、密码编码器等模块都做了重构。

很多同学从 5.x 迁移过来时,第一反应是"我的 configure(HttpSecurity http) 重写怎么编译不过了"。本文以 Spring Boot 3.x + Spring Security 6.x 为基线,从配置模型变化讲起,一路覆盖认证流程、JWT 集成、基于角色的访问控制、CSRF/CORS/Session 策略、OAuth2 授权码登录,以及自定义 PermissionEvaluator 的方法级安全,给出一套可以直接落地的完整实践。

版本基线

组件 版本
JDK 17+
Spring Boot 3.2.x
Spring Security 6.2.x
jjwt 0.12.x
数据库 MySQL 8 / H2(示例)

2. 配置模型变化:从 WebSecurityConfigurerAdapter 到 SecurityFilterChain

2.1 旧写法为什么被废弃

在 5.x 中,我们习惯继承 WebSecurityConfigurerAdapter 并重写 configure 方法:

java 复制代码
// Spring Security 5.x 写法,6.x 已移除
@Configuration
@EnableWebSecurity
public class OldSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/public/**").permitAll()
            .anyRequest().authenticated()
            .and()
            .formLogin();
    }
}

这种设计的问题在于:配置类被迫继承一个框架基类,导致多重继承受限、配置顺序隐晦、难以组合多个安全策略。6.x 的做法是用组件化的 Bean 声明替代继承。

2.2 新写法:SecurityFilterChain Bean

java 复制代码
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 替代 5.x 的 @EnableGlobalMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(restAuthenticationEntryPoint())
                .accessDeniedHandler(restAccessDeniedHandler()));
        return http.build();
    }

    @Bean
    public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/css/**", "/js/**").permitAll()
                .anyRequest().authenticated())
            .formLogin(form -> form.loginPage("/login").permitAll())
            .logout(logout -> logout.logoutSuccessUrl("/login?logout"));
        return http.build();
    }
}

几个关键变化:

  • authorizeRequests() → authorizeHttpRequests() ,antMatchers() → requestMatchers(),mvcMatchers() 与 antMatchers() 合并为 requestMatchers()。
  • Lambda DSL 成为唯一写法 ,and() 链式调用被移除,配置块更清晰。
  • securityMatcher() 支持多链 ,可以按 URL 前缀拆分出 API 链和页面链,@Order 控制优先级。
  • @EnableMethodSecurity 取代 @EnableGlobalMethodSecurity(prePostEnabled = true),默认就开启 @PreAuthorize。

2.3 组件化拆分配置

当配置变多时,建议把 UserDetailsService、PasswordEncoder、AuthenticationManager 等拆成独立 Bean:

java 复制代码
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

@Bean
public AuthenticationManager authenticationManager(
        AuthenticationConfiguration configuration) throws Exception {
    return configuration.getAuthenticationManager();
}

AuthenticationConfiguration 会自动收集容器中的 UserDetailsService 与 AuthenticationProvider,无需再手动 new DaoAuthenticationProvider() 塞进 AuthenticationManagerBuilder。

2.4 多链配置的整体结构

当项目同时存在 API 与页面两套入口时,SecurityFilterChain 的匹配顺序决定了请求最终落到哪条链上,可以用下图理解:

flowchart TD A["HTTP 请求进入 FilterChainProxy"] --> B{"匹配 securityMatcher"} B -->|"/api/**"| C["apiFilterChain"] B -->|"其余路径"| D["webFilterChain"] C --> C1["JwtAuthenticationFilter 解析 Token"] C1 --> C2["authorizeHttpRequests 校验权限"] C2 -->|"通过"| C3["进入 Controller"] C2 -->|"未认证"| C4["AuthenticationEntryPoint 返回 401"] C2 -->|"无权限"| C5["AccessDeniedHandler 返回 403"] D --> D1["FormLogin 表单认证"] D1 --> D2["Session 保存 SecurityContext"] D2 --> D3["渲染页面"]

@Order 值越小优先级越高,因此精确匹配的 API 链应排在页面链之前,避免 /api/** 被宽泛规则先截获。

3. 认证流程解析:AuthenticationManager、UserDetailsService、PasswordEncoder

3.1 一次登录请求经历了什么

flowchart TD A["客户端提交用户名密码"] --> B["UsernamePasswordAuthenticationFilter"] B --> C["AuthenticationManager"] C --> D["DaoAuthenticationProvider"] D --> E["UserDetailsService.loadUserByUsername"] E --> F["UserDetails 返回"] F --> G["PasswordEncoder.matches 校验"] G -->|通过| H["生成已认证 Authentication 写入 SecurityContext"] G -->|失败| I["抛出 BadCredentialsException"] H --> J["SecurityContextHolderFilter 持久化上下文"]

核心角色分工:

  • AuthenticationManager :认证入口,默认实现 ProviderManager,按顺序委托给多个 AuthenticationProvider。
  • AuthenticationProvider :真正执行认证逻辑,DaoAuthenticationProvider 负责数据库用户名密码校验。
  • UserDetailsService :按用户名加载用户,返回 UserDetails(含密码、权限、是否锁定等状态)。
  • PasswordEncoder :比对明文与密文,BCryptPasswordEncoder 是默认推荐。

3.2 自定义 UserDetailsService

java 复制代码
@Service
@RequiredArgsConstructor
public class DbUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));

        List<GrantedAuthority> authorities = user.getRoles().stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getCode()))
            .collect(Collectors.toList());

        return User.withUsername(user.getUsername())
            .password(user.getPassword())
            .authorities(authorities)
            .accountLocked(user.getStatus() == 0)
            .disabled(user.getStatus() == 2)
            .build();
    }
}

注意 ROLE_ 前缀:hasRole("ADMIN") 实际匹配的是 ROLE_ADMIN 权限,而 hasAuthority("user:read") 是精确匹配,两者不要混用。

3.3 手动触发认证

在登录接口中,我们通常不经过表单过滤器,而是直接调用 AuthenticationManager:

java 复制代码
@Service
@RequiredArgsConstructor
public class AuthService {

    private final AuthenticationManager authenticationManager;

    public LoginResponse login(LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.username(), request.password()));
        // 认证成功,authentication 已包含权限信息
        return buildToken(authentication);
    }
}

认证失败会抛出 BadCredentialsException(密码错误)或 DisabledException(账号禁用),建议在全局异常处理器中统一转换为业务错误码,避免把框架异常直接暴露给前端。

4. JWT 集成方案:生成、校验、刷新与注销

4.1 依赖与密钥配置

xml 复制代码
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
yaml 复制代码
jwt:
  secret: ${JWT_SECRET:change-me-to-a-256-bit-random-string-in-production}
  access-token-ttl: 30m
  refresh-token-ttl: 7d
  issuer: demo-app

密钥长度必须满足 HS256 的 256 位要求,否则 jjwt 会直接抛 WeakKeyException。生产环境务必从环境变量或配置中心注入,不要硬编码在仓库里。

4.2 Token 生成与解析

java 复制代码
@Component
public class JwtTokenProvider {

    private final SecretKey key;
    private final Duration accessTtl;
    private final Duration refreshTtl;
    private final String issuer;

    public JwtTokenProvider(JwtProperties props) {
        this.key = Keys.hmacShaKeyFor(props.getSecret().getBytes(StandardCharsets.UTF_8));
        this.accessTtl = props.getAccessTokenTtl();
        this.refreshTtl = props.getRefreshTokenTtl();
        this.issuer = props.getIssuer();
    }

    public String createAccessToken(Authentication authentication) {
        Instant now = Instant.now();
        return Jwts.builder()
            .subject(authentication.getName())
            .issuer(issuer)
            .issuedAt(Date.from(now))
            .expiration(Date.from(now.plus(accessTtl)))
            .claim("authorities", authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority).toList())
            .claim("type", "access")
            .signWith(key)
            .compact();
    }

    public String createRefreshToken(String username) {
        Instant now = Instant.now();
        return Jwts.builder()
            .subject(username)
            .issuer(issuer)
            .issuedAt(Date.from(now))
            .expiration(Date.from(now.plus(refreshTtl)))
            .claim("type", "refresh")
            .signWith(key)
            .compact();
    }

    public Claims parse(String token) {
        return Jwts.parser()
            .verifyWith(key)
            .requireIssuer(issuer)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
}

jjwt 0.12.x 的 API 与 0.11.x 差异较大:setSubject 改为 subject,signWith(key, SignatureAlgorithm.HS256) 简化为 signWith(key),解析用 parser().verifyWith(key)。迁移时注意别照抄旧教程。

4.3 认证过滤器

java 复制代码
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider tokenProvider;
    private final TokenBlacklistService blacklistService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);
            try {
                if (!blacklistService.isBlacklisted(token)) {
                    Claims claims = tokenProvider.parse(token);
                    if ("access".equals(claims.get("type", String.class))) {
                        List<String> authorities = claims.get("authorities", List.class);
                        var auth = new UsernamePasswordAuthenticationToken(
                            claims.getSubject(), null,
                            authorities.stream().map(SimpleGrantedAuthority::new).toList());
                        SecurityContextHolder.getContext().setAuthentication(auth);
                    }
                }
            } catch (JwtException | IllegalArgumentException e) {
                SecurityContextHolder.clearContext();
            }
        }
        chain.doFilter(request, response);
    }
}

过滤器只负责"解析并填充上下文",不负责抛异常中断请求。真正的 401 交给 AuthenticationEntryPoint 处理,这样未登录访问受保护接口时能返回统一的 JSON 结构。

4.4 刷新与注销

刷新:用 refresh token 换新的 access token,同时校验类型字段,防止拿 access token 当 refresh token 用。

java 复制代码
public LoginResponse refresh(String refreshToken) {
    Claims claims = tokenProvider.parse(refreshToken);
    if (!"refresh".equals(claims.get("type", String.class))) {
        throw new IllegalArgumentException("Token 类型不匹配");
    }
    if (blacklistService.isBlacklisted(refreshToken)) {
        throw new IllegalArgumentException("Token 已失效");
    }
    String username = claims.getSubject();
    UserDetails user = userDetailsService.loadUserByUsername(username);
    var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
    return new LoginResponse(tokenProvider.createAccessToken(auth), refreshToken);
}

注销:JWT 是无状态的,服务端无法"删除"已签发的 token,常见方案有两种:

  1. 黑名单:把未过期的 token 存入 Redis,TTL 设为 token 剩余有效期,过滤器每次校验。实现简单,但引入一次 Redis 查询。
  2. 短 access + 长 refresh + 服务端会话表:refresh token 落库,注销时删除记录,access token 靠短有效期自然过期。
java 复制代码
@Service
@RequiredArgsConstructor
public class TokenBlacklistService {

    private final StringRedisTemplate redisTemplate;

    public void blacklist(String token, Duration remaining) {
        redisTemplate.opsForValue().set("jwt:black:" + token, "1", remaining);
    }

    public boolean isBlacklisted(String token) {
        return Boolean.TRUE.equals(redisTemplate.hasKey("jwt:black:" + token));
    }
}

4.5 完整 Token 生命周期

把生成、校验、刷新、注销串起来,一次完整的 JWT 会话大致如下:

stateDiagram-v2 [*] --> 未认证 未认证 --> 已签发: &#34;登录成功签发 access + refresh&#34; 已签发 --> 已认证: &#34;请求携带 Bearer access token&#34; 已认证 --> 已认证: &#34;access 未过期,正常访问&#34; 已认证 --> 待刷新: &#34;access 过期返回 401&#34; 待刷新 --> 已签发: &#34;用 refresh 换取新 access&#34; 待刷新 --> 未认证: &#34;refresh 也过期或已吊销&#34; 已认证 --> 已注销: &#34;主动登出写入黑名单&#34; 已注销 --> 未认证: &#34;后续请求校验失败&#34;

关键约束:access token 只承载身份与权限快照,不承载可变状态;refresh token 必须落库或进黑名单,才能实现"主动注销"这一无状态方案下的补偿能力。

5. 基于角色的访问控制:@PreAuthorize、@Secured 与 URL 级授权

5.1 URL 级授权

java 复制代码
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority("order:write")
    .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
    .anyRequest().authenticated());

规则从上到下匹配,顺序很重要:宽泛规则写在前面会覆盖后面的精确规则。

5.2 方法级注解对比

注解 表达式能力 适用场景
@Secured 仅支持角色字符串,不支持 SpEL 简单角色判断
@PreAuthorize 支持完整 SpEL,可调用 Bean 复杂条件、参数级判断
@PostAuthorize 方法执行后校验返回值 数据归属校验
@PreFilter / @PostFilter 过滤集合元素 列表数据裁剪
java 复制代码
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @PreAuthorize("hasRole('ADMIN') or #userId == authentication.name")
    @GetMapping("/{userId}")
    public List<Order> listByUser(@PathVariable String userId) {
        return orderService.listByUser(userId);
    }

    @PreAuthorize("hasAuthority('order:write') and #request.amount < 10000")
    @PostMapping
    public Order create(@RequestBody OrderRequest request) {
        return orderService.create(request);
    }

    @Secured("ROLE_ADMIN")
    @DeleteMapping("/{id}")
    public void delete(@PathVariable Long id) {
        orderService.delete(id);
    }
}

@PreAuthorize 中的 #参数名 依赖编译时保留参数名(-parameters 编译参数,Spring Boot Maven 插件默认开启),否则需要写成 #p0、#p1。

5.3 授权决策的执行顺序

一个请求从进入安全链到最终放行,会依次经过 URL 级与方法级两层校验:

flowchart TD A[&#34;请求到达 Controller&#34;] --> B{&#34;URL 级规则匹配&#34;} B -->|&#34;permitAll&#34;| F[&#34;直接放行&#34;] B -->|&#34;hasRole / hasAuthority&#34;| C{&#34;FilterSecurityInterceptor 校验&#34;} C -->|&#34;不通过&#34;| E[&#34;AccessDeniedException 返回 403&#34;] C -->|&#34;通过&#34;| D{&#34;方法级注解校验&#34;} D -->|&#34;@PreAuthorize 不通过&#34;| E D -->|&#34;通过&#34;| G[&#34;执行方法体&#34;] G --> H{&#34;@PostAuthorize 校验返回值&#34;} H -->|&#34;不通过&#34;| E H -->|&#34;通过&#34;| I[&#34;返回结果&#34;]

实践建议:URL 级做"粗粒度闸门",方法级做"细粒度判断",两者叠加而不是互相替代,避免只靠注解导致漏配接口暴露。

6. CSRF、CORS 与 Session 管理策略

6.1 CSRF

CSRF 攻击依赖浏览器自动携带 Cookie。因此:

  • 前后端分离 + JWT(无 Cookie):可以关闭 CSRF,因为攻击者无法伪造 Authorization 头。
  • 传统表单/服务端 Session :必须开启,Spring Security 默认使用 CookieCsrfTokenRepository 或 Session 存储。
java 复制代码
// 前后端分离场景
http.csrf(AbstractHttpConfigurer::disable);

// 服务端渲染场景,把 token 暴露给前端
http.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()));

Spring Security 6 对 CsrfTokenRequestHandler 做了调整,使用 XorCsrfTokenRequestAttributeHandler 作为默认实现,配合 SPA 时若出现 403,优先检查这里。

6.2 CORS

java 复制代码
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.example.com"));
    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;
}

然后在 http.cors(Customizer.withDefaults()) 中启用。注意 allowCredentials(true) 时 allowedOrigins 不能写 *,必须显式列出域名。

6.3 Session 策略

策略 含义 适用
STATELESS 不创建、不使用 Session JWT 无状态 API
IF_REQUIRED 需要时才创建(默认) 传统 Web
NEVER 不主动创建,但可使用已有 混合场景
STATELESS + SessionCreationPolicy 配合 SecurityContextRepository 自定义 高级定制

JWT 场景下务必设置 STATELESS,否则 SecurityContextHolderFilter 仍可能尝试从 Session 恢复上下文,造成行为不一致。

7. OAuth2 授权码流程与第三方登录集成

7.1 授权码流程时序

sequenceDiagram participant U as &#34;用户浏览器&#34; participant C as &#34;客户端应用&#34; participant A as &#34;授权服务器(GitHub)&#34; participant R as &#34;资源服务器&#34; U->>C: 点击&#34;使用 GitHub 登录&#34; C->>U: 302 重定向到授权端点 U->>A: 用户授权 A->>U: 302 回调 code U->>C: /login/oauth2/code/github?code=xxx C->>A: 用 code 换 access_token A-->>C: access_token + id_token C->>R: 携带 access_token 拉取用户信息 R-->>C: 用户资料 C->>U: 建立本地会话或签发自有 JWT

7.2 Spring Boot 3.x 配置

yaml 复制代码
spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope: read:user
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          github:
            authorization-uri: https://github.com/login/oauth/authorize
            token-uri: https://github.com/login/oauth/access_token
            user-info-uri: https://api.github.com/user
            user-name-attribute: login
java 复制代码
http.oauth2Login(oauth2 -> oauth2
    .loginPage("/login")
    .successHandler((request, response, authentication) -> {
        // 第三方登录成功后,签发自有 JWT 返回前端
        String token = tokenProvider.createAccessToken(authentication);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"token\":\"" + token + "\"}");
    })
    .failureHandler((request, response, exception) ->
        response.sendRedirect("/login?error")));

关键点:第三方登录成功后,OAuth2User 与本地用户体系需要做一次"绑定或注册",通常通过 OAuth2UserService 的 loadUser 扩展点实现,把 OAuth2User 转换为本地 UserDetails。

8. 方法级安全与自定义 PermissionEvaluator

8.1 为什么需要 PermissionEvaluator

hasRole / hasAuthority 只能判断"有没有某个权限",无法表达"能不能操作这条 数据"。例如"用户只能查看自己的订单"、"编辑只能修改自己部门的文档",这类实例级权限 需要 PermissionEvaluator。

8.2 自定义实现

java 复制代码
@Component
@RequiredArgsConstructor
public class DocumentPermissionEvaluator implements PermissionEvaluator {

    private final DocumentRepository documentRepository;

    @Override
    public boolean hasPermission(Authentication authentication,
                                 Object targetDomainObject,
                                 Object permission) {
        if (targetDomainObject == null || !(permission instanceof String perm)) {
            return false;
        }
        Document doc = (Document) targetDomainObject;
        return switch (perm) {
            case "read" -> canRead(authentication, doc);
            case "write" -> canWrite(authentication, doc);
            default -> false;
        };
    }

    @Override
    public boolean hasPermission(Authentication authentication,
                                 Serializable targetId,
                                 String targetType,
                                 Object permission) {
        if (!"document".equals(targetType)) {
            return false;
        }
        Document doc = documentRepository.findById((Long) targetId).orElse(null);
        return hasPermission(authentication, doc, permission);
    }

    private boolean canRead(Authentication auth, Document doc) {
        return doc.isPublic()
            || doc.getOwner().equals(auth.getName())
            || auth.getAuthorities().stream()
                   .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
    }

    private boolean canWrite(Authentication auth, Document doc) {
        return doc.getOwner().equals(auth.getName())
            || auth.getAuthorities().stream()
                   .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
    }
}

8.3 注册与使用

java 复制代码
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
        DocumentPermissionEvaluator evaluator) {
    DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
    handler.setPermissionEvaluator(evaluator);
    return handler;
}
java 复制代码
@PreAuthorize("hasPermission(#id, 'document', 'write')")
@PutMapping("/documents/{id}")
public Document update(@PathVariable Long id, @RequestBody DocumentRequest request) {
    return documentService.update(id, request);
}

@PostAuthorize("hasPermission(returnObject, 'read')")
@GetMapping("/documents/{id}")
public Document get(@PathVariable Long id) {
    return documentService.get(id);
}

@PostAuthorize 适合"先查出来再判断"的场景,但要注意它是在方法执行之后 校验,数据已经查出来了,敏感场景建议优先用 @PreAuthorize + 参数级判断。

8.4 实例级权限判定流程

PermissionEvaluator 的两种重载分别对应"已有对象"和"只有 ID"两种调用形态,内部判定逻辑可以统一为下图:

flowchart TD A[&#34;hasPermission 被调用&#34;] --> B{&#34;入参形态&#34;} B -->|&#34;targetDomainObject&#34;| C[&#34;直接使用对象&#34;] B -->|&#34;targetId + targetType&#34;| D[&#34;按 ID 查询实体&#34;] D --> E{&#34;实体是否存在&#34;} E -->|&#34;否&#34;| X[&#34;返回 false&#34;] E -->|&#34;是&#34;| C C --> F{&#34;permission 类型&#34;} F -->|&#34;read&#34;| G[&#34;canRead 判定&#34;] F -->|&#34;write&#34;| H[&#34;canWrite 判定&#34;] F -->|&#34;其它&#34;| X G --> I{&#34;公开 或 本人 或 ADMIN&#34;} H --> J{&#34;本人 或 ADMIN&#34;} I -->|&#34;是&#34;| Y[&#34;返回 true&#34;] I -->|&#34;否&#34;| X J -->|&#34;是&#34;| Y J -->|&#34;否&#34;| X

注意 targetId 分支会触发一次数据库查询,高频接口建议在 Service 层先查出实体,再用对象形态调用,避免重复查库。

9. 常见坑与最佳实践

  1. ROLE_ 前缀混用 :hasRole("ADMIN") 与 hasAuthority("ROLE_ADMIN") 等价,但 hasAuthority("ADMIN") 不等价,权限字符串要统一规范。
  2. 过滤器顺序 :自定义 JWT 过滤器必须放在 UsernamePasswordAuthenticationFilter 之前,否则表单登录会先拦截。
  3. 异常处理不统一 :AuthenticationEntryPoint 处理未认证(401),AccessDeniedHandler 处理无权限(403),两者都要自定义成 JSON 返回。
  4. 密钥硬编码:JWT 密钥、OAuth2 client-secret 一律走环境变量或配置中心。
  5. Token 泄露风险:access token 有效期尽量短(15--30 分钟),refresh token 落库并支持主动吊销。
  6. 方法级安全未生效 :检查是否加了 @EnableMethodSecurity,以及被调用的方法是否经过 Spring 代理(同类内部调用不生效)。
  7. CORS 与 Security 冲突 :http.cors() 必须启用,否则预检请求会被安全链拦截返回 401。

10. 总结

Spring Security 6.x 的核心变化可以概括为三点:配置组件化 (SecurityFilterChain Bean 取代继承)、API 现代化 (Lambda DSL、requestMatchers)、默认更安全(方法级安全默认开启、CSRF 处理器升级)。

落地一套完整方案时,建议按这个顺序推进:先搭好 SecurityFilterChain 多链结构,再实现 UserDetailsService + PasswordEncoder 打通认证,接着接入 JWT 过滤器与刷新/注销机制,最后用 @PreAuthorize 与 PermissionEvaluator 补齐细粒度授权。每一步都保持"认证与授权分离、无状态优先、异常统一"的原则,后续扩展 OAuth2 或接入网关时就不会推倒重来。

10.1 整体落地路线

把全文各章节串成一条实施路径,便于按阶段推进:

flowchart LR A[&#34;搭建 SecurityFilterChain 多链&#34;] --> B[&#34;实现 UserDetailsService + PasswordEncoder&#34;] B --> C[&#34;接入 JWT 过滤器与刷新注销&#34;] C --> D[&#34;配置 URL 级与方法级授权&#34;] D --> E[&#34;补齐 PermissionEvaluator 实例级权限&#34;] E --> F[&#34;按需接入 OAuth2 第三方登录&#34;] F --> G[&#34;统一 401/403 异常与 CORS 策略&#34;]

按这个顺序推进,每一阶段都能独立验证,出问题时也更容易定位是认证、授权还是跨域配置导致的。

相关推荐
337802 小时前
5分钟搞懂登录认证和授权框架Spring Security
java·spring boot
world78112262 小时前
【C++】set 和 map
java·前端·c++·set·map
学长毕设2 小时前
基于SpringBoot的社区体育馆预约系统的设计与实现
java·spring boot·后端
RuoyiOffice2 小时前
达梦 DM8 + openGauss:RuoYi Office 的 MySQL 数据转换与导入实践
数据库·spring boot·python·sql·信创
Rain的Java大神之路2 小时前
100 亿条数据如何快速写入 MySQL?从 231 天到 3.5 小时,我把面试官问懵了
java·后端·面试
xyouyuweizhu2 小时前
Java IO 流详解:流的作用、流的划分与标准输入输出流
java·python·php
她的男孩3 小时前
Spring Boot 表单挂 Flowable 审批:设计器绑模型后,业务侧用注解把单子跑起来
spring boot·低代码·工作流引擎
范同学~3 小时前
项目添加水印
java·前端·javascript
快乐的韭菜根根3 小时前
Slurm 指定 GPU 卡:`File=/dev/nvidiaX` 映射的坑与修复
java·服务器·网络