ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot集成Spring Security实战:从配置到排错全解析

SpringBoot集成Spring Security实战:从配置到排错全解析

1. 项目概述:为什么在SpringBoot里绕不开Security

“SpringBoot下使用Security”——这短短八个字,是过去五年里我见过最多次出现在简历项目栏、技术面试开场白、以及凌晨两点的Stack Overflow搜索框里的组合。它不是某个炫技的新框架,而是一道几乎无法绕开的工程门槛:当你用SpringBoot快速搭起一个用户能注册、登录、查看个人中心的系统时,Security就是那个默默站在你代码背后、替你挡住90%基础安全风险的守门人。它不声张,但一旦缺位,轻则被爬虫扫光数据库,重则用户密码明文泄露、接口被恶意刷单、后台管理页变成公开游乐场。我带过的几个团队里,有新人用SpringBoot写了三个月业务逻辑,直到上线前一周才第一次听说Security,结果三天内紧急补上配置,又花两周时间排查CSRF误拦截、权限注解失效、密码加密方式不兼容这些“本该一开始就定死”的问题。所以这篇内容,不是教你怎么复制粘贴几行配置,而是带你回到真实开发现场:Security到底在解决什么?为什么SpringBoot要为它专门设计自动装配机制?哪些配置项看似可选,实则动一发而牵全身?它和你写的Controller、Service之间,到底存在怎样的隐式契约?如果你正卡在“登录成功却跳转不到首页”“明明配置了角色却提示403”“密码加密后前端传过来的永远校验失败”这类问题上,那说明你已经踩进了Security最典型的认知断层区——它表面是过滤器链配置,底层其实是整个应用的请求生命周期重写。接下来我会用一个真实压测环境下的案例切入:某次灰度发布时,Security的默认会话超时策略导致大量用户在支付中途被强制登出,我们如何通过源码级调试定位到SessionRegistry的并发计数缺陷,并用一行@Bean覆盖修复。这不是理论推演,而是把Security当成一个需要你亲手拆解、调试、甚至打补丁的活体组件来对待。

2. 核心设计思路与方案选型解析

2.1 Security不是插件,而是应用骨架的重铸

很多初学者把Spring Security当成一个“加个依赖就能用”的安全插件,这是最危险的认知偏差。实际上,当你引入spring-boot-starter-security,SpringBoot自动装配的远不止几个过滤器。它会悄然接管整个HTTP请求处理流程:从Tomcat接收到原始Socket数据,到最终你的@RestController方法返回JSON,中间至少经过12个Security专属过滤器(FilterChainProxy管理的SecurityFilterChain),每个过滤器都可能中断、重定向、或修改请求/响应。比如UsernamePasswordAuthenticationFilter会在/login路径上截获表单提交,解析username和password参数,调用AuthenticationManager进行认证;而ExceptionTranslationFilter则像一个兜底捕手,在任何过滤器抛出AccessDeniedException或AuthenticationException时,统一跳转到登录页或返回401。这种深度侵入性意味着,Security的配置不是“锦上添花”,而是定义应用安全边界的基石。我见过最典型的反模式,是开发者在Controller里手动校验SecurityContextHolder.getContext().getAuthentication(),以为这样就实现了权限控制。实则大错特错——这相当于让保安在大门外巡逻时,还要求每个进门的人自己掏出身份证给前台核对。正确姿势是让Security在请求进入Controller之前就完成所有鉴权,Controller只专注业务逻辑。因此,方案选型的第一原则是:拒绝手动安全校验,拥抱声明式安全。这意味着你要熟练使用@PreAuthorize("hasRole('ADMIN')")、@PostFilter("filterObject.owner == authentication.name")这类注解,而不是在Service里写if (!hasPermission()) throw new AccessDeniedException()。后者不仅耦合度高,更致命的是它绕过了Security的审计日志、失败计数、账户锁定等内置防护机制。

2.2 为什么选择基于内存的InMemoryUserDetailsManager做初期验证?

