From 214dc688ec8cccc0b58c4418c629ce16954d8442 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Mon, 29 Sep 2025 17:01:13 +0300 Subject: [PATCH] appview/signup: set up cf turnstile Change-Id: mozrrovxmlouuupynynrxspvvxuxxzvq 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/pages/templates/user/login.html | 2 +- appview/pages/templates/user/signup.html | 7 ++- appview/signup/signup.go | 66 +++++++++++++++++++++++- 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/appview/config/config.go b/appview/config/config.go index aed054b9..b2d21fb0 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -72,8 +72,10 @@ type PdsConfig struct { } 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 index aea80769..6825dc9f 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -226,8 +226,12 @@ func (p *Pages) Login(w io.Writer, params LoginParams) error { 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/pages/templates/user/login.html b/appview/pages/templates/user/login.html index be2bbe62..ebd3bafe 100644 --- 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 index cde92862..acd77e9a 100644 --- 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.

diff --git a/appview/signup/signup.go b/appview/signup/signup.go index 7a781aa7..f7a3e78f 100644 --- a/appview/signup/signup.go +++ b/appview/signup/signup.go @@ -2,9 +2,12 @@ package signup import ( "bufio" + "encoding/json" + "errors" "fmt" "log/slog" "net/http" + "net/url" "os" "strings" @@ -116,14 +119,25 @@ func (s *Signup) Router() http.Handler { 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 @@ -255,3 +269,53 @@ func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 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 +} -- 2.43.0