From af76c251c6bc90f746fc7a912dbb1d8249623ba3 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 19 May 2024 18:33:28 -0700 Subject: [PATCH] Cloudflare CAPTCHA for account signup page --- pkg/config/variable.go | 15 ++++-- pkg/controller/account/signup.go | 77 ++++++++++++++++++++++--------- pkg/mail/mail.go | 19 ++++++++ pkg/spam/captcha.go | 73 +++++++++++++++++++++++++++++ pkg/templates/template_vars.go | 3 ++ web/static/css/theme.css | 4 ++ web/templates/account/signup.html | 36 +++++++++++++++ web/templates/base.html | 2 + 8 files changed, 202 insertions(+), 27 deletions(-) create mode 100644 pkg/spam/captcha.go diff --git a/pkg/config/variable.go b/pkg/config/variable.go index 7f4ebe4..81e6007 100644 --- a/pkg/config/variable.go +++ b/pkg/config/variable.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" "os" "code.nonshy.com/nonshy/website/pkg/encryption/keygen" @@ -14,7 +13,7 @@ import ( // Version of the config format - when new fields are added, it will attempt // to write the settings.toml to disk so new defaults populate. -var currentVersion = 2 +var currentVersion = 3 // Current loaded settings.json var Current = DefaultVariable() @@ -31,6 +30,7 @@ type Variable struct { BareRTC BareRTC Maintenance Maintenance Encryption Encryption + Turnstile Turnstile UseXForwardedFor bool } @@ -62,7 +62,7 @@ func LoadSettings() { if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) { log.Info("Loading settings from %s", SettingsPath) - content, err := ioutil.ReadFile(SettingsPath) + content, err := os.ReadFile(SettingsPath) if err != nil { panic(fmt.Sprintf("LoadSettings: couldn't read settings.json: %s", err)) } @@ -119,7 +119,7 @@ func WriteSettings() error { panic(fmt.Sprintf("WriteSettings: couldn't marshal settings: %s", err)) } - return ioutil.WriteFile(SettingsPath, buf.Bytes(), 0600) + return os.WriteFile(SettingsPath, buf.Bytes(), 0600) } // Mail settings. @@ -165,3 +165,10 @@ type Maintenance struct { type Encryption struct { AESKey []byte } + +// Turnstile (Cloudflare CAPTCHA) settings. +type Turnstile struct { + Enabled bool + SiteKey string + SecretKey string +} diff --git a/pkg/controller/account/signup.go b/pkg/controller/account/signup.go index ea320bc..27d4e5b 100644 --- a/pkg/controller/account/signup.go +++ b/pkg/controller/account/signup.go @@ -14,6 +14,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/redis" "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/spam" "code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/utility" "github.com/google/uuid" @@ -71,7 +72,6 @@ func Signup() http.HandlerFunc { vars["SignupToken"] = tokenStr vars["Email"] = token.Email } - log.Info("Vars: %+v", vars) // Posting? if r.Method == http.MethodPost { @@ -86,10 +86,33 @@ func Signup() http.HandlerFunc { password2 = strings.TrimSpace(r.PostFormValue("password2")) dob = r.PostFormValue("dob") + // CAPTCHA response. + turnstileCAPTCHA = r.PostFormValue("cf-turnstile-response") + + // Honeytrap fields for lazy spam bots. + honeytrap1 = r.PostFormValue("phone") == "" + honeytrap2 = r.PostFormValue("referral") == "Word of mouth" + // Validation errors but still show the form again. hasError bool ) + // Honeytrap fields check. + if !honeytrap1 || !honeytrap2 { + session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email) + templates.Redirect(w, r.URL.Path) + return + } + + // Validate the CAPTCHA token. + if config.Current.Turnstile.Enabled { + if err := spam.ValidateTurnstileCAPTCHA(turnstileCAPTCHA, "signup"); err != nil { + session.FlashError(w, r, "There was an error validating your CAPTCHA response.") + templates.Redirect(w, r.URL.Path) + return + } + } + // Don't let them sneakily change their verified email address on us. if vars["SignupToken"] != "" && email != vars["Email"] { session.FlashError(w, r, "This email address is not verified. Please start over from the beginning.") @@ -119,17 +142,21 @@ func Signup() http.HandlerFunc { if _, err := models.FindUser(email); err == nil { // We don't want to admit that the email already is registered, so send an email to the // address in case the user legitimately forgot, but flash the regular success message. - err := mail.Send(mail.Message{ - To: email, - Subject: "You already have a nonshy account", - Template: "email/already_signed_up.html", - Data: map[string]interface{}{ - "Title": config.Title, - "URL": config.Current.BaseURL + "/forgot-password", - }, - }) - if err != nil { - session.FlashError(w, r, "Error sending an email: %s", err) + if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil { + err := mail.Send(mail.Message{ + To: email, + Subject: "You already have a nonshy account", + Template: "email/already_signed_up.html", + Data: map[string]interface{}{ + "Title": config.Title, + "URL": config.Current.BaseURL + "/forgot-password", + }, + }) + if err != nil { + session.FlashError(w, r, "Error sending an email: %s", err) + } + } else { + log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email) } session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email) @@ -158,17 +185,21 @@ func Signup() http.HandlerFunc { return } - err := mail.Send(mail.Message{ - To: email, - Subject: "Verify your e-mail address", - Template: "email/verify_email.html", - Data: map[string]interface{}{ - "Title": config.Title, - "URL": config.Current.BaseURL + "/signup?token=" + token.Token, - }, - }) - if err != nil { - session.FlashError(w, r, "Error sending an email: %s", err) + if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil { + err := mail.Send(mail.Message{ + To: email, + Subject: "Verify your e-mail address", + Template: "email/verify_email.html", + Data: map[string]interface{}{ + "Title": config.Title, + "URL": config.Current.BaseURL + "/signup?token=" + token.Token, + }, + }) + if err != nil { + session.FlashError(w, r, "Error sending an email: %s", err) + } + } else { + log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email) } session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email) diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go index 2606b21..b8cb909 100644 --- a/pkg/mail/mail.go +++ b/pkg/mail/mail.go @@ -7,9 +7,12 @@ import ( "fmt" "html/template" "strings" + "time" "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/encryption" "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/redis" "github.com/microcosm-cc/bluemonday" "gopkg.in/gomail.v2" ) @@ -23,6 +26,22 @@ type Message struct { Data map[string]interface{} } +// LockSending emails to the same address within 24 hours, e.g.: on the signup form to reduce chance for spam abuse. +// +// Call this before calling Send() if you want to throttle the sending. This function will put a key in Redis on +// the first call and return nil; on subsequent calls, if the key still remains, it will return an error. +func LockSending(namespace, email string, expires time.Duration) error { + var key = fmt.Sprintf("mail/lock-sending/%s/%s", namespace, encryption.Hash([]byte(email))) + + // See if we have already locked it. + if redis.Exists(key) { + return errors.New("email was in the lock-sending queue") + } + + redis.Set(key, email, expires) + return nil +} + // Send an email. func Send(msg Message) error { conf := config.Current.Mail diff --git a/pkg/spam/captcha.go b/pkg/spam/captcha.go new file mode 100644 index 0000000..5d8f16b --- /dev/null +++ b/pkg/spam/captcha.go @@ -0,0 +1,73 @@ +package spam + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/log" +) + +// ValidateTurnstileCAPTCHA tests a Cloudflare Turnstile CAPTCHA token. +func ValidateTurnstileCAPTCHA(token, actionName string) error { + if !config.Current.Turnstile.Enabled { + return errors.New("Cloudflare Turnstile CAPTCHA is not enabled in the server settings") + } + + // Prepare the request. + form := url.Values{} + form.Add("secret", config.Current.Turnstile.SecretKey) + form.Add("response", token) + url := "https://challenges.cloudflare.com/turnstile/v0/siteverify" + req, err := http.NewRequest("POST", url, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Make the request. + client := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Read the response. + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("Reading response body from Cloudflare: %s", err) + } + + if resp.StatusCode != http.StatusOK { + log.Error("Turnstile CAPTCHA error (status %d): %s", resp.StatusCode, string(body)) + return fmt.Errorf("CAPTCHA validation error: status code %d", resp.StatusCode) + } + + // Parse the response JSON. + type response struct { + Success bool `json:"success"` + ErrorCodes []string `json:"error-codes"` + ChallengeTS time.Time `json:"challenge_ts"` + Hostname string `json:"hostname"` + } + var result response + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("parsing result json from Cloudflare: %s", err) + } + + if !result.Success { + log.Error("Turnstile CAPTCHA error (status %d): %s", resp.StatusCode, string(body)) + return errors.New("verification failed") + } + + return nil +} diff --git a/pkg/templates/template_vars.go b/pkg/templates/template_vars.go index eab1e34..f73e8f8 100644 --- a/pkg/templates/template_vars.go +++ b/pkg/templates/template_vars.go @@ -20,6 +20,9 @@ func MergeVars(r *http.Request, m map[string]interface{}) { m["YYYY"] = time.Now().Year() m["WebsiteTheme"] = "" + // Integrations + m["TurnstileCAPTCHA"] = config.Current.Turnstile + if r == nil { return } diff --git a/web/static/css/theme.css b/web/static/css/theme.css index 051f2a3..521419d 100644 --- a/web/static/css/theme.css +++ b/web/static/css/theme.css @@ -204,4 +204,8 @@ img { background-repeat: no-repeat; background-color: #400040; height: 64px; +} + +.nonshy-htsignup { + display: none; } \ No newline at end of file diff --git a/web/templates/account/signup.html b/web/templates/account/signup.html index 49d554a..bcba185 100644 --- a/web/templates/account/signup.html +++ b/web/templates/account/signup.html @@ -166,6 +166,26 @@ {{end}} + +
+ + +

+ Please enter a valid phone number for your account. +

+
+
+ + +
+
+ + {{if .TurnstileCAPTCHA.Enabled}} +
+
+
+ {{end}} +
@@ -181,6 +211,12 @@ {{end}} {{define "scripts"}} + + +{{if .TurnstileCAPTCHA.Enabled}} + +{{end}} +