在项目启动阶段,我强烈建议先用InMemoryUserDetailsManager而非直接对接数据库。这不是偷懒,而是为了隔离验证环节。想象一下:你刚写完登录接口,却遇到“Bad credentials”错误。此时如果后端直连MySQL,你需要同时排查5个层面:前端密码是否被JS加密、HTTP请求头Content-Type是否正确、SpringBoot是否启用了@EnableWebSecurity、UserDetailsService实现类是否被Spring扫描到、数据库密码字段是否按BCrypt格式存储。而换成内存用户,你只需确认三点:UserDetails对象的password字段是否以{bcrypt}$2a$10$...开头、username是否完全匹配(注意大小写)、enabled属性是否为true。我曾帮一个团队定位过一个持续两天的登录失败问题,最终发现是MySQL的utf8mb4字符集导致密码哈希值末尾多了一个不可见空格,而内存方案能瞬间排除数据库干扰。当然,内存方案仅限于开发验证。正式环境必须切换到JdbcUserDetailsManager或自定义UserDetailsService,这时关键在于密码编码器的全局一致性。Spring Security 5.0之后强制要求显式声明PasswordEncoder,且必须与数据库中存储的哈希格式严格匹配。常见陷阱是:开发时用NoOpPasswordEncoder(明文存储),上线后换成BCryptPasswordEncoder,结果所有老用户无法登录。解决方案是在SecurityConfig中定义@Bean PasswordEncoder passwordEncoder(),并确保DAO层插入新用户时,调用passwordEncoder.encode(rawPassword)再存库。这里有个硬性经验:永远不要在SQL脚本里硬编码密码哈希值,而要在应用启动时通过CommandLineRunner批量初始化测试账号。

2.3 过滤器链顺序:为什么CorsConfigurationSource必须在SecurityFilterChain之前?

Spring Security的过滤器链顺序不是随意排列的,而是遵循严格的“先跨域、再认证、后授权”逻辑。CorsConfigurationSource负责处理浏览器预检请求(OPTIONS),它必须在任何认证过滤器之前执行。否则,当前端发起带Authorization头的跨域请求时,浏览器会先发一个无头的OPTIONS请求,而Security的BasicAuthenticationFilter会因缺少凭证直接返回401,导致预检失败,后续真正的GET/POST请求根本发不出去。这个问题在Vue+SpringBoot联调时高频出现。解决方案是在SecurityConfig中显式配置CORS:

@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(corsConfigurationSource())) // 必须第一行 .csrf(csrf -> csrf.disable()) // 开发期可禁用,生产必须配置 .authorizeHttpRequests(authz -> authz .requestMatchers("/public/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ); return http.build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList("http://localhost:8080", "https://myapp.com")); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowCredentials(true); configuration.addExposedHeader("X-Total-Count"); // 暴露分页总数头 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; }

注意cors.configurationSource(...)必须是http配置块的第一行,这是Spring Security 6.x的强制约定。另外,setAllowCredentials(true)时,allowedOrigins不能为*,必须指定具体域名,否则浏览器会拒绝携带Cookie的请求——这直接导致登录态无法维持。

3. 核心细节解析与实操要点

3.1UserDetails接口的四个必重写方法:不只是签名游戏

UserDetails是Security用户模型的核心契约,但很多人只关注getUsername()和getPassword(),却忽略其余三个方法对系统稳定性的影响。isEnabled()控制账户启用状态,但它的真正威力在于与LockoutFailureHandler联动:当连续登录失败5次,JdbcUserDetailsManager会将数据库enabled字段设为false,此时isEnabled()返回false,Security自动拒绝后续所有认证请求,无需额外代码。isAccountNonExpired()和isCredentialsNonExpired()则分别对应账户有效期和密码有效期。我在某金融项目中遇到过线上事故:运营人员误将测试账号的accountNonExpired设为false,导致所有用户无法登录,因为Security在认证成功后会二次校验这两个方法,任一返回false即抛出DisabledException。因此,数据库设计时必须包含account_non_expired、credentials_non_expired、account_locked、enabled四个布尔字段,并在UserDetails实现类中严格映射。更关键的是,getAuthorities()返回的Collection<? extends GrantedAuthority>不能是null或空集合,否则FilterSecurityInterceptor会因无法解析权限而抛出AccessDeniedException。我推荐的实现是:

