目录
- 1. 引言:为什么 Spring Security 6.x 值得重新学一遍
- 2. 配置模型变化:从 WebSecurityConfigurerAdapter 到 SecurityFilterChain
- 3. 认证流程解析:AuthenticationManager、UserDetailsService、PasswordEncoder
- 4. JWT 集成方案:生成、校验、刷新与注销
- 5. 基于角色的访问控制:@PreAuthorize、@Secured 与 URL 级授权
- 6. CSRF、CORS 与 Session 管理策略
- 7. OAuth2 授权码流程与第三方登录集成
- 8. 方法级安全与自定义 PermissionEvaluator
- 9. 常见坑与最佳实践
- 10. 总结
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 方法:
// 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,常见方案有两种:
- 黑名单:把未过期的 token 存入 Redis,TTL 设为 token 剩余有效期,过滤器每次校验。实现简单,但引入一次 Redis 查询。
- 短 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
[*] --> 未认证
未认证 --> 已签发: "登录成功签发 access + refresh"
已签发 --> 已认证: "请求携带 Bearer access token"
已认证 --> 已认证: "access 未过期,正常访问"
已认证 --> 待刷新: "access 过期返回 401"
待刷新 --> 已签发: "用 refresh 换取新 access"
待刷新 --> 未认证: "refresh 也过期或已吊销"
已认证 --> 已注销: "主动登出写入黑名单"
已注销 --> 未认证: "后续请求校验失败"
关键约束: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["请求到达 Controller"] --> B{"URL 级规则匹配"}
B -->|"permitAll"| F["直接放行"]
B -->|"hasRole / hasAuthority"| C{"FilterSecurityInterceptor 校验"}
C -->|"不通过"| E["AccessDeniedException 返回 403"]
C -->|"通过"| D{"方法级注解校验"}
D -->|"@PreAuthorize 不通过"| E
D -->|"通过"| G["执行方法体"]
G --> H{"@PostAuthorize 校验返回值"}
H -->|"不通过"| E
H -->|"通过"| I["返回结果"]
实践建议: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 | 不创建、不使用 Session | JWT 无状态 API |
IF_REQUIRED | 需要时才创建(默认) | 传统 Web |
NEVER | 不主动创建,但可使用已有 | 混合场景 |
STATELESS + SessionCreationPolicy | 配合 SecurityContextRepository 自定义 | 高级定制 |
JWT 场景下务必设置 STATELESS,否则 SecurityContextHolderFilter 仍可能尝试从 Session 恢复上下文,造成行为不一致。
7. OAuth2 授权码流程与第三方登录集成
7.1 授权码流程时序
sequenceDiagram
participant U as "用户浏览器"
participant C as "客户端应用"
participant A as "授权服务器(GitHub)"
participant R as "资源服务器"
U->>C: 点击"使用 GitHub 登录"
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["hasPermission 被调用"] --> B{"入参形态"}
B -->|"targetDomainObject"| C["直接使用对象"]
B -->|"targetId + targetType"| D["按 ID 查询实体"]
D --> E{"实体是否存在"}
E -->|"否"| X["返回 false"]
E -->|"是"| C
C --> F{"permission 类型"}
F -->|"read"| G["canRead 判定"]
F -->|"write"| H["canWrite 判定"]
F -->|"其它"| X
G --> I{"公开 或 本人 或 ADMIN"}
H --> J{"本人 或 ADMIN"}
I -->|"是"| Y["返回 true"]
I -->|"否"| X
J -->|"是"| Y
J -->|"否"| X
注意 targetId 分支会触发一次数据库查询,高频接口建议在 Service 层先查出实体,再用对象形态调用,避免重复查库。
9. 常见坑与最佳实践
ROLE_前缀混用:hasRole("ADMIN")与hasAuthority("ROLE_ADMIN")等价,但hasAuthority("ADMIN")不等价,权限字符串要统一规范。- 过滤器顺序:自定义 JWT 过滤器必须放在
UsernamePasswordAuthenticationFilter之前,否则表单登录会先拦截。 - 异常处理不统一:
AuthenticationEntryPoint处理未认证(401),AccessDeniedHandler处理无权限(403),两者都要自定义成 JSON 返回。 - 密钥硬编码:JWT 密钥、OAuth2 client-secret 一律走环境变量或配置中心。
- Token 泄露风险:access token 有效期尽量短(15–30 分钟),refresh token 落库并支持主动吊销。
- 方法级安全未生效:检查是否加了
@EnableMethodSecurity,以及被调用的方法是否经过 Spring 代理(同类内部调用不生效)。 - 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["搭建 SecurityFilterChain 多链"] --> B["实现 UserDetailsService + PasswordEncoder"]
B --> C["接入 JWT 过滤器与刷新注销"]
C --> D["配置 URL 级与方法级授权"]
D --> E["补齐 PermissionEvaluator 实例级权限"]
E --> F["按需接入 OAuth2 第三方登录"]
F --> G["统一 401/403 异常与 CORS 策略"]
按这个顺序推进,每一阶段都能独立验证,出问题时也更容易定位是认证、授权还是跨域配置导致的。