运营后台的权限需求我接到过不止一次:客服查订单,管理员取消订单、翻审计。早年我偷懒,在每个接口开头手写身份判断,if (!currentUser().isAdmin()) 复制到十几个方法里,后来加一个角色要全局搜一遍 if,说实话我自己都说不清某个接口到底谁能调。身份认证与授权是「所有请求进业务之前都要过一遍」的横切关卡,这类活儿交给 Spring Security 的过滤器链正合适,我在这篇里把整套方案在 Spring Boot 4.1.1 加 Spring Security 7.1.1 上完整落了一遍。
本文的工程是一个运营后台:SecurityFilterChain 按路径分角色,用户落 MySQL 存 {bcrypt} 密文,取消订单这类敏感操作收敛在服务层 @PreAuthorize,401 与 403 用 RFC 9457 风格的响应体区分开。18 个测试全绿,正文里的数字全部来自同一次运行,我留了底,你可以逐项对。
编程模型与自动配置的事实
写代码之前我习惯先把坐标、自动配置与 DSL 的现状确认清楚,免得照着过时的示例写。以下结论全部来自 Central 制品解包与 javap 实测。
Security 7 时代 starter 坐标没改名,spring-boot-starter-security 在 4.1.1 的 Central 目录里正常存在。组成我解包核对过(POM 实测):
1
2
3
4
5
6
7
spring-boot-starter-security = spring-boot-starter + spring-boot-security
+ spring-aop 7.0.9
spring-boot-security = spring-boot + spring-security-config 7.1.1
+ spring-security-web 7.1.1
spring-security-config = spring-security-core(core 再带 spring-security-crypto)
spring-boot-starter-security-test = spring-boot-starter-security + spring-boot-starter-test
+ spring-boot-security-test
有两个细节我专门记了一笔。其一,Boot 4 把安全自动配置代码放进了独立模块 spring-boot-security,包名是模块化的 org.springframework.boot.security.autoconfigure,该模块的 AutoConfiguration.imports 我数了一遍共 10 条,和 Servlet 工程直接相关的 4 条是:
1
2
3
4
org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration
org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration
其二,spring-security-crypto 不在 starter 的直接依赖里,靠 config 到 core 再到 crypto 两跳传递进来。BCryptPasswordEncoder 运行期直接可用,不用额外引依赖,这是我解包时才确认的。
默认链的退让条件用 javap 解出来是两层。外层 ServletWebSecurityAutoConfiguration 标着 @ConditionalOnDefaultWebSecurity,内部再分两个配置类:
1
2
3
4
5
6
7
8
ServletWebSecurityAutoConfiguration @ConditionalOnDefaultWebSecurity
├─ EnableWebSecurityConfiguration 贴 @EnableWebSecurity
└─ SecurityFilterChainConfiguration
@Bean defaultSecurityFilterChain(HttpSecurity) @Order(2147483642)
@ConditionalOnDefaultWebSecurity = DefaultWebSecurityCondition(AllNestedConditions)
├─ Beans @ConditionalOnMissingBean(SecurityFilterChain)
└─ Classes @ConditionalOnClass({SecurityFilterChain, HttpSecurity})
工程里只要声明一个 SecurityFilterChain bean,Boot 的默认链整条退让,@EnableWebSecurity 也不用自己贴。 这里最容易读错的是退让粒度:EnableWebSecurityConfiguration 只按 bean 名 springSecurityFilterChain 退让,跟工程里自定义链的 bean 名无关,所以自定义链和它并不冲突,注解贴不贴都在。我第一遍读源码时把这条粒度理解错了,白琢磨了十分钟。
spring.security.* 前缀下的属性只有 5 条(spring-configuration-metadata 实证),比我想的少得多:
1
2
3
4
5
spring.security.filter.dispatcher-types 默认 [async, error, forward, include, request]
spring.security.filter.order 默认 -100
spring.security.user.name 默认 user
spring.security.user.password 默认无(不配则启动日志打印随机密码)
spring.security.user.roles 默认无
user.* 三条归 UserDetailsServiceAutoConfiguration 消费:工程没有自己的 UserDetailsService bean 时,Boot 按它们造一个内存用户。DSL 现状也确认过:Security 7 的 HttpSecurity 上 authorizeHttpRequests、csrf、sessionManagement、formLogin、httpBasic、exceptionHandling 全部是 Customizer lambda 风格,没有 and() 链尾;路径匹配主力是 PathPatternRequestMatcher,位于 org.springframework.security.web.servlet.util.matcher。
依赖和配置
工程四块依赖,是我按职责挑的:starter-webmvc 撑起 HTTP 接口面,starter-security 是本篇主角,starter-jdbc 加 MySQL 驱动管用户表与审计表落库:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-webmvc</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-jdbc</artifactId>
</dependency>
<dependency>
<groupId> com.mysql</groupId>
<artifactId> mysql-connector-j</artifactId>
<scope> runtime</scope>
</dependency>
测试侧两个 starter,缺一不可,这个我踩过:security-test 提供 @WithMockUser 与 httpBasic 请求后处理器,但不含 @AutoConfigureMockMvc 本体,HTTP 级测试要另加 webmvc-test:
1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-security-test</artifactId>
<scope> test</scope>
</dependency>
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-webmvc-test</artifactId>
<scope> test</scope>
</dependency>
配置的关键一处是 spring.mvc.problemdetails.enabled=true,业务异常的 404 与 409 统一走 RFC 9457 的 application/problem+json。注意它管不到安全过滤器层的 401 与 403,原因在核心代码一节展开:
1
2
3
4
5
6
7
8
9
10
11
12
spring :
datasource :
url : jdbc:mysql://192.168.1.97:3306/springboot4_security?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username : root
password : root123456
mvc :
problemdetails :
enabled : true
sql :
init :
mode : always
schema-locations : classpath:sql/schema.sql
表两张。sec_users 存登录名、密文、启用位与角色,t_audit_log 留敏感操作审计:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
CREATE TABLE IF NOT EXISTS sec_users
(
username VARCHAR ( 64 ) NOT NULL COMMENT '登录名' ,
password VARCHAR ( 100 ) NOT NULL COMMENT '密文,带 {bcrypt} 前缀' ,
enabled TINYINT ( 1 ) NOT NULL DEFAULT 1 COMMENT '1 可用 0 停用' ,
role VARCHAR ( 32 ) NOT NULL COMMENT '角色名,不带 ROLE_ 前缀:ADMIN / OPERATOR' ,
PRIMARY KEY ( username )
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
CREATE TABLE IF NOT EXISTS t_audit_log
(
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键' ,
username VARCHAR ( 64 ) NOT NULL COMMENT '操作人' ,
action VARCHAR ( 64 ) NOT NULL COMMENT '操作名' ,
order_no VARCHAR ( 32 ) DEFAULT NULL COMMENT '关联订单号' ,
result VARCHAR ( 16 ) NOT NULL COMMENT 'OK / DENIED / ERROR' ,
detail VARCHAR ( 200 ) DEFAULT NULL COMMENT '补充说明' ,
created_at DATETIME ( 3 ) NOT NULL COMMENT '写入时间' ,
PRIMARY KEY ( id ),
KEY idx_created ( created_at )
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
核心代码
过滤器链:一处声明管住全部 HTTP 面
一个 SecurityConfig 类装下路径级授权、认证方式与会话策略,重点看 authorizeHttpRequests 的三条规则与 httpBasic 的 entry point 挂法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain ( HttpSecurity http , SecurityProblemWriter problems ) throws Exception {
http
// 纯 API + STATELESS 会话:没有浏览器表单、没有 Cookie 会话,
// CSRF 保护没有作用对象
. csrf ( AbstractHttpConfigurer :: disable )
. sessionManagement ( s -> s . sessionCreationPolicy ( SessionCreationPolicy . STATELESS ))
. authorizeHttpRequests ( auth -> auth
// 审计记录只有管理员能读
. requestMatchers ( "/api/audit/**" ). hasRole ( "ADMIN" )
// 订单查询与操作两种角色都放行,
// ADMIN 之外的收敛在服务层方法上做
. requestMatchers ( "/api/orders/**" ). hasAnyRole ( "OPERATOR" , "ADMIN" )
. anyRequest (). authenticated ())
// 取舍:管理后台是纯 API,调用方是前端脚本与运维脚本,没有登录页可跳,
// formLogin 的默认登录页与重定向对脚本不友好;httpBasic 每次请求都带凭据,
// 无状态干净。entry point 必须挂在 httpBasic 上,原因见避坑指南坑一
. httpBasic ( basic -> basic . authenticationEntryPoint ( problems :: unauthorized ))
. exceptionHandling ( e -> e
. authenticationEntryPoint ( problems :: unauthorized )
. accessDeniedHandler ( problems :: forbidden ));
return http . build ();
}
}
三条规则按具体路径在前、anyRequest 收尾的顺序排,先匹配到的先生效。认证方式在 httpBasic 与 formLogin 之间我选了 httpBasic:formLogin 面向浏览器,未登录时 302 跳登录页,对脚本调用方是灾难;httpBasic 未登录直接 401,配合 STATELESS 会话策略,服务端不留任何会话状态。两个自定义 Bean 接着登场:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Bean
PasswordEncoder passwordEncoder () {
return PasswordEncoderFactories . createDelegatingPasswordEncoder ();
}
@Bean
UserDetailsManager userDetailsService ( DataSource dataSource ) {
JdbcUserDetailsManager users = new JdbcUserDetailsManager ( dataSource );
users . setUsersByUsernameQuery (
"SELECT username, password, enabled FROM sec_users WHERE username = ?" );
users . setAuthoritiesByUsernameQuery (
"SELECT username, CONCAT('ROLE_', role) FROM sec_users WHERE username = ?" );
return users ;
}
PasswordEncoder 用 DelegatingPasswordEncoder,按密文前缀 {bcrypt}、{noop}、{pbkdf2} 分发算法,新密文 encode 出来默认落 {bcrypt}。UserDetailsService 声明成 JDBC 版本后,Boot 的 UserDetailsServiceAutoConfiguration(spring.security.user.* 内存用户)整体退让。authorities 查询里 CONCAT('ROLE_', role) 是在补角色前缀,数据库存 ADMIN,授权表达式 hasRole("ADMIN") 内部按 ROLE_ADMIN 比较,两边口径必须对齐。
用户与密码:JDBC 用户加 {bcrypt} 密文
注册在我这里就是 BCrypt 编码后入库,明文只出现在种用户的一处代码里:
1
2
3
4
5
6
7
8
9
10
11
12
private void seed ( String username , String rawPassword , String role ) {
Integer exists = jdbc . queryForObject (
"SELECT COUNT(*) FROM sec_users WHERE username = ?" , Integer . class , username );
if ( exists != null && exists > 0 ) {
// 已有账号只打日志不覆盖(INSERT-OR-LOG 语义)
return ;
}
String hash = encoder . encode ( rawPassword );
jdbc . update ( "INSERT INTO sec_users(username, password, enabled, role) VALUES (?, ?, 1, ?)" ,
username , hash , role );
log . info ( "SEED_USER inserted username={} hash={} role={}" , username , hash , role );
}
启动后 sec_users 里落了两行,密文取自实测日志 SEED_USER:
1
2
SEED_USER inserted username=admin hash={bcrypt}$2a$10$NEA3Qmb8lB/0uKs96OH1zugsASxwZ81G3F2uYnv/NnLmOsvc0z33O role=ADMIN
SEED_USER inserted username=operator hash={bcrypt}$2a$10$9RLVpDGRVOCL2q5fjClKnuWRNlfCMw4IiUNFUYnR37GdWRooZe4Yy role=OPERATOR
密文前缀 {bcrypt}$2a$10$ 里藏着两个事实:算法是 bcrypt,强度是 10。强度不是配置出来的,是 BCryptPasswordEncoder 无参构造的默认值,javap 反汇编里 iconst_m1 之后换算成 log rounds 10。库里永远存带 {id} 前缀的密文,裸的 2 a 2a 2 a 10$ 字符串没有前缀,认证时直接抛异常。 挺有意思的一点:BCrypt 自带的盐让同一明文每次编码结果都不同,我两次启动种出的密文不一样,认证照样通过,这是 matches 按盐重算的功劳,不是数据库里存了两个密码。
401 与 403:过滤器层的 problem+json
安全过滤器跑在 DispatcherServlet 之前,spring.mvc.problemdetails.enabled 管不到它们,401 与 403 的响应体要自己写,这是我一开始没料到的。自定义一个组件同时实现两种出口:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void unauthorized ( HttpServletRequest request , HttpServletResponse response ,
AuthenticationException ex ) throws IOException {
ProblemDetail pd = problem ( HttpStatus . UNAUTHORIZED , "未登录" , request ,
"请求缺少有效的 Basic 凭据或凭据无效" );
response . setStatus ( HttpStatus . UNAUTHORIZED . value ());
// Basic 认证的 401 必须带 WWW-Authenticate,客户端靠它知道该用哪种方式认证
response . setHeader ( "WWW-Authenticate" , "Basic realm=\"ops-console\"" );
write ( response , pd );
}
public void forbidden ( HttpServletRequest request , HttpServletResponse response ,
AccessDeniedException ex ) throws IOException {
ProblemDetail pd = problem ( HttpStatus . FORBIDDEN , "无权访问" , request ,
"当前账号的角色不足以访问该资源" );
response . setStatus ( HttpStatus . FORBIDDEN . value ());
write ( response , pd );
}
序列化用 Jackson 3 的 JsonMapper,构造时关掉 null 字段输出,ProblemDetail 没设置 properties 时响应体里就不会出现 "properties": null。type 字段按状态码映射成 urn:problem-type:unauthorized 与 urn:problem-type:forbidden,和 MVC 层渲染的业务异常响应体保持同一套风格。
401 与 403 的分界在认证:拿不出有效身份是 401,认证通过但角色不够才是 403。 用户名不存在对外伪装成凭据错误,同样落 401,不给探测账号清单的调用方留线索。
方法级:@PreAuthorize 收敛敏感操作
路径级规则把 /api/orders/** 放给 OPERATOR 之后,取消订单这种敏感操作要在更细的粒度收敛回 ADMIN:
1
2
3
4
5
6
7
8
9
10
11
12
13
@PreAuthorize ( "hasRole('ADMIN')" )
public Order cancel ( String orderNo ) {
Order order = store . get ( orderNo );
if ( Order . STATUS_CANCELLED . equals ( order . status ())) {
return order ;
}
Order cancelled = new Order ( order . orderNo (), order . amount (), Order . STATUS_CANCELLED ,
order . createdBy (), order . createdAt ());
store . update ( cancelled );
audit . record ( currentUser (), "取消订单" , orderNo , "OK" , "订单已取消" );
log . info ( "ORDER_EVENT cancelled orderNo={} by={}" , orderNo , currentUser ());
return cancelled ;
}
注解拦在方法代理上,比控制器更靠近业务:就算调用方绕过 HTTP 直接注入 OrderService bean 调 cancel,代理照样抛 AccessDeniedException,我写了专门的单测验证这一点。路径级规则管进门,方法级注解管办事,两层各管一段。 配套的 @EnableMethodSecurity 已经贴在 SecurityConfig 类上,少这一个注解,所有 @PreAuthorize 静默失效。
认证事件:成功与失败都有日志行
ProviderManager 在认证出结果时发布事件,我监听成对的两个类型就够了:
1
2
3
4
5
6
7
8
9
10
11
12
13
@EventListener
public void onSuccess ( AuthenticationSuccessEvent event ) {
log . info ( "AUTH_EVENT success principal={} type={}" ,
event . getAuthentication (). getName (),
event . getAuthentication (). getClass (). getSimpleName ());
}
@EventListener
public void onFailure ( AbstractAuthenticationFailureEvent event ) {
log . info ( "AUTH_EVENT failure principal={} reason={}" ,
event . getAuthentication () == null ? "unknown" : event . getAuthentication (). getName (),
event . getException (). getClass (). getSimpleName ());
}
两个事件类型都在 org.springframework.security.authentication.event 包。AUTH_EVENT 前缀的日志行是后面验证脚本与测试的对账依据,认证失败的原因(BadCredentialsException 这类)直接进了日志,安全告警可以按行订阅。
完整案例
我给这个运营后台定了一个最小权限矩阵:
接口 OPERATOR ADMIN GET /api/orders(订单列表) 200 200 POST /api/orders(创建订单) 201 201 POST /api/orders/{no}/cancel(取消订单) 403 200 GET /api/audit(审计查询) 403 200
启动应用,清空两张表,按场景打一轮。先看被拦下的三种:
1
2
3
4
GET /api/orders 匿名 -> 401
GET /api/audit operator -> 403
POST /api/orders/SK-SEC-1/cancel operator -> 403(方法级)
POST /api/audit admin + 错密码 -> 401
匿名请求的 401 与越权请求的 403 响应体逐字如下,instance 指向被拦的 URI,type 区分问题类别:
401 的响应头带着 WWW-Authenticate: Basic realm="ops-console",客户端靠它知道该补 Basic 凭据。两个 403 值得对照:读审计的 403 拦在过滤器层(路径级规则),取消订单的 403 拦在服务层方法代理(@PreAuthorize),出口都归到同一个 AccessDeniedHandler。同一个状态码,两层拦截点,排查时先看是哪一层拦的,能省一半时间。
再看放行的部分:
1
2
3
POST /api/orders {"orderNo":"SK-SEC-2","amount":299.00} operator -> 201
POST /api/orders/SK-SEC-2/cancel admin -> 200
GET /api/audit admin -> 200
建单响应里 createdBy 是 operator,取消响应里 cancelledBy 是 admin,取消成功后审计表落了 1 行:
审计行的数据流跟着取消操作走:@PreAuthorize 放行,业务改订单状态,AuditService 写库,id 从 1 开始说明是本轮首行。日志对账三项全对上:SEED_USER 2 行(两个账号的密文如上),AUTH_EVENT success 7 行、failure 1 行(错误密码那次),整个运行 ERROR 0 行。
测试怎么写
四个测试类覆盖四种能力,18 个用例全绿:
1
2
3
4
PasswordEncodingTest 4 个 BCrypt 编码与匹配、{noop} 前缀分发、库里密文实测
OrderApiSecurityTest 7 个 匿名/OPERATOR/ADMIN 三种身份打三类接口的全景
HttpBasicAuthTest 5 个 真实凭据链路:Basic -> JDBC 用户 -> {bcrypt} 密文
MethodSecurityTest 2 个 绕过 HTTP 直接调服务层,方法代理照样拦
真实认证链路用 httpBasic 请求后处理器打,凭据错误时连响应体和事件一起断言:
1
2
3
4
5
6
7
8
9
10
@Test
void wrong_password_gets_401_with_failure_event ( CapturedOutput output ) throws Exception {
mockMvc . perform ( get ( "/api/audit" ). with ( httpBasic ( "admin" , "badpass" )))
. andExpect ( status (). isUnauthorized ())
. andExpect ( jsonPath ( "$.type" ). value ( "urn:problem-type:unauthorized" ))
. andExpect ( jsonPath ( "$.title" ). value ( "未登录" ));
assertThat ( output ). contains ( "AUTH_EVENT failure principal=admin" );
assertThat ( output ). contains ( "reason=BadCredentialsException" );
}
这条测试同时验证了三件事:401 的 problem+json 响应体、失败事件发布、错误原因进日志。方法级安全的第二现场不经过 HTTP,@WithMockUser 把身份放进 SecurityContext 后直接调 bean:
1
2
3
4
5
6
@Test
@WithMockUser ( username = "op1" , roles = "OPERATOR" )
void direct_service_call_is_stopped_for_operator () {
assertThatThrownBy (() -> orderService . cancel ( "SK-DIRECT-1" ))
. isInstanceOf ( AccessDeniedException . class );
}
@WithMockUser 验授权规则,真实凭据链路要单独测。 它直接把身份塞进上下文,不经过 UserDetailsService,JDBC 查询与 bcrypt 校验在这类测试里一次都没跑过,所以 HttpBasicAuthTest 用真凭据把整条链补上。两类测试我一开始想合一套里偷懒,后来发现它们根本不是一回事,分开学得踏实。
避坑指南
坑一,Basic 凭据错误的 401 不走 exceptionHandling。 这是最反直觉的一个:BasicAuthenticationFilter 认证失败时直接调自己构造时持有的 entry point,exceptionHandling 里配的 authenticationEntryPoint 拦不到这条路。解法是 entry point 挂两处:.httpBasic(basic -> basic.authenticationEntryPoint(...)) 管凭据错误,exceptionHandling 管匿名访问与授权失败。少挂第一处时,错误密码的响应体是空的,我第一次跑就栽在这里,盯着空响应体查了半天。
坑二,@AutoConfigureMockMvc 的包名要先核对。 4.1.1 里它的全限定名是 org.springframework.boot.webmvc.test.autoconfigure,import 写错时 IDE 会提示同名注解不存在,很多人第一反应是 Boot 删了功能,先查包名再下结论。
坑三,security-test starter 不含 @AutoConfigureMockMvc 本体。 spring-boot-starter-security-test 只把 Security 的 MockMvc 定制器登记进 AutoConfigureMockMvc.imports,注解本体在 webmvc-test 里。只引前者,测试类上贴 @AutoConfigureMockMvc 直接编译失败。
坑四,@WithMockUser 不经过 UserDetailsService。 它在测试启动时把 SecurityContext 直接放好,JDBC 用户查询、{bcrypt} 校验、DaoAuthenticationProvider 全部被跳过。授权规则(401/403 分工)用它测没问题,认证链路必须用 httpBasic 之类的真实凭据另测一遍,两类测试不能互相替代。
坑五,角色前缀两边要对齐。 hasRole(“ADMIN”) 内部按 ROLE_ADMIN 比较,JdbcUserDetailsManager 的 authorities 查询里要显式 CONCAT('ROLE_', role)。数据库直接存 ROLE_ADMIN 再 CONCAT 一次,或者查询里忘了 CONCAT,结果都是任何角色都匹配不上,所有受保护接口清一色 403。
坑六,裸 bcrypt 密文会让认证直接抛异常。 DelegatingPasswordEncoder 按密文前缀分发算法,库里存了不带 {bcrypt} 前缀的 $2a$10$... 时,认证阶段抛 IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"。存量数据迁移时给每行密文补前缀,或者注册期统一走 DelegatingPasswordEncoder 的 encode。
坑七,安全过滤器层的响应体要自己写。 401 与 403 发生在 DispatcherServlet 之前,spring.mvc.problemdetails.enabled 与 @RestControllerAdvice 都够不着。自定义 AuthenticationEntryPoint 与 AccessDeniedHandler,用 ProblemDetail 组织内容再手工序列化,null 字段用 NON_NULL 收掉,否则响应体里出现 "properties": null 这类噪音。
坑八,CSRF 关闭的前提是无表单加无会话。 本工程 STATELESS 会话加纯 API,CSRF token 没有作用对象,disable 是有依据的取舍。带浏览器表单或 Cookie 会话的工程照抄这行 disable,等于把跨站请求伪造的防线拆了,这类工程该保留 CSRF 并按框架默认行为走。
小结
权限管理这件事,我的选型建议是按三层来收:路径级规则写进 SecurityFilterChain,管谁进得了门;用户来源交给一个 UserDetailsService bean,数据库加 DelegatingPasswordEncoder 是省心组合;服务层的敏感操作用 @PreAuthorize 收细粒度。自动配置能替你贴的注解就别自己贴,自定义链一出现默认链整条退让,内存用户那几条属性在没有自定义用户源的工程里才生效。碰到带浏览器表单的项目,把 CSRF 和 formLogin 留住,别照搬这篇的无状态配置。
没解决的事也直说:这套方案用的是 HTTP Basic,凭据随每个请求明文传输,生产环境必须压在 HTTPS 下面;登录态、token 签发、密码策略、账号锁定这些我都没碰,权限模型也只到角色这一层,数据级行权限是另一个量级的活儿。这些留给后面的篇章,等我把 OAuth2 资源服务器那条路走通再来对账。
参考链接