当前位置: 首页 > news >正文

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

一、MFA简介

定义:多因素认证(MFA)要求用户在登录时提供​​至少两种不同类别​​的身份验证因子,以提升账户安全性

核心目标:解决单一密码认证的脆弱性(如暴力破解、钓鱼攻击),将账户被盗风险降低​​80%以上;通过组合不同的验证因素,MFA 能够显著降低因密码泄露带来的风险

二、核心原理

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

  1. ​​初始验证​​:用户输入用户名和密码(知识因子)
  2. ​​二次验证​​:系统要求额外因子(如手机接收OTP码、指纹扫描)
  3. ​​动态授权​​:高风险操作(如转账)可触发更多验证(如硬件令牌+生物识别)
  4. ​​访问控制​​:所有因子验证通过后,授予最小必要权限

​​安全增强逻辑​​:

  • 攻击者即使破解密码(知识因子),仍需突破所有权或生物因子,难度呈指数级增长
  • 例如:钓鱼攻击中窃取密码后,因无法获取动态令牌或生物特征而失败

三、主流技术方案与对比

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

四、TOTP简介

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

五、SpringBoot集成TOTP

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

Untitled diagram _ Mermaid Chart-2025-07-28-121800

b.代码实现

原系统用户表添加以下字段

ALTER TABLE iot_user
ADD COLUMN mfa_secret VARCHAR(64) NULL COMMENT 'TOTP密钥(AES加密存储)',
ADD COLUMN backup_codes TEXT NULL COMMENT '备用验证码(JSON数组,AES加密存储)',
ADD COLUMN mfa_enabled TINYINT(1) DEFAULT 0 COMMENT '是否启用MFA(0-否,1-是)';
1.添加Maven依赖
    <dependency><groupId>com.warrenstrange</groupId><artifactId>googleauth</artifactId><version>1.5.0</version></dependency><dependency><groupId>commons-net</groupId><artifactId>commons-net</artifactId><version>3.9.0</version></dependency>
