appview/signup: set up cf turnstile #613

merged
opened by anirudh.fi targeting master from push-mozrrovxmlou

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

Changed files
+82 -7
appview
config
pages
signup
+4 -2
appview/config/config.go
···
}
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 {
+6 -2
appview/pages/pages.go
···
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 {
+1 -1
appview/pages/templates/user/login.html
···
placeholder="akshay.tngl.sh"
/>
<span class="text-sm text-gray-500 mt-1">
-
Use your <a href="https://atproto.com">ATProto</a>
+
Use your <a href="https://atproto.com">AT Protocol</a>
handle to log in. If you're unsure, this is likely
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
</span>
+6 -1
appview/pages/templates/user/signup.html
···
<script src="/static/htmx.min.js"></script>
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
<title>sign up &middot; tangled</title>
+
+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
···
invite code, desired username, and password in the next
page to complete your registration.
</span>
+
<div class="w-full mt-4">
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
+
</div>
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
<span>join now</span>
</button>
</form>
<p class="text-sm text-gray-500">
-
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
</p>
<p id="signup-msg" class="error w-full"></p>
+65 -1
appview/signup/signup.go
···
import (
"bufio"
+
"encoding/json"
+
"errors"
"fmt"
"log/slog"
"net/http"
+
"net/url"
"os"
"strings"
···
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
···
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
+
}