@Override public Collection<? extends GrantedAuthority> getAuthorities() { // 避免返回null,空权限也返回空集合 return Optional.ofNullable(authorities) .map(ArrayList::new) .orElseGet(ArrayList::new); }

3.2 密码编码器的版本陷阱:BCrypt的strength参数为何必须是10?

BCryptPasswordEncoder的strength参数(默认10)代表哈希计算的轮数,值越大越安全但越耗CPU。但很多人不知道,这个参数必须与数据库中已存哈希值的轮数严格一致。BCrypt哈希字符串形如$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy,其中$10$就是轮数值。如果数据库里存的是$2a$12$...(strength=12),而代码中用new BCryptPasswordEncoder(10),则matches()方法永远返回false。更隐蔽的问题是$2a$、$2b$、$2y$前缀的兼容性。$2a$是旧版,$2y$是修复了特定漏洞的版本,Spring Security 5.7+默认使用$2y$。如果你的遗留系统用$2a$生成密码,新代码必须显式指定:

@Bean public PasswordEncoder passwordEncoder() { // 兼容旧版$2a$前缀 return new BCryptPasswordEncoder(10, new SecureRandom()); }

而生产环境推荐使用DelegatingPasswordEncoder,它能根据哈希前缀自动选择解码器:

@Bean public PasswordEncoder passwordEncoder() { Map<String, PasswordEncoder> encoders = new HashMap<>(); encoders.put("bcrypt", new BCryptPasswordEncoder(12)); encoders.put("pbkdf2", new Pbkdf2PasswordEncoder()); return new DelegatingPasswordEncoder("bcrypt", encoders); }

此时数据库密码需以{bcrypt}$2a$...或{pbkdf2}$...开头,Security会自动识别前缀并委托对应编码器处理。

3.3 CSRF防护的现代取舍:为什么REST API通常禁用,而管理后台必须启用?

CSRF(跨站请求伪造)攻击的本质是利用用户已登录的Cookie,在不知情下提交恶意请求。对于传统表单提交的Web应用(如Thymeleaf模板),CSRF Token是刚需:表单中嵌入<input type="hidden" name="_csrf" value="...">,Security在CsrfFilter中校验该Token。但对纯REST API(如Vue调用/api/user),CSRF防护反而成为负担。原因有三:第一,前端通常用JWT或Bearer Token认证,不依赖Cookie,攻击者无法窃取Token;第二,每次请求需携带CSRF Token,增加前端复杂度;第三,移动端App无法像浏览器一样自动携带Cookie。因此,我的实践准则是:前后端分离的API服务,禁用CSRF;基于Session的传统Web应用,必须启用并正确传递Token。禁用方法很简单:

http.csrf(csrf -> csrf.disable())

但禁用不等于放弃防护。此时应强化其他层:1)所有敏感操作(删除、支付)必须用POST/PUT/DELETE,禁用GET;2)关键接口添加二次验证(短信/邮箱验证码);3)使用SameSite=StrictCookie属性防止跨站携带。而对于管理后台,CSRF Token必须贯穿全流程。我曾在一个后台系统中发现,前端用Axios全局拦截器自动注入X-CSRF-TOKEN头,但忘记在login接口成功后更新Token,导致后续所有请求因Token过期被拒。解决方案是在登录成功后的AuthenticationSuccessHandler中,将新Token写入响应头:

