SpringBoot中使用TOTP实现MFA(多因素认证)

[复制链接]
发表于 2025-7-29 09:58:14 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
一、MFA简介

定义:多因素认证(MFA)要求用户在登录时提供​​至少两种差别类别​​的身份验证因子,以提升账户安全性
核心目的:办理单一密码认证的脆弱性(如暴力破解、垂纶攻击),将账户被盗风险低沉​​80%以上;通过组合差别的验证因素,MFA 能够显著低沉因密码泄露带来的风险
二、核心原理

MFA通过多步骤验证构建安全屏障:

  • ​​初始验证​​:用户输入用户名和密码(知识因子)
  • ​​二次验证​​:体系要求额外因子(如手机接收OTP码、指纹扫描)
  • ​​动态授权​​:高风险操纵(如转账)可触发更多验证(如硬件令牌+生物辨认)
  • ​​访问控制​​:全部因子验证通过后,授予最小必要权限
​​安全增强逻辑​​:

  • 攻击者纵然破解密码(知识因子),仍需突破全部权或生物因子,难度呈指数级增长
  • 比方:垂纶攻击中盗取密码后,因无法获取动态令牌或生物特性而失败
三、主流技术方案与对比

认证方式安全性用户体验实施成本场景
TOTP动态码​​高优低通用:企业体系、云服务等(推荐首选)
​​短信验证码​中中中金融支付、社交平台(需运营商集成)
生物辨认​​(如人脸、指纹等)极高优高移动装备、高安全体系
​​硬件令牌​​(如YubiKey)极高中高金融、政府、军事体系
四、TOTP简介


  • 基于时间的一次性密码,动态验证码每30秒更新,基于共享密钥(Secret Key)和当前时间戳通过HMAC-SHA1算法生成6位数字。
  • 上风​​:离线可用、无需短信成本、兼容Google Authenticator等标准应用
五、SpringBoot集成TOTP

a.登录流程图(这里原体系使用 SA-Token,其他逻辑应该也大差不差)


b.代码实现

原体系用户表添加以下字段
  1. ALTER TABLE iot_user
  2. ADD COLUMN mfa_secret VARCHAR(64) NULL COMMENT 'TOTP密钥(AES加密存储)',
  3. ADD COLUMN backup_codes TEXT NULL COMMENT '备用验证码(JSON数组,AES加密存储)',
  4. ADD COLUMN mfa_enabled TINYINT(1) DEFAULT 0 COMMENT '是否启用MFA(0-否,1-是)';
复制代码
1.添加Maven依靠
  1.     <dependency>
  2.             <groupId>com.warrenstrange</groupId>
  3.             <artifactId>googleauth</artifactId>
  4.             <version>1.5.0</version>
  5.         </dependency>
  6.         <dependency>
  7.             <groupId>commons-net</groupId>
  8.             <artifactId>commons-net</artifactId>
  9.             <version>3.9.0</version>
  10.         </dependency>
