一、介绍
基于TOTP实现多因素认证,这其实就是登录时候的一个验证码,由于国内用的主流的是手机号短信验证,所以对这种方式了解的较少。目前github的登录验证集成了这种验证方式,可以上去把玩一下,更熟悉流程。
使用流程: 比如github,找到github设置页面,绑定手机app验证这个东西,他会给你一个QR码,你使用Microsoft Authenticator 或者Google Authenticator这种app扫描QR码之后,输入app显示的验证码,进行github身份绑定。下次登陆时,除了输入账号密码外,还需要打开Microsoft Authenticator APP,找到该账号的6位验证码(30秒一刷新),输入到github网站即可验证登录成功。
实现流程: 对于你的网站想要集成这个功能,你需要考虑这些:
1. 生成QR码供用户扫描绑定
2. 验证TOTP验证码是否正确
3. 提供恢复渠道(当用户无法使用设备时, 通过输入恢复码即可登录)
当然,对应的前端页面也要有,比如绑定页面,恢复码的展示页面,还要提供下载,让用户更方便保存。
二、实现
这个东西主要就是:生成个密钥给Microsoft Authenticator APP ,他会根据密钥和当前的时间,来生成六位数的验证码,那么你肯定要将密钥保存好,在代码中根据相同的TOTP算法,也生成一个六位数的验证码。当用户查看app的验证码,输入后,能够和你生成的验证码匹配上,即验证通过,登录成功。
下面是实现代码,其实主要就是工具类,一个用于MFA所有相关的生成,一个是AES加密工具类,剩下的就是和你的业务相关,你可以自己设计。
①你可以使用getQrCode方法生成QR码给用户扫,同时将密钥和用户绑定在一起存入数据库;
②你可以使用verifyCode方法来验证用户输入的验证码是否正确(将数据库用户的密钥和用户输入的验证码当参数传入,ps:你数据库的密钥和Microsoft Authenticator APP上的密钥是一致的,因为扫QR码时候已经给你扫走了,所以能够验证 验证码的正确性)。
③你可以使用generateRecoverKey方法生成一组恢复码,给用户提供备用登录渠道。
④当然 这些密钥和恢复码等敏感的东西,都可以使用AesEncryptionUtil中的算法加密,(你可以使用generateEncryptionKey生成一个加密密钥和用户进行绑定)
至于登录时候的逻辑去验证一下app的验证码或者数据库的恢复码就行。
1.引包:
<!-- ZXing Dependency -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.4.1</version>
</dependency>
2.我的两个工具类(核心:‘生成QR码’和‘验证TOTP验证码’):
① MFA工具类
import com.google.zxing.BarcodeFormat;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.ttap.framework.exception.BusinessException;
import org.apache.commons.codec.binary.Base32;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class MfaUtil {
private static final String QR_TITLE = "APPLICATION NAME";
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz";
private static final int TIME_STEP = 30; // 时间步长
private static final int TOTP_LENGTH = 6; // TOTP密钥长度
private static final int CODE_LENGTH = 5;
private static final int RECOVER_CODE_GROUP_LENGTH = 10;
/**
* 生成18位的TOTP密钥,需要放到QR码中
*
* @return length 18, example: IPJDUKPNDCTFVJ4DQJ
*/
public static String generateSecretKey() {
final int KEY_SIZE_BYTES = 20;
byte[] bytes = new byte[KEY_SIZE_BYTES];
SecureRandom random = new SecureRandom();
random.nextBytes(bytes);
Base32 base32 = new Base32();
return base32.encodeToString(bytes).substring(0, 18);
}
/**
* 生成加密密钥,用于对数据库的关键数据进行加密(TOTP验证码或恢复码)
*
* @return length 32, example: MDKA6Z76W7RC6QYUF3S26JSJ6VMEBAGD
*/
public static String generateEncryptionKey() {
final int KEY_SIZE_BYTES = 20;
byte[] bytes = new byte[KEY_SIZE_BYTES];
SecureRandom random = new SecureRandom();
random.nextBytes(bytes);
Base32 base32 = new Base32();
return base32.encodeToString(bytes);
}
/**
* 生成一组10个恢复码
*
* @return example: [q51cA-R041w, fg7Fs-R701a ...]
*/
public static List<String> generateRecoverKeys() {
return IntStream.range(0, RECOVER_CODE_GROUP_LENGTH)
.mapToObj(i -> generateRecoverKey())
.collect(Collectors.toList());
}
/**
* 生成一个恢复码
*
* @return example: q51cA-R041w
*/
private static String generateRecoverKey() {
SecureRandom random = new SecureRandom();
StringBuilder code = new StringBuilder(CODE_LENGTH * 2 + 1);
for (int i = 0; i < CODE_LENGTH; i++) {
code.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
}
code.append("-");
for (int i = 0; i < CODE_LENGTH; i++) {
code.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
}
return code.toString();
}
/**
* 生成QR码,返回的base64,里面包含用户名和密钥信息
*
* @param loginName
* @param secretKey
* @return qr base64 code
*/
public static String getQrCode(String loginName, String secretKey) {
String base64Image = null;
try {
// 1.Generate QR code content
String qrCodeText = getQrCodeText(secretKey, loginName, QR_TITLE);
int width = 200;
int height = 200;
try {
// Convert the URL to BitMatrix
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(qrCodeText, BarcodeFormat.QR_CODE, width, height);
// 2.Generate QR code image
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(bitMatrix, "png", outputStream);
// 3.Gets the byte array of the image and converts it to a string using Base64 encoding
byte[] imageData = outputStream.toByteArray();
base64Image = java.util.Base64.getEncoder().encodeToString(imageData);
return base64Image;
} catch (WriterException e) {
throw new BusinessException("Failed to generate QR code image.");
} catch (Exception e) {
throw new BusinessException("Unexpected error:" + e.getMessage());
}
} catch (BusinessException e) {
throw new BusinessException("Abnormal QR code generation:{0}", e.getMessage());
}
}
/**
* 设置QR码中的信息:密钥、用户名等
*
* @param secretKey
* @param account
* @param issuer
* @return
*/
private static String getQrCodeText(String secretKey, String account, String issuer) {
String normalizedBase32Key = secretKey.replace(" ", "").toUpperCase();
String url = null;
url = "otpauth://totp/"
+ URLEncoder.encode((!issuer.isEmpty() ? (issuer + ":") : "") + account, StandardCharsets.UTF_8).replace("+", "%20")
+ "?secret=" + URLEncoder.encode(normalizedBase32Key, StandardCharsets.UTF_8).replace("+", "%20")
+ (!issuer.isEmpty() ? ("&issuer=" + URLEncoder.encode(issuer, StandardCharsets.UTF_8).replace("+", "%20")) : "");
return url;
}
/**
* TOTP验证验证码是否正确,用处:用户登录时需要输入验证码进行验证。
* 该验证码来自于APP,将该用户输入的验证码和该用户的TOTP密钥传入这个方法,就能知道用户输入的是否正确。
*
* @param secret 某个用户的TOTP密钥
* @param code 该用户输入的验证码
* @return
*/
public static boolean verifyCode(String secret, String code) {
long timeWindow = Instant.now().getEpochSecond() / TIME_STEP;
Base32 base32 = new Base32();
byte[] secretBytes = base32.decode(secret);
try {
Mac mac = Mac.getInstance("HmacSHA1");
SecretKeySpec key = new SecretKeySpec(secretBytes, "HmacSHA1");
mac.init(key);
byte[] hash = mac.doFinal(longToBytes(timeWindow));
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 % (int) Math.pow(10, TOTP_LENGTH);
return otp == Integer.parseInt(code);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
e.printStackTrace();
return false;
}
}
private static byte[] longToBytes(long value) {
byte[] result = new byte[8];
for (int i = 7; i >= 0; i--) {
result[i] = (byte) (value & 0xFF);
value >>= 8;
}
return result;
}
}
②AES加密工具类:
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public class AesEncryptionUtil {
public static String encryptAES(String data, String secret) {
try {
SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] encryptedBytes = cipher.doFinal(data.getBytes());
return Base64.getUrlEncoder().withoutPadding().encodeToString(encryptedBytes);
} catch (Exception e) {
e.printStackTrace();
}
return "";
}
public static String decryptAES(String encryptedData, String secret) {
try {
SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, secretKey);
byte[] decryptedBytes = cipher.doFinal(Base64.getUrlDecoder().decode(encryptedData));
return new String(decryptedBytes);
} catch (Exception e) {
e.printStackTrace();
}
return "";
}
}
3. 建表字段参考:
这个表和业务相关的,就是让你参考一下,可以自行设计,主要就是这些字段。
# 用户和totp密钥对应的表
CREATE TABLE user_mfa_secret (
"id" int8 NOT NULL,
"user_id" int8,
"secret_key" varchar(100), // TOTP密钥
"refresh_secret_key" varchar(100), // TOTP临时密钥。因为每次刷新QR码都需要有一个临时密钥,当用户确认绑定时将该临时密钥赋值到TOTP密钥字段。
"is_enable" int4, // 用户是否启用TOTP的MFA验证
"encryption_key" varchar(100), // 加密密钥。恢复码和TOTP密钥不能明文存储在数据库,我将需要加密的东西和此key混合进行AES算法加密
"created_at" timestamp(6),
"updated_at" timestamp(6),
PRIMARY KEY ("id")
);
# 用户和恢复码对应的表(恢复码不和密钥绑定,而是跟随用户)
CREATE TABLE user_mfa_recover (
"id" int8 NOT NULL,
"user_id" int8,
"recover_key" varchar(100) , // 恢复码,用户不能够使用TOTP的验证码登录时,使用这个登录。
"is_used" int4,
"version" int4, // 恢复码的版本,由于我的业务,我设置了 当一组十个恢复码使用完毕后,自动生成新的一组恢复码,用版本进行了区分。
"created_at" timestamp(6),
"updated_at" timestamp(6),
PRIMARY KEY ("id")
);
其他
QR码的绑定图示: