diff --git a/pkg/bdd/steps/steps.go b/pkg/bdd/steps/steps.go index 3468168..965dbb1 100644 --- a/pkg/bdd/steps/steps.go +++ b/pkg/bdd/steps/steps.go @@ -401,10 +401,10 @@ func (sc *StepContext) theAuthenticationShouldFailWithValidationError() error { return fmt.Errorf("expected status 400, got %d", sc.client.GetLastStatusCode()) } - // Check if response contains validation error + // Check if response contains validation error (new structured format) body := string(sc.client.GetLastBody()) - if !strings.Contains(body, "invalid_request") { - return fmt.Errorf("expected response to contain invalid_request error, got %s", body) + if !strings.Contains(body, "validation_failed") && !strings.Contains(body, "invalid_request") { + return fmt.Errorf("expected response to contain validation_failed or invalid_request error, got %s", body) } return nil diff --git a/pkg/server/server.go b/pkg/server/server.go index a8eb380..ce0b591 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -164,7 +164,7 @@ func (s *Server) registerApiV1Routes(r chi.Router) { if s.userService != nil && s.userRepo != nil { // Use unified user service - much simpler! if s.userService != nil { - handler := userapi.NewAuthHandler(s.userService, s.userService) + handler := userapi.NewAuthHandler(s.userService, s.userService, s.validator) r.Route("/auth", func(r chi.Router) { handler.RegisterRoutes(r) }) diff --git a/pkg/user/api/auth_handler.go b/pkg/user/api/auth_handler.go index f3d5162..86691c7 100644 --- a/pkg/user/api/auth_handler.go +++ b/pkg/user/api/auth_handler.go @@ -2,9 +2,11 @@ package api import ( "encoding/json" + "errors" "net/http" "dance-lessons-coach/pkg/user" + "dance-lessons-coach/pkg/validation" "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" @@ -14,13 +16,15 @@ import ( type AuthHandler struct { authService user.AuthService userService user.UserService + validator *validation.Validator } // NewAuthHandler creates a new authentication handler -func NewAuthHandler(authService user.AuthService, userService user.UserService) *AuthHandler { +func NewAuthHandler(authService user.AuthService, userService user.UserService, validator *validation.Validator) *AuthHandler { return &AuthHandler{ authService: authService, userService: userService, + validator: validator, } } @@ -33,6 +37,29 @@ func (h *AuthHandler) RegisterRoutes(router chi.Router) { router.Post("/password-reset/complete", h.handlePasswordResetComplete) } +// writeValidationError writes a structured validation error response +func (h *AuthHandler) writeValidationError(w http.ResponseWriter, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + + // The validator returns a ValidationError that we can use directly + var validationErr *validation.ValidationError + if errors.As(err, &validationErr) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "validation_failed", + "message": "Invalid request data", + "details": validationErr.Messages, + }) + return + } + + // Fallback for other error types + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "validation_failed", + "message": err.Error(), + }) +} + // LoginRequest represents a login request type LoginRequest struct { Username string `json:"username" validate:"required,min=3,max=50"` @@ -45,17 +72,18 @@ type LoginResponse struct { } // handleLogin godoc -// @Summary User login -// @Description Authenticate user and return JWT token -// @Tags API/v1/User -// @Accept json -// @Produce json -// @Param request body LoginRequest true "Login credentials" -// @Success 200 {object} LoginResponse "Successful authentication" -// @Failure 400 {object} map[string]string "Invalid request" -// @Failure 401 {object} map[string]string "Invalid credentials" -// @Failure 500 {object} map[string]string "Server error" -// @Router /v1/auth/login [post] +// +// @Summary User login +// @Description Authenticate user and return JWT token +// @Tags API/v1/User +// @Accept json +// @Produce json +// @Param request body LoginRequest true "Login credentials" +// @Success 200 {object} LoginResponse "Successful authentication" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 401 {object} map[string]string "Invalid credentials" +// @Failure 500 {object} map[string]string "Server error" +// @Router /v1/auth/login [post] func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -65,10 +93,12 @@ func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) { return } - // Validate username and password are not empty - if req.Username == "" || req.Password == "" { - http.Error(w, `{"error":"invalid_request","message":"Username and password are required"}`, http.StatusBadRequest) - return + // Validate request using validator + if h.validator != nil { + if err := h.validator.Validate(req); err != nil { + h.writeValidationError(w, err) + return + } } // Authenticate user @@ -96,21 +126,22 @@ func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) { // RegisterRequest represents a user registration request type RegisterRequest struct { Username string `json:"username" validate:"required,min=3,max=50"` - Password string `json:"password" validate:"required,min=6"` + Password string `json:"password" validate:"required,min=6,max=100"` } // handleRegister godoc -// @Summary User registration -// @Description Register a new user account -// @Tags API/v1/User -// @Accept json -// @Produce json -// @Param request body RegisterRequest true "Registration details" -// @Success 201 {object} map[string]string "User created" -// @Failure 400 {object} map[string]string "Invalid request" -// @Failure 409 {object} map[string]string "Username already taken" -// @Failure 500 {object} map[string]string "Server error" -// @Router /v1/auth/register [post] +// +// @Summary User registration +// @Description Register a new user account +// @Tags API/v1/User +// @Accept json +// @Produce json +// @Param request body RegisterRequest true "Registration details" +// @Success 201 {object} map[string]string "User created" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 409 {object} map[string]string "Username already taken" +// @Failure 500 {object} map[string]string "Server error" +// @Router /v1/auth/register [post] func (h *AuthHandler) handleRegister(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -120,16 +151,12 @@ func (h *AuthHandler) handleRegister(w http.ResponseWriter, r *http.Request) { return } - // Validate username length (min 3, max 50 characters) - if len(req.Username) < 3 || len(req.Username) > 50 { - http.Error(w, `{"error":"invalid_username","message":"Username must be between 3 and 50 characters"}`, http.StatusBadRequest) - return - } - - // Validate password length (min 6 characters) - if len(req.Password) < 6 { - http.Error(w, `{"error":"invalid_password","message":"Password must be at least 6 characters"}`, http.StatusBadRequest) - return + // Validate request using validator + if h.validator != nil { + if err := h.validator.Validate(req); err != nil { + h.writeValidationError(w, err) + return + } } // Check if user already exists @@ -177,16 +204,17 @@ type PasswordResetRequest struct { } // handlePasswordResetRequest godoc -// @Summary Request password reset -// @Description Initiate password reset process for a user -// @Tags API/v1/User -// @Accept json -// @Produce json -// @Param request body PasswordResetRequest true "Password reset request" -// @Success 200 {object} map[string]string "Reset allowed" -// @Failure 400 {object} map[string]string "Invalid request" -// @Failure 500 {object} map[string]string "Server error" -// @Router /v1/auth/password-reset/request [post] +// +// @Summary Request password reset +// @Description Initiate password reset process for a user +// @Tags API/v1/User +// @Accept json +// @Produce json +// @Param request body PasswordResetRequest true "Password reset request" +// @Success 200 {object} map[string]string "Reset allowed" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 500 {object} map[string]string "Server error" +// @Router /v1/auth/password-reset/request [post] func (h *AuthHandler) handlePasswordResetRequest(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -196,6 +224,14 @@ func (h *AuthHandler) handlePasswordResetRequest(w http.ResponseWriter, r *http. return } + // Validate request using validator + if h.validator != nil { + if err := h.validator.Validate(req); err != nil { + h.writeValidationError(w, err) + return + } + } + // Request password reset if err := h.userService.RequestPasswordReset(ctx, req.Username); err != nil { log.Error().Ctx(ctx).Err(err).Msg("Failed to request password reset") @@ -212,20 +248,21 @@ func (h *AuthHandler) handlePasswordResetRequest(w http.ResponseWriter, r *http. // PasswordResetCompleteRequest represents a password reset completion request type PasswordResetCompleteRequest struct { Username string `json:"username" validate:"required,min=3,max=50"` - NewPassword string `json:"new_password" validate:"required,min=6"` + NewPassword string `json:"new_password" validate:"required,min=6,max=100"` } // handlePasswordResetComplete godoc -// @Summary Complete password reset -// @Description Complete password reset with new password -// @Tags API/v1/User -// @Accept json -// @Produce json -// @Param request body PasswordResetCompleteRequest true "Password reset completion" -// @Success 200 {object} map[string]string "Password updated" -// @Failure 400 {object} map[string]string "Invalid request" -// @Failure 500 {object} map[string]string "Server error" -// @Router /v1/auth/password-reset/complete [post] +// +// @Summary Complete password reset +// @Description Complete password reset with new password +// @Tags API/v1/User +// @Accept json +// @Produce json +// @Param request body PasswordResetCompleteRequest true "Password reset completion" +// @Success 200 {object} map[string]string "Password updated" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 500 {object} map[string]string "Server error" +// @Router /v1/auth/password-reset/complete [post] func (h *AuthHandler) handlePasswordResetComplete(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -235,6 +272,14 @@ func (h *AuthHandler) handlePasswordResetComplete(w http.ResponseWriter, r *http return } + // Validate request using validator + if h.validator != nil { + if err := h.validator.Validate(req); err != nil { + h.writeValidationError(w, err) + return + } + } + // Complete password reset if err := h.userService.CompletePasswordReset(ctx, req.Username, req.NewPassword); err != nil { log.Error().Ctx(ctx).Err(err).Msg("Failed to complete password reset") @@ -249,17 +294,18 @@ func (h *AuthHandler) handlePasswordResetComplete(w http.ResponseWriter, r *http } // handleAdminLogin godoc -// @Summary Admin login -// @Description Authenticate admin user with master password -// @Tags Admin/User -// @Accept json -// @Produce json -// @Param request body LoginRequest true "Admin login credentials" -// @Success 200 {object} LoginResponse "Successful admin authentication" -// @Failure 400 {object} map[string]string "Invalid request" -// @Failure 401 {object} map[string]string "Invalid admin credentials" -// @Failure 500 {object} map[string]string "Server error" -// @Router /v1/auth/admin/login [post] +// +// @Summary Admin login +// @Description Authenticate admin user with master password +// @Tags Admin/User +// @Accept json +// @Produce json +// @Param request body LoginRequest true "Admin login credentials" +// @Success 200 {object} LoginResponse "Successful admin authentication" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 401 {object} map[string]string "Invalid admin credentials" +// @Failure 500 {object} map[string]string "Server error" +// @Router /v1/auth/admin/login [post] func (h *AuthHandler) handleAdminLogin(w http.ResponseWriter, r *http.Request) { ctx := r.Context()