2FA

2FA,全称 Two Factor Authentication,中文名叫 双重因素认证,便是里面的核心技术。字面意来理解,双重因素认证,即认证需要用到两重因素。比如,你在银行 ATM 取钱的时候既要你的银行卡,也需要只有你才知道的六位数口令,这便是双重因素认证。

OTP

OTP(One-Time Password,中文名:一次性口令)
要求必须是一次性、不可预测 ,一般为了用户输入方便,会使用四位、六位或八位数字。

HOTP

HOTP(HMAC-based One-Time Password,中文名:基于哈希消息认证码的一次性口令)。它也属于一次性口令,但是生成这个一次性口令,还另外需要提供一串密钥和一个随机数,用于生成口令。
HMAC(Hash-based Message Authentication Code,中文名:基于哈希的消息认证码),这个算法主要是用于验证消息的合法性,与常见的哈希算法的唯一区别是,在计算哈希摘要时,还需要额外提供一串密钥,俗称加盐(salt 或 nonce)。一言蔽之:使用一串只有你自己(或双方)才知道的密钥,可以生成一串独一无二的哈希值。

在 HOTP 的应用中,这串密钥只有客户端和服务端双方才知道,被计算摘要的消息要求双方都能知道并保持相同,一般是一个自增计数器,比如:0, 1, 2, 3, 4。被计算出的一次性口令每使用一次,这个计数器就加一,由于密钥只有双方才知道,故双方都可以计算出一样的一次性口令,而第三方不知道这串密钥的,无法计算出一样的口令。

这个计数器必须为一个 8-byte 的整数,即 Int64,高位字节若不足应填充 0x00
由于最终结果需要用户手动输入到对应程序,但 SHA1 生成的为 20 字节,转换为十六进制字符串为长度 40 的英文 + 数字的字符串,不便于用户输入。因此,我们需要将这串摘要结果转换为便于用户输入的数据,也就是六位数数字。

转换算法为,取摘要结果最后一个字节的低 4 位,作为偏移值,然后以该偏移值为下标,从摘要中取从下标为该偏移值开始的 4 个字节,按大端模式组合成一个纯数字并忽略符号位,再取这个数字的后六位,高位长度不足 6 的应补上 0。

HOTP 存在一个很影响用户体验的缺点:在离线状态下,应当如何让客户端和服务端同步计数器?由于离线状态下客户端和服务端无法通信,因此刷新计数器只能靠用户手动去点刷新,万一用户手残,亦或是觉得好玩,多点了几下,导致客户端和服务端的计数器不同步了,怎么办?

解决方案一,让服务端一并计算计数器前后的验证码值(假设当前计数器为 5,一并计算 0~10 的验证码值),只要用户输入正确一个,视为验证成功。但是这也增加了被暴力破解的风险,而且万一用户的计数器还是超过太多,也会失效。

解决方案二,向用户展示服务端当前计数器的值,然后用户手动同步客户端的计数器。但是,这也会产生麻烦和不安全,用户需要手动同步计数器,而且如果用户的计数器大于服务端计数器的值,就会看到未来某个时候的验证码值,可能会被其他人看到,产生不安全因素。计数器内部值最好不要展示出来。

TOTP

TOTP(Time-Based One-Time Password,中文名:基于时间的一次性口令),他只是把上文 HOTP 中的计数器换成了时间戳,除此之外没有任何区别。

但是这个时间戳,不能直接当做计数器的值,因为还需要留给用户足够的输入时间,一般是 30 秒。因此,真正计数器的值的计算方法如下:

T = floor(currentTimestamp / step)

currentTimestamp 为当前的时间戳,单位为秒,step 为步长,一般为 30 比较合适,floor 为向下取整。

因此,通过这样计算出来的 T 值,在一定时长内会保持一致(比如 00:00 ~ 00:29 为 1,00:30 ~ 00:59 为 2),每 30 秒便会自增,无需用户手动同步计数器,唯一的缺点是要保持时钟同步。

截断处理的详细步骤(RFC 4226 标准)

获取 HMAC 哈希结果

首先通过 HMAC-SHA-1(K, C) 生成 160 位(20 字节)的哈希值,记为 H(十六进制表示为 40 个字符)。

示例:H = 1f8698690e02ca16618550ef7f19da8e945b555a(20 字节)

确定偏移量(Offset)

取哈希值的最后一个字节的低 4 位作为偏移量 offset,确保其值在 0-15 范围内(因为 20 字节的哈希最多支持到第 16 个字节开始的 4 字节块)。

计算方式:offset = H[19] & 0x0F(取最后一个字节的低 4 位)

示例:最后一个字节是 0x5a,0x5a & 0x0F = 0x0a → offset = 10

提取 4 字节块(32 位)

从偏移量 offset 开始,提取连续 4 个字节(32 位),记为 P。

