但凡你用Spring Boot写过正经项目,迟早会撞上Spring Security这堵墙。我最初接触它时,和很多人一样,第一反应是“这玩意怎么这么绕”,自己写个拦截器加Session判断不是更简单吗?后来在一个需要对接多处登录来源、动态权限控制的系统里折腾完,才发现当初的想法太天真。Spring Security真正解决的不是“能不能拦住没登录的人”,而是把认证(你是谁)、授权(你能干什么)、会话管理、CSRF防护、密码加密、登录态持久化这些事情成套地组织起来,让你不用每个项目都从零搭一套安全框架。
这篇文章写给正在入门Spring Security的同学,不管你之前是只写过几个接口的初学者,还是被公司老项目里乱七八糟的权限判断折磨过的后端开发,这篇文章都会按我实际踩坑的经验,带你从头捋一遍:为什么需要它、怎么配、认证授权是怎么运作的、以及它在OAuth2 Authorization Server这样一个热门方向上的落地方向。全文以Spring Boot 3.x和Spring Security 6.x为基础,代码可以直接抄走改改用。
1. 项目概述与核心需求解析
1.1 一个后台管理系统引发的安全痛点
先聊个实际场景。假设你接了一个中小型后台管理系统,用户分普通用户和管理员两类,普通用户能看自己的订单,管理员能管用户、看全部订单。最朴素的做法是什么?每个接口里写一段“当前用户是不是已登录”的判断,再判断一下角色。一开始只有两三个接口还好,等业务膨胀到上百个接口,你会发现到处都在重复这段逻辑,而且漏掉一个地方就是安全漏洞。
这还只是登录态的问题。密码存明文还是哈希?会话过期了怎么处理?登录接口被暴力破解怎么办?Ajax请求被跨站攻击怎么防?这些如果你全都自己写,工程量不亚于重新造一个框架。Spring Security的价值就在于,它把这些安全领域的基础问题提炼成了标准组件,你只需要做配置,把业务自己的用户来源、权限模型对它说清楚,剩下的通用防御机制它可以全部接管。
所以,要理解这个项目,核心要抓住三件事:认证、授权、防御。认证解决“你是谁”,授权解决“你能干啥”,防御解决“别人怎么搞你”。Spring Security默认帮你做了绝大部分防御工作,包括CSRF、点击劫持、XSS的一些基础防护,这些反而是很多自研方案容易忽略的部分。
1.2 为什么不是自己写拦截器
很多人问,拦截器加注解也能做到一部分事情,为什么要引入一个这么重的框架?我从个人经验给你做个对比。
自写方案初期确实轻巧,因为你的业务逻辑里只有两三处需要判断。但到了后面,你会遇到几个绕不开的问题:密码加密策略谁来统一?用户状态禁用后如何踢掉已有会话?多个接口间如何共享当前用户信息(总不能每次从数据库查)?分布式部署时Session同步怎么做?
这些问题Spring Security全都帮你抽象好了。比如当前用户信息,它在认证通过后会放入SecurityContext,你随时随地可以通过SecurityContextHolder.getContext().getAuthentication()拿到,不用在每个地方手动从Request里取Session。再比如密码加密,它内置了BCrypt等多种PasswordEncoder,还支持多套历史加密方案平滑升级,这些自己折腾非常费劲。
当然,自写方案在极简单的场景下也可以,比如只有几个接口、没有复杂的角色体系、用户量很小。但只要你预见到系统会增长,提前用Spring Security反而是省时间的路径。它前期会逼你做正确的抽象,但也因此避免了后续返工。
1.3 学习Spring Security的“主线”
如果你去看官方文档,会被它的庞大吓得劝退。我的建议是不要一上来就啃完所有Filter,先从一条主线入手:SecurityFilterChain里一连串过滤器是怎么工作的,认证结果是怎么存进SecurityContext的,授权判断发生在哪一层。
理解这条主线之后,后面的扩展才顺理成章。比如OAuth2登录是替换了“认证”环节的某几过滤器,JWT无状态登录是替换了“会话持久化”的存储方式,方法级权限注解是在进入Controller方法前做二次校验。这些都是在这个主框架上做局部修改,而不是推翻重来。所以这篇入门文章,我也会按这条主线来讲,先搞懂框架的骨架,再谈各种花式场景。
2. 环境准备与基础配置
2.1 技术选型与版本搭配
先确认一下版本。Spring Security 5.7之前和6.x的配置写法差别很大,6.x舍弃了WebSecurityConfigurerAdapter,改为基于SecurityFilterChain的Bean声明式配置。所以你在网上搜到的一些老写法,比如extends WebSecurityConfigurerAdapter再重写configure方法,在新版里已经被移除,照抄会直接报错。
我建议新项目直接用:
- Spring Boot 3.2+
- Spring Security 6.x(以Boot 3.x为基准自动引入)
- JDK 17+
这三个版本组合是当前的主流默认选择。如果你公司项目还在JDK 8,那只能用Spring Boot 2.7和Spring Security 5.7的写法,但思路是一样的,只是API形态不同。我这篇文章的代码示例基于Spring Security 6.x,但讲到的原理新旧版本通用。
2.2 引入依赖后的第一印象
在pom.xml里加上spring-boot-starter-security:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>你可能还没写任何代码,启动项目,控制台会打印一个随机生成的密码,访问任何接口都会被导向一个默认登录页。这就是Spring Boot自动配置带来的“默认安全”:没有你的任何定制,它也默认要求所有请求都需要认证。
这个阶段很多人会觉得很烦,项目还没写就弹个登录框。但换个角度想,它保证了无论谁引入这个依赖,默认状态都是安全的,而不是默认裸奔。你要做的不是删掉它,而是告诉它你的用户来源、接口开放规则。这一个“默认拦截一切”的设计思路,实际上避免了很多新手因为忘了加拦截而暴露接口的问题。
2.3 用户模型与数据库设计
既然要做认证,总得有个地方存用户。入门阶段我用最简模型:一张用户表,一张角色表,用户和角色多对多。
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, status TINYINT DEFAULT 1 COMMENT '1-正常 0-禁用', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(64) NOT NULL UNIQUE, role_name VARCHAR(64) ); CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) );注意password字段长度,BCrypt加密后的字符串是60个字符,加上未来可能的加密方案变动,留128长度比较保险。后面的role_code存的是“管理员”“普通用户”这种业务语义的角色标识,我们会在权限配置里给它们加ROLE_前缀来对接Spring Security的模型,这个细节后面专门说。
3. 认证流程核心机制详解
3.1 过滤器链:一队保安按顺序检查
理解Spring Security的第一步是理解过滤器链。你可以把这一串过滤器想象成进入游乐场之前的一队保安:第一个保安查你有没有门票,没有就让你去售票处买;第二个保安查你有没有带违禁品;第三个保安查你有没有走VIP通道的资格……每个过滤器只干一件事,干完就放行给下一个,任何一个环节卡住,请求就在这里停下。
Spring Security在Servlet里也维护了这么一条过滤器链,核心的几个包括UsernamePasswordAuthenticationFilter(处理表单登录)、BasicAuthenticationFilter(处理HTTP Basic认证)、AuthorizationFilter(最终做授权判断)等。配置里你写的每一行规则,最终都会影响这条链里的一些过滤器怎么工作。
比如你写.formLogin(),它就把处理表单登录的过滤器加到链上。你写.httpBasic(),它就把Basic认证的过滤器加进去。你写.csrf(csrf -> csrf.disable()),就决定纯前后端分离场景下要不要启用CSRF令牌校验。理解了这个模型,以后看Spring Security内部报错、调优过滤器顺序,心里就有底了。
3.2 认证成功后的信息放在哪里
认证成功后,用户信息不是扔在什么“上下文变量”里就算了。Spring Security会把Authentication对象放进SecurityContext,整个请求都存在于这个上下文里。这个对象包含三块信息:principal(用户主体,一般就是UserDetails实现类对象)、credentials(凭证,通常是密码,认证成功后会擦除以免泄露)、authorities(权限列表,比如ROLE_ADMIN)。
这些信息存哪?默认是一次性请求内有效,下次请求重新认证。但你配置了Session之后,它会被序列化到Session里,这样后续请求就能从Session里恢复身份。
这就是很多教程里让你在Controller里写@AuthenticationPrincipal UserDetails user来拿当前用户的原因。你也可以随时手动拿:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String username = authentication.getName();这个方法很实用,比如你想在日志里记录操作人、想根据当前登录用户做数据权限过滤,都可以从这里取,而不用controller里每个方法都加一个参数。不过要记住,SecurityContextHolder默认是每个请求一个副本,异步线程里拿不到主线程的认证信息,这个坑后面排错部分我再细讲。
3.3 自定义UserDetailsService:告诉框架用户从哪来
框架默认的用户在内存里,实际项目当然不可能这样。你自己实现UserDetailsService接口,重写loadUserByUsername(String username),从数据库查出用户,返回一个Spring Security认识的对象。
我写一个标准做法,加上MyBatis-Plus或JPA查询的伪代码:
@Service public class DbUserDetailsService implements UserDetailsService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; public DbUserDetailsService(UserMapper userMapper, PasswordEncoder passwordEncoder) { this.userMapper = userMapper; this.passwordEncoder = passwordEncoder; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = userMapper.findByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在: " + username); } // 从关联查询里拿角色编码,例如 ["ADMIN", "USER"] List<String> roleCodes = userMapper.selectRoleCodesByUserId(user.getId()); return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .roles(roleCodes.toArray(new String[0])) .disabled(user.getStatus() == 0) .build(); } }这里有几个容易踩的细节我强调一下。第一,loadUserByUsername里查不到用户,必须抛UsernameNotFoundException,Spring Security会据此统一返回“用户名或密码错误”的提示,防止别人探测你的用户列表。第二,roles()方法传入的是不带前缀的角色名,框架会帮你自动拼成ROLE_ADMIN。我见过有人在数据库里就存ROLE_ADMIN,再用roles()传进去,结果授权配置里拿到的权限就变成了双前缀ROLE_ROLE_ADMIN,很尴尬。统一存业务角色名,用roles()方法拼接,这是最不容易错的做法。第三,密码编码问题,千万不能数据库里存明文然后直接丢给Spring Security,必须配合PasswordEncoder,这个专门再说。
3.4 PasswordEncoder:密码加密不是可选项
Spring Security的PasswordEncoder负责两件事:加密用户输入、校验密码。BCrypt是目前最常用的实现,它自带盐值随机化,同一密码每次加密出来的结果都不一样,而且计算成本可调,能有效拖慢暴力破解速度。这种设计比很多自己写死盐值的方案健壮得多——你在MD5里加盐,如果盐值写死在代码里被泄露,所有哈希都能被离线撞库,而BCrypt的盐值直接嵌在输出的字符串里,每个密码串是“版本+成本因子+盐+哈希”的完整自描述结构,这在应对彩虹表攻击时是关键的门槛。
配置成Bean很简单:
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }注册用户时拿到明文密码这样做:
SysUser user = new SysUser(); user.setPassword(passwordEncoder.encode(rawPassword));这里还有个兼容性问题。如果老项目里有一部分用户是MD5存的,你不能直接全量迁移,用户会全部登录失败。Spring Security支持定义多个PasswordEncoder,比如用DelegatingPasswordEncoder管理一套策略,默认用BCrypt,如果检测到密码前缀标记是{MD5}就用老的MD5去校验,这就是官方推荐的平滑升级方案。我实际处理过这种存量老系统的认证改造,这个机制非常有用,否则就得强制用户重置密码,挨产品骂。
4. 授权机制与权限控制
4.1 基于路径的授权配置
认证搞定之后,接下来就是谁能访问哪些接口。Spring Security 6.x里推荐用authorizeHttpRequests来配置路径规则。看一个典型配置类:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/css/**", "/js/**", "/error").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/index", true) .permitAll() ) .logout(logout -> logout.logoutSuccessUrl("/login?logout")); return http.build(); } }这里的关键在于规则是有顺序的,从上到下匹配,一旦匹配成功就停止。所以比较宽松的规则(比如放行静态资源)要放在前面,比较严格的规则放在后面。如果反过来,anyRequest().authenticated()放第一行,那后面写啥都白搭,所有接口都会要登录。
hasRole("ADMIN")和hasAuthority("ROLE_ADMIN")是等价的,区别只是前者会自动拼ROLE_前缀。如果你在权限集合里存的本来就是完整权限标识,就用hasAuthority;如果存的是角色名,就用hasRole。但注意上面我用roles()构建的UserDetails,权限列表里已经自动带上了ROLE_前缀,所以配置侧用hasRole是最顺的。
4.2 角色与权限:别把两件事混为一谈
很多教程里把“角色”和“权限”混着说,但严格说它们是两个维度的东西。角色是用户身份的一种归类,比如管理员、运营、财务;权限是能执行的具体动作,比如“删除用户”“导出报表”。你可以把角色理解成一个权限的集合,也可以把角色当成一个特殊的粗粒度权限来用。
入门项目里,用角色控制接口路径就够了,比如“管理员才能访问/admin/”。但系统变复杂后,你会发现角色维度的粗粒度控制满足不了需求。比如“运营”和“客服”都能访问用户列表,但只有“运营”能导出用户数据。这种场景下,角色细分会越来越臃肿,更好的做法是给用户分配“权限字符串”,比如sys:user:export,然后通过hasAuthority("sys:user:export")来控制具体接口。角色和权限的联动,可以通过一个关系表维护:角色拥有哪些权限,用户就继承哪些权限。
我个人建议,如果系统超过三个角色,就直接上权限字符串模型,角色只作为权限的“分组标签”,而不是直接在Spring Security配置里写死hasRole("XX")。一开始稍微多设计一点点,后面扩展会很舒服。
4.3 方法级安全:更细粒度的二次校验
路径级别的配置解决的是“URL能不能访问”,但很多时候你要控制的是“这个业务方法谁能调用”。比如同一个接口/order/delete,可能管理员能删,普通用户却连这个入口都不该进。
在配置类上加@EnableMethodSecurity,然后在方法上写注解,就能做方法级校验:
@Service public class OrderService { @PreAuthorize("hasRole('ADMIN')") public void deleteAll() { ... } @PreAuthorize("hasAuthority('order:create')") public void create(Order order) { ... } @PreAuthorize("hasRole('USER') && #order.ownerId == authentication.principal.id") public void cancel(Order order) { ... } }第三个例子可能更贴近真实需求:用户只能取消自己的订单。SpEL表达式里可以引用方法参数(#order),也可以拿到当前认证对象(authentication),实现的数据权限控制非常灵活。不过要注意,方法级校验发生在Controller进入Service之前,如果Controller里有部分逻辑不在Service层控制,还是要在Controller方法上额外加注解或者走路径配置兜底。
有一点要特别提醒:@EnableMethodSecurity在Spring Security 6.x里替代了旧版的@EnableGlobalMethodSecurity,新版别写错了。另外,方法级安全只对Spring管理的Bean生效,如果你new了一个对象,上面注解是无效的。
4.4 登录成功与失败的跳转策略
登录成功后去哪、失败后提示什么,这些细节很影响前端联调效率。Spring Security的AuthenticationSuccessHandler和AuthenticationFailureHandler可以定制登录结果处理。默认情况下表单登录成功会重定向到你之前请求的页面,但如果用户是直接访问登录页,成功后会回到首页。如果你用的是纯前端路由,前后端分离,登录成功往往希望直接返回一个JSON,让前端决定跳哪一页。
写法上,可以用defaultSuccessUrl("/index", true)固定跳转,也可以自定义handler。在前后端分离场景下,我通常建议在配置里加一个成功处理器,返回{"code":0,"token":"xxx"}这样的结构,由前端统一跳转,这样后端不用关心前端路由结构。失败处理器则统一返回业务错误码,前端根据错误码提示“用户名或密码错误”“账号已禁用”等不同信息。
这里要延伸一个问题:登录成功响应格式对调试体验的影响是立竿见影的。如果你没有自定义,前后端联调时会出现“登录明明成功了但前端不知道去哪”“密码错误但不知道是啥错”的尴尬场面。很多“Spring Security好难联调”的抱怨,根源其实不是框架,而是没花时间配置处理handler,这块我个人认为属于入门阶段绕不开的必修课。
5. 实操演示:从零构建一个带登录和权限的小应用
5.1 完整配置代码对照
我把前面的配置整合成一份可以直接运行的完整配置。假设项目是Spring Boot 3.2 + MyBatis-Plus,用户表和角色表在前文已经建好。这里给出完整的SecurityConfig:
@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/register", "/css/**", "/js/**", "/error").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/order/**").authenticated() .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/doLogin") .defaultSuccessUrl("/index", true) .successHandler((request, response, authentication) -> { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":0,\"msg\":\"登录成功\"}"); }) .failureHandler((request, response, exception) -> { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":1,\"msg\":\"用户名或密码错误\"}"); }) .permitAll() ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessHandler((request, response, authentication) -> { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":0,\"msg\":\"退出成功\"}"); }) ) .csrf(csrf -> csrf.disable()); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }这份配置我实际在用,几个点解释一下。loginPage("/login")是指定前端登录页的路由,loginProcessingUrl("/doLogin")是表单实际提交的地址,这两个在学习时特别容易混淆。我在公司带新人的时候,经常遇到有人把loginPage当成接口在联调里反复测,最后发现传参全对但路由不对。表单登录默认提交的用户名字段是username、密码是password,如果你的前端传的是别的名字,需要额外配置usernameParameter("xxx")和passwordParameter("xxx")。
CSRF在这里关闭了,原因是我们是纯前后端分离,没有使用Session Cookie维持用户状态,而是通过后续改造的方案走Token。这种情况下CSRF的威胁明显降低,关闭可以避免Ajax请求带_csrf参数的麻烦。但如果你的应用还在用服务端渲染页面、用Cookie维持Session,强烈建议保留CSRF防护而不是随意关掉,这不只是一个技术选择,更是一种安全底线。
5.2 注册接口中密码的处理流程
安全地注册一个用户,接口代码大概长这样:
@RestController public class AuthController { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; public AuthController(UserMapper userMapper, PasswordEncoder passwordEncoder) { this.userMapper = userMapper; this.passwordEncoder = passwordEncoder; } @PostMapping("/register") @ResponseBody public Result register(@RequestBody RegisterRequest req) { if (userMapper.findByUsername(req.getUsername()) != null) { return Result.error("用户名已存在"); } SysUser user = new SysUser(); user.setUsername(req.getUsername()); user.setPassword(passwordEncoder.encode(req.getPassword())); userMapper.insert(user); // 默认分配普通用户角色,角色表里 predefine 一条 USER userMapper.insertUserRole(user.getId(), 2L); return Result.success(); } }注意注册接口本身要在权限配置里permitAll,否则没注册过的用户连注册入口都进不去。另外一定要对密码做强度校验,我见过不止一次密码存进去全是123456的测试数据,这在真实环境里就是灾难。BCrypt本身不限制密码长度,但为了防DDoS,可以加一个合理的最大长度限制,比如128位,避免用户提交超大字符串让加密计算撑满CPU。
5.3 在Controller中获取当前登录用户
登录之后,业务接口里经常要做“当前用户”相关的数据过滤。我列出几种常用写法:
@GetMapping("/order/mine") public List<OrderVO> myOrders(@AuthenticationPrincipal DbUserDetailsService.CustomUser currentUser) { return orderMapper.selectByUserId(currentUser.getId()); }用@AuthenticationPrincipal注入的是认证对象里的principal。但默认的principal类型是Spring Security内置的User,它只有用户名、密码、权限,没有你自定义的id、nickname等字段。这就是我前面在UserDetailsService里写自定义CustomUser的原因。如果你需要业务字段,要么实现自己的UserDetails实现类,要么在Controller里先拿Authentication再自己查库,显然后者更浪费一次查询。
为了拿到用户id,我经常的做法是在UserDetailsService返回的UserDetails实现里带上id字段。比如:
public class LoginUser extends org.springframework.security.core.userdetails.User { private Long id; public LoginUser(Long id, String username, String password, Collection<? extends GrantedAuthority> authorities) { super(username, password, authorities); this.id = id; } public Long getId() { return id; } }这样在Controller里通过@AuthenticationPrincipal LoginUser loginUser就能直接拿到loginUser.getId()。这个模式在对接审计日志、数据权限时特别实用,比每次查库优雅得多。
5.4 从单体SSO到无状态:JWT方案的一点思路
传统的Session方案有一个天然的分布式短板:多实例部署时,用户登录到A机器的Session,B机器不认。要解决就要做Session共享(比如存Redis),或者改造为无状态认证。
无状态认证里最流行的就是JWT。Spring Security本身不强制你用JWT,你可以自定义一个过滤器,在过滤器里解析请求头携带的Token、校验签名、然后把Authentication放进SecurityContext。做这件事的原理其实不复杂:用JwtParser解析Token,拿到用户名,然后查一遍用户权限,构造UsernamePasswordAuthenticationToken放进上下文。这个环节优秀的第三方库有spring-security-oauth2-jose和nimbus-jose-jwt。
这里要说一句实在话:JWT不是解决所有问题。它有几个需要你权衡的点:无状态意味着服务端没法主动让一个Token失效,除非维护黑名单;Token太大会导致每个请求头都变重;密钥管理如果泄露,等于把城门钥匙丢给别人。所以选不选JWT,取决于你的业务是否需要横向扩缩容、是否有移动端。常见策略是Access Token有效期短(如15分钟)配合Refresh Token轮换,把失效窗口缩到最小。
6. 扩展方向:Spring Security与OAuth2 Authorization Server
6.1 为什么授权服务器热度这么高
如果你最近关注Spring Security生态,会发现“spring security oauth2 authorization server”这个词热度非常高。这是Spring官方在旧版spring-security-oauth(一个独立项目)停止维护后,推出的继任者,现在集成在Spring Security 6.x的官方模块里,定位是帮你实现自己的OAuth2 Authorization Server。
它解决的核心场景是什么?当你的系统需要对接第三方单点登录、或者你需要对外授权API给其他应用时,你不想完全依赖第三方身份提供商(比如不把用户数据给微信或谷歌),而是想做一个自己的授权中心,统一接管多个应用的登录。比如公司内部有后台管理系统、APP接口、小程序端三个应用,如果各自维护一套登录逻辑,用户数据、会话状态全不互通,后续每个端加一个审核规则都要改三遍。用OAuth2 Authorization Server统一对外发放Token,所有端都对接这一个中心,就是它的核心价值。
6.2 基于授权码模式的配置思路
OAuth2 Authorization Server的对接比Spring Security的基础配置复杂不少,但入门可以先用“授权码模式+客户端注册”这条线理解。授权码模式简单说是:前端应用先把用户引导到授权服务器登录,授权服务器登录完成后不直接发Token,而是发一个短期有效的code;前端拿code再去后端换Token。这样Token从始至终不从浏览器经手,浏览器只看到临时code,降低了Token被截获的风险。
示例配置上,你需要定义客户端注册信息(RegisteredClientRepository)、授权端点路径、Token生成规则等。大概写一小段感受一下:
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig { @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("web-app") .clientSecret("{noop}secret") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri("http://localhost:8080/login/oauth2/code/web-app") .scope("openid", "profile") .build(); return new InMemoryRegisteredClientRepository(client); } }完整功能远不止这些,还要配置JWT编码、密码模式(client_credentials等)、自定义用户信息端点。网上对这一块完整的实战教程比较散,官方模块文档也以引用为主,实践起来需要一点啃文档的耐心。我自己的体会是,要真正掌握OAuth2 Authorization Server,先弄懂Authorization Code + PKCE这套公共客户端的安全流程,比一上来就折腾各种模式更重要。这已经是进阶话题了,入门阶段你只要知道它的入口是 Spring Security 6 自带模块spring-security-oauth2-authorization-server即可,等基础认证授权链路玩熟了,再来啃这层墙会舒服很多。
7. 常见问题与排查技巧实录
7.1 启动后控制台出现“Using generated security password”
这是Spring Boot自动配置的默认认证信息,很多人第一次见到不知道这密码是怎么来的。你还没提供自己的UserDetailsService和UserDetails时,框架会生成一个临时用户,用户名是user,密码是随机字符串打印在启动日志里。一旦你定义了自己的用户来源Bean,这个默认用户就自动消失。
但有个容易懵的情况:你明明写了自己的UserDetailsService,日志里还是打印这个默认密码。这通常是因为你的Bean没有生效——比如@Service所在包没被扫描到、或者同一个UserDetailsService类型被定义了两次导致注入冲突。看一下启动日志里的组件装载记录,确认你的UserDetailsService确实被注册,通常就是问题所在。
7.2 CSRF导致Ajax请求403
前面提到我为了前后端分离方便关了CSRF,但如果你在用服务端渲染或表单提交,关了CSRF是不可取的,这时候遇到403该怎么解?
CSRF的核心机制是:服务端在渲染页面时输出一个随机的_csrftoken,提交表单时把这个token一起带上,服务端校验它和Session里存的一致,才认为是自己人发的请求。如果你的前端页面拿不到、没传这个token,POST接口就会出现403。
解决方案:在Thymeleaf等模板引擎生成的表单里加隐藏域<input type="hidden" name="_csrf" th:value="${_csrf.token}">;如果用jQuery统一发Ajax,可以读取请求Cookie里的XSRF-TOKEN,放到请求头X-XSRF-TOKEN里,Spring Security内置支持Cookie存CSRF token。别一看到403就关CSRF,先确认是不是会话本身超时了,再确认是不是token没带到。调试时可以临时加日志打印CsrfFilter所持有的token和请求头带的token,这是最快的定位法。
7.3 用户名或密码正确但登录失败
排这种问题,我先教你看两个东西:第一,表单提交的字段名是不是username和password,如果前端用了别的字段名,后端取不到;第二,数据库里的密码格式是否和PasswordEncoder匹配。
第二种情况最常见。如果你用BCryptPasswordEncoder校验,数据库里存的却是明文123456,那永远校验不过。Spring Security也接受带前缀的密码格式,比如{noop}123456表示明文、{bcrypt}$2a$...表示BCrypt,但如果没有前缀,它会按DelegatingPasswordEncoder的默认规则去匹配,不匹配就报错。我调试老系统时经常发现这种问题:一个项目里几种加密方式混着存,密码政策不统一,最终还是要靠多PasswordEncoder策略去兜底。
另外,还要检查用户的enabled标志。如果你在UserDetails里设置了disabled(true),登录会报DisabledException,表现也是“登录失败”,但实际原因是账号被禁用。业务侧需要区分“密码错误”和“账号禁用”,就得在failureHandler里根据异常类型返回不同错误码。
7.4 异步线程里拿不到认证信息
用SecurityContextHolder.getContext().getAuthentication()时,如果在ExecutorService提交的异步任务里执行,返回的往往是null。这是Spring Security的默认线程绑定策略决定的:安全上下文默认存储在ThreadLocal,子线程当然没有主线程的变量。
解决方案有几个:其一,配置SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL),让子线程继承主线程上下文,但线程池复用场景下可能串数据;更好的做法是把需要的用户信息(比如userId)在提交任务前显式传过去,不要指望异步线程里还能安全地拿上下文。第二种做法是Spring官方推荐的DelegatingSecurityContextExecutor,它在提交任务时把上下文复制给线程池,用起来更正规,很少出意外。
7.5 静态资源被认证拦截
配置了permitAll但CSS/JS还是被拦,十有八九是路径写错。Spring Security 6里的requestMatchers匹配的是应用内路径,如果你的项目有context-path(比如/demo),路径要从/demo/css/**写起,否则匹配不上。
另一种可能是规则的顺序问题。/demo/css/**虽然写了,但如果前面的规则是anyRequest().authenticated(),请求会先被拦。务必把放行规则放最前面,然后再写需要认证的规则。这个顺序在Spring Security里非常严格,我在6.x和5.7版本下都踩到过一摸一样的坑。
7.6 从日志中排查过滤器链的利器
最后分享一个调试技巧:SecurityFilterChain里的每个过滤器会在启动时被构建,你可以在配置类里临时加一段:
System.out.println(http.build());或者直接打印过滤器链日志,把logging.level.org.springframework.security=TRACE打开。这样每个请求都会打出一大串“Security filter chain”日志,你能清楚看到请求经过了哪些过滤器、在哪一步被拦下来的。判断“是认证没通过还是授权没通过”,看日志里AuthorizationFilter的输出来源是最快的。这个方法在我排查“为什么我明明配置了放行但还是走到认证流程”的问题时救过我很多次。
8. 项目后续可以怎么演进
整篇走下来,你应该已经能徒手搭一套带登录、鉴权和基础防御的Spring Security应用了。但“入门”只是第一步,我建议接下来按自己的业务场景往这几个方向演进,再往下走拓展性就比较清晰了。
第一,接第三方登录。用oauth2Login()配置对接微信、Github登录,替换掉自己实现的UserDetailsService里的密码校验逻辑,让用户能用第三方账号直接登录。第二,把Session模式升级为Redis共享或JWT无状态,解决多实例部署的会话一致性。第三,引入Spring Authorization Server做纯正的授权中心,统一多应用的登录授权,这也是当前Spring生态里最热门的方向。第四,把权限控制从硬编码hasRole升级为数据库动态配置:权限表、角色权限关系表、注解上引用权限编码,做到运营后台在线调整角色权限而不用发版。
我在实战中摸索下来,最重要的一点体会是:Spring Security不难,难的是你是否有耐心把过滤器链这条主线吃透。多数报错看起来吓人,但只要你清楚每一个过滤器在“保安队列”里的位置和职责,定位问题只是时间问题。我建议你把文章里的代码自己敲一遍,踩几个坑,再回头看官方文档,那种豁然开朗的感觉会让你顺手许多。搜索时也优先看官方文档和6.x版本相关的资料,别让几年前的陈旧教程把你带偏,否则排错的路上很容易走弯路。