Java API接口签名认证
我们在进行程序开发的时候,一定会开发一些API接口,供他人访问。当然这些接口中有可能是开放的,也有可能是需要登录才能访问的,也就是需要Token鉴权成功后才可以访问的。那么问题来了,我们这些开放的接口,难道不是一直暴露在外吗?该如何来保证这些接口的安全性呢?
本编文章,将通过API接口签名认证的方式来解决以上问题。
什么是接口签名认证
这个可以看成,比如,我申请了微信公众号或者小程序,公众号的基本信息中就会包含AppId和AppSecret两个数据。这两个数据需要我们用户进行保存,尤其是AppSecret更不能暴露在外,以保证安全。当我们需要请求微信进行授权登录的时候,我们需要根据微信的规则拼接请求链接,其中请求链接中会包含AppId,AppSecret等的一些信息,通过按规则拼接好的链接则可以成功请求微信,否则会请求失败。
而我们要做的就是根据微信的这个原理,实现自己程序的API接口签名验证。
接口签名参数规则
需要在每次请求的header中携带以下参数:
- appKey:相当于appId,一个请求来源的标识。
- sign:签名,由签名规则计算而来。
- t:时间戳,通过计算此时间戳与服务器当前时间差来防止请求重放问题。
签名(sign)计算公式、规则:
sign=MD5(data+AppSecret+t)
其中data为请求参数的拼接,其规则如下:
- path传参形式:如/api/user/{userId}/{mobile},单个或多个参数,按地址中参数的位置排序。则data=userId+mobile的字符串拼接。
- 对象形式传参,即json形式:需要按对象中的属性进⾏字典升序排序,然后对其属性值按此顺序进⾏拼接。如User类如下
@Data
class User{
private String userId;
private String mobile;
}
则data的计算为:userId,mobile两个属性名按字典排序。顺序为mobile->userId,如果mobile=17612345678;userId=123,则data的拼接顺序为 17612345678123。而sign=MD5(17612345678123+AppSecret+t)。问号拼接参数同理。
3. list形式传参:需要将list⾥⾯的内容进⾏依次拼接。
代码实现
以下代码中包含一些自己造的工具类,如AssertUtils,LocalCacheUtils等。
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.stream.Stream;
@Slf4j
@Component
public class VerifySignUtils {
@Autowired
private RedisRepository redisRepository;
// 设置请求重放的时间差
private final long SIGN_EXPIRE = 1000 * 10;
public boolean verifySign(String appKey, String sign, String t, Object object) {
// 参数判空,校验
log.info("传入的时间戳:{}", t);
if (null == appKey) {
log.error("没有传入appKey");
return false;
}
long now = System.currentTimeMillis();
// 校验请求重放
if (Long.parseLong(t) < now - SIGN_EXPIRE) {
log.error("sign失效!传入时间戳{};当前时间戳:{}", t, now);
return false;
}
// 取出缓存的AppId和AppSecret配置表,因为是对接多个程序,是以配置表的形式实现的
Map<Integer, String> appInfos = Optional.ofNullable(LocalCacheUtils.get(BaseConstants.APP_CACHE_NAME))
.map(it -> (Map<Integer, String>) it).orElseGet(() ->
(Map<Integer, String>) LocalCacheUtils.setExpire(BaseConstants.APP_CACHE_NAME, Optional
.ofNullable(redisRepository.get(BaseConstants.APP_CACHE_NAME))
.map(it -> (Map<Integer, String>) it).orElseGet(null),
BaseConstants.LOCAL_CACHE_APP_INFO_EXPIRE));
String secret = appInfos.get(Integer.parseInt(appKey));
if (null == secret) {
log.error("appKey错误");
return false;
}
// 根据请求,计算拼接参数,即data的拼接
String objectFields;
if (object instanceof Object[]) {
StringBuilder builder = new StringBuilder();
for (Object o : ((Object[]) object)) {
builder.append(o);
}
objectFields = builder.toString();
} else if (object instanceof List) {
StringBuilder builder = new StringBuilder();
((List) object).forEach(it -> builder.append(getObjectFields(it)));
objectFields = builder.toString();
} else if (object instanceof String || object instanceof Long || object instanceof Integer || object instanceof Boolean) {
objectFields = object.toString();
} else {
objectFields = getObjectFields(object);
}
log.info("参数按顺序拼接:{}", objectFields);
// 计算sign签名的值
String tempSign = Md5Utils.getMD5((objectFields + secret + t).getBytes()).toUpperCase();
log.info("计算出的sign:{}\t传入的sign:{}", tempSign, sign);
// 校验传入的签名和服务端计算的签名是否一致,不一致则,签名认证失败
if (!tempSign.equals(sign)) {
log.error("计算验签与传入的验签不符");
return false;
}
return true;
}
// 以下为通过反射拼接对象参数的方法
private String getObjectFields(Object object) {
final Field[] fields = object.getClass().getDeclaredFields();
final TreeMap<String, Object> treeMap = new TreeMap<>();
Stream.of(fields).map(Field::getName).forEach(it -> treeMap.put(it, getFieldValueByName(it, object)));
final StringBuilder builder = new StringBuilder();
treeMap.forEach((k, v) -> builder.append(v));
return builder.toString();
}
private Object getFieldValueByName(String fieldName, Object o) {
try {
String firstLetter = fieldName.substring(0, 1).toUpperCase();
String getter = "get" + firstLetter + fieldName.substring(1);
final Method method = o.getClass().getMethod(getter, new Class[]{});
final Object value = method.invoke(o, new Object[]{});
if (null == value) {
return "";
}
if (value instanceof List) {
return ((List) value).stream().map(it -> {
if (it instanceof String || it instanceof Long || it instanceof Integer || it instanceof Boolean) {
return it;
} else {
return getObjectFields(it);
}
}).reduce((it1, it2) -> it1 + "" + it2).get().toString();
}
return value;
} catch (Exception e) {
return "";
}
}
}
以上代码并不固定,也可根据自己的签名规则进行改造配置。
调用代码如下,通过对Controller配置AOP的形式进行调用:
@Aspect
@Slf4j
@Configuration
public class LogRecordAspect {
@Autowired
private VerifySignUtils verifySignUtils;
@Autowired
private AopLogUtil aopLogUtil;
@Value("${spring.profiles.active}")
private String active;
private static final List<String> activeList = Arrays.asList("prod","test");
ThreadLocal<Long> startTime = new ThreadLocal<Long>();
@Pointcut("execution(* com.xx.xx.xx.controller..*.*(..))")
public void webLog() {
}
@Before("webLog()")
public void doBefore(JoinPoint joinPoint) {
startTime.set(System.currentTimeMillis());
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
List<Object> collect = aopLogUtil.verifySignLog(joinPoint);
String appKey = request.getHeader("appKey");
String sign = request.getHeader("sign");
String t = request.getHeader("t");
boolean flag;
// get请求与其它请求进行区分,如果有delete,put请求,需要另加
if ("GET".equals(request.getMethod())) {
flag = verifySignUtils.verifySign(appKey, sign, t, collect.toArray());
} else {
flag = verifySignUtils.verifySign(appKey, sign, t, collect.get(0));
}
if (!flag && activeList.contains(active)) {
log.error("验签失败!");
throw new BaseException(R.SERVICE_VERIFY_SIGN_ERROR, "");
}
}
@AfterReturning(returning = "ret", pointcut = "webLog()")
public void doAfterReturning(Object ret) {
// 处理完请求,返回内容
log.warn("开始响应:RESPONSE: {} ", ret);
log.warn("响应时间: {} ms", System.currentTimeMillis() - startTime.get());
}
}