e881d37de0
Every public method on these five services now takes ctx context.Context as the first arg and routes its repo calls through .WithContext(ctx). With TaskService and ResidenceService already migrated, this means every in-process service that touches Postgres now produces a flame graph in Jaeger where the SQL spans nest under the parent HTTP request span. Endpoints now fully traced (HTTP → service → SQL): - /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification - /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile - /api/contractors/* (CRUD + favorite + by-residence + tasks) - /api/documents/* (CRUD + activate/deactivate + image upload/delete) - /api/notifications/* (list, count, mark-read, prefs, devices) - /api/subscription/* (status, purchase, cancel, triggers, promotions) - All previously-migrated /api/tasks/* and /api/residences/* paths Internal helpers also threaded: - TaskService.sendTaskCompletedNotification → forwards ctx - TaskService.UpdateUserTimezone → forwards ctx to NotificationService - ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit - NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx ~75 method signatures, ~120 handler/test call sites updated. Tests pass green; the only failure is the pre-existing flaky TaskHandler_QuickComplete SQLite race that fails ~60% of runs on master. Step 3 of the observability plan is now genuinely complete: every API endpoint backed by a Go service emits a per-request flame graph with HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
540 lines
17 KiB
Go
540 lines
17 KiB
Go
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(c.Request().Context(), &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(c.Request().Context(), &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(c.Request().Context(), 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(c.Request().Context(), 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(c.Request().Context(), 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(c.Request().Context(), 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(c.Request().Context(), 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(c.Request().Context(), 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(c.Request().Context(), 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(c.Request().Context(), 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(c.Request().Context(), 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(c.Request().Context(), 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"})
|
|
}
|