开放接口签名(Signature)实现方案
既然是对外开放,那么调用者一定没有我们系统的Token,就需要对调用者进行签名验证,签名验证采用主流的验证方式,采用Signature 的方式。
字段 | 类型 | 必传 | 说明 |
appid | String | 是 | 应用id |
timestamp | String | 是 | 时间戳 |
nonce | String | 是 | 随机数、不少于10位 |
signature | String | 是 | 签名 |
signature: 生成方式
将参数appId=wx123456789&nonce=155121212121×tamp=1684565287668&key=35AB7ECF665EF5EF44CF8640EC136300 进行拼接 key指的是appid对应的appSecret
将上述参数进行MD5加密
二、流程
1、通过应用设置模块添加应用分配appid和appsecret,针对不同的调用方分配不同的appid和appsecret。
2、加入timestamp(时间戳),有效时间内内数据有效。
3、 加入随机字符串,则认为接口为重复调用,返回错误信息(防止重复提交)。
4、加入signature,所有数据的签名信息。
三、实现
简单来说,调用者调用接口业务参数在body中传递,header中额外增加四个参数signature、appid、timestamp,随机字符串。
我们在后台取到四个参数,其后三个参数加上调用者分配的appSecret,使用字典排序并使用MD5加密后与第一个参数signature进行比对,一致既表示调用者有权限调用。
接口异常:
403 | 签名不一致 |
403 | appId或appSecret不正确 |
422 | 参数timestamp不能为空 |
408 | 请求时间超过规定范围时间10分钟 |
422 | 随机串nonce不能为空 |
422 | 随机串nonce长度最少为10位 |
407 | 不允许重复请求 |
代码实现:
自定义注解:
/**
* 签名算法实现=>指定哪些接口或者哪些实体需要进行签名
*/
@Target({TYPE, METHOD})
@Retention(RUNTIME)
@Documented
public @interface Signature {
//允许重复请求
boolean resubmit() default true;
}
签名工具类:
/**
* 开放接口签名工具类
* @author ShawnWang
* @datetime 2023-05-19
* @desc 接口校验工具类
* 生成有序map,签名,验签
* 通过appId、timestamp、appSecret做签名
* @menu
*/
@Slf4j
public class SignUtil {
/**
* 生成签名sign
* 加密前:appid=wx123456789&nonce=155121212121×tamp=1684565287668&key=35AB7ECF665EF5EF44CF8640EC136300
* 加密后:4CD98E261F46AA75E8935695C864A26D
*/
public static String createSign(SortedMap<String, String> params, String key){
StringBuilder sb = new StringBuilder();
Set<Map.Entry<String, String>> es = params.entrySet();
Iterator<Map.Entry<String,String>> it = es.iterator();
//生成
while (it.hasNext()){
Map.Entry<String,String> entry = it.next();
String k = entry.getKey();
String v = entry.getValue();
if(null != v && !"".equals(v) && !"signature".equals(k) && !"key".equals(k)){
sb.append(k+"="+v+"&");
}
}
sb.append("key=").append(key);
System.err.println("生成密钥前 " + sb.toString());
String sign = MD5(sb.toString()).toUpperCase();
return sign;
}
/**
* 校验签名
*/
public static Boolean isCorrectSign(SortedMap<String, String> params, String key){
String sign = createSign(params,key);
//这是前端带过来的
String requestSign = params.get("signature").toUpperCase();
log.info("通过用户发送数据获取新签名:{}", sign);
return requestSign.equals(sign);
}
/**
* md5常用工具类
*/
public static String MD5(String data){
try {
MessageDigest md5 = MessageDigest.getInstance("MD5");
byte [] array = md5.digest(data.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte item : array) {
sb.append(Integer.toHexString((item & 0xFF) | 0x100).substring(1, 3));
}
return sb.toString().toUpperCase();
}catch (Exception e){
e.printStackTrace();
}
return null;
}
/**
* 生成uuid
*/
public static String generateUUID(){
String uuid = UUID.randomUUID().toString().replaceAll("-","").substring(0,32);
return uuid;
}
public static void main(String[] args) {
/**
* 模拟如下
*/
//第一步:用户端发起请求,生成签名后发送请求
//appId 和 appSecret 由生成者提供
String appSecret = "35AB7ECF665EF5EF44CF8640EC136300";
String appId = "wx123456789";
String timestamp = new Date().getTime() + "";
String Id = ObjectId.next();
//生成签名 注意map顺序
SortedMap<String, String> sortedMap = new TreeMap<>();
sortedMap.put("appid", appId);
sortedMap.put("timestamp", timestamp);
sortedMap.put("nonce", Id);
//通过sortedMap的参数 以及 appSecret 生成签名
String sign = SignUtil.createSign(sortedMap, appSecret);
System.out.println(appId + "生成签名:"+ sign);
/**
* 模拟服务端接受参数 并处理校验签名
*/
//服务端接受到的参数
String appid = sortedMap.get("appid");
String timestamp1 = sortedMap.get("timestamp");
String nonce = sortedMap.get("nonce");
String websign = sign;
//2.组装参数,
SortedMap<String, String> sortedMap12 = new TreeMap<>();
sortedMap12.put("appid", appid);
sortedMap12.put("timestamp", timestamp1);
sortedMap12.put("nonce", nonce);
sortedMap12.put("signature", websign);
//3.校验签名
//sortedMap12模拟客户请求 ,appSecret表示数据库中存储的密钥
Boolean flag = SignUtil.isCorrectSign(sortedMap12, appSecret);
if(flag){
System.out.println("签名验证通过");
}else {
System.out.println("签名验证未通过");
}
}
}
切面AOP:
@Order(2)
@Aspect
@Component
@Slf4j
/**
* 通过Aop的方式实现接口签名
*/
public class OpenApiValidatorAspect {
//同一个请求多长时间内有效 10分钟
private static final Long EXPIRE_TIME = 60 * 1000 * 10L;
//同一个nonce 请求多长时间内不允许重复请求 2秒
private static final Long RESUBMIT_DURATION = 2000L;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private ApplySettingMapper applySettingMapper;
private static ConcurrentHashMap<String, ExpiringMap<String, Integer>> map= new ConcurrentHashMap<>();
/**
* 执行OpenApiController 包下所有带注解@annotation的
*
* @param pjp
* @return
* @throws Throwable
*/
@Around("execution(" +
"* com.xx.xx.controller.api.OpenApiController.*(..)) " +
"&& @annotation(xx.xx.xx.xx.openApiUtils.Signature) " +
")"
)
public Object doAround(ProceedingJoinPoint pjp) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
//如果是对外开放的URL, 进行签名校验
//获取当前方法的组件
MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
Method method = methodSignature.getMethod();
Signature signature = AnnotationUtils.findAnnotation(method, Signature.class);
//验证并获取header中的相关参数
/*
(1)、appid是否合法
(2)、根据appid从配置中心中拿到appsecret
(3)、请求是否已经过时,默认10分钟
(4)、随机串是否合法
(5)、是否允许重复请求
*/
Map<String, String> signatureHeaders = generateSignatureHeaders(signature, request);
//2.组装参数,
SortedMap<String, String> sortedMap = new TreeMap<>();
sortedMap.put("appid", signatureHeaders.get("appid"));
sortedMap.put("timestamp", signatureHeaders.get("timestamp"));
sortedMap.put("nonce", signatureHeaders.get("nonce"));
sortedMap.put("signature", signatureHeaders.get("signature"));
//3.校验签名
//sortedMap模拟客户请求 ,appSecret表示数据库中存储的密钥
Boolean flag = SignUtil.isCorrectSign(sortedMap, signatureHeaders.get("appSecret"));
//比较客户端与服务端签名
if (!flag) {
String message = "签名不一致";
log.error(message);
throw new ServiceException("403", message);
}
// 获取Map value对象, 如果没有则返回默认值
//getOrDefault获取参数,获取不到则给默认值
ExpiringMap<String, Integer> em= map.getOrDefault(signatureHeaders.get("appid"), ExpiringMap.builder().variableExpiration().build());
Integer count = em.getOrDefault(signatureHeaders.get("appid"), 0);
if (count >= 10 ) { // 超过次数,不执行目标方法
throw new ServiceException("422", signatureHeaders.get("appid") + " 接口请求超过次数");
} else if (count == 0){ // 第一次请求时,设置有效时间
em.put(signatureHeaders.get("appid"), count + 1, ExpirationPolicy.CREATED,1 , TimeUnit.HOURS);
} else { // 未超过次数, 记录加一
em.put(signatureHeaders.get("appid"), count + 1);
}
map.put(signatureHeaders.get("appid"), em);
log.info("签名验证通过, 相关信息: " + signatureHeaders);
try {
return pjp.proceed();
} catch (Throwable e) {
throw e;
}
}
/**
* 根据request 中 header值生成SignatureHeaders实体
* <p>
* 1.处理header name,通过工具类将header信息绑定到签名实体SignatureHeaders对象上。
* 2.验证appid是否合法。
* 3.根据appid拿到appsecret。
* 4.请求是否已经超时,默认10分钟。
* 5.随机串是否合法。
* 6.是否允许重复请求。
*/
private Map<String, String> generateSignatureHeaders(Signature signature, HttpServletRequest request) throws Exception {
/**
* 需要用到的请求参数
*/
List<String> params = new ArrayList(4);
params.add("appid");
params.add("timestamp");
params.add("nonce");
params.add("signature");
/**
* 获取参数和value 生成Map
*/
Map<String, String> headerMap = Collections.list(request.getHeaderNames())
.stream()
.filter(headerName -> params.contains(headerName))
.collect(Collectors.toMap(headerName -> headerName, headerName -> request.getHeader(headerName)));
//根据appId查询数据库
LambdaQueryWrapper<ApplySetting> applySettingLambdaQueryWrapper = new LambdaQueryWrapper<>();
LambdaQueryWrapper<ApplySetting> appid = applySettingLambdaQueryWrapper.eq(ApplySetting::getDisabled, EnableStatus.ENABLE).eq(ApplySetting::getStatus, EnableStatus.ENABLE)
.eq(ApplySetting::getAppId, headerMap.get("appid"));
ApplySetting applySetting = applySettingMapper.selectOne(appid);
if (applySetting == null) {
String errMsg = "未找到appId对应的appSecret, appId=" + headerMap.get("appid");
log.error(errMsg);
throw new ServiceException("403", "appId或appSecret不正确");
} else {
headerMap.put("appSecret", applySetting.getAppSecret());
}
//其他合法性校验
String timestamp = headerMap.get("timestamp");
if (StringUtils.isEmpty(timestamp)) {
throw new ServiceException("422", "参数timestamp不能为空");
}
Long now = System.currentTimeMillis();
Long requestTimestamp = Long.parseLong(headerMap.get("timestamp"));
if ((now - requestTimestamp) > EXPIRE_TIME) {
String errMsg = "请求时间超过规定范围时间10分钟, timestamp =" + headerMap.get("timestamp");
log.error(errMsg);
throw new ServiceException("408", errMsg);
}
/**
* 随机数位数
*/
String nonce = headerMap.get("nonce");
if (StringUtils.isEmpty(nonce)) {
throw new ServiceException("422", "随机串nonce不能为空");
}
if (nonce.length() < 10) {
String errMsg = "随机串nonce长度最少为10位, nonce=" + nonce;
log.error(errMsg);
throw new ServiceException("422", errMsg);
}
/**
* 表单重复提交问题
*/
if (!signature.resubmit()) {
String existNonce = (String) redisTemplate.opsForValue().get(nonce);
if (Objects.isNull(existNonce)) {
redisTemplate.opsForValue().set(nonce, nonce, RESUBMIT_DURATION, TimeUnit.MILLISECONDS);
} else {
String errMsg = "不允许重复请求, nonce=" + nonce;
log.error(errMsg);
throw new ServiceException("407", errMsg);
}
}
return headerMap;
}
AOP实现中,同时也实现了对接口进行限流
private static ConcurrentHashMap<String, ExpiringMap<String, Integer>> map= new ConcurrentHashMap<>();// 获取Map value对象, 如果没有则返回默认值 //getOrDefault获取参数,获取不到则给默认值 ExpiringMap<String, Integer> em= map.getOrDefault(signatureHeaders.get("appid"), ExpiringMap.builder().variableExpiration().build()); Integer count = em.getOrDefault(signatureHeaders.get("appid"), 0); if (count >= 10 ) { // 超过次数,不执行目标方法 throw new ServiceException("422", signatureHeaders.get("appid") + " 接口请求超过次数"); } else if (count == 0){ // 第一次请求时,设置有效时间 em.put(signatureHeaders.get("appid"), count + 1, ExpirationPolicy.CREATED,1 , TimeUnit.HOURS); } else { // 未超过次数, 记录加一 em.put(signatureHeaders.get("appid"), count + 1); } map.put(signatureHeaders.get("appid"), em);