1、背景
我现在有几个公开的接口需要开放给第三方厂商,第三方厂商通过调用我的接口获取数据,由于这些接口放开了登录的拦截,而且是直接暴露在公网上的,因此想给这几个接口加上权限的验证,即加签验签。
2、技术选型
通过某歌和某度查找资料,以及向一些博主、论坛、微信群、QQ群请教…发现接口的加签验签最安全的方法是使用非对称加密的方式,即RSA的公私钥,但这种验签方式需要第三方提供他们的公钥给我方,第三方利用他们的私钥生成签名sign,然后我方使用第三方的公钥验证签名sign,识别调用接口者的身份。这个场景好像有点不符合我的要求,于是我选择了利用MD5加盐的方式,双方使用同一个盐字段加密解密(好像有点不是很安全…)
3、签名规则与流程
- 现在对原有的接口进行改造,主要是增加了三个参数:
appId: 由我分配给第三方的id,用于确认第三方的身份
timestamp: 当前时间戳,如果没有这个字段,那么就可以拿之前的链接重复访问了
sign: 签名字段,是把第三方的请求参数拼接,再拼接上secret盐字段,经过md5加密得到
- 我需要给第三方提供:
appId: 由我分配给第三方的id,用户确认第三方的身份
secret: 密钥,作为md5加密的盐,仅作加密使用, 不在请求参数中使用,要是这个泄露了,那么加密也就形同虚设了
uri: 接口在公网的地址
- 签名规则参考了支付宝开放平台的签名规则:
不包括字节类型参数,如文件、字节流,剔除 sign 字段,
剔除值为空的参数;按照第一个字符的键值 ASCII 码递增排序(字母升序排序),如果遇到相同字符则按照第二个字符的键值 ASCII 码递增排序,以此类推;将排序后的参数与其对应值,组合成 参数=参数值 的格式,并且把这些参数用 & 字符连接起来,此时生成的字符串为待签名字符串。
- 第三方将参数生成的字符串,拼接上secret密钥,经过md5加密,得到用户签名,就可以调用我的接口了:
http://localhost:8081/open/api/xxxxxx?appId=test×tamp=1616228945&sign=963DA2488CBCBB1E3736D9DA621408DE&参数1=value1&参数2=value2.......
- 接下来请求到了我这,我这边采用拦截器的方式,对特定接口进行验签
- 校验分发的appId是否存在
- 校验时间戳是否过期
- 与第三方生成签名sign一样,我这边使用同样的方式生成签名,如果我生成的签名和第三方参数中的签名一致,则放开拦截,继续执行方法,否则返回错误信息
4、代码
4.1、Controller参数
@RequestParam String appId,
@RequestParam Long timestamp,
@RequestParam String sign,
......
4.2、自定义拦截器
/**
* @author CHY
* @date 2021/3/18 15:50
* @description 自定义拦截器,拦截公开给第三方的接口,验签
*/
public class SignatureInterceptor implements HandlerInterceptor {
@Value("${open.api.secret}")
private String secret;
@Value("#{'${open.api.appIdList}'.split(',')}")
private List<String> appIdList;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws Exception {
String appId = request.getParameter("appId");
if (!appIdList.contains(appId)) {
throw new DException("appId错误");
}
String sign = request.getParameter("sign");
if (StringUtils.isEmpty(sign)) {
throw new DException("签名错误");
}
String timestamp = request.getParameter("timestamp");
// 时间戳要为10位的秒级时间戳
if (timestamp.length() != 10) {
throw new DException("timestamp时间戳格式错误");
};
//check时间戳的值是否在当前时间戳前后一30秒以内
String currTimestamp = String.valueOf(Instant.now().getEpochSecond());
int currTimestampNum = Integer.parseInt(currTimestamp);
int verifyTimestampNum = 0;
try {
verifyTimestampNum = Integer.parseInt(timestamp);
} catch (NumberFormatException e) {
throw new DException("timestamp时间戳格式错误");
}
// 在30秒范围之外,访问已过期
if (Math.abs(verifyTimestampNum - currTimestampNum) > 30) {
throw new DException("签名已经过期");
}
Enumeration<?> pNames = request.getParameterNames();
Map<String, String> params = new HashMap<>();
while (pNames.hasMoreElements()) {
String pName = (String) pNames.nextElement();
if ("sign".equals(pName)) {
continue;
}
String pValue = request.getParameter(pName);
params.put(pName, pValue);
}
if (sign.equals(SignUtils.getSign(params, secret))) {
return true;
} else {
throw new DException("签名错误");
}
}
}
4.3、注册拦截器
/**
* @author CHY
* @date 2020/8/30 1:14
* @description 配置
*/
@Configuration
public class MvcConfigurer implements WebMvcConfigurer {
@Bean
public SignatureInInterceptor signatureInInterceptor() {
return new SignatureInInterceptor();
}
/**
* 注册拦截器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
LinkedList<String> addPathList = new LinkedList<>();
addPathList.add("/open/api/xxxxxxxxxxxxxx");
addPathList.add("/open/api/xxxxxxxxxxxxxx");
addPathList.add("/open/api/xxxxxxxxxxxxxx");
registry.addInterceptor(signatureInInterceptor())
.addPathPatterns(addPathList);
}
/**
* 解决跨域问题
* @param registry
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowCredentials(true)
.allowedMethods("GET", "POST", "PUT", "DELETE","OPTION")
.allowedHeaders("*")
.maxAge(3600);
}
}
4.4、加密工具类,Main方法测试接口,模拟第三方发送请求
/**
* @author CHY
* @date 2021/3/18 9:25
* @description MD5加密工具类
*/
@Slf4j
public class SignMD5Utils {
private static final String appId = "xxx";
private static final String secret = "xxxxxxxxx";
public static void main(String[] args) throws IOException {
long nowTimestamp = Instant.now().getEpochSecond();
String updateTime = "2020/01/01 10:01:01";
String urlUpdateTime = updateTime.replaceAll(" ", "%20");
//参数签名测试例子
HashMap<String, String> signMap = new HashMap<>();
signMap.put("appId", appId);
signMap.put("timestamp", String.valueOf(nowTimestamp));
signMap.put("updateTime", updateTime);
String sign = getSign(signMap, secret);
System.out.println("得到签名sign: " + sign);
String url = "http://localhost:8081/open/api/xxxxxxxx?" + appId + "×tamp=" + nowTimestamp +
"&sign=" + sign + "&updateTime=" + urlUpdateTime;
System.out.println("生成的url: " + url);
String resp = doGet(url);
System.out.println(resp);
}
/**
* 得到签名
* @param params 参数集合,不含密钥secret
* @param secret 分配的密钥secret
* @return sign 签名
*/
private static String getSign(Map<String, String> params, String secret) {
StringBuilder sb = new StringBuilder();
// 先对请求参数去重并排序
Set<String> keySet = params.keySet();
TreeSet<String> sortSet = new TreeSet<>(keySet);
// 将排序后的参数与其对应值,组合成 参数=参数值 的格式,并且把这些参数用 & 字符连接起来,此时生成的字符串为待签名字符串
for (String key : sortSet) {
String value = params.get(key);
sb.append(key).append("=").append(value).append("&");
}
sb.append("secret=").append(secret);
byte[] md5Digest;
// Md5加密得到sign
md5Digest = getMd5Digest(sb.toString());
return byte2hex(md5Digest);
}
/**
* 获取md5信息摘要
* @param data 需要加密的字符串
* @return bytes 字节数组
*/
private static byte[] getMd5Digest(String data) {
byte[] bytes = null;
try {
MessageDigest md = MessageDigest.getInstance("MD5");
bytes = md.digest(data.getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException gse) {
log.error("生成签名错误", gse);
}
return bytes;
}
/**
* 将字节数组转化为16进制
* @param bytes 字节数组
* @return sign 签名
*/
private static String byte2hex(byte[] bytes) {
StringBuilder sign = new StringBuilder();
for (byte aByte : bytes) {
String hex = Integer.toHexString(aByte & 0xFF);
if (hex.length() == 1) {
sign.append("0");
}
sign.append(hex.toUpperCase());
}
return sign.toString();
}
/**
* java发送http请求
*/
private static String doGet(String url) throws IOException {
// 返回结果集
StringBuilder result = new StringBuilder();
// 输入流
BufferedReader in = null;
try {
// 链接URL
URL netUrl = new URL(url);
// 创建链接
HttpURLConnection conn = (HttpURLConnection) netUrl.openConnection();
// 连接服务器
conn.connect();
// 取得输入流,并使用Reader读取,设定字符编码
in = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
String line;
//读取返回值,直到为空
while ((line = in.readLine()) != null) {
result.append(line);
}
} catch (IOException e) {
throw new IOException("连接失败", e);
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
throw new IOException("生成签名错误", e);
}
}
}
return result.toString();
}
}