最近,项目上要添加一个双因子验证的功能,由于一些因素的限制,最终选择了 Google 验证器来做二次验证。这几天研究了一下这方面的知识,发觉还蛮有用的,所以有时间就分享一下学习成果,供有需要的同学参考。
TOTP : Time-Based One-Time Password,基于时间的一次性密码。RFC 6238是其算法实现规范,Google 身份验证器正是用此算法规范来计算动态的验证码。关于原理,我会贴出代码来讲,这样比较直观一点。不过下面列出几个参考资料,大家可以了解下。
OTP、HOTP、TOTP 三个在概念还是挺紧密的。OTP (One-Time Password)是一次性密码,HOTP 是基于计数器的一次性密码,TOTP是基于时间戳的一次性密码。网上资料也挺多的,大家可以搜索一下。
Google 身份验证器Google公司推出的一款动态口令(一次性密码)工具,每隔 30s 生成一个动态口令,这个动态口令是6位,可以做登录验证码使用。
它长这样 :
刚才说过,Google 身份验证器使用 RFC 6238 的算法规范来计算动态口令,而 RFC 6238是一个开源的算法,上面已经给出地址,大家往下翻翻就可以看到示例代码。这个算法有两个重要的参数,分别是:用户密钥和时间戳。所以,Google 身份验证器要求有一个用户密钥,以便和设备上的时间一同作为参数来计算动态口令。
既然 Google 身份验证器使用用户密钥和时间生成了一个动态口令,那么我们服务端如何去验证这个动态口令是否正确呢?答案就是:我们和 Google 身份验证器一样,照葫芦画瓢,也使用这个开源的算法,也传入同样的用户密钥和时间,那么生成的动态口令不就一样了,这样一来,就实现了验证功能。
(1)有同学可能会问:用户密钥哪里来的?
通常我们在用户注册的时候,就会随机生成一个唯一的密钥,然后保存起来。
(2)又有同学问了:如何保证时间是一致的呢?
很遗憾,无法保证。对于时间的控制只能我们自己来做,比如同步服务端和客户端(google验证器)上的时间;或者设置一个时间差值,我们自己处理一下;又或者如果时间容错上允许的话,我们可以计算出某一个时间段内的所有动态口令,只要有一个符合即可。
贴出代码之前,我先说一下算法里面的一些概念,有助于理解。
(1)time step : 时间步长,即动态口令的更新周期,Google 验证器是 30s 更新一次
(2)time window : 时间视窗,公式 : 时间视窗 = (Current Unix Time - T0) / 时间步长。 T0是开始计算时间步长的unix time,理解上就是开始更新动态口令的时间, 一般认为动态口令从 unix time 为 0 时就开始计算,所以默认是 0。
(3)动态口令公式 : TOTP(K,T) = Truncate(HMAC-SHA-1(K,T))
(1)定义一个随机算法枚举, 如果有同学想用其他算法,可以自己加上
/**
* 随机算法枚举
*
* @author Administrator
*
*/
public enum RNGAlgorithmEnum {
SHA1PRNG("SHA1PRNG");
private String algorithm;
private RNGAlgorithmEnum(String algorithm) {
this.algorithm = algorithm;
}
public String getAlgorithm() {
return algorithm;
}
}
(2)定义一个消息验证码加密算法枚举。Google 验证器使用的是 HmacSHA1 算法,如果有同学想用使用其他算法的验证器,也可以自己扩展下
/**
* 加密算法枚举
*
* @author Administrator
*
*/
public enum CryptoAlgorithmEnum {
HMACSHA1("HmacSHA1");
private String algorithm;
private CryptoAlgorithmEnum(String algorithm) {
this.algorithm = algorithm;
}
public String getAlgorithm() {
return algorithm;
}
}
(3)定义一个配置类
package com.alex.algorithm.doublecheck.google;
/**
* google 验证器配置类
*
* @author Administrator
*/
public class GoogleAuthenticatorConfig {
/**
* 用户密钥长度 : 80bit
*/
private int secretKeyBits = 80;
/**
* TOTP 长度 : 6
*/
private int codeDigits = 6;
/**
* 有效视窗长度 : (-3,3)
*/
private int timeWindowSize = 3;
/**
* TOTP更新周期 : 30s 更新一次
*/
private int timeStep = 30;
/**
* QR Code 前缀
*/
private String prefix = "test";
/**
* QR Code 发行者
*/
private String issuer = "AshesOfBlues";
/**
* 消息验证码生成算法
*/
private CryptoAlgorithmEnum cryptoAlgorithm = CryptoAlgorithmEnum.HMACSHA1;
/**
* 随机数算法
*/
private RNGAlgorithmEnum rngAlgorithm = RNGAlgorithmEnum.SHA1PRNG;
public int getSecretKeyBits() {
return secretKeyBits;
}
public void setSecretKeyBits(int secretKeyBits) {
if (secretKeyBits <= 128 || secretKeyBits % 8 != 0) {
throw new IllegalArgumentException("用户密钥长度至少是 128 bit 且为 8 的倍数");
}
this.secretKeyBits = secretKeyBits;
}
public int getCodeDigits() {
return codeDigits;
}
public void setCodeDigits(int codeDigits) {
if (codeDigits < 6) {
throw new IllegalArgumentException("一次性密码长度不宜小于 6 位数字");
}
this.codeDigits = codeDigits;
}
public int getTimeWindowSize() {
return timeWindowSize;
}
public void setTimeWindowSize(int timeWindowSize) {
if (timeWindowSize < 1) {
throw new IllegalArgumentException("密码有效期应控制在 " + this.timeStep + "s 以上");
}
this.timeWindowSize = timeWindowSize;
}
public int getTimeStep() {
return timeStep;
}
public void setTimeStep(int timeStep) {
if (timeStep < 0) {
throw new IllegalArgumentException("密码更新周期不能小于 0s");
}
this.timeStep = timeStep;
}
public String getPrefix() {
return prefix;
}
public void setPrefix(String prefix) {
this.prefix = prefix;
}
public String getIssuer() {
return issuer;
}
public void setIssuer(String issuer) {
this.issuer = issuer;
}
public CryptoAlgorithmEnum getCryptoAlgorithm() {
return cryptoAlgorithm;
}
public void setCryptoAlgorithm(CryptoAlgorithmEnum cryptoAlgorithm) {
this.cryptoAlgorithm = cryptoAlgorithm;
}
public RNGAlgorithmEnum getRngAlgorithm() {
return rngAlgorithm;
}
public void setRngAlgorithm(RNGAlgorithmEnum rngAlgorithm) {
this.rngAlgorithm = rngAlgorithm;
}
}
(3)定义一个接口,包含基本方法:
import java.security.GeneralSecurityException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.cglib.proxy.UndeclaredThrowableException;
/**
* 验证器接口
*
* @author Administrator
*
*/
public interface IAuthenticator {
/**
* 二维码 url 格式
**/
String QRCODE_URL = "otpauth://totp/%s:%s?secret=%s&issuer=%s";
/**
* 生成用户密钥
*
* @return 用户密钥
*/
String createSecretKey();
/**
* 生成基于 Hash 的 message authentication code
*
* @param secretKeyBytes 用户密钥
* @param timeWindowBytes[] 时间视窗
* @param cryptoAlgorithm 加密算法
* @return 消息验证码
*/
default public byte[] generateHmacShaCode(byte[] secretKeyByte, byte[] timeWindowBytes, String cryptoAlgorithm) {
SecretKeySpec keySpec = new SecretKeySpec(secretKeyByte, cryptoAlgorithm);
try {
// 使用 HmacSHA1 算法,返回一个 160 bit 的 hash 值
Mac keyMac = Mac.getInstance(cryptoAlgorithm);
keyMac.init(keySpec);
return keyMac.doFinal(timeWindowBytes);
} catch (GeneralSecurityException e) {
e.printStackTrace();
throw new UndeclaredThrowableException(e);
}
}
/**
* 生成 TOTP
*
* @param secretKey 用户密钥
* @param currentTimeMsec unix time
* @return TOTP
*/
String generateTotp(String secretKey, long currentTimeMsec);
/**
* 验证 TOTP 是否正确
*
* @param secretKey 用户密钥
* @param userTotp 用户输入的 TOTP
* @param currentTimeMsec 当前 unix time
* @return 成功/失败
*/
boolean checkTotp(String secretKey, String userTotp, long currentTimeMsec);
/**
* 生成二维码
*
* @param secretKey 用户密钥
* @param username 用户名
* @param issuer 发行者
* @param prefix 前缀
* @return 二维码 url
*/
String generateQrCode(String secretKey, String username, String issuer, String prefix);
(4)Google 验证器实现类。这就是我们主要的工作类了,大家可以仔细看下注释。
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.concurrent.TimeUnit;
import org.apache.commons.codec.binary.Base32;
import org.apache.commons.lang3.StringUtils;
/**
* google 验证器
*
* @author Administrator
*
*/
public class GoogleAuthenticator implements IAuthenticator {
private GoogleAuthenticatorConfig config;
public GoogleAuthenticator() {
this.config = new GoogleAuthenticatorConfig();
}
public GoogleAuthenticator(GoogleAuthenticatorConfig config) {
this.config = config;
}
@Override
public String createSecretKey() {
try {
// 使用 SecureRandom 产生安全的随机数
SecureRandom keyRandom = SecureRandom.getInstance(config.getRngAlgorithm().getAlgorithm());
byte[] keyBytes = keyRandom.generateSeed(config.getSecretKeyBits() / 8);
// 将随机数进行 Base32 编码,产生一个随机字符串密钥
Base32 keyBase32 = new Base32();
return keyBase32.encodeToString(keyBytes);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
return null;
}
}
@Override
public String generateTotp(String secretKey, long timeWindow) {
String resultTotp = null;
Base32 keyBase32 = new Base32();
byte[] keyBytes = keyBase32.decode(secretKey);
// 将 timeWindow 转为 byte 数组
byte[] timeWindowBytes = new byte[8];
for (int i = 8; i-- > 0; timeWindow >>>= 8) {
// 进行截断赋值
timeWindowBytes[i] = (byte) timeWindow;
}
byte[] hash = generateHmacShaCode(keyBytes, timeWindowBytes, config.getCryptoAlgorithm().getAlgorithm());
// offset : 开始取字节的位置; 由于 HmacSHA1算法返回的是160bit,也就是 20 byte, 所以 hash 长度是 20, 用hash
// 的最后一位和 0xF 做 & 操作,使 0 <= offset <= 15, 这样即使 offset 为 15 ,连续 4
// 次取字节,最多取到hash[18],不会发生数组越界
int offset = hash[hash.length - 1] & 0xF;
// 从 hash 中连续取出 4 个字节(32bit),将其组成一个 int 型正整数, 进行了 4 次操作,分别是将 4个 字节移到 originTotp
// 的第 1,2,3,4 字节
// (hash[offset] & 0x7F) 则是为了 originTotp 的首位是 0, 可以得到一个正数
int originTotp = ((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8) | (hash[offset + 3] & 0xFF);
// google 中 codeDigits 为 6,表示得到的 totp 长度是 6
// 对 10^6 取余,得到的余数的长度一定不大于 6
int totp = originTotp % (int) Math.pow(10, config.getCodeDigits());
// 如果得到的余数 totp 长度小于 6 ,则在前面补 0
resultTotp = Integer.toString(totp);
while (resultTotp.length() < 6) {
resultTotp = "0" + resultTotp;
}
return resultTotp;
}
@Override
public boolean checkTotp(String secretKey, String userTotp, long currentTimeMsec) {
long timeWindow = currentTimeMsec / TimeUnit.SECONDS.toMillis(config.getTimeStep());
int timeWindownSize = config.getTimeWindowSize();
// timeStep * timeWindownSize 秒内有验证码正确就验证通过
for (int i = -timeWindownSize; ++i < timeWindownSize;) {
String totp = generateTotp(secretKey, timeWindow + i);
if (StringUtils.equals(userTotp, totp)) {
return true;
}
}
return false;
}
@Override
public String generateQrCode(String secretKey, String username, String issuer, String prefix) {
return String.format(QRCODE_URL, prefix, username, secretKey, issuer);
}
public String generateQrCode(String secretKey, String username) {
return generateQrCode(QRCODE_URL, config.getPrefix(), username, secretKey, config.getIssuer());
}
// test
public static void main(String[] args) {
// GoogleAuthenticator authenticator = new GoogleAuthenticator();
// String secretKey = authenticator.createSecretKey();
// System.out.println(authenticator.generateQrCode(secretKey, "alex", "alex", "test"));
// System.out.println(authenticator.checkTotp("XXXXXXXXXXXXXXX", "123456", System.currentTimeMillis()));
}
}
总结:其实代码量很少,也不是很复杂,大家只要多想想就能理解了。
其实刚开始做的时候,我也是上网来搜索其他同学的代码来看,其中有一个外国友人在 github 上有一个项目是 google 验证器的,地址如下:https://github.com/wstrange/GoogleAuth。但由于这个事个人项目,所以不便直接拿过来使用,所以也研究了一下。
当时看的时候,发现和规范中的某些代码不一致,例如 :
(1)将用户密钥和时间窗口转换为byte 数组时,操作是不同的,估计是因为Google验证器的实现方式和规范是不一样的,大家可以自己对比一下
(2)生成动态口令时:
规范:
int originTotp = ((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8) | (hash[offset + 3] & 0xFF);
int totp = originTotp % (int) Math.pow(10, config.getCodeDigits());
wstrange/GoogleAuth :
long truncatedHash = 0;
for (int i = 0; i < 4; ++i)
{
truncatedHash <<= 8;
// Java bytes are signed but we need an unsigned integer:
// cleaning off all but the LSB.
truncatedHash |= (hash[offset + i] & 0xFF);
}
// Clean bits higher than the 32nd (inclusive) and calculate the
// module with the maximum validation code value.
truncatedHash &= 0x7FFFFFFF;
truncatedHash %= config.getKeyModulus(); // config.getKeyModulus() 是 10^6
其实作者的操作和规范是一样的,只不过代码实现的不同,作者使用了一个 long 型来接收结果,然后
truncatedHash &= 0x7FFFFFFF; 是为了和规范中的到的数据一致。个人感觉没有必要使用 long 型来接收数据,就像我注释中写的,规范里面进行动态码生成时已经保证得出来的数是一个正数了,使用 int 接收就可以了,可能作者是为了使用 for 循环,少些点代码吧。(纯属个人猜测)
当然,上面的这些只是个人的学习成果,难免有理解不到位的地方,如果有同学发现问题或者有什么疑问,可以在评论区提出来,大家共同讨论一下。
注:以上代码仅供学习交流使用,其他概不负责。