✨ feat: implement user authentication system with JWT and PostgreSQL
Added comprehensive user management system: - User registration with validation (3-50 char username, 6+ char password) - JWT-based authentication with bcrypt password hashing - Admin authentication with master password - Password reset workflow with admin flagging - PostgreSQL repository implementation - SQLite repository for testing - Unified authentication service interface API Endpoints: - POST /api/v1/auth/register - User registration - POST /api/v1/auth/login - User/admin authentication - POST /api/v1/auth/password-reset/request - Request password reset - POST /api/v1/auth/password-reset/complete - Complete password reset - POST /api/v1/auth/validate - JWT token validation Security Features: - Password hashing with bcrypt - JWT token generation and validation - Admin claims in JWT tokens - Configurable token expiration - Input validation for all endpoints Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
359
pkg/user/api/auth_handler.go
Normal file
359
pkg/user/api/auth_handler.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"dance-lessons-coach/pkg/user"
|
||||
"dance-lessons-coach/pkg/validation"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication-related HTTP requests
|
||||
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, validator *validation.Validator) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
userService: userService,
|
||||
validator: validator,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers authentication routes
|
||||
func (h *AuthHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Post("/login", h.handleLogin)
|
||||
router.Post("/register", h.handleRegister)
|
||||
router.Post("/password-reset/request", h.handlePasswordResetRequest)
|
||||
router.Post("/password-reset/complete", h.handlePasswordResetComplete)
|
||||
router.Post("/validate", h.handleValidateToken)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
}
|
||||
|
||||
// LoginResponse represents a login response
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// handleLogin godoc
|
||||
//
|
||||
// @Summary User login
|
||||
// @Description Authenticate user or admin and return JWT token. Supports both regular users and admin authentication.
|
||||
// @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()
|
||||
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request using validator
|
||||
if h.validator != nil {
|
||||
if err := h.validator.Validate(req); err != nil {
|
||||
h.writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Try unified authentication (regular user first, then admin fallback)
|
||||
var authenticatedUser *user.User
|
||||
var authError error
|
||||
|
||||
// Try regular user authentication first
|
||||
authenticatedUser, authError = h.authService.Authenticate(ctx, req.Username, req.Password)
|
||||
|
||||
// If regular auth fails, try admin authentication
|
||||
if authError != nil {
|
||||
authenticatedUser, authError = h.authService.AdminAuthenticate(ctx, req.Password)
|
||||
}
|
||||
|
||||
// If both authentication methods failed
|
||||
if authError != nil {
|
||||
log.Trace().Ctx(ctx).Err(authError).Str("username", req.Username).Msg("Authentication failed")
|
||||
http.Error(w, `{"error":"invalid_credentials","message":"Invalid username or password"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token using the authenticated user (regular or admin)
|
||||
token, err := h.authService.GenerateJWT(ctx, authenticatedUser)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to generate JWT token")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to generate authentication token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return token
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(LoginResponse{Token: token})
|
||||
}
|
||||
|
||||
// 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,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]
|
||||
func (h *AuthHandler) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, 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
|
||||
exists, err := h.userService.UserExists(ctx, req.Username)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to check if user exists")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process registration"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
http.Error(w, `{"error":"user_exists","message":"Username already taken"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := h.userService.HashPassword(ctx, req.Password)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to hash password")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process registration"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
newUser := &user.User{
|
||||
Username: req.Username,
|
||||
PasswordHash: hashedPassword,
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
if err := h.userService.CreateUser(ctx, newUser); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to create user")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to create user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "User registered successfully"})
|
||||
}
|
||||
|
||||
// PasswordResetRequest represents a password reset request
|
||||
type PasswordResetRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
}
|
||||
|
||||
// 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]
|
||||
func (h *AuthHandler) handlePasswordResetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req PasswordResetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
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")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process password reset request"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Password reset allowed, user can now reset password"})
|
||||
}
|
||||
|
||||
// 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,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]
|
||||
func (h *AuthHandler) handlePasswordResetComplete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req PasswordResetCompleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
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")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to complete password reset"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Password reset completed successfully"})
|
||||
}
|
||||
|
||||
// TokenValidationRequest represents a JWT token validation request
|
||||
// This is used for testing JWT validation with different token scenarios
|
||||
type TokenValidationRequest struct {
|
||||
Token string `json:"token" validate:"required"`
|
||||
}
|
||||
|
||||
// handleValidateToken godoc
|
||||
//
|
||||
// @Summary Validate JWT token
|
||||
// @Description Validate a JWT token and return user information if valid
|
||||
// @Tags API/v1/User
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body TokenValidationRequest true "Token validation request"
|
||||
// @Success 200 {object} map[string]interface{} "Token is valid with user info"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 401 {object} map[string]string "Invalid token"
|
||||
// @Router /v1/auth/validate [post]
|
||||
func (h *AuthHandler) handleValidateToken(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req TokenValidationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request using validator
|
||||
if h.validator != nil {
|
||||
if err := h.validator.Validate(req); err != nil {
|
||||
h.writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the JWT token
|
||||
user, err := h.authService.ValidateJWT(ctx, req.Token)
|
||||
if err != nil {
|
||||
log.Trace().Ctx(ctx).Err(err).Msg("JWT validation failed in validate endpoint")
|
||||
http.Error(w, fmt.Sprintf(`{"error":"invalid_token","message":"%s"}`, err.Error()), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success with user info
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"valid": true,
|
||||
"user_id": user.ID,
|
||||
"message": "Token is valid",
|
||||
})
|
||||
}
|
||||
79
pkg/user/api/password_handler.go
Normal file
79
pkg/user/api/password_handler.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"dance-lessons-coach/pkg/user"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// PasswordResetHandler handles password reset requests
|
||||
type PasswordResetHandler struct {
|
||||
passwordResetService user.PasswordResetService
|
||||
}
|
||||
|
||||
// NewPasswordResetHandler creates a new password reset handler
|
||||
func NewPasswordResetHandler(passwordResetService user.PasswordResetService) *PasswordResetHandler {
|
||||
return &PasswordResetHandler{
|
||||
passwordResetService: passwordResetService,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers password reset routes
|
||||
func (h *PasswordResetHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Post("/password-reset/request", h.handlePasswordResetRequest)
|
||||
router.Post("/password-reset/complete", h.handlePasswordResetComplete)
|
||||
}
|
||||
|
||||
// PasswordResetRequest represents a password reset request
|
||||
|
||||
// handlePasswordResetRequest handles password reset requests
|
||||
func (h *PasswordResetHandler) handlePasswordResetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req PasswordResetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Request password reset
|
||||
if err := h.passwordResetService.RequestPasswordReset(ctx, req.Username); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to request password reset")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process password reset request"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Password reset allowed, user can now reset password"})
|
||||
}
|
||||
|
||||
// PasswordResetCompleteRequest represents a password reset completion request
|
||||
|
||||
// handlePasswordResetComplete handles password reset completion requests
|
||||
func (h *PasswordResetHandler) handlePasswordResetComplete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req PasswordResetCompleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Complete password reset
|
||||
if err := h.passwordResetService.CompletePasswordReset(ctx, req.Username, req.NewPassword); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to complete password reset")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to complete password reset"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Password reset completed successfully"})
|
||||
}
|
||||
81
pkg/user/api/user_handler.go
Normal file
81
pkg/user/api/user_handler.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"dance-lessons-coach/pkg/user"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// UserHandler handles user management requests
|
||||
type UserHandler struct {
|
||||
userRepo user.UserRepository
|
||||
passwordService user.PasswordService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new user handler
|
||||
func NewUserHandler(userRepo user.UserRepository, passwordService user.PasswordService) *UserHandler {
|
||||
return &UserHandler{
|
||||
userRepo: userRepo,
|
||||
passwordService: passwordService,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers user routes
|
||||
func (h *UserHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Post("/register", h.handleRegister)
|
||||
}
|
||||
|
||||
// RegisterRequest represents a user registration request
|
||||
|
||||
// handleRegister handles user registration requests
|
||||
func (h *UserHandler) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
exists, err := h.userRepo.UserExists(ctx, req.Username)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to check if user exists")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process registration"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
http.Error(w, `{"error":"user_exists","message":"Username already taken"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := h.passwordService.HashPassword(ctx, req.Password)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to hash password")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process registration"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
newUser := &user.User{
|
||||
Username: req.Username,
|
||||
PasswordHash: hashedPassword,
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
if err := h.userRepo.CreateUser(ctx, newUser); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to create user")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to create user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "User registered successfully"})
|
||||
}
|
||||
Reference in New Issue
Block a user