Spring Security 登录流程定制化——记住我、图形验证码、MFA 多因素认证
Spring Security 登录流程定制化——记住我、图形验证码、MFA 多因素认证
适读人群:需要做定制化登录体验的Spring Boot开发者 | 阅读时长:约19分钟 | 核心价值:掌握登录流程各个扩展点,能独立实现记住我、验证码、多因素认证
产品一次次提需求,我终于搞明白了扩展点在哪
2022年,产品陆续给我提了三个登录相关的需求:
第一波:记住我功能。用户勾选"7天内免登录",7天内不需要重新输密码。
第二波:图形验证码。登录页面加验证码,防止暴力破解。
第三波:手机号短信验证码。重要操作(修改密码、大额转账)需要额外验证身份。
每次接到需求,我都要重新搜怎么在Spring Security里做。每次都找到不同的方案,有些改了SecurityFilterChain,有些直接改了登录接口,最终代码乱得一塌糊涂。
后来我系统学习了Spring Security的扩展机制,才搞明白:不同的需求对应不同的扩展点,找对了地方,代码非常简洁。
登录流程的完整时序
理解扩展点,先看完整登录流程:
HTTP POST /login
↓
UsernamePasswordAuthenticationFilter
→ 从请求中提取 username/password
→ 创建 UsernamePasswordAuthenticationToken(未认证)
↓
AuthenticationManager(ProviderManager)
→ 委托给 DaoAuthenticationProvider
→ 调用 UserDetailsService.loadUserByUsername()
→ 用 PasswordEncoder.matches() 验证密码
↓
认证成功 → AuthenticationSuccessHandler
认证失败 → AuthenticationFailureHandler
↓
成功后:SessionManagement、RememberMe、SecurityContext持久化功能一:记住我(RememberMe)
Spring Security 内置的两种实现
1. 基于Hash的Token(简单,无需数据库)
- Token格式:
base64(username + ":" + expirationTime + ":" + md5Hex(username + ":" + expirationTime + ":" + password + ":" + key)) - 优点:无需数据库,实现简单
- 缺点:修改密码后Token自动失效(因为hash里包含密码),但无法主动撤销Token
2. 基于持久化的Token(推荐,安全性更高)
- Token存在数据库里,每次使用后轮换
- 可以主动撤销(修改密码时清除数据库里的Token)
- 能检测Token重放攻击
-- 记住我 Token 表(Spring Security规范的表结构)
CREATE TABLE persistent_logins (
username VARCHAR(64) NOT NULL,
series VARCHAR(64) NOT NULL,
token VARCHAR(64) NOT NULL,
last_used TIMESTAMP NOT NULL,
PRIMARY KEY (series)
);@Configuration
@EnableWebSecurity
public class RememberMeConfig {
@Autowired
private DataSource dataSource;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin(form -> form
.loginPage("/login")
.permitAll()
)
.rememberMe(rememberMe -> rememberMe
// 使用持久化Token(推荐)
.tokenRepository(persistentTokenRepository())
// 记住时长:7天
.tokenValiditySeconds(7 * 24 * 3600)
// 表单中的checkbox字段名
.rememberMeParameter("remember-me")
// 记住我cookie的名称
.rememberMeCookieName("REMEMBER_ME")
// 必须设置密钥(防止Token被伪造)
.key("your-remember-me-key-min-32-chars")
// 使用HTTPS时,Cookie设置Secure
// .useSecureCookie(true)
);
return http.build();
}
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
// 首次启动创建表(已有表则注释掉)
// tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
}前端登录表单:
<form th:action="@{/login}" method="post">
<input type="text" name="username" placeholder="用户名"/>
<input type="password" name="password" placeholder="密码"/>
<label>
<input type="checkbox" name="remember-me"/> 7天内记住我
</label>
<button type="submit">登录</button>
</form>功能二:图形验证码
Spring Security没有内置验证码,需要自己实现。关键是把验证码校验插入到认证流程中。
实现思路
登录请求
↓
验证码过滤器(在UsernamePasswordAuthenticationFilter之前)
→ 检查验证码是否正确
→ 不正确:直接拒绝,不进入认证流程
→ 正确:清除验证码,继续后续认证
↓
UsernamePasswordAuthenticationFilter(正常认证)/**
* 图形验证码生成与验证
*/
@RestController
@RequiredArgsConstructor
public class CaptchaController {
private final RedisTemplate<String, String> redisTemplate;
@GetMapping("/captcha")
public void generateCaptcha(HttpServletRequest request,
HttpServletResponse response) throws IOException {
// 生成4位随机验证码
String captchaText = generateRandomText(4);
// 存入Redis,5分钟有效(以SessionId为key)
String sessionId = request.getSession().getId();
redisTemplate.opsForValue().set(
"captcha:" + sessionId, captchaText.toLowerCase(),
5, TimeUnit.MINUTES
);
// 生成图片(使用Hutool或手动绘制)
BufferedImage image = drawCaptchaImage(captchaText);
response.setContentType("image/png");
response.setHeader("Cache-Control", "no-cache, no-store");
ImageIO.write(image, "PNG", response.getOutputStream());
}
private String generateRandomText(int length) {
// 排除容易混淆的字符
String chars = "23456789abcdefghjkmnpqrstuvwxyz";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(chars.charAt((int)(Math.random() * chars.length())));
}
return sb.toString();
}
private BufferedImage drawCaptchaImage(String text) {
int width = 120, height = 40;
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
// 抗锯齿
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 背景
g.setColor(Color.WHITE);
g.fillRect(0, 0, width, height);
// 干扰线
Random random = new Random();
for (int i = 0; i < 5; i++) {
g.setColor(new Color(random.nextInt(200), random.nextInt(200), random.nextInt(200)));
g.drawLine(random.nextInt(width), random.nextInt(height),
random.nextInt(width), random.nextInt(height));
}
// 绘制文字
g.setFont(new Font("Arial", Font.BOLD | Font.ITALIC, 28));
for (int i = 0; i < text.length(); i++) {
g.setColor(new Color(random.nextInt(150), random.nextInt(150), random.nextInt(150)));
g.drawString(String.valueOf(text.charAt(i)), 20 + i * 24, 30);
}
g.dispose();
return image;
}
}
/**
* 验证码过滤器:在登录认证之前验证验证码
*/
@Component
public class CaptchaValidationFilter extends OncePerRequestFilter {
private final RedisTemplate<String, String> redisTemplate;
public CaptchaValidationFilter(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
// 只拦截POST /login
if (!"/login".equals(request.getServletPath()) ||
!"POST".equals(request.getMethod())) {
filterChain.doFilter(request, response);
return;
}
String captchaInput = request.getParameter("captcha");
String sessionId = request.getSession().getId();
String storedCaptcha = redisTemplate.opsForValue().get("captcha:" + sessionId);
if (storedCaptcha == null) {
sendCaptchaError(response, "验证码已过期,请刷新");
return;
}
if (!storedCaptcha.equals(captchaInput != null ? captchaInput.toLowerCase() : "")) {
sendCaptchaError(response, "验证码错误");
return;
}
// 验证成功,删除已使用的验证码
redisTemplate.delete("captcha:" + sessionId);
filterChain.doFilter(request, response);
}
private void sendCaptchaError(HttpServletResponse response, String msg) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"" + msg + "\"}");
}
}
// 在SecurityConfig中注册过滤器
// http.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class);功能三:MFA 多因素认证(TOTP)
MFA(Multi-Factor Authentication)的最常用形式是TOTP(Time-based One-Time Password),即Google Authenticator这类App生成的6位数字验证码。
<!-- TOTP 实现库 -->
<dependency>
<groupId>dev.samstevens.totp</groupId>
<artifactId>totp-spring-boot-starter</artifactId>
<version>1.7.1</version>
</dependency>/**
* MFA 完整实现:
* 1. 用户启用MFA时:生成密钥,显示二维码
* 2. 用户登录时:先验证密码,再验证TOTP码
*/
@Service
@RequiredArgsConstructor
public class MfaService {
private final SecretGenerator secretGenerator;
private final QrDataFactory qrDataFactory;
private final QrGenerator qrGenerator;
private final CodeVerifier codeVerifier;
private final UserRepository userRepository;
/**
* 生成MFA密钥和二维码(用户绑定MFA时调用)
*/
public MfaSetupResult setupMfa(String username) throws QrGenerationException {
// 生成Base32密钥
String secret = secretGenerator.generate();
// 生成二维码数据(otpauth://totp/...)
QrData qrData = qrDataFactory.newBuilder()
.label(username)
.secret(secret)
.issuer("MyApp")
.build();
// 生成二维码图片(Base64格式,直接嵌入HTML)
String qrImageBase64 = "data:image/png;base64," +
Base64.getEncoder().encodeToString(qrGenerator.generate(qrData));
// 临时存储密钥(用户确认绑定成功后才持久化)
String tempKey = "mfa:setup:" + username;
redisTemplate.opsForValue().set(tempKey, secret, 10, TimeUnit.MINUTES);
return new MfaSetupResult(qrImageBase64, secret);
}
/**
* 确认绑定:用户扫码后输入验证码,验证成功才保存密钥
*/
public boolean confirmMfaSetup(String username, String code) {
String tempKey = "mfa:setup:" + username;
String secret = (String) redisTemplate.opsForValue().get(tempKey);
if (secret == null) return false;
if (codeVerifier.isValidCode(secret, code)) {
// 验证成功,持久化密钥
userRepository.saveMfaSecret(username, secret);
redisTemplate.delete(tempKey);
return true;
}
return false;
}
/**
* 验证TOTP码(登录时使用)
*/
public boolean verifyCode(String username, String code) {
String secret = userRepository.getMfaSecret(username);
if (secret == null) return false;
return codeVerifier.isValidCode(secret, code);
}
}
/**
* 两步认证过滤器:
* 第一步:用户名+密码认证(Spring Security处理)
* 第二步:TOTP验证码验证(自定义处理)
*/
@Component
public class TotpAuthenticationFilter extends OncePerRequestFilter {
private final MfaService mfaService;
private final UserRepository userRepository;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
// 只处理MFA验证端点
if (!"/api/auth/mfa-verify".equals(request.getServletPath())) {
filterChain.doFilter(request, response);
return;
}
// 检查用户是否已经通过了第一步(用户名+密码)认证
// 用session标记第一步是否通过
String pendingUsername = (String) request.getSession().getAttribute("MFA_PENDING_USER");
if (pendingUsername == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"请先完成密码认证\"}");
return;
}
String totpCode = request.getParameter("code");
if (mfaService.verifyCode(pendingUsername, totpCode)) {
// TOTP验证成功,完成完整认证
request.getSession().removeAttribute("MFA_PENDING_USER");
// 重新加载用户并设置SecurityContext
UserDetails user = userDetailsService.loadUserByUsername(pendingUsername);
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(auth);
response.getWriter().write("{\"success\":true, \"message\":\"认证成功\"}");
} else {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"验证码错误或已过期\"}");
}
}
}三个踩坑实录
坑一:记住我 Cookie 在换了密码后没有失效
现象: 用户修改密码后,在另一台设备上用"记住我"还能继续登录(未修改密码的老Token还有效)。
原因: 使用了基于Hash的RememberMe,Token里包含了密码的hash。修改密码后,老Token里的密码hash和新密码不匹配,本应失效。但如果用的是持久化Token,修改密码时没有清除数据库里的Token,依然有效。
解法: 在修改密码的Service方法里,主动清除该用户的持久化RememberMe Token:
public void changePassword(String username, String newPassword) {
userRepository.updatePassword(username, passwordEncoder.encode(newPassword));
// 修改密码后,清除所有记住我Token(强制重新登录)
persistentTokenRepository.removeUserTokens(username);
}坑二:验证码过滤器正常工作,但忘记也要处理 AuthenticationFailureHandler
现象: 验证码校验失败时返回了正确的JSON,但成功登录后的逻辑走了formLogin的successHandler而不是我自定义的API响应。
原因: Spring Security的formLogin默认会重定向到登录成功页,前后端分离项目需要自定义successHandler和failureHandler:
http.formLogin(form -> form
.successHandler((request, response, auth) -> {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"success\":true, \"token\":\"" + generateToken(auth) + "\"}");
})
.failureHandler((request, response, ex) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\":\"" + ex.getMessage() + "\"}");
})
);坑三:MFA 验证时间窗口太严,导致用户无法登录
现象: 部分用户报告TOTP验证码一直错,手机上明明显示正确的6位数字,但系统拒绝。
原因: TOTP基于时间同步,如果用户的手机时间和服务器时间差超过30秒,验证会失败。
解法: 扩大时间窗口,允许前后各1个时间步(30秒)的误差:
// TOTP 配置:时间容差
@Bean
public TimeProvider timeProvider() {
return new SystemTimeProvider();
}
@Bean
public DefaultCodeVerifier codeVerifier(CodeGenerator codeGenerator, TimeProvider timeProvider) {
DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);
verifier.setTimePeriod(30); // 30秒一个时间步
verifier.setAllowedTimePeriodDiscrepancy(1); // 允许前后各1步的误差(±30秒)
return verifier;
}小结
Spring Security的登录流程扩展遵循"找对扩展点"的原则:
- 记住我:内置支持,选基于持久化的Token,数据库存储可撤销
- 验证码:自定义Filter插入到UsernamePasswordAuthenticationFilter之前
- MFA:自定义Filter + Session状态机,分两步完成认证
- 自定义响应:配置successHandler/failureHandler,适配前后端分离
每个功能都有清晰的扩展点,找对了之后实现非常简洁。乱改filter顺序或者在Controller层做认证逻辑,才是把自己绕进去的根本原因。
