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

20 阅读9分钟

目录

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 的方法级安全,给出一套可以直接落地的完整实践。

版本基线

组件版本
JDK17+
Spring Boot3.2.x
Spring Security6.2.x
jjwt0.12.x
数据库MySQL 8 / H2(示例)

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

2.1 旧写法为什么被废弃

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

// 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

@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:

@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

@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:

@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 依赖与密钥配置

<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>
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 生成与解析

@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 认证过滤器

@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 用。

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 靠短有效期自然过期。
@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 级授权

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过滤集合元素列表数据裁剪
@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 存储。
// 前后端分离场景
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

@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不创建、不使用 SessionJWT 无状态 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 配置

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
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 自定义实现

@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 注册与使用

@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
        DocumentPermissionEvaluator evaluator) {
    DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
    handler.setPermissionEvaluator(evaluator);
    return handler;
}
@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;]

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