认证流程
定义与作用
Spring Security 的认证流程,是指从用户提交凭据(通常是用户名和密码)开始,到系统确认用户身份并将其安全信息存入上下文为止的完整链路。它是整个安全框架的入口管线,决定了"谁能进入系统"。
手写过滤器的痛点
在没有 Spring Security 之前,开发者通常手写 Servlet Filter 处理登录:
// 典型手写登录过滤器(痛点累累)
public class HandmadeLoginFilter implements Filter {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
HttpServletRequest request = (HttpServletRequest) req;
if ("/login".equals(request.getRequestURI())) {
String username = request.getParameter("username");
String password = request.getParameter("password");
// 痛点1:密码明文比对,没有标准编码策略
// 痛点2:用户不存在 vs 密码错误,容易泄露信息
// 痛点3:登录成功后往哪存用户信息?HttpSession?ThreadLocal?各写各的
// 痛点4:异常处理不统一,有的抛异常,有的直接 response.sendError(401)
// 痛点5:权限信息怎么挂到用户身上?Role 和 Authority 混为一谈
// 痛点6:多个登录方式(表单、Basic、Token)无法复用同一套验证逻辑
}
}
}
Spring Security 的认证流程通过标准化接口 + 职责分离 + 策略模式一次性解决了以上所有问题。
核心原理
Spring Security 认证流程由过滤器链驱动,核心参与者如下:
| 组件 | 职责 | 类比 |
|---|---|---|
UsernamePasswordAuthenticationFilter | 拦截登录请求,提取凭据,封装为 Token | 前台接待 |
AuthenticationManager | 接收 Token,启动认证 | 大堂经理 |
ProviderManager | 遍历 Provider 列表,找到能处理此 Token 的人 | 调度中心 |
AuthenticationProvider | 执行具体认证逻辑(如查库、比对密码) | 业务专员 |
UserDetailsService | 根据用户名加载用户信息 | 档案管理员 |
PasswordEncoder | 密码哈希比对 | 密码校验机 |
SecurityContextHolder | 存放认证成功后的安全上下文 | 身份档案柜 |
认证时序全流程
关键状态转换
认证流程中最核心的状态转换是 Authentication 从未认证变为已认证:
// 步骤1:过滤器构造未认证 Token(principal=username, credentials=password)
UsernamePasswordAuthenticationToken unauthenticated =
new UsernamePasswordAuthenticationToken(username, password);
// 步骤2:经过 Provider 认证后,返回已认证 Token(principal=UserDetails, authorities=角色列表)
UsernamePasswordAuthenticationToken authenticated =
new UsernamePasswordAuthenticationToken(
userDetails, // principal:用户主体
null, // credentials:凭据已擦除
userDetails.getAuthorities() // authorities:权限列表
);
示例一:表单登录的完整认证链路
场景说明
一个传统 Web 应用,用户通过表单提交用户名密码,Spring Security 完成认证并将会话存入 HttpSession。
操作前配置
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/home")
.permitAll()
);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("admin").password("{noop}admin123").roles("ADMIN")
.and()
.withUser("user").password("{noop}user123").roles("USER");
}
}
操作后配置
将内存用户替换为数据库用户,并引入密码编码:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.requestMatchers(new AntPathRequestMatcher("/public/**")).permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/home")
.permitAll()
);
}
}
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity user = userMapper.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在: " + username);
}
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRole().split(","))
.build();
}
}
结果分析
- 登录请求被
UsernamePasswordAuthenticationFilter拦截 ProviderManager将认证委托给DaoAuthenticationProviderCustomUserDetailsService从数据库加载用户BCryptPasswordEncoder比对密码哈希- 认证成功后,
SecurityContextHolder存入Authentication,SecurityContextPersistenceFilter将其同步到 Session
示例二:REST API + Basic 认证的流程观察
场景说明
内部管理接口需要支持 HTTP Basic 认证,供第三方系统调用,观察其认证流程与表单登录的区别。
操作前配置
仅开放表单登录,无 Basic 认证:
http
.authorizeRequests(authorize -> authorize
.antMatchers("/api/internal/**").authenticated()
)
.formLogin(Customizer.withDefaults());
第三方系统调用 GET /api/internal/status 时,由于没有表单页面,会收到 302 重定向到登录页,API 调用失败。
操作后配置
增加 Basic 认证支持,让同一 URL 支持两种认证入口:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.requestMatchers(new AntPathRequestMatcher("/api/internal/**")).authenticated()
.anyRequest().permitAll()
)
.httpBasic(Customizer.withDefaults()) // 启用 BasicAuthenticationFilter
.formLogin(Customizer.withDefaults());
}
}
结果分析
BasicAuthenticationFilter与UsernamePasswordAuthenticationFilter共用同一套AuthenticationManager→ProviderManager→DaoAuthenticationProvider链路- 认证入口不同,但认证核心流程完全一致,体现了 Spring Security 认证架构的复用性
易错场景:认证成功后 SecurityContext 丢失
问题描述
开发者在登录成功后的 Controller 中直接新建线程处理业务,发现 SecurityContextHolder.getContext().getAuthentication() 返回 null。
错误代码
@Controller
public class LoginController {
@GetMapping("/home")
public String home() {
// 主线程能获取到认证信息
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
System.out.println("主线程: " + auth.getName()); // 正常输出
// 错误:新线程中 SecurityContext 丢失
new Thread(() -> {
Authentication authInThread = SecurityContextHolder.getContext().getAuthentication();
System.out.println("子线程: " + authInThread); // null!
}).start();
return "home";
}
}
原因分析
SecurityContextHolder 默认使用 MODE_THREADLOCAL 策略,安全上下文绑定到当前线程。新建线程不继承父线程的 ThreadLocal 变量。
正确做法
方式一:切换策略(应用启动时全局设置)
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_INHERITABLETHREADLOCAL
);
SpringApplication.run(Application.class, args);
}
}
方式二:手动传递 Authentication(更可控)
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
new Thread(() -> {
SecurityContextHolder.getContext().setAuthentication(auth);
// 执行业务...
}).start();
面试考点:
SecurityContextHolder的默认策略是什么?@Async异步方法中为什么拿不到当前用户?如何正确传递认证信息?