Spring Boot项目实战:5分钟搞定Google Authenticator两步验证集成

📅 发布时间:2026/7/10 11:36:54 👁️ 浏览次数:
Spring Boot项目实战:5分钟搞定Google Authenticator两步验证集成
Spring Boot项目实战5分钟搞定Google Authenticator两步验证集成最近在重构一个内部管理系统安全审计报告里明确提到了“账户登录环节缺乏多因素认证”。老板的原话是“现在都什么年代了还只用密码万一泄露了怎么办” 确实对于涉及敏感操作的后台单靠密码就像只用一把挂锁看门心理安慰大于实际效果。在评估了短信验证码、硬件令牌等多种方案后我们最终选择了基于TOTP基于时间的一次性密码协议的Google Authenticator方案。原因很简单零成本、用户体验好、集成快。用户不需要额外硬件只需在手机上装个App对我们开发者来说不用对接第三方短信或邮件服务没有额外费用核心逻辑几行代码就能搞定。这篇文章我就以一个真实的Spring Boot项目为例带你走一遍从零到一的集成全过程。我会重点分享几个实战中容易踩坑的地方比如密钥到底存哪里更安全、如何优雅地生成和展示二维码、验证逻辑如何与Spring Security无缝结合以及如何处理那个恼人的“时间窗口”同步问题。目标很明确让你在理解原理的基础上能快速、稳定地把这套机制应用到自己的项目里。1. 理解核心TOTP协议与Google Authenticator在动手写代码之前我们得先搞明白Google Authenticator后文简称GA到底在干什么。它不是一个魔法黑盒其核心是TOTP算法全称是“Time-based One-Time Password”。你可以把它想象成一个由时间和密钥共同驱动的精密密码生成器。TOTP的工作原理可以概括为以下几步共享密钥服务端生成一个唯一的、高强度的随机密钥通常是一个Base32编码的字符串并安全地分发给用户通常通过二维码。时间同步服务端和用户的GA App都维护着一个大致同步的当前时间通常使用Unix时间戳即从1970年1月1日开始的秒数。计算时间片将当前时间戳除以一个预定义的时间间隔通常是30秒得到一个不断递增的“时间计数器”。生成一次性密码使用HMAC-SHA1算法用共享密钥对“时间计数器”进行加密运算生成一个哈希值再从这个哈希值中截取一段映射成一个6位或8位的数字。整个过程的关键在于只要密钥相同且双方的时间在允许的误差范围内计算出的6位数就必然相同。GA App只是这个计算过程的客户端实现它存储了你的密钥并每隔30秒重新计算一次密码。注意GA App本身不需要联网来生成验证码。它只需要在初始绑定时通过扫码获取密钥之后的所有计算都在手机本地完成。这既是其优势离线可用也对时间同步提出了要求。那么在我们的Spring Boot应用中需要实现的就是这三件事生成并安全存储密钥。提供绑定接口生成包含密钥等信息的二维码。在登录时用存储的密钥和当前时间计算出正确的验证码并与用户输入比对。理解了这些代码写起来就心中有数了。2. 项目环境与核心依赖配置我们从一个全新的Spring Boot 2.7项目开始。我习惯用Spring Initializr初始化选择Web、Security用于集成登录流程、JPA用于存储用户和密钥等基础依赖。核心的Maven依赖除了Spring Boot Starter系列主要是下面几个!-- TOTP算法实现 -- dependency groupIdde.taimos/groupId artifactIdtotp/artifactId version1.0/version /dependency !-- Base32编解码用于处理密钥 -- dependency groupIdcommons-codec/groupId artifactIdcommons-codec/artifactId version1.15/version !-- 使用较新版本 -- /dependency !-- 生成二维码图片 -- dependency groupIdcom.google.zxing/groupId artifactIdcore/artifactId version3.5.1/version /dependency dependency groupIdcom.google.zxing/groupId artifactIdjavase/artifactId version3.5.1/version /dependencytotp一个轻量级的库封装了TOTP码的生成和验证逻辑让我们不必从头实现HMAC-SHA1。commons-codec我们主要用它的Base32类来处理密钥的编码和解码。zxing著名的二维码处理库用于将我们生成的绑定信息字符串转换为二维码图片。数据库方面我们在用户表里添加一个字段来存储密钥。为了安全强烈建议对这个字段进行加密存储而不是明文保存。ALTER TABLE sys_user ADD COLUMN totp_secret VARCHAR(64) DEFAULT NULL COMMENT TOTP密钥加密存储;实体类对应添加字段Entity Table(name sys_user) Data public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String username; private String password; // ... 其他字段 private String totpSecret; // 存储加密后的密钥 private Boolean totpEnabled Boolean.FALSE; // 是否已启用两步验证 }3. 构建核心工具类密钥、验证码与二维码我不喜欢把业务逻辑和工具代码混在一起所以先创建一个TOTPUtil工具类把生成密钥、生成验证码、构建绑定URI和生成二维码图片这些底层操作封装好。import com.google.zxing.BarcodeFormat; import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.QRCodeWriter; import de.taimos.totp.TOTP; import org.apache.commons.codec.binary.Base32; import org.apache.commons.codec.binary.Hex; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; /** * TOTP工具类 */ public class TOTPUtil { private static final SecureRandom RANDOM new SecureRandom(); /** * 生成一个随机的Base32编码密钥通常20字节编码后32字符 */ public static String generateSecretKey() { byte[] buffer new byte[20]; // RFC 4226 推荐至少160位20字节 RANDOM.nextBytes(buffer); Base32 base32 new Base32(); return base32.encodeToString(buffer); } /** * 根据密钥获取当前时间窗口内的TOTP验证码6位数字 * param secretKey Base32编码的密钥 * return 6位验证码字符串 */ public static String getCurrentCode(String secretKey) { if (secretKey null || secretKey.trim().isEmpty()) { throw new IllegalArgumentException(Secret key cannot be null or empty); } Base32 base32 new Base32(); byte[] decodedKey base32.decode(secretKey); String hexKey Hex.encodeHexString(decodedKey); // 使用 de.taimos.totp.TOTP 库生成 return TOTP.getOTP(hexKey); } /** * 构建用于生成二维码的OTPAUTH URI。 * 这是Google Authenticator等标准应用识别的格式。 * * param account 用户标识如邮箱 * param secretKey 密钥 * param issuer 发行者应用或公司名 * return otpauth:// 格式的URI字符串 */ public static String buildOtpAuthUri(String account, String secretKey, String issuer) { try { // 对参数进行URL编码确保特殊字符正确处理 String encodedIssuer URLEncoder.encode(issuer, StandardCharsets.UTF_8.toString()); String encodedAccount URLEncoder.encode(account, StandardCharsets.UTF_8.toString()); // 标准格式otpauth://totp/Issuer:Account?secretSecretissuerIssuer return String.format(otpauth://totp/%s:%s?secret%sissuer%s, encodedIssuer, encodedAccount, secretKey, encodedIssuer); } catch (Exception e) { throw new RuntimeException(Failed to build OTP Auth URI, e); } } /** * 将OTPAUTH URI字符串生成二维码图片并返回Base64字符串便于前端img标签直接显示 * * param otpAuthUri 上一步生成的URI * param width 图片宽度 * param height 图片高度 * return Base64编码的PNG图片字符串带data:image/png;base64,前缀 */ public static String generateQRCodeBase64(String otpAuthUri, int width, int height) throws Exception { QRCodeWriter qrCodeWriter new QRCodeWriter(); BitMatrix bitMatrix qrCodeWriter.encode(otpAuthUri, BarcodeFormat.QR_CODE, width, height); BufferedImage bufferedImage MatrixToImageWriter.toBufferedImage(bitMatrix); ByteArrayOutputStream os new ByteArrayOutputStream(); ImageIO.write(bufferedImage, png, os); String base64 Base64.getEncoder().encodeToString(os.toByteArray()); return data:image/png;base64, base64; } /** * 验证用户输入的TOTP码。 * 考虑到网络延迟和用户输入时间通常允许前后一个时间窗口共3个窗口。 * * param secretKey 用户存储的密钥 * param userInputCode 用户输入的6位码 * return 验证是否通过 */ public static boolean verifyCode(String secretKey, String userInputCode) { if (userInputCode null || userInputCode.length() ! 6) { return false; } // 获取当前时间的验证码 String currentCode getCurrentCode(secretKey); if (currentCode.equals(userInputCode)) { return true; } // 可选容忍时间偏移检查前一个时间窗口的码30秒前 // 这里需要更复杂的实现例如使用 System.currentTimeMillis() / 1000 / 30 - 1 计算前一个窗口 // 简单场景下严格校验当前窗口通常已足够但容错能提升用户体验。 return false; } }这个工具类就是我们的“瑞士军刀”。其中buildOtpAuthUri方法生成的URI格式是标准协议不仅GA能识别Microsoft Authenticator、Authy等主流App都能识别。generateQRCodeBase64方法直接返回Base64字符串前端用就能显示省去了文件上传下载的麻烦。4. 集成Spring Security改造登录流程现在进入重头戏如何把两步验证无缝嵌入到现有的Spring Security登录流程中。我们不想破坏原有的UsernamePasswordAuthenticationFilter逻辑而是希望在密码验证通过后拦截登录成功的过程检查用户是否启用了TOTP。如果启用了就跳转到输入TOTP验证码的页面如果未启用则正常完成登录。这里我采用自定义AuthenticationSuccessHandler的方式这是一种非常清晰且侵入性低的方案。第一步创建TOTP验证码的验证服务Service Slf4j public class TotpService { Autowired private UserRepository userRepository; // 你的用户数据访问层 Autowired private EncryptionService encryptionService; // 一个假设的加密服务用于加解密密钥 /** * 为用户生成一个新的TOTP密钥并返回绑定所需信息密钥明文、二维码URI等 */ public TotpBindingInfo generateBindingInfo(Long userId, String issuer) { User user userRepository.findById(userId).orElseThrow(() - new RuntimeException(User not found)); if (Boolean.TRUE.equals(user.getTotpEnabled())) { throw new RuntimeException(User already has TOTP enabled); } String rawSecret TOTPUtil.generateSecretKey(); String encryptedSecret encryptionService.encrypt(rawSecret); // 加密存储 user.setTotpSecret(encryptedSecret); // 注意此时 totpEnabled 还是 false因为用户还没完成绑定验证 userRepository.save(user); String otpAuthUri TOTPUtil.buildOtpAuthUri(user.getEmail(), rawSecret, issuer); return new TotpBindingInfo(rawSecret, otpAuthUri); // 返回明文密钥和URI用于生成二维码 } /** * 验证用户输入的绑定验证码 */ public boolean verifyBindingCode(Long userId, String inputCode) { User user userRepository.findById(userId).orElseThrow(() - new RuntimeException(User not found)); String encryptedSecret user.getTotpSecret(); if (encryptedSecret null) { return false; } String rawSecret encryptionService.decrypt(encryptedSecret); // 解密 boolean isValid TOTPUtil.verifyCode(rawSecret, inputCode); if (isValid) { // 验证通过正式启用 user.setTotpEnabled(true); userRepository.save(user); log.info(TOTP enabled for user: {}, user.getUsername()); } return isValid; } /** * 在登录过程中验证TOTP码 */ public boolean verifyLoginCode(String username, String inputCode) { User user userRepository.findByUsername(username).orElse(null); if (user null || !Boolean.TRUE.equals(user.getTotpEnabled())) { // 用户不存在或未启用TOTP则无需验证或根据策略处理 return true; } String encryptedSecret user.getTotpSecret(); if (encryptedSecret null) { return false; } String rawSecret encryptionService.decrypt(encryptedSecret); return TOTPUtil.verifyCode(rawSecret, inputCode); } // 简单的DTO用于返回绑定信息 Data AllArgsConstructor public static class TotpBindingInfo { private String secret; private String otpAuthUri; } }第二步创建自定义的登录成功处理器这个处理器负责在密码验证成功后判断下一步该去哪。Component public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler { Autowired private TotpService totpService; Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { String username authentication.getName(); // 假设你的UserDetails实现能获取到用户ID或实体 UserDetailsImpl userDetails (UserDetailsImpl) authentication.getPrincipal(); Long userId userDetails.getUserId(); // 检查该用户是否已启用TOTP boolean totpRequired totpService.isTotpRequiredForUser(username); // 这个方法需要你在TotpService中实现检查totpEnabled状态 if (totpRequired) { // 需要TOTP验证将用户标记为“第一步认证通过”并重定向到TOTP验证页面 // 可以将userId存入session或生成一个临时token request.getSession().setAttribute(TOTP_AUTH_USER_ID, userId); response.sendRedirect(/auth/totp-verify); } else { // 不需要TOTP直接跳转到登录成功页面如首页 response.sendRedirect(/home); } } }第三步创建TOTP验证码的校验端点我们需要一个Controller来处理TOTP验证页面的请求。Controller RequestMapping(/auth) public class TotpAuthController { Autowired private TotpService totpService; /** * 跳转到TOTP验证页面 */ GetMapping(/totp-verify) public String totpVerifyPage(HttpServletRequest request, Model model) { Long userId (Long) request.getSession().getAttribute(TOTP_AUTH_USER_ID); if (userId null) { // 会话丢失或非法访问重定向到登录页 return redirect:/login; } model.addAttribute(userId, userId); return totp-verify; // 对应 src/main/resources/templates/totp-verify.html } /** * 提交TOTP验证码进行验证 */ PostMapping(/totp-verify) public String verifyTotpCode(RequestParam String code, HttpServletRequest request, RedirectAttributes redirectAttributes) { Long userId (Long) request.getSession().getAttribute(TOTP_AUTH_USER_ID); if (userId null) { redirectAttributes.addFlashAttribute(error, 会话已过期请重新登录); return redirect:/login; } boolean isValid totpService.verifyLoginCodeForUserId(userId, code); // 需要实现这个方法根据userId验证 if (isValid) { // 验证通过清除临时session属性完成最终登录例如手动设置Authentication request.getSession().removeAttribute(TOTP_AUTH_USER_ID); // 这里通常需要手动调用某种方式完成最终的认证例如使用AuthenticationManager重新认证 // 简单起见可以设置一个标记由后续的Filter或Interceptor处理 request.getSession().setAttribute(TOTP_VERIFIED, true); return redirect:/home; } else { redirectAttributes.addFlashAttribute(error, 验证码错误请重试); return redirect:/auth/totp-verify; } } /** * 用户绑定TOTP的页面 */ GetMapping(/totp-bind) public String bindPage(AuthenticationPrincipal UserDetailsImpl userDetails, Model model) throws Exception { Long userId userDetails.getUserId(); TotpService.TotpBindingInfo info totpService.generateBindingInfo(userId, 我的SpringBoot应用); String qrCodeBase64 TOTPUtil.generateQRCodeBase64(info.getOtpAuthUri(), 250, 250); model.addAttribute(qrCode, qrCodeBase64); model.addAttribute(secret, info.getSecret()); // 通常不建议在前端显示明文密钥这里仅为演示 return totp-bind; } /** * 提交绑定验证码 */ PostMapping(/totp-bind) ResponseBody public ApiResponse bindTotp(RequestParam String code, AuthenticationPrincipal UserDetailsImpl userDetails) { Long userId userDetails.getUserId(); boolean success totpService.verifyBindingCode(userId, code); if (success) { return ApiResponse.ok(绑定成功); } else { return ApiResponse.fail(验证码错误绑定失败); } } }第四步配置Spring Security最后在Security配置类中将我们自定义的SuccessHandler注入到表单登录配置中。Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Autowired private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler; Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers(/css/**, /js/**, /auth/totp-verify, /auth/totp-bind).permitAll() .anyRequest().authenticated() .and() .formLogin() .loginPage(/login) .loginProcessingUrl(/perform_login) .successHandler(customAuthenticationSuccessHandler) // 使用自定义的成功处理器 .permitAll() .and() .logout() .permitAll(); // 注意需要配置一个Filter或Interceptor来检查 /home 等路径的请求是否完成了TOTP验证如果required。 // 这里为了简化假设所有需要认证的请求在TOTP验证完成后才可访问。 } }至此一个完整的、与Spring Security集成的TOTP两步验证流程就搭建起来了。用户登录时密码正确后会被引导至TOTP验证页面输入手机App上的6位码验证通过后方能进入系统。5. 实战优化与常见问题排查项目上线后我们遇到了几个典型问题这里分享下解决方案。1. 时间不同步导致验证失败这是最常见的问题。服务器时间和用户手机时间如果偏差超过30秒一个时间窗口验证就会失败。解决方案在服务端验证时不仅检查当前时间窗口的码也检查前后一个窗口的码即总共检查3个码。de.taimos.totp.TOTP库本身不支持容错我们可以手动计算public static boolean verifyCodeWithTolerance(String secretKey, String userInputCode, int timeStep, long toleranceWindow) { long currentTimeMillis System.currentTimeMillis() / 1000; // 当前Unix时间秒 long timeStepMillis timeStep; // 通常30秒 for (long offset -toleranceWindow; offset toleranceWindow; offset) { long adjustedTime currentTimeMillis (offset * timeStepMillis); // 需要根据adjustedTime计算TOTP这里需要自己实现或找支持时间戳输入的库 // String calculatedCode calculateTOTP(secretKey, adjustedTime, timeStep); // if (calculatedCode.equals(userInputCode)) return true; } return false; }更简单的方法是使用像javaotp这样的库它内置了容错验证。或者在服务器端使用NTP服务确保系统时间准确并引导用户检查手机时间设置。2. 密钥的安全存储明文存储密钥是安全大忌。我们采用了AES对称加密密钥来自应用配置环境变量。数据库里存的是密文。加解密服务示例Service public class EncryptionService { private final SecretKeySpec keySpec; private final Cipher cipher; public EncryptionService(Value(${totp.encryption.key}) String encryptionKey) throws Exception { // 确保密钥长度是16, 24 或 32字节 (对应 AES-128, AES-192, AES-256) byte[] key encryptionKey.getBytes(StandardCharsets.UTF_8); keySpec new SecretKeySpec(key, AES); cipher Cipher.getInstance(AES/ECB/PKCS5Padding); // 生产环境建议使用GCM模式 } public String encrypt(String data) throws Exception { cipher.init(Cipher.ENCRYPT_MODE, keySpec); byte[] encrypted cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } public String decrypt(String encryptedData) throws Exception { cipher.init(Cipher.DECRYPT_MODE, keySpec); byte[] decoded Base64.getDecoder().decode(encryptedData); byte[] decrypted cipher.doFinal(decoded); return new String(decrypted, StandardCharsets.UTF_8); } }在application.yml中配置totp: encryption: key: ${TOTP_ENCRYPTION_KEY:your-32-byte-long-secure-key-here!} # 从环境变量读取3. 用户丢失设备或App需要提供“备用代码”或“恢复代码”功能。在用户绑定TOTP时生成一组如10个一次性使用的备用码长的随机字符串让用户安全地保存下来。当无法使用App时可以用备用码登录。同时后台应提供管理员重置TOTP的接口需强身份验证。4. 前端交互体验TOTP验证码输入页面可以加入自动聚焦、输入框自动跳转6个独立输入框或粘贴拦截处理一次性填充6位码来提升体验。使用JavaScript实时验证格式6位数字也是个好习惯。整个集成过程最耗时的部分其实不是编码而是理清Spring Security的认证流程和设计状态管理如何记住用户已完成密码验证但未完成TOTP验证。一旦这个流程打通剩下的就是按部就班的工具调用和细节打磨。这套方案上线后我们的系统登录安全性提升了一个等级而用户反馈的接受度也很高毕竟大部分人都已经习惯了在各种App里使用类似的验证码。