From e4dba60fcbeb4b29e7d1f2f6d1c28240289f9bd0 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Mon, 29 Sep 2025 14:01:13 +0000 Subject: [PATCH] appview/signup: set up cf turnstile Sets up Cloudflare Turnstile for fairly non-intrusive captcha. The client token is verified with CF when the user hits 'join now' (POST /signup), so this should prevent bot signups. Signed-off-by: Anirudh Oppiliappan --- appview/config/config.go | 6 ++++-- appview/pages/pages.go | 8 ++++++-- appview/signup/signup.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- appview/pages/templates/user/login.html | 2 +- appview/pages/templates/user/signup.html | 7 ++++++- 5 file(s) changed, 82 insertion(s)(+), 7 deletion(s)(-) diff --git a/appview/config/config.go b/appview/config/config.go --- a/appview/config/config.go +++ b/appview/config/config.go @@ -72,8 +72,10 @@ } type Cloudflare struct { - ApiToken string `env:"API_TOKEN"` - ZoneId string `env:"ZONE_ID"` + ApiToken string `env:"API_TOKEN"` + ZoneId string `env:"ZONE_ID"` + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` } func (cfg RedisConfig) ToURL() string { diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -226,8 +226,12 @@ return p.executePlain("user/login", w, params) } -func (p *Pages) Signup(w io.Writer) error { - return p.executePlain("user/signup", w, nil) +type SignupParams struct { + CloudflareSiteKey string +} + +func (p *Pages) Signup(w io.Writer, params SignupParams) error { + return p.executePlain("user/signup", w, params) } func (p *Pages) CompleteSignup(w io.Writer) error { diff --git a/appview/signup/signup.go b/appview/signup/signup.go --- a/appview/signup/signup.go +++ b/appview/signup/signup.go @@ -2,9 +2,12 @@ import ( "bufio" + "encoding/json" + "errors" "fmt" "log/slog" "net/http" + "net/url" "os" "strings" @@ -116,14 +119,25 @@ func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - s.pages.Signup(w) + s.pages.Signup(w, pages.SignupParams{ + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, + }) case http.MethodPost: if s.cf == nil { http.Error(w, "signup is disabled", http.StatusFailedDependency) + return } emailId := r.FormValue("email") + cfToken := r.FormValue("cf-turnstile-response") noticeId := "signup-msg" + + if err := s.validateCaptcha(cfToken, r); err != nil { + s.l.Warn("turnstile validation failed", "error", err) + s.pages.Notice(w, noticeId, "Captcha validation failed.") + return + } + if !email.IsValidEmail(emailId) { s.pages.Notice(w, noticeId, "Invalid email address.") return @@ -254,4 +268,54 @@ }() return } +} + +type turnstileResponse struct { + Success bool `json:"success"` + ErrorCodes []string `json:"error-codes,omitempty"` + ChallengeTs string `json:"challenge_ts,omitempty"` + Hostname string `json:"hostname,omitempty"` +} + +func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { + if cfToken == "" { + return errors.New("captcha token is empty") + } + + if s.config.Cloudflare.TurnstileSecretKey == "" { + return errors.New("turnstile secret key not configured") + } + + data := url.Values{} + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) + data.Set("response", cfToken) + + // include the client IP if we have it + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { + data.Set("remoteip", remoteIP) + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { + data.Set("remoteip", strings.TrimSpace(ips[0])) + } + } else { + data.Set("remoteip", r.RemoteAddr) + } + + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) + if err != nil { + return fmt.Errorf("failed to verify turnstile token: %w", err) + } + defer resp.Body.Close() + + var turnstileResp turnstileResponse + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { + return fmt.Errorf("failed to decode turnstile response: %w", err) + } + + if !turnstileResp.Success { + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) + return errors.New("turnstile validation failed") + } + + return nil } diff --git a/appview/pages/templates/user/login.html b/appview/pages/templates/user/login.html --- a/appview/pages/templates/user/login.html +++ b/appview/pages/templates/user/login.html @@ -36,7 +36,7 @@ placeholder="akshay.tngl.sh" /> - Use your ATProto + Use your AT Protocol handle to log in. If you're unsure, this is likely your Tangled (.tngl.sh) or Bluesky (.bsky.social) account. diff --git a/appview/pages/templates/user/signup.html b/appview/pages/templates/user/signup.html --- a/appview/pages/templates/user/signup.html +++ b/appview/pages/templates/user/signup.html @@ -10,6 +10,8 @@ sign up · tangled + +
@@ -39,12 +41,15 @@ invite code, desired username, and password in the next page to complete your registration. +
+
+

- Already have an ATProto account? Login to Tangled. + Already have an AT Protocol account? Login to Tangled.

-- tangled.sh