2.Mfz服务类
@Log4j2
@Service
public class MfaService {@Lazy@Resourceprivate IotUserService iotUserService;@Resourceprivate RedisUtil redisUtil;private final GoogleAuthenticator gAuth = new GoogleAuthenticator();/*** 为用户启用MFA,生成密钥和备用码*/public MfaSetupResult setupMfa(String userId) {GoogleAuthenticatorKey key = gAuth.createCredentials();String secret = key.getKey();List<String> backupCodes = generateBackupCodes();// 加密存储(生产环境需替换为KMS加密)String encryptedSecret = encrypt(secret);log.info(secret + "====二维码生成===" + encryptedSecret);String encryptedBackupCodes = encrypt(String.join(",", backupCodes));IotUser user = iotUserService.getById(userId);if (user == null) {throw new RuntimeException("用户不存在");}// 更新数据库
        user.setMfaSecret(encryptedSecret);user.setBackupCodes(encryptedBackupCodes);user.setMfaEnabled(1);iotUserService.updateById(user);String qr = "otpauth://totp/" + userId + "?secret=" + secret + "&issuer=IOT_Platform"+ "&image=https://iot-dev.xxxxxx.cn/static/img/logo.34793a79.png";return new MfaSetupResult(qr, backupCodes);}/*** 生成10个备用验证码(一次性使用)*/private List<String> generateBackupCodes() {return new Random().ints(10, 100000, 999999).mapToObj(code -> String.format("%06d", code)).collect(Collectors.toList());}/*** 验证TOTP或备用码*/public boolean verifyCode(String userId, String code) {IotUser user = iotUserService.getById(userId);if (user == null) {throw new RuntimeException("用户不存在");}// 1. 获取加密的密钥和备用码String encryptedSecret = user.getMfaSecret();String encryptedBackupCodes = user.getBackupCodes();String secret = decrypt(encryptedSecret);log.info(secret + "校验" + encryptedSecret);List<String> backupCodes = new ArrayList<>(Arrays.asList(decrypt(encryptedBackupCodes).split(",")));// 2. 验证TOTP(允许时间偏差)if (gAuth.authorize(secret, Integer.parseInt(code))) {return true;}// 3. 验证备用码if (backupCodes.contains(code)) {backupCodes.remove(code);// 更新数据库user.setBackupCodes(encrypt(String.join(",", backupCodes)));iotUserService.updateById(user);return true;}return false;}/*** 开启7天免MFA认证*/public void setMfaSkip(String userId, String userAgent, String ip) {String deviceHash = DigestUtils.sha256Hex(userAgent + ip).substring(0, 8);String key = "mfa_skip:" + userId + ":" + deviceHash;long expireAt = System.currentTimeMillis() + 7 * 86_400_000L;String value = expireAt + "|" + userAgent;redisUtil.setEx(key, value, 7, TimeUnit.DAYS);}/*** 验证是否已开启免MFA认证*/public boolean isMfaSkipped(String userId, String userAgent, String ip) {String deviceHash = DigestUtils.sha256Hex(userAgent + ip).substring(0, 8);String key = "mfa_skip:" + userId + ":" + deviceHash;String value = redisUtil.get(key);if (value == null) {return false;}// 验证设备信息一致性(防盗用)String[] parts = value.split("\\|");long expireAt = Long.parseLong(parts[0]);String storedUserAgent = parts[1];return expireAt > System.currentTimeMillis()&& storedUserAgent.equals(userAgent);}// --- AES加密工具方法 ---private String encrypt(String data) {// 实际实现需使用AES-GCM(此处简化)return Base64.getEncoder().encodeToString(data.getBytes());}private String decrypt(String encrypted) {return new String(Base64.getDecoder().decode(encrypted));}
}
3.  IP获取工具IpUtils
public class IpUtils {public static String getClientIp(HttpServletRequest request) {// 1. 优先级解析代理头部String[] headers = {"X-Forwarded-For", "Proxy-Client-IP", "WL-Proxy-Client-IP", "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR"};for (String header : headers) {String ip = request.getHeader(header);if (isValidIp(ip)) {return parseFirstIp(ip);}}// 2. 直接获取远程地址String ip = request.getRemoteAddr();// 3. 处理本地环回地址(开发环境)if ("127.0.0.1".equals(ip) || "0:0:0:0:0:0:0:1".equals(ip)) {try {return InetAddress.getLocalHost().getHostAddress();} catch (Exception e) {return "127.0.0.1";}}return ip;}private static boolean isValidIp(String ip) {return ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip);}private static String parseFirstIp(String ip) {// 处理多IP场景(如:X-Forwarded-For: client, proxy1, proxy2)return ip.contains(",") ? ip.split(",")[0].trim() : ip;}
}
4.登录、Mfa开启、Mfa校验、Mfa二维码以及10个备用一次性code生成(服务类省略)
    @Overridepublic LoginResult login(LoginParam loginParam, HttpServletRequest request) {IotUser iotUser = this.getOne(new LambdaQueryWrapper<IotUser>().eq(IotUser::getAccount, loginParam.getAccount()).eq(IotUser::getStatus, 0));// 校验用户是否存在if (ObjectUtil.isNull(iotUser)) {throw new ServiceException(IotUserExceptionEnum.LOGIN_ERROR);}// 验证账号密码是否正常String requestMd5 = SaltUtil.md5Encrypt(loginParam.getPassword(), iotUser.getSalt());String dbMd5 = iotUser.getPassword();if (dbMd5 == null || !dbMd5.equalsIgnoreCase(requestMd5)) {throw new ServiceException(IotUserExceptionEnum.LOGIN_ERROR);}// 账号被冻结if (iotUser.getStatus().equals(1)) {throw new ServiceException(IotUserExceptionEnum.ACCOUNT_FREEZE_ERROR);}// 密码校验成功后登录,一行代码实现登录
        StpUtil.login(iotUser.getUserId());StpUtil.getSession().set(Constants.USER_INFO_KEY, userDto(iotUser));/** 获取当前登录用户的Token信息 */SaTokenInfo saTokenInfo = StpUtil.getTokenInfo();LoginResult loginResult = new LoginResult();loginResult.setToken(saTokenInfo.getTokenValue());loginResult.setMfaEnabled(iotUser.getMfaEnabled());// 开启了MFA认证if (iotUser.getMfaEnabled() == 1) {String ua = request.getHeader("User-Agent");String ip = IpUtils.getClientIp(request);log.info("登录请求IP:" +  ip);if (mfaService.isMfaSkipped(iotUser.getUserId(), ua, ip)) {// 触发免验证:激活安全会话StpUtil.openSafe( 7 * 24 * 60 * 60);} else {loginResult.setNeedMfa(true);}}return loginResult;}@Overridepublic VerifyResult verify(MfaVerifyParam verifyParam, HttpServletRequest request) {if (ObjectUtil.isNull(verifyParam.getCode())) {throw new ServiceException("验证码不能为空");}if (ObjectUtil.isNull(verifyParam.getRemember())) {verifyParam.setRemember(false);}String userId = StpUtil.getLoginIdAsString();// 1. 验证TOTP/备用码if (!mfaService.verifyCode(userId, verifyParam.getCode())) {throw new ServiceException("验证码无效");}// 2. 若选择免认证7天,更新数据库if (Boolean.TRUE.equals(verifyParam.getRemember())) {String userAgent = request.getHeader("User-Agent");String ip = IpUtils.getClientIp(request);log.info("MFA验证请求IP:" +  ip);mfaService.setMfaSkip(userId, userAgent, ip);}// 3. 激活SA-Token安全会话(7天或一次性)StpUtil.openSafe(verifyParam.getRemember() ? 7 * 24 * 60 * 60 : 120);VerifyResult verifyResult = new VerifyResult();verifyResult.setToken(StpUtil.getTokenValue());verifyResult.setMsg("验证成功");return verifyResult;}@Overridepublic MfaSetupResult qrCode() {String userId = StpUtil.getLoginIdAsString();return mfaService.setupMfa(userId);}@Overridepublic void openMfa() {String userId = StpUtil.getLoginIdAsString();IotUser iotUser = this.getById(userId);if (ObjectUtil.isNull(iotUser)) {throw new ServiceException("用户不存在");}iotUser.setMfaEnabled(1);this.updateById(iotUser);}
  @Override
  public void recoverMfa(RecoverMfaParam recoverMfaParam) {
  if (ObjectUtil.isNull(recoverMfaParam.getCode())) {
  throw new ServiceException("恢复码code不能为空");
  }
  String userId = StpUtil.getLoginIdAsString();
  IotUser iotUser = this.getById(userId);
  if (ObjectUtil.isNull(iotUser)) {
   throw new ServiceException("用户不存在");
  }
  String encryptedBackupCodes = iotUser.getBackupCodes();
  List<String> backupCodes = new ArrayList<>(
Arrays.asList(decrypt(encryptedBackupCodes).split(","))
  );
  if (backupCodes.contains(recoverMfaParam.getCode())) {
   backupCodes.remove(recoverMfaParam.getCode());
   // 更新数据库
   iotUser.setBackupCodes(encrypt(String.join(",", backupCodes)));
   // 重置MFA,再次登录时需要重新设置并扫码绑定
   iotUser.setMfaEnabled(0);
   this.updateById(iotUser);
  }
  }
5.Mfa校验入参类
@Data
public class MfaVerifyParam {/*** Mfa动态、一次性备用代码*/private String code;/*** 当前机器近7天是否跳过Mfa校验*/private Boolean remember;
}
6.控制类
@RestController
public class IotPlatFormAuthController {@Resourceprivate IotUserService iotUserService;/*** @description:  登录* @param: [loginParam]* @return: com.honyar.core.model.response.ResponseData* @author: zhouhong*/@PostMapping("/auth/login")public ResponseData login(@RequestBody LoginParam loginParam, HttpServletRequest request) {return new SuccessResponseData(iotUserService.login(loginParam, request));}/*** @description:  开启MFA* @param: []* @return: com.honyar.core.model.response.ResponseData* @author: zhouhong*/@PostMapping("/auth/mfa/openMfa")public ResponseData openMfa() {iotUserService.openMfa();return new SuccessResponseData();}/*** @description:  获取MFA二维码* @param: []* @return: com.honyar.core.model.response.ResponseData* @author: zhouhong*/@PostMapping("/auth/mfa/qrcode")public ResponseData qrCode() {return new SuccessResponseData(iotUserService.qrCode());}/*** @description:  MFA验证* @param: [verifyParam]* @return: com.honyar.core.model.response.ResponseData* @author: zhouhong*/@PostMapping("/auth/mfa/verify")public ResponseData verify(@RequestBody MfaVerifyParam verifyParam, HttpServletRequest request) {return new SuccessResponseData(iotUserService.verify(verifyParam, request));}
  /**
   * @description: 恢复MFA(用户未扫描二维码,需要使用备用码重置并在下次登录时重新设置MFA)
   * @param: [recoverMfaParam]
   * @return: com.honyar.core.model.response.ResponseData
   * @author: zhouhong
   */
  @PostMapping("/auth/mfa/recoverMfa")
  public ResponseData recoverMfa(@RequestBody RecoverMfaParam recoverMfaParam) {
  iotUserService.recoverMfa(recoverMfaParam);
   return new SuccessResponseData();
  }
/*** @description:  登出* @param: []* @return: com.honyar.core.model.response.ResponseData* @author: zhouhong*/@PostMapping("/auth/logout")public ResponseData logout() {iotUserService.logout();return new SuccessResponseData();}
}

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进行二次认证即可

首页code

3.调用Mfa校验接口

校验1

说明:校验成功后进入系统

http://www.sczhlp.com/news/682.html

相关文章:

  • 上拉电阻和下拉电阻
  • 蓝桥杯2024省赛A组题解
  • 春训#2题解
  • 国内AI编码工具哪家强CodeBuddy+通义灵码+Trae
  • js基础第二天
  • [PaperReading] Stable Video Diffusion: Scaling Latent Video Diffusion Models to Large Datasets
  • 蓝桥杯2025省赛A组游记题解
  • 7.28 闲话
  • FM2023利兹联崛起之路#1
  • 暑训#1补题
  • 07.08 论文精读 人像线稿生成模型
  • 7/28
  • 【LeetCode 141】算法:环形链表
  • 暑训#3补题
  • 关于跨域的一点新理解
  • js基础第三天
  • 龙哥量化:股票期货- 精华资料目录
  • 2025省选组合数学笔记
  • 字符串
  • js基础第四天
  • 同时点亮LED、数码管以及点阵
  • 今日总结
  • docker安装
  • 二进制简史:从理论到芯片
  • Qcom dcvs_epss.c 驱动解析.
  • AirSim+PX4+QGC实现无人机自动避障
  • js基础第五天
  • 简单了解高阻抗(High-Z)
  • 中台建设为什么需要领域驱动设计
  • 不同Linux发行版Node安装指南