一、信任边界:客户端说的都不算
接入第三方 SDK 时,最容易犯的错误是把“客户端已经登录”当成“服务端可以放行”。客户端运行在玩家设备上,可以被逆向、改包、注入,也可以直接重放旧请求。SDK 返回值只应被当作“线索”,不是“结论”。
需要默认不可信的声明包括:
- “我已经拿到渠道授权”,对应客户端上传的
openid、昵称、头像; - “我已经支付成功”,对应客户端展示的订单状态;
- “我已经完成实名认证”,对应客户端传来的年龄或身份证标记;
- “这个请求是刚刚生成的”,对应客户端自己填写的时间戳。
服务端能信的只有两类东西:渠道服务端返回的换票结果,以及用服务端持有密钥验证过的签名或回调。二者都不经过客户端转发,或者即使经过,也必须能证明内容未被篡改。
把这条边界写进代码,就是“登录接口不接收身份字段,只接收凭据”。客户端可以传 code、ticket、nonce、timestamp、signature,但不能传 account_id、openid、vip_level、paid。后者一旦出现在请求结构里,迟早会有人图省事直接采信。
二、服务端只信三类验证
| 验证方式 | 可信原因 | 典型渠道 | 失败表现 | 幂等键 |
|---|---|---|---|---|
| 授权码换票 | 服务端持密钥向渠道换取身份 | 微信、Google | code 无效、过期、已使用 | provider + code |
| 票据签名校验 | 签名由服务端密钥或渠道私钥生成 | 自建登录、渠道票据 | 签名不匹配、时间戳越界 | provider + nonce |
| 渠道服务端回调 | 渠道直接请求游戏服,可验签 | 支付、实名、封禁通知 | 签名错误、金额不符、重放 | provider + 通知 ID |
三类验证的共同点是“验证发生在服务端”,但副作用不同:
- 换票通常是一次性消费,失败后不能反复重试同一个
code; - 签名校验只证明请求完整,不证明业务状态,需要再查订单或账号;
- 回调可能重复投递,必须按通知 ID 去重,并保证处理逻辑幂等。
不要把三类验证混成一个“万能登录接口”。支付回调验签通过,只说明通知来自渠道,不说明这笔订单属于当前登录玩家;实名回调验签通过,也不说明客户端展示的实名信息就是本次登录者。每一步都要绑定到具体的 account_id 或订单号。
三、签名串、时间戳与 nonce
自建票据或渠道回调常要求客户端或渠道方携带签名。签名串的拼接规则必须固定:
- 取所有参与签名的参数,排除
sign本身; - 按参数名 ASCII 升序排序;
- 以
k=v形式用&拼接,空值是否参与要写进文档; - 使用 HMAC-SHA256 或渠道指定算法计算,输出小写十六进制;
- 服务端用常量时间比较,避免时序侧信道。
客户端不应承担“唯一可信签名”。如果签名密钥在客户端,逆向者就能伪造任意请求;客户端签名最多用于防误改,不能作为身份依据。真正可信的签名密钥只能放在服务端,或者由渠道用其私钥签名、服务端用公钥验证。
时间戳用于缩小重放窗口,但不能单独防重放。攻击者可以在窗口内反复提交同一个请求,所以还需要 nonce:服务端记录已使用的 nonce,在有效期内拒绝重复。nonce 缓存要设置过期时间,否则内存会无限增长;过期时间应不小于时间戳窗口,避免“时间戳还有效但 nonce 已过期”的缝隙。
校验顺序同样重要:先校验格式和时间戳,再校验签名,最后才检查并写入 nonce。如果先写 nonce 再验签,攻击者可以用伪造签名把合法用户的 nonce 提前占掉,形成拒绝服务。
四、弱网与幂等:同一个 code 只能建一个号
弱网下最常见的链路是:客户端发起登录,服务端换票成功并建号,但响应在回程丢失;客户端超时重试,服务端第二次换票失败,因为 code 已被消费;客户端于是提示“登录失败”,用户再点一次,可能换了一个新 code,又建了一个新号。
要避免重复建号,需要两层幂等:
| 层级 | 幂等键 | 处理方式 |
|---|---|---|
| 换票层 | provider + code | 缓存换票结果,重复请求返回同一身份 |
| 建号层 | provider + external_id | 唯一约束 + “查到即返回”,不重复插入 |
| 回调层 | provider + notification_id | 已处理的通知直接返回成功 |
| 会话层 | account_id + device_id | 重复登录按策略顶号或复用会话 |
换票结果缓存要保存“成功结果”,而不是只保存“处理中”。TTL 可以略大于渠道授权码有效期,避免授权码刚过期、缓存也刚好失效的边界。缓存值至少包含规范化后的外部标识与渠道,不能缓存原始 access_token。
建号层的唯一约束是最后一道防线。即使缓存被清空、服务重启,数据库的唯一索引也能保证同一个 provider + external_id 只对应一个 account_id。捕获唯一键冲突后重新查询并返回已有账号,比“先查再插”更可靠。
失败场景可以这样复现:客户端设置 3 秒超时,服务端换票耗时 4 秒;客户端重试同一个 code,第二次换票返回“已使用”,服务端直接把错误抛给客户端。用户看到失败后重新授权,拿到新 code,服务端按新 code 建了第二个账号。修复不是调大客户端超时,而是让服务端对同一个 code 的重复请求返回第一次的成功结果,并按 external_id 做建号幂等。
五、Go 实现:Verifier 与 nonce 缓存
下面的 Verifier 把“格式、时间戳、签名、nonce、票据”串成固定顺序,任何一步失败都不会消耗 nonce 或票据。MemoryVerifier 用内存表模拟渠道出票和服务端验票,适合在单元测试里制造重放、过期和伪造签名;ProviderKind 复用 4.3.1 中的 provider.go,这里只关注验签与重放控制。
// file: ch04/oauth/verify.go
// tier: L1
// verify: go test ./ch04/oauth/
package oauth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"sort"
"strconv"
"strings"
"sync"
"time"
)
var (
ErrTicketExpired = errors.New("oauth: ticket expired")
ErrTicketReplayed = errors.New("oauth: nonce already used")
ErrSignatureMismatch = errors.New("oauth: signature mismatch")
ErrTimestampSkew = errors.New("oauth: timestamp outside window")
ErrCodeNotFound = errors.New("oauth: unknown authorization code")
)
type VerifyRequest struct {
Provider ProviderKind
Code string
Timestamp int64
Nonce string
Signature string
Extra map[string]string
}
type VerifyResult struct {
Provider ProviderKind
ExternalID string
AccountKey string
IssuedAt time.Time
Claims map[string]string
}
type Verifier interface {
Verify(ctx context.Context, req VerifyRequest) (VerifyResult, error)
}
type issuedCode struct {
provider ProviderKind
externalID string
issuedAt time.Time
ttl time.Duration
}
type MemoryVerifier struct {
secret []byte
window time.Duration
now func() time.Time
mu sync.Mutex
codes map[string]issuedCode
nonces map[string]time.Time
}
func NewMemoryVerifier(secret string, window time.Duration) *MemoryVerifier {
return &MemoryVerifier{
secret: []byte(secret),
window: window,
now: time.Now,
codes: make(map[string]issuedCode),
nonces: make(map[string]time.Time),
}
}
func (v *MemoryVerifier) IssueCode(provider ProviderKind, code, externalID string, ttl time.Duration) {
v.mu.Lock()
defer v.mu.Unlock()
v.codes[code] = issuedCode{provider: provider, externalID: externalID, issuedAt: v.now(), ttl: ttl}
}
func (v *MemoryVerifier) Verify(_ context.Context, req VerifyRequest) (VerifyResult, error) {
if req.Code == "" || req.Nonce == "" {
return VerifyResult{}, ErrSignatureMismatch
}
now := v.now()
skew := now.Sub(time.Unix(req.Timestamp, 0))
if skew < 0 {
skew = -skew
}
if skew > v.window {
return VerifyResult{}, ErrTimestampSkew
}
want := SignParams(string(v.secret), req.signatureParams())
if !hmac.Equal([]byte(want), []byte(req.Signature)) {
return VerifyResult{}, ErrSignatureMismatch
}
v.mu.Lock()
defer v.mu.Unlock()
v.purgeLocked(now)
nonceKey := string(req.Provider) + ":" + req.Nonce
if exp, ok := v.nonces[nonceKey]; ok && now.Before(exp) {
return VerifyResult{}, ErrTicketReplayed
}
t, ok := v.codes[req.Code]
if !ok || t.provider != req.Provider {
return VerifyResult{}, ErrCodeNotFound
}
if now.After(t.issuedAt.Add(t.ttl)) {
return VerifyResult{}, ErrTicketExpired
}
delete(v.codes, req.Code)
v.nonces[nonceKey] = now.Add(v.window)
return VerifyResult{
Provider: t.provider,
ExternalID: t.externalID,
AccountKey: string(t.provider) + ":" + t.externalID,
IssuedAt: t.issuedAt,
Claims: map[string]string{"nonce": req.Nonce},
}, nil
}
func (v *MemoryVerifier) purgeLocked(now time.Time) {
for k, exp := range v.nonces {
if now.After(exp) {
delete(v.nonces, k)
}
}
}
func (r VerifyRequest) signatureParams() map[string]string {
params := make(map[string]string, len(r.Extra)+4)
for k, val := range r.Extra {
params[k] = val
}
params["provider"] = string(r.Provider)
params["code"] = r.Code
params["timestamp"] = strconv.FormatInt(r.Timestamp, 10)
params["nonce"] = r.Nonce
return params
}
func CanonicalString(params map[string]string) string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for i, k := range keys {
if i > 0 {
b.WriteByte('&')
}
b.WriteString(k)
b.WriteByte('=')
b.WriteString(params[k])
}
return b.String()
}
func SignParams(secret string, params map[string]string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(CanonicalString(params)))
return hex.EncodeToString(mac.Sum(nil))
}
实现里有三个值得注意的细节:签名比较使用 hmac.Equal;nonce 只在签名和票据都通过后才写入;票据在成功返回前删除,保证一个 code 只能兑换一次。
// file: ch04/oauth/verify_test.go
// tier: L1
// verify: go test ./ch04/oauth/
package oauth
import (
"context"
"errors"
"testing"
"time"
)
func signRequest(secret string, req VerifyRequest) VerifyRequest {
req.Signature = SignParams(secret, req.signatureParams())
return req
}
func TestVerifyAcceptsSignedTicket(t *testing.T) {
v := NewMemoryVerifier("server-secret", time.Minute)
v.IssueCode(Google, "code-ok", "sub-100", time.Minute)
req := signRequest("server-secret", VerifyRequest{
Provider: Google, Code: "code-ok", Timestamp: time.Now().Unix(), Nonce: "nonce-1",
})
got, err := v.Verify(context.Background(), req)
if err != nil {
t.Fatalf("verify: %v", err)
}
if got.AccountKey != "google:sub-100" {
t.Fatalf("account key = %q", got.AccountKey)
}
}
func TestVerifyRejectsNonceReplay(t *testing.T) {
v := NewMemoryVerifier("server-secret", time.Minute)
v.IssueCode(WeChat, "code-1", "openid-1", time.Minute)
req := signRequest("server-secret", VerifyRequest{
Provider: WeChat, Code: "code-1", Timestamp: time.Now().Unix(), Nonce: "nonce-2",
})
if _, err := v.Verify(context.Background(), req); err != nil {
t.Fatalf("first verify: %v", err)
}
if _, err := v.Verify(context.Background(), req); !errors.Is(err, ErrTicketReplayed) {
t.Fatalf("replay: got %v, want %v", err, ErrTicketReplayed)
}
}
func TestVerifyRejectsExpiredTicket(t *testing.T) {
v := NewMemoryVerifier("server-secret", time.Minute)
v.IssueCode(Steam, "code-old", "76561198000000000", -time.Second)
req := signRequest("server-secret", VerifyRequest{
Provider: Steam, Code: "code-old", Timestamp: time.Now().Unix(), Nonce: "nonce-3",
})
if _, err := v.Verify(context.Background(), req); !errors.Is(err, ErrTicketExpired) {
t.Fatalf("got %v, want %v", err, ErrTicketExpired)
}
}
func TestVerifyRejectsForgedSignatureWithoutBurningNonce(t *testing.T) {
v := NewMemoryVerifier("server-secret", time.Minute)
v.IssueCode(Google, "code-2", "sub-2", time.Minute)
now := time.Now().Unix()
bad := signRequest("attacker-secret", VerifyRequest{
Provider: Google, Code: "code-2", Timestamp: now, Nonce: "nonce-4",
})
if _, err := v.Verify(context.Background(), bad); !errors.Is(err, ErrSignatureMismatch) {
t.Fatalf("forged: got %v, want %v", err, ErrSignatureMismatch)
}
good := signRequest("server-secret", VerifyRequest{
Provider: Google, Code: "code-2", Timestamp: now, Nonce: "nonce-4",
})
if _, err := v.Verify(context.Background(), good); err != nil {
t.Fatalf("valid request after forgery: %v", err)
}
}
func TestCanonicalStringSortsKeys(t *testing.T) {
got := CanonicalString(map[string]string{"b": "2", "a": "1"})
if got != "a=1&b=2" {
t.Fatalf("canonical string = %q", got)
}
}
测试覆盖正常验签、nonce 重放、票据过期、伪造签名,以及“伪造请求不消耗合法 nonce”这一关键边界。最后一项保证攻击者无法通过发送伪造请求把用户的 nonce 提前占掉。
六、落库与日志的最小纪律
- 数据库只保存
provider、external_id、account_id和必要的时间字段,不保存access_token、refresh_token、完整id_token。 - 如果业务必须使用渠道令牌,应加密后单独存储,并设置比业务会话更短的保留期。
- 日志中脱敏
code、ticket、signature,只保留前缀和哈希;排障时用请求 ID 关联,不要打印完整凭据。 - 回调处理记录通知 ID、处理结果和时间,重复通知直接返回成功,避免渠道持续重试。
- 验签失败、时间戳越界、
nonce重放分别计数;三类指标异常往往对应不同的攻击或配置问题。
小结
- 客户端 SDK 的返回值只是线索,身份、支付、实名等声明必须由服务端复核。
- 服务端只信三类来源:授权码换票、服务端密钥验签、渠道服务端回调。
- 签名串要固定排序与拼接规则,签名密钥不能放在客户端。
- 时间戳只能缩小重放窗口,
nonce缓存和票据单次消费才是防重放的核心。 - 弱网重试要靠“换票结果缓存 + 数据库唯一约束”两层幂等,不能靠客户端超时设置。
常见错误与边界
- 只校验格式不校验来源:
code看起来像真的、openid长度也对,并不代表它来自渠道。 - 把 SDK 返回值直接抄进数据库:昵称、头像、VIP 等级都可能被改包伪造,落库前要区分“渠道资料”和“游戏数据”。
- 回调不验签:支付与实名回调必须验证渠道签名,并核对订单号、金额、应用 ID。
- 先写
nonce再验签:伪造请求可以提前占用合法nonce,造成合法用户登录失败。 - 用客户端时间戳做唯一判断:设备时间可改,服务端应使用自己的时钟并限制窗口。
- 忽略时钟偏差:窗口过小会误伤正常用户,过大会放大重放空间;应使用固定、可配置的容差。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。