示例:从偏移量 10 开始提取 4 字节:H[10]H[11]H[12]H[13] → 0x61 0x66 0x18 0x55

执行截短(Truncation)

清除最高位(符号位),将 32 位整数转换为 31 位非负整数(避免符号问题)。

计算方式:P = (H[offset] & 0x7F) << 24 | (H[offset+1] & 0xFF) << 16 | (H[offset+2] & 0xFF) << 8 | (H[offset+3] & 0xFF)

示例:0x61 最高位为 0,无需处理 → P = 0x61661855(十进制:1639870037)

转换为固定长度数字

对 31 位整数取模 10^d(d 为密码长度,通常 6 位),得到最终 OTP 码。

示例:6 位 OTP → 1639870037 % 1000000 = 870037

示例代码

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Base64;
import org.apache.commons.codec.binary.Base32;

public class OTPUtils {
    
    // HOTP默认密码长度
    private static final int DEFAULT_HOTP_LENGTH = 6;
    
    // TOTP默认时间步长(秒)
    private static final int TOTP_TIME_STEP = 30;
    
    // TOTP默认起始时间(Unix时间戳,毫秒)
    private static final long TOTP_T0 = 0;
    
    /**
     * 生成HOTP密码
     * 
     * @param key 共享密钥(Base32编码)
     * @param counter 计数器
     * @param digits 密码长度(6-8)
     * @return 生成的HOTP密码
     */
    public static String generateHOTP(String key, long counter, int digits) {
        // 解码Base32密钥
        byte[] keyBytes = new Base32().decode(key);
        
        // 将计数器转换为8字节数组(大端序)
        byte[] counterBytes = new byte[8];
        for (int i = 7; i >= 0; i--) {
            counterBytes[i] = (byte) (counter & 0xFF);
            counter >>= 8;
        }
        
        try {
            // 计算HMAC-SHA-1
            Mac mac = Mac.getInstance("HmacSHA1");
            mac.init(new SecretKeySpec(keyBytes, "HmacSHA1"));
            byte[] hmacResult = mac.doFinal(counterBytes);
            
            // 截断处理(动态截断函数)
            int offset = hmacResult[hmacResult.length - 1] & 0x0F;
            int binary = ((hmacResult[offset] & 0x7F) << 24) |
                         ((hmacResult[offset + 1] & 0xFF) << 16) |
                         ((hmacResult[offset + 2] & 0xFF) << 8) |
                         (hmacResult[offset + 3] & 0xFF);
            
            // 生成指定长度的数字
            int otp = binary % (int) Math.pow(10, digits);
            
            // 补前导零
            return String.format("%0" + digits + "d", otp);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("生成HOTP失败", e);
        }
    }
    
    /**
     * 生成默认长度(6位)的HOTP密码
     */
    public static String generateHOTP(String key, long counter) {
        return generateHOTP(key, counter, DEFAULT_HOTP_LENGTH);
    }
    
    /**
     * 生成TOTP密码
     * 
     * @param key 共享密钥(Base32编码)
     * @param digits 密码长度(6-8)
     * @return 生成的TOTP密码
     */
    public static String generateTOTP(String key, int digits) {
        // 计算当前计数器值:C = floor((T - T0) / 时间步长)
        long currentTime = System.currentTimeMillis() / 1000;
        long counter = (currentTime - TOTP_T0) / TOTP_TIME_STEP;
        
        // 调用HOTP生成密码
        return generateHOTP(key, counter, digits);
    }
    
    /**
     * 生成默认长度(6位)的TOTP密码
     */
    public static String generateTOTP(String key) {
        return generateTOTP(key, DEFAULT_HOTP_LENGTH);
    }
    
    /**
     * 验证HOTP密码
     */
    public static boolean verifyHOTP(String key, long counter, String code, int digits) {
        String generatedCode = generateHOTP(key, counter, digits);
        return generatedCode.equals(code);
    }
    
    /**
     * 验证TOTP密码(考虑时间容错,默认允许前后各一个时间窗口)
     */
    public static boolean verifyTOTP(String key, String code, int digits, int window) {
        long currentTime = System.currentTimeMillis() / 1000;
        long currentCounter = (currentTime - TOTP_T0) / TOTP_TIME_STEP;
        
        // 检查当前窗口及前后window个窗口
        for (int i = -window; i <= window; i++) {
            long counter = currentCounter + i;
            if (generateHOTP(key, counter, digits).equals(code)) {
                return true;
            }
        }
        return false;
    }
    
    /**
     * 生成随机的Base32编码密钥
     */
    public static String generateRandomKey() {
        // 生成160位(20字节)的随机密钥,符合HOTP推荐长度
        byte[] key = new byte[20];
        new java.security.SecureRandom().nextBytes(key);
        return new Base32().encodeToString(key).replaceAll("=", ""); // 移除填充
    }
}