fix(game): issue compact AMG provider token
This commit is contained in:
parent
290e9ceb93
commit
aa0ddbc3b0
@ -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" +
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user