📝 docs: restore ADR-0011 validation library selection
- Restored ADR-0011 with updated implementation details - Documented go-playground/validator adoption and integration strategy - Added technical implementation examples and migration path - Updated status to 'Adopted' reflecting current usage 🔧 refactor: integrate authentication handlers with validation system - Added validation tags to all authentication request DTOs: - LoginRequest: username (3-50 chars), password (6+ chars) - RegisterRequest: username (3-50 chars), password (6-100 chars) - PasswordResetRequest: username (3-50 chars) - PasswordResetCompleteRequest: username (3-50 chars), new_password (6-100 chars) - Updated AuthHandler to accept validator parameter - Replaced manual validation with structured validator.Validate() calls - Added writeValidationError() helper for consistent error responses - Updated server to inject validator into authentication handler - Improved error messages with field-level validation details 🧪 test: update BDD tests for new validation error format - Updated authentication validation tests to expect structured errors - All 25 BDD scenarios passing with improved validation coverage - Maintained backward compatibility for error handling Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user