谷歌二步身份验证
前言
现在随处登录都需要填写验证码或者点击相关字符等等模式,此文根据大佬的灵感,结合springboot完成谷歌二次身份验证。此处大佬地址。
一、谷歌二次身份验证是什么?
(此图来源)
谷歌二次身份验证的目的是什么想必大家都清楚,此app类似于steam令牌、企鹅令牌等等,在输入密码登录中的时候,拉取接口完成验证码的校验。
二、使用步骤
1.maven依赖(若不想后台直接生成二维码可不导)
<!-- 二维码依赖 -->
<dependency>
<groupId>org.iherus</groupId>
<artifactId>qrext4j</artifactId>
<version>${qrext4j.version}</version>
</dependency>
2.工具类代码
工具类:GoogleAuthenticator 提供了生成谷歌秘钥key、校验验证码的功能。
代码如下:
import org.apache.commons.codec.binary.Base32;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.binary.Hex;
import org.springframework.util.StringUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
/**
* 谷歌身份验证器工具类
*/
public class GoogleAuthenticator {
/**
* 时间前后偏移量
* 用于防止客户端时间不精确导致生成的TOTP与服务器端的TOTP一直不一致
* 如果为0,当前时间为 10:10:15
* 则表明在 10:10:00-10:10:30 之间生成的TOTP 能校验通过
* 如果为1,则表明在
* 10:09:30-10:10:00
* 10:10:00-10:10:30
* 10:10:30-10:11:00 之间生成的TOTP 能校验通过
* 以此类推
*/
private static int WINDOW_SIZE = 0;
/**
* 加密方式,HmacSHA1、HmacSHA256、HmacSHA512
*/
private static final String CRYPTO = "HmacSHA1";
/**
* 生成密钥,每个用户独享一份密钥
*
* @return
*/
public static String getSecretKey() {
SecureRandom random = new SecureRandom();
/*密钥太长不容易输入*/
// byte[] bytes = new byte[20];
/*密钥太短不符合gooleAPP*/
// byte[] bytes = new byte[5];
/*生成16位秘钥*/
byte[] bytes = new byte[10];
random.nextBytes(bytes);
Base32 base32 = new Base32();
String secretKey = base32.encodeToString(bytes);
// make the secret key more human-readable by lower-casing and
// inserting spaces between each group of 4 characters
return secretKey.toUpperCase();
}
/**
* 生成二维码内容
*
* @param secretKey 密钥
* @param account 账户名
* @param issuer 网站地址(可不写)
* @return
*/
public static String getQrCodeText(String secretKey, String account, String issuer) {
String normalizedBase32Key = secretKey.replace(" ", "").toUpperCase();
try {
String s = "otpauth://totp/"
+ URLEncoder.encode((!StringUtils.isEmpty(issuer) ? (issuer + ":") : "") + account, "UTF-8").replace("+", "%20")
+ "?secret=" + URLEncoder.encode(normalizedBase32Key, "UTF-8").replace("+", "%20")
+ (!StringUtils.isEmpty(issuer) ? ("&issuer=" + URLEncoder.encode(issuer, "UTF-8").replace("+", "%20")) : "");
System.out.println(s);
return "otpauth://totp/"
+ URLEncoder.encode((!StringUtils.isEmpty(issuer) ? (issuer + ":") : "") + account, "UTF-8").replace("+", "%20")
+ "?secret=" + URLEncoder.encode(normalizedBase32Key, "UTF-8").replace("+", "%20")
+ (!StringUtils.isEmpty(issuer) ? ("&issuer=" + URLEncoder.encode(issuer, "UTF-8").replace("+", "%20")) : "");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException(e);
}
}
/**
* 获取验证码
*
* @param secretKey
* @return
*/
public static String getCode(String secretKey) {
String normalizedBase32Key = secretKey.replace(" ", "").toUpperCase();
Base32 base32 = new Base32();
byte[] bytes = base32.decode(normalizedBase32Key);
String hexKey = Hex.encodeHexString(bytes);
long time = (System.currentTimeMillis() / 1000) / 30;
String hexTime = Long.toHexString(time);
return TOTP.generateTOTP(hexKey, hexTime, "6", CRYPTO);
}
/**
* 检验 code 是否正确
*
* @param secret 密钥
* @param code code
* @param time 时间戳
* @return
*/
public static boolean checkCode(String secret, long code, long time) {
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 = (time / 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.
long hash;
for (int i = -WINDOW_SIZE; i <= WINDOW_SIZE; ++i) {
try {
hash = verifyCode(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;
}
}
return false;
}
/**
* 根据时间偏移量计算
*
* @param key
* @param t
* @return
* @throws NoSuchAlgorithmException
* @throws InvalidKeyException
*/
private static long verifyCode(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, CRYPTO);
Mac mac = Mac.getInstance(CRYPTO);
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 truncatedHash;
}
}
工具类:TOTP 验证码生成工具类
代码如下:
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.lang.reflect.UndeclaredThrowableException;
import java.math.BigInteger;
import java.security.GeneralSecurityException;
/**
* 验证码生成工具类
*/
public class TOTP {
// 0 1 2 3 4 5 6 7 8
private static final int[] DIGITS_POWER = {1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000};
/**
* This method uses the JCE to provide the crypto algorithm. HMAC computes a
* Hashed Message Authentication Code with the crypto hash algorithm as a
* parameter.
*
* @param crypto : the crypto algorithm (HmacSHA1, HmacSHA256, HmacSHA512)
* @param keyBytes : the bytes to use for the HMAC key
* @param text : the message or text to be authenticated
*/
private static byte[] hmac_sha(String crypto, byte[] keyBytes, byte[] text) {
try {
Mac hmac;
hmac = Mac.getInstance(crypto);
SecretKeySpec macKey = new SecretKeySpec(keyBytes, "RAW");
hmac.init(macKey);
return hmac.doFinal(text);
} catch (GeneralSecurityException gse) {
throw new UndeclaredThrowableException(gse);
}
}
/**
* This method converts a HEX string to Byte[]
*
* @param hex : the HEX string
* @return: a byte array
*/
private static byte[] hexStr2Bytes(String hex) {
// Adding one byte to get the right conversion
// Values starting with "0" can be converted
byte[] bArray = new BigInteger("10" + hex, 16).toByteArray();
// Copy all the REAL bytes, not the "first"
byte[] ret = new byte[bArray.length - 1];
System.arraycopy(bArray, 1, ret, 0, ret.length);
return ret;
}
/**
* This method generates a TOTP value for the given set of parameters.
*
* @param key : the shared secret, HEX encoded
* @param time : a value that reflects a time
* @param returnDigits : number of digits to return
* @param crypto : the crypto function to use
* @return: a numeric String in base 10 that includes
*/
public static String generateTOTP(String key, String time, String returnDigits, String crypto) {
int codeDigits = Integer.decode(returnDigits);
String result = null;
// Using the counter
// First 8 bytes are for the movingFactor
// Compliant with base RFC 4226 (HOTP)
while (time.length() < 16) {
time = "0" + time;
}
// Get the HEX in a Byte[]
byte[] msg = hexStr2Bytes(time);
byte[] k = hexStr2Bytes(key);
byte[] hash = hmac_sha(crypto, k, msg);
// put selected bytes into result int
int offset = hash[hash.length - 1] & 0xf;
int binary = ((hash[offset] & 0x7f) << 24)
| ((hash[offset + 1] & 0xff) << 16)
| ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff);
int otp = binary % DIGITS_POWER[codeDigits];
result = Integer.toString(otp);
while (result.length() < codeDigits) {
result = "0" + result;
}
return result;
}
}
三、业务逻辑
整体大致逻辑如图
- 密码正确后的是否需要二次验证校验
已经有了工具类和依赖包,现在开始着手业务逻辑了。因为公司系统已完善用户权限,也具有相应的库,所以我个人的操作就是直接查询数据库的权限表, 根据数据进行权限二次校验(需求为个别权限用户需要二次身份验证),此处“admin”可以理解为管理员,但是实际开发中建议用常亮隐藏代替。
// 判断是否需要二次校验
List<Integer> userPermission = userRoleService.checkPermission(uaUserBean);
// 判断角色权限
if (!userPermission.contains(admin)) {
// 角色权限不足,需要二次校验 则返回二维码内容
String qrcode = UserService.getQrcode(uaUserBean);
uafLoginRespVo.setQrcode(qrcode);
}
以上代码只返回了二维码内容,生成策略和前端对齐后是由前端进行生成。若后端生成则要导入依赖,利用SimpleQrcodeGenerator方法进行生成,代码如下:
/**
* 生成二维码,APP直接扫描绑定,两种方式任选一种
*/
@GetMapping("getQrcode")
public void getQrcode(@RequestParam("name") String name, HttpServletResponse response) throws Exception {
String key = GoogleAuthenticator.getSecretKey();
// 生成二维码内容
String qrCodeText = GoogleAuthenticator.getQrCodeText(key, name, "");
// 生成二维码输出
new SimpleQrcodeGenerator().generate(qrCodeText).toStream(response.getOutputStream());
}
- 验证码校验
验证码校验需要根据生成的key来获取,代码如下:
@PostMapping("/checkCode")
public BaseRespVo checkCode(@RequestBody LoginCode LoginCode) {
// 获取secretKey
if (Objects.isNull(LoginCode.getUserId())) {
throw new CheckException("参数id不能为空");
}
if (StringUtils.isBlank(uaLoginCode.getCode())){
throw new CheckException("验证码不能为空");
}
String secretKey = UserDao.selectBySecretKey(LoginCode.getUserId());
// 校验验证码
boolean flag = GoogleAuthenticator.checkCode(secretKey, Long.parseLong(LoginCode.getCode()), System.currentTimeMillis());
if (!flag){
return ServerUtils.responseFail(ServerException.ERR_1013,"验证码错误,请重新输入",null);
}else {
return ServerUtils.responseSuccess();
}
}
总结
非常感谢前人的指导,没有大家的文章深度也就没有现在后人如此简单的学习和实践。