diff --git a/cmd/server/main.go b/cmd/server/main.go index 9991428..10c471f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,4 +1,3 @@ -// File: backend/cmd/server/main.go package main import ( @@ -10,7 +9,7 @@ import ( "gitea.mrixs.me/minecraft-platform/backend/internal/database" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" + "github.comcom/go-chi/chi/v5/middleware" ) func main() { @@ -18,20 +17,31 @@ func main() { db := database.Connect() defer db.Close() - // Собираем наши зависимости (Dependency Injection) + // --- Собираем наши зависимости (Dependency Injection) --- userRepo := &database.UserRepository{DB: db} - userService := &core.UserService{Repo: userRepo} - userHandler := &api.UserHandler{Service: userService} - // Создаем роутер + // Сервисы + userService := &core.UserService{Repo: userRepo} + authService := &core.AuthService{UserRepo: userRepo} // Новый сервис + + // Хендлеры + userHandler := &api.UserHandler{Service: userService} + authHandler := &api.AuthHandler{Service: authService} // Новый хендлер + + // --- Настраиваем роутер --- r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) - // Группа маршрутов для API + // Группа маршрутов для Web API r.Route("/api", func(r chi.Router) { r.Post("/register", userHandler.Register) - // Здесь будут другие маршруты API + }) + + // Группа маршрутов для Yggdrasil API + r.Route("/authserver", func(r chi.Router) { + r.Post("/authenticate", authHandler.Authenticate) + // Здесь будут другие эндпоинты: refresh, validate, signout, invalidate }) // Маршрут для проверки, что сервер жив diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go new file mode 100644 index 0000000..9be4758 --- /dev/null +++ b/internal/api/auth_handler.go @@ -0,0 +1,51 @@ +package api + +import ( + "encoding/json" + "errors" + "log" + "net/http" + + "gitea.mrixs.me/minecraft-platform/backend/internal/core" + "gitea.mrixs.me/minecraft-platform/backend/internal/models" +) + +type AuthHandler struct { + Service *core.AuthService +} + +// YggdrasilError - стандартный формат ошибки для authserver +type YggdrasilError struct { + Error string `json:"error"` + ErrorMessage string `json:"errorMessage"` +} + +func (h *AuthHandler) Authenticate(w http.ResponseWriter, r *http.Request) { + var req models.AuthenticateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + response, err := h.Service.Authenticate(r.Context(), req) + if err != nil { + if errors.Is(err, core.ErrInvalidCredentials) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) // 403 + json.NewEncoder(w).Encode(YggdrasilError{ + Error: "ForbiddenOperationException", + ErrorMessage: "Invalid credentials. Invalid username or password.", + }) + return + } + + // Другие ошибки - внутренние + log.Printf("internal server error during authentication: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} diff --git a/internal/core/auth_service.go b/internal/core/auth_service.go new file mode 100644 index 0000000..703e8ed --- /dev/null +++ b/internal/core/auth_service.go @@ -0,0 +1,67 @@ +package core + +import ( + "context" + "errors" + "strings" + + "gitea.mrixs.me/minecraft-platform/backend/internal/database" + "gitea.mrixs.me/minecraft-platform/backend/internal/models" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") +) + +type AuthService struct { + UserRepo *database.UserRepository +} + +// Authenticate проверяет учетные данные и возвращает данные для ответа Yggdrasil. +func (s *AuthService) Authenticate(ctx context.Context, req models.AuthenticateRequest) (*models.AuthenticateResponse, error) { + // 1. Найти пользователя по имени + user, err := s.UserRepo.GetUserByUsername(ctx, req.Username) + if err != nil { + if errors.Is(err, database.ErrUserNotFound) { + return nil, ErrInvalidCredentials + } + return nil, err // Другая ошибка БД + } + + // 2. Сравнить хеш пароля из БД с паролем из запроса + err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) + if err != nil { + // Если хеши не совпадают, bcrypt возвращает ошибку + return nil, ErrInvalidCredentials + } + + // 3. Сгенерировать новый accessToken + accessToken := uuid.New().String() + + // 4. Сохранить токен в БД + err = s.UserRepo.CreateAccessToken(ctx, user.ID, accessToken, req.ClientToken) + if err != nil { + return nil, err + } + + // 5. Сформировать ответ согласно спецификации Yggdrasil + profile := models.ProfileInfo{ + ID: strings.ReplaceAll(user.UUID.String(), "-", ""), // UUID без дефисов + Name: user.Username, + } + + response := &models.AuthenticateResponse{ + AccessToken: accessToken, + ClientToken: req.ClientToken, + AvailableProfiles: []models.ProfileInfo{profile}, + SelectedProfile: profile, + User: &models.UserProperty{ + ID: user.UUID.String(), + Properties: []any{}, + }, + } + + return response, nil +} diff --git a/internal/database/user_repository.go b/internal/database/user_repository.go index 64a9933..fc7b751 100644 --- a/internal/database/user_repository.go +++ b/internal/database/user_repository.go @@ -7,6 +7,7 @@ import ( "errors" "gitea.mrixs.me/minecraft-platform/backend/internal/models" + "github.com/google/uuid" ) var ( @@ -57,3 +58,39 @@ func (r *UserRepository) CreateUserTx(ctx context.Context, user *models.User) er // Шаг 10 из ТЗ: Коммитим транзакцию return tx.Commit() } + +var ( + ErrUserExists = errors.New("user with this username or email already exists") + ErrUserNotFound = errors.New("user not found") // Новая ошибка +) + +// ... + +// GetUserByUsername находит пользователя по его имени. +// Возвращает полную структуру User, включая хеш пароля для проверки. +func (r *UserRepository) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { + user := &models.User{} + var userUUID string + + query := "SELECT id, uuid, username, email, password_hash, role FROM users WHERE username = $1" + err := r.DB.QueryRowContext(ctx, query, username).Scan( + &user.ID, &userUUID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, err + } + + user.UUID, _ = uuid.Parse(userUUID) + return user, nil +} + +// CreateAccessToken сохраняет новый токен доступа в базу данных. +func (r *UserRepository) CreateAccessToken(ctx context.Context, userID int, accessToken, clientToken string) error { + query := "INSERT INTO access_tokens (user_id, access_token, client_token) VALUES ($1, $2, $3)" + _, err := r.DB.ExecContext(ctx, query, userID, accessToken, clientToken) + return err +} diff --git a/internal/models/auth.go b/internal/models/auth.go new file mode 100644 index 0000000..a2871b5 --- /dev/null +++ b/internal/models/auth.go @@ -0,0 +1,36 @@ +package models + +// Agent представляет информацию о лаунчере, который делает запрос +type Agent struct { + Name string `json:"name"` // "Minecraft" + Version int `json:"version"` // 1 +} + +// AuthenticateRequest - это тело запроса на /authserver/authenticate +type AuthenticateRequest struct { + Agent Agent `json:"agent"` + Username string `json:"username"` + Password string `json:"password"` + ClientToken string `json:"clientToken"` +} + +// ProfileInfo содержит краткую информацию о профиле игрока +type ProfileInfo struct { + ID string `json:"id"` // UUID пользователя без дефисов + Name string `json:"name"` // Никнейм пользователя +} + +// AuthenticateResponse - это тело успешного ответа +type AuthenticateResponse struct { + AccessToken string `json:"accessToken"` + ClientToken string `json:"clientToken"` + AvailableProfiles []ProfileInfo `json:"availableProfiles"` + SelectedProfile ProfileInfo `json:"selectedProfile"` + User *UserProperty `json:"user,omitempty"` // Необязательное поле с доп. свойствами +} + +// UserProperty - часть ответа, может содержать доп. свойства пользователя +type UserProperty struct { + ID string `json:"id"` // UUID пользователя + Properties []any `json:"properties"` // Обычно пустой массив +}