← 返回

数据开放平台架构设计(三):鉴权与访问控制

上一篇: 数据开放平台(二):SQL 转 API 引擎

数据开放平台把数据变成 API 给外部调用,安全问题是第一位的。谁都能调不行,调了不该看的字段也不行,更不能让某个调用方把数据库连接池打满。

这一篇聊三个问题:怎么认证调用方身份怎么隔离不同租户的数据怎么控制调用频率


一、AK/SK 签名认证

认证方案有很多种,为什么选 AK/SK?因为数据开放平台的调用方通常是服务端应用,不是浏览器用户。服务端对服务端的认证,AK/SK 是更成熟的方案,AWS、阿里云、腾讯云的开放 API 全用这个。

核心概念

  • Access Key (AK):调用方的身份标识,公开的,放在请求头里
  • Secret Key (SK):签名密钥,保密的,不在网络上传输
  • 签名 (Signature):用 SK 对请求内容做 HMAC-SHA256,服务端验签

为什么 SK 不直接传?因为网络传输可能被中间人截获。签名的好处是:即使签名被截获,攻击者也无法反推出 SK,而且签名是和请求内容绑定的,换个请求签名就失效了。

签名流程

AK/SK 认证的关键是两端对同一份规范化请求做签名,服务端不接收明文 SK:

sequenceDiagram participant SDK as 调用方 SDK participant GW as API 网关 participant Auth as 鉴权 Handler participant Store as 凭据存储 participant Ctx as 请求上下文 SDK->>SDK: 规范化方法、路径、参数、时间戳、Nonce SDK->>SDK: 使用 SK 计算 HMAC SDK->>GW: 携带 AK、签名、时间戳、Nonce GW->>Auth: 透传认证头 Auth->>Store: 用 AK 查询 SK 摘要或密钥材料 Store-->>Auth: 返回调用方与租户信息 Auth->>Auth: 校验时间窗口与 Nonce Auth->>Auth: 重算签名并比较 Auth->>Ctx: 写入应用、租户、权限范围

调用方发起请求时:

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、租户、字段和流量边界:

flowchart TD A["请求进入"] --> B{"AK/SK 认证"} B -- "失败" --> R401["401 未认证"] B -- "通过" --> C{"应用与 API 权限"} C -- "无权限" --> R403["403 无权限"] C -- "通过" --> D{"配额检查"} D -- "超配额" --> R429A["429 配额用尽"] D -- "通过" --> E{"限流检查"} E -- "触发限流" --> R429B["429 请求过快"] E -- "通过" --> F["注入租户条件"] F --> G["字段权限过滤"] G --> H["审计记录"] H --> I["执行业务查询"]

每一步都是独立的 Handler,通过 Pipeline 串联。任何一步失败都直接返回,不继续执行后续步骤。这样既保证了安全性,又保持了代码的可维护性,每个 Handler 只关注自己的职责,互不耦合。


上一篇: 数据开放平台(二):SQL 转 API 引擎
下一篇: 数据开放平台(四):数据转换与加工