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("=", ""); // 移除填充
}
}
Comments