Spring Security实战:认证授权、过滤器链与SpringBoot集成全解析 Spring Security这个名字凡是用Java做过后端Web项目的朋友基本都会在某一天撞上它。我印象很深第一次接手一个带有登录权限的旧项目时打开配置类看到一长串security的配置先是懵了再是改一个小需求就得重启半天改完还不确定会不会把别的接口拦掉。后来系统地把过滤器链、认证流程、授权模型摸了一遍才发现这东西并不玄乎核心就是一条过滤器链加几个扩展点。这篇博文我想用一篇文章的量把SpringBoot加Spring Security最实用的部分掰开揉碎讲清楚认证和授权的关系、过滤器链怎么串起来、最小工程怎么搭、表单登录背后发生了什么、接口权限怎么设计以及最常见的报错和排查思路。适合刚接触Spring Security的Web开发也适合用过但一直是靠模板配置、碰到问题就想问搜索引擎的朋友。1. 前置认知Spring Security到底管了哪些事1.1 认证和授权别把两件事搅在一起很多人在配置Spring Security的时候越写越乱根源在于没分清认证和授权。这两个概念就像进门和上楼你要进一栋写字楼保安先看你是谁这是认证进去之后你能去三楼财务室还是只有一楼大厅这是授权。认证解决你是不是你的问题授权解决你能干什么的问题。再说具体一点。认证是用户提交用户名密码系统验证凭证是否正确验证通过后系统就知道当前用户是谁把这个身份信息放进一个叫SecurityContext的东西里。授权是系统根据这个身份判断用户能不能访问某个接口、调用某个方法、操作某份数据。Spring Security默认的认证机制支持表单登录、HTTP Basic、OAuth2、CAS等一大票方式但不管用哪种方式最终都会生成一个Authentication对象里面装着身份信息和权限列表。而授权部分的判断不管是基于URL路径还是基于方法注解本质都是通过这个Authentication对象里携带的权限信息做匹配。如果你没有这个概念配置的时候很容易出现一个典型问题在认证环节里纠结某个角色能不能访问某个URL或者在授权配置里写一堆记住了用户是谁的代码最后两边都拧巴。我见到不少初学者拿着一段配置问为什么我登录成功了还是访问不了一问才发现他把登录逻辑和接口权限判断混在了同一个自定义过滤器里。先想清楚你要解决的是认证还是授权再去动代码思路会顺畅很多。1.2 Spring Security的过滤器链是怎么串起来的Spring Security能拦下所有请求靠的是Servlet规范里的Filter机制。我们知道Servlet容器处理一次请求会经过一系列过滤器这些过滤器串成一条链每个过滤器可以决定继续放行还是拦截。Spring Security就是往这条链里插入了一整串和认证授权相关的过滤器并统一由DelegatingFilterProxy代理注册让SpringBoot程序在启动时自动生效。这一串过滤器里从上到下比较关键的几个大概是这样的最前面是SecurityContextHolderFilter负责从当前请求上下文中恢复SecurityContext也就是这个人上次登录过吗中间有UsernamePasswordAuthenticationFilter专门处理表单登录提交的请求把用户名密码封装成认证请求交给AuthenticationManager后面还有FilterSecurityInterceptor负责做最终的授权判断。整条链的执行逻辑一句话概括先尝试让当前请求带上身份信息再做权限判断全部通过就放行任何一个环节不过就抛异常。这里面有个很重要的点你写的SecurityFilterChain其实就是在定义这条过滤器链的行为比如哪些请求路径要拦截、登录页是什么、用什么方式认证。当配置里某个环节顺序不对时请求很可能还没走到认证过滤器就被权限过滤器拦下表现出来就是你配置了登录页但页面总是跳转异常。我在做项目排查的时候第一步永远是先梳理当前系统的过滤器链顺序很多疑难杂症都是一看链结构就明白了。2. 快速搭建一个能跑的最小工程2.1 依赖引入与版本选择如果你用的是SpringBoot引入安全依赖的方式非常简单。建议创建一个新的Maven项目继承spring-boot-starter-parent这样不用手动管理Spring Boot框架和Spring Security的版本号避免版本不匹配导致的诡异问题。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency /dependencies我知道有的朋友用的是SpringBoot 2.x那也没关系下面的配置思路完全通用只是部分API在写法上有差异。比如SpringBoot 2.7及之后版本推荐用lambda风格的配置方法老式的WebSecurityConfigurerAdapter已经在2.7被废弃3.x彻底移除了所以不要再去用继承适配器的方式写配置了新同学直接学新写法就行。2.2 默认行为什么都不配时会发生什么仅引入依赖启动项目你会看到一个现象访问任何接口都会弹出一个默认的登录页面用户名是固定值user密码不是你自己设置的而是随机生成后打印在了启动日志里是一段类似Using generated security password: xxxxxx的文字。这个密码每次重启都会变化。这个行为是Spring Security自动配置导致的。它帮你生成了一套最基础的默认安全策略所有请求必须认证登录走内置表单页用户是内存里临时创建的一个普通用户。这套逻辑的意义是保证项目在引入安全依赖后立刻具备最基本的防护能力不至于裸奔上线。但它显然不是我们实际想要的配置因为你不可能每次都从日志里抄密码让用户去登录。所以最小工程的第一步就是写一个自定义SecurityFilterChain配置把默认策略替换成自己想要的策略。从这一步开始你就正式接管了认证授权规则的制定权。2.3 第一个自定义用户与登录页我们先用内存用户写一个最简单的可运行配置实际项目中内存用户基本不会用但它能帮你跑通整个Spring Security的认证流程也适合用来做后续测试。配置类代码如下import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize - authorize .requestMatchers(/, /home, /css/**, /js/**, /images/**).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .formLogin(form - form .loginPage(/login) .defaultSuccessUrl(/index) .permitAll() ) .logout(logout - logout .logoutUrl(/logout) .logoutSuccessUrl(/login) ); return http.build(); } Bean public UserDetailsService userDetailsService() { UserDetails admin User.withUsername(admin) .password(passwordEncoder().encode(123456)) .roles(ADMIN) .build(); UserDetails normalUser User.withUsername(user) .password(passwordEncoder().encode(123456)) .roles(USER) .build(); return new InMemoryUserDetailsManager(admin, normalUser); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }这段配置里最关键的是三件事一是authorizeHttpRequests定义了不同路径的访问规则二是formLogin把登录页指向了/login这个我们自己写的页面三是userDetailsService定义了系统中存在的用户身份。注意这里定义了admin和user两个角色角色名会自动带一个ROLE_前缀hasRole(ADMIN)等价于要求用户拥有ROLE_ADMIN权限这是Spring Security的默认约定。你还需要提供一个/login的控制器方法或者静态页面不然点击登录会找不到视图。另外Spring Security里登录表单提交的接口是没有显式写在普通Controller里的默认是POST到/login表单里用户名和密码的字段名默认是username和password。注意不要用明文密码放在内存用户里。示例中我用BCryptPasswordEncoder做了加密这一点在实际项目中无论任何环境都成立。密码明文存储一旦数据库泄露就是安全事故BCrypt是目前比较推荐的选项自带盐值相同密码每次加密结果都不同。3. 认证核心流程拆解3.1 表单登录的完整请求链路配置看起来简单但很多人只在网上复制了这么一段根本不理解登录时Spring Security具体做了什么。等你需要处理登录成功之后往哪里跳怎么记录用户信息为什么修改密码后旧会话还能用这些问题时不懂底层链路就会非常被动。走一遍表单登录的完整流程大概是这样的用户填写完用户名密码点提交浏览器把请求发到/login这个URL。UsernamePasswordAuthenticationFilter拦截到这个POST请求把表单里的username和password取出来封装成一个UsernamePasswordAuthenticationToken对象这个Token本质上是用户还没通过验证的认证请求。然后它会把这个Token交给AuthenticationManager去处理。AuthenticationManager是个核心调度器它本身不做校验而是找能处理这个Token的AuthenticationProvider。你配置的PasswordEncoder和UserDetailsService就是在这里被用到的。比如默认的DaoAuthenticationProvider它先调用UserDetailsService.loadUserByUsername拿到系统里保存的用户信息如果没有找到这个用户直接抛UsernameNotFoundException找到了就再用PasswordEncoder比对密码密码错误抛BadCredentialsException。校验通过后Provider会重新生成一个完整的Authentication对象里面包含用户信息和完整的权限列表这个对象会被放进SecurityContextHolder之后你就能在代码里用SecurityContextHolder.getContext().getAuthentication()拿到当前登录人的信息了。最后Spring Security会把SecurityContext存到Session里下次同一个浏览器再来访问时SecurityContextHolderFilter会优先从Session里把之前保存的认证信息恢复出来这样就不需要每次请求都重新登录一遍。3.2 UserDetailsService与PasswordEncoder的配合理解了流程你就知道实际项目的核心扩展点就落在UserDetailsService和PasswordEncoder上。内存用户只是演示用的真实项目中用户名密码都存在数据库里你只需要实现UserDetailsService接口告诉Spring Security怎么根据用户名查用户。下面是一个从数据库查用户的自定义实现示例搭配MyBatis或者Jpa都可以import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; Service public class DatabaseUserDetailsService implements UserDetailsService { private final UserMapper userMapper; public DatabaseUserDetailsService(UserMapper userMapper) { this.userMapper userMapper; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser userMapper.findByUsername(username); if (sysUser null) { throw new UsernameNotFoundException(用户不存在); } return org.springframework.security.core.userdetails.User .withUsername(sysUser.getUsername()) .password(sysUser.getPassword()) .roles(sysUser.getRoles().split(,)) .build(); } }注意几个细节loadUserByUsername返回的是Spring Security的UserDetails对象这和你自己的用户实体SysUser是两个东西千万别混。UserDetails里的密码必须是已经加密过的密文因为你前面配置的PasswordEncoder是BCrypt再加密Spring Security拿你表单提交的明文密码去和这个密文比对。如果你在数据库里存的是明文比对就会失败即使你看着密码一样也登不进去。有时候我们看到数据库里的密码字段是一串很长的带花括号前缀的字符串比如{bcrypt}开头这是Spring Security的DelegatingPasswordEncoder在起作用它会在密码哈希前把密码的编码方案类型存下来方便不同编码方案兼容。你如果直接使用BCryptPasswordEncoder就不需要去管这个前缀但如果你想在同一个系统里兼容多种加密方式可以考虑使用PasswordEncoderFactories.createDelegatingPasswordEncoder()这个工厂方法它能自动根据密码前缀识别方案。3.3 会话与Remember-Me机制要点默认情况下Spring Security会把登录成功的SecurityContext放进Session这个过程的代码你不需要自己写。但你至少要知道两件事一是会话ID存在浏览器的Cookie里二是Session有超时时间超时后用户就需要重新登录。对于需要下次访问不用重新登录的场景Spring Security提供了RememberMe功能。开启方式很简单在HttpSecurity配置里加上rememberMe()即可.rememberMe(withDefaults())原理说白了就是登录成功后发一个带签名信息的Cookie给浏览器下次访问时Spring Security通过Cookie里的签名来识别用户帮助你自动登录。这里有个默认情况要注意Remember-Me的开关是登录页里一个名为remember-me的复选框如果你希望默认开启可以配置rememberMeParameter(remember-me)配合前端参数来控制。生产环境中如果用户勾选了记住我你的Cookie安全属性也要检查好Secure和HttpOnly尽量都打开。我遇到过一类问题开发时用localhost一切正常部署到某个环境后每次请求接口都返回未登录查来查去发现是Cookie字段的Secure属性导致某些非HTTPS环境下Cookie没发这种问题在本地环境很难复现排查挺费劲的。后面讲到问题速查时再展开。4. 授权配置接口权限怎么设计4.1 基于请求路径的权限控制授权配置是最日常的内容也是最容易写乱的。最常见的写法是按请求路径来区分把接口按功能模块分成几类分别设置不同的访问规则。继续用我们之前的配置举例.authorizeHttpRequests(authorize - authorize .requestMatchers(/, /home, /css/**, /js/**, /images/**).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/user/**).hasAnyRole(USER, ADMIN) .anyRequest().authenticated() )这里要注意匹配顺序的问题规则是从上往下按顺序匹配第一个被命中的规则生效。所以越具体的路径应该放在越前面anyRequest().authenticated()作为兜底规则放最后。如果规则顺序反了比如把permitAll放在最前面所有请求都会直接放行后面的权限规则形同虚设。路径表达式里支持**匹配多级目录*匹配单级目录。注意requestMatchers方法支持传多个字符串也支持用AntPathRequestMatcher等更复杂的方式但对于大多数项目直接传字符串路径就够用了。如果你用了SpringBoot与Spring Security的新API部分写法可能提示WebSecurityConfigurerAdapter的旧方法已废弃别慌按新API调整即可。4.2 方法级安全注解与表达式路径级别的控制能拦住大部分未授权访问但有时候规则粒度需要更细比如同一个Controller里根据操作者身份不同放行的接口也不同。Spring Security从5.6开始推荐使用方法级安全配置在配置类上加上EnableMethodSecurity注解旧版本是EnableGlobalMethodSecurity然后在方法上使用PreAuthorize注解来声明访问条件。import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api) public class DemoController { GetMapping(/public) public String publicApi() { return 这是一个公开接口; } PreAuthorize(hasRole(ADMIN)) GetMapping(/admin) public String adminApi() { return 只有管理员能访问; } PreAuthorize(hasAnyRole(ADMIN, USER)) GetMapping(/user) public String userApi() { return 登录用户都能访问; } PreAuthorize(hasRole(ADMIN) and #id authentication.principal.id) GetMapping(/user/{id}) public String selfCheck(PathVariable Long id) { return 只能操作自己的资源; } }方法级安全的核心优势是控制粒度精细到单方法级别而且可以使用SpEL表达式比如检查当前用户的用户名、判断是否拥有某个权限、甚至结合方法参数做数据级校验。上面的第三个示例就演示了怎么在表达式里同时引用路径参数和当前认证对象。方法级配置要做通需要把Spring的AOP原理粗略了解一点PreAuthorize本质上靠AOP增强来拦截方法调用和过滤器链是两回事但两者经常配合使用路径配置控制大方向方法注解控制更细的权限维度。4.3 动态权限与自定义鉴权逻辑的取舍实际项目中你可能会遇到这样的需求系统管理员可以在后台管理页面里动态给不同角色配权限比如给运营角色开放某个后台报表接口权限规则存在数据库里改完不用重启就生效。这就意味着你没法把这套权限写死在静态配置里Spring Security也提供了自定义扩展点。一种常见的做法是自定义一个FilterInvocationSecurityMetadataSource用来动态加载每个URL对应的访问权限列表再自定义一个AccessDecisionManager重写决策逻辑决定当前用户是否拥有访问该URL所需权限。这个方案的原理不算复杂但实现起来代码量不小还容易在请求高峰期引入性能隐患因为每次请求都要查库。我的建议是不要一上来就做动态权限。多数项目的权限需求其实很固定角色也不多用路径方法级权限完全够用。真正要做动态权限时先把缓存做好别让数据库在每次请求都被打一次。另外权限模型也要提前设计好RABC模型加角色继承基本能满足大多数场景。最简单的动态方案其实是动态生成或修改SecurityFilterChain里的规则而不是重写整套决策组件但那样做需要评估好管理的复杂度和对现有请求的影响范围。5. 从入门到落地常见问题与排查技巧实录5.1 常见报错速查表我整理了一张高频问题速查表都是我实际项目里踩过或者看别人反复踩的坑当你看到对应报错时可以快速定位方向。报错信息或现象常见原因解决思路401 Unauthorized未登录访问受保护接口认证失败确认请求是否携带登录凭证检查登录失败原因403 Forbidden已登录但权限不足或CSRF拦截检查hasRole角色前缀、路径匹配顺序、是否关闭CSRFThere is no PasswordEncoder mapped for the id null数据库密码是明文没有编码器ID配置PasswordEncoder密码改为加密存储或用DelegatingPasswordEncoder兼容404后跳转到/login失败登录页视图路径不对或控制器缺失确认存在/login对应的Controller或静态页确认permitAll放行路径明明登录成功但接口总是返回未认证Session失效或Cookie未携带检查会话超时配置、Cookie的Secure属性、跨域场景下的凭证带带问题Bad credentials用户名不存在或密码错误分开排查用户是否存在、密码策略、加密器是否一致AccessDeniedException抛出后页面是白屏没有配置403处理逻辑用exceptionHandling配置accessDeniedHandler或authenticationEntryPoint记着一点401和403很容易搞混。401是你还没证明你是谁403是我知道你是谁但你没权限。很多朋友调试时看到403就一直调权限配置其实是因为CSRF拦截了POST请求。具体区别我下面单独说。5.2 实操中容易踩的坑第一个坑是CSRF导致的POST请求403。Spring Security默认开启CSRF防护它要求POST、PUT、DELETE这些可能改变状态的请求必须携带一个CSRF Token。如果你没有在前端把Token塞进请求头或表单隐藏域就会出现GET请求没问题POST请求全部403的诡异情况。解决方案有两种前端登录页和表单处理都用模板引擎的自动注入或者等待静态前后端分离场景下接口配置里对特定路径关闭CSRF。我这里要特别提醒关闭CSRF要谨慎尤其是带Cookie会话的Web系统CSRF还是有意义的如果是纯后端API且使用无状态Token方案关闭是常见选择。第二个我遇到最多的是跨域问题。前端在某端口跑Vue或React后端接口在某8080端口登录接口能通但带上token后的请求一直跨域报错。后来发现是Spring Security在过滤器链里加了CORS处理而我没在Security配置里启用cors()导致预检请求OPTIONS被拦截了。解决办法就是在HttpSecurity上调用http.cors()并在项目里定义好CorsConfigurationSource把允许的源、请求头、方法都配好。这件事单独配SpringMVC的CrossOrigin往往不够因为很多实际接口请求先过Spring Security层的过滤器安全层如果不放行后面的控制器根本收不到请求。第三个是关于登录页的默认路径冲突。有人总问怎么自定义登录路径改了loginPage又发现提交地址不对或者登录失败后跳不到自己写的错误页。其实核心是你要记住loginPage定义了用户未登录时重定向到哪里这个地址你直接写一个Controller映射返回视图即可但表单提交的真正地址默认还是POST /login除非你用loginProcessingUrl另外指定。很多状况都是这里没有理清。第四个是测试期间一直在调试内存用户是否生效但修改配置后始终看不到效果。排查思路先看是不是项目没有重启Spring Security的配置都是启动时加载的改了SecurityConfig必须重启。如果重启了还是不对检查是不是有多个SecurityFilterChain定义或者某个starter里带了旧版Security依赖导致配置冲突。第五个需要提的是静态资源的放行。前端页面里的css、js、图片可能被Security拦掉导致页面样式异常。配置里记得显式放行静态路径例如requestMatchers(/css/, /js/, /images/**).permitAll()。而且注意顺序放在前面。更保险的做法是用静态资源放在resources/static目录下路径不要和Controller里的拦截规则冲突。5.3 后续可以怎么扩展如果你已经能熟练完成上面的认证和授权配置接下来可以往这几个方向延伸一下。一个是无状态Token方案。传统基于Session的登录方式对前后端分离不是特别友好常见做法是用JWT把认证信息放进Token里客户端每次请求通过Authorization请求头发送。Spring Security和JWT有现成的整合思路但核心还是那套过滤器链只是你需要在UsernamePasswordAuthenticationFilter前面或者适当位置增加一个JWT认证过滤器把Token解析成Authentication对象放进SecurityContext。这个扩展能让你彻底理解认证过滤器如何自行扩展。另一个方向是OAuth2和单点登录。Spring Security对OAuth2的客户端模式、授权码模式支持得很好适合对接第三方登录或实现多系统统一认证。如果你有公司内部的多个子系统需要共用一套登录体系这个方向的实际价值很高。还有个方向是响应式安全如果你用Spring WebFlux写响应式后端Spring Security也有一整套WebFlux版本的配置方式很多概念是互通的但过滤器模型有区别。这个不在入门范围里但知道有这么回事对你未来选型有帮助。最后再分享一个我自己的实践体会Spring Security入门时最好的学习方法不是背配置而是自己写一个最小Demo把内存用户改成数据库用户再改一个自定义登录页整个过程里被迫去理解Request、Session、Authentication这几个概念。等你需要给项目做权限控制时这些知识比抄过多少份配置有用得多。踩过几次坑之后你就会发现Spring Security难的不是某个具体功能而是你始终没搞懂自己的请求到底是怎么一步步被处理和放行的。我每次排查问题第一步永远是打开日志看看请求走到了哪一层过滤器这个习惯比翻十篇文章都管用。