public class CustomAuthSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) throws IOException { // 生成新CSRF Token并写入响应头 CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf"); response.setHeader("X-CSRF-TOKEN", csrfToken.getToken()); response.sendRedirect("/admin/dashboard"); } }

4. 实操过程与核心环节实现

4.1 从零搭建一个可运行的Security配置:逐行解读关键配置

我们以一个标准的前后端分离项目为例,实现登录、权限控制、登出全流程。首先添加依赖(pom.xml):

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency>

注意:spring-boot-starter-security必须显式声明,SpringBoot 3.x不再默认启用Security。接下来创建核心配置类:

@Configuration @EnableWebSecurity public class SecurityConfig { // 1. 定义密码编码器(必须) @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } // 2. 定义用户详情服务(必须) @Bean public UserDetailsService userDetailsService(JdbcTemplate jdbcTemplate) { String usersByUsernameQuery = "select username, password, enabled from users where username = ?"; String authsByUserQuery = "select username, authority from authorities where username = ?"; return new JdbcUserDetailsManager(jdbcTemplate, usersByUsernameQuery, authsByUserQuery); } // 3. 定义安全过滤器链(必须) @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // CORS配置(必须在第一位) .cors(cors -> cors.configurationSource(corsConfigurationSource())) // 禁用CSRF(REST API场景) .csrf(csrf -> csrf.disable()) // 会话管理 .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .invalidSessionUrl("/login?invalid-session=true") .maximumSessions(1) .maxSessionsPreventsLogin(false) .expiredSessionStrategy(new CustomExpiredSessionStrategy()) ) // 异常处理 .exceptionHandling(exceptions -> exceptions .authenticationEntryPoint(new CustomAuthEntryPoint()) .accessDeniedHandler(new CustomAccessDeniedHandler()) ) // 请求授权规则 .authorizeHttpRequests(authz -> authz .requestMatchers("/login", "/register", "/public/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN") .anyRequest().authenticated() ) // 表单登录配置 .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/perform_login") .defaultSuccessUrl("/dashboard", true) .failureUrl("/login?error=true") .usernameParameter("username") .passwordParameter("password") .successHandler(new CustomAuthSuccessHandler()) .failureHandler(new CustomAuthFailureHandler()) ) // 登出配置 .logout(logout -> logout .logoutUrl("/perform_logout") .logoutSuccessUrl("/login?logout=true") .invalidateHttpSession(true) .clearAuthentication(true) .deleteCookies("JSESSIONID") ); return http.build(); } // 4. CORS配置源(必须) @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000")); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowCredentials(true); configuration.addExposedHeader("X-Total-Count"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } }

关键点解析:

  • @EnableWebSecurity是Spring Security 6.x的必需注解,替代了旧版的@EnableWebMvcSecurity。
  • sessionManagement()中maximumSessions(1)限制单用户只能一处登录,maxSessionsPreventsLogin(false)表示新登录时踢掉旧会话而非拒绝新登录。
  • formLogin().loginProcessingUrl("/perform_login")指定了表单提交的目标URL,必须与HTML表单的action属性一致。
  • logout().deleteCookies("JSESSIONID")显式删除会话Cookie,避免登出后Cookie仍有效。

4.2 数据库初始化:H2内存库的完整建表与测试数据

为快速验证,我们使用H2内存数据库。在application.yml中配置:

spring: h2: console: enabled: true path: /h2-console datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop

创建初始化SQL(src/main/resources/data.sql):

-- 创建用户表 CREATE TABLE users ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, enabled BOOLEAN DEFAULT TRUE, account_non_expired BOOLEAN DEFAULT TRUE, credentials_non_expired BOOLEAN DEFAULT TRUE, account_locked BOOLEAN DEFAULT FALSE ); -- 创建权限表 CREATE TABLE authorities ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, authority VARCHAR(50) NOT NULL, CONSTRAINT fk_authorities_users FOREIGN KEY (username) REFERENCES users(username) ); -- 插入测试用户(密码为'password'经BCrypt编码) INSERT INTO users (username, password, enabled) VALUES ('admin', '{bcrypt}$2a$12$KQVfBvYxZzRwTtUuIiOoPpNnMmLlKkJjHhGgFfDdSsAaZzXx', TRUE), ('user', '{bcrypt}$2a$12$KQVfBvYxZzRwTtUuIiOoPpNnMmLlKkJjHhGgFfDdSsAaZzXx', TRUE); -- 插入权限 INSERT INTO authorities (username, authority) VALUES ('admin', 'ROLE_ADMIN'), ('admin', 'ROLE_USER'), ('user', 'ROLE_USER');

