数据开放平台架构设计(三):鉴权与访问控制
数据开放平台把数据变成 API 给外部调用,安全问题是第一位的。谁都能调不行,调了不该看的字段也不行,更不能让某个调用方把数据库连接池打满。
这一篇聊三个问题:怎么认证调用方身份、怎么隔离不同租户的数据、怎么控制调用频率。
一、AK/SK 签名认证
认证方案有很多种,为什么选 AK/SK?因为数据开放平台的调用方通常是服务端应用,不是浏览器用户。服务端对服务端的认证,AK/SK 是更成熟的方案,AWS、阿里云、腾讯云的开放 API 全用这个。
核心概念
- Access Key (AK):调用方的身份标识,公开的,放在请求头里
- Secret Key (SK):签名密钥,保密的,不在网络上传输
- 签名 (Signature):用 SK 对请求内容做 HMAC-SHA256,服务端验签
为什么 SK 不直接传?因为网络传输可能被中间人截获。签名的好处是:即使签名被截获,攻击者也无法反推出 SK,而且签名是和请求内容绑定的,换个请求签名就失效了。
签名流程
AK/SK 认证的关键是两端对同一份规范化请求做签名,服务端不接收明文 SK:
调用方发起请求时:
1. 拼接签名字符串:
stringToSign = HTTP_METHOD + "\n"
+ URI_PATH + "\n"
+ SORTED_QUERY_STRING + "\n"
+ TIMESTAMP + "\n"
+ NONCE
2. 用 SK 做 HMAC-SHA256:
signature = HMAC-SHA256(SK, stringToSign)
3. 放到请求头:
X-Access-Key: your-ak
X-Timestamp: 1713686400
X-Nonce: random-string
X-Signature: base64(signature)服务端验签:
@Component
public class AkSkAuthHandler {
@Autowired
private AppKeyService appKeyService;
public AppInfo verify(HttpServletRequest request) {
// 1. 提取请求头
String ak = request.getHeader("X-Access-Key");
String timestamp = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String signature = request.getHeader("X-Signature");
if (ak == null || timestamp == null || nonce == null || signature == null) {
throw new AuthException("缺少认证参数");
}
// 2. 校验时间戳(5 分钟内有效,防重放)
long reqTime = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() / 1000 - reqTime) > 300) {
throw new AuthException("请求已过期");
}
// 3. 校验 Nonce(防重放,Redis 存 5 分钟)
String nonceKey = "auth:nonce:" + ak + ":" + nonce;
if (Boolean.TRUE.equals(redisTemplate.hasKey(nonceKey))) {
throw new AuthException("重复请求");
}
redisTemplate.opsForValue().set(nonceKey, "1", 5, TimeUnit.MINUTES);
// 4. 查 SK
AppInfo app = appKeyService.getByAccessKey(ak);
if (app == null) {
throw new AuthException("无效的 Access Key");
}
// 5. 重新计算签名并比对
String expected = computeSignature(request, app.getSecretKey());
if (!expected.equals(signature)) {
throw new AuthException("签名验证失败");
}
return app;
}
private String computeSignature(HttpServletRequest request, String sk) {
String method = request.getMethod();
String uri = request.getRequestURI();
String queryString = buildSortedQueryString(request);
String timestamp = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String stringToSign = method + "\n" + uri + "\n"
+ queryString + "\n" + timestamp + "\n" + nonce;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(sk.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
}
}SDK 封装
调用方不需要自己实现签名逻辑,提供 SDK 一行代码搞定:
// 调用方使用 SDK
DataOpenClient client = DataOpenClient.builder()
.endpoint("https://api.example.com")
.accessKey("your-ak")
.secretKey("your-sk")
.build();
// 调用 API,签名自动处理
Map<String, Object> result = client.call("/api/user/list",
Map.of("status", 1, "pageNum", 1, "pageSize", 10));SDK 内部自动拼签名字符串、计算 HMAC、设置请求头。调用方只管传参数,不用关心签名细节。
实际实现时还有两个小细节别漏:POST/PUT 请求更稳妥把 body hash 也放进签名串,否则只签了 path 和 query;签名比对不要用普通字符串比较,生产里用常量时间比较,避免时序攻击。
二、租户隔离
数据开放平台通常服务多个租户(业务方),每个租户只能访问自己的数据。隔离方案有两种:
方案一:行级隔离
所有租户的数据在同一个表里,通过 tenant_id 字段区分。SQL 模板里自动注入租户条件:
@Component
public class TenantIsolationHandler {
public ParsedSql injectTenantCondition(ParsedSql parsed, String tenantId) {
String sql = parsed.getSql();
List<Object> args = new ArrayList<>(Arrays.asList(parsed.getArgs()));
// 在 WHERE 后面自动加上 tenant_id 条件
String tenantCondition = "tenant_id = ?";
args.add(tenantId);
// 找到 WHERE 位置插入条件
int whereIdx = sql.toUpperCase().indexOf("WHERE");
if (whereIdx >= 0) {
sql = sql.substring(0, whereIdx + 5) + " " + tenantCondition + " AND "
+ sql.substring(whereIdx + 5);
} else {
// 没有 WHERE,加一个
int orderByIdx = sql.toUpperCase().indexOf("ORDER BY");
if (orderByIdx >= 0) {
sql = sql.substring(0, orderByIdx) + "WHERE "
+ tenantCondition + " " + sql.substring(orderByIdx);
} else {
sql += " WHERE " + tenantCondition;
}
}
return new ParsedSql(sql, args.toArray());
}
}方案二:库级隔离
每个租户一个独立数据库。数据源路由根据租户 ID 选择不同的数据库连接:
public DataSource resolveForTenant(String tenantId, String datasourceName) {
String key = tenantId + ":" + datasourceName;
return dataSourceCache.computeIfAbsent(key, k -> {
TenantDsConfig config = tenantConfigService.get(tenantId, datasourceName);
return createDataSource(config);
});
}行级隔离成本低,适合租户少、数据量不大的场景。库级隔离隔离性更稳妥,适合对数据安全要求高的场景。实际项目中大多从行级隔离开始,数据量大了再迁移到库级。无论选哪种,都不要让租户条件依赖调用方传参,必须由平台从认证上下文里注入。
三、流量控制
流量控制防止某个调用方把平台打挂。用令牌桶算法,按调用方维度限流:
@Component
public class FlowControlService {
// 每个 appKey 一个独立的限流器
private final Map<String, RateLimiter> limiterMap = new ConcurrentHashMap<>();
/**
* 检查是否超限,超限直接拒绝
*/
public void checkLimit(String appKey) {
FlowConfig config = getFlowConfig(appKey);
RateLimiter limiter = limiterMap.computeIfAbsent(appKey,
k -> RateLimiter.create(config.getQps()));
if (!limiter.tryAcquire()) {
throw new FlowLimitException(
String.format("调用频率超限,当前限制 %d QPS", config.getQps()));
}
}
/**
* 获取限流配置,支持动态调整
*/
private FlowConfig getFlowConfig(String appKey) {
// 优先读 Redis 缓存,支持运行时动态调整
String cacheKey = "flow:config:" + appKey;
FlowConfig config = redisTemplate.opsForValue().get(cacheKey);
if (config == null) {
config = flowConfigMapper.selectByAppKey(appKey);
if (config == null) {
config = FlowConfig.builder().qps(100).build(); // 默认 100 QPS
}
redisTemplate.opsForValue().set(cacheKey, config, 5, TimeUnit.MINUTES);
}
return config;
}
}限流分三个维度:
- 应用级:每个调用方独立限流,互不影响
- API 级:单个 API 独立限流,防止热点接口被打爆
- 全局级:平台整体限流,保护数据库连接池
超限时返回 HTTP 429,响应体带上限流信息:
{
"code": 429,
"message": "调用频率超限,当前限制 100 QPS,请稍后重试",
"data": {
"limit": 100,
"retryAfter": 1
}
}四、配额管理
配额和限流不同。限流是"每秒上限调多少次",配额是"每天/每月上限调多少次"。
@Component
public class QuotaService {
public void checkQuota(String appKey, String apiId) {
QuotaConfig config = getQuotaConfig(appKey, apiId);
// 日配额
String dailyKey = "quota:daily:" + appKey + ":" + apiId;
Long dailyCount = redisTemplate.opsForValue().increment(dailyKey);
if (dailyCount == 1) {
// 设置过期时间到当天结束
redisTemplate.expire(dailyKey, getSecondsUntilMidnight(), TimeUnit.SECONDS);
}
if (dailyCount > config.getDailyLimit()) {
throw new QuotaException("今日调用次数已用完,每日限额 "
+ config.getDailyLimit() + " 次");
}
// 月配额
String monthlyKey = "quota:monthly:" + appKey + ":" + apiId;
Long monthlyCount = redisTemplate.opsForValue().increment(monthlyKey);
if (monthlyCount == 1) {
redisTemplate.expire(monthlyKey, getSecondsUntilMonthEnd(), TimeUnit.SECONDS);
}
if (monthlyCount > config.getMonthlyLimit()) {
throw new QuotaException("本月调用次数已用完,每月限额 "
+ config.getMonthlyLimit() + " 次");
}
}
}配额用 Redis 的 INCR 原子操作计数,天然支持分布式部署。设置过期时间自动归零,不用定时任务清理。
配额信息在管理后台可视化展示:今日已用、本月已用、剩余次数、用量趋势图。支持按 API 维度查看,方便调用方管理自己的用量。
五、权限矩阵
不同调用方能访问哪些 API,用权限矩阵控制:
@Data
public class AppPermission {
private String appKey;
private List<String> allowedApis; // 允许访问的 API 列表
private List<String> allowedFields; // 允许返回的字段(字段级权限)
private String dataScope; // 数据范围:ALL / TENANT / SELF
}权限校验在 Pipeline 的鉴权步骤里:
public void checkApiPermission(AppInfo app, String apiId) {
AppPermission permission = permissionService.getByAppKey(app.getAppKey());
if (permission.getAllowedApis() != null
&& !permission.getAllowedApis().contains(apiId)) {
throw new AuthException("无权访问该 API");
}
}字段级权限更细粒度,同一个 API,调用方 A 能看到所有字段,调用方 B 只能看到部分字段。这在数据开放场景里很常见:给财务系统开放完整的订单数据,给营销系统只开放用户画像字段。字段过滤更稳妥放在数据转换之后、响应包装之前,避免字段重命名后权限名对不上。
六、完整认证链路
把上面的模块串起来,鉴权的完整链路:
访问控制不要只停在认证层,认证之后还要继续收窄到 API、租户、字段和流量边界:
每一步都是独立的 Handler,通过 Pipeline 串联。任何一步失败都直接返回,不继续执行后续步骤。这样既保证了安全性,又保持了代码的可维护性,每个 Handler 只关注自己的职责,互不耦合。
上一篇: 数据开放平台(二):SQL 转 API 引擎
下一篇: 数据开放平台(四):数据转换与加工