做Java后端到现在,Spring Security是我见过最容易把新手劝退的框架之一。网上搜“Spring Security 入门”,出来的要么是几年前的旧配置,要么是只贴代码不解释为什么的Demo。尤其是过滤器链和JDBC认证这两块,几乎每个项目都要用,但真正讲清楚“请求到底怎么流转”的文章少之又少。这篇文章我打算把自己在实际项目里拆过滤器链、用JDBC做认证的经验完整写一遍,包括每个过滤器在干什么、为什么顺序不能乱、数据库表怎么设计、JDBC的UserDetailsService和自定义SQL怎么选。适合刚接触Spring Security的初学者,也适合被认证逻辑折磨过、想真正搞懂底层机制的开发者。
1. Spring Security的认证机制与过滤器链设计
1.1 认证到底在解决什么问题
认证的本质是回答一个问题:“你是谁”。在Web应用里,HTTP协议本身是无状态的,每次请求都像第一次见面。所以我们需要一种机制,让请求带上某种凭证,服务端验证凭证后确认用户身份。Spring Security做的事情就是把这套验证逻辑标准化,通过一长串过滤器来处理。
传统的做法是用Session,登录成功后把用户信息塞进Session,后续请求通过Cookie里的Session ID找回用户。Spring Security也支持这种模式,但它的设计更抽象:认证成功后,会把用户信息封装成Authentication对象放到SecurityContext里,而SecurityContextHolder负责存储它,默认存在ThreadLocal中,保证同一个请求线程内可以随时拿到当前用户。
这里有个很重要的概念:Authentication接口的三个核心信息,principal(用户主体)、credentials(凭证,通常是密码,认证成功后会被清除)、authorities(权限集合)。认证过程本质上就是拿用户提交的凭证和系统中存储的凭证做比对,比对通过就生成一个完整填充的Authentication对象,否则抛出异常。
我见过不少刚接触Spring Security的同事,上来就写一个自定义过滤器,然后把token解析逻辑塞进去,结果跟Spring Security原本的过滤器链冲突。说到底,没有先理解过滤器链,写再多代码都是盲人摸象。
1.2 过滤器链:一次请求经历了什么
Spring Security的核心机制是一组Filter组成的链,标准说法是FilterChain。当请求进入Servlet容器时,先经过Servlet容器内部的过滤器,然后进入Spring Security的过滤器链,最后才到达你的Controller。
默认的过滤器链顺序在源码里是固定写死的,每一个过滤器只需要关心自己的职责,然后把请求传给下一个。下面的表格列出了实际项目中最常打交道的几个过滤器:
| 过滤器 | 核心职责 |
|---|---|
| SecurityContextPersistenceFilter | 请求开始时从Session或其它存储中加载SecurityContext,请求结束后保存修改并清理当前线程的上下文 |
| UsernamePasswordAuthenticationFilter | 处理表单登录请求,解析用户名密码,调用AuthenticationManager认证 |
| BasicAuthenticationFilter | 处理HTTP Basic认证方式的Authorization请求头 |
| ExceptionTranslationFilter | 捕获过滤器链下游抛出的认证/授权异常,转化成对应的HTTP状态码或重定向 |
| AuthorizationFilter | 做最后的授权判断,决定当前用户能否访问该资源 |
看清楚这个结构后再回头看自己的配置类,你会恍然大悟。在Spring Security 5.x及之后的版本中,默认的过滤器链是通过HttpSecurity配置生成的,不同的配置项对应不同的过滤器加入链中,顺序则由OrderedFilter的注册顺序决定。
需要注意,ExceptionTranslationFilter只在过滤器链层捕获异常,Controller层抛出的异常它管不到,这是很多人在认证失败时看到奇怪错误的原因之一。
2. 核心过滤器逐个拆解
2.1 从UsernamePasswordAuthenticationFilter看认证流程
表单登录是绝大多数Web应用的入口,对应过滤器就是UsernamePasswordAuthenticationFilter。它默认只处理POST /login请求,并且要求请求参数包含username和password。当请求进来时,该过滤器会做三件事:
第一,从请求中取出用户名和密码,封装成一个UsernamePasswordAuthenticationToken对象。这个对象其实是一个未认证的Authentication实现,它的isAuthenticated()此时是false。
第二,把这个token交给AuthenticationManager。AuthenticationManager本身是个接口,实际干活的是ProviderManager,它会遍历注册的AuthenticationProvider列表,找到支持当前token类型的Provider去执行认证。如果用JDBC认证,底层就是DaoAuthenticationProvider。
第三,认证成功后,DaoAuthenticationProvider会返回一个认证完成的Authentication对象,过滤器拿到它后调用SecurityContextHolder.getContext().setAuthentication(...)保存起来,然后跳转到登录成功页。如果认证失败,则会调用AuthenticationFailureHandler。
下面这段代码演示了如何手动实现一个类似过程,便于理解机制:
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) { String username = request.getParameter("username"); String password = request.getParameter("password"); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); // 交给 AuthenticationManager 完成认证 return this.getAuthenticationManager().authenticate(authRequest); }2.2 授权过滤器与异常处理过滤器
认证通过后,真正的访问控制在AuthorizationFilter(旧版本叫FilterSecurityInterceptor)中完成。这个过滤器会调用AuthorizationManager,根据配置规则检查当前Authentication对象是否拥有访问某个URL所需的权限。如果权限不足,就会抛出AccessDeniedException。
授权规则的写法在Spring Security 6.x中有些变化,但核心思路不变。最简单的配置就是用requestMatchers()指定路径和权限,比如:
http.authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/css/**", "/js/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() );这里hasRole("ADMIN")会自动给权限加上ROLE_前缀,对应数据库里的角色字段如果是“ROLE_ADMIN”就能匹配上。这个前缀机制经常被忽略,导致自定义权限查询时对不上。
ExceptionTranslationFilter则像一个网兜,它包住过滤器链上后续所有过滤器的调用,一旦捕获到认证异常(AuthenticationException)或授权异常(AccessDeniedException),会决定是返回401、403、还是重定向到登录页。它的关键逻辑是:
- 如果是认证异常且用户没登录,就重定向到登录页或返回认证失败点;
- 如果是授权异常且用户已登录,就返回403禁止访问;
- 如果用户未登录且请求的是受保护资源且触发了授权异常,则先触发认证流程。
2.3 过滤器顺序为什么不能乱
我踩过一次典型的坑:自定义了一个过滤器用于解析请求头里的token,然后在配置里用addFilterBefore把它放在UsernamePasswordAuthenticationFilter之前,结果token解析成功了,但后续在Controller里通过SecurityContextHolder.getContext().getAuthentication()取用户时却发现是null。
原因就是我的自定义过滤器没有在解析完token后把Authentication写入SecurityContext,也没有在过滤器链结束后清理上下文。顺序本身没错,但我没有理解过滤器的职责边界。SecurityContextPersistenceFilter负责上下文的加载和清理,我的过滤器在它之后执行时,确实能拿到干净的上下文,但如果不主动写入,上下文里就没有用户信息。
过滤器的顺序直接影响SecurityContext的可见性。比如把自定义过滤器加在SecurityContextPersistenceFilter之前,那么当它执行时SecurityContext可能还是空的。所以配置过滤器位置时,至少要明白这几个原则:
- 需要读取SecurityContext的过滤器,放在
SecurityContextPersistenceFilter之后; - 需要把用户身份写入SecurityContext的过滤器,也要放在该过滤器之后,并且在给下游使用前完成写入;
- 涉及异常转换的过滤器尽量在链的末端,确保它能捕获到前面过滤器的异常。
3. 基于JDBC的认证实现:从数据表到登录成功
3.1 数据库表设计与依赖引入
JDBC认证最典型的场景是用户名密码都存在关系型数据库里。Spring Security官方提供了一个可选的spring-security-jdbc模块,内置了用户和权限表的默认查询SQL,但生产项目我强烈建议使用自定义表结构。原因很简单,内置SQL依赖固定的表名和字段名,也就是users和authorities表,而且密码字段就叫password,角色字段在authorities表里,这跟业务系统的用户表往往对不上。
我自己的习惯是设计一张简洁的用户表:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, enabled TINYINT NOT NULL DEFAULT 1, role VARCHAR(30) NOT NULL DEFAULT 'ROLE_USER', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );这里把角色直接放用户表里,对于中小项目最省事。如果要做多角色、细粒度权限,再拆用户-角色-权限三张表也不迟。注意enabled字段,Spring Security的UserDetails里正好有这个布尔属性,可以方便地控制账号是否冻结。
依赖方面,创建一个Spring Boot项目时,在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-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>数据库连接信息正常配置在application.yml里,这里不多说。
3.2 配置UserDetailsService与PasswordEncoder
JDBC认证的核心是实现UserDetailsService接口,这个接口只有一个方法loadUserByUsername(String username)。Spring Security在认证时,会拿着表单传过来的用户名去调用这个方法,如果你的方法返回了正确的UserDetails,DaoAuthenticationProvider就会继续比对密码。
代码可以这样写:
@Service public class JdbcUserDetailsService implements UserDetailsService { private final JdbcTemplate jdbcTemplate; public JdbcUserDetailsService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { String sql = "SELECT id, username, password, enabled, role FROM sys_user WHERE username = ?"; List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, username); if (rows.isEmpty()) { throw new UsernameNotFoundException("用户不存在: " + username); } Map<String, Object> row = rows.get(0); String password = (String) row.get("password"); boolean enabled = ((Number) row.get("enabled")).intValue() == 1; String role = (String) row.get("role"); return User.withUsername(username) .password(password) .disabled(!enabled) .roles(role.replace("ROLE_", "")) .build(); } }这里有个容易出错的地方:roles(String... roles)方法会自动给每个角色加上ROLE_前缀。如果你的数据库里存的是ROLE_ADMIN,直接传进去就会变成ROLE_ROLE_ADMIN。所以我上面的代码里先去掉前缀再传给roles(),这样才能得到正确的ROLE_ADMIN。
PasswordEncoder是另一个必须配置的组件。Spring Security 5之后默认推荐的编码器是BCryptPasswordEncoder,它是bcrypt算法的实现,自带随机盐,每次加密同一个密码得到的密文都不同。如果你的数据库里是明文密码或者MD5密文,直接按默认配置登录必然失败。
配置一个Bean:
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }然后在注册用户时用passwordEncoder.encode(plainPassword)存库。首录测试时,可以用下面这行代码生成密文:
System.out.println(new BCryptPasswordEncoder().encode("123456"));3.3 自定义查询SQL的两种写法
如果你连UserDetailsService都不想手写,Spring Security也提供了JdbcUserDetailsManager,它能直接帮你从数据库加载用户,但要求使用官方默认表结构。在完全不改表结构、直接用内置表时,可以这样配置:
@Bean public UserDetailsService userDetailsService(DataSource dataSource) { return new JdbcUserDetailsManager(dataSource); }这样它会默认执行这样两条查询:
- 根据用户名查用户:
select username, password, enabled from users where username = ? - 查询权限:
select username, authority from authorities where username = ?
如果不想按默认表结构建表,可以自定义查询SQL传给JdbcUserDetailsManager,例如:
JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource); manager.setUsersByUsernameQuery("select username, password, enabled from sys_user where username = ?"); manager.setAuthoritiesByUsernameQuery("select username, role from sys_user where username = ?"); return manager;注意setAuthoritiesByUsernameQuery查出来的第二列会自动被当作权限字符串,不需要手动加ROLE_前缀。这个写法的好处是非常简洁,不用新建类,缺点是权限模型固定为单角色字符串,复杂场景不如自定义UserDetailsService灵活。
两种方式怎么选?我的建议是:如果只是快速做原型,用JdbcUserDetailsManager加自定义SQL;如果项目会持续演进,用户表、权限模型会变,那直接自定义UserDetailsService,可控性最高。
3.4 与过滤器链如何串联起来
可能很多人会有疑问:我配置了UserDetailsService和PasswordEncoder,Spring Security就能自动认证了,那刚才讲的过滤器链又是怎么参与进来的?
关键在于DaoAuthenticationProvider。它是UsernamePasswordAuthenticationFilter背后的实际执行者。当过滤器接收到登录请求后,AuthenticationManager找到DaoAuthenticationProvider,在这个Provider内部会调用你配置的UserDetailsService.loadUserByUsername(),再使用PasswordEncoder比对密码。
所以整个调用链是这样的:
- 登录请求进入
UsernamePasswordAuthenticationFilter; - 过滤器创建未认证的token,交给
AuthenticationManager; AuthenticationManager遍历AuthenticationProvider,找到DaoAuthenticationProvider;DaoAuthenticationProvider调用UserDetailsService查出用户信息;DaoAuthenticationProvider调用PasswordEncoder.matches()校验密码;- 校验通过后,构造已认证的
Authentication对象,过滤器中写入SecurityContextHolder。
在这个串联过程中,最常被忽略的是ProviderManager可能会调用多个Provider。比如一个项目既支持账号密码登录,又支持手机验证码登录,就需要注册两个不同的Provider。一旦理解了这个设计,后面再加自定义登录方式就会非常顺手。
4. 常见问题与排查技巧实录
4.1 密码编码器不匹配导致登录失败
这是出现频率最高的问题。现象是数据库里存的是MD5或者明文,密码框输入之后一直提示“Bad credentials”,日志里也没有明显堆栈,只有一条匿名的认证失败记录。
排查时先站在代码里确认两个点:loadUserByUsername返回的UserDetails里的password是什么格式,PasswordEncoder用的是哪一种。如果数据库里是明文,却配置了BCryptPasswordEncoder,那matches()永远返回false。
我自己的经验是写一个简单的CommandLineRunner测试一下:
@Bean public CommandLineRunner passwordChecker(PasswordEncoder encoder) { return args -> { boolean ok = encoder.matches("123456", "$2a$10$..."); System.out.println("密码校验结果: " + ok); }; }这能快速定位是编码器问题还是数据库数据问题。生产环境建议所有密码都统一用BCryptPasswordEncoder重新加密。
4.2 自定义UserDetailsService返回null
我曾经看到有同事在loadUserByUsername里,查不到用户时直接return null,结果登录接口直接报500。正确做法是抛出UsernameNotFoundException。
原因在于DaoAuthenticationProvider内部会调用UserDetailsService.loadUserByUsername,然后处理返回结果,如果返回null,它没有对应的null检查,最后会引发NullPointerException或认证逻辑混乱。而抛出UsernameNotFoundException是符合约定,由ExceptionTranslationFilter转成标准认证失败流程。
好的写法:
return userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));4.3 过滤器顺序导致静态资源被拦截
静态资源被拦截是最让前端同学崩溃的问题。配置了/static/**放行,但刷新页面还是跳登录。这往往不是因为URL写错,而是因为配置的匹配方式不对。
在Spring Security 6.x中,推荐写法是:
http.authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/css/**", "/js/**", "/images/**", "/favicon.ico").permitAll() .anyRequest().authenticated() );有个坑:如果项目里配置了额外的前缀路径,比如server.servlet.context-path=/app,那么请求路径会带上前缀,requestMatchers里的路径也要对应调整。否则你写的是/css/**,实际请求是/app/css/**,匹配不上,于是被拦截。
还有一种情况是,permitAll()只是放行授权判断,并不会让过滤器链跳过所有过滤器。如果自定义过滤器里有强制解析token的逻辑,静态资源依然会被解析失败引起异常。这种情况要为自定义过滤器增加跳过逻辑,判断是否属于放行路径。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 登录失败但无异常堆栈 | PasswordEncoder不匹配 | 统一使用BCryptPasswordEncoder |
| 数据库用户存在却始终“用户不存在” | loadUserByUsername中SQL错误或未抛异常 | 检查SQL字段名,查不到时抛出UsernameNotFoundException |
| 登录成功后获取不到用户信息 | 自定义过滤器写入SecurityContext的位置不对 | 确认在SecurityContextPersistenceFilter之后写入 |
| 接口总是返回403 | 角色前缀不匹配或权限配置错误 | 检查ROLE_前缀,打印当前用户权限调试 |
| 静态资源被拦 | requestMatchers路径与请求路径不一致 | 结合context-path检查路径匹配方式 |
| 使用JdbcUserDetailsManager时报表不存在 | 没有创建内置users表 | 自定义SQL映射到自己表,或执行官方建表脚本 |
排查认证相关问题时,最有效的办法是在关键过滤器里临时打断点,或者使用Spring Boot的TRACE日志级别。在application.yml里开启:
logging.level.org.springframework.security=TRACE这样能看到每一次认证的详细流转过程,包括过滤器顺序、AuthenticationProvider的选择、密码校验的结果,比瞎猜快得多。
5. 生产环境落地时的几个额外建议
5.1 登录成功的用户信息从哪里拿
很多人在Controller里用@AuthenticationPrincipal注解来获取当前登录用户。这个注解会把当前SecurityContext中的principal对象直接注入进来,也就是你自定义UserDetailsService里返回的那个UserDetails对象。如果你的业务需要拿到数据库里的用户ID、手机号等字段,有两种做法。
第一种是扩展UserDetails,写一个自定义类持有业务字段。这种方法侵入性强,需要实现一堆方法,但最规范。第二种是登录成功后手动把业务信息放入Session或SecurityContext中。在AuthenticationSuccessHandler里,你可以从Authentication对象拿到用户名,然后重新查一次数据库,把用户主键放入Session。
我自己更推荐第一种,因为一旦定义了业务UserDetails,后面做@AuthenticationPrincipal传参就非常顺手,不用到处查库。
5.2 记住我与JDBC持久化
登录页通常有个“记住我”选项。Spring Security的rememberMe功能默认使用内存或Cookie存储token,一旦应用重启,记住我信息就会失效。为了持久化,可以把Token信息存到数据库表中。
配置方式先准备表:
CREATE TABLE persistent_logins ( username VARCHAR(64) NOT NULL, series VARCHAR(64) PRIMARY KEY, token VARCHAR(64) NOT NULL, last_used TIMESTAMP NOT NULL );然后在配置里指定数据源:
http.rememberMe(remember -> remember .tokenRepository(persistentTokenRepository()) .tokenValiditySeconds(7 * 24 * 3600) ); @Bean public PersistentTokenRepository persistentTokenRepository(DataSource dataSource) { JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); tokenRepository.setDataSource(dataSource); return tokenRepository; }注意JdbcTokenRepositoryImpl有个setCreateTableOnStartup(true)选项,可以在启动时自动建表,但生产环境不建议依赖它,最好用脚本手动建表。
5.3 过滤器层面的性能和安全隐患
生产环境里,每个请求都会经过整条Spring Security过滤器链。如果某些接口无需认证,但在定义放行路径时写得太粗,比如/api/**放行,而内部又有部分接口需要权限,那就留下了安全隐患。更稳妥的做法是放行具体路径,再配合方法级权限@PreAuthorize做二次校验。
开启方法级安全很简单:
@Configuration @EnableMethodSecurity public class SecurityConfig { // ... }然后在Service或Controller方法上加:
@PreAuthorize("hasRole('ADMIN')") public void deleteUser(Long id) { // ... }这样即使过滤器层配置失误,核心业务接口仍有保护。
性能方面,需要注意BCryptPasswordEncoder本身设计上就是慢加密,故意消耗CPU。登录接口如果被暴力请求,容易拖垮服务。生产环境建议对登录接口加限流措施,比如使用Bucket4j或简单的IP计数拦截器。这一点常常被忽略,等到线上被刷才发现。
还有一个隐蔽的问题:自定义过滤器里如果对请求体进行了读取,比如为了解析JSON里的用户名密码,会导致后续Controller再读请求体时读到空流。因为请求体只能读一次。解决办法是用ContentCachingRequestWrapper包装一次请求,或者干脆只从标准表单参数读登录信息,这点在做前后端分离时要格外注意。
从最初学习Spring Security时总被各种术语绕晕,到现在能轻松自定义过滤器链、把JDBC认证玩明白,中间确实踩了太多坑。如果这篇文章能帮你在某个排查的晚上省下两个小时,那它就很有价值了。最后再重申一次核心观点:Spring Security的认证和授权,本质上是过滤器链在按顺序干活,JDBC认证只是给其中一个环节提供了数据来源。把这根链条理顺,剩下的都是配置细节。