Monorepo for Tangled tangled.org

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 <anirudh@tangled.org>

anirudh.fi 214dc688 4805af2f

verified
Changed files
+82 -7
appview
config
pages
signup
+4 -2
appview/config/config.go
··· 72 72 } 73 73 74 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 78 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 77 79 } 78 80 79 81 func (cfg RedisConfig) ToURL() string {
+6 -2
appview/pages/pages.go
··· 226 226 return p.executePlain("user/login", w, params) 227 227 } 228 228 229 - func (p *Pages) Signup(w io.Writer) error { 230 - return p.executePlain("user/signup", w, nil) 229 + type SignupParams struct { 230 + CloudflareSiteKey string 231 + } 232 + 233 + func (p *Pages) Signup(w io.Writer, params SignupParams) error { 234 + return p.executePlain("user/signup", w, params) 231 235 } 232 236 233 237 func (p *Pages) CompleteSignup(w io.Writer) error {
+1 -1
appview/pages/templates/user/login.html
··· 36 36 placeholder="akshay.tngl.sh" 37 37 /> 38 38 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 39 + Use your <a href="https://atproto.com">AT Protocol</a> 40 40 handle to log in. If you're unsure, this is likely 41 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 42 </span>
+6 -1
appview/pages/templates/user/signup.html
··· 10 10 <script src="/static/htmx.min.js"></script> 11 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 12 <title>sign up &middot; tangled</title> 13 + 14 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 13 15 </head> 14 16 <body class="flex items-center justify-center min-h-screen"> 15 17 <main class="max-w-md px-6 -mt-4"> ··· 39 41 invite code, desired username, and password in the next 40 42 page to complete your registration. 41 43 </span> 44 + <div class="w-full mt-4"> 45 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 46 + </div> 42 47 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 48 <span>join now</span> 44 49 </button> 45 50 </form> 46 51 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 52 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 48 53 </p> 49 54 50 55 <p id="signup-msg" class="error w-full"></p>
+65 -1
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "encoding/json" 6 + "errors" 5 7 "fmt" 6 8 "log/slog" 7 9 "net/http" 10 + "net/url" 8 11 "os" 9 12 "strings" 10 13 ··· 116 119 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 117 120 switch r.Method { 118 121 case http.MethodGet: 119 - s.pages.Signup(w) 122 + s.pages.Signup(w, pages.SignupParams{ 123 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 124 + }) 120 125 case http.MethodPost: 121 126 if s.cf == nil { 122 127 http.Error(w, "signup is disabled", http.StatusFailedDependency) 128 + return 123 129 } 124 130 emailId := r.FormValue("email") 131 + cfToken := r.FormValue("cf-turnstile-response") 125 132 126 133 noticeId := "signup-msg" 134 + 135 + if err := s.validateCaptcha(cfToken, r); err != nil { 136 + s.l.Warn("turnstile validation failed", "error", err) 137 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 138 + return 139 + } 140 + 127 141 if !email.IsValidEmail(emailId) { 128 142 s.pages.Notice(w, noticeId, "Invalid email address.") 129 143 return ··· 255 269 return 256 270 } 257 271 } 272 + 273 + type turnstileResponse struct { 274 + Success bool `json:"success"` 275 + ErrorCodes []string `json:"error-codes,omitempty"` 276 + ChallengeTs string `json:"challenge_ts,omitempty"` 277 + Hostname string `json:"hostname,omitempty"` 278 + } 279 + 280 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 281 + if cfToken == "" { 282 + return errors.New("captcha token is empty") 283 + } 284 + 285 + if s.config.Cloudflare.TurnstileSecretKey == "" { 286 + return errors.New("turnstile secret key not configured") 287 + } 288 + 289 + data := url.Values{} 290 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 291 + data.Set("response", cfToken) 292 + 293 + // include the client IP if we have it 294 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 295 + data.Set("remoteip", remoteIP) 296 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 297 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 298 + data.Set("remoteip", strings.TrimSpace(ips[0])) 299 + } 300 + } else { 301 + data.Set("remoteip", r.RemoteAddr) 302 + } 303 + 304 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 305 + if err != nil { 306 + return fmt.Errorf("failed to verify turnstile token: %w", err) 307 + } 308 + defer resp.Body.Close() 309 + 310 + var turnstileResp turnstileResponse 311 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 312 + return fmt.Errorf("failed to decode turnstile response: %w", err) 313 + } 314 + 315 + if !turnstileResp.Success { 316 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 317 + return errors.New("turnstile validation failed") 318 + } 319 + 320 + return nil 321 + }