feat: 新增 TOTP 实现

This commit is contained in:
click33 2025-04-02 23:08:47 +08:00
parent a4c18c5238
commit f75ab31222
6 changed files with 357 additions and 1 deletions

View File

@ -30,6 +30,7 @@ import cn.dev33.satoken.listener.SaTokenEventCenter;
import cn.dev33.satoken.log.SaLog;
import cn.dev33.satoken.log.SaLogForConsole;
import cn.dev33.satoken.same.SaSameTemplate;
import cn.dev33.satoken.secure.totp.SaTotpTemplate;
import cn.dev33.satoken.serializer.SaSerializerTemplate;
import cn.dev33.satoken.serializer.impl.SaSerializerTemplateForJson;
import cn.dev33.satoken.sign.SaSignTemplate;
@ -296,7 +297,26 @@ public class SaManager {
public static SaLog getLog() {
return SaManager.log;
}
/**
* TOTP 算法类支持 生成/验证 动态一次性密码
*/
private volatile static SaTotpTemplate totpTemplate;
public static void setSaTotpTemplate(SaTotpTemplate totpTemplate) {
SaManager.totpTemplate = totpTemplate;
SaTokenEventCenter.doRegisterComponent("SaTotpTemplate", totpTemplate);
}
public static SaTotpTemplate getSaTotpTemplate() {
if (totpTemplate == null) {
synchronized (SaManager.class) {
if (totpTemplate == null) {
SaManager.totpTemplate = new SaTotpTemplate();
}
}
}
return totpTemplate;
}
/**
* StpLogic 集合, 记录框架所有成功初始化的 StpLogic
*/

View File

@ -0,0 +1,41 @@
/*
* Copyright 2020-2099 sa-token.cc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package cn.dev33.satoken.exception;
/**
* 一个异常代表 TOTP 校验未通过
*
* @author click33
* @since 1.41.0
*/
public class TotpAuthException extends SaTokenException {
/**
* 序列化版本号
*/
private static final long serialVersionUID = 6806129545290130144L;
/** 异常提示语 */
public static final String BE_MESSAGE = "totp check fail";
/**
* 一个异常代表会话未通过 Totp 校验
*/
public TotpAuthException() {
super(BE_MESSAGE);
}
}

View File

@ -0,0 +1,181 @@
/*
* Copyright 2020-2099 sa-token.cc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package cn.dev33.satoken.secure.totp;
import cn.dev33.satoken.exception.TotpAuthException;
import cn.dev33.satoken.secure.SaBase32Util;
import cn.dev33.satoken.util.StrFormatter;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.time.Instant;
/**
* TOTP 算法类支持 生成/验证 动态一次性密码
*
* @author click33
* @since 1.41.0
*/
public class SaTotpTemplate {
/**
* 时间窗口步长
*/
public int timeStep = 30;
/**
* 生成的验证码位数
*/
public int codeDigits = 6;
/**
* 哈希算法HmacSHA1HmacSHA256等
*/
public String hmacAlgorithm = "HmacSHA1";
/**
* 密钥长度字节推荐16或32
*/
public int secretKeyLength = 16;
/**
* 构造函数 (使用默认参数)
*/
public SaTotpTemplate() {
}
/**
* 构造函数 (使用自定义参数)
*
* @param timeStep 时间窗口步长
* @param codeDigits 生成的验证码位数
* @param hmacAlgorithm 哈希算法HmacSHA1HmacSHA256等
* @param secretKeyLength 密钥长度字节推荐16或32
*/
public SaTotpTemplate(int timeStep, int codeDigits, String hmacAlgorithm, int secretKeyLength) {
this.timeStep = timeStep;
this.codeDigits = codeDigits;
this.hmacAlgorithm = hmacAlgorithm;
this.secretKeyLength = secretKeyLength;
}
/**
* 生成随机密钥Base32编码
*
* @return /
*/
public String generateSecretKey() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[secretKeyLength];
random.nextBytes(bytes);
return SaBase32Util.encodeBytesToString(bytes).replace("=", "");
}
/**
* 生成当前时间的 TOTP 验证码
*
* @param secretKey Base32 编码的密钥
* @return /
*/
public String _generateTOTP(String secretKey) {
return _generateTOTP(secretKey, Instant.now().getEpochSecond());
}
/**
* 判断用户输入的 TOTP 是否有效
*
* @param secretKey Base32编码的密钥
* @param code 用户输入的验证码
* @param timeWindowOffset 允许的时间窗口偏移量如1表示允许前后各1个时间窗口
* @return /
*/
public boolean validateTOTP(String secretKey, String code, int timeWindowOffset) {
long currentWindow = Instant.now().getEpochSecond() / timeStep;
for (int i = -timeWindowOffset; i <= timeWindowOffset; i++) {
String calculatedCode = _generateTOTP(secretKey, (currentWindow + i) * timeStep);
if (calculatedCode.equals(code)) {
return true;
}
}
return false;
}
/**
* 校验用户输入的TOTP是否有效如果无效则抛出异常
*
* @param secretKey Base32编码的密钥
* @param code 用户输入的验证码
* @param timeWindowOffset 允许的时间窗口偏移量如1表示允许前后各1个时间窗口
*/
public void checkTOTP(String secretKey, String code, int timeWindowOffset) {
if (!validateTOTP(secretKey, code, timeWindowOffset)) {
throw new TotpAuthException();
}
}
/**
* 生成谷歌认证器的扫码字符串 (形如otpauth://totp/{account}?secret={secretKey})
*
* @param account 账户名
* @return /
*/
public String generateGoogleSecretKey(String account) {
return generateGoogleSecretKey(account, generateSecretKey());
}
/**
* 生成谷歌认证器的扫码字符串 (形如otpauth://totp/{account}?secret={secretKey})
*
* @param account 账户名
* @param secretKey TOTP 秘钥
* @return /
*/
public String generateGoogleSecretKey(String account, String secretKey) {
return StrFormatter.format("otpauth://totp/{}?secret={}", account, secretKey);
}
protected String _generateTOTP(String secretKey, long time) {
// Base32解码密钥
byte[] keyBytes = SaBase32Util.decodeStringToBytes(secretKey);
byte[] counterBytes = ByteBuffer.allocate(8).putLong(time / timeStep).array();
try {
// 计算HMAC哈希
Mac hmac = Mac.getInstance(hmacAlgorithm);
hmac.init(new SecretKeySpec(keyBytes, hmacAlgorithm));
byte[] hash = hmac.doFinal(counterBytes);
// 动态截断RFC 6238
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, codeDigits);
return String.format("%0" + codeDigits + "d", otp);
} catch (GeneralSecurityException e) {
throw new RuntimeException("TOTP生成失败", e);
}
}
}

