身份验证
Owem Pay 外部 API 采用三层主要身份验证安全模型 -- API Key + Secret、逐请求 HMAC-SHA512 签名(仅 POST)和强制 IP 白名单 -- 在一个更大的 plug 管道内执行,该管道还验证 Content-Type、应用速率限制并实现幂等性。
管道概览
HTTP 请求按以下顺序通过下列 plug。任何 plug 都可以用终止错误(halt)中断管道:
POST /api/external/...
│
├─ 1. Content-Type ──────── 不是 application/json 也不是 multipart/form-data?→ 415
├─ 2. X-Key-Case (KeyCase) ─ 将 params/response 在 snake_case ↔ camelCase 之间转换(可选)
├─ 3. API Key + Secret ───── 凭证缺失/无效?→ 401 | API Key 未激活?→ 401 | API Key 已过期?→ 401
├─ 4. IP 白名单 ───────── 白名单为空?→ 403 "ip whitelist required" | IP 不在列表中?→ 403 "ip not allowed" | 账户未激活?→ 403
├─ 5. HMAC-SHA512 (POST) ─── 缺少 `hmac` 头?→ 401 | 签名无效?→ 401 | body 无效?→ 400 | API Key 无 secret?→ 403
├─ 6. Rate Limiter (ETS) ─── 每 IP 超过 90.000 req/min?→ 429 Retry-After: 60
├─ 7. Idempotency (POST) ─── 键 > 256 字符?→ 400 | 24h 内重放?→ 返回缓存 body + X-Idempotent-Replay: true
└─ 8. RequirePermission ───── API Key 缺少路由所需权限?→ 403
│
└─ 请求被接受 → 控制器/业务逻辑按 HTTP 方法划分的管道
GET /balance仅使用Content-Type → KeyCase → API Key + Secret → IP 白名单 → RequirePermission-- 无速率限制,无 HMAC,无幂等性(授权的高频轮询)。- 其他
GET/DELETE使用Content-Type → KeyCase → API Key + Secret → IP 白名单 → Rate Limiter → RequirePermission-- 无 HMAC 和幂等性。 POST使用上面完整的管道(全部 8 个 plug)。
第一层 -- API Key + Secret
所有请求必须包含 Authorization 头。API 接受两种等价格式 -- 原生 ApiKey scheme 或 HTTP Basic Authentication。两者由同一个 plug(ApiKeyAuth)校验,行为相同。请选择对您 HTTP 客户端最方便的。
推荐格式 -- ApiKey scheme
Authorization: ApiKey {client_id}:{client_secret}替代格式 -- HTTP Basic
Authorization: Basic {base64(client_id:client_secret)}Bash 示例:
CLIENT_ID="cli_a1b2c3d4e5f6"
CLIENT_SECRET="sk_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01"
# 等价于 Authorization: ApiKey cli_a1b2...:sk_0123...
BASIC=$(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64)
curl -X GET https://api.owem.com.br/api/external/balance \
-H "Authorization: Basic $BASIC"何时使用 Basic vs ApiKey
如果您的 HTTP 客户端(库、网关、代理)已自动通过 base64 构造凭证(几乎所有客户端都会),使用 Basic。如果您想在头部以纯文本发送 secret,使用 ApiKey -- 两者在后端都走同一个 parser。
凭证字段
| 组件 | 描述 | 前缀 |
|---|---|---|
client_id | API Key 的公开标识符 | cli_ |
client_secret | 密钥(我们仅存储哈希值) | sk_ |
Secret 从不以明文存储。当请求到达时,发送的 secret 会与存储的哈希进行比对。如果不匹配,请求在到达业务逻辑之前就会被拒绝。
API Key 可能会过期
尽管实践中大多数 API Key 创建时没有过期时间,schema 中存在 expires_at 字段。如果配置了过去的 expires_at,身份验证会失败并返回 401 API key has expired。也可以撤销(标记为未激活):返回 401 API key is inactive。这取代此前文档所述 API Key 永久有效的说法。
第二层 -- HMAC-SHA512
交易类请求(POST、PUT、PATCH)要求在 hmac 头中提供请求体的 HMAC-SHA512 签名。验证使用恒定时间比较(constant-time comparison)以防止时序攻击。
请参阅 HMAC-SHA512 获取 6 种编程语言的实现示例。
第三层 -- IP 白名单
每个 API Key 必须在白名单中至少有一个 IP -- 即使是带有有效凭证的新创建 API Key,只要白名单为空就会被拒绝:
{
"error": {
"status": 403,
"message": "IP whitelist required. Configure at least one allowed IP to use this API key."
}
}白名单至少有一条记录后,来自列表外 IP 的请求会收到:
{
"error": {
"status": 403,
"message": "Request IP not in API key whitelist"
}
}接受的格式
| 格式 | 示例 | 注释 |
|---|---|---|
| 单个 IPv4 | 203.0.113.45 | 仅一个公开端点 |
| CIDR 表示法(IPv4) | 203.0.113.0/24 | 整个 /24 子网(256 个 IP)。单个主机使用 /32 |
| 聚合 CIDR | 172.20.16.0/20 | 私有段(示例)-- 按字面接受 |
IPv4 vs IPv6
后端在与白名单比对前将 ::ffff:A.B.C.D(映射到 IPv6 的 IPv4,GKE 负载均衡器使用)归一化为对应的 IPv4 地址。您无需包含 IPv6 映射形式;仅需注册 IPv4 字面值。对于仅通过 IPv6 出口的客户端,按标准表示法注册完整的 IPv6 地址(例:2001:db8::1)。
字符串格式
白名单期望精确的字符串。前后空格、错误的掩码(/28 而实际 range 为 256 个 IP)、或带前导零的 IP(203.000.113.045)会在没有任何提示的情况下静默拒绝请求,仅返回标准的 403。始终先在 Merchant Portal 使用测试 IP 验证后再投入生产。
在 Merchant Portal 中创建或编辑 API Key 时配置白名单。
请求头
必需的头
| 头 | 值 | 是否必需 |
|---|---|---|
Authorization | ApiKey {client_id}:{client_secret} 或 Basic {base64(client_id:client_secret)} | 是 -- 所有请求 |
Content-Type | application/json(或上传时 multipart/form-data) | 是 -- 带 body 的 POST、PUT、PATCH。发送 application/x-www-form-urlencoded(curl -d 不带 -H 时的默认)会返回 415 Unsupported Media Type |
hmac | body 的 HMAC-SHA512 签名,十六进制小写 | 是 -- 仅 /api/external/* 的 POST |
可选头
| 头 | 值 | 效果 |
|---|---|---|
Idempotency-Key | 唯一键 ≤ 256 字符(推荐 UUID v4) | 在 24 小时内去重重放。仅在 POST 时工作 -- GET/DELETE 会静默忽略该头(不报错) |
X-Key-Case | camelCase | 将 request params 从 camelCase 转为 snake_case(输入),并将整个 JSON 响应的键从 snake_case 转为 camelCase(输出)。对 JavaScript、TypeScript 或 Kotlin 客户端很有用 |
X-Forwarded-For | 以逗号分隔的 IP | 仅在直接 TCP 连接来自受信代理(GKE 负载均衡器)时被尊重。直接来自客户端的连接会忽略 |
Idempotency-Key -- 服务器响应
发送 Idempotency-Key 头时,服务器会在响应中回显相同的 Idempotency-Key 值;如果请求是过去 24 小时内已处理请求的重放(相同键 + 相同 HTTP 方法 + 相同 path),还会添加 X-Idempotent-Replay: true 头并返回缓存 body 如第一次执行时返回的那样(相同 HTTP 状态,相同 body 字节级别)。缓存按 (method, path, key) 作用域 -- 在不同端点使用相同键不会冲突。超过 256 字符的键会被拒绝并返回 400 Idempotency-Key must be at most 256 characters。只有 2xx 响应会被缓存 -- 错误响应(4xx/5xx)允许使用相同键重试。
X-Key-Case -- 转换为 camelCase
如果您的技术栈使用 camelCase(JS/TS、Kotlin、Swift),请发送 X-Key-Case: camelCase,API 将接受 camelCase request body(例:externalId、pixKey)并返回带 camelCase 键的响应。不带此头,API 保持 snake_case(例:external_id、pix_key)。该头可发送到任意 /api/external/* 端点 -- 每个 API Key 不必始终使用相同值。
HMAC 在内部转换之前对 body 签名
如果您在需要 HMAC 的 POST 上发送 X-Key-Case: camelCase,请对将在网络上传输的 body 形态签名(即如果您使用 camelCase 序列化,就按 camelCase 签名)。HMAC 在服务器上对收到的 body 计算,而不是内部的 snake_case 形式。
完整示例
CLIENT_ID="cli_a1b2c3d4e5f6"
CLIENT_SECRET="sk_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01"
# 余额查询(GET -- 无需 HMAC)
curl -X GET https://api.owem.com.br/api/external/balance \
-H "Authorization: ApiKey $CLIENT_ID:$CLIENT_SECRET"
# PIX Cash-Out(POST -- 带 HMAC + Idempotency-Key)
# 重要:键按字母顺序(amount < description < pix_key < pix_key_type)。
# 服务器在计算预期 HMAC 之前会按字母顺序重新排序 — 见 /hmac。
BODY='{"amount":3000,"description":"Pagamento","pix_key":"12345678901","pix_key_type":"cpf"}'
HMAC=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$CLIENT_SECRET" | awk '{print $2}')
curl -X POST https://api.owem.com.br/api/external/pix/cash-out \
-H "Authorization: ApiKey $CLIENT_ID:$CLIENT_SECRET" \
-H "Content-Type: application/json" \
-H "hmac: $HMAC" \
-H "Idempotency-Key: cashout-order-9876" \
-d "$BODY"附加保护
| 保护措施 | 描述 |
|---|---|
| 速率限制(后端) | 所有 /api/external/* 下的 90.000 req/min(1.500 req/s)每 IP,GET /balance 除外(无速率限制 -- 允许高频轮询)。限制器键:(IP, 60s 窗口)。超出限制时,服务器响应 429 Too Many Requests 及 Retry-After: 60 头。在所有经过限制器的 2xx 响应中,附带 x-ratelimit-remaining 头,显示窗口中剩余的 request 数 |
| 速率限制(Cloud Armor) | Google Cloud 负载均衡器上按 API Key 的规则(通常每个 key 3.000/min)。按 merchant 配置。在后端之前起作用,因此这一层的 429 不会计入后端计数器 |
| 速率限制(auth) | admin/merchant 身份验证端点 5 req/min(不适用于 /api/external/*) |
| DICT 每 merchant 配额(ClientLimiter) | 专门针对 POST /pix/cash-out 通过 DICT 解析 PIX key 时。默认每 merchant 120 req/min。超出时:返回 HTTP 202,status: "queued" 并触发 webhook pix.payout.queued -- 自动重试最多 120 分钟。参见 PIX Cash-Out(按密钥) |
| Cloud Armor (WAF) | 保护集群的应用防火墙,使用 OWASP 规则(XSS、SQLi、LFI、RFI、RCE) |
| HTTPS + TLS 1.2+ | 所有连接强制加密 |
| HSTS | 浏览器强制使用 HTTPS |
响应中的速率限制头
每当请求通过 rate limiter plug 时,响应都会包含:
| 头 | 何时出现 | 值 |
|---|---|---|
x-ratelimit-remaining | 2xx 响应(通过限制器后) | 整数:当前 60s 窗口中剩余 request 数,按 IP 作用域 |
Retry-After | 仅 429 Too Many Requests | 60(始终,以秒计) -- 请在重试前等待 |
限制器如何计数 "窗口"
限制器使用60 秒固定窗口,不是滑动窗口。ETS 键是 (IP, floor(now_ms / 60000))。这意味着理论上,客户端在 60 真实秒内可累积多达 180.000 个 request,如果在一个窗口末端执行 90.000 个,在下个窗口开头执行 90.000 个。实践中这种 burst 难以察觉,稳定的 1.500 req/s 才是重点。
为什么选择 HMAC-SHA512 而非 mTLS?
mTLS(双向 TLS)认证的是连接,而非内容。如果连接已认证,所有请求都会直接通过而无需逐一验证。
HMAC 逐请求单独验证。即使在有效的连接中,载荷的任何更改都会导致请求被拒绝。
| 方面 | mTLS | HMAC-SHA512 |
|---|---|---|
| 验证对象 | TLS 通道 | 请求载荷 |
| 管理 | X.509 证书(签发、轮换、吊销、CRL/OCSP) | 生成密钥对、更新、废止 |
| 运维风险 | 证书过期 -- 常见事故原因 | 密钥是简单字符串 |
| 内容完整性 | 否 | 是 |
TLS 已保证传输加密。HMAC 添加了载荷的完整性和真实性验证 -- 这是 mTLS 本身无法覆盖的。
错误响应
API 有 4 种不同的错误格式
根据管道中哪个 plug 拒绝了请求,JSON body 的结构不同。在客户端解析错误前,始终检查结构。按管道中出现的顺序:
{"error": {status, message}}--Content-Type(415)、ApiKeyAuth(401/403)、Idempotency(400)、RateLimiter(429)。{"error": "forbidden", "message": "..."}-- 仅RequirePermission(403)。{"worked": false, "detail": "..."}-- 仅HmacValidation(400、401、403)。{"errors": {atom: "msg"}}--FallbackController(任何 4xx/5xx 业务错误)。
第 0 层 -- Content-Type(plug RequireJsonContentType)
在任何身份验证之前,没有接受的 Content-Type(application/json 或 multipart/form-data)的 POST/PUT/PATCH 会被阻止。
415 -- Content-Type 不支持
{
"error": {
"status": 415,
"message": "Unsupported Media Type. Expected Content-Type: application/json",
"hint": "Add header: -H 'Content-Type: application/json'"
}
}curl -d 的常见陷阱
curl -d '{"...":""}' URL(不带 -H)默认发送 Content-Type: application/x-www-form-urlencoded。然后 Plug.Parsers 将 JSON 视为单个 form 键,控制器会抱怨"缺少必需字段"。RequireJsonContentType plug 通过返回 415 并提示来避免这种噪音。
第 1 层 -- API Key + IP 白名单(plug ApiKeyAuth)
凭证缺失、无效、API Key 未激活/过期或 IP 不在白名单中的错误,格式为 {"error": {status, message}}:
401 -- 凭证缺失
{
"error": {
"status": 401,
"message": "Missing API key credentials. Use Authorization: ApiKey <client_id>:<client_secret>"
}
}401 -- 凭证无效
{
"error": {
"status": 401,
"message": "Invalid API key credentials"
}
}401 -- API Key 未激活
{
"error": {
"status": 401,
"message": "API key is inactive"
}
}401 -- API Key 已过期
{
"error": {
"status": 401,
"message": "API key has expired"
}
}403 -- IP 白名单为空
{
"error": {
"status": 403,
"message": "IP whitelist required. Configure at least one allowed IP to use this API key."
}
}403 -- IP 未授权
{
"error": {
"status": 403,
"message": "Request IP not in API key whitelist"
}
}403 -- 账户未激活
{
"error": {
"status": 403,
"message": "Account is not active"
}
}403 -- 权限不足
此 403 来自另一个 plug
此错误由在 ApiKeyAuth 之后运行的 RequirePermission plug 发出(已通过 API Key 身份验证、IP 白名单检查和速率限制,已进入控制器)。因此其 JSON 结构与本层的其他 401/403 不同:使用 {"error": "forbidden", "message": "..."}(字符串而非对象)。
{
"error": "forbidden",
"message": "API key lacks permission: transfer:write"
}第 2 层 -- HMAC-SHA512 验证(HMAC plug)
HmacValidation plug 发出的错误始终采用 {"worked": false, "detail": "..."} 格式,HTTP 代码根据原因而不同:
401 -- 签名无效或头缺失
{
"worked": false,
"detail": "Invalid HMAC signature"
}{
"worked": false,
"detail": "Missing HMAC header"
}400 -- Body 缺失或 JSON 无效
{
"worked": false,
"detail": "Request body is required for HMAC validation"
}{
"worked": false,
"detail": "Request body must be valid JSON for HMAC validation"
}403 -- API Key 未配置 HMAC secret
{
"worked": false,
"detail": "HMAC secret not configured for this API key"
}第 3 层 -- 业务错误(FallbackController)
身份验证后,验证错误、参数缺失、资源未找到和业务规则采用 {"errors": {atom: "msg"}} 格式:
400 -- 请求无效
{
"errors": {
"bad_request": {
"amount": ["is required"]
}
}
}404 -- 资源未找到
{
"errors": {
"not_found": "Transaction not found"
}
}401 -- 未授权(业务规则)
{
"errors": {
"unauthorized": "invalid credentials"
}
}422 -- 不可处理实体
{
"errors": {
"unprocessable_entity": "Invalid PIX key format"
}
}第 4 层 -- 速率限制(RateLimiter plug 或 Cloud Armor)
429 -- 超过速率限制
来自后端的 429 body(RateLimiter plug):
{
"error": {
"status": 429,
"message": "Too many requests. Please try again later."
}
}429 响应中包含的头:
| 头 | 值 |
|---|---|
Retry-After | 60(重试前需等待的秒数) |
如何响应 429
- 指数退避:从 60s(
Retry-After值)开始,每次后续重试加倍直到合理上限(例:5 min)。 - 绝不忽略头:即使您的客户端有自己的重试策略,
Retry-After仍是此端点的规范真实来源。 - 如果 429 来自 Cloud Armor(后端之上的层),body 可能有不同的结构 -- 但状态 429 +
Retry-After: 60仍然是任何层的标准。
第 5 层 -- Idempotency(Idempotency plug)
400 -- Idempotency-Key 过长
{
"error": {
"status": 400,
"message": "Idempotency-Key must be at most 256 characters"
}
}权限(Permissions)
每个 API Key 都有一个权限列表,决定可以访问哪些端点。如果 API Key 没有所需权限,请求会被拒绝并返回 403 Forbidden。
如何注册 API Key
- 访问管理面板 core.owem.com.br
- 导航至 安全 → API Keys
- 点击 创建 API Key
- 填写名称,选择账户,并在白名单中添加 IP
- 勾选所需权限(见下方表格)
- 点击保存。
client_id和client_secret将仅显示一次 -- 复制并安全存储
要编辑现有 API Key 的权限,在 API Key 列表中点击权限图标。
发送 PIX 必需
要执行 PIX Cash-Out(发送 PIX)操作,API Key 必须具有 transfer:write 权限。没有此权限,所有发送尝试都会返回 403 Forbidden,消息为 API key lacks permission: transfer:write。
完整操作的最低推荐权限:
- Cash-In(接收):
pix:write+transfer:read - Cash-Out(发送):
transfer:write+transfer:read - 查询:
transfer:read+account:read+statement:read - Webhooks:
account:write+account:read
可用权限
| 权限 | 描述 |
|---|---|
pix:write | 生成 QR Code(Cash-In) |
pix:read | 列出 PIX 密钥 |
transfer:write | 发送 PIX(Cash-Out) |
transfer:read | 查询交易(按 ID、E2E、Tag、External ID)、凭证、列出交易 |
payment:write | 申请退款(refund) |
payment:read | 列出和查询 MED |
account:write | 创建和移除 webhook |
account:read | 查询余额、列出 webhook、验证 CPF |
statement:read | 查询对账单 |
按端点划分的权限
| 端点 | 方法 | 权限 |
|---|---|---|
/pix/cash-in | POST | pix:write |
/pix/cash-out | POST | transfer:write |
/pix/refund | POST | payment:write |
/cpf/validate | POST | account:read |
/webhooks | POST | account:write |
/webhooks | GET | account:read |
/webhooks/:id | DELETE | account:write |
/balance | GET | account:read |
/transactions | GET | transfer:read |
/transactions/:id | GET | transfer:read |
/transactions/e2e/:e2e_id | GET | transfer:read |
/transactions/tag/:tag | GET | transfer:read |
/transactions/ref/:external_id | GET | transfer:read |
/transactions/:id/receipt | GET | transfer:read |
/pix/keys | GET | pix:read |
/med | GET | payment:read |
/med/:id | GET | payment:read |
/statement | GET | statement:read |
错误响应 -- 403(权限不足)
参见格式:第 1 层 -- 403 权限不足。
权限在创建 API Key 时通过 Merchant Portal 或管理 API 配置。
安全提示
- 切勿在前端代码或公共仓库中暴露
client_secret - 请在服务器中使用环境变量
- 如果配置了
expires_at字段,API Key 可能会过期;否则,它保持有效,直到在 Merchant Portal 中手动撤销 - 在白名单中配置允许的 IP -- 空白名单会以
403 IP whitelist required阻止密钥