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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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角色前缀、路径匹配顺序、是否关闭CSRF |
| There 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难的不是某个具体功能,而是你始终没搞懂自己的请求到底是怎么一步步被处理和放行的。我每次排查问题第一步永远是打开日志,看看请求走到了哪一层过滤器,这个习惯比翻十篇文章都管用。