From 056aa05c50563709864e74cd4de63cee655f4987 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Sun, 15 Jun 2025 17:09:36 +0300 Subject: [PATCH] feat(profile): implement yggdrasil profile signing --- cmd/server/main.go | 23 +++++- internal/api/profile_handler.go | 40 +++++++++ internal/core/profile_service.go | 119 +++++++++++++++++++++++++++ internal/database/user_repository.go | 33 ++++++++ internal/models/auth.go | 33 ++++++++ internal/models/user.go | 8 ++ 6 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 internal/api/profile_handler.go create mode 100644 internal/core/profile_service.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 10c471f..a42ad6e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,6 +3,7 @@ package main import ( "log" "net/http" + "os" "gitea.mrixs.me/minecraft-platform/backend/internal/api" "gitea.mrixs.me/minecraft-platform/backend/internal/core" @@ -23,10 +24,23 @@ func main() { // Сервисы userService := &core.UserService{Repo: userRepo} authService := &core.AuthService{UserRepo: userRepo} // Новый сервис - + // Инициализируем сервис профилей, читая путь к ключу и домен из переменных окружения + keyPath := os.Getenv("RSA_PRIVATE_KEY_PATH") + if keyPath == "" { + log.Fatal("RSA_PRIVATE_KEY_PATH environment variable is not set") + } + domain := os.Getenv("APP_DOMAIN") // Нам нужен домен для генерации URL + if domain == "" { + log.Fatal("APP_DOMAIN environment variable is not set") + } + profileService, err := core.NewProfileService(userRepo, keyPath, domain) + if err != nil { + log.Fatalf("Failed to create profile service: %v", err) + } // Хендлеры userHandler := &api.UserHandler{Service: userService} - authHandler := &api.AuthHandler{Service: authService} // Новый хендлер + authHandler := &api.AuthHandler{Service: authService} // Новый хендлер + profileHandler := &api.ProfileHandler{Service: profileService} // Новый хендлер // --- Настраиваем роутер --- r := chi.NewRouter() @@ -43,6 +57,11 @@ func main() { r.Post("/authenticate", authHandler.Authenticate) // Здесь будут другие эндпоинты: refresh, validate, signout, invalidate }) + // Группа маршрутов для Session Server API + r.Route("/sessionserver/session/minecraft", func(r chi.Router) { + r.Get("/profile/{uuid}", profileHandler.GetProfile) + // Здесь будет эндпоинт /join + }) // Маршрут для проверки, что сервер жив r.Get("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/profile_handler.go b/internal/api/profile_handler.go new file mode 100644 index 0000000..2ff1539 --- /dev/null +++ b/internal/api/profile_handler.go @@ -0,0 +1,40 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "gitea.mrixs.me/minecraft-platform/backend/internal/core" + "gitea.mrixs.me/minecraft-platform/backend/internal/database" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type ProfileHandler struct { + Service *core.ProfileService +} + +func (h *ProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) { + playerUUIDStr := chi.URLParam(r, "uuid") + playerUUID, err := uuid.Parse(playerUUIDStr) + if err != nil { + http.Error(w, "Invalid UUID format", http.StatusBadRequest) + return + } + + profile, err := h.Service.GetSignedProfile(r.Context(), playerUUID) + if err != nil { + if errors.Is(err, database.ErrUserNotFound) { + // Yggdrasil возвращает 204 No Content, если профиль не найден + w.WriteHeader(http.StatusNoContent) + return + } + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(profile) +} diff --git a/internal/core/profile_service.go b/internal/core/profile_service.go new file mode 100644 index 0000000..9b96367 --- /dev/null +++ b/internal/core/profile_service.go @@ -0,0 +1,119 @@ +package core + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "os" + "strings" + "time" + + "gitea.mrixs.me/minecraft-platform/backend/internal/database" + "gitea.mrixs.me/minecraft-platform/backend/internal/models" + "github.com/google/uuid" +) + +type ProfileService struct { + UserRepo *database.UserRepository + privateKey *rsa.PrivateKey + domain string +} + +// NewProfileService создает новый сервис и загружает приватный ключ. +func NewProfileService(repo *database.UserRepository, keyPath, domain string) (*ProfileService, error) { + keyData, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("failed to read private key file: %w", err) + } + + block, _ := pem.Decode(keyData) + if block == nil { + return nil, errors.New("failed to parse PEM block containing the key") + } + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + // Попробуем PKCS#8, если PKCS#1 не удался + key, errPkcs8 := x509.ParsePKCS8PrivateKey(block.Bytes) + if errPkcs8 != nil { + return nil, fmt.Errorf("failed to parse private key: %v / %v", err, errPkcs8) + } + var ok bool + privateKey, ok = key.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("key is not an RSA private key") + } + } + + return &ProfileService{ + UserRepo: repo, + privateKey: privateKey, + domain: domain, + }, nil +} + +// GetSignedProfile формирует и подписывает профиль игрока. +func (s *ProfileService) GetSignedProfile(ctx context.Context, playerUUID uuid.UUID) (*models.SessionProfileResponse, error) { + user, profile, err := s.UserRepo.GetProfileByUUID(ctx, playerUUID) + if err != nil { + return nil, err + } + + // 1. Формируем структуру со свойствами текстур + textures := models.Textures{} + if profile.SkinHash != "" { + textures.SKIN = &models.TextureInfo{URL: fmt.Sprintf("http://%s/files/textures/%s", s.domain, profile.SkinHash)} + } + if profile.CapeHash != "" { + textures.CAPE = &models.TextureInfo{URL: fmt.Sprintf("http://%s/files/textures/%s", s.domain, profile.CapeHash)} + } + + profileID := strings.ReplaceAll(user.UUID.String(), "-", "") + propValue := models.ProfilePropertyValue{ + Timestamp: time.Now().UnixMilli(), + ProfileID: profileID, + ProfileName: user.Username, + Textures: textures, + } + + // 2. Маршализация в JSON и кодирование в Base64 + valueJSON, err := json.Marshal(propValue) + if err != nil { + return nil, err + } + valueBase64 := base64.StdEncoding.EncodeToString(valueJSON) + + // 3. Подпись + hasher := sha1.New() + hasher.Write([]byte(valueBase64)) + hashed := hasher.Sum(nil) + + signature, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA1, hashed) + if err != nil { + return nil, err + } + signatureBase64 := base64.StdEncoding.EncodeToString(signature) + + // 4. Формирование итогового ответа + response := &models.SessionProfileResponse{ + ID: profileID, + Name: user.Username, + Properties: []models.ProfileProperty{ + { + Name: "textures", + Value: valueBase64, + Signature: signatureBase64, + }, + }, + } + + return response, nil +} diff --git a/internal/database/user_repository.go b/internal/database/user_repository.go index fc7b751..cbcf9b0 100644 --- a/internal/database/user_repository.go +++ b/internal/database/user_repository.go @@ -94,3 +94,36 @@ func (r *UserRepository) CreateAccessToken(ctx context.Context, userID int, acce _, err := r.DB.ExecContext(ctx, query, userID, accessToken, clientToken) return err } + +// GetProfileByUUID находит пользователя и его профиль по UUID. +func (r *UserRepository) GetProfileByUUID(ctx context.Context, userUUID uuid.UUID) (*models.User, *models.Profile, error) { + user := &models.User{UUID: userUUID} + profile := &models.Profile{} + var skinHash, capeHash sql.NullString // Используем NullString для полей, которые могут быть NULL + + query := ` + SELECT u.id, u.username, p.skin_hash, p.cape_hash + FROM users u + JOIN profiles p ON u.id = p.user_id + WHERE u.uuid = $1` + + err := r.DB.QueryRowContext(ctx, query, userUUID).Scan( + &user.ID, &user.Username, &skinHash, &capeHash, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, ErrUserNotFound + } + return nil, nil, err + } + + if skinHash.Valid { + profile.SkinHash = skinHash.String + } + if capeHash.Valid { + profile.CapeHash = capeHash.String + } + + return user, profile, nil +} diff --git a/internal/models/auth.go b/internal/models/auth.go index a2871b5..a5b3929 100644 --- a/internal/models/auth.go +++ b/internal/models/auth.go @@ -34,3 +34,36 @@ type UserProperty struct { ID string `json:"id"` // UUID пользователя Properties []any `json:"properties"` // Обычно пустой массив } + +// TextureInfo содержит URL для конкретной текстуры +type TextureInfo struct { + URL string `json:"url"` +} + +// Textures содержит ссылки на скин и плащ +type Textures struct { + SKIN *TextureInfo `json:"SKIN,omitempty"` + CAPE *TextureInfo `json:"CAPE,omitempty"` +} + +// ProfilePropertyValue - это закодированное в Base64 значение свойства textures +type ProfilePropertyValue struct { + Timestamp int64 `json:"timestamp"` + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + Textures Textures `json:"textures"` +} + +// ProfileProperty - это свойство 'textures' в ответе +type ProfileProperty struct { + Name string `json:"name"` // Всегда "textures" + Value string `json:"value"` // Base64(ProfilePropertyValue) + Signature string `json:"signature"` // Base64(RSA-SHA1(Value)) +} + +// SessionProfileResponse - это ответ от /sessionserver/session/minecraft/profile/{uuid} +type SessionProfileResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Properties []ProfileProperty `json:"properties"` +} diff --git a/internal/models/user.go b/internal/models/user.go index 8819e8b..dca0854 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -24,3 +24,11 @@ type RegisterRequest struct { Email string `json:"email"` Password string `json:"password"` } +type Profile struct { + ID int `json:"-"` + UserID int `json:"-"` + SkinHash string `json:"skin_hash,omitempty"` + CapeHash string `json:"cape_hash,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +}