Skip to content

身份验证

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 示例:

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_idAPI 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,只要白名单为空就会被拒绝:

json
{
  "error": {
    "status": 403,
    "message": "IP whitelist required. Configure at least one allowed IP to use this API key."
  }
}

白名单至少有一条记录后,来自列表外 IP 的请求会收到:

json
{
  "error": {
    "status": 403,
    "message": "Request IP not in API key whitelist"
  }
}

接受的格式

格式示例注释
单个 IPv4203.0.113.45仅一个公开端点
CIDR 表示法(IPv4)203.0.113.0/24整个 /24 子网(256 个 IP)。单个主机使用 /32
聚合 CIDR172.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 时配置白名单。

请求头

必需的头

是否必需
AuthorizationApiKey {client_id}:{client_secret}Basic {base64(client_id:client_secret)}是 -- 所有请求
Content-Typeapplication/json(或上传时 multipart/form-data是 -- 带 body 的 POSTPUTPATCH。发送 application/x-www-form-urlencodedcurl -d 不带 -H 时的默认)会返回 415 Unsupported Media Type
hmacbody 的 HMAC-SHA512 签名,十六进制小写是 -- 仅 /api/external/*POST

可选头

效果
Idempotency-Key唯一键 ≤ 256 字符(推荐 UUID v4)在 24 小时内去重重放。仅在 POST 时工作 -- GET/DELETE 会静默忽略该头(不报错)
X-Key-CasecamelCase将 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(例:externalIdpixKey)并返回带 camelCase 键的响应。不带此头,API 保持 snake_case(例:external_idpix_key)。该头可发送到任意 /api/external/* 端点 -- 每个 API Key 不必始终使用相同值。

HMAC 在内部转换之前对 body 签名

如果您在需要 HMAC 的 POST 上发送 X-Key-Case: camelCase,请对将在网络上传输的 body 形态签名(即如果您使用 camelCase 序列化,就按 camelCase 签名)。HMAC 在服务器上对收到的 body 计算,而不是内部的 snake_case 形式。

完整示例

bash
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 RequestsRetry-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-remaining2xx 响应(通过限制器后)整数:当前 60s 窗口中剩余 request 数,按 IP 作用域
Retry-After429 Too Many Requests60(始终,以秒计) -- 请在重试前等待

限制器如何计数 "窗口"

限制器使用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 逐请求单独验证。即使在有效的连接中,载荷的任何更改都会导致请求被拒绝。

方面mTLSHMAC-SHA512
验证对象TLS 通道请求载荷
管理X.509 证书(签发、轮换、吊销、CRL/OCSP)生成密钥对、更新、废止
运维风险证书过期 -- 常见事故原因密钥是简单字符串
内容完整性

TLS 已保证传输加密。HMAC 添加了载荷的完整性和真实性验证 -- 这是 mTLS 本身无法覆盖的。

错误响应

API 有 4 种不同的错误格式

根据管道中哪个 plug 拒绝了请求,JSON body 的结构不同。在客户端解析错误前,始终检查结构。按管道中出现的顺序:

  1. {"error": {status, message}} -- Content-Type(415)、ApiKeyAuth(401/403)、Idempotency(400)、RateLimiter(429)。
  2. {"error": "forbidden", "message": "..."} -- 仅 RequirePermission(403)。
  3. {"worked": false, "detail": "..."} -- 仅 HmacValidation(400、401、403)。
  4. {"errors": {atom: "msg"}} -- FallbackController(任何 4xx/5xx 业务错误)。

第 0 层 -- Content-Type(plug RequireJsonContentType

在任何身份验证之前,没有接受的 Content-Type(application/jsonmultipart/form-data)的 POST/PUT/PATCH 会被阻止。

415 -- Content-Type 不支持

json
{
  "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 -- 凭证缺失

json
{
  "error": {
    "status": 401,
    "message": "Missing API key credentials. Use Authorization: ApiKey <client_id>:<client_secret>"
  }
}

401 -- 凭证无效

json
{
  "error": {
    "status": 401,
    "message": "Invalid API key credentials"
  }
}

401 -- API Key 未激活

json
{
  "error": {
    "status": 401,
    "message": "API key is inactive"
  }
}

401 -- API Key 已过期

json
{
  "error": {
    "status": 401,
    "message": "API key has expired"
  }
}

403 -- IP 白名单为空

json
{
  "error": {
    "status": 403,
    "message": "IP whitelist required. Configure at least one allowed IP to use this API key."
  }
}

403 -- IP 未授权

json
{
  "error": {
    "status": 403,
    "message": "Request IP not in API key whitelist"
  }
}

403 -- 账户未激活

json
{
  "error": {
    "status": 403,
    "message": "Account is not active"
  }
}

403 -- 权限不足

此 403 来自另一个 plug

此错误由在 ApiKeyAuth 之后运行的 RequirePermission plug 发出(已通过 API Key 身份验证、IP 白名单检查和速率限制,已进入控制器)。因此其 JSON 结构与本层的其他 401/403 不同:使用 {"error": "forbidden", "message": "..."}(字符串而非对象)。

json
{
  "error": "forbidden",
  "message": "API key lacks permission: transfer:write"
}

第 2 层 -- HMAC-SHA512 验证(HMAC plug)

HmacValidation plug 发出的错误始终采用 {"worked": false, "detail": "..."} 格式,HTTP 代码根据原因而不同:

401 -- 签名无效或头缺失

json
{
  "worked": false,
  "detail": "Invalid HMAC signature"
}
json
{
  "worked": false,
  "detail": "Missing HMAC header"
}

400 -- Body 缺失或 JSON 无效

json
{
  "worked": false,
  "detail": "Request body is required for HMAC validation"
}
json
{
  "worked": false,
  "detail": "Request body must be valid JSON for HMAC validation"
}

403 -- API Key 未配置 HMAC secret

json
{
  "worked": false,
  "detail": "HMAC secret not configured for this API key"
}

第 3 层 -- 业务错误(FallbackController

身份验证后,验证错误、参数缺失、资源未找到和业务规则采用 {"errors": {atom: "msg"}} 格式:

400 -- 请求无效

json
{
  "errors": {
    "bad_request": {
      "amount": ["is required"]
    }
  }
}

404 -- 资源未找到

json
{
  "errors": {
    "not_found": "Transaction not found"
  }
}

401 -- 未授权(业务规则)

json
{
  "errors": {
    "unauthorized": "invalid credentials"
  }
}

422 -- 不可处理实体

json
{
  "errors": {
    "unprocessable_entity": "Invalid PIX key format"
  }
}

第 4 层 -- 速率限制(RateLimiter plug 或 Cloud Armor)

429 -- 超过速率限制

来自后端的 429 body(RateLimiter plug):

json
{
  "error": {
    "status": 429,
    "message": "Too many requests. Please try again later."
  }
}

429 响应中包含的头:

Retry-After60(重试前需等待的秒数)

如何响应 429

  • 指数退避:从 60s(Retry-After 值)开始,每次后续重试加倍直到合理上限(例:5 min)。
  • 绝不忽略头:即使您的客户端有自己的重试策略,Retry-After 仍是此端点的规范真实来源。
  • 如果 429 来自 Cloud Armor(后端之上的层),body 可能有不同的结构 -- 但状态 429 + Retry-After: 60 仍然是任何层的标准。

第 5 层 -- Idempotency(Idempotency plug)

400 -- Idempotency-Key 过长

json
{
  "error": {
    "status": 400,
    "message": "Idempotency-Key must be at most 256 characters"
  }
}

权限(Permissions)

每个 API Key 都有一个权限列表,决定可以访问哪些端点。如果 API Key 没有所需权限,请求会被拒绝并返回 403 Forbidden

如何注册 API Key

  1. 访问管理面板 core.owem.com.br
  2. 导航至 安全 → API Keys
  3. 点击 创建 API Key
  4. 填写名称,选择账户,并在白名单中添加 IP
  5. 勾选所需权限(见下方表格)
  6. 点击保存。client_idclient_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-inPOSTpix:write
/pix/cash-outPOSTtransfer:write
/pix/refundPOSTpayment:write
/cpf/validatePOSTaccount:read
/webhooksPOSTaccount:write
/webhooksGETaccount:read
/webhooks/:idDELETEaccount:write
/balanceGETaccount:read
/transactionsGETtransfer:read
/transactions/:idGETtransfer:read
/transactions/e2e/:e2e_idGETtransfer:read
/transactions/tag/:tagGETtransfer:read
/transactions/ref/:external_idGETtransfer:read
/transactions/:id/receiptGETtransfer:read
/pix/keysGETpix:read
/medGETpayment:read
/med/:idGETpayment:read
/statementGETstatement:read

错误响应 -- 403(权限不足)

参见格式:第 1 层 -- 403 权限不足

权限在创建 API Key 时通过 Merchant Portal 或管理 API 配置。

安全提示

  • 切勿在前端代码或公共仓库中暴露 client_secret
  • 请在服务器中使用环境变量
  • 如果配置了 expires_at 字段,API Key 可能会过期;否则,它保持有效,直到在 Merchant Portal 中手动撤销
  • 在白名单中配置允许的 IP -- 空白名单会以 403 IP whitelist required 阻止密钥

Owem Pay Instituição de Pagamento — ISPB 37839059