《游戏服务端编程实践》4.3.1 微信 / Steam / Google OAuth 登录

从微信、Steam、Google 三条授权链路出发,拆解移动端授权码、OpenID 2.0 断言与 OIDC id_token 的流程差异,说明游戏服应在哪一步介入换票、校验与建号,并给出可运行的 Go Provider 抽象、一次性 state 存储和三种渠道内存 fake 测试,帮助读者划清客户端与服务端的信任边界。

一、先明确游戏服到底要什么

第三方登录常被简化成“拿到昵称和头像”,但游戏服真正需要的只有三样东西:

  1. 一个稳定、可复核的外部身份标识,例如微信的 openid、Steam 的 steamid、Google 的 sub;
  2. 渠道对“这次授权确实由该用户完成”的声明,例如授权码、OpenID 断言或 id_token;
  3. 一份由游戏服自己签发、可以随时撤销的本地会话,而不是渠道的 access_token。

把这三样混在一起,就会出现“客户端传什么服务端信什么”的经典漏洞。正确边界是:客户端只负责拉起渠道授权并回传一次性凭据,服务端负责换票、验签、绑定与签发本地会话;渠道令牌留在服务端,既不落客户端,也不写进日志。

二、微信:code 换 openid,客户端说了不算

移动端微信登录的典型顺序如下:

  1. 客户端调用微信 SDK,用户确认授权后拿到一次性 code;
  2. 客户端把 code 和本次登录的 state 发给游戏服;
  3. 游戏服携带 appid、appsecret、code 调用微信的 sns/oauth2/access_token;
  4. 微信返回 access_token、openid,部分场景还有 unionid;
  5. 游戏服以 openid 作为外部标识完成建号或绑定,再签发本地会话。

关键点有三个:appsecret 只能存在于服务端,把它打进客户端包等于把“换票权”交给所有玩家;客户端上传的 openid 不能当身份,它必须来自服务端换票的响应;code 有效期通常只有数分钟且只能使用一次,服务端应主动拦截重复提交,而不是把渠道错误直接暴露成“登录失败”。

openid 是“用户 + 应用”维度的标识,同一个微信用户在不同游戏、不同 AppID 下可能得到不同的 openid。如果同一开放平台账号下需要跨应用识别用户,应使用 unionid;但 unionid 也仍然只是外部标识,不能直接当游戏账号。

一个常见的失败场景是:客户端为了“减少一次请求”,把 SDK 返回的 openid 和 code 一起上传,服务端看到 openid 非空就直接建号,只在 openid 为空时才去换票。攻击者只需构造一个从未授权过的 openid,就能登录任意未注册账号,甚至在知道他人 openid 时冒用其身份。修复方式不是“再加一个签名”,而是删除这条捷径:openid 只能由服务端换票产生。

三、Steam:OpenID 2.0 的断言校验

Steam 登录走的是 OpenID 2.0,不是 OAuth 授权码。浏览器被重定向到 Steam,用户确认后回到游戏服配置的 return_to,带回 openid.mode=id_res、openid.claimed_id(形如 https://steamcommunity.com/openid/id/7656119...)以及 openid.identity、openid.sig、openid.signed 等签名字段。

服务端不能只解析 claimed_id 就相信用户身份。正确做法是把收到的整套参数原样转发给 Steam,并附加 openid.mode=check_authentication,只有响应正文包含 is_valid:true 才算通过。return_to 也必须与申请时一致,否则攻击者可以把断言重放到另一个回调地址。

校验通过后,从 claimed_id 中提取 64 位 steamid 作为外部标识。ISteamUser/GetPlayerSummaries 可以用来补充昵称、头像,但它只是资料接口,不是身份来源;即使资料接口超时,也不应把登录判为失败。

claimed_id 与 steamid 的绑定要固定:入库保存提取后的 steamid,不要保存整条 URL。否则同一用户通过不同协议、不同大小写或带尾斜杠的 URL 进入时,会被识别成多个账号。

失败场景同样常见:回调页只判断 openid.mode == "id_res",然后从查询串里取 claimed_id 建号。攻击者可以自己拼一个回调 URL,claimed_id 填别人的 SteamID,完全不需要经过 Steam。check_authentication 是这条链路唯一可信的来源,不能省。

四、Google:OAuth 2.0 + OIDC 的双重校验

Google 登录同时涉及 OAuth 2.0 授权码和 OIDC 身份层。移动端(原生 App)使用授权码 + PKCE:客户端生成 code_verifier,把 code_challenge(S256)随授权请求发出,换票时再带上原始 code_verifier,公共客户端不持有 client_secret;Web 机密客户端可以使用 client_secret,但仍应配合 state 与 nonce。服务端用 code 换回 access_token 与 id_token 后,必须校验 id_token。

id_token 是 JWT,但不是“解出 JSON 就能信”。服务端至少要校验:

  1. 签名:从 Google 的 JWKS 拉取公钥,按 kid 验签,并处理密钥轮换;
  2. aud 与 iss:aud 必须等于本应用的 client_id,iss 必须是 https://accounts.google.com 或文档允许的等价形式;
  3. exp、iat、nonce 与 sub:检查有效期并容忍少量时钟偏差,nonce 必须与发起时保存的值一致,sub 作为外部标识而 email 可能变化。

只做 Base64 解码、读取 sub 和 email,等于信任客户端提交的任何 JSON。攻击者可以伪造一个 id_token,把 sub 写成受害者的值;没有验签,服务端无法发现。

五、三类渠道的对照

维度微信(移动端)Steam(OpenID 2.0)Google(OIDC)
客户端交给服务端code + stateopenid.* 断言参数code + code_verifier + state
服务端换票方式appid + appsecret + codecheck_authentication 回传校验code + client_id + redirect_uri(+ PKCE)
是否需要 client_secret是否原生 App 否;Web 机密客户端是
能否拿 refresh token可以,按授权范围没有该概念可以,需 access_type=offline
外部标识字段openid(跨应用用 unionid)steamid(从 claimed_id 提取)sub
授权码/断言有效期通常数分钟、一次性断言一次性校验授权码通常十分钟内、一次性
主要风险客户端直传 openid不调 check_authentication不验签的 id_token

这张表最重要的不是数字,而是“谁是可信来源”。微信和 Google 的可信来源是服务端换票响应,Steam 的可信来源是 check_authentication 的返回值;客户端提交的任何身份字段都只是线索。

六、游戏服应该在哪一步介入

推荐把登录拆成固定的六步,渠道差异封装在“换票”这一步:

  1. 发起:服务端生成一次性 state,与设备、回调地址、过期时间一起保存;
  2. 回调:校验 state 存在、未过期、未使用,失败立即终止;
  3. 换票:调用对应渠道的 Exchange,拿到规范化后的外部身份;
  4. 归一:把渠道返回的字段映射成内部结构,拒绝空标识;
  5. 绑定:按 provider + external_id 查找或创建游戏账号;
  6. 签发:生成游戏自己的会话或 Token,渠道令牌不向下游传递。

state 必须一次性消费。校验失败时不要删除正确的 state,但同一个 state 成功使用后必须立刻失效,否则攻击者可以重放整个回调。state 也不要只存在客户端:服务端要能判断它是不是自己发出的。账号体系同样要隔离:openid、steamid、sub 都是外部标识,应该落在绑定表里,游戏账号使用内部自增 ID 或雪花 ID,这样换渠道、解绑、封禁都不会影响账号主体。

七、Go 实现:Provider 抽象与内存 fake

下面的实现把渠道差异收敛到 Provider 接口:Kind 用于路由,Exchange 负责“凭据换身份”。ProviderMeta 记录各渠道的协议差异,StateStore 负责一次性 state,Authenticator 把两者串起来。FakeProvider 用内存表模拟渠道换票,便于在单元测试里制造重放、过期和缺少标识等异常。

// file: ch04/oauth/provider.go
// tier: L1
// verify: go test ./ch04/oauth/
package oauth

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"strings"
	"sync"
	"time"
)

type ProviderKind string

const (
	WeChat ProviderKind = "wechat"
	Steam  ProviderKind = "steam"
	Google ProviderKind = "google"
)

var (
	ErrStateMismatch = errors.New("oauth: state mismatch")
	ErrStateExpired  = errors.New("oauth: state expired")
	ErrCodeInvalid   = errors.New("oauth: authorization code not found")
	ErrCodeExpired   = errors.New("oauth: authorization code expired")
	ErrCodeReplayed  = errors.New("oauth: authorization code already used")
	ErrMissingOpenID = errors.New("oauth: provider returned empty external id")
)

type ProviderMeta struct {
	Kind                 ProviderKind
	CodeTTL              time.Duration
	ClientSecretRequired bool
	RefreshToken         bool
	IDField              string
}

var providerMeta = map[ProviderKind]ProviderMeta{
	WeChat: {Kind: WeChat, CodeTTL: 5 * time.Minute, ClientSecretRequired: true, RefreshToken: true, IDField: "openid"},
	Steam:  {Kind: Steam, ClientSecretRequired: false, IDField: "steamid"},
	Google: {Kind: Google, CodeTTL: 10 * time.Minute, RefreshToken: true, IDField: "sub"},
}

func MetaOf(kind ProviderKind) (ProviderMeta, bool) {
	m, ok := providerMeta[kind]
	return m, ok
}

type Identity struct {
	Provider   ProviderKind
	ExternalID string
	Nickname   string
	Claims     map[string]string
}

type Provider interface {
	Kind() ProviderKind
	Exchange(ctx context.Context, code string) (Identity, error)
}

type StateStore struct {
	mu     sync.Mutex
	ttl    time.Duration
	now    func() time.Time
	states map[string]time.Time
}

func NewStateStore(ttl time.Duration) *StateStore {
	return &StateStore{ttl: ttl, now: time.Now, states: make(map[string]time.Time)}
}

func (s *StateStore) Issue() (string, error) {
	buf := make([]byte, 16)
	if _, err := rand.Read(buf); err != nil {
		return "", err
	}
	state := hex.EncodeToString(buf)
	s.mu.Lock()
	defer s.mu.Unlock()
	s.states[state] = s.now().Add(s.ttl)
	return state, nil
}

func (s *StateStore) Consume(state string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	exp, ok := s.states[state]
	if !ok || state == "" {
		return ErrStateMismatch
	}
	delete(s.states, state)
	if s.now().After(exp) {
		return ErrStateExpired
	}
	return nil
}

type Authenticator struct {
	provider Provider
	states   *StateStore
}

func NewAuthenticator(p Provider, stateTTL time.Duration) *Authenticator {
	return &Authenticator{provider: p, states: NewStateStore(stateTTL)}
}

func (a *Authenticator) Begin() (string, error) { return a.states.Issue() }

func (a *Authenticator) Finish(ctx context.Context, code, state string) (Identity, error) {
	if err := a.states.Consume(state); err != nil {
		return Identity{}, err
	}
	return a.provider.Exchange(ctx, code)
}

type fakeCode struct {
	externalID string
	expiresAt  time.Time
	used       bool
}

type FakeProvider struct {
	kind  ProviderKind
	mu    sync.Mutex
	now   func() time.Time
	codes map[string]fakeCode
}

func newFake(kind ProviderKind) *FakeProvider {
	return &FakeProvider{kind: kind, now: time.Now, codes: make(map[string]fakeCode)}
}

func NewWeChatFake() *FakeProvider { return newFake(WeChat) }
func NewSteamFake() *FakeProvider  { return newFake(Steam) }
func NewGoogleFake() *FakeProvider { return newFake(Google) }

func (f *FakeProvider) Kind() ProviderKind { return f.kind }

func (f *FakeProvider) IssueCode(code, externalID string, ttl time.Duration) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.codes[code] = fakeCode{externalID: externalID, expiresAt: f.now().Add(ttl)}
}

func (f *FakeProvider) Exchange(_ context.Context, code string) (Identity, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	c, ok := f.codes[code]
	if !ok {
		return Identity{}, ErrCodeInvalid
	}
	if c.used {
		return Identity{}, ErrCodeReplayed
	}
	if f.now().After(c.expiresAt) {
		return Identity{}, ErrCodeExpired
	}
	c.used = true
	f.codes[code] = c
	if strings.TrimSpace(c.externalID) == "" {
		return Identity{}, ErrMissingOpenID
	}
	return Identity{Provider: f.kind, ExternalID: c.externalID, Claims: map[string]string{"code": code}}, nil
}

const steamClaimPrefix = "https://steamcommunity.com/openid/id/"

func SteamIDFromClaimedID(claimedID string) (string, error) {
	if !strings.HasPrefix(claimedID, steamClaimPrefix) {
		return "", ErrMissingOpenID
	}
	id := strings.TrimPrefix(claimedID, steamClaimPrefix)
	if id == "" || strings.ContainsAny(id, "/?#&") {
		return "", ErrMissingOpenID
	}
	return id, nil
}

测试覆盖四个关键边界:state 被篡改后拒绝、原始 state 仍可使用、成功使用后不可重放;code 第二次提交返回“已使用”;渠道返回空标识时失败;过期 code 失败。另外单独验证 Steam 的 claimed_id 只接受官方前缀,避免把任意 URL 当成 SteamID。

