diff --git a/cmd/web/bootstrap/bootstrap.go b/cmd/web/bootstrap/bootstrap.go index 63402b0..e21fae1 100644 --- a/cmd/web/bootstrap/bootstrap.go +++ b/cmd/web/bootstrap/bootstrap.go @@ -5,6 +5,7 @@ import ( "time" "tm/pkg/authorization" "tm/pkg/config" + "tm/pkg/hcaptcha" "tm/pkg/logger" "tm/pkg/mongo" "tm/pkg/notification" @@ -249,3 +250,21 @@ func InitOllamaService(conf config.OllamaConfig, log logger.Logger) ollama.SDK { return *ollamaSDK } + +// InitHCaptchaService initializes the hCaptcha verifier +func InitHCaptchaService(conf config.HCaptchaConfig, log logger.Logger) hcaptcha.Verifier { + hcaptchaConfig := &hcaptcha.Config{ + SecretKey: conf.SecretKey, + VerifyURL: conf.VerifyURL, + Timeout: conf.Timeout, + } + + hcaptchaVerifier := hcaptcha.NewVerifier(hcaptchaConfig, log) + + log.Info("hCaptcha verifier initialized successfully", map[string]interface{}{ + "verify_url": conf.VerifyURL, + "timeout": conf.Timeout, + }) + + return hcaptchaVerifier +} diff --git a/cmd/web/bootstrap/config.go b/cmd/web/bootstrap/config.go index 3bdef7f..16070cf 100644 --- a/cmd/web/bootstrap/config.go +++ b/cmd/web/bootstrap/config.go @@ -15,6 +15,7 @@ type Config struct { Assets config.AssetsConfig Notification config.NotificationConfig Ollama config.OllamaConfig + HCaptcha config.HCaptchaConfig Scraper ScraperConfig UserAuth AuthConfig CustomerAuth AuthConfig diff --git a/cmd/web/main.go b/cmd/web/main.go index 12d16f3..c9a07cd 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -146,6 +146,9 @@ func main() { notificationSDK := bootstrap.InitNotificationService(conf.Notification, logger) xssPolicy := security.NewXSSPolicy() + // Initialize hCaptcha verifier + hcaptchaVerifier := bootstrap.InitHCaptchaService(conf.HCaptcha, logger) + // Initialize authorization service userAuthService := bootstrap.InitAuthorizationService(conf.UserAuth.JWT, redisClient, logger) customerAuthService := bootstrap.InitAuthorizationService(conf.CustomerAuth.JWT, redisClient, logger) @@ -195,10 +198,10 @@ func main() { tenderHandler := tender.NewHandler(tenderService, logger) feedbackHandler := feedback.NewHandler(feedbackService, userHandler, customerHandler, logger) tenderApprovalHandler := tender_approval.NewHandler(tenderApprovalService, logger) - inquiryHandler := inquiry.NewHandler(inquiryService, logger, xssPolicy) + inquiryHandler := inquiry.NewHandler(inquiryService, logger, hcaptchaVerifier, xssPolicy) flagHandler := assets.NewHandler(flagService, logger) notificationHandler := notification.NewHandler(notificationService) - contactHandler := contact.NewHandler(contactService) + contactHandler := contact.NewHandler(contactService, hcaptchaVerifier) cmsHandler := cms.NewHandler(cmsService) scraperHandler := scraper.NewHandler(scraperService) logger.Info("Handlers initialized successfully", map[string]interface{}{ diff --git a/internal/contact/form.go b/internal/contact/form.go index cb8b0d4..04c8595 100644 --- a/internal/contact/form.go +++ b/internal/contact/form.go @@ -4,10 +4,11 @@ import "tm/pkg/response" // CreateContactForm represents the form for creating a new contact message type CreateContactForm struct { - FullName string `json:"full_name" valid:"required,length(2|100)" example:"John Doe"` - Email string `json:"email" valid:"required,email" example:"john.doe@example.com"` - Phone string `json:"phone" valid:"required,length(10|20)" example:"+1234567890"` - Message string `json:"message" valid:"required,length(10|1000)" example:"I have a question about your services"` + FullName string `json:"full_name" valid:"required,length(2|100)" example:"John Doe"` + Email string `json:"email" valid:"required,email" example:"john.doe@example.com"` + Phone string `json:"phone" valid:"required,length(10|20)" example:"+1234567890"` + Message string `json:"message" valid:"required,length(10|1000)" example:"I have a question about your services"` + HCaptchaToken string `json:"hcaptcha_token" valid:"required" example:"P0_eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."` } // UpdateContactStatusForm represents the form for updating contact status (admin only) diff --git a/internal/contact/handler.go b/internal/contact/handler.go index 1f88d47..a998891 100644 --- a/internal/contact/handler.go +++ b/internal/contact/handler.go @@ -1,6 +1,7 @@ package contact import ( + "tm/pkg/hcaptcha" "tm/pkg/response" "github.com/labstack/echo/v4" @@ -8,13 +9,15 @@ import ( // Handler handles HTTP requests for contact operations type Handler struct { - service Service + service Service + hcaptchaVerifier hcaptcha.Verifier } // NewHandler creates a new contact handler -func NewHandler(service Service) *Handler { +func NewHandler(service Service, hcaptchaVerifier hcaptcha.Verifier) *Handler { return &Handler{ - service: service, + service: service, + hcaptchaVerifier: hcaptchaVerifier, } } @@ -35,6 +38,17 @@ func (h *Handler) Create(c echo.Context) error { return response.ValidationError(c, "Invalid request data", err.Error()) } + // Verify hCaptcha token + remoteIP := c.RealIP() + verificationResponse, err := h.hcaptchaVerifier.Verify(form.HCaptchaToken, remoteIP) + if err != nil { + return response.BadRequest(c, "Failed to verify hCaptcha token", "") + } + + if !verificationResponse.Success { + return response.BadRequest(c, "hCaptcha verification failed", "") + } + // Create contact contact, err := h.service.Create(c.Request().Context(), form) if err != nil { diff --git a/internal/inquiry/form.go b/internal/inquiry/form.go index e986574..80795ee 100644 --- a/internal/inquiry/form.go +++ b/internal/inquiry/form.go @@ -11,10 +11,11 @@ import ( // CreateInquiryForm represents the data required to create a new inquiry type CreateInquiryForm struct { - FullName string `json:"full_name" valid:"required,length(2|100)" example:"John Doe"` // Full name of the inquirer - CompanyName string `json:"company_name" valid:"required,length(2|100)" example:"Opplens"` // Company name - WorkEmail string `json:"work_email" valid:"required,email" example:"info@opplens.com"` // Work email address - PhoneNumber string `json:"phone_number" valid:"required,length(10|20)" example:"+1234567890"` // Phone number + FullName string `json:"full_name" valid:"required,length(2|100)" example:"John Doe"` // Full name of the inquirer + CompanyName string `json:"company_name" valid:"required,length(2|100)" example:"Opplens"` // Company name + WorkEmail string `json:"work_email" valid:"required,email" example:"info@opplens.com"` // Work email address + PhoneNumber string `json:"phone_number" valid:"required,length(10|20)" example:"+1234567890"` // Phone number + HCaptchaToken string `json:"hcaptcha_token" valid:"required" example:"P0_eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..."` } // UpdateInquiryStatusForm represents the data required to update inquiry status diff --git a/internal/inquiry/handler.go b/internal/inquiry/handler.go index 152cca5..718259d 100644 --- a/internal/inquiry/handler.go +++ b/internal/inquiry/handler.go @@ -2,6 +2,7 @@ package inquiry import ( "encoding/json" + "tm/pkg/hcaptcha" "tm/pkg/logger" "tm/pkg/response" "tm/pkg/security" @@ -11,17 +12,19 @@ import ( // Handler handles HTTP requests for inquiry operations type Handler struct { - service Service - logger logger.Logger - xssPolicy *security.XSSPolicy + service Service + logger logger.Logger + hcaptchaVerifier hcaptcha.Verifier + xssPolicy *security.XSSPolicy } // NewInquiryHandler creates a new inquiry handler -func NewHandler(service Service, logger logger.Logger, xssPolicy *security.XSSPolicy) *Handler { +func NewHandler(service Service, logger logger.Logger, hcaptchaVerifier hcaptcha.Verifier, xssPolicy *security.XSSPolicy) *Handler { return &Handler{ - service: service, - logger: logger, - xssPolicy: xssPolicy, + service: service, + logger: logger, + hcaptchaVerifier: hcaptchaVerifier, + xssPolicy: xssPolicy, } } @@ -49,6 +52,17 @@ func (h *Handler) CreateInquiry(c echo.Context) error { return response.ValidationError(c, err.Error(), "") } + // Verify hCaptcha token + remoteIP := c.RealIP() + verificationResponse, err := h.hcaptchaVerifier.Verify(form.HCaptchaToken, remoteIP) + if err != nil { + return response.BadRequest(c, "Failed to verify hCaptcha token", "") + } + + if !verificationResponse.Success { + return response.BadRequest(c, "hCaptcha verification failed", "") + } + // Call service inquiry, err := h.service.Create(c.Request().Context(), form) if err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index e785796..d328f11 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -89,6 +89,12 @@ type OllamaConfig struct { EnableStreaming bool `env:"OLLAMA_ENABLE_STREAMING" default:"false"` } +type HCaptchaConfig struct { + SecretKey string `env:"HCAPTCHA_SECRET_KEY"` + VerifyURL string `env:"HCAPTCHA_VERIFY_URL"` + Timeout time.Duration `env:"HCAPTCHA_TIMEOUT"` +} + // LoadConfig loads configuration with priority: OS environment variables > .env file // OS environment variables take precedence over .env file values func LoadConfig[T any](path string, config T) (T, error) { diff --git a/pkg/hcaptcha/hcaptcha.go b/pkg/hcaptcha/hcaptcha.go new file mode 100644 index 0000000..2f1ff46 --- /dev/null +++ b/pkg/hcaptcha/hcaptcha.go @@ -0,0 +1,145 @@ +package hcaptcha + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "tm/pkg/logger" +) + +const ( + // DefaultVerifyURL is the default hCaptcha verification endpoint + DefaultVerifyURL = "https://hcaptcha.com/siteverify" + // DefaultTimeout is the default timeout for hCaptcha verification requests + DefaultTimeout = 10 * time.Second +) + +// Verifier handles hCaptcha token verification +type Verifier interface { + Verify(token string, remoteIP string) (*VerificationResponse, error) +} + +// Config holds configuration for hCaptcha verification +type Config struct { + SecretKey string `env:"HCAPTCHA_SECRET_KEY"` + VerifyURL string `env:"HCAPTCHA_VERIFY_URL"` + Timeout time.Duration `env:"HCAPTCHA_TIMEOUT"` +} + +// VerificationResponse represents the response from hCaptcha verification API +type VerificationResponse struct { + Success bool `json:"success"` + ChallengeTS string `json:"challenge_ts,omitempty"` + Hostname string `json:"hostname,omitempty"` + Credit bool `json:"credit,omitempty"` + ErrorCodes []string `json:"error-codes,omitempty"` +} + +// verifier implements the Verifier interface +type verifier struct { + secretKey string + verifyURL string + timeout time.Duration + logger logger.Logger + client *http.Client +} + +// NewVerifier creates a new hCaptcha verifier +func NewVerifier(config *Config, log logger.Logger) Verifier { + verifyURL := config.VerifyURL + if verifyURL == "" { + verifyURL = DefaultVerifyURL + } + + timeout := config.Timeout + if timeout == 0 { + timeout = DefaultTimeout + } + + return &verifier{ + secretKey: config.SecretKey, + verifyURL: verifyURL, + timeout: timeout, + logger: log, + client: &http.Client{ + Timeout: timeout, + }, + } +} + +// Verify verifies an hCaptcha token with the hCaptcha API +func (v *verifier) Verify(token string, remoteIP string) (*VerificationResponse, error) { + if token == "" { + return &VerificationResponse{ + Success: false, + ErrorCodes: []string{"missing-input-response"}, + }, fmt.Errorf("hCaptcha token is required") + } + + // Prepare form data + data := url.Values{} + data.Set("secret", v.secretKey) + data.Set("response", token) + if remoteIP != "" { + data.Set("remoteip", remoteIP) + } + + // Create request with form data in body + req, err := http.NewRequest("POST", v.verifyURL, strings.NewReader(data.Encode())) + if err != nil { + v.logger.Error("Failed to create hCaptcha verification request", map[string]interface{}{ + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to create verification request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Make request + resp, err := v.client.Do(req) + if err != nil { + v.logger.Error("Failed to verify hCaptcha token", map[string]interface{}{ + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to verify token: %w", err) + } + defer resp.Body.Close() + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + v.logger.Error("Failed to read hCaptcha verification response", map[string]interface{}{ + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to read verification response: %w", err) + } + + // Parse response + var verificationResponse VerificationResponse + if err := json.Unmarshal(body, &verificationResponse); err != nil { + v.logger.Error("Failed to parse hCaptcha verification response", map[string]interface{}{ + "error": err.Error(), + "body": string(body), + "status": resp.StatusCode, + }) + return nil, fmt.Errorf("failed to parse verification response: %w", err) + } + + // Log verification result + if verificationResponse.Success { + v.logger.Debug("hCaptcha token verified successfully", map[string]interface{}{ + "hostname": verificationResponse.Hostname, + }) + } else { + v.logger.Warn("hCaptcha token verification failed", map[string]interface{}{ + "error_codes": verificationResponse.ErrorCodes, + }) + } + + return &verificationResponse, nil +}