mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 08:01:16 +00:00
143 lines
3.2 KiB
Go
143 lines
3.2 KiB
Go
package captcha
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
)
|
|
|
|
type TurnstileConfig struct {
|
|
Secret string
|
|
SiteVerifyURL string
|
|
HTTPClient *http.Client
|
|
ExpectedHost string // optional: verify hostname
|
|
BypassToken string // optional: local/dev bypass token
|
|
}
|
|
|
|
type Response struct {
|
|
Success bool `json:"success"`
|
|
ChallengeTs string `json:"challenge_ts,omitempty"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
ErrorCodes []string `json:"error-codes,omitempty"`
|
|
Action string `json:"action,omitempty"`
|
|
CData string `json:"cdata,omitempty"`
|
|
}
|
|
|
|
type Turnstile struct {
|
|
cfg TurnstileConfig
|
|
}
|
|
|
|
const CloudflareTurnstileVerifyEndpoint = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
|
|
|
func NewTurnstile(secret string) *Turnstile {
|
|
return NewTurnstileFromConfig(TurnstileConfig{
|
|
Secret: secret,
|
|
SiteVerifyURL: CloudflareTurnstileVerifyEndpoint,
|
|
HTTPClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
})
|
|
}
|
|
|
|
func NewTurnstileFromConfig(cfg TurnstileConfig) *Turnstile {
|
|
if cfg.HTTPClient == nil {
|
|
cfg.HTTPClient = &http.Client{Timeout: 10 * time.Second}
|
|
}
|
|
if cfg.SiteVerifyURL == "" {
|
|
cfg.SiteVerifyURL = CloudflareTurnstileVerifyEndpoint
|
|
}
|
|
return &Turnstile{cfg: cfg}
|
|
}
|
|
|
|
func (t *Turnstile) Verify(ctx context.Context, token, remoteIP string) *errx.Error {
|
|
if t.cfg.BypassToken != "" && token == t.cfg.BypassToken {
|
|
return nil
|
|
}
|
|
|
|
if token == "" {
|
|
return errx.ErrCaptcha
|
|
}
|
|
|
|
data := url.Values{
|
|
"secret": {t.cfg.Secret},
|
|
"response": {token},
|
|
}
|
|
if remoteIP != "" {
|
|
if net.ParseIP(remoteIP) == nil {
|
|
return errx.ErrCaptcha
|
|
}
|
|
data.Set("remoteip", remoteIP)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
t.cfg.SiteVerifyURL,
|
|
strings.NewReader(data.Encode()),
|
|
)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := t.cfg.HTTPClient.Do(req)
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<12)) // 4KB max
|
|
if readErr != nil {
|
|
sentry.CaptureException(readErr)
|
|
}
|
|
sentry.CaptureException(fmt.Errorf("turnstile verify http status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))))
|
|
return errx.ErrCaptcha
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MB max
|
|
if err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
|
|
var r Response
|
|
if err := json.Unmarshal(body, &r); err != nil {
|
|
sentry.CaptureException(err)
|
|
return errx.InternalError()
|
|
}
|
|
|
|
if !r.Success {
|
|
return errx.ErrCaptcha
|
|
}
|
|
|
|
// Optional: verify hostname
|
|
if t.cfg.ExpectedHost != "" && r.Hostname != t.cfg.ExpectedHost {
|
|
return errx.ErrCaptcha
|
|
}
|
|
|
|
// Optional: verify timestamp is recent (prevent replay)
|
|
if r.ChallengeTs != "" {
|
|
ts, err := time.Parse(time.RFC3339, r.ChallengeTs)
|
|
if err != nil {
|
|
return errx.ErrCaptcha
|
|
}
|
|
if time.Since(ts) > 5*time.Minute {
|
|
return errx.ErrCaptcha
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|