feat(yggdrasil): implement standard error responses and UUID format improvements
All checks were successful
Build / build (push) Successful in 2m17s
Build / build-docker (push) Successful in 57s

- Add YggdrasilErrorResponse struct and standard error codes for protocol compliance
- Change UUID storage from varchar(36) to varchar(32) for unsigned format
- Add utility functions: GenerateUUID, FormatUUIDToNoDash, RandomHex
- Support unsigned query parameter in GetProfileByUUID endpoint
- Improve refresh token response with available profiles list
- Fix key pair retrieval to use correct database column (rsa_private_key)
- Update UUID validator to accept both 32-char and 36-char formats
- Add SignStringWithProfileRSA method for profile-specific signing
- Fix profile assignment validation in refresh token flow
This commit is contained in:
2026-02-23 13:26:53 +08:00
parent 3e8b7d150d
commit 29f0bad2bc
16 changed files with 719 additions and 89 deletions

View File

@@ -3,13 +3,13 @@ package service
import (
"carrotskin/pkg/config"
"carrotskin/pkg/redis"
"carrotskin/pkg/utils"
"context"
"errors"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/wenlng/go-captcha-assets/resources/imagesv2"
"github.com/wenlng/go-captcha-assets/resources/tiles"
"github.com/wenlng/go-captcha/v2/slide"
@@ -87,7 +87,7 @@ func NewCaptchaService(redisClient *redis.Client, logger *zap.Logger) CaptchaSer
// Generate 生成验证码
func (s *captchaService) Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error) {
// 生成uuid作为验证码进程唯一标识
captchaID = uuid.NewString()
captchaID = utils.GenerateUUID()
if captchaID == "" {
err = errors.New("生成验证码唯一标识失败")
return

View File

@@ -79,6 +79,7 @@ type TextureService interface {
type TokenService interface {
// 令牌管理
Create(ctx context.Context, userID int64, uuid, clientToken string) (*model.Profile, []*model.Profile, string, string, error)
CreateWithProfile(ctx context.Context, userID int64, profileUUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error)
Validate(ctx context.Context, accessToken, clientToken string) bool
Refresh(ctx context.Context, accessToken, clientToken, selectedProfileID string) (string, string, error)
Invalidate(ctx context.Context, accessToken string)
@@ -116,6 +117,7 @@ type YggdrasilService interface {
// 序列化
SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{}
SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{}
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
// 证书

View File

@@ -4,6 +4,7 @@ import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/database"
"carrotskin/pkg/utils"
"context"
"crypto/rand"
"crypto/rsa"
@@ -12,7 +13,6 @@ import (
"errors"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
@@ -64,7 +64,7 @@ func (s *profileService) Create(ctx context.Context, userID int64, name string)
}
// 生成UUID和RSA密钥
profileUUID := uuid.New().String()
profileUUID := utils.GenerateUUID()
privateKey, err := generateRSAPrivateKeyInternal()
if err != nil {
return nil, fmt.Errorf("生成RSA密钥失败: %w", err)

View File

@@ -274,3 +274,26 @@ func FormatPublicKey(publicKeyPEM string) string {
}
return strings.Join(keyLines, "")
}
// SignStringWithProfileRSA 使用Profile的RSA私钥签名字符串
func (s *SignatureService) SignStringWithProfileRSA(data string, privateKeyPEM string) (string, error) {
// 解析PEM格式的私钥
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return "", fmt.Errorf("解析PEM私钥失败")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("解析RSA私钥失败: %w", err)
}
// 签名
hashed := sha1.Sum([]byte(data))
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA1, hashed[:])
if err != nil {
return "", fmt.Errorf("签名失败: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}

View File

@@ -4,12 +4,12 @@ import (
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/auth"
"carrotskin/pkg/utils"
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
)
@@ -63,9 +63,13 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
}
}
// 生成ClientToken
// 生成ClientToken使用32字符十六进制字符串
if clientToken == "" {
clientToken = uuid.New().String()
var err error
clientToken, err = utils.RandomHex(16) // 16字节 = 32字符十六进制
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成ClientToken失败: %w", err)
}
}
// 获取或创建Client
@@ -73,7 +77,10 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
existingClient, err := s.clientRepo.FindByClientToken(ctx, clientToken)
if err != nil {
// Client不存在创建新的
clientUUID := uuid.New().String()
clientUUID, err := utils.RandomHex(16) // 16字节 = 32字符十六进制
if err != nil {
return selectedProfileID, availableProfiles, "", "", fmt.Errorf("生成ClientUUID失败: %w", err)
}
client = &model.Client{
UUID: clientUUID,
ClientToken: clientToken,
@@ -173,6 +180,11 @@ func (s *tokenServiceRedis) Create(ctx context.Context, userID int64, UUID strin
return selectedProfileID, availableProfiles, accessToken, clientToken, nil
}
// CreateWithProfile 创建Token并绑定指定Profile使用JWT + Redis存储
func (s *tokenServiceRedis) CreateWithProfile(ctx context.Context, userID int64, profileUUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) {
return s.Create(ctx, userID, profileUUID, clientToken)
}
// Validate 验证Token使用JWT验证 + Redis存储验证
func (s *tokenServiceRedis) Validate(ctx context.Context, accessToken, clientToken string) bool {
// 设置超时上下文

View File

@@ -14,6 +14,8 @@ import (
type SerializationService interface {
// SerializeProfile 序列化档案为Yggdrasil格式
SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{}
// SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式支持unsigned参数
SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{}
// SerializeUser 序列化用户为Yggdrasil格式
SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{}
}
@@ -45,8 +47,13 @@ func NewSerializationService(
}
}
// SerializeProfile 序列化档案为Yggdrasil格式
// SerializeProfile 序列化档案为Yggdrasil格式(默认返回签名)
func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, profile model.Profile) map[string]interface{} {
return s.SerializeProfileWithUnsigned(ctx, profile, false)
}
// SerializeProfileWithUnsigned 序列化档案为Yggdrasil格式支持unsigned参数
func (s *yggdrasilSerializationService) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} {
// 创建基本材质数据
texturesMap := make(map[string]interface{})
textures := map[string]interface{}{
@@ -99,26 +106,36 @@ func (s *yggdrasilSerializationService) SerializeProfile(ctx context.Context, pr
}
textureData := base64.StdEncoding.EncodeToString(bytes)
signature, err := s.signatureService.SignStringWithSHA1withRSA(textureData)
if err != nil {
s.logger.Error("签名textures失败",
zap.Error(err),
zap.String("profileUUID", profile.UUID),
)
return nil
// 只有在 unsigned=false 时才签名
var signature string
if !unsigned {
signature, err = s.signatureService.SignStringWithSHA1withRSA(textureData)
if err != nil {
s.logger.Error("签名textures失败",
zap.Error(err),
zap.String("profileUUID", profile.UUID),
)
return nil
}
}
// 构建属性
property := Property{
Name: "textures",
Value: textureData,
}
// 只有在 unsigned=false 时才添加签名
if !unsigned {
property.Signature = signature
}
// 构建结果
data := map[string]interface{}{
"id": profile.UUID,
"name": profile.Name,
"properties": []Property{
{
Name: "textures",
Value: textureData,
Signature: signature,
},
},
"id": profile.UUID,
"name": profile.Name,
"properties": []Property{property},
}
return data
}

View File

@@ -85,8 +85,8 @@ func (s *yggdrasilServiceComposite) JoinServer(ctx context.Context, serverID, ac
return fmt.Errorf("验证Token失败: %w", err)
}
// 格式化UUID并验证与Token关联的配置文件
formattedProfile := utils.FormatUUID(selectedProfile)
// 确保UUID是32位无符号格式用于向后兼容
formattedProfile := utils.FormatUUIDToNoDash(selectedProfile)
if uuid != formattedProfile {
return errors.New("selectedProfile与Token不匹配")
}
@@ -115,6 +115,11 @@ func (s *yggdrasilServiceComposite) SerializeProfile(ctx context.Context, profil
return s.serializationService.SerializeProfile(ctx, profile)
}
// SerializeProfileWithUnsigned 序列化档案支持unsigned参数
func (s *yggdrasilServiceComposite) SerializeProfileWithUnsigned(ctx context.Context, profile model.Profile, unsigned bool) map[string]interface{} {
return s.serializationService.SerializeProfileWithUnsigned(ctx, profile, unsigned)
}
// SerializeUser 序列化用户
func (s *yggdrasilServiceComposite) SerializeUser(ctx context.Context, user *model.User, uuid string) map[string]interface{} {
return s.serializationService.SerializeUser(ctx, user, uuid)

View File

@@ -57,16 +57,38 @@ func (v *Validator) ValidateEmail(email string) error {
return nil
}
// ValidateUUID 验证UUID格式简单验证
// ValidateUUID 验证UUID格式支持32位无符号和36位带连字符格式
func (v *Validator) ValidateUUID(uuid string) error {
if uuid == "" {
return errors.New("UUID不能为空")
}
// UUID格式xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (32个十六进制字符 + 4个连字符)
if len(uuid) < 32 || len(uuid) > 36 {
return errors.New("UUID格式无效")
// 验证32位无符号UUID格式纯十六进制字符串
if len(uuid) == 32 {
// 检查是否为有效的十六进制字符串
for _, c := range uuid {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return errors.New("UUID格式无效包含非十六进制字符")
}
}
return nil
}
return nil
// 验证36位标准UUID格式带连字符
if len(uuid) == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-' {
// 检查除连字符外的字符是否为有效的十六进制
for i, c := range uuid {
if i == 8 || i == 13 || i == 18 || i == 23 {
continue // 跳过连字符位置
}
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return errors.New("UUID格式无效包含非十六进制字符")
}
}
return nil
}
return errors.New("UUID格式无效长度应为32位或36位")
}
// ValidateAccessToken 验证访问令牌