diff --git a/api/proto/game/v1/game.pb.go b/api/proto/game/v1/game.pb.go index 6ed173eb..53c309d5 100644 --- a/api/proto/game/v1/game.pb.go +++ b/api/proto/game/v1/game.pb.go @@ -1192,7 +1192,9 @@ type LaunchGameRequest struct { // Provider adapters pass it through as the game token instead of minting a separate game token. AccessToken string `protobuf:"bytes,7,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` // 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 sizeCache protoimpl.SizeCache } @@ -1283,6 +1285,13 @@ func (x *LaunchGameRequest) GetCountryId() int64 { return 0 } +func (x *LaunchGameRequest) GetProviderToken() string { + if x != nil { + return x.ProviderToken + } + return "" +} + type LaunchGameResponse struct { state protoimpl.MessageState `protogen:"open.v1"` 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" + "\x17GetBridgeScriptResponse\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" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\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" + "\faccess_token\x18\a \x01(\tR\vaccessToken\x12\x1d\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" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x1d\n" + diff --git a/api/proto/game/v1/game.proto b/api/proto/game/v1/game.proto index 3b89bc9b..8f73d142 100644 --- a/api/proto/game/v1/game.proto +++ b/api/proto/game/v1/game.proto @@ -144,6 +144,8 @@ message LaunchGameRequest { string access_token = 7; // country_id 由 gateway 从当前用户资料注入,供 provider 回调订单按国家统计。 int64 country_id = 8; + // provider_token 是 gateway 签发的短期厂商令牌;仅需要限长令牌的 adapter 使用。 + string provider_token = 9; } message LaunchGameResponse { diff --git a/services/game-service/internal/service/game/amg_adapter_test.go b/services/game-service/internal/service/game/amg_adapter_test.go index 3f71fd6d..349538d1 100644 --- a/services/game-service/internal/service/game/amg_adapter_test.go +++ b/services/game-service/internal/service/game/amg_adapter_test.go @@ -10,6 +10,7 @@ import ( "encoding/json" "net/url" "strconv" + "strings" "testing" "time" @@ -51,7 +52,8 @@ func TestLaunchGameBuildsAMGURL(t *testing.T) { DisplayUserID: "420001", GameID: "amg_1001", RoomID: "room_1", - AccessToken: "app_access_token_amg", + AccessToken: strings.Repeat("full_app_access_token", 40), + ProviderToken: "short_amg_provider_token", }) if err != nil { t.Fatalf("LaunchGame failed: %v", err) @@ -67,7 +69,7 @@ func TestLaunchGameBuildsAMGURL(t *testing.T) { t.Fatalf("AMG launch base mismatch: %s", result.LaunchURL) } want := map[string]string{ - "user_token": "app_access_token_amg", + "user_token": "short_amg_provider_token", "app_key": "kzphamrv01", "room_id": "room_1", "mini": "1", diff --git a/services/game-service/internal/service/game/service.go b/services/game-service/internal/service/game/service.go index 36118398..bb0d77d1 100644 --- a/services/game-service/internal/service/game/service.go +++ b/services/game-service/internal/service/game/service.go @@ -137,6 +137,7 @@ type LaunchCommand struct { RegionID int64 CountryID int64 AccessToken string + ProviderToken string } type LaunchResult struct { @@ -308,6 +309,11 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch now := s.now() sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6) 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 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") diff --git a/services/game-service/internal/transport/grpc/server.go b/services/game-service/internal/transport/grpc/server.go index 27305214..8ff8bd25 100644 --- a/services/game-service/internal/transport/grpc/server.go +++ b/services/game-service/internal/transport/grpc/server.go @@ -132,6 +132,7 @@ func (s *Server) LaunchGame(ctx context.Context, req *gamev1.LaunchGameRequest) RoomID: req.GetRoomId(), CountryID: req.GetCountryId(), AccessToken: req.GetAccessToken(), + ProviderToken: req.GetProviderToken(), }) if err != nil { return nil, xerr.ToGRPCError(err) diff --git a/services/gateway-service/internal/auth/jwt.go b/services/gateway-service/internal/auth/jwt.go index bad57977..4f11c5c9 100644 --- a/services/gateway-service/internal/auth/jwt.go +++ b/services/gateway-service/internal/auth/jwt.go @@ -2,10 +2,13 @@ package auth import ( "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" "strconv" "strings" + "time" jwt "github.com/golang-jwt/jwt/v5" "hyapp/pkg/appcode" @@ -14,6 +17,8 @@ import ( // ErrAccessTokenExpired 让 transport 精确区分“可通过 refresh 恢复”的 JWT 过期与签名非法/缺失凭证。 var ErrAccessTokenExpired = errors.New("access token expired") +const gameProviderTokenType = "game_provider" + type contextKey string const ( @@ -57,6 +62,16 @@ func (v *Verifier) VerifyUserID(header string) (int64, error) { // Verify 校验 access token 并返回 gateway 入口需要的用户状态快照。 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 ") { return Claims{}, fmt.Errorf("missing bearer token") } @@ -82,6 +97,11 @@ func (v *Verifier) Verify(header string) (Claims, error) { if !ok { 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) if !ok || strings.TrimSpace(rawUserID) == "" { @@ -110,6 +130,32 @@ func (v *Verifier) Verify(header string) (Claims, error) { }, 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 { switch typed := value.(type) { case float64: diff --git a/services/gateway-service/internal/auth/jwt_test.go b/services/gateway-service/internal/auth/jwt_test.go index d665374a..cd6859cd 100644 --- a/services/gateway-service/internal/auth/jwt_test.go +++ b/services/gateway-service/internal/auth/jwt_test.go @@ -90,3 +90,22 @@ func TestVerifierDistinguishesExpiredAccessTokenFromInvalidCredentials(t *testin 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") + } +} diff --git a/services/gateway-service/internal/transport/http/game_handler_test.go b/services/gateway-service/internal/transport/http/game_handler_test.go index 8e1d0def..b3ffb673 100644 --- a/services/gateway-service/internal/transport/http/game_handler_test.go +++ b/services/gateway-service/internal/transport/http/game_handler_test.go @@ -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 { 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) { @@ -574,7 +579,10 @@ func TestZGameFixedCallbackRouteIsRawPublicResponse(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 { name string method string diff --git a/services/gateway-service/internal/transport/http/gameapi/game_handler.go b/services/gateway-service/internal/transport/http/gameapi/game_handler.go index badf0771..d93d513a 100644 --- a/services/gateway-service/internal/transport/http/gameapi/game_handler.go +++ b/services/gateway-service/internal/transport/http/gameapi/game_handler.go @@ -21,6 +21,7 @@ import ( const ( diceAccessTokenRenewBefore = 10 * time.Minute + gameProviderTokenTTL = 24 * time.Hour hyappAccessTokenHeader = "X-Hyapp-Access-Token" hyappTokenTypeHeader = "X-Hyapp-Token-Type" hyappExpiresInHeader = "X-Hyapp-Expires-In" @@ -148,6 +149,13 @@ func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request) httpkit.WriteRPCError(writer, request, err) 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{ Meta: gameMeta(request), UserId: userID, @@ -157,6 +165,7 @@ func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request) RoomId: body.RoomID, AccessToken: bearerAccessToken(request), CountryId: userResp.GetUser().GetCountryId(), + ProviderToken: providerToken, }) httpkit.Write(writer, request, gameLaunchDataFromProto(resp), err) } @@ -840,12 +849,12 @@ func (h *Handler) withVerifiedCallbackToken(request *http.Request, raw []byte) ( return request, false } for _, token := range jwtCandidates { - claims, err := h.jwtVerifier.Verify("Bearer " + token) + claims, err := h.jwtVerifier.VerifyGameCallbackToken("Bearer " + token) if err != nil { continue } - // 游戏回调是公网入口,callback 自带 app_code/header 都不能作为归因事实;只有 gateway 验过签的 HyApp access token - // 才能覆盖 request context,后续 gameMeta 会把 token 内 app_code/user_id 透传到 game-service RequestMeta。 + // 游戏回调是公网入口,callback 自带 app_code/header 都不能作为归因事实;只有 gateway 验过签的 + // HyApp access/provider token 才能覆盖 request context,后续 gameMeta 会透传签名内的 app_code/user_id。 return request.WithContext(auth.WithClaims(request.Context(), claims)), true } return request, false