SpringBoot中使用TOTP實現MFA(多因素認證)
一、MFA簡介
定義:多因素認證(MFA)要求用戶在登錄時提供??至少兩種不同類別??的身份驗證因子,以提升賬戶安全性
核心目標:解決單一密碼認證的脆弱性(如暴力破解、釣魚攻擊),將賬戶被盜風險降低??80%以上;通過組合不同的驗證因素,MFA 能夠顯著降低因密碼泄露帶來的風險
二、核心原理
MFA通過多步驟驗證構建安全屏障:
- ??初始驗證??:用戶輸入用戶名和密碼(知識因子)
- ??二次驗證??:系統要求額外因子(如手機接收OTP碼、指紋掃描)
- ??動態授權??:高風險操作(如轉賬)可觸發更多驗證(如硬件令牌+生物識別)
- ??訪問控制??:所有因子驗證通過后,授予最小必要權限
??安全增強邏輯??:
- 攻擊者即使破解密碼(知識因子),仍需突破所有權或生物因子,難度呈指數級增長
- 例如:釣魚攻擊中竊取密碼后,因無法獲取動態令牌或生物特征而失敗
三、主流技術方案與對比
| 認證方式 | 安全性 | 用戶體驗 | 實施成本 | 場景 |
| TOTP動態碼?? | 高 | 優 | 低 | 通用:企業系統、云服務等(推薦首選) |
| ??短信驗證碼? | 中 | 中 | 中 | 金融支付、社交平臺(需運營商集成) |
| 生物識別??(如人臉、指紋等) | 極高 | 優 | 高 | 移動設備、高安全系統 |
| ??硬件令牌??(如YubiKey) | 極高 | 中 | 高 | 金融、政府、軍事系統 |
四、TOTP簡介
- 基于時間的一次性密碼,動態驗證碼每30秒更新,基于共享密鑰(Secret Key)和當前時間戳通過HMAC-SHA1算法生成6位數字。
- 優勢??:離線可用、無需短信成本、兼容Google Authenticator等標準應用
五、SpringBoot集成TOTP
a.登錄流程圖(這里原系統使用 SA-Token,其他邏輯應該也大差不差)

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 @Resource private IotUserService iotUserService; @Resource private 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); iotUserService.updateById(user); String qr = "otpauth://totp/" + userId + "?secret=" + secret + "&issuer=IOT_Platform"; 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生成(服務類省略)
@Override public 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; } @Override public 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("驗證碼無效"); } String userAgent = request.getHeader("User-Agent"); String ip = IpUtils.getClientIp(request); // 2. 若選擇免認證7天,更新數據庫 if (Boolean.TRUE.equals(verifyParam.getRemember())) { log.info("MFA驗證請求IP:" + ip); mfaService.setMfaSkip(userId, userAgent, ip); } else { // 未選擇7天免認證,則刪除redis String deviceHash = DigestUtils.sha256Hex(userAgent + ip).substring(0, 8); String key = "mfa_skip:" + userId + ":" + deviceHash; redisUtil.delete(key); } // 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; } @Override public MfaSetupResult qrCode() { return mfaService.setupMfa(); } @Override public void openMfa(RecoverMfaParam recoverMfaParam) { if (ObjectUtil.isNull(recoverMfaParam.getCode())) { throw new ServiceException("請輸入驗證碼"); } String userId = StpUtil.getLoginIdAsString(); IotUser iotUser = this.getById(userId); if (ObjectUtil.isNull(iotUser)) { throw new ServiceException("用戶不存在"); } if (mfaService.verifyCode(userId, recoverMfaParam.getCode())) { iotUser.setMfaEnabled(1); this.updateById(iotUser); } else { throw new ServiceException("驗證碼錯誤"); } } @Override public void recoverMfa(RecoverMfaParam recoverMfaParam, HttpServletRequest request) { 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 (mfaService.verifyCode(userId, recoverMfaParam.getCode())) { backupCodes.remove(recoverMfaParam.getCode()); // 更新數據庫 iotUser.setBackupCodes(encrypt(String.join(",", backupCodes))); // 重置MFA,再次登錄時需要重新設置并掃碼綁定 iotUser.setMfaEnabled(0); iotUser.setMfaSecret(null); iotUser.setBackupCodes(null); this.updateById(iotUser); String userAgent = request.getHeader("User-Agent"); String ip = IpUtils.getClientIp(request); String deviceHash = DigestUtils.sha256Hex(userAgent + ip).substring(0, 8); String key = "mfa_skip:" + userId + ":" + deviceHash; redisUtil.delete(key); } else { throw new ServiceException(1, "備用碼或MFA碼錯誤"); } } @Override public void logout() { /** 會話注銷 */ StpUtil.logoutByTokenValue(StpUtil.getTokenValue()); }
5.Mfa校驗入參類
@Data public class MfaVerifyParam { /** * Mfa動態、一次性備用代碼 */ private String code; /** * 當前機器近7天是否跳過Mfa校驗 */ private Boolean remember; }
6.控制類
@RestController public class IotPlatFormAuthController { @Resource private 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(@RequestBody RecoverMfaParam recoverMfaParam) { iotUserService.openMfa(recoverMfaParam); return new SuccessResponseData(); } /** * @description: 恢復MFA(用戶未掃描二維碼,需要使用備用碼重置并在下次登錄時重新設置MFA) * @param: [recoverMfaParam] * @return: com.honyar.core.model.response.ResponseData * @author: zhouhong */ @PostMapping("/auth/mfa/recoverMfa") public ResponseData recoverMfa(@RequestBody RecoverMfaParam recoverMfaParam, HttpServletRequest request) { iotUserService.recoverMfa(recoverMfaParam, request); 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: 登出 * @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,再使用綁定的code調用接口開啟mfa(數據庫用戶mfaEnable字段置為1即可),然后再調用mfa校驗接口進行mfa校驗,如果用戶選擇不開啟則直接登錄成功進入系統。
2.調用mfa二維碼、備用一次性code生成接口

說明:調用這個接口后前端根據 qrUrl信息生成一個二維碼,并且同時瀏覽器下載備用code 到本地,用戶使用Authenticator APP進行掃碼添加用戶,然后再使用 Authenticator 里面生成的code調用校驗Mfa接口校驗成功后進入系統;第二次用戶直接從Authenticator獲取code進行二次認證即可


3.使用code開啟當前登錄用戶的MFA

4.調用Mfa校驗接口

說明:校驗成功后進入系統
5.恢復/解綁MFA

需要使用MFA或者生成二維碼時的備用碼來解綁
本文來自博客園,作者:Tom-shushu,轉載請注明原文鏈接:http://www.rzrgm.cn/Tom-shushu/p/19009537

浙公網安備 33010602011771號