前言
我们往往会在不同的网站上使用相同的密码,这样一旦一个网站账户的密码泄露,就会危及到其他使用相同密码的账户的安全,这也是最近的密码泄露事件造成如此大影响的原因。为了解决这个问题,一些网站在登录时要求除了输入账户密码之外,还需要输入另一个一次性密码。银行常用的动态口令卡就是这种一次性密码的例子,在线支付网站的一次性短信密码则是另一种实现。
此工具提供基于时间的一次性密码(Time-based One-time Password,简称TOTP) ,只需要在手机上安装密码生成应用程序,就可以生成一个随着时间变化的一次性密码,用于帐户验证,而且这个应用程序不需要连接网络即可工作。
工作原理
客户端和服务器事先协商好一个密钥K,用于一次性密码的生成过程,此密钥不被任何第三方所知道。此外,客户端和服务器各有一个计数器C,并且事先将计数值同步。
进行验证时,客户端对密钥和计数器的组合(K,C)使用HMAC(Hash-based Message Authentication Code)算法计算一次性密码,公式如下:
HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
上面采用了HMAC-SHA-1,当然也可以使用HMAC-MD5等。HMAC算法得出的值位数比较多,不方便用户输入,因此需要截断(Truncate)成为一组不太长十进制数(例如6位)。计算完成之后客户端计数器C计数值加1。用户将这一组十进制数输入并且提交之后,服务器端同样的计算,并且与用户提交的数值比较,如果相同,则验证通过,服务器端将计数值C增加1。如果不相同,则验证失败。
介绍完了HOTP,Time-based One-time Password(TOTP)也就容易理解了。TOTP将HOTP中的计数器C用当前时间T来替代,于是就得到了随着时间变化的一次性密码。非常有趣吧!
虽然原理很简单,但是用时间来替代计数器会有一些特殊的问题,这些问题也很有意思,我们选取几个进行一下探讨。
首先,时间T的值怎么选取?因为时间每时每刻都在变化,如果选择一个变化太快的T(例如从某一时间点开始的秒数),那么用户来不及输入密码。如果选择一个变化太慢的T(例如从某一时间点开始的小时数),那么第三方攻击者就有充足的时间去尝试所有可能的一次性密码(试想6位数字的一次性密码仅仅有10^6种组合),降低了密码的安全性。除此之外,变化太慢的T还会导致另一个问题。如果用户需要在短时间内两次登录账户,由于密码是一次性的不可重用,用户必须等到下一个一次性密码被生成时才能登录,这意味着最多需要等待59分59秒!这显然不可接受。综合以上考虑,Google选择了30秒作为时间片,T的数值为从Unix epoch(1970年1月1日 00:00:00)来经历的30秒的个数。
第二个问题是,由于网络延时,用户输入延迟等因素,可能当服务器端接收到一次性密码时,T的数值已经改变,这样就会导致服务器计算的一次性密码值与用户输入的不同,验证失败。解决这个问题个一个方法是,服务器计算当前时间片以及前面的n个时间片内的一次性密码值,只要其中有一个与用户输入的密码相同,则验证通过。当然,n不能太大,否则会降低安全性。
事实上,这个方法还有一个另外的功能。我们知道如果客户端和服务器的时钟有偏差,会造成与上面类似的问题,也就是客户端生成的密码和服务端生成的密码不一致。但是,如果服务器通过计算前n个时间片的密码并且成功验证之后,服务器就知道了客户端的时钟偏差。因此,下一次验证时,服务器就可以直接将偏差考虑在内进行计算,而不需要进行n次计算。
java中使用
引入依赖
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.10</version>
</dependency>
书写工具类( GoogleAuthenticator )
package com.cetc.common.util;
import org.apache.commons.codec.binary.Base32;
import org.apache.commons.codec.binary.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
public class GoogleAuthenticator {
// 来自谷歌文档,不用修改
public static final int SECRET_SIZE = 10;
// 产生密钥的种子
public static final String SEED = "N%U%cls36e7Ab!@#asd34nB4%9%Nmo2ai1IC9@54n06aY";
// 安全哈希算法(Secure Hash Algorithm)
public static final String RANDOM_NUMBER_ALGORITHM = "SHA1PRNG";
//可偏移的时间 -- 3*30秒的验证时间(客户端30秒生成一次验证码)
private static Integer window_size = 3;
/**
* 生成密钥
*
* @return
*/
public static String generateSecretKey() {
SecureRandom sr;
try {
sr = SecureRandom.getInstance(RANDOM_NUMBER_ALGORITHM);
sr.setSeed(Base64.decodeBase64(SEED));
byte[] buffer = sr.generateSeed(SECRET_SIZE);
Base32 codec = new Base32();
byte[] bEncodedKey = codec.encode(buffer);
return new String(bEncodedKey);
} catch (NoSuchAlgorithmException e) {
// should never occur... configuration error
}
return null;
}
/**
* 校验验证码
*
* @param secret
* @param code
* @param timeMsec
* @return
*/
public static Boolean check_code(String secret, long code, long timeMsec) {
Base32 codec = new Base32();
byte[] decodedKey = codec.decode(secret);
// convert unix msec time into a 30 second "window"
// this is per the TOTP spec (see the RFC for details)
long t = (timeMsec / 1000L) / 30L;
// Window is used to check codes generated in the near past.
// You can use this value to tune how far you're willing to go.
for (int i = -window_size; i <= window_size; ++i) {
long hash;
try {
hash = verify_code(decodedKey, t + i);
} catch (Exception e) {
// Yes, this is bad form - but
// the exceptions thrown would be rare and a static configuration problem
e.printStackTrace();
throw new RuntimeException(e.getMessage());
}
if (hash == code) {
return true;
}
}
// The validation code is invalid.
return false;
}
/**
* 生成验证码
*
* @param key
* @param t
* @return
* @throws NoSuchAlgorithmException
* @throws InvalidKeyException
*/
private static int verify_code(byte[] key, long t) throws NoSuchAlgorithmException, InvalidKeyException {
byte[] data = new byte[8];
long value = t;
for (int i = 8; i-- > 0; value >>>= 8) {
data[i] = (byte) value;
}
SecretKeySpec signKey = new SecretKeySpec(key, "HmacSHA1");
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(signKey);
byte[] hash = mac.doFinal(data);
int offset = hash[20 - 1] & 0xF;
// We're using a long because Java hasn't got unsigned int.
long truncatedHash = 0;
for (int i = 0; i < 4; ++i) {
truncatedHash <<= 8;
// We are dealing with signed bytes:
// we just keep the first byte.
truncatedHash |= (hash[offset + i] & 0xFF);
}
truncatedHash &= 0x7FFFFFFF;
truncatedHash %= 1000000;
return (int) truncatedHash;
}
}
生成验证码
如果用户是要进行绑定二次验证的话,使用 generateSecretKey() 方法生成密钥,返回给用户,用户使用这个密钥在验证器终端进行绑定,就可以获取六位数字。此方法返回的是字符串,用户只能手动输入的。也可以生成一个二维码,用验证器终端扫。
生成二维码的方式:
文件中打印:
String s = GoogleAuthenticator.generateSecretKey();
System.out.println(s);
System.out.println("otpauth://totp/" + "test@demo" + "?secret=" + s);
其中《test@demo》 可以用登录名@产品名称给用户固定好 (自己测试可通过在线转换二维码,然后通过谷歌验证器扫描二维码添加到应用中,比如:https://cn.online-qrcode-generator.com/)
校验验证码
直接调用 check_code 方法即可。参数分别为 密钥,六位验证码,当前时间戳。
check_code("GWBDN7GSV5OBFMPN", Long.valueOf("380168"), System.currentTimeMillis())
谷歌验证器下载方式
https://0xzx.com/2020121612001013817.html