From 45173c406c0ad04d8e6060e2ea4533a0b3184384 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Mon, 16 Jun 2025 08:25:20 +0300 Subject: [PATCH] feat(auth): implement web login endpoint with JWT --- cmd/server/main.go | 1 + internal/api/auth_handler.go | 28 ++++++++++++++++ internal/core/auth_service.go | 48 ++++++++++++++++++++++++++++ internal/database/user_repository.go | 22 +++++++++++++ internal/models/auth.go | 13 ++++++++ 5 files changed, 112 insertions(+) diff --git a/cmd/server/main.go b/cmd/server/main.go index b031e62..32e6d23 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -50,6 +50,7 @@ func main() { // --- Публичные роуты --- r.Route("/api", func(r chi.Router) { r.Post("/register", userHandler.Register) + r.Post("/login", authHandler.Login) // Здесь будет публичный эндпоинт для логина в веб-интерфейс }) r.Route("/authserver", func(r chi.Router) { diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go index 98e1c28..7025f5e 100644 --- a/internal/api/auth_handler.go +++ b/internal/api/auth_handler.go @@ -78,3 +78,31 @@ func (h *AuthHandler) Join(w http.ResponseWriter, r *http.Request) { // В случае успеха возвращаем пустой ответ со статусом 204 w.WriteHeader(http.StatusNoContent) } + +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + var req models.LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + token, user, err := h.Service.LoginUser(r.Context(), req) + if err != nil { + if errors.Is(err, core.ErrInvalidCredentials) { + http.Error(w, "Invalid username or password", http.StatusUnauthorized) + return + } + log.Printf("internal server error during login: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + response := models.LoginResponse{ + Token: token, + User: user, + } + + 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 index 703e8ed..a9a266f 100644 --- a/internal/core/auth_service.go +++ b/internal/core/auth_service.go @@ -3,10 +3,13 @@ package core import ( "context" "errors" + "os" "strings" + "time" "gitea.mrixs.me/minecraft-platform/backend/internal/database" "gitea.mrixs.me/minecraft-platform/backend/internal/models" + "github.com/golang-jwt/jwt" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -65,3 +68,48 @@ func (s *AuthService) Authenticate(ctx context.Context, req models.AuthenticateR return response, nil } + +// LoginUser проверяет учетные данные и генерирует JWT для веб-сессии. +func (s *AuthService) LoginUser(ctx context.Context, req models.LoginRequest) (string, *models.User, error) { + // 1. Найти пользователя по логину (username или email) + user, err := s.UserRepo.GetUserByLogin(ctx, req.Login) + 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 { + return "", nil, ErrInvalidCredentials + } + + // 3. Создать JWT + // Устанавливаем срок действия токена, например, 72 часа + expirationTime := time.Now().Add(72 * time.Hour) + + // Создаем claims (полезная нагрузка токена) + claims := &jwt.MapClaims{ + "exp": expirationTime.Unix(), + "iat": time.Now().Unix(), + "user_id": user.ID, + "role": user.Role, + } + + // Создаем токен с указанием алгоритма подписи и claims + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Подписываем токен нашим секретным ключом + jwtSecret := os.Getenv("JWT_SECRET_KEY") + tokenString, err := token.SignedString([]byte(jwtSecret)) + if err != nil { + return "", nil, err + } + + // Скрываем хеш пароля перед отправкой данных пользователя на клиент + user.PasswordHash = "" + + return tokenString, user, nil +} diff --git a/internal/database/user_repository.go b/internal/database/user_repository.go index e96d053..db42c77 100644 --- a/internal/database/user_repository.go +++ b/internal/database/user_repository.go @@ -176,3 +176,25 @@ func (r *UserRepository) UpdateSkinHash(ctx context.Context, userID int, skinHas return nil } + +// GetUserByLogin находит пользователя по его имени или email. +func (r *UserRepository) GetUserByLogin(ctx context.Context, login string) (*models.User, error) { + user := &models.User{} + var userUUID string + + // Ищем по username ИЛИ по email + query := "SELECT id, uuid, username, email, password_hash, role, created_at, updated_at FROM users WHERE username = $1 OR email = $1" + err := r.DB.QueryRowContext(ctx, query, login).Scan( + &user.ID, &userUUID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, err + } + + user.UUID, _ = uuid.Parse(userUUID) + return user, nil +} diff --git a/internal/models/auth.go b/internal/models/auth.go index d655fa4..30deee5 100644 --- a/internal/models/auth.go +++ b/internal/models/auth.go @@ -74,3 +74,16 @@ type JoinRequest struct { SelectedProfile string `json:"selectedProfile"` // UUID пользователя без дефисов ServerID string `json:"serverId"` } + +// LoginRequest - это тело запроса на /api/login +type LoginRequest struct { + // Позволяем логиниться как по username, так и по email + Login string `json:"login"` + Password string `json:"password"` +} + +// LoginResponse - это тело успешного ответа с JWT +type LoginResponse struct { + Token string `json:"token"` + User *User `json:"user"` // Отдаем информацию о пользователе +}