在为第三方系统提供接口时,数据安全是首要考虑的问题,比如数据是否会被篡改、请求是否已经过时、数据能否被重复提交等。这篇文章会聊一聊我在设计三方接口调用方案时的一些思路,重点探讨如何利用 API 密钥(Access Key/Secret Key)进行身份验证,并兼顾安全性和可用性。

设计方案概述
一个可靠的设计方案,通常包含以下几个核心环节:
-
API 密钥生成:为每个三方应用分配唯一的 API 密钥对(AK/SK)。AK 用于标识应用,而 SK 则用于生成签名和加密。
- AK (Access Key Id):用户的身份标识,相当于公开的“用户名”。
- SK (Secret Access Key):用于加密认证字符串和验证其合法性的密钥,必须严格保密,类似于“密码”。
通过这对密钥,我们可以验证某个请求的发送者身份。
-
接口鉴权:客户端调用接口时,必须用 AK 和请求参数一起生成签名,并把签名放进请求头或参数中,以供服务端校验。
-
回调地址设置:三方应用需提供一个回调地址,用于接收异步通知和处理结果。
-
接口 API 设计:这包括规划接口的 URL、HTTP 方法、请求参数、响应格式等具体细节。
权限划分
为了更精细地控制接口权限,我们引入了几个概念:
- appID:应用的唯一标识,就像一个用户 ID。在数据库中建立索引可以方便快速查找。同一个
appId 可以对应多对 appKey+appSecret,从而实现更细粒度的权限隔离。
- appKey:公钥(相当于账号),是公开的,用于调用服务。
- appSecret:私钥(相当于密码),需要保密存储,用于生成签名。
- token:令牌(有过期时间),用于维持会话状态。
典型使用流程
- 客户端向服务端发起授权请求,提交
appKey 和 appSecret(appSecret 应存于服务端)。
- 服务端验证这对凭证在数据库或缓存中是否存在。
- 验证成功后,生成一个唯一的字符串(
token令牌),返回给客户端。
- 之后,客户端每次请求都需要携带这个
token 令牌。
为什么需要 appKey + appSecret 成对出现?
这主要是为了加密和权限细分。通常,在首次验证(类似登录)时,用 appKey(申请权限标识)和 appSecret(密码,证明你真的持有这个权限)来换取一个 accessToken(通常有过期时间)。后续请求只需提供 accessToken 即可证明权限。
当我们需要对同一个业务划分不同权限时(例如,某些场景只需只读,某些需要读写),单一的 appId 和 appSecret 就难以满足需求。通过为同一个 appId 生成多对 appKey+appSecret,并为每对 appKey+appSecret 分配不同的权限,就能轻松解决。例如:
appKey1 + appSecret1 只拥有删除权限。
appKey2 + appSecret2 拥有读写权限。
这样,你就可以把不同权限的凭证分发给不同的开发者。
这里有几个简化的应用场景:
- 开放接口:类似于公开的地图 API,可能会省去
app_id 和 app_key,此时三者合一,appId = appKey = appSecret。这种模式主要就是为了统计用户的调用次数。
- 单一权限用户:如果每个用户只有一套权限,可以直接整合,让
app_id = app_key,为每个用户分配一对 appId+ appSecret 就够了。
当然,也可以采用签名(signature)的方式:调用方发起请求时,带上 appKey、timeStamp、nonce 和签名 sign。sign 通常由 AppSecret、timeStamp 和 nonce 通过 sha1 或 md5 算法生成。服务端收到请求后,会用同样的方式生成本地签名,然后和收到的签名进行比对,一致则通过。
签名流程
下面这张序列图清晰地展示了客户端与服务端之间基于签名的完整验证流程。

签名规则
一套完善的签名规则,是接口安全的基石。核心步骤如下:
-
分配 appId 和 appSecret:appId 全局唯一,appSecret 需高度保密。
-
加入 timeStamp(时间戳):以服务端时间为准,单位通常是毫秒(ms),用于防止 DOS 攻击。服务端会设置一个时间阈值(例如 5 分钟),如果服务端当前时间与请求中的时间戳之差超过阈值,则认为签名超时,请求失败。
-
加入临时流水号 nonce:至少 10 位,用于在有效期内防止重复提交。相邻两次请求的 nonce 不允许重复,否则会被认为是重复提交。
- 对于查询接口,
nonce 主要用于日志记录,方便后期排查。
- 对于办理类接口(如支付、修改数据等),则必须校验
nonce 在有效期内的唯一性,彻底避免重复请求。
通过在请求参数中加入 timeStamp 和 nonce,可以有效抵御“重放攻击”。
timeStamp 限定了请求的有效时间窗口,比如 60 秒。即使请求被截获,攻击窗口也只有这 60 秒。
nonce 则弥补了时间窗口内的风险。服务端会检查 60 秒内的 nonce 是否重复。在极短时间内,随机生成两个相同 nonce 的概率几乎为零。因此,如果发现重复的 nonce,基本可以判定为重放攻击。通常使用 Redis 来存储和校验 nonce。
-
加入签名字段 sign:解决身份验证和防止“参数篡改”。请求会携带 appId 和 Sign 参数。只有 appId 合法且 Sign 正确,请求才会被放行。即使请求参数被劫持,由于黑客无法获取不参与网络传输的 appSecret,也就无法伪造出合法的请求。
以上这些字段通常都放在 HTTP 请求头中。
API接口设计
根据你的具体业务,这里有几个基础的 API 接口设计示例:
1. 获取资源列表接口
- URL:
/api/resources
- HTTP Method:
GET
- 请求参数:
page (可选): 页码
limit (可选): 每页数量
- 响应:
- 成功状态码:
200 OK
- 响应体: 返回资源列表的 JSON 数组
2. 创建资源接口
- URL:
/api/resources
- HTTP Method:
POST
- 请求参数:
name (必填): 资源名称
description (可选): 资源描述
- 响应:
- 成功状态码:
201 Created
- 响应体: 返回新创建资源的 ID 等信息
3. 更新资源接口
- URL:
/api/resources/{resourceId}
- HTTP Method:
PUT
- 请求参数:
resourceId (路径参数, 必填): 资源ID
name (可选): 更新后的资源名称
description (可选): 更新后的资源描述
- 响应:
4. 删除资源接口
- URL:
/api/resources/{resourceId}
- HTTP Method:
DELETE
- 请求参数:
resourceId (路径参数, 必填): 资源ID
- 响应:
安全性考虑
为了让接口更安全,除了签名机制,我们还可以从以下几个层面加固:
- 传输安全:全程使用 HTTPS 协议,保证数据传输过程中的私密性和完整性。
- 身份与请求校验:在请求中使用 AK/SK 签名进行身份验证和请求验签,服务端负责严格校验,以防止非法请求和重放攻击。
- 敏感数据加密:对于核心敏感数据,即使通过 HTTPS,也可在应用层进行二次加密,例如使用 TLS 加密算法或 RSA 非对称加密。
防止重放攻击
重放攻击是指攻击者通过抓包获取了你的合法请求,然后原封不动地反复向服务器发送。如果这是付款或下单接口,后果将十分严重。使用 timestamp + nonce + sign 的组合拳可以有效防范。
- sign 的作用:确保请求的有效性和完整性,防止参数被篡改。因为签名需要密钥,第三方无法伪造。
- timestamp 的作用:确保请求的时效性。服务端拒绝处理过期或时间不合理的请求。
- nonce 的作用:确保请求的“一次性”。服务端会记住近期处理过的
nonce,拒绝处理携带相同 nonce 的请求。
实现时需要注意:
- 在请求中加入由客户端生成的、唯一的
Nonce(如 UUID)和 Timestamp。
- 服务端收到请求后,先验证
Timestamp 是否在允许的时间范围内,再检查 Nonce 是否已经被使用过。可以借助 Redis 等缓存来存储和管理已使用的 Nonce,并设置与时效性相匹配的过期时间。
防篡改、防重放攻击拦截器
下面是一个基于 HandlerInterceptor 的拦截器实现,它会从请求头中获取 timestamp、nonceStr 和 signature,并执行完整的防重放与验签逻辑。
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");
// 判断时间是否大于xx秒(防止重放攻击)
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参数是否已经在redis中(防止短时间内的重放攻击)
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中设置xx秒后自动删除
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);
log.info("qs:{}", qs);
String sign = SecureUtil.md5(qs).toLowerCase();
log.info("sign:{}", sign);
return sign;
}
/**
* 按照字母顺序进行升序排序
*
* @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(传输层安全)协议可以为通信通道提供加密与完整性保障。用 Java 实现一个简单的 HTTPS 连接的示例代码如下:
// 创建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());
// 设置其他请求参数、发送请求、处理响应等
请记得将示例中的 keystore.jks 文件路径和密码替换为你自己的真实信息。
AK和SK生成方案
可以参考主流云服务厂商的 AK/SK 生成机制。你需要设计一个 API 密钥管理系统,用于生成和管理这些凭证。AK 通常是唯一的标识符,可以公开;SK 是保密的私钥,用于签名和加密,必须安全存储。建议实施严格的权限控制,对存储的 SK 进行加密处理,并建立一个安全的分发机制给到客户. 同时,定期轮换 AK/SK 也是一个提升安全性的好习惯。
下面是存储 AK/SK 的数据库表设计参考:
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
);
这个表包含了 app_id、AK/SK、有效期起止时间、是否启用、允许访问的端点列表及创建时间等关键字段,你可以根据实际需求进行调整。
API 接口设计补充
除了核心的安全机制,一个完善、好用的 API 还需要在很多细节上下功夫。下面这张思维导图很好地概括了规范要点。

1. 使用POST作为接口请求方式
为了更高的安全性并避免 URL 长度限制问题,建议将除简单的公开查询外的所有接口都采用 POST 方式请求。GET 请求会直接将参数暴露在 URL 中,风险太高。
2. 客户端IP白名单
通过防火墙或 Sentinel 等工具设置 IP 白名单,仅允许名单内的 IP 访问接口,可以有效阻止未授权的访问。缺点是当客户端发生迁移时,需要更新白名单,推荐使用防火墙规则进行管理。
3. 单个接口针对IP限流
限流是保障系统稳定性的重要手段。可以利用 Redis,以 IP + 接口地址 作为 key,访问次数作为 value,并设置过期时间,来限制单个 IP 对特定接口的调用频率。
4. 记录接口请求日志
全面的请求日志是问题排查的利器。通过 Spring AOP 等方式,可以全局拦截并记录接口的请求参数、响应结果、耗时、客户端 IP 等信息,快速定位异常请求。
5. 敏感数据脱敏
接口交互中可能涉及订单号、手机号等敏感数据,这类数据在存储和传输时应进行脱敏处理。最常用的方式是加密,推荐使用安全性较高的 RSA 非对称加密算法。
6. 幂等性问题
幂等性是指多次请求的结果与一次请求的结果相同,副作用一致。查询操作天然是幂等的,但新增、修改等操作则不是。解决非幂等操作重复提交问题的一种严谨思路是:
- 客户端在每次提交前,先从服务端获取一个全局唯一的“令牌”(或随机数)。
- 第一次提交时,业务处理成功后,将令牌作为 key,操作结果作为 value,存入 Redis 并设置过期时间。
- 第二次提交携带同一令牌时,服务端会发现 Redis 中已存在该 key,直接判定为重复提交并返回错误。
7. 版本控制
一套成熟的 API 文档发布后,不应随意修改既有接口。当需要新增或修改时,必须引入版本控制。常见做法是在接口地址中带上版本号,如 http://ip:port/v1/list 和 http://ip:port/v2/list。
8. 响应状态码规范
一个设计优秀的 API 需要提供清晰明了的响应状态码。可以直接采用 HTTP 状态码进行封装,比如 200 代表成功,4xx 代表客户端错误,5xx 代表服务端错误。
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;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
9. 统一响应数据格式
为了方便客户端处理,响应数据应该有一个统一的外壳,通常包含三个属性:code(状态码)、message(信息描述)和 data(响应数据)。
public class Result implements Serializable {
private static final long serialVersionUID = 793034041048451317L;
private int code;
private String message;
private Object data = null;
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public Object getData() {
return data;
}
/**
* 放入响应枚举
*/
public Result fillCode(CodeEnum codeEnum) {
this.setCode(codeEnum.getCode());
this.setMessage(codeEnum.getMessage());
return this;
}
/**
* 放入响应码及信息
*/
public Result fillCode(int code, String message) {
this.setCode(code);
this.setMessage(message);
return this;
}
/**
* 处理成功,放入自定义业务数据集合
*/
public Result fillData(Object data) {
this.setCode(CodeEnum.SUCCESS.getCode());
this.setMessage(CodeEnum.SUCCESS.getMessage());
this.data = data;
return this;
}
}
10. 接口文档
一个好的 API 离不开一份清晰易读的接口文档。Swagger2 或类似的接口管理工具能极大地减轻程序员的负担,通过简单配置,即可在开发中在线测试接口,上线后也能导出离线文档。
11. 生成签名sign的详细步骤
结合一个具体案例,看看 sign 究竟是如何生成的。
第1步:除去 sign 本身,将所有非空参数(包括 appId, timeStamp, nonce 等)按参数名字符升序排序。
第2步:将排序后的参数以 key1value1key2value2…keyXvalueX 的格式拼接成一个字符串。注意,这里拼接的都是原始值,不能经过转义处理。
第3步:将分发给调用方的密钥 secret 拼接到第2步得到的字符串最后。
第4步:计算第3步得到字符串的 MD5 值(32位),然后转成大写,这个最终得到的字符串就是签名 sign。
举例:
假设传输的数据(最好是 POST 方式)和请求头是:
-
请求头: appId:zs001, timeStamp:1612691221000, sign:2B42AAED20E4B2D5BA389F7C344FE91B, nonce:1234567890
-
第一步:去除 sign 和值为空的参数,将剩余参数 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)一致,否则验签会失败。
12. Token 与有状态接口签名
什么是Token?
Token 即访问令牌,用于标识接口调用者的身份,减少用户名和密码在网络中的传输次数。它的值通常是 UUID。服务端生成 Token 后,会以 Token 为 key,将关联的用户信息作为 value 存入 Redis 等缓存。当一个带 Token 的请求到来时,服务端就去缓存中查询这个 Token 是否存在,以此判断是否放行。
Token 主要分为两种:
- API Token (接口令牌):用于访问不需要用户登录的公共接口,如获取基础数据等。它通过
appId、timestamp 和 sign 来换取。
- USER Token (用户令牌):用于访问需要登录后才能操作的接口。它通过用户名和密码来换取。
Token + 签名验证
单纯的 Token 验证存在安全隐患:一旦 Token 被劫持,攻击者就可以伪造请求和篡改参数。解决方法是结合签名。
为客户端分配一个不参与传输的 appSecret(密钥)。客户端在发送请求时,将 Token 与所有请求参数一并进行签名计算,然后将签名值 sign 一起发送给服务端。服务端用同样的规则验证签名。这样,即使 Token 泄露,攻击者因不知道 appSecret 和签名算法,也无法伪造合法的请求。
下图展示了用户登录、登出及后续带 Token + 签名验证的流程。

后续请求的拦截与验证逻辑,则可参考下面的流程:

至此,一个相对完整的三方接口调用安全方案就聊完了。实际开发中,关于 后端架构 与安全的设计还有很多细节值得深挖,希望这篇文章能为你提供一个清晰的设计方向和参考。