Stories

Detail Return Return

SpringBoot中使用TOTP實現MFA(多因素認證) - Stories Detail

一、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
    @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進行二次認證即可

首頁code

3.使用code開啓當前登錄用户的MFA

image

4.調用Mfa校驗接口

校驗1

説明:校驗成功後進入系統

5.恢復/解綁MFA

image

 需要使用MFA或者生成二維碼時的備用碼來解綁

Add a new Comments

Some HTML is okay.