001 - TOTP 和 Google 身份验证器

最近,项目上要添加一个双因子验证的功能,由于一些因素的限制,最终选择了 Google 验证器来做二次验证。这几天研究了一下这方面的知识,发觉还蛮有用的,所以有时间就分享一下学习成果,供有需要的同学参考。

一、TOTP

TOTP : Time-Based One-Time Password,基于时间的一次性密码。RFC 6238是其算法实现规范,Google 身份验证器正是用此算法规范来计算动态的验证码。关于原理,我会贴出代码来讲,这样比较直观一点。不过下面列出几个参考资料,大家可以了解下。

OTP、HOTP、TOTP 三个在概念还是挺紧密的。OTP (One-Time Password)是一次性密码,HOTP 是基于计数器的一次性密码,TOTP是基于时间戳的一次性密码。网上资料也挺多的,大家可以搜索一下。

二、Google 身份验证器

Google 身份验证器Google公司推出的一款动态口令(一次性密码)工具,每隔 30s 生成一个动态口令,这个动态口令是6位,可以做登录验证码使用。
它长这样 :
Google身份验证器

工作原理

刚才说过,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))

  • K : 用户密钥
  • T :时间视窗
  • HMAC-SHA-1 : 消息验证码生成函数,我们用 HmacSHA1算法生成一个消息验证码
  • Truncate :截断函数 : 一般我们代码生成的动态口令不止 6 位,所以要将其截断为6位
  • TOTP :动态口令

代码

(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 循环,少些点代码吧。(纯属个人猜测)

最后

当然,上面的这些只是个人的学习成果,难免有理解不到位的地方,如果有同学发现问题或者有什么疑问,可以在评论区提出来,大家共同讨论一下。

注:以上代码仅供学习交流使用,其他概不负责。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值