最近公司新进项目需求:谷歌身份验证器来做一个二次因素校验,查了很多网上的例子,也自己写了demo,其中遇到了些问题,记录一下:
1.谷歌身份验证器原理简单说明
2.谷歌身份验证器的使用流程
3.谷歌身份验证器使用需要引入的jar包
4.谷歌身份验证器server端代码
5.谷歌身份验证器test端代码
6.谷歌身份验证器server端校验code要求传long型数据,如果验证码为0023456这种情况的解决
引用谷歌身份验证器,可以直接百度就可以下载了,应用商店中不一定会有这个软件,至少我用的华为就没搜索到,直接手机浏览器搜索下载即可。
谷歌身份验证器不需要引入谷歌的API来实现,主要是通过加密算法来实现的身份统一。
手机端需要拿到用户的secret密钥,从而计算得出6位的动态验证码。这个获取密钥的途径可以通过两种场景实现:
1)服务端产生加密密钥并生成二维码展示给用户,用户直接扫码即可识别密钥并产生动态验证码的服务
2)直接展示给用户密钥串,用户在手机端主动输入
通过这两种方式都可以将用户的secret记到当前设备上。
具体原理就是:客户端和服务端使用相同的密钥,相同的算法,最终在验证时,因为可能会存在时差的问题,服务端实际上是校验了大约四组数据与当前输入的6位动态验证码,有一组相同即认为是通过了验证。
这是我理解的流程;
commons-codec
commons-codec
1.12
主要是这个jar包,jdk用的是1.8版本的,低版本的没试过。
package com.yu.test.controller;
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 GoogleGenerator {
// 生成的key长度( Generate secret key length)
public static final int SECRET_SIZE = 10;
public static final String SEED = "22150146801713967E8g";
// Java实现随机数算法
public static final String RANDOM_NUMBER_ALGORITHM = "SHA1PRNG";
// 最多可偏移的时间
int window_size = 3; // default 3 - max 17
public static String generateSecretKey ( ) {
SecureRandom sr = null;
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 );
String encodedKey = new String ( bEncodedKey );
return encodedKey;
} catch (NoSuchAlgorithmException e) {
// should never occur... configuration error
}
return null;
}
/**
* 根据user和secret生成二维码的密钥
*
* @param user
* @param host
* @param secret
* @return
*/
public static String getQRBarcodeURL ( String user , String host , String secret ) {
String format = "http://www.google.com/chart?chs=200x200&chld=M%%7C0&cht=qr&chl=otpauth://totp/%s@%s?secret=%s";
return String.format ( format , user , host , secret );
}
/**
* 这个format不可以修改,身份验证器无法识别二维码
*
* @param user
* @param secret
* @return
*/
public static String getQRBarcode ( String user , String secret ) {
String format = "otpauth://totp/%s?secret=%s";
return String.format ( format , user , secret );
}
public boolean check_code ( String secret , String 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 () );
// return false;
}
System.out.println ( "code=" + code );
System.out.println ( "hash=" + hash );
if (code.equals ( addZero ( hash ) )) {
return true;
}
/* if (code==hash ) {
return true;
}*/
}
// The validation code is invalid.
return false;
}
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;
}
/*private String addZero ( long code ) {
System.out.println ( "addZero code:" + code );
String codeString = String.valueOf ( code );
System.out.println ( "addZero codeString" + codeString );
int codeLength = codeString.length ();
StringBuffer sb = new StringBuffer ( codeString );
if (codeLength < 6) {
for (int i = 0; i < (6 - codeLength); i++) {
sb.insert ( 0 , "0" );
}
}
return sb.toString ();
}*/
这部分做了改进
private String addZero ( long code ) {
return String.format ( "%06d",code );
}
}
这部分代码大部分都和网上一样,不过网上的实例都是以long型来传递code值,很可能出现023456,000234等这种情况,这会导致功能的异常,这部分会在第五点详细说明。
server部分主要是3个方法:
1.生成secret的方法
2.生成二维码QRCode的方法
3.校验动态验证码是否正确的verify方法
当然加了一个数据转换的方法
private static String secret = "543X2ILASPYIZ2MN";
@Test
public void testGenerator(){
secret = GoogleGenerator.generateSecretKey ();
String qrCode = GoogleGenerator.getQRBarcode ( "helloz",secret );
System.out.println ( "qrCode="+qrCode+";key="+secret );
}
@Test
public void testValidCode(){
String code = "678785";
long time = System.currentTimeMillis ();
GoogleGenerator g = new GoogleGenerator ();
boolean result = g.check_code ( secret,code,time );
System.out.println ( result );
}
test使用junit写的测试,第一个是生成secret的方法,同时也输出了二维码页面的字符串,可以找个网页在线生成二维码的生成一个扫描就是了。
再就是验证验证码是否有效的方法了,这部分要记得使用刚刚生成的secret,secret才是校验的灵魂与核心。
网上的大多数实例都采用long型传递code参数,在遇到002345这种口令的时候,会有各种问题,这边改造了方法,要求传入参数使用String,这样00的位不会丢失,但是后端校验加密出来的串hash是long型有可能会出现023232这种数据,从而会丢失位数,或校验不准。所以写了个方法用0来补位。试用了一下String.format的方式来补位,还是很好用的。
以上大部分来自于网络上共用的东西,在开发中加入了些自己的理解。
有问题可以一起讨论:QQ740273040