fix(game): issue compact AMG provider token

This commit is contained in:
zhx 2026-07-22 10:59:17 +08:00
parent 290e9ceb93
commit aa0ddbc3b0
9 changed files with 112 additions and 9 deletions

View File

@ -1193,6 +1193,8 @@ type LaunchGameRequest struct {
AccessToken string `protobuf:"bytes,7,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` AccessToken string `protobuf:"bytes,7,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"`
// country_id 由 gateway 从当前用户资料注入,供 provider 回调订单按国家统计。 // country_id 由 gateway 从当前用户资料注入,供 provider 回调订单按国家统计。
CountryId int64 `protobuf:"varint,8,opt,name=country_id,json=countryId,proto3" json:"country_id,omitempty"` CountryId int64 `protobuf:"varint,8,opt,name=country_id,json=countryId,proto3" json:"country_id,omitempty"`
// provider_token 是 gateway 签发的短期厂商令牌;仅需要限长令牌的 adapter 使用。
ProviderToken string `protobuf:"bytes,9,opt,name=provider_token,json=providerToken,proto3" json:"provider_token,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1283,6 +1285,13 @@ func (x *LaunchGameRequest) GetCountryId() int64 {
return 0 return 0
} }
func (x *LaunchGameRequest) GetProviderToken() string {
if x != nil {
return x.ProviderToken
}
return ""
}
type LaunchGameResponse struct { type LaunchGameResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
@ -6949,7 +6958,7 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\x14bridge_script_sha256\x18\x03 \x01(\tR\x12bridgeScriptSha256\"~\n" + "\x14bridge_script_sha256\x18\x03 \x01(\tR\x12bridgeScriptSha256\"~\n" +
"\x17GetBridgeScriptResponse\x12=\n" + "\x17GetBridgeScriptResponse\x12=\n" +
"\x06config\x18\x01 \x01(\v2%.hyapp.game.v1.GameBridgeScriptConfigR\x06config\x12$\n" + "\x06config\x18\x01 \x01(\v2%.hyapp.game.v1.GameBridgeScriptConfigR\x06config\x12$\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\x8e\x02\n" + "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xb5\x02\n" +
"\x11LaunchGameRequest\x12.\n" + "\x11LaunchGameRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12&\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12&\n" +
@ -6959,7 +6968,8 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
"\aroom_id\x18\x06 \x01(\tR\x06roomId\x12!\n" + "\aroom_id\x18\x06 \x01(\tR\x06roomId\x12!\n" +
"\faccess_token\x18\a \x01(\tR\vaccessToken\x12\x1d\n" + "\faccess_token\x18\a \x01(\tR\vaccessToken\x12\x1d\n" +
"\n" + "\n" +
"country_id\x18\b \x01(\x03R\tcountryId\"\xdf\x01\n" + "country_id\x18\b \x01(\x03R\tcountryId\x12%\n" +
"\x0eprovider_token\x18\t \x01(\tR\rproviderToken\"\xdf\x01\n" +
"\x12LaunchGameResponse\x12\x1d\n" + "\x12LaunchGameResponse\x12\x1d\n" +
"\n" + "\n" +
"session_id\x18\x01 \x01(\tR\tsessionId\x12\x1d\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x1d\n" +

View File

@ -144,6 +144,8 @@ message LaunchGameRequest {
string access_token = 7; string access_token = 7;
// country_id gateway provider // country_id gateway provider
int64 country_id = 8; int64 country_id = 8;
// provider_token gateway adapter 使
string provider_token = 9;
} }
message LaunchGameResponse { message LaunchGameResponse {

View File

@ -10,6 +10,7 @@ import (
"encoding/json" "encoding/json"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
@ -51,7 +52,8 @@ func TestLaunchGameBuildsAMGURL(t *testing.T) {
DisplayUserID: "420001", DisplayUserID: "420001",
GameID: "amg_1001", GameID: "amg_1001",
RoomID: "room_1", RoomID: "room_1",
AccessToken: "app_access_token_amg", AccessToken: strings.Repeat("full_app_access_token", 40),
ProviderToken: "short_amg_provider_token",
}) })
if err != nil { if err != nil {
t.Fatalf("LaunchGame failed: %v", err) t.Fatalf("LaunchGame failed: %v", err)
@ -67,7 +69,7 @@ func TestLaunchGameBuildsAMGURL(t *testing.T) {
t.Fatalf("AMG launch base mismatch: %s", result.LaunchURL) t.Fatalf("AMG launch base mismatch: %s", result.LaunchURL)
} }
want := map[string]string{ want := map[string]string{
"user_token": "app_access_token_amg", "user_token": "short_amg_provider_token",
"app_key": "kzphamrv01", "app_key": "kzphamrv01",
"room_id": "room_1", "room_id": "room_1",
"mini": "1", "mini": "1",

View File

@ -137,6 +137,7 @@ type LaunchCommand struct {
RegionID int64 RegionID int64
CountryID int64 CountryID int64
AccessToken string AccessToken string
ProviderToken string
} }
type LaunchResult struct { type LaunchResult struct {
@ -308,6 +309,11 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
now := s.now() now := s.now()
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6) sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
token := strings.TrimSpace(command.AccessToken) token := strings.TrimSpace(command.AccessToken)
if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) && strings.TrimSpace(command.ProviderToken) != "" {
// AMG 限制 user_token 最长 512 字节Gateway 签发的厂商令牌保留可验签的 App/用户归因,
// 且不会把完整登录 JWT 暴露给厂商。滚动发布期旧 Gateway 没有该字段时仍保留原逻辑。
token = strings.TrimSpace(command.ProviderToken)
}
if token == "" { if token == "" {
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) || strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) { if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) || strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) {
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "access token is required for provider game launch") return LaunchResult{}, xerr.New(xerr.InvalidArgument, "access token is required for provider game launch")

View File

@ -132,6 +132,7 @@ func (s *Server) LaunchGame(ctx context.Context, req *gamev1.LaunchGameRequest)
RoomID: req.GetRoomId(), RoomID: req.GetRoomId(),
CountryID: req.GetCountryId(), CountryID: req.GetCountryId(),
AccessToken: req.GetAccessToken(), AccessToken: req.GetAccessToken(),
ProviderToken: req.GetProviderToken(),
}) })
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)

View File

@ -2,10 +2,13 @@ package auth
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
jwt "github.com/golang-jwt/jwt/v5" jwt "github.com/golang-jwt/jwt/v5"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
@ -14,6 +17,8 @@ import (
// ErrAccessTokenExpired 让 transport 精确区分“可通过 refresh 恢复”的 JWT 过期与签名非法/缺失凭证。 // ErrAccessTokenExpired 让 transport 精确区分“可通过 refresh 恢复”的 JWT 过期与签名非法/缺失凭证。
var ErrAccessTokenExpired = errors.New("access token expired") var ErrAccessTokenExpired = errors.New("access token expired")
const gameProviderTokenType = "game_provider"
type contextKey string type contextKey string
const ( const (
@ -57,6 +62,16 @@ func (v *Verifier) VerifyUserID(header string) (int64, error) {
// Verify 校验 access token 并返回 gateway 入口需要的用户状态快照。 // Verify 校验 access token 并返回 gateway 入口需要的用户状态快照。
func (v *Verifier) Verify(header string) (Claims, error) { func (v *Verifier) Verify(header string) (Claims, error) {
return v.verify(header, false)
}
// VerifyGameCallbackToken 同时接受历史 access token 与新的短期游戏令牌。
// game_provider 令牌只能走厂商回调入口;普通 API 仍由 Verify 拒绝,避免短令牌被当作登录凭证复用。
func (v *Verifier) VerifyGameCallbackToken(header string) (Claims, error) {
return v.verify(header, true)
}
func (v *Verifier) verify(header string, allowGameProvider bool) (Claims, error) {
if !strings.HasPrefix(header, "Bearer ") { if !strings.HasPrefix(header, "Bearer ") {
return Claims{}, fmt.Errorf("missing bearer token") return Claims{}, fmt.Errorf("missing bearer token")
} }
@ -82,6 +97,11 @@ func (v *Verifier) Verify(header string) (Claims, error) {
if !ok { if !ok {
return Claims{}, fmt.Errorf("invalid claims") return Claims{}, fmt.Errorf("invalid claims")
} }
tokenType, _ := claims["typ"].(string)
tokenType = strings.TrimSpace(tokenType)
if tokenType != "" && tokenType != "access" && !(allowGameProvider && tokenType == gameProviderTokenType) {
return Claims{}, fmt.Errorf("invalid token type")
}
rawUserID, ok := claims["user_id"].(string) rawUserID, ok := claims["user_id"].(string)
if !ok || strings.TrimSpace(rawUserID) == "" { if !ok || strings.TrimSpace(rawUserID) == "" {
@ -110,6 +130,32 @@ func (v *Verifier) Verify(header string) (Claims, error) {
}, nil }, nil
} }
// IssueGameProviderToken 把已完成入口鉴权的用户身份压缩成厂商专用 JWT。
// 令牌只包含回调归因需要的 App、用户、过期时间和随机 ID避免把不断增长的完整登录 claims 暴露给厂商。
func (v *Verifier) IssueGameProviderToken(appCodeValue string, userID int64, ttl time.Duration) (string, error) {
if v == nil || len(v.secret) == 0 || userID <= 0 || ttl <= 0 {
return "", fmt.Errorf("invalid game provider token input")
}
var nonce [12]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", fmt.Errorf("generate game provider token id: %w", err)
}
now := time.Now().UTC()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"app_code": appcode.Normalize(appCodeValue),
"exp": now.Add(ttl).Unix(),
"iat": now.Unix(),
"jti": hex.EncodeToString(nonce[:]),
"typ": gameProviderTokenType,
"user_id": strconv.FormatInt(userID, 10),
})
signed, err := token.SignedString(v.secret)
if err != nil {
return "", fmt.Errorf("sign game provider token: %w", err)
}
return signed, nil
}
func claimUnixSecondsMS(value any) int64 { func claimUnixSecondsMS(value any) int64 {
switch typed := value.(type) { switch typed := value.(type) {
case float64: case float64:

View File

@ -90,3 +90,22 @@ func TestVerifierDistinguishesExpiredAccessTokenFromInvalidCredentials(t *testin
t.Fatalf("expired token with another invalid time claim must remain generic unauthorized, got %v", err) t.Fatalf("expired token with another invalid time claim must remain generic unauthorized, got %v", err)
} }
} }
func TestGameProviderTokenIsShortAndCallbackOnly(t *testing.T) {
t.Parallel()
verifier := NewVerifier("secret")
token, err := verifier.IssueGameProviderToken("fami", 399999999999990001, 24*time.Hour)
if err != nil {
t.Fatalf("IssueGameProviderToken failed: %v", err)
}
if len(token) > 512 {
t.Fatalf("game provider token length=%d, want <=512", len(token))
}
claims, err := verifier.VerifyGameCallbackToken("Bearer " + token)
if err != nil || claims.AppCode != "fami" || claims.UserID != 399999999999990001 {
t.Fatalf("callback token claims mismatch: claims=%+v err=%v", claims, err)
}
if _, err := verifier.Verify("Bearer " + token); err == nil {
t.Fatal("game provider token must not authenticate ordinary App APIs")
}
}

View File

@ -203,6 +203,11 @@ func TestLaunchGameForwardsDisplayUserID(t *testing.T) {
if gameClient.lastLaunch.GetGameId() != "demo_rocket_001" || gameClient.lastLaunch.GetDisplayUserId() != "100001" || gameClient.lastLaunch.GetRoomId() != "room-1" || gameClient.lastLaunch.GetAccessToken() != accessToken { if gameClient.lastLaunch.GetGameId() != "demo_rocket_001" || gameClient.lastLaunch.GetDisplayUserId() != "100001" || gameClient.lastLaunch.GetRoomId() != "room-1" || gameClient.lastLaunch.GetAccessToken() != accessToken {
t.Fatalf("LaunchGame request mismatch: %+v", gameClient.lastLaunch) t.Fatalf("LaunchGame request mismatch: %+v", gameClient.lastLaunch)
} }
providerToken := gameClient.lastLaunch.GetProviderToken()
claims, err := auth.NewVerifier("secret").VerifyGameCallbackToken("Bearer " + providerToken)
if err != nil || len(providerToken) > 512 || claims.UserID != 42 || claims.AppCode != "lalu" {
t.Fatalf("provider token mismatch: length=%d claims=%+v err=%v", len(providerToken), claims, err)
}
} }
func TestDiceMatchRenewsAccessTokenWhenTokenNearExpiry(t *testing.T) { func TestDiceMatchRenewsAccessTokenWhenTokenNearExpiry(t *testing.T) {
@ -574,7 +579,10 @@ func TestZGameFixedCallbackRouteIsRawPublicResponse(t *testing.T) {
} }
func TestAMGFixedCallbackRoutesUseAuthorizationTokenAppAndRawResponse(t *testing.T) { func TestAMGFixedCallbackRoutesUseAuthorizationTokenAppAndRawResponse(t *testing.T) {
token := signGatewayTokenWithAppCode(t, "secret", 42, "fami") token, err := auth.NewVerifier("secret").IssueGameProviderToken("fami", 42, 24*time.Hour)
if err != nil {
t.Fatalf("IssueGameProviderToken failed: %v", err)
}
tests := []struct { tests := []struct {
name string name string
method string method string

View File

@ -21,6 +21,7 @@ import (
const ( const (
diceAccessTokenRenewBefore = 10 * time.Minute diceAccessTokenRenewBefore = 10 * time.Minute
gameProviderTokenTTL = 24 * time.Hour
hyappAccessTokenHeader = "X-Hyapp-Access-Token" hyappAccessTokenHeader = "X-Hyapp-Access-Token"
hyappTokenTypeHeader = "X-Hyapp-Token-Type" hyappTokenTypeHeader = "X-Hyapp-Token-Type"
hyappExpiresInHeader = "X-Hyapp-Expires-In" hyappExpiresInHeader = "X-Hyapp-Expires-In"
@ -148,6 +149,13 @@ func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request)
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
return return
} }
// 厂商令牌由已经完成 access-token 鉴权的 gateway 签发;它只带回调归因需要的最小 claims
// game-service 会按 adapter 决定是否使用,避免 AMG 因完整 App JWT 超过 512 字节而拒绝启动。
providerToken, err := h.jwtVerifier.IssueGameProviderToken(appcode.FromContext(request.Context()), userID, gameProviderTokenTTL)
if err != nil {
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal server error")
return
}
resp, err := h.gameClient.LaunchGame(request.Context(), &gamev1.LaunchGameRequest{ resp, err := h.gameClient.LaunchGame(request.Context(), &gamev1.LaunchGameRequest{
Meta: gameMeta(request), Meta: gameMeta(request),
UserId: userID, UserId: userID,
@ -157,6 +165,7 @@ func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request)
RoomId: body.RoomID, RoomId: body.RoomID,
AccessToken: bearerAccessToken(request), AccessToken: bearerAccessToken(request),
CountryId: userResp.GetUser().GetCountryId(), CountryId: userResp.GetUser().GetCountryId(),
ProviderToken: providerToken,
}) })
httpkit.Write(writer, request, gameLaunchDataFromProto(resp), err) httpkit.Write(writer, request, gameLaunchDataFromProto(resp), err)
} }
@ -840,12 +849,12 @@ func (h *Handler) withVerifiedCallbackToken(request *http.Request, raw []byte) (
return request, false return request, false
} }
for _, token := range jwtCandidates { for _, token := range jwtCandidates {
claims, err := h.jwtVerifier.Verify("Bearer " + token) claims, err := h.jwtVerifier.VerifyGameCallbackToken("Bearer " + token)
if err != nil { if err != nil {
continue continue
} }
// 游戏回调是公网入口callback 自带 app_code/header 都不能作为归因事实;只有 gateway 验过签的 HyApp access token // 游戏回调是公网入口callback 自带 app_code/header 都不能作为归因事实;只有 gateway 验过签的
// 才能覆盖 request context后续 gameMeta 会把 token 内 app_code/user_id 透传到 game-service RequestMeta // HyApp access/provider token 才能覆盖 request context后续 gameMeta 会透传签名内的 app_code/user_id
return request.WithContext(auth.WithClaims(request.Context(), claims)), true return request.WithContext(auth.WithClaims(request.Context(), claims)), true
} }
return request, false return request, false