复制代码
2.Mfz服务类
  1. @Log4j2
  2. @Service
  3. public class MfaService {
  4.     @Lazy
  5.     @Resource
  6.     private IotUserService iotUserService;
  7.     @Resource
  8.     private RedisUtil redisUtil;
  9.     private final GoogleAuthenticator gAuth = new GoogleAuthenticator();
  10.     /**
  11.      * 为用户启用MFA,生成密钥和备用码
  12.      */
  13.     public MfaSetupResult setupMfa(String userId) {
  14.         GoogleAuthenticatorKey key = gAuth.createCredentials();
  15.         String secret = key.getKey();
  16.         List<String> backupCodes = generateBackupCodes();
  17.         // 加密存储(生产环境需替换为KMS加密)
  18.         String encryptedSecret = encrypt(secret);
  19.         String encryptedBackupCodes = encrypt(String.join(",", backupCodes));
  20.         IotUser user = iotUserService.getById(userId);
  21.         if (user == null) {
  22.             throw new RuntimeException("用户不存在");
  23.         }
  24.         // 更新数据库
  25.         user.setMfaSecret(encryptedSecret);
  26.         user.setBackupCodes(encryptedBackupCodes);
  27.         user.setMfaEnabled(1);
  28.         iotUserService.updateById(user);
  29.         String qr = "otpauth://totp/" + userId + "?secret=" + secret + "&issuer=IOT_Platform"
  30.                 + "&image=https://iot-dev.xxxxxx.cn/static/img/logo.34793a79.png";
  31.         return new MfaSetupResult(qr, backupCodes);
  32.     }
  33.     /**
  34.      * 生成10个备用验证码(一次性使用)
  35.      */
  36.     private List<String> generateBackupCodes() {
  37.         return new Random().ints(10, 100000, 999999)
  38.                 .mapToObj(code -> String.format("%06d", code))
  39.                 .collect(Collectors.toList());
  40.     }
  41.     /**
  42.      * 验证TOTP或备用码
  43.      */
  44.     public boolean verifyCode(String userId, String code) {
  45.         IotUser user = iotUserService.getById(userId);
  46.         if (user == null) {
  47.             throw new RuntimeException("用户不存在");
  48.         }
  49.         // 1. 获取加密的密钥和备用码
  50.         String encryptedSecret = user.getMfaSecret();
  51.         String encryptedBackupCodes = user.getBackupCodes();
  52.         String secret = decrypt(encryptedSecret);
  53.         log.info(secret + "校验" + encryptedSecret);
  54.         List<String> backupCodes = new ArrayList<>(
  55.                 Arrays.asList(decrypt(encryptedBackupCodes).split(","))
  56.         );
  57.         // 2. 验证TOTP(允许时间偏差)
  58.         if (gAuth.authorize(secret, Integer.parseInt(code))) {
  59.             return true;
  60.         }
  61.         // 3. 验证备用码
  62.         if (backupCodes.contains(code)) {
  63.             backupCodes.remove(code);
  64.             // 更新数据库
  65.             user.setBackupCodes(encrypt(String.join(",", backupCodes)));
  66.             iotUserService.updateById(user);
  67.             return true;
  68.         }
  69.         return false;
  70.     }
  71.     /**
  72.      * 开启7天免MFA认证
  73.      */
  74.     public void setMfaSkip(String userId, String userAgent, String ip) {
  75.         String deviceHash = DigestUtils.sha256Hex(userAgent + ip).substring(0, 8);
  76.         String key = "mfa_skip:" + userId + ":" + deviceHash;
  77.         long expireAt = System.currentTimeMillis() + 7 * 86_400_000L;
  78.         String value = expireAt + "|" + userAgent;
  79.         redisUtil.setEx(key, value, 7, TimeUnit.DAYS);
  80.     }
  81.     /**
  82.      * 验证是否已开启免MFA认证
  83.      */
  84.     public boolean isMfaSkipped(String userId, String userAgent, String ip) {
  85.         String deviceHash = DigestUtils.sha256Hex(userAgent + ip).substring(0, 8);
  86.         String key = "mfa_skip:" + userId + ":" + deviceHash;
  87.         String value = redisUtil.get(key);
  88.         if (value == null) {
  89.             return false;
  90.         }
  91.         // 验证设备信息一致性(防盗用)
  92.         String[] parts = value.split("\\|");
  93.         long expireAt = Long.parseLong(parts[0]);
  94.         String storedUserAgent = parts[1];
  95.         return expireAt > System.currentTimeMillis()
  96.                 && storedUserAgent.equals(userAgent);
  97.     }
  98.     // --- AES加密工具方法 ---
  99.     private String encrypt(String data) {
  100.         // 实际实现需使用AES-GCM(此处简化)
  101.         return Base64.getEncoder().encodeToString(data.getBytes());
  102.     }
  103.     private String decrypt(String encrypted) {
  104.         return new String(Base64.getDecoder().decode(encrypted));
  105.     }
  106. }
复制代码
3.  IP获取工具IpUtils
  1. public class IpUtils {
  2.     public static String getClientIp(HttpServletRequest request) {
  3.         // 1. 优先级解析代理头部
  4.         String[] headers = {"X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP", "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR"};
  5.         for (String header : headers) {
  6.             String ip = request.getHeader(header);
  7.             if (isValidIp(ip)) {
  8.                 return parseFirstIp(ip);
  9.             }
  10.         }
  11.         // 2. 直接获取远程地址
  12.         String ip = request.getRemoteAddr();
  13.         // 3. 处理本地环回地址(开发环境)
  14.         if ("127.0.0.1".equals(ip) || "0:0:0:0:0:0:0:1".equals(ip)) {
  15.             try {
  16.                 return InetAddress.getLocalHost().getHostAddress();
  17.             } catch (Exception e) {
  18.                 return "127.0.0.1";
  19.             }
  20.         }
  21.         return ip;
  22.     }
  23.     private static boolean isValidIp(String ip) {
  24.         return ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip);
  25.     }
  26.     private static String parseFirstIp(String ip) {
  27.         // 处理多IP场景(如:X-Forwarded-For: client, proxy1, proxy2)
  28.         return ip.contains(",") ? ip.split(",")[0].trim() : ip;
  29.     }
  30. }
复制代码
4.登录、Mfa开启、Mfa校验、Mfa二维码以及10个备用一次性code生成(服务类省略)
  1.     @Override
  2.     public LoginResult login(LoginParam loginParam, HttpServletRequest request) {
  3.         IotUser iotUser = this.getOne(new LambdaQueryWrapper<IotUser>().eq(IotUser::getAccount, loginParam.getAccount())
  4.                 .eq(IotUser::getStatus, 0));
  5.         // 校验用户是否存在
  6.         if (ObjectUtil.isNull(iotUser)) {
  7.             throw new ServiceException(IotUserExceptionEnum.LOGIN_ERROR);
  8.         }
  9.         // 验证账号密码是否正常
  10.         String requestMd5 = SaltUtil.md5Encrypt(loginParam.getPassword(), iotUser.getSalt());
  11.         String dbMd5 = iotUser.getPassword();
  12.         if (dbMd5 == null || !dbMd5.equalsIgnoreCase(requestMd5)) {
  13.             throw new ServiceException(IotUserExceptionEnum.LOGIN_ERROR);
  14.         }
  15.         // 账号被冻结
  16.         if (iotUser.getStatus().equals(1)) {
  17.             throw new ServiceException(IotUserExceptionEnum.ACCOUNT_FREEZE_ERROR);
  18.         }
  19.         // 密码校验成功后登录,一行代码实现登录
  20.         StpUtil.login(iotUser.getUserId());
  21.         StpUtil.getSession().set(Constants.USER_INFO_KEY, userDto(iotUser));
  22.         /** 获取当前登录用户的Token信息 */
  23.         SaTokenInfo saTokenInfo = StpUtil.getTokenInfo();
  24.         LoginResult loginResult = new LoginResult();
  25.         loginResult.setToken(saTokenInfo.getTokenValue());
  26.         loginResult.setMfaEnabled(iotUser.getMfaEnabled());
  27.         // 开启了MFA认证
  28.         if (iotUser.getMfaEnabled() == 1) {
  29.             String ua = request.getHeader("User-Agent");
  30.             String ip = IpUtils.getClientIp(request);
  31.             log.info("登录请求IP:" +  ip);
  32.             if (mfaService.isMfaSkipped(iotUser.getUserId(), ua, ip)) {
  33.                 // 触发免验证:激活安全会话
  34.                 StpUtil.openSafe( 7 * 24 * 60 * 60);
  35.             } else {
  36.                 loginResult.setNeedMfa(true);
  37.             }
  38.         }
  39.         return loginResult;
  40.     }
  41.     @Override
  42.     public VerifyResult verify(MfaVerifyParam verifyParam, HttpServletRequest request) {
  43.         if (ObjectUtil.isNull(verifyParam.getCode())) {
  44.             throw new ServiceException("验证码不能为空");
  45.         }
  46.         if (ObjectUtil.isNull(verifyParam.getRemember())) {
  47.             verifyParam.setRemember(false);
  48.         }
  49.         String userId = StpUtil.getLoginIdAsString();
  50.         // 1. 验证TOTP/备用码
  51.         if (!mfaService.verifyCode(userId, verifyParam.getCode())) {
  52.             throw new ServiceException("验证码无效");
  53.         }
  54.         // 2. 若选择免认证7天,更新数据库
  55.         if (Boolean.TRUE.equals(verifyParam.getRemember())) {
  56.             String userAgent = request.getHeader("User-Agent");
  57.             String ip = IpUtils.getClientIp(request);
  58.             log.info("MFA验证请求IP:" +  ip);
  59.             mfaService.setMfaSkip(userId, userAgent, ip);
  60.         }
  61.         // 3. 激活SA-Token安全会话(7天或一次性)
  62.         StpUtil.openSafe(verifyParam.getRemember() ? 7 * 24 * 60 * 60 : 120);
  63.         VerifyResult verifyResult = new VerifyResult();
  64.         verifyResult.setToken(StpUtil.getTokenValue());
  65.         verifyResult.setMsg("验证成功");
  66.         return verifyResult;
  67.     }
  68.     @Override
  69.     public MfaSetupResult qrCode() {
  70.         String userId = StpUtil.getLoginIdAsString();
  71.         return mfaService.setupMfa(userId);
  72.     }
  73.     @Override
  74.     public void openMfa() {
  75.         String userId = StpUtil.getLoginIdAsString();
  76.         IotUser iotUser = this.getById(userId);
  77.         if (ObjectUtil.isNull(iotUser)) {
  78.             throw new ServiceException("用户不存在");
  79.         }
  80.         iotUser.setMfaEnabled(1);
  81.         this.updateById(iotUser);
  82.     }
复制代码
5.Mfa校验入参类
  1. @Data
  2. public class MfaVerifyParam {
  3.     /**
  4.      * Mfa动态、一次性备用代码
  5.      */
  6.     private String code;
  7.     /**
  8.      * 当前机器近7天是否跳过Mfa校验
  9.      */
  10.     private Boolean remember;
  11. }
复制代码
6.控制类
  1. @RestController
  2. public class IotPlatFormAuthController {
  3.     @Resource
  4.     private IotUserService iotUserService;
  5.     /**
  6.      * @description:  登录
  7.      * @param: [loginParam]
  8.      * @return: com.honyar.core.model.response.ResponseData
  9.      * @author: zhouhong
  10.      */
  11.     @PostMapping("/auth/login")
  12.     public ResponseData login(@RequestBody LoginParam loginParam, HttpServletRequest request) {
  13.         return new SuccessResponseData(iotUserService.login(loginParam, request));
  14.     }
  15.     /**
  16.      * @description:  开启MFA
  17.      * @param: []
  18.      * @return: com.honyar.core.model.response.ResponseData
  19.      * @author: zhouhong
  20.      */
  21.     @PostMapping("/auth/mfa/openMfa")
  22.     public ResponseData openMfa() {
  23.         iotUserService.openMfa();
  24.         return new SuccessResponseData();
  25.     }
  26.     /**
  27.      * @param: []
  28.      * @return: com.honyar.core.model.response.ResponseData
  29.      * @author: zhouhong
  30.      */
  31.     @PostMapping("/auth/mfa/qrcode")
  32.     public ResponseData qrCode() {
  33.         return new SuccessResponseData(iotUserService.qrCode());
  34.     }
  35.     /**
  36.      * @description:  MFA验证
  37.      * @param: [verifyParam]
  38.      * @return: com.honyar.core.model.response.ResponseData
  39.      * @author: zhouhong
  40.      */
  41.     @PostMapping("/auth/mfa/verify")
  42.     public ResponseData verify(@RequestBody MfaVerifyParam verifyParam, HttpServletRequest request) {
  43.         return new SuccessResponseData(iotUserService.verify(verifyParam, request));
  44.     }
  45.     /**
  46.      * @description:  登出
  47.      * @param: []
  48.      * @return: com.honyar.core.model.response.ResponseData
  49.      * @author: zhouhong
  50.      */
  51.     @PostMapping("/auth/logout")
  52.     public ResponseData logout() {
  53.         iotUserService.logout();
  54.         return new SuccessResponseData();
  55.     }
  56. }
复制代码
c.演示

1.调用登录接口


说明:登录返回当前用户是否已经开启Mfa,当用户已经开启mfa(mfaEnable=1)而且needMfa(需要举行mfa)时需要前端拉起mfa校验页面调用mfa校验接口举行二次校验;当mfaEnable=1而且needMfa=false时,说明当前装备已经开启7天面mfa校验,直接登录乐成进入体系;当mfaEnable=0时,说明用户为开启mfa,则引导用户调用接口先开启mfa(数据库用户mfaEnable字段置为1即可),然后再调用mfa校验接口举行mfa校验,假如用户选择不开启则直接登录乐成进入体系。
2.调用mfa二维码、备用一次性code生成接口


说明:调用这个接口后前端根据 qrUrl信息生成一个二维码,而且同时欣赏器下载备用code 到本地,用户使用Authenticator APP举行扫码添加用户,然后再使用 Authenticator 里面生成的code调用校验Mfa接口校验乐成后进入体系;第二次用户直接从Authenticator获取code举行二次认证即可

3.调用Mfa校验接口


说明:校验乐成后进入体系
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表