1. 项目概述与核心价值
最近在重构一个老项目的用户认证模块,发现不少同事对Spring Security的理解还停留在“配置一下就能用”的阶段,尤其是密码从注册到登录的完整安全链路,很多细节都是黑盒。正好借着这次机会,我把整个流程重新梳理并实现了一遍,核心就是标题里的那套组合拳:BCrypt + UserDetailsService。这不仅仅是两个组件的简单堆砌,而是一套从数据落地到请求鉴权的全链路安全方案。
简单来说,这个项目要解决的就是两个核心场景:用户注册时,如何安全地存储密码(防数据库泄露);以及用户登录时,如何无感地完成密码校验(对接现有业务)。BCrypt负责前者,它是一种自适应哈希函数,专门为密码存储而生,能有效抵御彩虹表攻击。UserDetailsService则负责后者,它是Spring Security加载用户核心数据的标准接口,我们实现它,就能将数据库里的用户信息(包括加密后的密码)安全地对接上Spring Security的认证流程。
这套方案特别适合正在将老旧的自研鉴权系统迁移到Spring Security,或者希望深入理解Security认证内核的开发者。你不需要成为安全专家,但跟着走完这一趟,你会对“密码安全”和“框架集成”有更立体的认识。接下来,我会从设计思路开始,拆解每一个环节的“为什么”和“怎么做”。
2. 整体架构设计与核心思路拆解
在动手写代码之前,我们先得想清楚整个链路应该如何串联。一个常见的误区是,只关注登录时Spring Security怎么配,却忽略了注册环节同样关键。安全的密码,必须在诞生(注册)的那一刻就被妥善处理。
2.1 为什么是BCrypt,而不是MD5或SHA-256?
这是第一个要掰扯清楚的问题。很多老系统还在用MD5甚至明文存密码,这是极度危险的。MD5、SHA-1、SHA-256这些是加密哈希函数,设计目标是快速计算和验证数据的完整性。而密码哈希需要的是慢哈希函数。
- 对抗暴力破解:现代GPU每秒能进行数十亿次MD5运算。如果数据库被“拖库”,攻击者可以轻易地快速尝试验证海量密码。BCrypt被设计得很慢(可通过
strength参数调整,默认10),一次校验可能需要上百毫秒,这对用户登录无感,但却能将攻击者的尝试速度降低数个数量级。 - 内置盐值(Salt):盐值是添加到密码上再进行哈希的一串随机字符,用于确保即使两个用户密码相同,哈希值也不同,从而防御预计算的彩虹表攻击。BCrypt将盐值直接作为哈希输出的一部分,无需我们单独存储和管理盐值,极大地简化了操作并避免了“忘存盐”的安全失误。
- 自适应成本:BCrypt的
strength参数(工作因子)决定了计算成本。随着硬件性能提升,我们可以调高这个参数(如从10调到12),而无需修改现有哈希值或用户密码,原有的哈希值依然能被验证。这种向前兼容的安全性升级能力至关重要。
所以,选择BCrypt不是跟风,而是基于其专为密码存储设计的特性:慢哈希、内置盐、成本可调。在Spring Security的PasswordEncoder体系里,BCryptPasswordEncoder是官方推荐的首选。
2.2 UserDetailsService的核心角色:连接数据库与安全框架
Spring Security在处理登录请求(如/login)时,它需要知道“当前登录的用户是谁”。它通过用户名(或其他凭证)去加载用户的完整信息,包括密码、权限等。这个加载动作的抽象接口就是UserDetailsService。
它的核心作用就一个:根据用户名,返回一个UserDetails对象。这个对象是Security认识用户的核心模型。我们实现这个接口,就是在告诉Spring Security:“别去默认的内存里找了,去我的数据库里,按我写的SQL查用户信息。”
这里的关键在于,我们返回的UserDetails对象中,其password字段,必须是当初注册时通过BCryptPasswordEncoder加密后的密文字符串。Spring Security会拿登录请求中的明文密码,用同一个PasswordEncoder进行加密,然后与UserDetails中的密文进行比较。匹配,则登录成功。
2.3 全链路数据流设计
理解了核心组件,整个数据流就清晰了:
- 注册端:用户提交用户名、明文密码。后端Controller使用
BCryptPasswordEncoder.encode(明文密码),得到密文,连同用户名等其他信息存入数据库。 - 登录端:用户提交用户名、明文密码。Spring Security拦截到登录请求,调用我们实现的
UserDetailsService.loadUserByUsername(用户名),从数据库查出用户信息(含BCrypt密文密码),封装成UserDetails返回。 - 认证引擎:Spring Security的认证管理器(
AuthenticationManager)自动使用容器中的BCryptPasswordEncoder,对登录请求中的明文密码进行加密,并与UserDetails中的密文密码比对。一致则生成认证通过的令牌。
整个过程中,明文密码只在内存中出现极短的时间,从未被写入日志或数据库,传输层则应配合HTTPS。我们的代码主要聚焦在实现1和2,3由Spring Security自动完成。
3. 核心组件配置与实战实现
理论清晰后,我们进入实战环节。我会基于Spring Boot环境来演示,这是目前最主流的集成方式。
3.1 环境准备与依赖引入
首先,创建一个Spring Boot项目,确保你的pom.xml中包含以下核心依赖:
<dependencies> <!-- Spring Boot Web Starter --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Spring Security Starter - 核心 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- Spring Data JPA (这里用JPA做示例,你可替换为MyBatis等) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- 数据库驱动,以MySQL为例 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok (可选,简化实体类代码) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>注意:引入
spring-boot-starter-security后,应用启动时会自动生成一个随机密码,所有端点默认被保护。这是Security的默认行为,我们后续通过配置会覆盖它。
3.2 密码编码器(BCryptPasswordEncoder)的配置
这是整个安全链路的基石。我们需要将其声明为Spring Bean,供注册和登录两个环节使用。通常在一个配置类中完成。
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class SecurityConfig { /** * 配置密码编码器为 BCrypt。 * @return BCryptPasswordEncoder 实例 */ @Bean public PasswordEncoder passwordEncoder() { // 强度因子默认为10,可根据服务器性能调整(范围4-31,值越大越慢越安全) return new BCryptPasswordEncoder(); // 如果需要指定强度:return new BCryptPasswordEncoder(12); } }关键点解析:
@Bean注解确保PasswordEncoder被Spring容器管理。- 这里返回的是
PasswordEncoder接口类型,而不是具体实现类,这是更好的面向接口编程实践。 - 强度因子(strength):默认值10是一个在安全性和性能间平衡较好的值。每增加1,计算时间大约翻一倍。生产环境建议进行压力测试后选择,通常10-12是常见选择。不建议低于10。
3.3 实现自定义UserDetailsService
这是连接我们业务数据库的关键。假设我们有一个User实体对应数据库中的用户表。
第一步:创建用户实体与Repository
import lombok.Data; import javax.persistence.*; import java.util.Collections; import java.util.List; @Entity @Table(name = "sys_user") @Data public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String username; // 登录用户名 @Column(nullable = false) private String password; // 存储的是BCrypt加密后的密文 private String email; // 其他业务字段... // 角色字段,这里简单用逗号分隔,实际可用关联表 private String roles; } // JPA Repository public interface UserRepository extends JpaRepository<User, Long> { User findByUsername(String username); }第二步:实现UserDetailsService
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; 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; import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.List; @Service // 标记为Spring的服务组件 public class CustomUserDetailsService implements UserDetailsService { @Autowired private UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 1. 根据用户名查询用户 User user = userRepository.findByUsername(username); if (user == null) { // 必须抛出此异常,Spring Security才能正确处理“用户不存在” throw new UsernameNotFoundException("用户不存在: " + username); } // 2. 将数据库中的角色字符串转换为Spring Security需要的权限集合 List<GrantedAuthority> authorities = new ArrayList<>(); if (StringUtils.hasText(user.getRoles())) { for (String role : user.getRoles().split(",")) { // 角色名通常需要添加`ROLE_`前缀,具体取决于你的配置 authorities.add(new SimpleGrantedAuthority("ROLE_" + role.trim())); } } // 3. 构建并返回Spring Security认识的UserDetails对象 // 注意:这里User是org.springframework.security.core.userdetails.User // 构造参数:用户名、密码(密文)、是否启用、是否账户未过期、是否凭证未过期、是否未锁定、权限列表 return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), // 这里直接传入数据库存储的BCrypt密文 true, // enabled, 可根据业务字段调整 true, // accountNonExpired true, // credentialsNonExpired true, // accountNonLocked authorities ); } }实现要点与避坑指南:
- 异常必须抛:如果用户没找到,一定要抛出
UsernameNotFoundException。这是Spring Security识别“用户名错误”的标准方式,如果你返回null或抛其他异常,可能会导致错误的错误信息(如“凭证错误”)或流程中断。 - 密码直接传递:
UserDetails构造器的第二个参数password,必须直接传入从数据库查出来的那个BCrypt密文字符串。千万不要在这里对密码做任何解码或二次加密。校验工作由框架后面的流程完成。 - 权限的转换:将业务系统中的角色/权限标识,转换为
GrantedAuthority对象的集合。这里示例使用了简单的逗号分隔字符串,复杂系统可能需要从关联表查询。注意SimpleGrantedAuthority构造器传入的字符串,通常需要与你在Security配置中hasRole(‘ADMIN’)这样的表达式匹配。默认hasRole会检查ROLE_前缀,所以我们这里加上了。 - 账户状态:
UserDetails构造器后四个布尔参数(启用、未过期、未锁定、凭证未过期)非常重要。你可以将它们与数据库中的字段(如is_locked,expire_date)绑定,实现更精细的账户控制。例如,发现密码错误多次,可以将accountNonLocked设为false。
3.4 注册接口的实现
注册接口是密码安全链路的起点,它不直接涉及Spring Security的认证流程,但必须使用相同的PasswordEncoder。
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserRepository userRepository; @Autowired private PasswordEncoder passwordEncoder; // 注入同一个编码器 @PostMapping("/register") public ResponseEntity<?> registerUser(@RequestBody @Valid RegisterRequest request) { // 1. 检查用户名是否已存在 if (userRepository.findByUsername(request.getUsername()) != null) { return ResponseEntity.badRequest().body("用户名已存在"); } // 2. 创建用户实体 User user = new User(); user.setUsername(request.getUsername()); user.setEmail(request.getEmail()); // 3. 【核心安全步骤】使用BCrypt加密密码后存储 String encodedPassword = passwordEncoder.encode(request.getPassword()); user.setPassword(encodedPassword); // 存密文,非明文! user.setRoles("USER"); // 默认角色 // 4. 保存到数据库 userRepository.save(user); // 5. 返回结果(注意不要返回密码,即使是密文) return ResponseEntity.ok("注册成功"); } } // 注册请求DTO @Data class RegisterRequest { @NotBlank private String username; @NotBlank @Email private String email; @NotBlank @Size(min = 6, max = 20) private String password; }注册环节的核心安全纪律:
- 前端传输:务必通过HTTPS协议提交注册信息,防止密码在传输中被窃听。
- 后端处理:明文密码只在
request.getPassword()这一刻出现,应立即交给passwordEncoder.encode()处理,之后的内存中不应再保留明文。加密后的密文才可写入数据库。 - 日志禁忌:绝对不能在日志中打印明文密码,甚至是加密后的密码也应避免。在调试时尤其要注意。
- 响应内容:注册成功的响应里,不要包含任何密码信息。
4. 整合Spring Security配置与登录流程
现在,我们有了编码器、有了加载用户的Service,也有了注册入口。接下来需要配置Spring Security,将这一切串联起来,并处理登录请求。
4.1 安全配置类详解
我们需要扩展WebSecurityConfigurerAdapter(Spring Security 5.7+已标记为过时,推荐使用基于组件的配置,但为清晰起见,此处仍用此方式,新项目建议使用SecurityFilterChainBean)。
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity public class SecurityConfiguration extends WebSecurityConfigurerAdapter { @Autowired private CustomUserDetailsService userDetailsService; @Autowired private PasswordEncoder passwordEncoder; /** * 配置认证管理器,使用我们自定义的UserDetailsService和密码编码器 */ @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService) // 指定自定义的UserDetailsService .passwordEncoder(passwordEncoder); // 指定密码编码器(必须与注册时一致) } /** * 配置HTTP安全规则 */ @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/api/auth/register", "/public/**").permitAll() // 注册和公开接口放行 .anyRequest().authenticated() // 其他所有请求都需要认证 .and() .formLogin() // 启用表单登录(会提供默认的/login端点) .loginProcessingUrl("/api/auth/login") // 自定义登录处理URL,对应前端提交地址 .usernameParameter("username") // 默认就是username,可省略 .passwordParameter("password") // 默认就是password,可省略 .successHandler(loginSuccessHandler()) // 自定义登录成功处理 .failureHandler(loginFailureHandler()) // 自定义登录失败处理 .permitAll() .and() .logout() .logoutUrl("/api/auth/logout") // 自定义登出URL .permitAll() .and() .csrf().disable() // 为简化演示禁用CSRF,生产环境API建议使用Token等方式处理 .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 设置为无状态,通常用于前后端分离+JWT } /** * 将AuthenticationManager暴露为Bean,方便其他地方注入使用(如用于生成JWT的Controller) */ @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } // 以下为自定义的成功/失败处理器,用于返回JSON格式响应 @Bean public AuthenticationSuccessHandler loginSuccessHandler() { return (request, response, authentication) -> { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\": 200, \"message\": \"登录成功\"}"); }; } @Bean public AuthenticationFailureHandler loginFailureHandler() { return (request, response, exception) -> { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpStatus.UNAUTHORIZED.value()); String message = "登录失败"; if (exception instanceof BadCredentialsException) { message = "用户名或密码错误"; } else if (exception instanceof UsernameNotFoundException) { message = "用户不存在"; } else if (exception instanceof DisabledException) { message = "账户已被禁用"; } // ... 其他异常处理 response.getWriter().write(String.format("{\"code\": 401, \"message\": \"%s\"}", message)); }; } }配置深度解析:
configure(AuthenticationManagerBuilder auth):这是核心绑定。它告诉Spring Security的认证管理器,当需要验证用户时,去调用我们的userDetailsService来加载用户,并用我们配置的passwordEncoder来校验密码。这里指定的passwordEncoder必须与注册时使用的完全一致,否则永远校验失败。configure(HttpSecurity http):定义URL级别的安全规则和登录行为。.antMatchers().permitAll():放行注册接口和公开资源,避免被安全拦截。.formLogin():启用表单登录。默认会提供一个/login的POST端点。我们通过.loginProcessingUrl()将其自定义为/api/auth/login,这样更符合RESTful风格。.successHandler()和.failureHandler():强烈建议自定义。默认的成功处理是重定向,失败是重定向到/login?error,这在前后端分离项目中不适用。我们自定义为返回JSON。
csrf().disable():对于纯API服务且使用无状态认证(如JWT)的场景,可以禁用CSRF保护。如果是有状态的Web应用(使用Session),则应启用并妥善处理。sessionCreationPolicy(SessionCreationPolicy.STATELESS):设置为无状态,意味着Security不会创建和使用HttpSession。这通常与JWT等Token认证方案配合使用。如果使用传统的Session-Cookie方案,应移除这行或设置为IF_REQUIRED。
4.2 登录流程的自动验证剖析
当用户调用POST /api/auth/login时,Spring Security的魔法就开始了,这个过程对开发者是无感的:
- 请求拦截:
UsernamePasswordAuthenticationFilter会拦截到该URL的POST请求。 - 提取凭证:从请求参数中提取
username和password(我们自定义了参数名)。 - 创建令牌:生成一个未认证的
UsernamePasswordAuthenticationToken。 - 调用认证管理器:认证管理器(
AuthenticationManager)接手。 - 加载用户:认证管理器调用我们配置的
CustomUserDetailsService.loadUserByUsername(username),获取到包含BCrypt密文密码的UserDetails对象。 - 密码校验:认证管理器使用我们配置的
BCryptPasswordEncoder,对用户提交的明文密码进行加密(调用encode或matches方法)。关键在于,BCryptPasswordEncoder.matches(明文, 密文)方法内部会智能地处理:它从数据库存储的密文中提取出当初加密时使用的盐值(salt)和强度因子(cost),然后用相同的算法和参数对本次输入的明文进行哈希,最后比较两个哈希值是否一致。这个过程对我们完全透明。 - 认证决策:如果
matches返回true,则认证成功。认证管理器会填充Authentication对象的权限等信息,并将其放入安全上下文(SecurityContextHolder)。随后,我们自定义的successHandler被触发,返回成功JSON。如果失败(密码错误、用户不存在等),则抛出相应异常,由failureHandler捕获并返回错误JSON。
整个过程,我们只需要确保UserDetailsService返回正确的密文,以及PasswordEncoder是同一个BCrypt实例,校验工作框架全包了。
5. 进阶话题:常见问题、性能与扩展
基础链路跑通后,在实际项目中你肯定会遇到更多具体问题。这里分享几个高频问题和优化思路。
5.1 常见问题排查与解决方案实录
问题1:登录时始终提示“Bad credentials”(凭证错误),但确认密码正确。这是最常见的问题,排查思路如下:
- 第一步:检查数据库密码。确认注册时确实使用了
BCryptPasswordEncoder加密,并且密文已成功存入数据库。一个BCrypt密文通常以$2a$10$...或$2b$10$...开头,长度固定为60字符。如果你的密码字段看起来不像这样,那肯定不对。 - 第二步:检查UserDetailsService。在
loadUserByUsername方法中打日志,确认根据用户名查到了用户,并且user.getPassword()返回的就是那个60位的BCrypt密文。 - 第三步:检查Security配置。确认
SecurityConfiguration中configure(AuthenticationManagerBuilder auth)方法里,.passwordEncoder(passwordEncoder)指定的编码器,与注册时使用的编码器是同一个Bean。确保没有在别处不小心声明了另一个PasswordEncoder的Bean。 - 第四步:手动验证。在测试用例或临时接口中,注入
PasswordEncoder,手动调用passwordEncoder.matches(“用户输入的明文”, “数据库中的密文”)看结果是否为true。这能最快定位是编码问题还是流程问题。
问题2:我想在用户注册时,除了BCrypt加密,还想加点“盐”(比如拼接用户名)再加密,可以吗?强烈不建议这样做。BCrypt的设计已经包含了强随机盐值,并且盐就保存在哈希输出里。额外拼接字符串(称为“pepper”或自定义盐)会破坏BCrypt的内置机制,并且你需要自己安全地存储和管理这个额外的“盐”,这引入了新的复杂性和风险点。BCrypt本身的安全性已经足够应对密码存储场景。如果你有更高的安全需求(如防止内部人员盗用哈希值),应在架构层面考虑,例如使用硬件安全模块(HSM)或在应用层进行整体加密,而不是修改密码哈希过程。
问题3:BCrypt加密很慢,会影响登录性能吗?这是一个性能与安全的权衡。BCrypt的“慢”是设计使然,通常一次哈希在100ms左右。对于登录这种低频操作(一个用户每秒最多尝试几次),这个开销完全可以接受,并且能有效阻止大规模暴力破解。如果登录QPS真的非常高(如万人同时登录),可以考虑:
- 适当调整强度因子:在性能测试后,将强度从10降到9或8。但这是以牺牲安全性为代价的,需谨慎评估。
- 横向扩展:通过增加应用服务器实例来分担认证压力。
- 异步处理:将耗时的密码匹配操作放到单独的线程池中,避免阻塞HTTP请求线程。但Spring Security默认是同步的,改造有一定复杂度。实测建议:在主流服务器上,强度10的BCrypt处理每秒上百次登录请求通常没有问题。性能瓶颈更可能出现在数据库查询或网络IO上。
问题4:如何实现“记住我”(Remember-Me)功能?Spring Security原生支持Remember-Me。在HttpSecurity配置中加上.and().rememberMe()即可。其本质是在用户浏览器中存储一个加密的Token Cookie。下次访问时,即使Session过期,也能通过该Token自动认证。你需要配置一个tokenRepository(通常用PersistentTokenRepository基于数据库实现,更安全)和key(用于加密Token的密钥)。注意,Remember-Me会降低安全性,仅适用于安全要求不高的内部系统或可信任设备。
问题5:我想用手机号登录,而不是用户名,怎么办?UserDetailsService.loadUserByUsername的参数名虽然是username,但它本质上是一个“用户标识”。你可以:
- 让用户用手机号注册,并将手机号作为
username存入数据库和用于登录。 - 如果用户名和手机号是两个字段,可以在
loadUserByUsername方法中,先尝试用username查,查不到再尝试用手机号查。或者,更优雅的方式是实现Spring Security的AuthenticationProvider接口,完全自定义认证逻辑,支持多种凭证类型。
5.2 性能优化与最佳实践
缓存UserDetails:
UserDetailsService在每次认证时都会被调用,频繁查询数据库可能成为瓶颈。可以考虑使用Spring Cache(如Redis)缓存UserDetails对象。但需注意,当用户信息(尤其是权限)更新时,需要及时清除缓存。@Cacheable(value = "userDetails", key = "#username") @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // ... 查询数据库逻辑 }注意:缓存的是
UserDetails对象,其中包含密码密文。虽然密文泄露风险已比明文小,但仍需确保缓存存储(如Redis)本身的安全。分离认证服务器:在微服务架构下,可以考虑将认证逻辑抽离为独立的认证服务(Auth Server),统一处理所有应用的注册、登录、Token签发。其他业务服务(Resource Server)则无需集成Spring Security的完整认证流程,只需验证Token即可。这是向OAuth 2.0 / JWT架构演进的方向。
监控与告警:监控登录失败频率。对同一用户名或IP的频繁失败登录尝试,应触发告警并可能实施临时锁定策略。这可以在自定义的
AuthenticationFailureHandler中实现计数逻辑。
5.3 向JWT无状态认证扩展
我们目前的配置使用了SessionCreationPolicy.STATELESS,但登录成功后的认证信息默认还是放在SecurityContext(线程局部变量)中,请求结束就没了。要实现真正的无状态,通常需要结合JWT。
- 登录成功签发JWT:在自定义的
AuthenticationSuccessHandler中,在认证成功后,使用如jjwt库生成一个JWT令牌,将用户ID、用户名等信息放入payload,并设置过期时间。然后将此Token返回给前端。 - 配置JWT过滤器:创建一个
JwtAuthenticationFilter,继承OncePerRequestFilter。在doFilterInternal方法中,从请求头(如Authorization: Bearer <token>)中提取JWT,进行解析和验证。验证通过后,根据JWT中的信息(如用户名),手动调用UserDetailsService加载用户权限,并构建一个Authentication对象设置到SecurityContextHolder中。 - 调整Security配置:在
HttpSecurity配置中,通过.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)将JWT过滤器添加到用户名密码过滤器之前。同时,确保登录接口放行,其他接口需要认证。
这样,前端在登录后获取JWT,后续请求在Header中携带此Token。服务端通过JWT过滤器完成认证,无需Session,实现了真正的无状态分布式认证。这是目前前后端分离架构下非常流行的方案。
整个从BCrypt加密存储到UserDetailsService加载,再到Spring Security自动验证的链路,构成了一个稳固的密码安全基石。无论你后续是沿用Session,还是扩展为JWT、OAuth2,这个基石都是通用的。理解了这个链路,你对Spring Security的核心认证机制就算真正入门了。