View File

@ -0,0 +1,91 @@
/*
* Copyright 2020-2099 sa-token.cc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package cn.dev33.satoken.secure.totp;
import cn.dev33.satoken.SaManager;
/**
* TOTP 工具类支持 生成/验证 动态一次性密码
*
* @author click33
* @since 1.41.0
*/
public class SaTotpUtil {
/**
* 生成随机密钥Base32编码
*
* @return /
*/
public static String generateSecretKey() {
return SaManager.getSaTotpTemplate().generateSecretKey();
}
/**
* 生成当前时间的TOTP验证码
*
* @param secretKey Base32编码的密钥
* @return /
*/
public static String generateTOTP(String secretKey) {
return SaManager.getSaTotpTemplate()._generateTOTP(secretKey);
}
/**
* 判断用户输入的TOTP是否有效
*
* @param secretKey Base32编码的密钥
* @param code 用户输入的验证码
* @param timeWindowOffset 允许的时间窗口偏移量如1表示允许前后各1个时间窗口
* @return /
*/
public static boolean validateTOTP(String secretKey, String code, int timeWindowOffset) {
return SaManager.getSaTotpTemplate().validateTOTP(secretKey, code, timeWindowOffset);
}
/**
* 校验用户输入的TOTP是否有效如果无效则抛出异常
*
* @param secretKey Base32编码的密钥
* @param code 用户输入的验证码
* @param timeWindowOffset 允许的时间窗口偏移量如1表示允许前后各1个时间窗口
*/
public static void checkTOTP(String secretKey, String code, int timeWindowOffset) {
SaManager.getSaTotpTemplate().checkTOTP(secretKey, code, timeWindowOffset);
}
/**
* 生成谷歌认证器的扫码字符串 (形如otpauth://totp/{account}?secret={secretKey})
*
* @param account 账户名
* @return /
*/
public static String generateGoogleSecretKey(String account) {
return SaManager.getSaTotpTemplate().generateGoogleSecretKey(account);
}
/**
* 生成谷歌认证器的扫码字符串 (形如otpauth://totp/{account}?secret={secretKey})
*
* @param account 账户名
* @param secretKey TOTP 秘钥
* @return /
*/
public static String generateGoogleSecretKey(String account, String secretKey) {
return SaManager.getSaTotpTemplate().generateGoogleSecretKey(account, secretKey);
}
}

View File

@ -32,6 +32,7 @@ import cn.dev33.satoken.log.SaLog;
import cn.dev33.satoken.plugin.SaTokenPlugin;
import cn.dev33.satoken.plugin.SaTokenPluginHolder;
import cn.dev33.satoken.same.SaSameTemplate;
import cn.dev33.satoken.secure.totp.SaTotpTemplate;
import cn.dev33.satoken.serializer.SaSerializerTemplate;
import cn.dev33.satoken.sign.SaSignTemplate;
import cn.dev33.satoken.stp.StpInterface;
@ -228,6 +229,17 @@ public class SaBeanInject {
SaManager.setSaSignTemplate(saSignTemplate);
}
/**
* 注入自定义的 TOTP 算法 Bean
*
* @param totpTemplate TOTP 算法类
*/
@Condition(onBean = SaTotpTemplate.class)
@Bean
public void setSaTotpTemplate(SaTotpTemplate totpTemplate) {
SaManager.setSaTotpTemplate(totpTemplate);
}
/**
* 注入自定义的 StpLogic
*

View File

@ -32,6 +32,7 @@ import cn.dev33.satoken.log.SaLog;
import cn.dev33.satoken.plugin.SaTokenPlugin;
import cn.dev33.satoken.plugin.SaTokenPluginHolder;
import cn.dev33.satoken.same.SaSameTemplate;
import cn.dev33.satoken.secure.totp.SaTotpTemplate;
import cn.dev33.satoken.serializer.SaSerializerTemplate;
import cn.dev33.satoken.sign.SaSignTemplate;
import cn.dev33.satoken.spring.pathmatch.SaPathMatcherHolder;
@ -214,6 +215,16 @@ public class SaBeanInject {
SaManager.setSaSignTemplate(saSignTemplate);
}
/**
* 注入自定义的 TOTP 算法 Bean
*
* @param totpTemplate TOTP 算法类
*/
@Autowired(required = false)
public void setSaTotpTemplate(SaTotpTemplate totpTemplate) {
SaManager.setSaTotpTemplate(totpTemplate);
}
/**
* 注入自定义的 StpLogic
* @param stpLogic /