Compare commits
1 Commits
4d42cfff2d
...
056aa05c50
| Author | SHA1 | Date | |
|---|---|---|---|
| 056aa05c50 |
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
"gitea.mrixs.me/minecraft-platform/backend/internal/api"
|
"gitea.mrixs.me/minecraft-platform/backend/internal/api"
|
||||||
"gitea.mrixs.me/minecraft-platform/backend/internal/core"
|
"gitea.mrixs.me/minecraft-platform/backend/internal/core"
|
||||||
@@ -23,10 +24,23 @@ func main() {
|
|||||||
// Сервисы
|
// Сервисы
|
||||||
userService := &core.UserService{Repo: userRepo}
|
userService := &core.UserService{Repo: userRepo}
|
||||||
authService := &core.AuthService{UserRepo: 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}
|
userHandler := &api.UserHandler{Service: userService}
|
||||||
authHandler := &api.AuthHandler{Service: authService} // Новый хендлер
|
authHandler := &api.AuthHandler{Service: authService} // Новый хендлер
|
||||||
|
profileHandler := &api.ProfileHandler{Service: profileService} // Новый хендлер
|
||||||
|
|
||||||
// --- Настраиваем роутер ---
|
// --- Настраиваем роутер ---
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -43,6 +57,11 @@ func main() {
|
|||||||
r.Post("/authenticate", authHandler.Authenticate)
|
r.Post("/authenticate", authHandler.Authenticate)
|
||||||
// Здесь будут другие эндпоинты: refresh, validate, signout, invalidate
|
// Здесь будут другие эндпоинты: 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) {
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
40
internal/api/profile_handler.go
Normal file
40
internal/api/profile_handler.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
119
internal/core/profile_service.go
Normal file
119
internal/core/profile_service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -94,3 +94,36 @@ func (r *UserRepository) CreateAccessToken(ctx context.Context, userID int, acce
|
|||||||
_, err := r.DB.ExecContext(ctx, query, userID, accessToken, clientToken)
|
_, err := r.DB.ExecContext(ctx, query, userID, accessToken, clientToken)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,3 +34,36 @@ type UserProperty struct {
|
|||||||
ID string `json:"id"` // UUID пользователя
|
ID string `json:"id"` // UUID пользователя
|
||||||
Properties []any `json:"properties"` // Обычно пустой массив
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,3 +24,11 @@ type RegisterRequest struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user