Files
backend/internal/service/yggdrasil_session_service.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

180 lines
4.6 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 service
import (
"context"
"fmt"
"net"
"strings"
"time"
apperrors "carrotskin/internal/errors"
"carrotskin/pkg/redis"
"go.uber.org/zap"
)
// SessionKeyPrefix Redis会话键前缀
const SessionKeyPrefix = "Join_"
// SessionTTL 会话超时时间 - 增加到15分钟
const SessionTTL = 15 * time.Minute
// SessionData 会话数据
type SessionData struct {
AccessToken string `json:"accessToken"`
UserName string `json:"userName"`
SelectedProfile string `json:"selectedProfile"`
IP string `json:"ip"`
}
// SessionService 会话管理服务接口
type SessionService interface {
// CreateSession 创建服务器会话
CreateSession(ctx context.Context, serverID, accessToken, username, profileUUID, ip string) error
// GetSession 获取会话数据
GetSession(ctx context.Context, serverID string) (*SessionData, error)
// ValidateSession 验证会话用户名和IP
ValidateSession(ctx context.Context, serverID, username, ip string) error
}
// yggdrasilSessionService 会话服务实现
type yggdrasilSessionService struct {
redis *redis.Client
logger *zap.Logger
}
// NewSessionService 创建会话服务实例
func NewSessionService(redisClient *redis.Client, logger *zap.Logger) SessionService {
return &yggdrasilSessionService{
redis: redisClient,
logger: logger,
}
}
// ValidateServerID 验证服务器ID格式
func ValidateServerID(serverID string) error {
if serverID == "" {
return apperrors.ErrInvalidServerID
}
if len(serverID) > 100 || strings.ContainsAny(serverID, "<>\"'&") {
return apperrors.ErrInvalidServerID
}
return nil
}
// ValidateIP 验证IP地址格式
func ValidateIP(ip string) error {
if ip == "" {
return nil // IP是可选的
}
if net.ParseIP(ip) == nil {
return apperrors.ErrIPMismatch
}
return nil
}
// CreateSession 创建服务器会话
func (s *yggdrasilSessionService) CreateSession(ctx context.Context, serverID, accessToken, username, profileUUID, ip string) error {
// 输入验证
if err := ValidateServerID(serverID); err != nil {
return err
}
if accessToken == "" {
return apperrors.ErrInvalidAccessToken
}
if username == "" {
return apperrors.ErrUsernameRequired
}
if profileUUID == "" {
return apperrors.ErrProfileMismatch
}
if err := ValidateIP(ip); err != nil {
return err
}
// 创建会话数据
data := SessionData{
AccessToken: accessToken,
UserName: username,
SelectedProfile: profileUUID,
IP: ip,
}
// 序列化会话数据
marshaledData, err := json.Marshal(data)
if err != nil {
s.logger.Error("序列化会话数据失败",
zap.Error(err),
zap.String("serverID", serverID),
)
return fmt.Errorf("序列化会话数据失败: %w", err)
}
// 存储会话数据到Redis
sessionKey := SessionKeyPrefix + serverID
if err = s.redis.Set(ctx, sessionKey, marshaledData, SessionTTL); err != nil {
s.logger.Error("保存会话数据失败",
zap.Error(err),
zap.String("serverID", serverID),
)
return fmt.Errorf("保存会话数据失败: %w", err)
}
s.logger.Info("会话创建成功",
zap.String("username", username),
zap.String("serverID", serverID),
)
return nil
}
// GetSession 获取会话数据(内部调用前已对 serverID 校验过,跳过重复校验)
func (s *yggdrasilSessionService) GetSession(ctx context.Context, serverID string) (*SessionData, error) {
// 从Redis获取会话数据
sessionKey := SessionKeyPrefix + serverID
data, err := s.redis.GetBytes(ctx, sessionKey)
if err != nil {
s.logger.Error("获取会话数据失败",
zap.Error(err),
zap.String("serverID", serverID),
)
return nil, fmt.Errorf("获取会话数据失败: %w", err)
}
// 反序列化会话数据
var sessionData SessionData
if err = json.Unmarshal(data, &sessionData); err != nil {
s.logger.Error("解析会话数据失败",
zap.Error(err),
zap.String("serverID", serverID),
)
return nil, fmt.Errorf("解析会话数据失败: %w", err)
}
return &sessionData, nil
}
// ValidateSession 验证会话用户名和IP
func (s *yggdrasilSessionService) ValidateSession(ctx context.Context, serverID, username, ip string) error {
if serverID == "" || username == "" {
return apperrors.ErrSessionMismatch
}
// ValidateSession 由 HasJoinedServer 调用serverID 已经过校验,此处无需重复 ValidateServerID
sessionData, err := s.GetSession(ctx, serverID)
if err != nil {
return apperrors.ErrSessionNotFound
}
// 验证用户名
if sessionData.UserName != username {
return apperrors.ErrUsernameMismatch
}
// 验证IP如果提供
if ip != "" && sessionData.IP != ip {
return apperrors.ErrIPMismatch
}
return nil
}