如何设计三方接口调用方案
在为第三方系统提供接口时,必须考虑接口数据的安全性,例如防止数据篡改、过期、重复提交等问题。以下是一种设计方案的概述:
设计方案概述
-
API密钥生成
- AK(Access Key):标识应用,公开用于标示用户。
- SK(Secret Key):加密认证密钥,必须保密,通过AK和SK加密验证请求的发送者身份。
-
接口鉴权
- 客户端使用AK和请求参数生成签名并放入请求头中进行身份验证。
-
回调地址设置
- 三方应用提供回调地址,用于接收异步通知和回调结果。
-
接口API设计
- 包括URL、HTTP方法、请求参数和响应格式等细节。
权限划分
- appID:应用的唯一标识,用于标识开发者账号。
- appKey:公开的密钥,相当于账号。
- appSecret:私密密钥,相当于密码。
- token:临时令牌,用于验证身份。
使用方法
- 请求授权时带上
AppKey
和AppSecret
。 - 验证
appKey
和appSecret
的合法性。 - 若合法,生成唯一
token
返回给客户端,后续每次请求都需要带上该token
。
appKey + appSecret机制的作用
为了加密和权限控制,首次验证(类似登录场景)需使用appKey
和appSecret
来申请accessToken
(带失效时间)。之后每次请求都需提供accessToken
来表明权限通过。
现在有了统一的appId,此时如果针对同一个业务要划分不同的权限,比如同一功能,某些场景需要只读权限,某些场景需要读写权限。这样提供一个appId和对应的秘钥appSecret就没办法满足需求。 此时就需要根据权限进行账号分配,通常使用appKey和appSecret。
简化的场景:
- 第一种场景: 通常用于开放性接口,像地图api,会省去app_id和app_key,此时相当于三者相等,合而为一 appId = appKey = appSecret。这种模式下,带上app_id的目的仅仅是统计某一个用户调用接口的次数而已了。
- 第二种场景: 当每一个用户有且仅有一套权限配置 可以去掉 appKey,直接将app_id = app_key,每个用户分配一个appId+ appSecret就够了。也可以采用签名(signature)的方式: 当调用方向服务提供方法发起请求时,带上(appKey、时间戳timeStamp、随机数nonce、签名sign) 签名sign 可以使用 (AppSecret + 时间戳 + 随机数)使用sha1、md5生成,服务提供方收到后,生成本地签名和收到的签名比对,如果一致,校验成功
签名流程
- appId和appSecret:唯一标识和密钥,给不同调用方分配。
- 时间戳(timeStamp):服务端当前时间为准,有效期5分钟以内。
- 流水号(nonce):临时随机数,有效期内不允许重复提交。
- 签名字段(sign):客户端传递签名信息,包含
appId
和sign
字段用于验证身份和防止参数篡改。
API接口设计示例
-
获取资源列表接口
- URL:
/api/resources
- HTTP方法:
GET
- 响应: 返回资源列表的JSON数组
- URL:
-
创建资源接口
- URL:
/api/resources
- HTTP方法:
POST
- 请求参数: 资源名称、描述
- 响应: 返回新资源ID等信息
- URL:
-
更新资源接口
- URL:
/api/resources/{resourceId}
- HTTP方法:
PUT
- 请求参数: 资源ID、资源名称、描述
- 响应: 200 OK
- URL:
-
删除资源接口
- URL:
/api/resources/{resourceId}
- HTTP方法:
DELETE
- 请求参数: 资源ID
- 响应: 204 No Content
- URL:
API接口设计
根据你的具体需求和业务场景,以下是一个简单示例的API接口设计:
- 获取资源列表接口
- URL: /api/resources
- HTTP 方法: GET
- 请求参数:
- page (可选): 页码
- limit (可选): 每页限制数量
- 响应:
- 成功状态码: 200 OK
- 响应体: 返回资源列表的JSON数组
- 创建资源接口
- URL: /api/resources
- HTTP 方法: POST
- 请求参数:
- name (必填): 资源名称
- description (可选): 资源描述
- 响应:
- 成功状态码: 201 Created
- 响应体: 返回新创建资源的ID等信息
- 更新资源接口
- URL: /api/resources/{resourceId}
- HTTP 方法: PUT
- 请求参数:
- resourceId (路径参数, 必填): 资源ID
- name (可选): 更新后的资源名称description (可选): 更新后的资源描述响应:成功状态码: 200 OK
- 删除资源接口
- URL: /api/resources/{resourceId}
- HTTP 方法: DELETE
- 请求参数:
- resourceId (路径参数, 必填): 资源ID
- 响应:
* 成功状态码: 204 No Content
安全性考虑
- 使用HTTPS:保护数据传输安全。
- 请求验签:服务端进行校验和鉴权。
- 敏感数据加密传输:例如TLS加密。
以上是一个简单的设计方案和API接口设计示例。具体的实现细节可能因项目需求而有所不同。在实际开发中,还要考虑错误处理、异常情况处理、日志记录等方面。防止重放攻击和对敏感数据进行加密传输都是保护三方接口安全的重要措施。以下是一些示例代码,展示了如何实现这些功能。
防止重放攻击的最佳实践
-
使用Nonce和Timestamp
- 在请求中添加唯一Nonce和Timestamp,服务端验证其有效性。
- 在请求中添加唯一的Nonce(随机数)和Timestamp(时间戳),并将其包含在签名计算中。服务端在验证签名时,可以检查Nonce和Timestamp的有效性,并确保请求没有被重放。
- 防止重放攻击是在三方接口中非常重要的安全措施之一。使用Nonce(一次性随机数)和Timestamp(时间戳)结合起来,可以有效地防止重放攻击。下面是实现此功能的最佳实践:
- 生成Nonce和Timestamp:Nonce应该是一个随机的、唯一的字符串,可以使用UUID或其他随机字符串生成算法来创建。Timestamp表示请求的时间戳,通常使用标准的Unix时间戳格式(以秒为单位)。
- 在每个请求中包含Nonce和Timestamp:将生成的Nonce和Timestamp作为参数添加到每个请求中,可以通过URL参数、请求头或请求体的方式进行传递。确保Nonce和Timestamp在每个请求中都是唯一且正确的。
- 服务器端验证Nonce和Timestamp:在服务器端接收到请求后,首先验证Nonce和Timestamp的有效性。检查Nonce是否已经被使用过,如果已经被使用过,则可能是重放攻击,拒绝该请求。检查Timestamp是否在合理的时间范围内,如果超出预定的有效期,则认为请求无效。
- 存储Nonce以检查重复请求,超时清理过期Nonce。
- 在请求中添加唯一Nonce和Timestamp,服务端验证其有效性。
-
添加过期时间
- 请求中加入过期时间字段(如token的有效期),超时请求直接拒绝。
防篡改、防重放攻击拦截器
- 每次HTTP请求,都需要加上timestamp参数,然后把timestamp和其他参数一起进行数字签名。HTTP请求从发出到达服务器一般都不会超过60s,所以服务器收到HTTP请求之后,首先判断时间戳参数与当前时间相比较,是否超过了60s,如果超过了则认为是非法的请求。
- 一般情况下,从抓包重放请求耗时远远超过了60s,所以此时请求中的timestamp参数已经失效了,如果修改timestamp参数为当前的时间戳,则signature参数对应的数字签名就会失效,因为不知道签名秘钥,没有办法生成新的数字签名。
- nonce的意思是仅一次有效的随机字符串,要求每次请求时,该参数要保证不同。我们将每次请求的nonce参数存储到一个“集合”中,每次处理HTTP请求时,首先判断该请求的nonce参数是否在该“集合”中,如果存在则认为是非法请求。
- nonce参数在首次请求时,已经被存储到了服务器上的“集合”中,再次发送请求会被识别并拒绝。
- nonce参数作为数字签名的一部分,是无法篡改的,因为不知道签名秘钥,没有办法生成新的数字签名。
- 这种方式也有很大的问题,那就是存储nonce参数的“集合”会越来越大。nonce的一次性可以解决timestamp参数60s(防止重放攻击)的问题,timestamp可以解决nonce参数“集合”越来越大的问题。
示例代码:
public class SignAuthInterceptor implements HandlerInterceptor {
private RedisTemplate<String, String> redisTemplate;
private String key;
public SignAuthInterceptor(RedisTemplate<String, String> redisTemplate, String key) {
this.redisTemplate = redisTemplate;
this.key = key;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String timestamp = request.getHeader("timestamp");
String nonceStr = request.getHeader("nonceStr");
String signature = request.getHeader("signature");
// 验证时间戳是否超时
long NONCE_STR_TIMEOUT_SECONDS = 60L;
if (StrUtil.isEmpty(timestamp) || DateUtil.between(DateUtil.date(Long.parseLong(timestamp) * 1000), DateUtil.date(), DateUnit.SECOND) > NONCE_STR_TIMEOUT_SECONDS) {
throw new BusinessException("invalid timestamp");
}
// 验证nonceStr是否已使用
Boolean haveNonceStr = redisTemplate.hasKey(nonceStr);
if (StrUtil.isEmpty(nonceStr) || Objects.isNull(haveNonceStr) || haveNonceStr) {
throw new BusinessException("invalid nonceStr");
}
// 验证签名
if (StrUtil.isEmpty(signature) || !Objects.equals(signature, this.signature(timestamp, nonceStr, request))) {
throw new BusinessException("invalid signature");
}
// 将nonceStr存入Redis并设置过期时间
redisTemplate.opsForValue().set(nonceStr, nonceStr, NONCE_STR_TIMEOUT_SECONDS, TimeUnit.SECONDS);
return true;
}
private String signature(String timestamp, String nonceStr, HttpServletRequest request) throws UnsupportedEncodingException {
Map<String, Object> params = new HashMap<>(16);
Enumeration<String> enumeration = request.getParameterNames();
if (enumeration.hasMoreElements()) {
String name = enumeration.nextElement();
String value = request.getParameter(name);
params.put(name, URLEncoder.encode(value, CommonConstants.UTF_8));
}
String qs = String.format("%s×tamp=%s&nonceStr=%s&key=%s", this.sortQueryParamString(params), timestamp, nonceStr, key);
return SecureUtil.md5(qs).toLowerCase();
}
/**
* 按照字母顺序进行升序排序
*
* @param params 请求参数 。注意请求参数中不能包含key
* @return 排序后结果
*/
private String sortQueryParamString(Map<String, Object> params) {
List<String> listKeys = Lists.newArrayList(params.keySet());
Collections.sort(listKeys);
StrBuilder content = StrBuilder.create();
for (String param : listKeys) {
content.append(param).append("=").append(params.get(param).toString()).append("&");
}
if (content.length() > 0) {
return content.subString(0, content.length() - 1);
}
return content.toString();
}
}
对敏感数据进行加密传输
使用TLS(传输层安全)协议可以保证通信过程中的数据加密和完整性。以下是一些基本步骤:
- 在服务器上配置TLS证书(包括公钥和私钥)。
- 客户端和服务器之间建立TLS连接。
- 客户端向服务器发送HTTPS请求。
- 在TLS握手期间,客户端和服务器协商加密算法和密钥交换方法。
- 握手成功后,客户端和服务器之间的所有数据传输都会经过加密处理。
以下是使用Java的示例代码,演示如何使用TLS进行加密传输:
// 创建SSLContext对象
SSLContext sslContext = SSLContext.getInstance("TLS");
// 初始化SSLContext,加载证书和私钥
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(new FileInputStream("keystore.jks"), "password".toCharArray());
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, "password".toCharArray());
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(keyStore);
sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom());
// 创建HttpsURLConnection连接
URL url = new URL("https://api.example.com/endpoint");
HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
connection.setSSLSocketFactory(sslContext.getSocketFactory());
// 设置其他请求参数、发送请求、处理响应等
在这段代码中,创建了一个SSLContext对象并初始化它,加载了服务器的证书和私钥。然后,通过HttpsURLConnection
对象设置TLS的安全套接字工厂,并与指定的URL建立了HTTPS连接。
AK和SK生成方案
可以参考各大云服务厂商如何获取AK/SK:如何获取AK/SK
三方接口AK和SK生成方案
- 设计API密钥管理系统:用于生成和管理AK和SK。
- 生成AK和SK:AK通常是一个公开的标识符,可以使用随机字符串或UUID生成;SK则是一个保密的私钥,用于生成身份验证签名和加密访问令牌。
- 存储和管理AK和SK:将生成的AK和SK存储在数据库或其他持久化存储中。
- 提供API密钥分发机制:可以通过界面、API或者自助注册流程向客户提供AK和SK。
- 安全性和最佳实践:定期轮换AK和SK,并使用AK和SK进行身份验证和权限控制。
数据库表设计
api_credentials
表
CREATE TABLE api_credentials (
id INT AUTO_INCREMENT PRIMARY KEY,
app_id VARCHAR(255) NOT NULL,
access_key VARCHAR(255) NOT NULL,
secret_key VARCHAR(255) NOT NULL,
valid_from DATETIME NOT NULL,
valid_to DATETIME NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
allowed_endpoints VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
字段 | 描述 |
---|---|
id | 主键,自增标识符 |
app_id | 应用程序ID |
access_key | 访问密钥(AK) |
secret_key | 秘密密钥(SK) |
valid_from | 有效期起始时间 |
valid_to | 有效期结束时间 |
enabled | 是否启用 |
allowed_endpoints | 允许访问的接口/端点列表 |
created_at | 记录创建时间 |
API接口设计补充
- 使用POST作为接口请求方式:所有接口采用POST方式请求。
- 客户端IP白名单:推荐使用防火墙规则进行白名单设置。
- 单个接口针对IP限流:可以使用Redis进行限流。
- 记录接口请求日志:快速定位异常请求,排查问题原因。
- 敏感数据脱敏:使用RSA非对称加密对敏感数据进行脱敏处理。
- 幂等性问题:使用唯一随机数确保幂等性。
- 版本控制:使用URL中的版本号进行接口版本管理。
- 响应状态码规范:采用HTTP状态码进行数据封装。
状态码设计示例
public enum CodeEnum {
SUCCESS(200, "处理成功"),
ERROR_PATH(404, "请求地址错误"),
ERROR_SERVER(505, "服务器内部发生错误");
private int code;
private String message;
CodeEnum(int code, String message) {
this.code = code;
this.message = message;
}
// Getter and Setter
}
统一响应数据格式
public class Result implements Serializable {
private static final long serialVersionUID = 793034041048451317L;
private int code;
private String message;
private Object data = null;
// Getter and Setter methods
public Result fillCode(CodeEnum codeEnum) {
this.setCode(codeEnum.getCode());
this.setMessage(codeEnum.getMessage());
return this;
}
public Result fillData(Object data) {
this.setCode(CodeEnum.SUCCESS.getCode());
this.setMessage(CodeEnum.SUCCESS.getMessage());
this.data = data;
return this;
}
}
接口签名生成步骤
- 参数排序:除去
sign
参数本身,按key升序排序。 - 拼接参数:将排序后的参数拼接成字符串。
- 拼接密钥:将分配的密钥
secret
拼接到字符串最后。 - 计算MD5:计算上述字符串的MD5值并转为大写作为
sign
。
示例
生成签名sign的详细步骤
- 第1步: 将所有参数(注意是所有参数,包括appId,timeStamp,nonce),除去sign本身,以及值是空的参数,按key名升序排序存储。
- 第2步: 然后把排序后的参数按 key1value1key2value2…keyXvalueX的方式拼接成一个字符串。这里的参数和值必须是传输参数的原始值,不能是经过处理的,如不能将"转成”后再拼接)
- 第3步: 把分配给调用方的密钥secret拼接在第2步得到的字符串最后面。即: key1value1key2value2…keyXvalueX + secret
- 第4步: 计算第3步字符串的md5值(32位),然后转成大写,最终得到的字符串作为签名sign。即: Md5(key1value1key2value2…keyXvalueX + secret) 转大写
- 举例:假设传输的数据是http://www.xxx.com/openApi?sign=sign_value&k1=v1&k2=v2&method=cancel&k3=&kX=vX
- 请求头是appId:zs001timeStamp:1612691221000sign:2B42AAED20E4B2D5BA389F7C344FE91Bnonce:1234567890
- 实际情况最好是通过post方式发送,其中sign参数对应的sign_value就是签名的值。
- 第一步:拼接字符串。首先去除sign参数本身,然后去除值是空的参数k3,剩下appId=zs001&timeStamp=1612691221000&nonce=1234567890&k1=v1&k2=v2&&method=cancel&kX=vX,然后按参数名字符升序排序,appId=zs001&k1=v1&k2=v2&kX=vX&method=cancel&nonce=1234567890&timeStamp=1612691221000
- 第二步:将参数名和值的拼接appIdzs001k1v1k2v2kXvXmethodcancelnonce1234567890timeStamp1612691221000
- 第三步:在上面拼接得到的字符串前加上密钥secret假设是miyao,得到新的字符串appIdzs001k1v1k2v2kXvXmethodcancelnonce1234567890timeStamp1612691221000miyao
- 第四步:然后将这个字符串进行md5计算假设得到的是abcdef,然后转为大写,得到ABCDEF这个值作为签名sign注意,计算md5之前调用方需确保签名加密字符串编码与提供方一致,如统一使用utf-8编码或者GBK编码,如果编码方式不一致则计算出来的签名会校验失败
什么是Token
Token用于标识接口调用者的身份,减少用户名和密码的传输次数。Token的类型:
- API Token:用于访问不需要用户登录的接口。
- User Token:用于访问需要用户登录的接口。
Token + 签名验证
- 客户端向服务器提供认证信息(如账号和密码),服务器验证成功后返回Token给客户端。
- 客户端将Token缓存在本地,后续每次发起请求时,携带此Token。
- 服务端检查Token有效性,有效则放行,无效则拒绝。
评论区