SpringBoot集成双因素认证

一、TOTP 是什么?

TOTP(Time-based One-Time Password) 即 基于时间的一次性密码算法,是一种广泛用于双因素认证(2FA)的安全机制。其核心原理是通过 时间戳 和 密钥 生成动态密码,每次生成的密码仅在短时间内有效,从而防止重放攻击和密码泄露风险。

二、TOTP 的核心原理

TOTP 基于 HOTP(HMAC-based One-Time Password)算法,并引入 时间因子 实现动态性。流程如下:

密钥生成

用户在启用 TOTP 时,服务器会生成一个 密钥(Secret Key),通常为 Base32 编码的字符串(如 JBSWY3DPEHPK3PXP)。
密钥需同时存储在服务器和用户的认证设备(如 Google Authenticator、微软 Authenticator)中。

时间步长(Time Step)

将时间划分为固定长度的 时间窗口(通常为 30 秒,即 TIME_STEP = 30)。
当前时间戳(以秒为单位)除以时间步长,得到 时间计数器(Counter),例如:
counter = current_time_seconds / TIME_STEP

动态密码生成

使用 HMAC 算法(如 HmacSHA1、HmacSHA256)对 密钥 和 时间计数器 进行哈希计算,得到哈希值。
从哈希值中提取 动态截取值,转换为 固定长度的数字密码(通常为 6 位或 8 位)。

三、TOTP 的关键要素
要素 说明
密钥(Secret Key) 长度建议至少 16 字节(Base32 编码后为 20 字符),需安全存储,避免泄露。
时间步长 常见为 30 秒或 60 秒,时间步长越短,安全性越高,但用户输入密码的时间窗口越小。
哈希算法 支持 HmacSHA1(默认)、HmacSHA256 等,SHA256 比 SHA1 更安全。
密码长度 通常为 6 位或 8 位数字,位数越多,暴力破解难度越大。
四、TOTP 的工作流程示例

假设:

时间步长 TIME_STEP = 30 秒,
密钥为 JBSWY3DPEHPK3PXP(Base32 编码,对应原始字节为 1234567890abcdef),
当前时间戳为 1689000000 秒(对应 counter = 1689000000 / 30 = 56300000)。

生成时间计数器的字节数组
将 counter(56300000)转换为 8 字节的大端序(Big-Endian)数组:
data = [0x00, 0x35, 0x82, 0xA8, 0x00, 0x00, 0x00, 0x00]

计算 HMAC-SHA1 哈希值
使用密钥和 data 计算哈希值,假设结果为:
0x4A 0x9F 0x...(实际为 20 字节)。

动态截取与转换

取哈希值最后一个字节的低 4 位作为偏移量 offset(如 0x0F),
从 offset 位置开始截取 4 字节,转换为 31 位整数(避免符号位影响),
对 10^6 取模得到 6 位数字密码,例如 123456

五、TOTP 的优缺点
优点 缺点
无需网络连接(依赖本地时间同步) 依赖设备时间同步,时间偏差可能导致验证失败
动态密码时效性强(30-60 秒过期) 无法抵抗中间人攻击(需配合其他安全措施)
实现简单,兼容多种设备和协议 密钥泄露后存在安全风险
六、常见问题与解决方案

时间不同步问题

现象:用户设备与服务器时间偏差较大,导致生成的 counter 不一致。
解决方案

在验证时允许 前后多个时间窗口(如 counter ±1 或 ±2),扩大匹配范围。
定期同步设备时间(如通过 NTP 协议)。

密钥管理

