# 两步验证
# 两步验证是什么?
大多数情境下,只要用户输入了账号密码,就判定为登录成功了。但是,如果用户的账号密码泄露的话,就会给不法分子可趁之机,因此,即使账号密码输入正确了,还要再次进行验证,来保证系统的安全。传统的方法是调用短信来验证,但是调用 API 给运营商发送短信是有成本的,而且也不一定成功,因此我们需要一种更加好的方法来完善这个认证,因此 OPT 就诞生了,页面首次登录时提示一个验证码,上面有着密钥,这个密钥只会出现一次,用于给验证器进行绑定,而且验证码会随着时间变化,因此可以保证安全,而且他是基于算法生成的,不需要联网。
TOTP (Time-Based One-time Password)
# OTP 验证 核心代码
package com.example.twofactorauthdemo;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Random;
public class TwoFactorAuthUtils {
private static final int VALID_TFA_WINDOW_MILLIS = 60_000;
/**
* 生成两步验证 Key
*
* @return 两步验证 Key
*/
public static String generateTFAKey() {
return TimeBasedOneTimePasswordUtil.generateBase32Secret(32);
}
/**
* 生成两步验证码
*
* @param tfaKey 两步验证 Key
* @return 两步验证码
*/
public static String generateTFACode(String tfaKey) {
try {
return TimeBasedOneTimePasswordUtil.generateCurrentNumberString(tfaKey);
} catch (GeneralSecurityException e) {
throw new RuntimeException("两步验证码生成异常");
}
}
/**
* 验证两步验证码
*
* @param tfaKey 两步验证 Key
* @param tfaCode 两步验证码
*/
public static void validateTFACode(String tfaKey, String tfaCode) {
try {
int validCode = Integer.parseInt(tfaCode);
boolean result = TimeBasedOneTimePasswordUtil
.validateCurrentNumber(tfaKey, validCode, VALID_TFA_WINDOW_MILLIS);
if (!result) {
throw new RuntimeException("两步验证码验证错误,请确认时间是否同步");
}
} catch (NumberFormatException e) {
throw new RuntimeException("两步验证码请输入数字");
} catch (GeneralSecurityException e) {
throw new RuntimeException("两步验证码验证异常");
}
}
/**
* 生成 Otp Auth Url
*
* @param userName 用户名
* @param tfaKey 两步验证 Key
* @return URL
*/
public static String generateOtpAuthUrl(final String userName, final String tfaKey) {
return TimeBasedOneTimePasswordUtil.generateOtpAuthUrl(userName, tfaKey);
}
static class TimeBasedOneTimePasswordUtil {
public static final int DEFAULT_TIME_STEP_SECONDS = 30;
private static final String BLOCK_OF_ZEROS;
private static final int NUM_DIGITS_OUTPUT = 6;
static {
char[] chars = new char[NUM_DIGITS_OUTPUT];
Arrays.fill(chars, '0');
BLOCK_OF_ZEROS = new String(chars);
}
public static String generateBase32Secret(int length) {
StringBuilder sb = new StringBuilder(length);
Random random = new SecureRandom();
for (int i = 0; i < length; i++) {
int val = random.nextInt(32);
if (val < 26) {
sb.append((char) ('A' + val));
} else {
sb.append((char) ('2' + (val - 26)));
}
}
return sb.toString();
}
public static boolean validateCurrentNumber(String base32Secret, int authNumber,
int windowMillis)
throws GeneralSecurityException {
return validateCurrentNumber(base32Secret, authNumber, windowMillis,
System.currentTimeMillis(),
DEFAULT_TIME_STEP_SECONDS);
}
public static boolean validateCurrentNumber(String base32Secret, int authNumber,
int windowMillis, long timeMillis,
int timeStepSeconds) throws GeneralSecurityException {
long fromTimeMillis = timeMillis;
long toTimeMillis = timeMillis;
if (windowMillis > 0) {
fromTimeMillis -= windowMillis;
toTimeMillis += windowMillis;
}
long timeStepMillis = timeStepSeconds * 1000L;
for (long millis = fromTimeMillis; millis <= toTimeMillis; millis += timeStepMillis) {
int generatedNumber = generateNumber(base32Secret, millis, timeStepSeconds);
if (generatedNumber == authNumber) {
return true;
}
}
return false;
}
public static String generateCurrentNumberString(String base32Secret)
throws GeneralSecurityException {
return generateNumberString(base32Secret, System.currentTimeMillis(),
DEFAULT_TIME_STEP_SECONDS);
}
public static String generateNumberString(String base32Secret, long timeMillis,
int timeStepSeconds)
throws GeneralSecurityException {
int number = generateNumber(base32Secret, timeMillis, timeStepSeconds);
return zeroPrepend(number);
}
public static int generateNumber(String base32Secret, long timeMillis, int timeStepSeconds)
throws GeneralSecurityException {
byte[] key = decodeBase32(base32Secret);
byte[] data = new byte[8];
long value = timeMillis / 1000 / timeStepSeconds;
for (int i = 7; value > 0; i--) {
data[i] = (byte) (value & 0xFF);
value >>= 8;
}
// encrypt the data with the key and return the SHA1 of it in hex
SecretKeySpec signKey = new SecretKeySpec(key, "HmacSHA1");
// if this is expensive, could put in a thread-local
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(signKey);
byte[] hash = mac.doFinal(data);
// take the 4 least significant bits from the encrypted string as an offset
int offset = hash[hash.length - 1] & 0xF;
// We're using a long because Java hasn't got unsigned int.
long truncatedHash = 0;
for (int i = offset; i < offset + 4; ++i) {
truncatedHash <<= 8;
// get the 4 bytes at the offset
truncatedHash |= hash[i] & 0xFF;
}
// cut off the top bit
truncatedHash &= 0x7FFFFFFF;
// the token is then the last 6 digits in the number
truncatedHash %= 1000000;
// this is only 6 digits so we can safely case it
return (int) truncatedHash;
}
public static String generateOtpAuthUrl(String keyId, String secret) {
StringBuilder sb = new StringBuilder(64);
addOtpAuthPart(keyId, secret, sb);
return sb.toString();
}
private static void addOtpAuthPart(String keyId, String secret, StringBuilder sb) {
sb.append("otpauth://totp/").append(keyId).append("?secret=").append(secret);
}
static String zeroPrepend(int num) {
String numStr = Integer.toString(num);
if (numStr.length() >= TimeBasedOneTimePasswordUtil.NUM_DIGITS_OUTPUT) {
return numStr;
} else {
StringBuilder sb = new StringBuilder(TimeBasedOneTimePasswordUtil.NUM_DIGITS_OUTPUT);
int zeroCount = TimeBasedOneTimePasswordUtil.NUM_DIGITS_OUTPUT - numStr.length();
sb.append(BLOCK_OF_ZEROS, 0, zeroCount);
sb.append(numStr);
return sb.toString();
}
}
static byte[] decodeBase32(String str) {
// each base-32 character encodes 5 bits
int numBytes = ((str.length() * 5) + 7) / 8;
byte[] result = new byte[numBytes];
int resultIndex = 0;
int which = 0;
int working = 0;
for (int i = 0; i < str.length(); i++) {
char ch = str.charAt(i);
int val;
if (ch >= 'a' && ch <= 'z') {
val = ch - 'a';
} else if (ch >= 'A' && ch <= 'Z') {
val = ch - 'A';
} else if (ch >= '2' && ch <= '7') {
val = 26 + (ch - '2');
} else if (ch == '=') {
// special case
which = 0;
break;
} else {
throw new IllegalArgumentException("Invalid base-32 character: " + ch);
}
/*
* There are probably better ways to do this but this seemed the most
* straightforward.
*/
switch (which) {
case 0:
// all 5 bits is top 5 bits
working = (val & 0x1F) << 3;
which = 1;
break;
case 1:
// top 3 bits is lower 3 bits
working |= (val & 0x1C) >> 2;
result[resultIndex++] = (byte) working;
// lower 2 bits is upper 2 bits
working = (val & 0x03) << 6;
which = 2;
break;
case 2:
// all 5 bits is mid 5 bits
working |= (val & 0x1F) << 1;
which = 3;
break;
case 3:
// top 1 bit is lowest 1 bit
working |= (val & 0x10) >> 4;
result[resultIndex++] = (byte) working;
// lower 4 bits is top 4 bits
working = (val & 0x0F) << 4;
which = 4;
break;
case 4:
// top 4 bits is lowest 4 bits
working |= (val & 0x1E) >> 1;
result[resultIndex++] = (byte) working;
// lower 1 bit is top 1 bit
working = (val & 0x01) << 7;
which = 5;
break;
case 5:
// all 5 bits is mid 5 bits
working |= (val & 0x1F) << 2;
which = 6;
break;
case 6:
// top 2 bits is lowest 2 bits
working |= (val & 0x18) >> 3;
result[resultIndex++] = (byte) working;
// lower 3 bits of byte 6 is top 3 bits
working = (val & 0x07) << 5;
which = 7;
break;
case 7:
// all 5 bits is lower 5 bits
working |= val & 0x1F;
result[resultIndex++] = (byte) working;
which = 0;
break;
default:
// should never happen
throw new IllegalArgumentException();
}
}
if (which != 0) {
result[resultIndex++] = (byte) working;
}
if (resultIndex != result.length) {
result = Arrays.copyOf(result, resultIndex);
}
return result;
}
}
}
TwoFactorAuthUtils.generateTFAKey()
生成两步验证 KeyTwoFactorAuthUtils.generateTFACode(tfaKey)
生成两步验证码TwoFactorAuthUtils.validateTFACode(tfaKey,tfaCode)
验证两步验证码TwoFactorAuthUtils.generateOtpAuthUrl(userName, tfaKey)
生成 Otp Auth Url
# 两步验证应用
Authy 功能丰富 专为两步验证码 iOS/Android/Windows/Mac/Linux (opens new window) Chrome 扩展 (opens new window)
Google Authenticator 简单易用,但不支持密钥导出备份 iOS (opens new window) Android (opens new window)
Microsoft Authenticator 使用微软全家桶的推荐 iOS/Android (opens new window)
1Password 强大安全的密码管理付费应用 iOS/Android/Windows/Mac/Linux/ChromeOS (opens new window)
腾讯身份验证码 简单好用 Android (opens new window)
参考文献: