本地开放接口设计最佳实践
在实际工作中,我们需要经常跟第三方平台打交道,可能会对接第三方平台Controller接口,或者提供Controller接口给第三方平台调用。
那么问题来了,如果设计一个优雅的Controller接口,能够满足:安全性、可重复调用、稳定性、好定位问题等多方面需求?
今天跟大家一起聊聊设计Controller接口时,需要注意的一些地方,希望对你会有所帮助。
1. 签名验证(防篡改)
为了防止Controller接口中的数据被篡改,很多时候我们需要对Controller接口做签名。
请求方传递的参数
请求方需要在**请求头(Headers)**中传递以下关键参数:
| Header参数 | 说明 | 示例 |
|---|---|---|
X-Timestamp |
Unix时间戳(毫秒),用于防止重放攻击 | 1715632845123 |
X-Sign |
签名值,通过指定算法生成 | a1b2c3d4e5f6... |
X-AccessKey |
(AK/SK模式)访问密钥标识 | ak_1234567890abcdef |
X-Nonce |
随机字符串,防止重放攻击(可选) | random123456 |
注意:所有业务参数仍然放在请求体(Body)或URL参数中,签名只针对这些参数进行计算。
签名生成算法(详细步骤)
固定密钥模式
// 1. 收集所有参与签名的参数(包括URL参数和Body参数)
Map<String, Object> params = new HashMap<>();
params.put("userId", "12345");
params.put("amount", "100.50");
params.put("currency", "CNY");
params.put("timestamp", "1715632845123"); // 必须与X-Timestamp一致
// 2. 按参数名ASCII码排序
List<String> sortedKeys = new ArrayList<>(params.keySet());
Collections.sort(sortedKeys);
// 3. 拼接参数字符串(格式:key1=value1&key2=value2...)
StringBuilder paramString = new StringBuilder();
for (String key : sortedKeys) {
if (paramString.length() > 0) {
paramString.append("&");
}
paramString.append(key).append("=").append(params.get(key));
}
// 4. 拼接密钥(固定密钥)
String privateKey = "your_fixed_private_key_123456";
String signSource = paramString.toString() + "&key=" + privateKey;
// 5. 生成MD5签名(32位小写)
String sign = DigestUtils.md5Hex(signSource).toLowerCase();
// 6. 在请求头中设置签名和时间戳
headers.put("X-Timestamp", "1715632845123");
headers.put("X-Sign", sign);
AK/SK模式(推荐)
// 1. 参数收集和排序(同上)
// ... 参数收集和排序逻辑 ...
// 2. 拼接参数字符串
// ... 拼接逻辑 ...
// 3. 获取对应的SecretKey(根据AccessKey查询)
String accessKey = "ak_1234567890abcdef";
String secretKey = getSecretKeyByAccessKey(accessKey); // 从数据库或配置中心获取
// 4. 拼接签名源字符串
String signSource = paramString.toString() + "&key=" + secretKey;
// 5. 生成签名
String sign = DigestUtils.md5Hex(signSource).toLowerCase();
// 6. 设置请求头
headers.put("X-Timestamp", "1715632845123");
headers.put("X-AccessKey", accessKey);
headers.put("X-Sign", sign);
服务端验证流程
完整验证步骤
@RestController
public class ApiController {
@PostMapping("/api/order")
public ResponseEntity<?> createOrder(@RequestBody OrderRequest request,
HttpServletRequest httpRequest) {
try {
// 1. 验证时间戳(防止重放攻击)
validateTimestamp(httpRequest);
// 2. 验证签名
validateSignature(request, httpRequest);
// 3. 业务逻辑处理
return processOrder(request);
} catch (SecurityException e) {
return ResponseEntity.status(401).body(createErrorResponse(e.getMessage()));
}
}
private void validateTimestamp(HttpServletRequest request) {
String timestampStr = request.getHeader("X-Timestamp");
if (timestampStr == null) {
throw new SecurityException("Missing X-Timestamp header");
}
long timestamp = Long.parseLong(timestampStr);
long currentTime = System.currentTimeMillis();
long timeDiff = Math.abs(currentTime - timestamp);
// 允许15分钟的时间偏差
if (timeDiff > 15 * 60 * 1000) {
throw new SecurityException("Request timestamp expired");
}
}
private void validateSignature(Object requestBody, HttpServletRequest request) {
String providedSign = request.getHeader("X-Sign");
String timestamp = request.getHeader("X-Timestamp");
String accessKey = request.getHeader("X-AccessKey");
if (providedSign == null) {
throw new SecurityException("Missing X-Sign header");
}
// 1. 重新构建参数Map(包含请求体和时间戳)
Map<String, Object> params = buildParamsFromRequest(requestBody, timestamp);
// 2. 获取正确的密钥
String secretKey;
if (accessKey != null) {
// AK/SK模式
secretKey = getSecretKeyByAccessKey(accessKey);
} else {
// 固定密钥模式
secretKey = getFixedPrivateKey();
}
// 3. 用相同算法生成签名
String expectedSign = generateSign(params, secretKey);
// 4. 比较签名(使用安全的字符串比较)
if (!MessageDigest.isEqual(
expectedSign.getBytes(StandardCharsets.UTF_8),
providedSign.getBytes(StandardCharsets.UTF_8))) {
throw new SecurityException("Invalid signature");
}
}
private Map<String, Object> buildParamsFromRequest(Object requestBody, String timestamp) {
Map<String, Object> params = new HashMap<>();
// 将请求体对象转换为Map
if (requestBody instanceof Map) {
params.putAll((Map<String, Object>) requestBody);
} else {
// 使用JSON工具将对象转换为Map
params.putAll(objectToMap(requestBody));
}
// 添加时间戳
params.put("timestamp", timestamp);
return params;
}
}
实现流程
- 接口请求方将请求参数 + 时间戳 + 密钥拼接成一个字符串
- 通过MD5等hash算法,生成一个签名sign
- 在请求参数或者请求头中,增加sign参数,传递给API接口
- API接口的网关服务获取到该sign值,用相同的请求参数 + 时间戳 + 密钥拼接成字符串
- 用相同的MD5算法生成另外一个sign,对比两个sign值是否相等
验证结果
- 签名相等:认为是有效请求,转发给业务系统
- 签名不等:直接返回签名错误
为什么签名中要加时间戳?
为了安全性考虑,防止同一次请求被反复利用,增加了密钥被破解的可能性。我们必须要对每次请求都设置一个合理的过期时间,比如:15分钟。
这样一次请求,在15分钟之内是有效的,超过15分钟,API接口的网关服务会返回超过有效期的异常提示。
密钥管理方式
目前生成签名中的密钥有两种形式:
- 固定密钥:双方约定一个固定值
privateKey - AK/SK模式:API接口提供方给出AK/SK两个值
- 双方约定用SK作为签名中的密钥
- AK作为header中的
accessKey传递给API接口提供方 - API接口提供方根据AK获取到SK,生成新的sign
最佳实践:推荐使用AK/SK模式,便于密钥轮换和权限管理。
安全注意事项
- 参数排序:必须严格按照参数名的ASCII码顺序排序,确保客户端和服务端生成的字符串完全一致
- 空值处理:空值参数应该包含在签名中(如
param=),不能忽略 - 编码一致性:确保字符编码一致(推荐UTF-8)
- 安全比较:签名比较时使用恒定时间比较算法,防止时序攻击
- HTTPS传输:签名验证必须在HTTPS环境下进行,防止中间人攻击
完整请求示例
请求头(Headers):
X-Timestamp: 1715632845123
X-AccessKey: ak_1234567890abcdef
X-Sign: a1b2c3d4e5f678901234567890abcdef
Content-Type: application/json
请求体(Body):
{
"userId": "12345",
"amount": 100.50,
"currency": "CNY",
"orderId": "ORD20240513001"
}
签名源字符串(按ASCII排序后):
amount=100.50¤cy=CNY&orderId=ORD20240513001×tamp=1715632845123&userId=12345&key=sk_secret_key_here
2. 数据加密(应用层加密是额外防护,隐私数据加密处理)
有些时候,我们的Controller接口直接传递非常重要的数据,比如:用户的登录密码、银行卡号、转账金额、用户身份证等,如果将这些参数直接明文暴露到公网上是非常危险的事情。
非对称加密方案(RSA)
目前使用比较多的是用RSA加密。
RSA工作原理
- RSA包含一对:公钥和私钥
- 公钥加密的数据只能用私钥解密
- 私钥加密的数据只能用公钥解密
用户登录密码示例
- 用户输入密码后,在前端使用公钥做加密处理
- 公钥保留在前端代码中,即使泄露也没关系
- 后端服务使用对应的私钥才能解密
- 私钥保存在后端服务的配置中,别人无法获取
密钥生成:可以使用在线工具生成密钥对:https://tools.ytdevops.com/rsa-
对称加密补充(AES)
对于大量数据传输,也可以考虑使用AES对称加密:
- 性能更好,适合大数据量加密
- 需要安全地交换密钥(可通过RSA加密传输AES密钥)
3. IP白名单
为了进一步加强API接口的安全性,防止接口的签名或者加密被破解了,攻击者可以在自己的服务器上请求该接口。
实施策略
- 限制请求IP:只有在白名单中的IP地址,才能成功请求API接口
- 网关层实现:IP白名单也可以加在API网关服务上
- 防御纵深:防止公司内部应用服务器被攻破的情况,需要增加Web防火墙(如:ModSecurity等)
注意:IP白名单不是万能的,需要结合其他安全措施使用。
4. 限流控制(限制请求频次)
如果你的API接口被第三方平台调用了,这就意味着调用频率是没法控制的。第三方平台调用你的API接口时,如果并发量一下子太高,可能会导致你的API服务不可用,接口直接挂掉。
限流方法
- IP限流:同一个IP,在一分钟内,对API接口总的请求次数不能超过10000次
- 接口限流:同一个IP,在一分钟内,对指定的API接口,请求次数不能超过2000次
- 用户限流:同一个AK/SK用户,在一分钟内,对API接口总的请求次数不能超过10000次
技术实现
- Nginx:基于IP或请求路径的限流
- Redis:分布式限流,支持复杂的限流策略
- API Gateway:Spring Cloud Gateway、Kong等网关产品
- Guava RateLimiter:单机限流
建议:生产环境建议使用Redis+Lua实现分布式限流。
5. 参数校验(防御式编程实践)
我们需要对API接口做参数校验,比如:校验必填字段是否为空,校验字段类型,校验字段长度,校验枚举值等等。
校验的重要性
- 拦截无效请求:避免浪费系统资源
- 防止数据异常:避免数据库报错或保存异常数据
- 业务逻辑保护:防止恶意输入导致业务逻辑错误
具体场景
- 字段长度:超过数据库字段最大长度会导致数据库报错
- 金额字段:传入负数可能导致不必要的损失
- 状态字段:传入不存在的枚举值会导致数据异常
Java实现方案
在Java中校验数据使用最多的是Hibernate Validator框架,包含以下注解:
@NotNull、@NotEmpty、@NotBlank@Size、@Length@Max、@Min、@DecimalMax、@DecimalMin@Pattern、@Email
对于日期字段和枚举字段,可能需要通过自定义注解的方式实现参数校验。
6. 统一返回值
返回值格式不统一是很多API接口的痛点。
问题示例
// 正常返回
{
"code": 0,
"message": null,
"data": [{"id": 123, "name": "abc"}]
}
// 签名错误返回
{
"code": 1001,
"message": "签名错误",
"data": null
}
// 权限错误返回(格式不一致!)
{
"rt": 10,
"errorMgt": "没有权限",
"result": null
}
解决方案
在设计API网关时统一处理:
- 业务系统出现异常时,抛出业务异常的RuntimeException
- 异常中包含message字段定义异常信息
- 所有API接口都必须经过API网关
- API网关捕获业务异常,转换成统一的异常结构返回
推荐返回格式
{
"code": 200,
"message": "success",
"data": {},
"timestamp": 1640995200000
}
7. 统一封装异常
API接口需要对异常进行统一处理,避免敏感信息泄露。
安全风险
如果直接返回异常堆栈信息、数据库信息、错误代码和行数等,不法分子可能利用这些信息进行SQL注入或者直接脱库。
处理原则
- 对外隐藏:返回通用错误信息给第三方
- 对内记录:在内部日志中记录详细错误信息
示例
// 对外返回
{
"code": 500,
"message": "服务器内部错误",
"data": null
}
实现方式
在Gateway中对异常进行拦截,做统一封装,然后给第三方平台处理后没有敏感信息的错误信息。
8. 请求日志
在第三方平台请求你的API接口时,接口的请求日志非常重要,通过它可以快速分析和定位问题。
日志内容
需要记录以下信息到日志文件中:
- API接口的请求URL
- 请求参数
- 请求头
- 请求方式
- 响应数据
- 响应时间
- TraceId:用于串联整个请求的日志
日志管理策略
- 内部查看:开发人员查看日志文件
- 外部查看:第三方平台用户也需要查看接口请求日志
- 将日志落地到数据库(MongoDB或ElasticSearch)
- 提供UI页面,给第三方平台用户开通查看权限
- 支持在外网查看请求日志,便于他们自己定位问题
注意:日志中也要进行数据脱敏,避免敏感信息泄露。
9. 幂等设计
第三方平台极有可能在极短的时间内请求我们接口多次,比如:在1秒内请求两次。可能是他们业务系统有bug,或者在做接口调用失败重试。
幂等性定义
支持在极短的时间内,第三方平台用相同的参数请求API接口多次:
- 第一次请求:数据库新增数据
- 第二次及以后请求:不会新增数据,但返回成功
实现方案
- 数据库唯一索引:通过业务字段建立唯一约束
- Redis缓存:保存requestId和请求参数,设置过期时间
- Token机制:客户端生成唯一token,服务端验证token是否存在
参考:《高并发下如何保证接口的幂等性?》
10. 限制记录条数
对于提供的批量接口,一定要限制请求的记录条数。
原因
- 请求数据太多容易造成API接口超时
- 影响API接口稳定性
- 防止恶意大量请求消耗系统资源
建议
- 默认限制:一次请求最多支持传入500条记录
- 可配置化:这个参数做成可配置的
- 提前协商:事先跟第三方平台协商好,避免上线后产生问题
11. 压力测试
上线前务必要对API接口做压力测试,了解各个接口的QPS情况。
测试目的
- 预估需要部署多少服务器节点
- 验证限流配置是否合理
- 确保API接口的稳定性
风险提醒
即使做了限流,也要验证API接口是否能够达到限制的阈值。比如:
- 限流设置:1秒允许50次请求
- 实际处理能力:只能处理30次请求
- 结果:API接口仍然会处理不过来
测试工具
- JMeter:功能强大的压力测试工具
- Apache Bench (ab):简单易用的HTTP压力测试工具
- wrk:高性能的HTTP基准测试工具
12. 异步处理
一般的API接口逻辑都是同步处理的,但有时候业务逻辑非常复杂,特别是批量接口,同步处理耗时会非常长。
异步处理方案
在API接口中发送MQ消息,然后直接返回成功。专门的MQ消费者异步消费消息,做业务逻辑处理。
结果获取方式
第三方平台有两种方式获取处理结果:
- 回调通知:我们回调第三方平台的接口,告知处理结果(支付接口常用)
- 轮询查询:第三方平台通过轮询调用查询状态的API接口
- 每隔一段时间查询一次状态
- 传入参数是原API接口中的ID集合
适用场景
- 批量数据处理
- 复杂业务逻辑
- 耗时较长的操作(>2秒)
13. 数据脱敏
第三方平台调用API接口时,获取的数据中可能包含敏感数据,比如:用户手机号、银行卡号等。
脱敏必要性
- 防止用户隐私数据泄露
- 符合法律法规要求(GDPR、个人信息保护法等)
- 降低数据泄露风险
存储层 vs 传输层脱敏策略
存储层(数据库):
- ✅ 保持原始数据完整:不要覆盖或删除原始敏感数据
- ✅ 加密存储:对敏感字段使用可逆加密存储
- ✅ 权限控制:通过数据库角色权限限制数据访问
- ✅ 审计日志:记录敏感数据的访问行为
传输层(API响应):
- ✅ 按需动态脱敏:根据调用方角色和权限决定脱敏程度
- ✅ 响应时处理:在数据序列化阶段进行脱敏,不影响存储
- ✅ 灵活控制:同一份数据,不同用户看到不同脱敏级别
重要原则:原始敏感数据必须完整保存在存储层,脱敏只在数据输出时进行。覆盖原始数据会导致业务功能受损、合规风险和数据恢复困难。
脱敏实现方案
方案1:DTO转换脱敏
public class UserResponse {
private Long id;
private String name;
private String phone; // 脱敏后的手机号
public static UserResponse fromUser(User user, UserRole role) {
UserResponse response = new UserResponse();
response.setId(user.getId());
response.setName(user.getName());
// 根据角色决定是否脱敏
if (role == UserRole.ADMIN) {
response.setPhone(user.getPhone()); // 管理员看完整号码
} else {
response.setPhone(maskPhone(user.getPhone())); // 普通用户看脱敏号码
}
return response;
}
}
方案2:注解驱动脱敏
public class User {
@SensitiveData(type = SensitiveType.PHONE)
private String phone;
@SensitiveData(type = SensitiveType.ID_CARD)
private String idCard;
}
// 全局序列化时自动脱敏
@Component
public class SensitiveDataSerializer implements JsonSerializer<String> {
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider provider) {
String maskedValue = SensitiveDataUtils.mask(value, getSensitiveType());
gen.writeString(maskedValue);
}
}
分层安全策略
数据安全需要多层防护,每层都有不同的职责和保护措施:
| 层级 | 策略 | 目的 | 实施要点 |
|---|---|---|---|
| 存储层 | 保持原始数据 + 加密存储 + 权限控制 | 确保数据完整性,满足业务需求 | • 敏感字段使用AES等可逆加密 • 数据库角色权限最小化 • 定期备份和审计 |
| 应用层 | 基于角色/权限的动态脱敏 | 最小权限原则,按需展示 | • 不同用户角色看到不同数据级别 • 接口级别的数据访问控制 • 业务逻辑中的敏感数据处理 |
| 传输层 | HTTPS + 响应数据脱敏 | 防止传输过程中的数据泄露 | • 强制HTTPS传输 • API响应时动态脱敏 • 禁止敏感数据明文传输 |
| 日志层 | 日志自动脱敏 + 访问控制 | 防止敏感信息写入日志 | • 日志框架集成脱敏功能 • 敏感字段自动掩码 • 日志访问权限控制 |
为什么不能覆盖原始数据?
❌ 覆盖原始数据的问题
- 业务功能受损:无法进行短信发送、身份验证等需要完整数据的操作
- 合规风险:某些法规要求保留完整的原始数据用于审计
- 数据恢复困难:一旦脱敏就无法还原,影响业务连续性
- 灵活性差:不同场景需要不同程度的脱敏
✅ 正确做法的优势
- 数据完整性:原始数据完整保存,支持所有业务场景
- 灵活控制:可以根据用户角色、接口类型动态调整脱敏策略
- 安全分层:多层防护,即使某一层被突破也有其他保护
- 合规友好:满足数据保留和隐私保护的双重需求
脱敏示例
- 手机号:
182****887 - 身份证:
310***********1234 - 银行卡:
6222**********1234 - 邮箱:
us****@example.com
原则:即使数据被泄露,也只泄露部分信息,降低危害程度。
14. 接口文档
一份完整的API接口文档可以减少很多沟通成本,让对方少走很多弯路。
文档必备内容
- 接口地址
- 请求方式:POST、GET、PUT、DELETE等
- 请求参数和字段介绍
- 返回值和字段介绍
- 返回码和错误信息
- 加密或签名示例
- 完整的请求Demo
- 额外说明:如开通IP白名单等
规范化要求
- 命名风格统一:建议使用驼峰命名
- 版本号管理:接口地址加版本号,如
/v1/query/getCategory - 字段规范:
- ID字段:Long类型,长度20
- Status字段:int类型,长度2
- 时间字段:String类型,格式
yyyy-MM-dd HH:mm:ss
- 安全信息:AK/SK和域名找指定人员单独提供
15. HTTPS强制使用(补充)
必要性
所有开放接口必须通过HTTPS提供,这是最基本的安全要求。
实施要点
- 强制HTTPS:HTTP请求自动重定向到HTTPS
- TLS版本:使用TLS 1.2或更高版本
- 证书管理:定期更新SSL证书,监控证书有效性
- HSTS:设置HTTP Strict Transport Security头,防止降级攻击
配置示例(Nginx)
server {
listen 80;
server_name api.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
16. 认证授权机制(补充)
除了签名验证,还需要完善的认证授权机制。
认证方案选择
| 方案 | 适用场景 | 特点 |
|---|---|---|
| API Key | 简单场景 | 易实现,安全性一般 |
| OAuth 2.0 | 用户授权场景 | 标准化,支持多种授权模式 |
| JWT | 微服务架构 | 无状态,支持自包含信息 |
| 双向TLS | 高安全场景 | 证书认证,安全性最高 |
权限控制
- RBAC(基于角色的访问控制):不同角色有不同的API访问权限
- ABAC(基于属性的访问控制):根据用户属性动态授权
- Scope控制:OAuth 2.0中的scope机制,精细化权限控制
17. CORS跨域配置(补充)
如果前端需要直接调用开放接口,需要正确配置CORS。
安全配置原则
- 限制源:不要使用
*,明确指定允许的域名 - 限制方法:只允许必要的HTTP方法
- 限制头部:只允许必要的自定义头部
- 预检缓存:合理设置预检请求缓存时间
配置示例
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("https://trusted-domain.com"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Sign"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L); // 预检缓存1小时
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
}
18. 版本管理策略(补充)
良好的版本管理策略有助于接口的平滑演进。
版本策略
- URL版本:
/api/v1/users、/api/v2/users - Header版本:
Accept: application/vnd.company.v1+json - 参数版本:
?version=1
推荐做法
- URL版本为主:最直观,易于理解和维护
- 向后兼容:新版本尽量保持对旧版本的兼容
- 废弃策略:明确标注废弃版本的停用时间
- 文档同步:每个版本都有对应的完整文档
生命周期管理
- v1:稳定版本,长期支持
- v2:新功能版本,逐步迁移
- v1-deprecated:标记为废弃,给出迁移指南
总结
设计一个优秀的开放接口需要综合考虑安全性、稳定性、可用性、可维护性等多个维度。关键要点包括:
- 安全第一:HTTPS + 签名 + 加密 + IP白名单 + 限流
- 规范统一:统一返回格式、错误处理、命名规范
- 用户体验:完整文档、清晰错误信息、合理限流策略
- 运维友好:详细日志、监控告警、压力测试
- 扩展性好:版本管理、异步处理、幂等设计
记住:开放接口是系统的门面,其质量直接影响到整个系统的安全性和稳定性。