package handlers import ( "errors" "net/http" "github.com/labstack/echo/v4" "github.com/rs/zerolog/log" "github.com/treytartt/honeydue-api/internal/apperrors" "github.com/treytartt/honeydue-api/internal/dto/requests" "github.com/treytartt/honeydue-api/internal/dto/responses" "github.com/treytartt/honeydue-api/internal/middleware" "github.com/treytartt/honeydue-api/internal/services" "github.com/treytartt/honeydue-api/internal/validator" ) // AuthHandler handles authentication endpoints type AuthHandler struct { authService *services.AuthService emailService *services.EmailService cache *services.CacheService appleAuthService *services.AppleAuthService googleAuthService *services.GoogleAuthService storageService *services.StorageService auditService *services.AuditService } // NewAuthHandler creates a new auth handler func NewAuthHandler(authService *services.AuthService, emailService *services.EmailService, cache *services.CacheService) *AuthHandler { return &AuthHandler{ authService: authService, emailService: emailService, cache: cache, } } // SetAppleAuthService sets the Apple auth service (called after initialization) func (h *AuthHandler) SetAppleAuthService(appleAuth *services.AppleAuthService) { h.appleAuthService = appleAuth } // SetGoogleAuthService sets the Google auth service (called after initialization) func (h *AuthHandler) SetGoogleAuthService(googleAuth *services.GoogleAuthService) { h.googleAuthService = googleAuth } // SetStorageService sets the storage service for file deletion during account deletion func (h *AuthHandler) SetStorageService(storageService *services.StorageService) { h.storageService = storageService } // SetAuditService sets the audit service for logging security events func (h *AuthHandler) SetAuditService(auditService *services.AuditService) { h.auditService = auditService } // Login handles POST /api/auth/login/ func (h *AuthHandler) Login(c echo.Context) error { var req requests.LoginRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } response, err := h.authService.Login(&req) if err != nil { log.Debug().Err(err).Str("identifier", req.Username).Msg("Login failed") if h.auditService != nil { h.auditService.LogEvent(c, nil, services.AuditEventLoginFailed, map[string]interface{}{ "identifier": req.Username, }) } return err } if h.auditService != nil { userID := response.User.ID h.auditService.LogEvent(c, &userID, services.AuditEventLogin, nil) } return c.JSON(http.StatusOK, response) } // Register handles POST /api/auth/register/ func (h *AuthHandler) Register(c echo.Context) error { var req requests.RegisterRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } response, confirmationCode, err := h.authService.Register(&req) if err != nil { log.Debug().Err(err).Msg("Registration failed") return err } if h.auditService != nil { userID := response.User.ID h.auditService.LogEvent(c, &userID, services.AuditEventRegister, map[string]interface{}{ "username": req.Username, "email": req.Email, }) } // Send welcome email with confirmation code (async) if h.emailService != nil && confirmationCode != "" { go func() { defer func() { if r := recover(); r != nil { log.Error().Interface("panic", r).Str("email", req.Email).Msg("Panic in welcome email goroutine") } }() if err := h.emailService.SendWelcomeEmail(req.Email, req.FirstName, confirmationCode); err != nil { log.Error().Err(err).Str("email", req.Email).Msg("Failed to send welcome email") } }() } return c.JSON(http.StatusCreated, response) } // Logout handles POST /api/auth/logout/ func (h *AuthHandler) Logout(c echo.Context) error { token := middleware.GetAuthToken(c) if token == "" { return apperrors.Unauthorized("error.not_authenticated") } // Log audit event before invalidating the token if h.auditService != nil { user := middleware.GetAuthUser(c) if user != nil { h.auditService.LogEvent(c, &user.ID, services.AuditEventLogout, nil) } } // Invalidate token in database if err := h.authService.Logout(token); err != nil { log.Warn().Err(err).Msg("Failed to delete token from database") } // Invalidate token in cache if h.cache != nil { if err := h.cache.InvalidateAuthToken(c.Request().Context(), token); err != nil { log.Warn().Err(err).Msg("Failed to invalidate token in cache") } } return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Logged out successfully"}) } // CurrentUser handles GET /api/auth/me/ func (h *AuthHandler) CurrentUser(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } response, err := h.authService.GetCurrentUser(user.ID) if err != nil { log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to get current user") return err } return c.JSON(http.StatusOK, response) } // UpdateProfile handles PUT/PATCH /api/auth/profile/ func (h *AuthHandler) UpdateProfile(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } var req requests.UpdateProfileRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } response, err := h.authService.UpdateProfile(user.ID, &req) if err != nil { log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to update profile") return err } return c.JSON(http.StatusOK, response) } // VerifyEmail handles POST /api/auth/verify-email/ func (h *AuthHandler) VerifyEmail(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } var req requests.VerifyEmailRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } err = h.authService.VerifyEmail(user.ID, req.Code) if err != nil { log.Debug().Err(err).Uint("user_id", user.ID).Msg("Email verification failed") return err } // Send post-verification welcome email with tips (async) if h.emailService != nil { go func() { defer func() { if r := recover(); r != nil { log.Error().Interface("panic", r).Str("email", user.Email).Msg("Panic in post-verification email goroutine") } }() if err := h.emailService.SendPostVerificationEmail(user.Email, user.FirstName); err != nil { log.Error().Err(err).Str("email", user.Email).Msg("Failed to send post-verification email") } }() } return c.JSON(http.StatusOK, responses.VerifyEmailResponse{ Message: "Email verified successfully", Verified: true, }) } // ResendVerification handles POST /api/auth/resend-verification/ func (h *AuthHandler) ResendVerification(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } code, err := h.authService.ResendVerificationCode(user.ID) if err != nil { log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to resend verification") return err } // Send verification email (async) if h.emailService != nil { go func() { defer func() { if r := recover(); r != nil { log.Error().Interface("panic", r).Str("email", user.Email).Msg("Panic in verification email goroutine") } }() if err := h.emailService.SendVerificationEmail(user.Email, user.FirstName, code); err != nil { log.Error().Err(err).Str("email", user.Email).Msg("Failed to send verification email") } }() } return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Verification email sent"}) } // ForgotPassword handles POST /api/auth/forgot-password/ func (h *AuthHandler) ForgotPassword(c echo.Context) error { var req requests.ForgotPasswordRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } code, user, err := h.authService.ForgotPassword(req.Email) if err != nil { var appErr *apperrors.AppError if errors.As(err, &appErr) && appErr.Code == http.StatusTooManyRequests { // Only reveal rate limit errors return err } log.Error().Err(err).Str("email", req.Email).Msg("Forgot password failed") // Don't reveal other errors to prevent email enumeration } // Send password reset email (async) - only if user found if h.emailService != nil && code != "" && user != nil { go func() { defer func() { if r := recover(); r != nil { log.Error().Interface("panic", r).Str("email", user.Email).Msg("Panic in password reset email goroutine") } }() if err := h.emailService.SendPasswordResetEmail(user.Email, user.FirstName, code); err != nil { log.Error().Err(err).Str("email", user.Email).Msg("Failed to send password reset email") } }() } if h.auditService != nil { h.auditService.LogEvent(c, nil, services.AuditEventPasswordReset, map[string]interface{}{ "email": req.Email, }) } // Always return success to prevent email enumeration return c.JSON(http.StatusOK, responses.ForgotPasswordResponse{ Message: "Password reset email sent", }) } // VerifyResetCode handles POST /api/auth/verify-reset-code/ func (h *AuthHandler) VerifyResetCode(c echo.Context) error { var req requests.VerifyResetCodeRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } resetToken, err := h.authService.VerifyResetCode(req.Email, req.Code) if err != nil { log.Debug().Err(err).Str("email", req.Email).Msg("Verify reset code failed") return err } return c.JSON(http.StatusOK, responses.VerifyResetCodeResponse{ Message: "Reset code verified", ResetToken: resetToken, }) } // ResetPassword handles POST /api/auth/reset-password/ func (h *AuthHandler) ResetPassword(c echo.Context) error { var req requests.ResetPasswordRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } err := h.authService.ResetPassword(req.ResetToken, req.NewPassword) if err != nil { log.Debug().Err(err).Msg("Password reset failed") return err } if h.auditService != nil { h.auditService.LogEvent(c, nil, services.AuditEventPasswordChanged, map[string]interface{}{ "method": "reset_token", }) } return c.JSON(http.StatusOK, responses.ResetPasswordResponse{ Message: "Password reset successful", }) } // AppleSignIn handles POST /api/auth/apple-sign-in/ func (h *AuthHandler) AppleSignIn(c echo.Context) error { var req requests.AppleSignInRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } if h.appleAuthService == nil { log.Error().Msg("Apple auth service not configured") return &apperrors.AppError{ Code: 500, MessageKey: "error.apple_signin_not_configured", } } response, err := h.authService.AppleSignIn(c.Request().Context(), h.appleAuthService, &req) if err != nil { // Check for legacy Apple Sign In error (not yet migrated) if errors.Is(err, services.ErrAppleSignInFailed) { log.Debug().Err(err).Msg("Apple Sign In failed (legacy error)") return apperrors.Unauthorized("error.invalid_apple_token") } log.Debug().Err(err).Msg("Apple Sign In failed") return err } // Send welcome email for new users (async) if response.IsNewUser && h.emailService != nil && response.User.Email != "" { go func() { defer func() { if r := recover(); r != nil { log.Error().Interface("panic", r).Str("email", response.User.Email).Msg("Panic in Apple welcome email goroutine") } }() if err := h.emailService.SendAppleWelcomeEmail(response.User.Email, response.User.FirstName); err != nil { log.Error().Err(err).Str("email", response.User.Email).Msg("Failed to send Apple welcome email") } }() } return c.JSON(http.StatusOK, response) } // GoogleSignIn handles POST /api/auth/google-sign-in/ func (h *AuthHandler) GoogleSignIn(c echo.Context) error { var req requests.GoogleSignInRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) } if h.googleAuthService == nil { log.Error().Msg("Google auth service not configured") return &apperrors.AppError{ Code: 500, MessageKey: "error.google_signin_not_configured", } } response, err := h.authService.GoogleSignIn(c.Request().Context(), h.googleAuthService, &req) if err != nil { // Check for legacy Google Sign In error (not yet migrated) if errors.Is(err, services.ErrGoogleSignInFailed) { log.Debug().Err(err).Msg("Google Sign In failed (legacy error)") return apperrors.Unauthorized("error.invalid_google_token") } log.Debug().Err(err).Msg("Google Sign In failed") return err } // Send welcome email for new users (async) if response.IsNewUser && h.emailService != nil && response.User.Email != "" { go func() { defer func() { if r := recover(); r != nil { log.Error().Interface("panic", r).Str("email", response.User.Email).Msg("Panic in Google welcome email goroutine") } }() if err := h.emailService.SendGoogleWelcomeEmail(response.User.Email, response.User.FirstName); err != nil { log.Error().Err(err).Str("email", response.User.Email).Msg("Failed to send Google welcome email") } }() } return c.JSON(http.StatusOK, response) } // RefreshToken handles POST /api/auth/refresh/ func (h *AuthHandler) RefreshToken(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } token := middleware.GetAuthToken(c) if token == "" { return apperrors.Unauthorized("error.not_authenticated") } response, err := h.authService.RefreshToken(token, user.ID) if err != nil { log.Debug().Err(err).Uint("user_id", user.ID).Msg("Token refresh failed") return err } // If the token was refreshed (new token), invalidate the old one from cache if response.Token != token && h.cache != nil { if cacheErr := h.cache.InvalidateAuthToken(c.Request().Context(), token); cacheErr != nil { log.Warn().Err(cacheErr).Msg("Failed to invalidate old token from cache during refresh") } } return c.JSON(http.StatusOK, response) } // DeleteAccount handles DELETE /api/auth/account/ func (h *AuthHandler) DeleteAccount(c echo.Context) error { user, err := middleware.MustGetAuthUser(c) if err != nil { return err } var req requests.DeleteAccountRequest if err := c.Bind(&req); err != nil { return apperrors.BadRequest("error.invalid_request") } fileURLs, err := h.authService.DeleteAccount(user.ID, req.Password, req.Confirmation) if err != nil { log.Debug().Err(err).Uint("user_id", user.ID).Msg("Account deletion failed") return err } if h.auditService != nil { h.auditService.LogEvent(c, &user.ID, services.AuditEventAccountDeleted, map[string]interface{}{ "user_id": user.ID, "username": user.Username, "email": user.Email, }) } // Delete files from disk (best effort, don't fail the request) if h.storageService != nil && len(fileURLs) > 0 { go func() { defer func() { if r := recover(); r != nil { log.Error().Interface("panic", r).Uint("user_id", user.ID).Msg("Panic in file cleanup goroutine") } }() for _, fileURL := range fileURLs { if err := h.storageService.Delete(fileURL); err != nil { log.Warn().Err(err).Str("file_url", fileURL).Msg("Failed to delete file during account cleanup") } } }() } // Invalidate auth token from cache token := middleware.GetAuthToken(c) if h.cache != nil && token != "" { if err := h.cache.InvalidateAuthToken(c.Request().Context(), token); err != nil { log.Warn().Err(err).Msg("Failed to invalidate token in cache after account deletion") } } return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Account deleted successfully"}) }