ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Security过滤器链与JDBC认证:从请求流转到数据库登录实战解析

Spring Security过滤器链与JDBC认证:从请求流转到数据库登录实战解析 做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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency数据库连接信息正常配置在application.yml里这里不多说。3.2 配置UserDetailsService与PasswordEncoderJDBC认证的核心是实现UserDetailsService接口这个接口只有一个方法loadUserByUsername(String username)。Spring Security在认证时会拿着表单传过来的用户名去调用这个方法如果你的方法返回了正确的UserDetailsDaoAuthenticationProvider就会继续比对密码。代码可以这样写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 ?; ListMapString, Object rows jdbcTemplate.queryForList(sql, username); if (rows.isEmpty()) { throw new UsernameNotFoundException(用户不存在: username); } MapString, 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密文直接按默认配置登录必然失败。配置一个BeanBean 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和PasswordEncoderSpring Security就能自动认证了那刚才讲的过滤器链又是怎么参与进来的关键在于DaoAuthenticationProvider。它是UsernamePasswordAuthenticationFilter背后的实际执行者。当过滤器接收到登录请求后AuthenticationManager找到DaoAuthenticationProvider在这个Provider内部会调用你配置的UserDetailsService.loadUserByUsername()再使用PasswordEncoder比对密码。所以整个调用链是这样的登录请求进入UsernamePasswordAuthenticationFilter过滤器创建未认证的token交给AuthenticationManagerAuthenticationManager遍历AuthenticationProvider找到DaoAuthenticationProviderDaoAuthenticationProvider调用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.securityTRACE这样能看到每一次认证的详细流转过程包括过滤器顺序、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认证只是给其中一个环节提供了数据来源。把这根链条理顺剩下的都是配置细节。
返回列表