Java实现 OTP动态口令
2025-03-10 本文已影响0人
饱饱抓住了灵感
一、概述
OTP(One-Time Password)动态口令是一种基于时间戳算法的一次性密码,通常每30或60秒产生一个新口令,要求客户端和服务器能够保持一致的时间来进行验证。OTP动态口令的认证原理是基于共享密钥和时间戳算法,确保每次生成的口令都是唯一的,使用后即作废,从而提高了安全性。
OTP动态口令的实现方式主要有两种:
- TOTP(Time-Based One-Time Password Algorithm):基于时间戳的一次性密码,客户端和服务器必须能够彼此知道或者推算出对方的Unix时间。Internet工程任务组标准RFC 6238
- HOTP(HMAC-Based One-Time Password Algorithm):基于HMAC的一次性密码,客户端和服务器事先协商好一个密钥,并各自有一个计数器,通过HMAC算法生成一次性密码。
OTP动态口令的应用场景
OTP动态口令广泛应用于多种系统渠道,如Web应用、手机应用、电话应用、ATM自助终端等。由于其简单易用且与系统集成良好,OTP动态口令技术在网络安全管理中得到广泛应用,可以有效降低帐号被冒用、盗用等风险。
OTP动态口令与静态口令的比较
与传统静态口令相比,OTP动态口令具有以下优势:
- 安全性更高:每次生成的口令都是唯一的,使用后即作废,难以被猜测和破解。
- 防截获:由于口令是动态生成的,黑客难以通过网上或电话线截获静态密码。
- 防内部泄露:由于口令是动态的,内部工作人员无法通过合法授权取得用户密码而非法使用。
OTP动态口令的不足
尽管OTP动态口令具有较高的安全性,但其也存在一些不足:
依赖时间同步:客户端和服务器必须能够保持一致的时间,否则无法生成正确的动态口令。
设备依赖:需要使用专门的动态口令生成设备或应用程序,增加了用户的设备负担。
二、TOTP实现
Java
import org.apache.commons.codec.binary.Base32;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.time.DateFormatUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@Component
public class OtpUtil {
/** 时间步长(秒) */
private static final int PERIOD = 30;
/** OtpCode 长度 */
private static final int DIGITS = 6;
/** 哈希算法,SHA1,SHA256,SHA512 */
private static final String ALGORITHM = "SHA256";
/** 时间容错范围, 0:不容错, 1:前后30秒, 2:前后1分钟 ... 最大值见 {@link #timeErrorRangeMax} */
@Value("${login.otp.timeErrorRange:2}")
private static final int timeErrorRange = 2;
/** 时间容错范围最大值 */
private static final int timeErrorRangeMax = 6;
/** 开启错误过多封禁 */
private static final boolean isOpenBlock = false;
/** 最大尝试次数 */
private static final int MAX_ATTEMPTS = 5;
/** 封锁时间(毫秒) */
private static final long BLOCK_DURATION = 300_000;
/** 尝试次数 */
private static final ConcurrentHashMap<String, AtomicInteger> attemptCounter = new ConcurrentHashMap<>();
/** 解禁时间 */
private static final ConcurrentHashMap<String, Long> blockedUsers = new ConcurrentHashMap<>();
// 生成的key长度( Generate secret key length)
private static final int SECRET_SIZE = 10;
// 随机数生成器的起始值
private static final String SEED = "8ff7425cc8344021b7f386c1241e87b3";
// Java实现随机数算法
private static final String RANDOM_NUMBER_ALGORITHM = "SHA1PRNG";
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;
}
/**
* 获取要生成二维码的Otp地址
* @param user
* @param secret
* @return
*/
public static String getOtpAuthQrcode(String user, String secret) {
return QRCodeGenerator.genQRCodeBase64(getOtpAuthUrl(user, secret));
}
public static String getOtpAuthUrl(String user, String secret) {
String format = "otpauth://totp/%s?secret=%s&algorithm=%s&digits=%d&period=%d";
return String.format(format, user, secret, ALGORITHM, DIGITS, PERIOD);
}
/**
* 生成 TOTP
*/
public static String generateTOTP(String secret) throws Exception {
long timeIndex = Instant.now().getEpochSecond() / PERIOD;
return generateOtp(secret, timeIndex);
}
/**
* 验证OtpCode
*/
public static boolean validateTOTP(String secret, String otpCode, String userName) throws Exception {
checkIsBlocked(userName);
long timeIndex = Instant.now().getEpochSecond() / PERIOD;
// 在验证窗口内检查 OTP
boolean isPass = false;
// 容错范围, 0表达当前时间的结果, -1退后30秒的结果, -2退后1分钟, 1前进30秒的结果, 2前进1分钟的结果
int end = Math.min(timeErrorRange, timeErrorRangeMax);
int start = -end;
for (int i = start; i <= end; i++) {
String generatedOtp = generateOtp(secret, timeIndex + i);
// System.out.println(i + "次: generatedOtp = " + generatedOtp);
if (generatedOtp.equals(otpCode)) {
isPass = true;
}
}
if (isPass) {
clearAttempts(userName);
} else {
recordFailedAttempt(userName);
}
return isPass;
}
private static String generateOtp(String secret, long timeIndex) throws Exception {
// 解码 Base32 密钥
Base32 base32 = new Base32();
byte[] keyBytes = base32.decode(secret);
// 转换时间索引为字节数组
byte[] timeBytes = ByteBuffer.allocate(8).putLong(timeIndex).array();
// 使用 HMAC-SHA1 生成哈希
Mac mac = Mac.getInstance("Hmac" + ALGORITHM);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "Hmac" + ALGORITHM);
mac.init(keySpec);
byte[] hash = mac.doFinal(timeBytes);
// 提取动态偏移量
int offset = hash[hash.length - 1] & 0x0F;
int binary = ((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8) | (hash[offset + 3] & 0xFF);
// 生成 OTP
int otp = binary % (int) Math.pow(10, DIGITS);
return String.format("%0" + DIGITS + "d", otp);
}
private static void recordFailedAttempt(String userId) {
if (!isOpenBlock) {
return;
}
attemptCounter.putIfAbsent(userId, new AtomicInteger(0));
int attempts = attemptCounter.get(userId).incrementAndGet();
if (attempts >= MAX_ATTEMPTS) {
blockedUsers.put(userId, System.currentTimeMillis() + BLOCK_DURATION);
}
}
private static void clearAttempts(String userId) {
if (!isOpenBlock) {
return;
}
attemptCounter.remove(userId);
blockedUsers.remove(userId);
}
private static void checkIsBlocked(String userId) {
if (!isOpenBlock) {
return;
}
if (!blockedUsers.containsKey(userId)) {
return;
}
Long blockTime = blockedUsers.get(userId);
if (System.currentTimeMillis() > blockTime) {
blockedUsers.remove(userId);
return;
}
throw new RuntimeException(String.format("%s错误次数过多已被封禁,请在%s之后重试", userId, DateFormatUtils.format(blockTime, "yyyy-MM-dd HH:mm:ss")));
}
}
生成二维码工具类
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
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.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class QRCodeGenerator {
private static final int width = 350; // 二维码的宽度
private static final int height = 350; // 二维码的高度
private static void generateQRCodeImage(String text, String filePath) throws WriterException, IOException {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.L); // 设置纠错等级,L为最低,M为中等,Q为较高,H为最高
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); // 设置字符编码为UTF-8
BitMatrix bitMatrix = qrCodeWriter.encode(text, BarcodeFormat.QR_CODE, width, height, hints);
Path path = FileSystems.getDefault().getPath(filePath);
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", path); // 输出图片格式为PNG,也可以选择其他格式如JPG等
}
// 你要编码的文本,例如一个网址
public static String genQrCodeImg(String text, String filePath) {
try {
// 输出文件的路径和文件名
generateQRCodeImage(text, filePath);
return filePath;
} catch (WriterException | IOException e) {
System.err.println("Could not generate QR Code, WriterException | IOException :" + e.getMessage());
return "";
}
}
public static String genQRCodeBase64(String text) {
try {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.L); // 设置纠错等级,L为最低,M为中等,Q为较高,H为最高
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); // 设置字符编码为UTF-8
BitMatrix bitMatrix = qrCodeWriter.encode(text, BarcodeFormat.QR_CODE, 400, 400, hints);
BufferedImage image = MatrixToImageWriter.toBufferedImage(bitMatrix);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ImageIO.write(image, "png", byteArrayOutputStream);
byte[] bytes = byteArrayOutputStream.toByteArray();
String base64 = Base64.getEncoder().encodeToString(bytes);
return "data:image/png;base64," + base64;
} catch (WriterException | IOException e) {
throw new RuntimeException(e);
}
}
}