Comprehensive security hardening from audit findings: - Add validation tags to all DTO request structs (max lengths, ranges, enums) - Replace unsafe type assertions with MustGetAuthUser helper across all handlers - Remove query-param token auth from admin middleware (prevents URL token leakage) - Add request validation calls in handlers that were missing c.Validate() - Remove goroutines in handlers (timezone update now synchronous) - Add sanitize middleware and path traversal protection (path_utils) - Stop resetting admin passwords on migration restart - Warn on well-known default SECRET_KEY - Add ~30 new test files covering security regressions, auth safety, repos, and services - Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
409 lines
13 KiB
Go
409 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/treytartt/casera-api/internal/apperrors"
|
|
"github.com/treytartt/casera-api/internal/dto/requests"
|
|
"github.com/treytartt/casera-api/internal/dto/responses"
|
|
"github.com/treytartt/casera-api/internal/middleware"
|
|
"github.com/treytartt/casera-api/internal/services"
|
|
"github.com/treytartt/casera-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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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")
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|