一、简介
首先谈谈什么是接口安全问题?接口安全,其实就是保证自己应用程序对外暴露接口的安全,即我这个接口只能某些第三方应用进行访问,不应该被别人随意访问。
服务端对外开放API接口,必须关注接口安全性的问题,要确保第三方应用程序与API接口之间的安全通信,防止数据被恶意篡改、伪造参数等攻击。
常见保证接口安全的方式有下面几种方式:
【a】签名验证方式(也叫url签名算法,本篇文章以本方式为例 ):服务端从某种层面来说需要验证接受到数据是否和客户端发来的数据是否一致,要验证数据在传输过程中有没有被注入攻击。这时候客户端和服务端就有必要做签名和验签。具体做法: 客户端对所有请求服务端接口参数做加密生成签名,并将签名作为请求参数一并传到服务端,服务端接受到请求同时要做验签的操作,对称加密对请求参数生成签名,并与客户端传过来的签名进行比对,如签名不一致,服务端需要拦截该请求;如果验证签名成功,才能放行接口。
【b】HTTPS
HTTPS能够有效防止中间人攻击,有效保证接口不被劫持,对数据窃取篡改做了安全防范。但HTTP升级HTTPS会带来更多的握手,而握手中的运算会带来更多的性能消耗。
【c】Token机制
具体的做法: 在用户成功登录时,系统可以返回客户端一个Token,后续客户端调用服务端的接口,都需要带上Token,而服务端需要校验客户端Token的合法性,是否超时过期等。Token不一致的情况下,服务端需要拦截该请求。
二、具体实现步骤
假设请求接口地址为: localhost:1111/getAllStudent ----POST方式
RequestBody请求参数: {noncestr=e697ea7d-27c3-42a5-a421-2aa3d7d58efc, timestamp=1590375647, signature=CC78E603495F5AB72727024F4EFF1168}
下面得区分客户端和服务器端:
(一)客户端
【a】生成当前时间戳timestamp=now: 可以使用下面的方法生成。
/**
* 获取时间戳
*/
private static String create_timestamp() {
return Long.toString(System.currentTimeMillis() / 1000);
}
【b】生成唯一随机字符串noncestr=random:可以使用下面的方法生成。
/**
* 获取随机字符串
*/
private static String create_nonce_str() {
return UUID.randomUUID().toString();
}
【c】按照请求参数名的字母升序排列非空请求参数(包含appKey、secretKey、noncestr和timestamp等). 【使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串string】
string="appkey=STUDENT_LIST_SAFE_INTERFACE_APP_KEY&noncestr=bc30b801-2683-4f09-baf5-890ee53f8a3a&secretkey=f1e4cd8ca987f11f2a773aa021316159×tamp=1590719955";
【d】使用MD5算法对上面拼接成功的string进行加密,并转换为大写(这里也可以使用SHA1算法等其他加密方式)。加密完成后生成signature签名。
signature = MD5(string).toUpperCase();
【e】将签名signature、随机字符串noncestr、随机时间戳timestamp作为参数传递到服务器端。
以上几个步骤就是客户端需要做的五件事情,下面聊聊服务器端需要怎么验证客户端发送过来的签名是否正确。
(二)服务器端
以一张图讲述大体验证签名的过程:
三、示例
下面通过一个SpringBoot项目简单说明签名验证的实现步骤,为了防止程序堆栈异常信息暴露给第三方应用,我们服务器端需要构建异常统一处理框架,将服务可能出现的异常做统一封装,返回固定的code与msg。
【a】创建一个SpringBoot项目作为服务器端,也就是暴露接口给第三方的服务,pom.xml依赖文件如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.11.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.wsh.springboot</groupId>
<artifactId>springboot-redis</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot-redis</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.58</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
【b】定义开放接口返回状态代码的枚举类:用于包装接口访问的一些错误代码
/**
* 接口返回状态代码的枚举类
*
* @author weixiaohuai
*/
public enum CommonResultCodeEnum {
/**
* 操作成功 {@code code=1}
*/
SUCCESS(1, "操作成功"),
/**
* 操作失败,请稍候再试 {@code code=0}
*/
FATAL(0, "操作失败,请稍候再试"),
/**
* 参数解析失败 {@code code=40000}
*/
PARAMETER_ANALYSIS_FAILURE(40000, "参数解析失败"),
/**
* 接口授权认证失败,签名不匹配 {@code code=40001}
*/
SIGNATURE_MISMATCH(40001, "接口授权认证失败,签名不匹配"),
/**
* 参数不足 {@code code=40002}
*/
NULL_PARAMS_DATA(40002, "参数不足"),
/**
* 参数值为空 {@code code=40003}
*/
NULL_PARAMS_VALUE(40003, "参数值为空"),
/**
* 缺少timestamp参数 {@code code=40004}
*/
MISSING_TIMESTAMP(40004, "缺少timestamp参数"),
/**
* 缺少nonceStr参数 {@code code=40005}
*/
MISSING_NONCESTR(40005, "缺少nonceStr参数"),
/**
* 缺少appkey参数 {@code code=40006}
*/
MISSING_APPKEY(40006, "缺少appKey参数"),
/**
* 缺少secretkey参数 {@code code=40007}
*/
MISSING_SECRETKEY(40007, "缺少secretKey参数"),
/**
* 服务器执行错误 {@code code=50000}
*/
ERROR(50000, "服务器执行错误"),
/**
* 请求数据不存在,或数据集为空 {@code code=60000}
*/
NULL_RESULT_DATA(60000, "请求数据不存在,或数据集为空");
private int value;
private String text;
private CommonResultCodeEnum(int value, String text) {
this.value = value;
this.text = text;
}
/**
* 获取value
*
* @return int
*/
public int getValue() {
return value;
}
/**
* 设置 value
*
* @param value int
*/
public void setValue(int value) {
this.value = value;
}
/**
* 获取text
*
* @return String
*/
public String getText() {
return text;
}
/**
* 设置 text
*
* @param text String
*/
public void setText(String text) {
this.text = text;
}
}
【c】对外开放接口统一返回结果包装类:包装返回结果,防止暴露异常信息出去。
package com.wsh.springboot.springbootredis.common;
/**
* 对外接口统一返回JSON结果包装类
*
* @author weixiaohuai
*/
public class CommonApiResult {
/**
* 是否成功标志,1:成功,0:失败
*/
private Integer isSuccess;
/**
* 返回业务数据
*/
private Object data;
/**
* 返回消息内容
*/
private String resMsg;
/**
* 返回结果代码
*/
private Integer resCode;
/**
* 默认构造方法
*/
public CommonApiResult() {
this.resCode = CommonResultCodeEnum.SUCCESS.getValue();
this.resMsg = CommonResultCodeEnum.SUCCESS.getText();
if (this.resCode == 1) {
this.isSuccess = 1;
} else {
this.isSuccess = 0;
}
}
public CommonApiResult(CommonResultCodeEnum commonresultcodeenum) {
this.resCode = commonresultcodeenum.getValue();
this.resMsg = commonresultcodeenum.getText();
if (this.resCode == 1) {
this.isSuccess = 1;
} else {
this.isSuccess = 0;
}
}
public Integer getIsSuccess() {
return isSuccess;
}
public void setIsSuccess(Integer isSuccess) {
this.isSuccess = isSuccess;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
public String getResMsg() {
return resMsg;
}
public void setResMsg(String resMsg) {
this.resMsg = resMsg;
}
public Integer getResCode() {
return resCode;
}
public void setResCode(Integer resCode) {
this.resCode = resCode;
}
@Override
public String toString() {
return "CommonApiResult{" +
"isSuccess=" + isSuccess +
", data=" + data +
", resMsg='" + resMsg + '\'' +
", resCode=" + resCode +
'}';
}
}
【d】定义接口验证签名工具类:用于服务器端验证签名相关的算法
package com.wsh.springboot.springbootredis.common;
import org.apache.commons.lang.StringUtils;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
/**
* @Description: 接口验证签名工具类
* @author: weishihuai
* @Date: 2020/5/25 09:59
*/
public class SignatureUtils {
/**
* 应用唯一标识
*/
private static final String APP_KEY = "appkey";
/**
* 应用秘钥
*/
private static final String SECRET_KEY = "secretkey";
/**
* 时间戳
*/
private static final String TIMESTAMP = "timestamp";
/**
* 随机字符串
*/
private static final String NONCE_STR = "noncestr";
//params: {noncestr=e697ea7d-27c3-42a5-a421-2aa3d7d58efc, timestamp=1590375647, signature=CC78E603495F5AB72727024F4EFF1168}
/**
* 验证签名算法
*
* @param params 第三方传递过来的验证签名的参数,包括签名、随机字符串、时间戳等
* @param appKey 应用唯一标识
* @param secretKey 应用秘钥
* @return 状态码
*/
public static CommonResultCodeEnum checkInterfaceSignature(Map<String, Object> params, String appKey, String secretKey) {
// 1. 验证参数
if (null == params || params.isEmpty() || StringUtils.isBlank(appKey) || StringUtils.isBlank(secretKey)) {
return CommonResultCodeEnum.NULL_PARAMS_DATA;
}
//第三方传入的签名
String signature = null != params.get("signature") ? params.get("signature").toString() : "";
//2. 验证是否缺少验签所需参数
Set<String> keys = params.keySet();
Iterator<String> iterator = keys.iterator();
//缺少随机时间戳参数
if (!keys.contains(TIMESTAMP)) {
return CommonResultCodeEnum.MISSING_TIMESTAMP;
}
//缺少随机字符串参数
if (!keys.contains(NONCE_STR)) {
return CommonResultCodeEnum.MISSING_NONCESTR;
}
//循环校验参数的值是否为空
while (iterator.hasNext()) {
String paramName = iterator.next();
Object paramValue = params.get(paramName);
if ((TIMESTAMP.equals(paramName) || NONCE_STR.equals(paramName)) && null == paramValue) {
return CommonResultCodeEnum.NULL_PARAMS_VALUE;
}
}
String[] signatureParamsKeys = new String[]{APP_KEY, SECRET_KEY, TIMESTAMP, NONCE_STR};
// 3.对参数进行排序
sort(signatureParamsKeys);
//组装参与签名生成的字符串(使用 URL 键值对的格式(即key1=value1&key2=value2…)拼接成字符串)
StringBuilder string = new StringBuilder();
for (int i = 0; i < signatureParamsKeys.length; i++) {
if (APP_KEY.equals(signatureParamsKeys[i])) {
if (StringUtils.isBlank(appKey)) {
return CommonResultCodeEnum.MISSING_APPKEY;
} else {
string.append(signatureParamsKeys[i]).append("=").append(appKey);
}
} else if (SECRET_KEY.equals(signatureParamsKeys[i])) {
if (StringUtils.isBlank(secretKey)) {
return CommonResultCodeEnum.MISSING_SECRETKEY;
} else {
string.append(signatureParamsKeys[i]).append("=").append(secretKey);
}
} else {
//拼接随机字符串、时间戳参数
String paramValue = null != params.get(signatureParamsKeys[i]) ? params.get(signatureParamsKeys[i]).toString() : "";
string.append(signatureParamsKeys[i]).append("=").append(paramValue);
}
if (i != signatureParamsKeys.length - 1) {
string.append("&");
}
}
// 4.生成加密签名
String mySignature = MD5(string.toString()).toUpperCase();
// 5.校验签名是否一致
if (StringUtils.isNotBlank(mySignature) && mySignature.equals(signature)) {
return CommonResultCodeEnum.SUCCESS;
}
return CommonResultCodeEnum.SIGNATURE_MISMATCH;
}
/**
* 字典排序算法
*
* @param strArr 待排序数组
*/
public static void sort(String[] strArr) {
for (int i = 0; i < strArr.length - 1; i++) {
for (int j = i + 1; j < strArr.length; j++) {
if (strArr[j].compareTo(strArr[i]) < 0) {
String temp = strArr[i];
strArr[i] = strArr[j];
strArr[j] = temp;
}
}
}
}
/**
* MD5加密
*
* @param plainText 待加密字符串
* @return
*/
public static String MD5(String plainText) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(plainText.getBytes());
byte[] b = md.digest();
int i;
StringBuilder buf = new StringBuilder();
for (byte b1 : b) {
i = b1;
if (i < 0) {
i += 256;
}
if (i < 16) {
buf.append("0");
}
buf.append(Integer.toHexString(i));
}
// 32位加密
return buf.toString();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
return null;
}
}
}
可见,上面使用了MD5加密方式,当前可以根据项目要求灵活选择其他加密算法。
【e】定义对外开放接口类
package com.wsh.springboot.springbootredis.controller;
import com.wsh.springboot.springbootredis.common.CommonApiResult;
import com.wsh.springboot.springbootredis.common.CommonResultCodeEnum;
import com.wsh.springboot.springbootredis.common.SignatureUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @Description: 对外开放接口类
* @author: weishihuai
* @Date: 2019/12/19 16:26
*/
@RestController
public class StudentController {
private static final Logger logger = LoggerFactory.getLogger(StudentController.class);
private static List<Map<String, Object>> studentList = new ArrayList<>();
static {
Map<String, Object> map;
for (int i = 1; i <= 5; i++) {
map = new HashMap<>();
map.put("name", "学生" + i);
map.put("sex", i % 2 == 0 ? "male" : "female");
map.put("age", i + 20);
studentList.add(map);
}
}
/**
* 查询所有学生信息
*
* @param params 查询参数
* @return
*/
@RequestMapping(value = "/getAllStudent", method = RequestMethod.POST)
public CommonApiResult getAllStudent(@RequestBody Map<String, Object> params) {
try {
/**
* 正常情况下, appKey和secretKey应该要弄成系统参数配置项进行配置或者从数据库中读取
* 这里为了演示方便,直接写死.
*/
//开发接口应用唯一标识
String appKey = "STUDENT_LIST_SAFE_INTERFACE_APP_KEY";
//开发接口秘钥
String secretKey = "f1e4cd8ca987f11f2a773aa021316159";
//验证签名, 验签通过,接口放行; 验签失败,直接返回错误码
CommonResultCodeEnum resultCode = SignatureUtils.checkInterfaceSignature(params, appKey, secretKey);
// 验证签名通过
if (resultCode.getValue() == 1) {
logger.info("接口验证通过......");
//返回结果
CommonApiResult commonApiResult;
//这里使用模拟数据返回,正常这里应该写具体的业务逻辑
if (null != studentList && studentList.size() > 0) {
commonApiResult = new CommonApiResult(CommonResultCodeEnum.SUCCESS);
//将业务数据包装返回数据
commonApiResult.setData(studentList);
} else {
commonApiResult = new CommonApiResult(CommonResultCodeEnum.NULL_RESULT_DATA);
}
return commonApiResult;
} else {
logger.info("接口验证失败......");
// 验证签名失败,直接返回错误码
return new CommonApiResult(resultCode);
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
return new CommonApiResult(CommonResultCodeEnum.ERROR);
}
}
}
以上就是服务器端的一些关键点,主要关注签名的验证方面。下面我们写一个测试类简单测试一下我们开放接口是否安全。
【f】测试类
@RequestMapping("/test")
public String test() {
Map<String, Object> params = new HashMap<>();
params.put("noncestr", "e697ea7d-27c3-42a5-a421-2aa3d7d58efc");
params.put("timestamp", "1590375647");
params.put("signature", "CC78E603495F5AB72727024F4EFF1168");
//为了演示方便,正常项目中应该是发送HTTP请求进行调用,这里主要关注验证签名这一块
this.getAllStudent(params);
return "success";
}
【g】测试结果
浏览器访问:http://localhost:1111/test
观察后端服务日志,可见接口验证签名通过,返回的数据也正常包装返回。
下面模拟一下接口验证不通过的场景,修改下测试类:随便写了一个时间戳参数1111111111.
@RequestMapping("/test")
public String test() {
Map<String, Object> params = new HashMap<>();
params.put("noncestr", "e697ea7d-27c3-42a5-a421-2aa3d7d58efc");
params.put("timestamp", "1111111111");
params.put("signature", "CC78E603495F5AB72727024F4EFF1168");
this.getAllStudent(params);
return "success";
}
访问http://localhost:1111/test,观察后台日志。
以上就是关于开放接口安全性--验证签名算法方式的讲解,可能还可以进一步优化,
【a】比如加入接口被调用的阈值限制,对接口访问频率设置一定阈值,对超过阈值的请求进行屏蔽及预警,防止我们的接口被玩坏。
【b】怎么防止请求被多次使用。
【c】白名单机制:就是指定一些可以访问我们暴露接口的域名,不在白名单里面的域名发过来的请求,直接拒绝。
还有一种更加安全的处理方式,大体步骤如下:
- 首先判断请求参数是否缺失,是否包含timestamp,token,signature,noncestr参数,如果参数缺失直接返回错误码;
- 其次判断服务器接到请求的时间和参数中的时间戳是否相差很长一段时间(时间自定义如10分钟),如果超过10分钟则说明该url已经过期(如果url被盗,改变了时间戳,会导致签名生成的signature不同,验签失败);
- 接着判断token是否有效,根据请求过来的token,查询redis缓存中是否存在该用户对应的token,如果获取不到,说明该token已过期,无法请求;
- 根据客户端提交过来的url参数,服务器端按照同样的规则生成signature签名,对比签名看是否相等,相等则放行;
流程图大体如下: