一、先明确游戏服到底要什么
第三方登录常被简化成“拿到昵称和头像”,但游戏服真正需要的只有三样东西:
- 一个稳定、可复核的外部身份标识,例如微信的
openid、Steam 的steamid、Google 的sub; - 渠道对“这次授权确实由该用户完成”的声明,例如授权码、OpenID 断言或
id_token; - 一份由游戏服自己签发、可以随时撤销的本地会话,而不是渠道的
access_token。
把这三样混在一起,就会出现“客户端传什么服务端信什么”的经典漏洞。正确边界是:客户端只负责拉起渠道授权并回传一次性凭据,服务端负责换票、验签、绑定与签发本地会话;渠道令牌留在服务端,既不落客户端,也不写进日志。
二、微信:code 换 openid,客户端说了不算
移动端微信登录的典型顺序如下:
- 客户端调用微信 SDK,用户确认授权后拿到一次性
code; - 客户端把
code和本次登录的state发给游戏服; - 游戏服携带
appid、appsecret、code调用微信的sns/oauth2/access_token; - 微信返回
access_token、openid,部分场景还有unionid; - 游戏服以
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 就能信”。服务端至少要校验:
- 签名:从 Google 的 JWKS 拉取公钥,按
kid验签,并处理密钥轮换; aud与iss:aud必须等于本应用的client_id,iss必须是https://accounts.google.com或文档允许的等价形式;exp、iat、nonce与sub:检查有效期并容忍少量时钟偏差,nonce必须与发起时保存的值一致,sub作为外部标识而email可能变化。
只做 Base64 解码、读取 sub 和 email,等于信任客户端提交的任何 JSON。攻击者可以伪造一个 id_token,把 sub 写成受害者的值;没有验签,服务端无法发现。
五、三类渠道的对照
| 维度 | 微信(移动端) | Steam(OpenID 2.0) | Google(OIDC) |
|---|---|---|---|
| 客户端交给服务端 | code + state | openid.* 断言参数 | code + code_verifier + state |
| 服务端换票方式 | appid + appsecret + code | check_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 的返回值;客户端提交的任何身份字段都只是线索。
六、游戏服应该在哪一步介入
推荐把登录拆成固定的六步,渠道差异封装在“换票”这一步:
- 发起:服务端生成一次性
state,与设备、回调地址、过期时间一起保存; - 回调:校验
state存在、未过期、未使用,失败立即终止; - 换票:调用对应渠道的
Exchange,拿到规范化后的外部身份; - 归一:把渠道返回的字段映射成内部结构,拒绝空标识;
- 绑定:按
provider + external_id查找或创建游戏账号; - 签发:生成游戏自己的会话或 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校验过严会把正常用户挡在门外,过松则放大重放窗口;应使用固定且可配置的容差。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。