// file: ch04/oauth/provider_test.go
// tier: L1
// verify: go test ./ch04/oauth/
package oauth

import (
	"context"
	"errors"
	"testing"
	"time"
)

func TestFinishRejectsStateMismatch(t *testing.T) {
	fake := NewWeChatFake()
	auth := NewAuthenticator(fake, time.Minute)
	state, err := auth.Begin()
	if err != nil {
		t.Fatalf("begin: %v", err)
	}
	fake.IssueCode("code-1", "openid-1", time.Minute)

	if _, err := auth.Finish(context.Background(), "code-1", state+"tampered"); !errors.Is(err, ErrStateMismatch) {
		t.Fatalf("tampered state: got %v, want %v", err, ErrStateMismatch)
	}
	if _, err := auth.Finish(context.Background(), "code-1", state); err != nil {
		t.Fatalf("original state should remain valid: %v", err)
	}
	if _, err := auth.Finish(context.Background(), "code-1", state); !errors.Is(err, ErrStateMismatch) {
		t.Fatalf("state reuse: got %v, want %v", err, ErrStateMismatch)
	}
}

func TestExchangeRejectsCodeReplay(t *testing.T) {
	fake := NewGoogleFake()
	fake.IssueCode("code-2", "sub-2", time.Minute)
	ctx := context.Background()
	if _, err := fake.Exchange(ctx, "code-2"); err != nil {
		t.Fatalf("first exchange: %v", err)
	}
	if _, err := fake.Exchange(ctx, "code-2"); !errors.Is(err, ErrCodeReplayed) {
		t.Fatalf("replay: got %v, want %v", err, ErrCodeReplayed)
	}
}

func TestExchangeRejectsMissingOpenID(t *testing.T) {
	fake := NewWeChatFake()
	fake.IssueCode("code-3", "   ", time.Minute)
	if _, err := fake.Exchange(context.Background(), "code-3"); !errors.Is(err, ErrMissingOpenID) {
		t.Fatalf("got %v, want %v", err, ErrMissingOpenID)
	}
}

func TestExchangeRejectsExpiredCode(t *testing.T) {
	fake := NewWeChatFake()
	fake.IssueCode("code-4", "openid-4", -time.Second)
	if _, err := fake.Exchange(context.Background(), "code-4"); !errors.Is(err, ErrCodeExpired) {
		t.Fatalf("got %v, want %v", err, ErrCodeExpired)
	}
}

func TestSteamIDFromClaimedID(t *testing.T) {
	got, err := SteamIDFromClaimedID("https://steamcommunity.com/openid/id/76561198000000000")
	if err != nil || got != "76561198000000000" {
		t.Fatalf("got %q, %v", got, err)
	}
	if _, err := SteamIDFromClaimedID("https://evil.example/id/76561198000000000"); !errors.Is(err, ErrMissingOpenID) {
		t.Fatalf("unexpected claimed_id accepted: %v", err)
	}
}

在 .local/checks/gs-practice-lab 下执行 go vet ./ch04/oauth/ 与 go test ./ch04/oauth/ 即可复跑。

小结

  • 游戏服要的是可复核的外部身份和本地会话,不是渠道令牌。
  • 微信必须服务端用 code 换 openid,客户端直传的 openid 一律不采信。
  • Steam 的可信来源是 check_authentication,claimed_id 要提取成 steamid 再入库。
  • Google 的 id_token 必须验签并校验 aud、iss、nonce、exp,不能只做 Base64 解码。
  • state 与 code 都是一次性凭据,登录编排要把“消费”放在最前面。

常见错误与边界

  • 把 access_token 当长期凭证:它通常会过期,且 scope 可能变化;长期登录态应由游戏服自己的会话或 Refresh Token 机制承担。
  • 回调不校验 state:攻击者可以构造回调链接,把受害者账号登录到攻击者会话,或反向把攻击者账号塞进受害者客户端。
  • 把渠道 openid 直接当游戏账号:渠道标识会随应用、平台变化,且无法承载封禁、角色、跨区等游戏侧属性。
  • Steam 只解析 claimed_id:没有 check_authentication 就没有身份保证,整条链路可被任意伪造。
  • Google 只解 JWT 不验签:aud、iss、nonce 全部失效,等同于接受任意 JSON。
  • 忽略时钟偏差:exp 校验过严会把正常用户挡在门外,过松则放大重放窗口;应使用固定且可配置的容差。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist