Files
backend/pkg/auth/yggdrasil_jwt_test.go
lan 1dc9c36a3a
Some checks failed
Build / build (push) Failing after 21m45s
Build / build-docker (push) Has been skipped
refactor(yggdrasil): 整理与性能优化,修复若干 Bug
Bug 修复
- textures metadata:SKIN 仅在 IsSlim 时输出 {"model":"slim"};CAPE 不带 metadata。
  原实现误用文件字节数 skin.Size 作为 metadata,违反 Yggdrasil 协议。
- KeyPair 持久化不全:Profile 新增 rsa_public_key/public_key_signature/
  public_key_signature_v2/key_expires_at/key_refresh_at 列;GetKeyPair/UpdateKeyPair
  读写全部字段。AutoMigrate 自动加列。原实现每次请求都重新生成 RSA-4096。
- GetPlayerCertificates:无效 token 返回 401(先前误用 403 且未判 err)。
- HasJoinedServer:失败返回 204 无 body,符合 Yggdrasil 协议(原用 APIResponse
  + body 违反 204 规范)。

代码质量
- Authenticate 删除冗余 body 读取与回放。
- 证书服务引入具名结构 PlayerCertificate/PlayerKeyPair,替代 map[string]interface{}。
- CreateSession 用户名缺失改用 ErrUsernameRequired(新增)。

性能优化(签名一致性的回归测试已覆盖)
- SignatureService 缓存已解析的根私钥(sync.RWMutex 双重检查),
  快路径仅一次 RLock + RSA 签名,免去每次签名 PEM 解析与 Redis 往返。
- GetOrCreateYggdrasilKeyPair 用 MGet 单次往返取三字段,缓存命中后零 Redis。
- NewKeyPair 消息构造用 strconv.AppendInt 避免 string+拼接分配;V2 复用 DER 字节。
- 序列化服务:texturesMap 预分配 cap(2);slim 分支直接构造完整 map。
- 会话服务 GetSession 移除重复的 ValidateServerID 校验。

死代码清理
- 删除 yggdrasil_validator.go(未被调用的 Validator)。
- 删除 signature_service.FormatPublicKey/SignStringWithProfileRSA。
- 删除 pkg/auth 中未被使用的 YggdrasilJWTManager 与 GenerateKeyPair/
  EncodePrivateKeyToPEM/RedisClient/YggdrasilPrivateKeyRedisKey。
- 删除 yggdrasil_handler.go 中 25 个未使用常量、passwordRegex、
  APIResponse/standardResponse。
- 删除 errors.go 中未被使用的 ErrYggForbiddenOperation/ErrYggIllegalArgument/
  ErrInvalidSignature/ErrInvalidTextureType/ErrCertificateGenerate/ErrInvalidPassword。

测试
- 新增 signature_service_test.go(基于 miniredis):8 个测试 + 基准。
  核心:SignString_MatchesReference 与重构前参考实现逐字节比对签名输出一致。
  -race 检测通过;基准约 7.1ms/op(缓存命中路径)。

附带(与本次任务前已存在的未提交改动)
- handler/captcha_handler:将响应字段 msg 改为 message,与前端约定对齐。
2026-07-09 17:18:26 +08:00

