Merge remote-tracking branch 'base-version/main' into dev
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
package com.zt.plat.framework.common.util.security;
|
||||
|
||||
import cn.hutool.crypto.SecureUtil;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* 通用的签名、加解密工具类
|
||||
*/
|
||||
public final class CryptoSignatureUtils {
|
||||
|
||||
public static final String ENCRYPT_TYPE_AES = "AES";
|
||||
public static final String ENCRYPT_TYPE_DES = "DES";
|
||||
public static final String SIGNATURE_TYPE_MD5 = "MD5";
|
||||
public static final String SIGNATURE_TYPE_SHA256 = "SHA256";
|
||||
|
||||
private static final String AES_TRANSFORMATION = "AES/ECB/PKCS5Padding";
|
||||
private static final String SIGNATURE_FIELD = "signature";
|
||||
|
||||
private CryptoSignatureUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 AES 密钥(SecretKeySpec)
|
||||
*
|
||||
* @param password 密钥字符串
|
||||
* @return SecretKeySpec
|
||||
*/
|
||||
public static SecretKeySpec getSecretKey(String password) {
|
||||
try {
|
||||
KeyGenerator kg = KeyGenerator.getInstance("AES");
|
||||
SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
|
||||
random.setSeed(password.getBytes(StandardCharsets.UTF_8));
|
||||
kg.init(128, random);
|
||||
SecretKey secretKey = kg.generateKey();
|
||||
return new SecretKeySpec(secretKey.getEncoded(), "AES");
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("Failed to generate AES secret key", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对称加密(Base64 格式输出)
|
||||
*
|
||||
* @param plaintext 明文内容
|
||||
* @param key 密钥
|
||||
* @param type 加密类型,支持 AES、DES
|
||||
* @return 密文(Base64 格式)
|
||||
*/
|
||||
public static String encrypt(String plaintext, String key, String type) {
|
||||
if (ENCRYPT_TYPE_AES.equalsIgnoreCase(type)) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey(key));
|
||||
byte[] result = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(result);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Failed to encrypt using AES", ex);
|
||||
}
|
||||
} else if (ENCRYPT_TYPE_DES.equalsIgnoreCase(type)) {
|
||||
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] desKey = new byte[8];
|
||||
System.arraycopy(keyBytes, 0, desKey, 0, Math.min(keyBytes.length, desKey.length));
|
||||
byte[] encrypted = SecureUtil.des(desKey).encrypt(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(encrypted);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported encryption type: " + type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对称解密(输入为 Base64 格式密文)
|
||||
*
|
||||
* @param ciphertext 密文内容(Base64 格式)
|
||||
* @param key 密钥
|
||||
* @param type 加密类型,支持 AES、DES
|
||||
* @return 明文内容
|
||||
*/
|
||||
public static String decrypt(String ciphertext, String key, String type) {
|
||||
if (ciphertext == null) {
|
||||
return null;
|
||||
}
|
||||
if (ENCRYPT_TYPE_AES.equalsIgnoreCase(type)) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
|
||||
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(key));
|
||||
byte[] decoded = decodeBase64Ciphertext(ciphertext);
|
||||
byte[] result = cipher.doFinal(decoded);
|
||||
return new String(result, StandardCharsets.UTF_8);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Failed to decrypt using AES", ex);
|
||||
}
|
||||
} else if (ENCRYPT_TYPE_DES.equalsIgnoreCase(type)) {
|
||||
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] desKey = new byte[8];
|
||||
System.arraycopy(keyBytes, 0, desKey, 0, Math.min(keyBytes.length, desKey.length));
|
||||
byte[] decoded = decodeBase64Ciphertext(ciphertext);
|
||||
byte[] decrypted = SecureUtil.des(desKey).decrypt(decoded);
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported encryption type: " + type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证请求签名
|
||||
*
|
||||
* @param reqMap 请求参数 Map
|
||||
* @param type 签名算法类型,支持 MD5、SHA256
|
||||
* @return 签名是否有效
|
||||
*/
|
||||
public static boolean verifySignature(Map<String, Object> reqMap, String type) {
|
||||
Map<String, Object> sortedMap = new TreeMap<>(reqMap);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, Object> entry : sortedMap.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
if (SIGNATURE_FIELD.equals(key) || value == null) {
|
||||
continue;
|
||||
}
|
||||
sb.append(key).append('=');
|
||||
sb.append(value);
|
||||
sb.append('&');
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.deleteCharAt(sb.length() - 1);
|
||||
}
|
||||
String provided = (String) reqMap.get(SIGNATURE_FIELD);
|
||||
if (provided == null) {
|
||||
return false;
|
||||
}
|
||||
String computed;
|
||||
if (SIGNATURE_TYPE_MD5.equalsIgnoreCase(type)) {
|
||||
computed = SecureUtil.md5(sb.toString());
|
||||
} else if (SIGNATURE_TYPE_SHA256.equalsIgnoreCase(type)) {
|
||||
computed = SecureUtil.sha256(sb.toString());
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported signature type: " + type);
|
||||
}
|
||||
return provided.equalsIgnoreCase(computed);
|
||||
}
|
||||
|
||||
private static byte[] decodeBase64Ciphertext(String ciphertext) {
|
||||
IllegalArgumentException last = null;
|
||||
for (String candidate : buildBase64Candidates(ciphertext)) {
|
||||
if (candidate == null || candidate.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return Base64.getDecoder().decode(candidate);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
last = ex;
|
||||
}
|
||||
}
|
||||
throw last != null ? last : new IllegalArgumentException("Invalid Base64 content");
|
||||
}
|
||||
|
||||
private static Set<String> buildBase64Candidates(String ciphertext) {
|
||||
Set<String> candidates = new LinkedHashSet<>();
|
||||
if (ciphertext == null) {
|
||||
return candidates;
|
||||
}
|
||||
String trimmed = ciphertext.trim();
|
||||
candidates.add(trimmed);
|
||||
|
||||
String withoutWhitespace = stripWhitespace(trimmed);
|
||||
candidates.add(withoutWhitespace);
|
||||
|
||||
if (trimmed.indexOf(' ') >= 0) {
|
||||
String restoredPlus = trimmed.replace(' ', '+');
|
||||
candidates.add(restoredPlus);
|
||||
candidates.add(stripWhitespace(restoredPlus));
|
||||
}
|
||||
|
||||
String urlNormalised = withoutWhitespace
|
||||
.replace('-', '+')
|
||||
.replace('_', '/');
|
||||
candidates.add(urlNormalised);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static String stripWhitespace(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
boolean hasWhitespace = false;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
if (Character.isWhitespace(value.charAt(i))) {
|
||||
hasWhitespace = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasWhitespace) {
|
||||
return value;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(value.length());
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char ch = value.charAt(i);
|
||||
if (!Character.isWhitespace(ch)) {
|
||||
sb.append(ch);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.zt.plat.framework.common.util.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
class CryptoSignatureUtilsTest {
|
||||
|
||||
@Test
|
||||
void decryptShouldIgnoreWhitespaceInCiphertext() {
|
||||
String key = "test-key";
|
||||
String plaintext = "{\"sample\":123}";
|
||||
|
||||
String encrypted = CryptoSignatureUtils.encrypt(plaintext, key, CryptoSignatureUtils.ENCRYPT_TYPE_AES);
|
||||
int splitIndex = Math.max(1, encrypted.length() / 2);
|
||||
String cipherWithWhitespace = " " + encrypted.substring(0, splitIndex)
|
||||
+ " \n\t "
|
||||
+ encrypted.substring(splitIndex);
|
||||
|
||||
String decrypted = CryptoSignatureUtils.decrypt(cipherWithWhitespace, key, CryptoSignatureUtils.ENCRYPT_TYPE_AES);
|
||||
|
||||
assertEquals(plaintext, decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decryptShouldRestorePlusCharactersConvertedToSpaces() {
|
||||
String key = "test-key";
|
||||
String basePlaintext = "payload-";
|
||||
|
||||
String encryptedWithPlus = null;
|
||||
String chosenPlaintext = null;
|
||||
for (int i = 0; i < 100; i++) {
|
||||
String candidatePlaintext = basePlaintext + i;
|
||||
String candidateEncrypted = CryptoSignatureUtils.encrypt(candidatePlaintext, key, CryptoSignatureUtils.ENCRYPT_TYPE_AES);
|
||||
if (candidateEncrypted.indexOf('+') >= 0) {
|
||||
encryptedWithPlus = candidateEncrypted;
|
||||
chosenPlaintext = candidatePlaintext;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assertNotNull(encryptedWithPlus, "Expected to generate ciphertext containing '+' character");
|
||||
|
||||
String mutatedCipher = encryptedWithPlus.replace('+', ' ');
|
||||
String decrypted = CryptoSignatureUtils.decrypt(mutatedCipher, key, CryptoSignatureUtils.ENCRYPT_TYPE_AES);
|
||||
|
||||
assertEquals(chosenPlaintext, decrypted);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
package com.zt.plat.framework.signature.config;
|
||||
|
||||
import com.zt.plat.framework.redis.config.ZtRedisAutoConfiguration;
|
||||
import com.zt.plat.framework.signature.core.aop.ApiSignatureAspect;
|
||||
import com.zt.plat.framework.signature.core.ApiSignatureVerifier;
|
||||
import com.zt.plat.framework.signature.core.config.ApiSignatureProperties;
|
||||
import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO;
|
||||
import com.zt.plat.framework.signature.core.web.ApiSignatureHandlerInterceptor;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* HTTP API 签名的自动配置类
|
||||
@@ -13,16 +22,47 @@ import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
* @author Zhougang
|
||||
*/
|
||||
@AutoConfiguration(after = ZtRedisAutoConfiguration.class)
|
||||
@EnableConfigurationProperties(ApiSignatureProperties.class)
|
||||
public class ZtApiSignatureAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
|
||||
return new ApiSignatureAspect(signatureRedisDAO);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
|
||||
return new ApiSignatureRedisDAO(stringRedisTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ApiSignatureVerifier apiSignatureVerifier(ApiSignatureRedisDAO signatureRedisDAO) {
|
||||
return new ApiSignatureVerifier(signatureRedisDAO);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ApiSignatureHandlerInterceptor apiSignatureHandlerInterceptor(ApiSignatureVerifier verifier,
|
||||
ApiSignatureProperties properties) {
|
||||
return new ApiSignatureHandlerInterceptor(verifier, properties);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public WebMvcConfigurer apiSignatureWebMvcConfigurer(ApiSignatureHandlerInterceptor interceptor,
|
||||
ApiSignatureProperties properties) {
|
||||
return new WebMvcConfigurer() {
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
if (!properties.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
InterceptorRegistration registration = registry.addInterceptor(interceptor);
|
||||
List<String> includePaths = properties.getIncludePaths();
|
||||
if (CollectionUtils.isEmpty(includePaths)) {
|
||||
registration.addPathPatterns("/**");
|
||||
} else {
|
||||
registration.addPathPatterns(includePaths.toArray(new String[0]));
|
||||
}
|
||||
List<String> excludePaths = properties.getExcludePaths();
|
||||
if (!CollectionUtils.isEmpty(excludePaths)) {
|
||||
registration.excludePathPatterns(excludePaths.toArray(new String[0]));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.zt.plat.framework.signature.core;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.zt.plat.framework.common.exception.ServiceException;
|
||||
import com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import com.zt.plat.framework.common.util.servlet.ServletUtils;
|
||||
import com.zt.plat.framework.signature.core.model.ApiSignatureRule;
|
||||
import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import static com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
|
||||
|
||||
/**
|
||||
* API 签名校验器
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ApiSignatureVerifier {
|
||||
|
||||
private final ApiSignatureRedisDAO signatureRedisDAO;
|
||||
|
||||
public boolean verify(ApiSignatureRule rule, HttpServletRequest request) {
|
||||
// 1. 校验请求头
|
||||
verifyHeaders(rule, request);
|
||||
|
||||
// 2. 校验 appId 对应的 appSecret 是否存在
|
||||
String appId = request.getHeader(rule.getAppId());
|
||||
String appSecret = signatureRedisDAO.getAppSecret(appId);
|
||||
Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId);
|
||||
|
||||
// 3. 校验签名
|
||||
String clientSignature = request.getHeader(rule.getSign());
|
||||
String serverSignatureString = buildSignatureString(rule, request, appSecret);
|
||||
String serverSignature = DigestUtil.sha256Hex(serverSignatureString);
|
||||
if (ObjUtil.notEqual(clientSignature, serverSignature)) {
|
||||
throw new ServiceException(BAD_REQUEST.getCode(), rule.getMessage());
|
||||
}
|
||||
|
||||
// 4. 缓存 nonce,防止重复请求
|
||||
String nonce = request.getHeader(rule.getNonce());
|
||||
if (BooleanUtil.isFalse(signatureRedisDAO.setNonce(appId, nonce, rule.getTimeout() * 2, rule.getTimeUnit()))) {
|
||||
String timestamp = request.getHeader(rule.getTimestamp());
|
||||
log.info("[verifySignature][appId({}) timestamp({}) nonce({}) sign({}) 存在重复请求]", appId, timestamp, nonce, clientSignature);
|
||||
throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), "存在重复请求");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void verifyHeaders(ApiSignatureRule rule, HttpServletRequest request) {
|
||||
String appId = request.getHeader(rule.getAppId());
|
||||
String timestamp = request.getHeader(rule.getTimestamp());
|
||||
String nonce = request.getHeader(rule.getNonce());
|
||||
String sign = request.getHeader(rule.getSign());
|
||||
if (StrUtil.isBlank(appId) || StrUtil.isBlank(timestamp) || StrUtil.isBlank(sign) || StrUtil.length(nonce) < 10) {
|
||||
throw new ServiceException(BAD_REQUEST.getCode(), rule.getMessage());
|
||||
}
|
||||
|
||||
long expireTime = rule.getTimeUnit().toMillis(rule.getTimeout());
|
||||
long requestTimestamp;
|
||||
try {
|
||||
requestTimestamp = Long.parseLong(timestamp);
|
||||
} catch (NumberFormatException ex) {
|
||||
throw new ServiceException(BAD_REQUEST.getCode(), rule.getMessage());
|
||||
}
|
||||
long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
|
||||
if (timestampDisparity > expireTime) {
|
||||
throw new ServiceException(BAD_REQUEST.getCode(), rule.getMessage());
|
||||
}
|
||||
|
||||
if (signatureRedisDAO.getNonce(appId, nonce) != null) {
|
||||
throw new ServiceException(BAD_REQUEST.getCode(), rule.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String buildSignatureString(ApiSignatureRule rule, HttpServletRequest request, String appSecret) {
|
||||
SortedMap<String, String> parameterMap = getRequestParameterMap(request);
|
||||
SortedMap<String, String> headerMap = getRequestHeaderMap(rule, request);
|
||||
String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), "");
|
||||
return MapUtil.join(parameterMap, "&", "=")
|
||||
+ requestBody
|
||||
+ MapUtil.join(headerMap, "&", "=")
|
||||
+ appSecret;
|
||||
}
|
||||
|
||||
private SortedMap<String, String> getRequestHeaderMap(ApiSignatureRule rule, HttpServletRequest request) {
|
||||
SortedMap<String, String> sortedMap = new TreeMap<>();
|
||||
sortedMap.put(rule.getAppId(), request.getHeader(rule.getAppId()));
|
||||
sortedMap.put(rule.getTimestamp(), request.getHeader(rule.getTimestamp()));
|
||||
sortedMap.put(rule.getNonce(), request.getHeader(rule.getNonce()));
|
||||
return sortedMap;
|
||||
}
|
||||
|
||||
private SortedMap<String, String> getRequestParameterMap(HttpServletRequest request) {
|
||||
SortedMap<String, String> sortedMap = new TreeMap<>();
|
||||
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
|
||||
sortedMap.put(entry.getKey(), entry.getValue()[0]);
|
||||
}
|
||||
return sortedMap;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,18 @@
|
||||
package com.zt.plat.framework.signature.core.aop;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.zt.plat.framework.common.exception.ServiceException;
|
||||
import com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import com.zt.plat.framework.common.util.servlet.ServletUtils;
|
||||
import com.zt.plat.framework.signature.core.ApiSignatureVerifier;
|
||||
import com.zt.plat.framework.signature.core.annotation.ApiSignature;
|
||||
import com.zt.plat.framework.signature.core.config.ApiSignatureProperties;
|
||||
import com.zt.plat.framework.signature.core.model.ApiSignatureRule;
|
||||
import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import static com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
|
||||
|
||||
/**
|
||||
* 拦截声明了 {@link ApiSignature} 注解的方法,实现签名
|
||||
@@ -32,143 +21,33 @@ import static com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConsta
|
||||
*/
|
||||
@Aspect
|
||||
@Slf4j
|
||||
@AllArgsConstructor
|
||||
@Deprecated
|
||||
public class ApiSignatureAspect {
|
||||
|
||||
private final ApiSignatureRedisDAO signatureRedisDAO;
|
||||
private final ApiSignatureVerifier verifier;
|
||||
private final ApiSignatureProperties properties;
|
||||
|
||||
public ApiSignatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
|
||||
this(new ApiSignatureVerifier(signatureRedisDAO), new ApiSignatureProperties());
|
||||
}
|
||||
|
||||
public ApiSignatureAspect(ApiSignatureVerifier verifier, ApiSignatureProperties properties) {
|
||||
this.verifier = verifier;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Before("@annotation(signature)")
|
||||
public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) {
|
||||
// 1. 验证通过,直接结束
|
||||
if (verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 验证不通过,抛出异常
|
||||
log.error("[beforePointCut][方法{} 参数({}) 签名失败]", joinPoint.getSignature().toString(),
|
||||
joinPoint.getArgs());
|
||||
throw new ServiceException(BAD_REQUEST.getCode(),
|
||||
StrUtil.blankToDefault(signature.message(), BAD_REQUEST.getMsg()));
|
||||
HttpServletRequest request = Objects.requireNonNull(ServletUtils.getRequest());
|
||||
ApiSignatureRule rule = ApiSignatureRule.from(signature, properties);
|
||||
verifier.verify(rule, request);
|
||||
log.debug("[beforePointCut][方法{} 参数({}) 签名校验通过]", joinPoint.getSignature(), joinPoint.getArgs());
|
||||
}
|
||||
|
||||
public boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
|
||||
// 1.1 校验 Header
|
||||
if (!verifyHeaders(signature, request)) {
|
||||
return false;
|
||||
}
|
||||
// 1.2 校验 appId 是否能获取到对应的 appSecret
|
||||
String appId = request.getHeader(signature.appId());
|
||||
String appSecret = signatureRedisDAO.getAppSecret(appId);
|
||||
Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId);
|
||||
|
||||
// 2. 校验签名【重要!】
|
||||
String clientSignature = request.getHeader(signature.sign()); // 客户端签名
|
||||
String serverSignatureString = buildSignatureString(signature, request, appSecret); // 服务端签名字符串
|
||||
String serverSignature = DigestUtil.sha256Hex(serverSignatureString); // 服务端签名
|
||||
if (ObjUtil.notEqual(clientSignature, serverSignature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 )
|
||||
String nonce = request.getHeader(signature.nonce());
|
||||
if (BooleanUtil.isFalse(signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit()))) {
|
||||
String timestamp = request.getHeader(signature.timestamp());
|
||||
log.info("[verifySignature][appId({}) timestamp({}) nonce({}) sign({}) 存在重复请求]", appId, timestamp, nonce, clientSignature);
|
||||
throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), "存在重复请求");
|
||||
}
|
||||
ApiSignatureRule rule = ApiSignatureRule.from(signature, properties);
|
||||
verifier.verify(rule, request);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求头加签参数
|
||||
* <p>
|
||||
* 1. appId 是否为空
|
||||
* 2. timestamp 是否为空,请求是否已经超时,默认 10 分钟
|
||||
* 3. nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了
|
||||
* 4. sign 是否为空
|
||||
*
|
||||
* @param signature signature
|
||||
* @param request request
|
||||
* @return 是否校验 Header 通过
|
||||
*/
|
||||
private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) {
|
||||
// 1. 非空校验
|
||||
String appId = request.getHeader(signature.appId());
|
||||
if (StrUtil.isBlank(appId)) {
|
||||
return false;
|
||||
}
|
||||
String timestamp = request.getHeader(signature.timestamp());
|
||||
if (StrUtil.isBlank(timestamp)) {
|
||||
return false;
|
||||
}
|
||||
String nonce = request.getHeader(signature.nonce());
|
||||
if (StrUtil.length(nonce) < 10) {
|
||||
return false;
|
||||
}
|
||||
String sign = request.getHeader(signature.sign());
|
||||
if (StrUtil.isBlank(sign)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)
|
||||
long expireTime = signature.timeUnit().toMillis(signature.timeout());
|
||||
long requestTimestamp = Long.parseLong(timestamp);
|
||||
long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
|
||||
if (timestampDisparity > expireTime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查 nonce 是否存在,有且仅能使用一次
|
||||
return signatureRedisDAO.getNonce(appId, nonce) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建签名字符串
|
||||
* <p>
|
||||
* 格式为 = 请求参数 + 请求体 + 请求头 + 密钥
|
||||
*
|
||||
* @param signature signature
|
||||
* @param request request
|
||||
* @param appSecret appSecret
|
||||
* @return 签名字符串
|
||||
*/
|
||||
private String buildSignatureString(ApiSignature signature, HttpServletRequest request, String appSecret) {
|
||||
SortedMap<String, String> parameterMap = getRequestParameterMap(request); // 请求头
|
||||
SortedMap<String, String> headerMap = getRequestHeaderMap(signature, request); // 请求参数
|
||||
String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), ""); // 请求体
|
||||
return MapUtil.join(parameterMap, "&", "=")
|
||||
+ requestBody
|
||||
+ MapUtil.join(headerMap, "&", "=")
|
||||
+ appSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求头加签参数 Map
|
||||
*
|
||||
* @param request 请求
|
||||
* @param signature 签名注解
|
||||
* @return signature params
|
||||
*/
|
||||
private static SortedMap<String, String> getRequestHeaderMap(ApiSignature signature, HttpServletRequest request) {
|
||||
SortedMap<String, String> sortedMap = new TreeMap<>();
|
||||
sortedMap.put(signature.appId(), request.getHeader(signature.appId()));
|
||||
sortedMap.put(signature.timestamp(), request.getHeader(signature.timestamp()));
|
||||
sortedMap.put(signature.nonce(), request.getHeader(signature.nonce()));
|
||||
return sortedMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求参数 Map
|
||||
*
|
||||
* @param request 请求
|
||||
* @return queryParams
|
||||
*/
|
||||
private static SortedMap<String, String> getRequestParameterMap(HttpServletRequest request) {
|
||||
SortedMap<String, String> sortedMap = new TreeMap<>();
|
||||
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
|
||||
sortedMap.put(entry.getKey(), entry.getValue()[0]);
|
||||
}
|
||||
return sortedMap;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.zt.plat.framework.signature.core.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* API 签名配置
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "zt.api-signature")
|
||||
public class ApiSignatureProperties {
|
||||
|
||||
/**
|
||||
* 是否开启全局签名校验
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* 签名有效期
|
||||
*/
|
||||
private int timeout = 60;
|
||||
|
||||
/**
|
||||
* 时间单位
|
||||
*/
|
||||
private TimeUnit timeUnit = TimeUnit.SECONDS;
|
||||
|
||||
/**
|
||||
* 校验失败时的提示信息
|
||||
*/
|
||||
private String message = "签名不正确";
|
||||
|
||||
/**
|
||||
* 请求头:appId
|
||||
*/
|
||||
private String appId = "appId";
|
||||
|
||||
/**
|
||||
* 请求头:timestamp
|
||||
*/
|
||||
private String timestamp = "timestamp";
|
||||
|
||||
/**
|
||||
* 请求头:nonce
|
||||
*/
|
||||
private String nonce = "nonce";
|
||||
|
||||
/**
|
||||
* 请求头:sign
|
||||
*/
|
||||
private String sign = "sign";
|
||||
|
||||
/**
|
||||
* 需要进行签名校验的路径,默认全量
|
||||
*/
|
||||
private List<String> includePaths = new ArrayList<>(Arrays.asList("/**"));
|
||||
|
||||
/**
|
||||
* 无需签名校验的路径
|
||||
*/
|
||||
private List<String> excludePaths = new ArrayList<>(Arrays.asList("/error", "/swagger-ui/**", "/v3/api-docs/**"));
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.zt.plat.framework.signature.core.model;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.zt.plat.framework.signature.core.annotation.ApiSignature;
|
||||
import com.zt.plat.framework.signature.core.config.ApiSignatureProperties;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 签名校验规则
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
public class ApiSignatureRule {
|
||||
|
||||
private final int timeout;
|
||||
private final TimeUnit timeUnit;
|
||||
private final String message;
|
||||
private final String appId;
|
||||
private final String timestamp;
|
||||
private final String nonce;
|
||||
private final String sign;
|
||||
|
||||
public static ApiSignatureRule from(ApiSignatureProperties properties) {
|
||||
return ApiSignatureRule.builder()
|
||||
.timeout(properties.getTimeout())
|
||||
.timeUnit(properties.getTimeUnit())
|
||||
.message(properties.getMessage())
|
||||
.appId(properties.getAppId())
|
||||
.timestamp(properties.getTimestamp())
|
||||
.nonce(properties.getNonce())
|
||||
.sign(properties.getSign())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static ApiSignatureRule from(ApiSignature signature, ApiSignatureProperties defaults) {
|
||||
return ApiSignatureRule.builder()
|
||||
.timeout(signature.timeout())
|
||||
.timeUnit(signature.timeUnit())
|
||||
.message(StrUtil.blankToDefault(signature.message(), defaults.getMessage()))
|
||||
.appId(signature.appId())
|
||||
.timestamp(signature.timestamp())
|
||||
.nonce(signature.nonce())
|
||||
.sign(signature.sign())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.zt.plat.framework.signature.core.web;
|
||||
|
||||
import com.zt.plat.framework.signature.core.ApiSignatureVerifier;
|
||||
import com.zt.plat.framework.signature.core.annotation.ApiSignature;
|
||||
import com.zt.plat.framework.signature.core.config.ApiSignatureProperties;
|
||||
import com.zt.plat.framework.signature.core.model.ApiSignatureRule;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import org.springframework.web.util.UrlPathHelper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 全局 API 签名拦截器
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class ApiSignatureHandlerInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final ApiSignatureVerifier verifier;
|
||||
private final ApiSignatureProperties properties;
|
||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
private final UrlPathHelper urlPathHelper = new UrlPathHelper();
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
if (!properties.isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
if (!(handler instanceof HandlerMethod handlerMethod)) {
|
||||
return true;
|
||||
}
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
return true;
|
||||
}
|
||||
String lookupPath = urlPathHelper.getLookupPathForRequest(request);
|
||||
if (shouldSkip(lookupPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
ApiSignatureRule rule = createRule(handlerMethod);
|
||||
verifier.verify(rule, request);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean shouldSkip(String path) {
|
||||
List<String> includePaths = properties.getIncludePaths();
|
||||
if (!CollectionUtils.isEmpty(includePaths)) {
|
||||
boolean matched = includePaths.stream().anyMatch(pattern -> pathMatcher.match(pattern, path));
|
||||
if (!matched) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
List<String> excludePaths = properties.getExcludePaths();
|
||||
if (!CollectionUtils.isEmpty(excludePaths)) {
|
||||
for (String pattern : excludePaths) {
|
||||
if (pathMatcher.match(pattern, path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private ApiSignatureRule createRule(HandlerMethod handlerMethod) {
|
||||
ApiSignature signature = handlerMethod.getMethodAnnotation(ApiSignature.class);
|
||||
if (signature != null) {
|
||||
return ApiSignatureRule.from(signature, properties);
|
||||
}
|
||||
signature = handlerMethod.getBeanType().getAnnotation(ApiSignature.class);
|
||||
if (signature != null) {
|
||||
return ApiSignatureRule.from(signature, properties);
|
||||
}
|
||||
return ApiSignatureRule.from(properties);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
com.zt.plat.framework.idempotent.config.ZtIdempotentConfiguration
|
||||
com.zt.plat.framework.lock4j.config.ZtLock4jConfiguration
|
||||
com.zt.plat.framework.ratelimiter.config.ZtRateLimiterConfiguration
|
||||
com.zt.plat.framework.signature.config.ZtApiSignatureAutoConfiguration
|
||||
@@ -3,13 +3,13 @@ package com.zt.plat.framework.signature.core;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.zt.plat.framework.signature.core.annotation.ApiSignature;
|
||||
import com.zt.plat.framework.signature.core.aop.ApiSignatureAspect;
|
||||
import com.zt.plat.framework.signature.core.ApiSignatureVerifier;
|
||||
import com.zt.plat.framework.signature.core.config.ApiSignatureProperties;
|
||||
import com.zt.plat.framework.signature.core.model.ApiSignatureRule;
|
||||
import com.zt.plat.framework.signature.core.redis.ApiSignatureRedisDAO;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@@ -28,9 +28,6 @@ import static org.mockito.Mockito.*;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class ApiSignatureTest {
|
||||
|
||||
@InjectMocks
|
||||
private ApiSignatureAspect apiSignatureAspect;
|
||||
|
||||
@Mock
|
||||
private ApiSignatureRedisDAO signatureRedisDAO;
|
||||
|
||||
@@ -45,13 +42,16 @@ public class ApiSignatureTest {
|
||||
String sign = DigestUtil.sha256Hex(signString);
|
||||
|
||||
// 准备参数
|
||||
ApiSignature apiSignature = mock(ApiSignature.class);
|
||||
when(apiSignature.appId()).thenReturn("appId");
|
||||
when(apiSignature.timestamp()).thenReturn("timestamp");
|
||||
when(apiSignature.nonce()).thenReturn("nonce");
|
||||
when(apiSignature.sign()).thenReturn("sign");
|
||||
when(apiSignature.timeout()).thenReturn(60);
|
||||
when(apiSignature.timeUnit()).thenReturn(TimeUnit.SECONDS);
|
||||
ApiSignatureProperties properties = new ApiSignatureProperties();
|
||||
ApiSignatureRule apiSignature = ApiSignatureRule.builder()
|
||||
.appId("appId")
|
||||
.timestamp("timestamp")
|
||||
.nonce("nonce")
|
||||
.sign("sign")
|
||||
.timeout(60)
|
||||
.timeUnit(TimeUnit.SECONDS)
|
||||
.message(properties.getMessage())
|
||||
.build();
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader(eq("appId"))).thenReturn(appId);
|
||||
when(request.getHeader(eq("timestamp"))).thenReturn(String.valueOf(timestamp));
|
||||
@@ -62,11 +62,13 @@ public class ApiSignatureTest {
|
||||
when(request.getContentType()).thenReturn("application/json");
|
||||
when(request.getReader()).thenReturn(new BufferedReader(new StringReader("test")));
|
||||
// mock 方法
|
||||
when(signatureRedisDAO.getNonce(eq(appId), eq(nonce))).thenReturn(null);
|
||||
when(signatureRedisDAO.getAppSecret(eq(appId))).thenReturn(appSecret);
|
||||
when(signatureRedisDAO.setNonce(eq(appId), eq(nonce), eq(120), eq(TimeUnit.SECONDS))).thenReturn(true);
|
||||
|
||||
// 调用
|
||||
boolean result = apiSignatureAspect.verifySignature(apiSignature, request);
|
||||
ApiSignatureVerifier verifier = new ApiSignatureVerifier(signatureRedisDAO);
|
||||
boolean result = verifier.verify(apiSignature, request);
|
||||
// 断言结果
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user