游戏客户端登录最怕两件事:把长期凭证塞进客户端,以及让游戏服务器重复造一套身份系统。这个方案的核心是“双令牌”:客户端先用 Amazon Cognito User Pool 通过 SRP 流程完成认证,拿到 Cognito JWT;Nakama 运行时再验证这个 JWT,把 Cognito 用户身份桥接成 Nakama session。这样客户端不需要 client secret,服务器侧仍然能掌握会话签发边界。
为什么是 SRP 加无 client secret
在移动端、PC 客户端或 Web 游戏里,所谓“客户端密钥”基本无法保密。只要应用包能被下载,secret 就有被提取的风险。因此 Cognito User Pool App Client 应配置为不生成 client secret,并启用适合客户端直接认证的 SRP 流程。
SRP 的价值在于客户端可以完成用户名密码认证流程,但不把密码明文交给你的游戏后端。认证成功后,Cognito 返回 ID token、access token、refresh token。Nakama 不需要接管密码校验,它只需要在运行时 hook 中验证 JWT 的签名、issuer、audience/client_id、过期时间等声明。
这里的边界很清楚:
- Cognito 负责玩家的主身份认证。
- Nakama 负责游戏会话、匹配、存档、排行榜等游戏侧能力。
- Go runtime hook 负责把两者粘起来:可信地读取 Cognito JWT,并映射到 Nakama 用户 ID。
配置 Cognito:客户端不要 secret
可以这样实践:用 AWS CLI 创建一个不带 client secret 的 User Pool App Client,并允许 SRP 认证流。下面命令假设你已经有一个 User Pool,并且本机配置好了 AWS CLI 凭证。
把 USER_POOL_ID、AWS_REGION 换成你的值:
export AWS_REGION=us-east-1
export USER_POOL_ID=us-east-1_ExamplePool
aws cognito-idp create-user-pool-client \
--region "$AWS_REGION" \
--user-pool-id "$USER_POOL_ID" \
--client-name nakama-game-client \
--no-generate-secret \
--explicit-auth-flows ALLOW_USER_SRP_AUTH ALLOW_REFRESH_TOKEN_AUTH
创建后记下返回里的 ClientId。这个值会进入游戏客户端,也会被 Nakama runtime 用来校验 token 受众。不要把后台服务用的管理权限凭证放进游戏客户端。
如果要快速查看 User Pool 的 issuer,格式通常是:
export COGNITO_ISSUER="https://cognito-idp.${AWS_REGION}.amazonaws.com/${USER_POOL_ID}"
echo "$COGNITO_ISSUER"
在 Nakama runtime 里验证 JWT
来源方案强调的是 Go runtime hook:Nakama 收到客户端传来的 Cognito JWT 后,不是盲信 token 内容,而是在服务端验证它,然后把 Cognito 的 sub 这样的稳定用户标识映射到 Nakama session。
下面是一个可改造的最小 Go 示例。它展示了验证 Cognito JWT 的关键路径:读取 Cognito JWKS、按 kid 找公钥、校验签名和声明。实际接入 Nakama 时,你可以把 validateCognitoJWT 放到 runtime hook 内,在认证入口里调用,再用验证出的 Cognito sub 作为外部身份键。
运行前需要修改
region、userPoolID、clientID,并传入一个真实的 Cognito ID token。
package main
import (
"context"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
)
type jwks struct {
Keys []jsonWebKey `json:"keys"`
}
type jsonWebKey struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
Alg string `json:"alg"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
}
type cognitoClaims struct {
TokenUse string `json:"token_use"`
ClientID string `json:"aud"`
Username string `json:"cognito:username"`
jwt.RegisteredClaims
}
func main() {
region := "us-east-1"
userPoolID := "us-east-1_ExamplePool"
clientID := "replace-with-app-client-id"
tokenString := os.Getenv("COGNITO_ID_TOKEN")
if tokenString == "" {
log.Fatal("set COGNITO_ID_TOKEN to a Cognito ID token")
}
claims, err := validateCognitoJWT(context.Background(), region, userPoolID, clientID, tokenString)
if err != nil {
log.Fatal(err)
}
fmt.Printf("valid player: cognito_sub=%s username=%s\n", claims.Subject, claims.Username)
}
func validateCognitoJWT(ctx context.Context, region, userPoolID, clientID, tokenString string) (*cognitoClaims, error) {
issuer := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", region, userPoolID)
jwksURL := issuer + "/.well-known/jwks.json"
keySet, err := fetchJWKS(ctx, jwksURL)
if err != nil {
return nil, err
}
claims := &cognitoClaims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if token.Method.Alg() != jwt.SigningMethodRS256.Alg() {
return nil, fmt.Errorf("unexpected signing method: %s", token.Method.Alg())
}
kid, ok := token.Header["kid"].(string)
if !ok || kid == "" {
return nil, errors.New("missing kid header")
}
return keySet.lookupRSA(kid)
}, jwt.WithIssuer(issuer), jwt.WithAudience(clientID), jwt.WithExpirationRequired())
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
if claims.TokenUse != "id" {
return nil, fmt.Errorf("expected id token, got %q", claims.TokenUse)
}
return claims, nil
}
func fetchJWKS(ctx context.Context, url string) (*jwks, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 5 * time.Second}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("jwks request failed: %s", res.Status)
}
var keys jwks
if err := json.NewDecoder(res.Body).Decode(&keys); err != nil {
return nil, err
}
return &keys, nil
}
func (j *jwks) lookupRSA(kid string) (*rsa.PublicKey, error) {
for _, key := range j.Keys {
if key.Kid != kid {
continue
}
parsed, err := jwt.ParseRSAPublicKeyFromPEM([]byte(""))
_ = parsed
return nil, fmt.Errorf("convert JWK n/e to rsa.PublicKey here, or use github.com/lestrrat-go/jwx for production")
}
return nil, fmt.Errorf("kid not found: %s", kid)
}
上面代码故意把 JWK 到 RSA 公钥的转换留成显式边界,因为生产实现更建议直接使用成熟 JWK 库,避免自己处理 base64url 大整数细节。可以这样改成更完整的生产方向:用 github.com/lestrrat-go/jwx/v2/jwk 拉取并缓存 JWKS,再把 kid 对应的 key 转为 rsa.PublicKey。
一个更接近 Nakama runtime 的伪代码结构如下,注意这是接入形态示意,不是完整 Nakama API 签名:
// Assumption: this function is called from a Nakama Go runtime auth hook.
func authenticateWithCognito(ctx context.Context, token string) (string, error) {
claims, err := validateCognitoJWT(ctx, "us-east-1", "us-east-1_ExamplePool", "app-client-id", token)
if err != nil {
return "", err
}
// Use Cognito sub as the stable external identity.
externalID := "cognito:" + claims.Subject
// In a real Nakama hook, look up or create the Nakama user associated with externalID,
// then let Nakama issue its own session token.
return externalID, nil
}
双令牌不是重复登录,而是分层授权
这个架构里会同时出现 Cognito token 和 Nakama session token。它们不应该混用:
- Cognito JWT 证明“这个玩家通过了 AWS 身份认证”。
- Nakama session 证明“这个连接被游戏服务器接受,并可访问游戏服务”。
客户端启动时可以先向 Cognito 登录,拿到 ID token,再把它交给 Nakama 自定义认证入口。Nakama 验证成功后签发自己的 session。后续调用 Nakama 游戏 API 时,客户端使用 Nakama session,而不是每个请求都重复提交 Cognito JWT。
这样做的好处是职责干净:Cognito 的 refresh token 生命周期、MFA、账号恢复、密码策略等由 AWS 处理;Nakama 的会话过期、封禁、游戏内权限、设备限制可以独立控制。
落地时要盯住这些边界
生产环境里,JWT 验证不是“能 decode 就行”。至少检查这些项:
iss必须等于你的 User Pool issuer。aud或适用的 client 标识必须匹配你的 App Client ID。exp、nbf、iat要按合理时钟偏移校验。token_use要符合你选择的 token 类型,例如 ID token。- JWKS 要缓存,但要能在 Cognito key rotation 后刷新。
- Nakama 用户映射要使用 Cognito
sub这类稳定 ID,不要用可变邮箱或昵称。
还有一个常见取舍:如果你需要游戏内临时封禁,不能只依赖 Cognito 账号状态。Nakama hook 验证 JWT 后,还应该查一层游戏侧账号状态;被封禁的玩家即使 Cognito 登录成功,也不应该拿到 Nakama session。
采用这套方案的检查清单很短:客户端 App Client 无 secret;SRP flow 已启用;Nakama runtime 校验 JWT 签名和声明;Cognito sub 到 Nakama 用户的映射稳定;Nakama session 成为游戏 API 的唯一会话凭证。做到这些,身份系统就不会和游戏逻辑缠成一团。