注意:密码哈希值需用BCryptPasswordEncoder.encode("password")生成,不能手写。可在@PostConstruct方法中动态生成:

@Component public class DataInitializer { @Autowired private JdbcTemplate jdbcTemplate; @Autowired private PasswordEncoder passwordEncoder; @PostConstruct public void init() { String encodedPassword = passwordEncoder.encode("password"); jdbcTemplate.update( "INSERT INTO users (username, password, enabled) VALUES (?, ?, ?)", "admin", encodedPassword, true ); jdbcTemplate.update( "INSERT INTO authorities (username, authority) VALUES (?, ?)", "admin", "ROLE_ADMIN" ); } }

4.3 前端联调关键:如何让Vue Axios正确携带认证信息

前后端分离下,登录成功后前端必须持久化认证凭证。Spring Security默认使用JSESSIONIDCookie,因此前端需配置Axios全局设置:

// axios.js import axios from 'axios' // 启用withCredentials,使请求携带Cookie axios.defaults.withCredentials = true // 设置基础URL axios.defaults.baseURL = 'http://localhost:8080' // 请求拦截器:添加CSRF Token(如果启用) axios.interceptors.request.use(config => { const csrfToken = localStorage.getItem('X-CSRF-TOKEN') if (csrfToken && config.method.toUpperCase() !== 'GET') { config.headers['X-CSRF-TOKEN'] = csrfToken } return config }) // 响应拦截器:处理401/403 axios.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { window.location.href = '/login' } return Promise.reject(error) } ) export default axios

关键点:

  • withCredentials = true是必须的,否则浏览器不会发送Cookie。
  • localStorage.getItem('X-CSRF-TOKEN')需在登录成功后由后端写入(见4.1节CustomAuthSuccessHandler)。
  • 对于GET请求,通常不需CSRF Token,故拦截器中做了方法判断。

登录接口调用示例:

// login.vue methods: { async handleLogin() { try { const res = await axios.post('/perform_login', { username: this.username, password: this.password }) // 登录成功,跳转首页 this.$router.push('/dashboard') } catch (error) { this.errorMessage = '用户名或密码错误' } } }

注意:/perform_login是Security配置的loginProcessingUrl,不是自定义Controller。Spring Security会自动处理此路径。

5. 常见问题与排查技巧实录

5.1 问题速查表:高频故障现象与根因定位

现象可能根因排查步骤解决方案
登录成功后跳转到/login页面循环defaultSuccessUrl未设alwaysUse=true,或/dashboard被permitAll()放行1. 检查SecurityConfig中defaultSuccessUrl("/dashboard", true)
2. 查看/dashboard是否在permitAll()列表中
移除/dashboard的permitAll(),确保其需要authenticated()
访问/admin/**返回403 Forbidden用户无ROLE_ADMIN权限,或hasRole('ADMIN')未匹配数据库中的ROLE_ADMIN1. 查询数据库authorities表,确认用户有ROLE_ADMIN记录
2. 检查getAuthorities()返回的GrantedAuthority是否含ROLE_ADMIN
确保数据库权限值为ROLE_ADMIN(必须带ROLE_前缀),或改用hasAuthority('ADMIN')
登录后无法访问任何接口,全部401前端未发送JSESSIONIDCookie,或withCredentials=false1. 浏览器开发者工具Network标签,检查请求Headers是否有Cookie: JSESSIONID=xxx
2. 检查Axios配置withCredentials=true
在Axios全局配置中设置withCredentials = true,并确保后端CORS允许凭据
Bad Credentials错误,但密码确认正确密码编码器不匹配,或数据库密码字段长度不足1. 检查BCryptPasswordEncoder的strength是否与哈希值前缀一致
2. 查看数据库password字段类型是否为VARCHAR(100)(BCrypt哈希约60字符)
将密码字段改为VARCHAR(100),确保编码器strength与哈希前缀一致
登出后仍能访问受保护接口logout()配置未invalidateHttpSession(true),或前端未清除Cookie1. 检查SecurityConfig中logout().invalidateHttpSession(true)
2. 浏览器Application标签,确认JSESSIONIDCookie已被删除
显式配置invalidateHttpSession(true)和deleteCookies("JSESSIONID")

5.2 深度调试技巧:如何用断点精准定位Security过滤器链问题

当常规排查无效时,需深入Security源码。我的标准调试流程如下:

第一步:开启Security调试日志在application.yml中添加:

logging: level: org.springframework.security: DEBUG org.springframework.web: DEBUG

启动后,控制台会输出详细的过滤器链执行日志,例如:

Securing GET /login Will not secure GET /login Securing POST /perform_login Trying to match using Ant [pattern='/login'] Checking match of request : '/perform_login'; against '/login'

第二步:在关键过滤器打条件断点在IDE中打开UsernamePasswordAuthenticationFilter,在其attemptAuthentication()方法首行打断点。条件设置为request.getRequestURL().toString().contains("perform_login"),确保只在登录请求时触发。

第三步:跟踪AuthenticationManager执行流当断点停住,F7步入this.getAuthenticationManager().authenticate(authentication),你会进入ProviderManager.authenticate()。此处可观察AuthenticationProvider列表,确认DaoAuthenticationProvider是否在首位。若UserDetailsService未正确注入,此处会抛出ProviderNotFoundException。

第四步:检查SecurityContext是否持久化在任意Controller方法中,添加断点并执行:

Authentication auth = SecurityContextHolder.getContext().getAuthentication(); System.out.println("Current auth: " + auth);

若返回null,说明Security上下文未正确绑定到当前线程。常见原因是异步方法(@Async)中未手动传播上下文,需配置SecurityContextPropagation。

5.3 生产环境避坑清单:那些文档不会写的血泪教训

  • 会话超时时间必须前后端一致:Spring Boot的server.servlet.session.timeout=1800(30分钟)必须与前端Token刷新逻辑同步。我曾遇到前端每25分钟刷新一次Token,但后端Session 30分钟过期,导致第28分钟的请求因Session失效被重定向到登录页。解决方案是后端Session超时设为45分钟,前端每35分钟刷新。

  • @PreAuthorize注解在@Service层失效:默认情况下,@EnableGlobalMethodSecurity(旧版)或@EnableMethodSecurity(新版)只对@Controller生效。若要在Service层使用,必须在配置类上添加proxyTargetClass = true:

    @EnableMethodSecurity(proxyTargetClass = true)
  • HikariCP连接池与Security的UserDetailsService冲突:当JdbcUserDetailsManager查询用户时,若数据库连接池已满,会导致登录请求阻塞。解决方案是为Security专用数据源配置独立连接池:

    @Bean @Primary public DataSource securityDataSource() { HikariDataSource ds = new HikariDataSource(); ds.setJdbcUrl("jdbc:h2:mem:testdb"); ds.setUsername("sa"); ds.setMaximumPoolSize(5); // 限制为5,避免抢占主业务连接 return ds; }
  • @Order注解的隐形陷阱:当项目中有多个SecurityFilterChainBean(如API和管理后台分离配置),必须用@Order明确优先级。@Order(SecurityProperties.BASIC_AUTH_ORDER - 10)是常用值,但更安全的做法是用常量:

    @Bean @Order(1) public SecurityFilterChain apiFilterChain(HttpSecurity http) { ... } @Bean @Order(2) public SecurityFilterChain adminFilterChain(HttpSecurity http) { ... }

最后分享一个小技巧:在SecurityConfig中添加一个@Bean用于打印所有启用的过滤器,方便快速确认链结构:

@Bean public FilterRegistrationBean<Filter> filterRegistrationBean() { FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new OncePerRequestFilter() { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { System.out.println("Active filters: " + Arrays.toString( ((FilterChainProxy) request.getAttribute("org.springframework.security.web.FilterChainProxy")).getFilterChains().toArray())); filterChain.doFilter(request, response); } }); registrationBean.setOrder(Ordered.LOWEST_PRECEDENCE); return registrationBean; }

这个Bean会在每次请求时打印当前激活的过滤器链,是排查“为什么这个请求没进Security”的终极利器。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进