c77ff07ce9
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps), tracked in deploy-k3s/SECURITY.md, plus fixes from two independent post-remediation reviews. Auth & sessions: - SHA-256 hashed auth-token storage (C1); prior-token cache eviction on re-login (MEDIUM-1) - local Google JWKS verification, iss/aud/exp checks (C2/C3) - constant-time login + generic errors (L1/LIVE-L11/LIVE-L13) - per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3) - verified-email gating, login rate limiting (LIVE-L19, H1-H3) IAP & webhooks: - Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6) - migrations 000003-000006 (token hashing, IAP replay, audit_log + webhook_event_log table creation, append-only audit log) Authorization & races: - file-ownership owner-OR-member fix (C7), atomic share-code join (C9/H9), device-token reassignment (C8/LOW-3) Secrets & deploy: - secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis password out of the ConfigMap (HIGH-1); B2 keys reconciled - digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban + unattended-upgrades at provision; secret-rotation runbook Build, vet, and the full test suite (incl. -race) pass; the goose migration chain is verified against PostgreSQL 16. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
557 lines
17 KiB
Go
557 lines
17 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
}
|
|
|
|
// noStore marks a response as non-cacheable (audit L2) — auth responses
|
|
// carry tokens and user data that must never sit in any cache.
|
|
func noStore(c echo.Context) {
|
|
c.Response().Header().Set("Cache-Control", "no-store")
|
|
}
|
|
|
|
// Login handles POST /api/auth/login/
|
|
func (h *AuthHandler) Login(c echo.Context) error {
|
|
noStore(c)
|
|
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, c.RealIP())
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("identifier", req.Username).
|
|
Str("ip", c.RealIP()).Str("user_agent", c.Request().UserAgent()).
|
|
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 {
|
|
noStore(c)
|
|
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 {
|
|
noStore(c)
|
|
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))
|
|
}
|
|
|
|
noStore(c)
|
|
|
|
if h.auditService != nil {
|
|
h.auditService.LogEvent(c, nil, services.AuditEventPasswordReset, map[string]interface{}{
|
|
"email": req.Email,
|
|
})
|
|
}
|
|
|
|
// Audit LIVE-L13: run the user lookup, code generation, and email send
|
|
// entirely in the background, then return the generic response
|
|
// immediately. This makes the response time identical whether or not
|
|
// the email belongs to a real account, defeating timing-based user
|
|
// enumeration. context.Background() is used because the request context
|
|
// is cancelled the moment this handler returns. Per-account rate
|
|
// limiting still runs inside the service; the edge auth-rate-limit
|
|
// middleware covers per-IP abuse.
|
|
email := req.Email
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Error().Interface("panic", r).Str("email", email).Msg("Panic in forgot-password goroutine")
|
|
}
|
|
}()
|
|
code, user, err := h.authService.ForgotPassword(context.Background(), email)
|
|
if err != nil || code == "" || user == nil {
|
|
return
|
|
}
|
|
if h.emailService != nil {
|
|
if sendErr := h.emailService.SendPasswordResetEmail(user.Email, user.FirstName, code); sendErr != nil {
|
|
log.Error().Err(sendErr).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(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 {
|
|
noStore(c)
|
|
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 {
|
|
noStore(c)
|
|
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 {
|
|
noStore(c)
|
|
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"})
|
|
}
|