避免硬编码密钥,建议通过安全通道(如 HTTPS)传输密钥,并使用加密存储(如 AES)。
用户端可通过 QR 码(如 otpauth:// 协议)快速导入密钥,减少手动输入错误。

算法选择

优先使用 HmacSHA256 或 HmacSHA512 替代默认的 HmacSHA1,提升抗碰撞能力。

七、TOTP 的应用场景

双因素认证(2FA):如银行账户、云服务(AWS、Google)、企业 VPN 等。
一次性密码登录:替代传统静态密码,用于敏感操作验证。
离线场景:如无网络环境下的设备身份验证。

代码实战:

Controller层:主要生成二维码和SecretKey(把它存到数据库中)

package com.ruoyi.web.controller.system;

import com.google.zxing.BarcodeFormat;
import com.google.zxing.MultiFormatWriter;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.totp.TotpUtils;
import com.ruoyi.system.service.ISysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.net.URLEncoder;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
/**
 * 2FA认证控制器
 */
@RestController
@RequestMapping("/api/system/2fa")
public class AuthController extends BaseController {

    @Autowired
    private ISysUserService userService;
    public static final String BASE64_IMAGE_PREFIX = "data:image/png;base64,";
    /**
     *  获取2FA设置信息(生成密钥和QR码URL)
     *  SysUser表 新加字段
     *  private String secretKey; // 存储TOTP密钥
     *  private char usingTwoFA; // 是否启用2FA
     */
    @GetMapping("/setup")
    public AjaxResult setup2FA(String userName) throws Exception {

        SysUser user = userService.selectUserByUserName(userName);
        Map<String, Object> result = new HashMap<>();
        if (user == null) {
            return error("用户不存在");
        }else {
//            if (user.getUsingTwoFA() == '1') {
//                return error("用户已启用2FA");
//            } else {
                // 生成新的密钥
                String secretKey = TotpUtils.generateSecretKey();
                user.setSecretKey(secretKey);//用户表设置SecretKey和UsingTwoFA
                user.setUsingTwoFA('1');
                userService.updateUser(user);
                // 生成QR码URL
                String qrCodeUrl = TotpUtils.getQRCodeUrl(
                        secretKey,
                        user.getUserName(),
                        URLEncoder.encode("若依系统", "UTF-8")
                );
                // 生成二维码图像
                String qrCodeImage = generateQRCodeImage(qrCodeUrl);
                result.put("qrCodeImage", BASE64_IMAGE_PREFIX + qrCodeImage); // 二维码图像的Base64编码
                result.put("secretKey", secretKey);
//            }
            return success(result);
        }
    }

    // 生成二维码图像的Base64编码
    private String generateQRCodeImage(String content) throws Exception {
        int width = 200;
        int height = 200;
        String format = "png";
        // 使用ZXing库生成二维码
        BitMatrix bitMatrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
        BufferedImage image = MatrixToImageWriter.toBufferedImage(bitMatrix);
        // 将图像转换为Base64编码
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ImageIO.write(image, format, baos);
        byte[] imageBytes = baos.toByteArray();
        return Base64.getEncoder().encodeToString(imageBytes);
    }
}

TotpUtils:SecretKey+时间戳生成的动态验证码

package com.ruoyi.common.utils.totp;

import org.apache.commons.codec.binary.Base32;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;

/**
 * TOTP工具类,用于生成和验证一次性密码
 */
public class TotpUtils {

    private static final int TIME_STEP = 30; // 时间步长30秒
    private static final int CODE_DIGITS = 6; // 验证码6位
    private static final String HMAC_ALGORITHM = "HmacSHA1"; // 加密算法

    /**
     * 生成随机密钥
     */
    public static String generateSecretKey() {
        byte[] buffer = new byte[10];
        new java.util.Random().nextBytes(buffer);
        return new Base32().encodeToString(buffer);
    }

    /**
     * 生成当前时间的TOTP码
     */
    public static String generateTOTP(String secretKey) {
        long time = new Date().getTime() / 1000 / TIME_STEP;
        return generateHOTP(secretKey, time);
    }

    public static boolean verifyTOTP(String secretKey, String code) {
        if (secretKey == null || code == null) {
            return false;
        }
        long time = System.currentTimeMillis() / 1000 / TIME_STEP; // 统一时间计算方式
        for (int i = -1; i <= 1; i++) { // 恢复时间窗口
            String expectedCode = generateHOTP(secretKey, time + i);
            if (MessageDigest.isEqual(expectedCode.getBytes(), code.getBytes())) { // 常量时间比较
                return true;
            }
        }
        return false;
    }

    private static String generateHOTP(String secretKey, long counter) {
        if (secretKey == null) {
            throw new IllegalArgumentException("Secret key cannot be null"); // 添加空值校验
        }
        byte[] key = new Base32().decode(secretKey);
        byte[] data = new byte[8];
        for (int i = 7; i >= 0; i--) {
            data[i] = (byte) (counter & 0xff);
            counter >>= 8;
        }
        try {
            Mac mac = Mac.getInstance(HMAC_ALGORITHM);
            mac.init(new SecretKeySpec(key, HMAC_ALGORITHM));
            byte[] hash = mac.doFinal(data);

            int offset = hash[hash.length - 1] & 0xf;
            int binary = ((hash[offset] & 0x7f) << 24
                    | (hash[offset + 1] & 0xff) << 16
                    | (hash[offset + 2] & 0xff) << 8
                    | (hash[offset + 3] & 0xff));

            int otp = binary % (int) Math.pow(10, CODE_DIGITS);
            return String.format("%0" + CODE_DIGITS + "d", otp);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("生成HOTP失败", e);
        }
    }

    // 生成QR码URL
    public static String getQRCodeUrl(String secret, String username, String issuer) {
        return String.format("otpauth://totp/%s:%s?secret=%s&issuer=%s",
                issuer, username, secret, issuer);
    }
}

最后就是修改登录逻辑了,需要根据实际需求更改

    @PostMapping("/login")
    public AjaxResult login1(@RequestBody LoginBody loginBody) {
        AjaxResult ajax = AjaxResult.success();

        try {
            // 解密用户名和密码
            String username = SM2Util.Decrypt(SM2_PRIVATE_KEY, loginBody.getUsername());
            String password = SM2Util.Decrypt(SM2_PRIVATE_KEY, loginBody.getPassword());

            // 第一步:验证用户名和密码
            SysUser user = userService.selectUserByUserName(username);

            if (user == null) {
                throw new AuthenticationException("登录失败,用户名或密码错误");
            }

            // 第二步:检查是否需要双因素认证
            if ( user.getUsingTwoFA()=='1') {
                // 需要双因素认证,但用户未提供验证码
                if (StringUtils.isEmpty(loginBody.getCode())) {
                    // 返回需要2FA的提示,要求用户提供验证码
                    ajax.put("need2FA", true);
                    ajax.put("username", username); // 保留用户名用于下一步验证
                    return ajax;
                }

                // 验证双因素验证码
                if (!TotpUtils.verifyTOTP(user.getSecretKey(), loginBody.getCode())) {
                    throw new AuthenticationException("双因素验证码错误");
                }
            }

            // 第三步:生成令牌(如果通过了所有验证)
            Map<String, Object> map = loginService.login(username, password, loginBody.getCode(), loginBody.getUuid());
            ajax.put(Constants.TOKEN, map.get("token").toString());
            ajax.put("lgbDeptId", map.get("lgbDeptId").toString());
            ajax.put("loginUserType", map.get("loginUserType").toString());

        } catch (Exception e) {
            // 处理异常
            log.error("登录失败", e);
            return error(e.getMessage());
        }

        return ajax;
    }

附件:相关Maven

<dependencies>
    <!-- 核心依赖:Base32编码/解码 -->
    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
        <version>1.15</version>
    </dependency>

    <!-- 可选:Google Authenticator兼容库 -->
    <dependency>
        <groupId>com.warrenstrange</groupId>
        <artifactId>googleauth</artifactId>
        <version>1.1.1</version>
    </dependency>

    <!-- 可选:Hutool工具库 -->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.16</version>
    </dependency>

 <!-- ZXing(二维码生成) -->
        <dependency>
            <groupId>com.google.zxing</groupId>
            <artifactId>core</artifactId>
            <version>3.5.2</version>
        </dependency>
        <dependency>
            <groupId>com.google.zxing</groupId>
            <artifactId>javase</artifactId>
            <version>3.5.2</version>
        </dependency>
</dependencies>

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容