一、登录服只回答四个问题
登录服不是网关,也不是角色服。它只回答四件事:你是谁、凭证是否有效、这个会话是否还活着、这次登录是否已经处理过。边界定清楚,接口才不会长成第二个业务大杂烩。
| 方法与路径 | 认证 | 幂等键 | 作用 |
|---|---|---|---|
POST /v1/auth/login | 无 | 必须 | 渠道票据换游戏会话 |
POST /v1/auth/refresh | Refresh Token | 必须 | 轮换访问凭证 |
POST /v1/auth/logout | Access Token | 建议 | 主动结束当前会话 |
GET /v1/auth/me | Access Token | 不适用 | 返回账号与会话摘要 |
POST /v1/auth/select-player | Access Token | 建议 | 选定区服角色并换区服凭证 |
二、请求与响应怎么定
登录请求只收四个字段:provider、ticket、device_id、idempotency_key。ticket 是渠道下发的短期凭证,服务端必须自己换票,不能信客户端传来的 openid。响应返回 session_id、account_id、version、access_token、token_type、expires_in,客户端不需要理解 token 内部结构。
刷新接口把 Refresh Token 放在请求体,绝不能放进 URL。URL 会进入访问日志、浏览器历史和 Referer,等于把长期凭证公开。登出接口按幂等设计:会话已不存在也返回成功,避免客户端重试时卡在错误分支。
GET /v1/auth/me 只从 token 的 claims 取账号,禁止接受 ?account_id= 这类参数。选角接口要校验角色确实属于当前账号和目标区服,再签发带 aid(区服)的区服凭证;这一步是越权访问的高发点。
三、统一响应包络
成功和失败都返回同一个 JSON 结构:
| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 业务错误码,0 表示成功 |
message | string | 面向人的稳定描述,不拼接内部错误 |
request_id | string | 本次请求的追踪号,原样回传 |
data | object | 成功时的业务数据,失败时省略 |
业务错误码与 HTTP 状态码分离,但两者必须同时正确:
| HTTP | 业务码示例 | 语义 |
|---|---|---|
| 200 | 0 | 成功 |
| 400 | 40001 | 参数缺失、JSON 非法 |
| 401 | 40101 | 未携带、过期或伪造凭证 |
| 403 | 40301 | 账号与角色/区服不匹配 |
| 409 | 40901 | 幂等键复用了不同请求体 |
| 413 | 41301 | 请求体超过限制 |
| 429 | 42901 | 触发限流 |
| 500 | 50001 | 服务端内部错误 |
“所有错误都返回 200”是最常见的偷懒。它让网关、监控和客户端重试策略全部失真:401 被当成成功,5xx 无法告警,幂等冲突被静默吞掉。
四、错误码要能编程
业务码按大类分段:400xx 客户端参数、401xx 认证、403xx 授权、409xx 冲突、413xx 体积、429xx 限流、500xx 服务端。每新增一个码,都要同时写清“客户端该做什么”:修正参数、刷新 token、提示用户、退避重试,还是直接上报。
不要把渠道返回的原始错误码透传给客户端。渠道码会变,内部实现也会变;对外只保留登录服自己的稳定码,渠道细节写进带 request_id 的服务端日志。
五、幂等与重试
request_id 用于追踪,idempotency_key 用于去重,两者不能混用。客户端在一次“逻辑登录”开始时生成 key,后续所有重试复用同一个 key;换一次登录就要换 key。
服务端把 key、请求指纹(provider|ticket|device_id)和会话结果一起保存。相同 key、相同指纹,直接返回第一次的会话;相同 key、不同指纹,返回 40901,因为这通常意味着客户端串了请求。幂等窗口应短于 token 有效期,避免把早已过期的会话反复返回。
单机内存表只能用于单实例演示。多实例部署必须把幂等记录放进 Redis 或数据库,并用 SET NX 或唯一索引保证“只有一个请求真正建号”。否则两个 Pod 各自建号,客户端拿到两个账号。
六、安全细节
- 限流至少三个维度:IP、设备、账号。只按 IP 会让同一 NAT 下的玩家互相误伤,只按账号挡不住批量撞库。
- 日志脱敏:
ticket和 token 只记录哈希前缀或末四位,请求体默认不落日志。 - 内部错误只回
50001和固定文案,堆栈、SQL、渠道响应只进服务端日志。 - 请求体限制在 64 KiB 以内,开启
DisallowUnknownFields,拒绝多余字段悄悄改变语义。 - token 不放 URL、不放 Cookie 以外的可被脚本读取的位置;网关侧统一加 HSTS。
七、失败场景:超时重试与半成功
渠道回调超时是最典型的失败场景。客户端发出登录请求,服务端已经建号并签发 token,但响应在网络上丢了;客户端重试。如果幂等键只存在某一台机器的内存里,重试落到另一台机器,就会再建一个账号。
更隐蔽的是“半成功”:账号已写入,签发 token 时密钥服务超时。此时不能把半成品会话写进幂等表,否则后续重试会拿到一个没有 token 的会话。正确顺序是先完成可失败的签发,再原子地写入“key → 完整会话”;或者写入 pending 状态并允许用同一 key 继续推进。
这类问题不能靠客户端“别重试”解决:移动网络、渠道 SDK、负载均衡都会重试,服务端必须把幂等做成硬约束。
八、Go 实现
下面的处理器只保留登录主链路,演示包络、错误码、请求体限制与幂等去重。签发器通过 TokenIssuer 接口注入,测试用 fake 替代,避免依赖真实时钟和密钥。
// file: ch04/login/api.go
// tier: L1
// verify: go test ./ch04/login/
package login
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"sync"
"time"
)
// tokenAudience 在签发与校验两侧共用,避免字符串漂移。
const tokenAudience = "game-gateway"
// 业务错误码稳定、可编程;HTTP 状态码只表达传输层语义。
const (
CodeOK = 0
CodeInvalidArg = 40001
CodeConflict = 40901
CodeTooLarge = 41301
CodeMethod = 40501
CodeInternal = 50001
)
type Envelope struct {
Code int `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data any `json:"data,omitempty"`
}
type LoginRequest struct {
Provider string `json:"provider"`
Ticket string `json:"ticket"`
DeviceID string `json:"device_id"`
IdempotencyKey string `json:"idempotency_key"`
}
type SessionView struct {
SessionID string `json:"session_id"`
AccountID uint64 `json:"account_id"`
Version uint64 `json:"version"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// TokenIssuer 由 4.4.2 的 JWT 签发器实现;测试注入 fake,避免依赖时钟与网络。
type TokenIssuer interface {
Issue(accountID uint64, sessionID string, version uint64, audience string) (string, int, error)
}
type loginRecord struct {
fingerprint string
session SessionView
}
type API struct {
issuer TokenIssuer
mu sync.Mutex
logins map[string]loginRecord
nextAccount uint64
}
func NewAPI(issuer TokenIssuer) *API {
return &API{issuer: issuer, logins: map[string]loginRecord{}, nextAccount: 10001}
}
func (a *API) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/login", a.handleLogin)
return mux
}
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
rid := requestID(r)
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, rid, CodeMethod, "method not allowed", nil)
return
}
var req LoginRequest
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeJSON(w, http.StatusRequestEntityTooLarge, rid, CodeTooLarge, "invalid request body", nil)
} else {
writeJSON(w, http.StatusBadRequest, rid, CodeInvalidArg, "invalid request body", nil)
}
return
}
req.Provider, req.Ticket, req.DeviceID = strings.TrimSpace(req.Provider), strings.TrimSpace(req.Ticket), strings.TrimSpace(req.DeviceID)
if req.Provider == "" || req.Ticket == "" || req.DeviceID == "" || len(req.IdempotencyKey) > 128 {
writeJSON(w, http.StatusBadRequest, rid, CodeInvalidArg, "provider, ticket and device_id are required", nil)
return
}
fingerprint := req.Provider + "|" + req.Ticket + "|" + req.DeviceID
a.mu.Lock()
rec, seen := a.logins[req.IdempotencyKey]
a.mu.Unlock()
if req.IdempotencyKey != "" && seen {
if rec.fingerprint != fingerprint {
writeJSON(w, http.StatusConflict, rid, CodeConflict, "idempotency key reused with different payload", nil)
return
}
writeJSON(w, http.StatusOK, rid, CodeOK, "ok", rec.session)
return
}
a.mu.Lock()
accountID := a.nextAccount
a.nextAccount++
a.mu.Unlock()
session := SessionView{SessionID: newID("sess_"), AccountID: accountID, Version: 1, TokenType: "Bearer", ExpiresIn: 900}
token, expiresIn, err := a.issuer.Issue(accountID, session.SessionID, session.Version, tokenAudience)
if err != nil {
writeJSON(w, http.StatusInternalServerError, rid, CodeInternal, "internal error", nil)
return
}
session.AccessToken, session.ExpiresIn = token, expiresIn
a.mu.Lock()
if rec, ok := a.logins[req.IdempotencyKey]; ok && req.IdempotencyKey != "" {
a.mu.Unlock()
if rec.fingerprint != fingerprint {
writeJSON(w, http.StatusConflict, rid, CodeConflict, "idempotency key reused with different payload", nil)
return
}
writeJSON(w, http.StatusOK, rid, CodeOK, "ok", rec.session)
return
}
if req.IdempotencyKey != "" {
a.logins[req.IdempotencyKey] = loginRecord{fingerprint: fingerprint, session: session}
}
a.mu.Unlock()
writeJSON(w, http.StatusOK, rid, CodeOK, "ok", session)
}
func requestID(r *http.Request) string {
if id := r.Header.Get("X-Request-ID"); len(id) >= 8 && len(id) <= 64 && strings.IndexFunc(id, func(c rune) bool {
return !(c == '-' || c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z')
}) < 0 {
return id
}
return newID("req_")
}
func newID(prefix string) string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return prefix + time.Now().Format("150405.000000000")
}
return prefix + hex.EncodeToString(b[:])
}
func writeJSON(w http.ResponseWriter, status int, rid string, code int, msg string, data any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Request-ID", rid)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(Envelope{Code: code, Message: msg, RequestID: rid, Data: data})
}
测试覆盖正常登录、重复请求返回同一会话、缺参数、坏 JSON、超大请求体和错误方法:
// file: ch04/login/api_test.go
// tier: L1
// verify: go test ./ch04/login/
package login
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type fakeIssuer struct{ calls int }
func (f *fakeIssuer) Issue(accountID uint64, sessionID string, version uint64, audience string) (string, int, error) {
f.calls++
return "tok_" + sessionID, 900, nil
}
func call(h http.Handler, method, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "/v1/auth/login", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func decode(t *testing.T, rec *httptest.ResponseRecorder) (Envelope, SessionView) {
t.Helper()
var env Envelope
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v", err)
}
raw, _ := json.Marshal(env.Data)
var view SessionView
_ = json.Unmarshal(raw, &view)
return env, view
}
func TestLoginAndReplay(t *testing.T) {
issuer := &fakeIssuer{}
h := NewAPI(issuer).Routes()
body := `{"provider":"wechat","ticket":"code-123","device_id":"dev-a","idempotency_key":"idem-1"}`
first := call(h, http.MethodPost, body)
env1, v1 := decode(t, first)
if first.Code != http.StatusOK || env1.Code != CodeOK || env1.RequestID == "" || v1.SessionID == "" {
t.Fatalf("first login: status=%d env=%+v view=%+v", first.Code, env1, v1)
}
env2, v2 := decode(t, call(h, http.MethodPost, body))
if env2.Code != CodeOK || issuer.calls != 1 || v2.SessionID != v1.SessionID {
t.Fatalf("replay: env=%+v calls=%d view=%+v", env2, issuer.calls, v2)
}
}
func TestRejectInvalidRequests(t *testing.T) {
h := NewAPI(&fakeIssuer{}).Routes()
cases := []struct {
name, body string
status, code int
}{
{"missing ticket", `{"provider":"wechat","device_id":"dev-a"}`, http.StatusBadRequest, CodeInvalidArg},
{"bad json", `{"provider":`, http.StatusBadRequest, CodeInvalidArg},
{"oversized", `{"provider":"wechat","ticket":"` + strings.Repeat("x", 70<<10) + `","device_id":"dev-a"}`, http.StatusRequestEntityTooLarge, CodeTooLarge},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := call(h, http.MethodPost, tc.body)
if env, _ := decode(t, rec); rec.Code != tc.status || env.Code != tc.code {
t.Fatalf("status=%d code=%d, want %d/%d", rec.Code, env.Code, tc.status, tc.code)
}
})
}
rec := call(h, http.MethodGet, "")
if env, _ := decode(t, rec); rec.Code != http.StatusMethodNotAllowed || env.Code != CodeMethod {
t.Fatalf("method: status=%d code=%d", rec.Code, env.Code)
}
}
九、运行测试
cd .local/checks/gs-practice-lab
gofmt -l ch04/login
go vet ./ch04/login/
go test ./ch04/login/ -count=1
ok gspractice/ch04/login 0.405s
gofmt -l 没有输出表示格式干净;go vet 无输出表示静态检查通过。
小结
登录 API 的难点不在路由,而在契约:五个接口各司其职,统一包络让成功和失败都可编程,幂等键把重试变成安全操作。把 HTTP 状态、业务码、request_id 三者分开,监控和排障才有抓手;代码层面只需守住三条:请求体限量、错误不回显内部细节、重复请求返回同一会话;其余能力可以随规模逐步外置到 Redis、配置中心和网关。
常见错误与边界
- 所有错误都返回 HTTP 200,导致网关和监控无法区分失败。
- 把 token 放在 URL 查询参数里,凭证进入日志和 Referer。
- 不限制请求体大小,攻击者用超大 JSON 拖垮解码和内存。
- 把
idempotency_key当成request_id,每次重试都换新 key,幂等形同虚设。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。