internal/handler/auth_handler.go
Rama main · réplica de lectura del repositorio original.
package handler
import (
"encoding/json"
"net/http"
"github.com/johnzastrow/actalog/internal/service"
"github.com/johnzastrow/actalog/pkg/logger"
)
// AuthHandler handles authentication endpoints
type AuthHandler struct {
userService *service.UserService
logger *logger.Logger
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(userService *service.UserService, l *logger.Logger) *AuthHandler {
return &AuthHandler{
userService: userService,
logger: l,
}
}
// RegisterRequest represents a registration request
// @Description User registration request body
type RegisterRequest struct {
Name string `json:"name" example:"John Doe"`
Email string `json:"email" example:"john@example.com"`
Password string `json:"password" example:"securePassword123"`
}
// LoginRequest represents a login request
// @Description User login request body
type LoginRequest struct {
Email string `json:"email" example:"john@example.com"`
Password string `json:"password" example:"securePassword123"`
RememberMe bool `json:"remember_me,omitempty" example:"true"`
}
// AuthResponse represents an authentication response
// @Description Authentication response containing JWT token and user info
type AuthResponse struct {
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
RefreshToken string `json:"refresh_token,omitempty" example:"550e8400-e29b-41d4-a716-446655440000"`
User interface{} `json:"user"`
}
// Register handles user registration
// @Summary Register a new user
// @Description Create a new user account with name, email, and password
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RegisterRequest true "Registration details"
// @Success 201 {object} AuthResponse "Successfully registered"
// @Failure 400 {object} ErrorResponse "Invalid request or validation error"
// @Failure 403 {object} ErrorResponse "Registration is closed"
// @Failure 409 {object} ErrorResponse "Email already exists"
// @Router /auth/register [post]
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondErrorWithDetail(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// Validate input
if req.Name == "" || req.Email == "" || req.Password == "" {
respondError(w, http.StatusBadRequest, "Name, email, and password are required")
return
}
// Log attempt
if h.logger != nil {
h.logger.Info("action=register_attempt email=%s name=%s remote=%s ua=%s", req.Email, req.Name, r.RemoteAddr, r.UserAgent())
}
// Register user
user, token, err := h.userService.Register(req.Name, req.Email, req.Password)
if err != nil {
switch err {
case service.ErrEmailAlreadyExists:
if h.logger != nil {
h.logger.Warn("action=register outcome=failure email=%s reason=email_exists", req.Email)
}
respondError(w, http.StatusConflict, "Email already exists")
case service.ErrRegistrationClosed:
if h.logger != nil {
h.logger.Warn("action=register outcome=failure email=%s reason=registration_closed", req.Email)
}
respondError(w, http.StatusForbidden, "Registration is closed. Please contact an administrator.")
default:
if h.logger != nil {
h.logger.Error("action=register outcome=failure email=%s error=%v", req.Email, err)
}
// Return the actual validation error to help users fix their input
respondError(w, http.StatusBadRequest, err.Error())
}
return
}
if h.logger != nil {
h.logger.Info("action=register outcome=success user_id=%d email=%s", user.ID, user.Email)
}
respondJSON(w, http.StatusCreated, AuthResponse{
Token: token,
User: user,
})
}
// Login handles user login
// @Summary User login
// @Description Authenticate user with email and password. Set remember_me to true for refresh token.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body LoginRequest true "Login credentials"
// @Success 200 {object} AuthResponse "Successfully authenticated"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 401 {object} ErrorResponse "Invalid email or password"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/login [post]
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondErrorWithDetail(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// Validate input
if req.Email == "" || req.Password == "" {
respondError(w, http.StatusBadRequest, "Email and password are required")
return
}
if h.logger != nil {
h.logger.Info("action=login_attempt email=%s remote=%s ua=%s", req.Email, r.RemoteAddr, r.UserAgent())
}
// Login user
user, token, err := h.userService.Login(req.Email, req.Password)
if err != nil {
if err == service.ErrInvalidCredentials {
if h.logger != nil {
h.logger.Warn("action=login outcome=failure email=%s reason=invalid_credentials", req.Email)
}
respondError(w, http.StatusUnauthorized, "Invalid email or password")
} else {
if h.logger != nil {
h.logger.Error("action=login outcome=failure email=%s error=%v", req.Email, err)
}
respondErrorWithDetail(w, http.StatusInternalServerError, "Failed to login", err.Error())
}
return
}
response := AuthResponse{
Token: token,
User: user,
}
// Create refresh token if remember_me is true
if req.RememberMe {
deviceInfo := r.UserAgent() // Get browser/device info from User-Agent header
refreshToken, err := h.userService.CreateRefreshToken(user.ID, deviceInfo, req.RememberMe)
if err != nil {
// Log error but don't fail the login
if h.logger != nil {
h.logger.Warn("action=create_refresh_token outcome=failure user_id=%d email=%s error=%v", user.ID, user.Email, err)
}
// User can still use the access token
respondErrorWithDetail(w, http.StatusInternalServerError, "Warning: Failed to create refresh token", err.Error())
} else {
response.RefreshToken = refreshToken
if h.logger != nil {
h.logger.Info("action=create_refresh_token outcome=success user_id=%d email=%s remember_me=%v", user.ID, user.Email, req.RememberMe)
}
}
}
if h.logger != nil {
h.logger.Info("action=login outcome=success user_id=%d email=%s", user.ID, user.Email)
}
respondJSON(w, http.StatusOK, response)
}
// ForgotPasswordRequest represents a forgot password request
// @Description Request to initiate password reset
type ForgotPasswordRequest struct {
Email string `json:"email" example:"john@example.com"`
}
// ResetPasswordRequest represents a reset password request
// @Description Request to complete password reset with token
type ResetPasswordRequest struct {
Token string `json:"token" example:"abc123def456"`
NewPassword string `json:"new_password" example:"newSecurePassword123"`
}
// MessageResponse represents a simple message response
// @Description Generic message response
type MessageResponse struct {
Message string `json:"message" example:"Operation completed successfully"`
}
// ForgotPassword handles forgot password requests
// @Summary Request password reset
// @Description Send a password reset email to the user. Always returns success for security.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body ForgotPasswordRequest true "Email address"
// @Success 200 {object} MessageResponse "Reset email sent if address exists"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 500 {object} ErrorResponse "Failed to process request"
// @Router /auth/forgot-password [post]
func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
var req ForgotPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Validate email
if req.Email == "" {
respondError(w, http.StatusBadRequest, "Email is required")
return
}
// Request password reset (always succeeds for security)
err := h.userService.RequestPasswordReset(req.Email)
if err != nil {
// Log error but don't reveal to user
// In production, this should use proper logging
respondError(w, http.StatusInternalServerError, "Failed to process request")
return
}
// Always return success (don't reveal if email exists)
respondJSON(w, http.StatusOK, MessageResponse{
Message: "If your email is registered, you will receive a password reset link shortly",
})
}
// ResetPassword handles password reset requests
// @Summary Reset password
// @Description Complete password reset using token from email
// @Tags auth
// @Accept json
// @Produce json
// @Param request body ResetPasswordRequest true "Reset token and new password"
// @Success 200 {object} MessageResponse "Password reset successfully"
// @Failure 400 {object} ErrorResponse "Invalid token, expired token, or weak password"
// @Failure 500 {object} ErrorResponse "Failed to reset password"
// @Router /auth/reset-password [post]
func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
var req ResetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Validate input
if req.Token == "" || req.NewPassword == "" {
respondError(w, http.StatusBadRequest, "Token and new password are required")
return
}
// Reset password (policy enforced in service layer)
err := h.userService.ResetPassword(req.Token, req.NewPassword)
if err != nil {
switch err {
case service.ErrInvalidResetToken:
respondError(w, http.StatusBadRequest, "Invalid reset token")
case service.ErrResetTokenExpired:
respondError(w, http.StatusBadRequest, "Reset token has expired. Please request a new one")
default:
respondError(w, http.StatusInternalServerError, "Failed to reset password")
}
return
}
respondJSON(w, http.StatusOK, MessageResponse{
Message: "Password has been reset successfully. You can now login with your new password",
})
}
// VerifyEmailRequest represents an email verification request
// @Description Email verification request
type VerifyEmailRequest struct {
Token string `json:"token" example:"verification-token-123"`
}
// VerifyEmail handles email verification requests
// @Summary Verify email address
// @Description Verify user's email using token from verification email
// @Tags auth
// @Accept json
// @Produce json
// @Param token query string true "Verification token from email"
// @Success 200 {object} MessageResponse "Email verified successfully"
// @Failure 400 {object} ErrorResponse "Invalid or expired token, or already verified"
// @Failure 500 {object} ErrorResponse "Failed to verify email"
// @Router /auth/verify-email [get]
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
// Get token from query parameter
token := r.URL.Query().Get("token")
if token == "" {
respondError(w, http.StatusBadRequest, "Verification token is required")
return
}
// Verify email
err := h.userService.VerifyEmail(token)
if err != nil {
switch err {
case service.ErrInvalidVerificationToken:
respondError(w, http.StatusBadRequest, "Invalid verification token")
case service.ErrVerificationTokenExpired:
respondError(w, http.StatusBadRequest, "Verification token has expired. Please request a new one")
case service.ErrEmailAlreadyVerified:
respondError(w, http.StatusBadRequest, "Email is already verified")
default:
respondError(w, http.StatusInternalServerError, "Failed to verify email")
}
return
}
respondJSON(w, http.StatusOK, MessageResponse{
Message: "Email verified successfully. You can now login",
})
}
// ResendVerificationRequest represents a resend verification email request
// @Description Request to resend verification email
type ResendVerificationRequest struct {
Email string `json:"email" example:"john@example.com"`
}
// ResendVerification handles resend verification email requests
// @Summary Resend verification email
// @Description Resend email verification link. Always returns success for security.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body ResendVerificationRequest true "Email address"
// @Success 200 {object} MessageResponse "Verification email sent if applicable"
// @Failure 400 {object} ErrorResponse "Invalid request or already verified"
// @Failure 500 {object} ErrorResponse "Failed to resend verification email"
// @Router /auth/resend-verification [post]
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
var req ResendVerificationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Validate email
if req.Email == "" {
respondError(w, http.StatusBadRequest, "Email is required")
return
}
// Resend verification email
err := h.userService.ResendVerificationEmail(req.Email)
if err != nil {
if err == service.ErrEmailAlreadyVerified {
respondError(w, http.StatusBadRequest, "Email is already verified")
return
}
respondError(w, http.StatusInternalServerError, "Failed to resend verification email")
return
}
// Always return success (don't reveal if email exists)
respondJSON(w, http.StatusOK, MessageResponse{
Message: "If your email is registered and not yet verified, you will receive a verification link shortly",
})
}
// RefreshTokenRequest represents a refresh token request
// @Description Request to refresh access token
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" example:"550e8400-e29b-41d4-a716-446655440000"`
}
// RefreshToken handles refresh token requests
// @Summary Refresh access token
// @Description Get a new access token using a valid refresh token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RefreshTokenRequest true "Refresh token"
// @Success 200 {object} AuthResponse "New access token"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 401 {object} ErrorResponse "Invalid or expired refresh token"
// @Failure 500 {object} ErrorResponse "Failed to refresh token"
// @Router /auth/refresh-token [post]
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
var req RefreshTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Validate input
if req.RefreshToken == "" {
respondError(w, http.StatusBadRequest, "Refresh token is required")
return
}
// Refresh access token
user, newAccessToken, err := h.userService.RefreshAccessToken(req.RefreshToken)
if err != nil {
if err == service.ErrInvalidRefreshToken {
respondError(w, http.StatusUnauthorized, "Invalid or expired refresh token")
} else {
respondError(w, http.StatusInternalServerError, "Failed to refresh token")
}
return
}
respondJSON(w, http.StatusOK, AuthResponse{
Token: newAccessToken,
User: user,
})
}
// RevokeTokenRequest represents a revoke token request
// @Description Request to revoke a refresh token (logout)
type RevokeTokenRequest struct {
RefreshToken string `json:"refresh_token" example:"550e8400-e29b-41d4-a716-446655440000"`
}
// RevokeToken handles token revocation (logout)
// @Summary Revoke refresh token
// @Description Invalidate a refresh token to logout from a specific session
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RevokeTokenRequest true "Refresh token to revoke"
// @Success 200 {object} MessageResponse "Token revoked successfully"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 404 {object} ErrorResponse "Refresh token not found"
// @Failure 500 {object} ErrorResponse "Failed to revoke token"
// @Router /auth/revoke-token [post]
func (h *AuthHandler) RevokeToken(w http.ResponseWriter, r *http.Request) {
var req RevokeTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Validate input
if req.RefreshToken == "" {
respondError(w, http.StatusBadRequest, "Refresh token is required")
return
}
// Revoke token
err := h.userService.RevokeRefreshToken(req.RefreshToken)
if err != nil {
if err == service.ErrInvalidRefreshToken {
respondError(w, http.StatusNotFound, "Refresh token not found")
} else {
respondError(w, http.StatusInternalServerError, "Failed to revoke token")
}
return
}
respondJSON(w, http.StatusOK, MessageResponse{
Message: "Token revoked successfully",
})
}
// Helper functions
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, ErrorResponse{Message: message})
}
func respondErrorWithDetail(w http.ResponseWriter, status int, message string, detail string) {
respondJSON(w, status, ErrorResponse{Message: message, Error: detail})
}