342 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"crypto/rand"
"crypto/rsa"
"testing"
"time"
)
// generateTestKeyPair 生成测试用的 RSA 密钥对
func generateTestKeyPair(t *testing.T) *rsa.PrivateKey {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("生成密钥对失败: %v", err)
}
return privateKey
}
func TestNewYggdrasilJWTService(t *testing.T) {
privateKey := generateTestKeyPair(t)
tests := []struct {
name string
issuer string
expected string
}{
{
name: "自定义issuer",
issuer: "test-issuer",
expected: "test-issuer",
},
{
name: "空issuer使用默认值",
issuer: "",
expected: "carrotskin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service := NewYggdrasilJWTService(privateKey, tt.issuer)
if service == nil {
t.Fatal("服务创建失败")
}
if service.issuer != tt.expected {
t.Errorf("期望issuer为 %s实际为 %s", tt.expected, service.issuer)
}
if service.privateKey == nil {
t.Error("私钥不应为nil")
}
if service.publicKey == nil {
t.Error("公钥不应为nil")
}
})
}
}
func TestYggdrasilJWTService_GenerateAccessToken(t *testing.T) {
privateKey := generateTestKeyPair(t)
service := NewYggdrasilJWTService(privateKey, "test-issuer")
userID := int64(123)
clientUUID := "test-client-uuid"
version := 1
profileID := "test-profile-uuid"
expiresAt := time.Now().Add(24 * time.Hour)
staleAt := time.Now().Add(30 * 24 * time.Hour)
token, err := service.GenerateAccessToken(userID, clientUUID, version, profileID, expiresAt, staleAt)
if err != nil {
t.Fatalf("生成Token失败: %v", err)
}
if token == "" {
t.Error("Token不应为空")
}
// 验证Token可以解析
claims, err := service.ParseAccessToken(token, StalePolicyAllow)
if err != nil {
t.Fatalf("解析Token失败: %v", err)
}
if claims.UserID != userID {
t.Errorf("期望UserID为 %d实际为 %d", userID, claims.UserID)
}
if claims.Subject != clientUUID {
t.Errorf("期望Subject为 %s实际为 %s", clientUUID, claims.Subject)
}
if claims.Version != version {
t.Errorf("期望Version为 %d实际为 %d", version, claims.Version)
}
if claims.ProfileID != profileID {
t.Errorf("期望ProfileID为 %s实际为 %s", profileID, claims.ProfileID)
}
if claims.Issuer != "test-issuer" {
t.Errorf("期望Issuer为 test-issuer实际为 %s", claims.Issuer)
}
}
func TestYggdrasilJWTService_ParseAccessToken(t *testing.T) {
privateKey := generateTestKeyPair(t)
service := NewYggdrasilJWTService(privateKey, "test-issuer")
userID := int64(123)
clientUUID := "test-client-uuid"
version := 1
profileID := "test-profile-uuid"
expiresAt := time.Now().Add(24 * time.Hour)
staleAt := time.Now().Add(30 * 24 * time.Hour)
// 生成Token
token, err := service.GenerateAccessToken(userID, clientUUID, version, profileID, expiresAt, staleAt)
if err != nil {
t.Fatalf("生成Token失败: %v", err)
}
tests := []struct {
name string
token string
policy StaleTokenPolicy
expectError bool
}{
{
name: "有效Token允许过期",
token: token,
policy: StalePolicyAllow,
expectError: false,
},
{
name: "有效Token拒绝过期",
token: token,
policy: StalePolicyDeny,
expectError: false,
},
{
name: "无效Token",
token: "invalid-token",
policy: StalePolicyAllow,
expectError: true,
},
{
name: "空Token",
token: "",
policy: StalePolicyAllow,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
claims, err := service.ParseAccessToken(tt.token, tt.policy)
if tt.expectError {
if err == nil {
t.Error("期望出现错误,但没有错误")
}
if claims != nil {
t.Error("期望claims为nil")
}
} else {
if err != nil {
t.Errorf("不期望出现错误,但出现: %v", err)
}
if claims == nil {
t.Error("claims不应为nil")
}
}
})
}
}
func TestYggdrasilJWTService_ParseAccessToken_Expired(t *testing.T) {
privateKey := generateTestKeyPair(t)
service := NewYggdrasilJWTService(privateKey, "test-issuer")
// 生成已过期的Token
expiresAt := time.Now().Add(-1 * time.Hour) // 1小时前过期
staleAt := time.Now().Add(30 * 24 * time.Hour)
token, err := service.GenerateAccessToken(123, "client-uuid", 1, "profile-uuid", expiresAt, staleAt)
if err != nil {
t.Fatalf("生成Token失败: %v", err)
}
// 使用StalePolicyDeny应该拒绝过期TokenJWT库会自动检查过期时间
_, err = service.ParseAccessToken(token, StalePolicyDeny)
if err == nil {
t.Error("期望拒绝过期Token但没有错误")
}
// 注意JWT库在解析时会自动验证过期时间即使使用StalePolicyAllow
// 所以过期Token无法解析这是JWT库的行为
// 如果需要支持过期Token需要在解析时禁用过期验证但这不是标准做法
_, err = service.ParseAccessToken(token, StalePolicyAllow)
if err == nil {
t.Log("注意JWT库会自动拒绝过期Token即使使用StalePolicyAllow")
}
}
func TestYggdrasilJWTService_ParseAccessToken_WrongKey(t *testing.T) {
privateKey1 := generateTestKeyPair(t)
privateKey2 := generateTestKeyPair(t)
service1 := NewYggdrasilJWTService(privateKey1, "test-issuer")
service2 := NewYggdrasilJWTService(privateKey2, "test-issuer")
// 使用service1生成Token
token, err := service1.GenerateAccessToken(123, "client-uuid", 1, "profile-uuid",
time.Now().Add(24*time.Hour), time.Now().Add(30*24*time.Hour))
if err != nil {
t.Fatalf("生成Token失败: %v", err)
}
// 使用service2不同密钥解析Token应该失败
_, err = service2.ParseAccessToken(token, StalePolicyAllow)
if err == nil {
t.Error("期望使用错误密钥解析Token失败但没有错误")
}
}
func TestYggdrasilJWTService_GetPublicKey(t *testing.T) {
privateKey := generateTestKeyPair(t)
service := NewYggdrasilJWTService(privateKey, "test-issuer")
publicKey := service.GetPublicKey()
if publicKey == nil {
t.Error("公钥不应为nil")
}
// 验证公钥与私钥匹配
if publicKey != nil && privateKey != nil {
if publicKey.N.Cmp(privateKey.PublicKey.N) != 0 {
t.Error("公钥与私钥不匹配")
}
}
}
func TestYggdrasilTokenClaims_EmptyProfileID(t *testing.T) {
privateKey := generateTestKeyPair(t)
service := NewYggdrasilJWTService(privateKey, "test-issuer")
// 生成没有ProfileID的Token
token, err := service.GenerateAccessToken(123, "client-uuid", 1, "",
time.Now().Add(24*time.Hour), time.Now().Add(30*24*time.Hour))
if err != nil {
t.Fatalf("生成Token失败: %v", err)
}
// 解析Token
claims, err := service.ParseAccessToken(token, StalePolicyAllow)
if err != nil {
t.Fatalf("解析Token失败: %v", err)
}
if claims.ProfileID != "" {
t.Errorf("期望ProfileID为空实际为 %s", claims.ProfileID)
}
}
func TestYggdrasilJWTService_VersionMismatch(t *testing.T) {
privateKey := generateTestKeyPair(t)
service := NewYggdrasilJWTService(privateKey, "test-issuer")
// 生成Version=1的Token
token1, err := service.GenerateAccessToken(123, "client-uuid", 1, "profile-uuid",
time.Now().Add(24*time.Hour), time.Now().Add(30*24*time.Hour))
if err != nil {
t.Fatalf("生成Token失败: %v", err)
}
// 生成Version=2的Token
token2, err := service.GenerateAccessToken(123, "client-uuid", 2, "profile-uuid",
time.Now().Add(24*time.Hour), time.Now().Add(30*24*time.Hour))
if err != nil {
t.Fatalf("生成Token失败: %v", err)
}
// 解析两个Token
claims1, err := service.ParseAccessToken(token1, StalePolicyAllow)
if err != nil {
t.Fatalf("解析Token1失败: %v", err)
}
claims2, err := service.ParseAccessToken(token2, StalePolicyAllow)
if err != nil {
t.Fatalf("解析Token2失败: %v", err)
}
// 验证Version不同
if claims1.Version == claims2.Version {
t.Error("两个Token的Version应该不同")
}
if claims1.Version != 1 {
t.Errorf("期望Token1的Version为1实际为 %d", claims1.Version)
}
if claims2.Version != 2 {
t.Errorf("期望Token2的Version为2实际为 %d", claims2.Version)
}
}
// 基准测试
func BenchmarkGenerateAccessToken(b *testing.B) {
privateKey := generateTestKeyPair(&testing.T{})
service := NewYggdrasilJWTService(privateKey, "test-issuer")
userID := int64(123)
clientUUID := "test-client-uuid"
version := 1
profileID := "test-profile-uuid"
expiresAt := time.Now().Add(24 * time.Hour)
staleAt := time.Now().Add(30 * 24 * time.Hour)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := service.GenerateAccessToken(userID, clientUUID, version, profileID, expiresAt, staleAt)
if err != nil {
b.Fatalf("生成Token失败: %v", err)
}
}
}
func BenchmarkParseAccessToken(b *testing.B) {
privateKey := generateTestKeyPair(&testing.T{})
service := NewYggdrasilJWTService(privateKey, "test-issuer")
token, err := service.GenerateAccessToken(123, "client-uuid", 1, "profile-uuid",
time.Now().Add(24*time.Hour), time.Now().Add(30*24*time.Hour))
if err != nil {
b.Fatalf("生成Token失败: %v", err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := service.ParseAccessToken(token, StalePolicyAllow)
if err != nil {
b.Fatalf("解析Token失败: %v", err)
}
}
}