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

一、TOTP

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

  • RFC4226 : HOTP(Hmac-BasedOne-Time Password) 的规范
  • RFC6238 : TOTP的规范
  • 动态令牌-(OTP,HOTP,TOTP)-基本原理
  • HOTP原理
  • TOTP原理

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

二、Google 身份验证器

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))

  • 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;}@Overridepublic 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;}}@Overridepublic 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 取余,得到的余数的长度一定不大于 6int totp = originTotp % (int) Math.pow(10, config.getCodeDigits());// 如果得到的余数 totp 长度小于 6 ,则在前面补 0resultTotp = Integer.toString(totp);while (resultTotp.length() < 6) {resultTotp = "0" + resultTotp;}return resultTotp;}@Overridepublic 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;}@Overridepublic 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 循环,少些点代码吧。(纯属个人猜测)

最后

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

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

001 - TOTP 和 Google 身份验证器相关推荐

  1. 两步验证杀手锏:Java 接入 Google 身份验证器实战

    转载自   两步验证杀手锏:Java 接入 Google 身份验证器实战 什么是两步验证? 大家应该对两步验证都熟悉吧?如苹果有自带的两步验证策略,防止用户账号密码被盗而锁定手机进行敲诈,这种例子屡见 ...

  2. 使用google身份验证器实现动态口令验证

    最近有用户反应我们现有的短信+邮件验证,不安全及短信条数限制和邮件收验证码比较慢的问题,希望我们 也能做一个类似银行动态口令的验证方式.经过对可行性的分析及慎重考虑,可以实现一个这样的功能. 怎么实现 ...

  3. Java使用google身份验证器实现动态口令验证

    google身份认证器服务端key的生成和它生成的随机密码的验证: 客户端和服务器事先协商好一个密钥K,用于一次性密码的生成过程,此密钥不被任何第三方所知道.此外,客户端和服务器各有一个计数器C,并且 ...

  4. php接入Google身份验证器

    如果不清楚Google身份验证器是个什么东西的,建议先去了解一下,再回来看此篇文章,会更好理解一些. GitHub上有对Google Auth的php实现封装,这里直接拿下来用,可从以下地址自行获取G ...

  5. linux 利用Google身份验证器配置ssh完成双重登录验证

    一:准备一台linux 操作系统 在Ubuntu上安装Google身份验证器 sudo apt-get install libpam-google-authenticator -y 在centos上安 ...

  6. 关于Google身份验证器、基于时间的一次性密码 (TOTP)算法的初步了解

    一.Google Authenticator 1.概述 Google Authenticator是基于双因素身份验证 ( 2FA ) 的应用程序,有助于识别用户身份并确认用户声称自己是谁以及他是否真的 ...

  7. Java web接入google身份验证器二次验证

    实现原理参考: https://blog.seetee.me/post/2011/google-two-step-verification/ 第一步: maven工程加入依赖 <dependen ...

  8. Google身份验证器操作步骤

    ctrl+alt+printScreen 截图  q+tab 补齐 一. 搜索Google Authenticator ssh date -s "2015-6-25 17:24" ...

  9. 【SpringBoot】61、SpringBoot中使用谷歌身份验证器(Google Authenticator)实现二步身份验证

    Google 身份验证器 Google Authenticator 是谷歌推出的基于时间的一次性密码 (Time-based One-time Password,简称 TOTP),只需要在手机上安装该 ...

  10. 使用google authenticator(谷歌身份验证器)打造用户登录动态口令

    google authenticator php 服务端 使用php类 直接下载 https://github.com/PHPGangsta/GoogleAuthenticator/raw/maste ...

最新文章

  1. SWFupload 在(FF)火狐下报404错误的解决方案
  2. mysql单点故障_如何解决云服务商单点故障频发的问题?
  3. mysql主从同步-问题梳理
  4. java简单毕设_计算机毕业设计之自定义毕设课题需要如何确定工作量
  5. DataList绑定xml数据,并实现删除和修改
  6. 三星死守中国市场,强撑溢价难挽回颓势吗?
  7. 用java编写一个故事的程序_使用Java怎么编写一个递归程序
  8. 单机环境下(双机或是分布式系统不用考虑这个问题),app_offline.htm是个不错的选择...
  9. iOS开发UI篇—程序启动原理和UIApplication
  10. Django总叙(转)
  11. 电脑连接西门子S7-200CPU的步骤
  12. 微软服务器和onenote,为什么微软推出“阉割版”onenote?一篇文章快速入门onenote uwp...
  13. 使用VS2019将c#生成dll文件
  14. I/O模型(同步、非同步、阻塞、非阻塞)总结
  15. 山东大学项目实训设计系统(四)管理员端
  16. 微信开发 缓存Storage
  17. 装上后这 10个扩展后,VSCode 猛虎添翼,无敌了
  18. matlab 自动生成陷波滤波器算法实现
  19. 思博伦TestCenter Virtual上线AWS Marketplace, 可简化公有云和混合云测试
  20. Sublime Text 3安装及常用插件安装

热门文章

  1. 查看 java opts,如何在命令提示符中检查JAVA_OPTS值?
  2. 【光纤传输特性】图文并茂,你该了解这些
  3. 【python报错】Python词云库wordcloud不显示中文
  4. ubuntu resolution
  5. IP地址与DNS的作用,什么是IP地址与DNS【详细】
  6. 获取网站icon图标
  7. linux系统添加host,Linux:在系统上设置hostid?
  8. java:123321是一个非常特殊的数,它从左边读和从右边读是一样的。   输入一个正整数n, 编程求所有这样的五位和六位十进制数,满足各位数字之和等于n 。
  9. Linux系统压力测试工具stress
  10. C++ 设置字体颜色