Cloudflare CAPTCHA for account signup page

This commit is contained in:
Noah Petherbridge 2024-05-19 18:33:28 -07:00
parent 8ed489c264
commit af76c251c6
8 changed files with 202 additions and 27 deletions

View File

@ -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
}

View File

@ -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)

View File

@ -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

73
pkg/spam/captcha.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -205,3 +205,7 @@ img {
background-color: #400040;
height: 64px;
}
.nonshy-htsignup {
display: none;
}

View File

@ -166,6 +166,26 @@
</div>
{{end}}
<!-- Honeytrap fields -->
<div class="field nonshy-htsignup">
<label class="label" for="phone">Phone number:</label>
<input type="text" class="input"
placeholder="555-1234"
name="phone"
id="phone">
<p class="help">
Please enter a valid phone number for your account.
</p>
</div>
<div class="field nonshy-htsignup">
<label class="label" for="referral">How did you hear about us?</label>
<input type="text" class="input"
placeholder="Google"
name="referral"
id="referral"
value="Word of mouth">
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="confirm" value="true" required>
@ -173,6 +193,16 @@
</label>
</div>
<!-- Cloudflare Turnstile CAPTCHA widget -->
{{if .TurnstileCAPTCHA.Enabled}}
<div class="field">
<div class="cf-turnstile"
data-sitekey="{{.TurnstileCAPTCHA.SiteKey}}"
data-action="signup"
></div>
</div>
{{end}}
<div class="field">
<button type="submit" class="button is-primary">Continue and verify email</button>
</div>
@ -181,6 +211,12 @@
</div>
{{end}}
{{define "scripts"}}
<!-- Cloudflare Turnstile CAPTCHA -->
{{if .TurnstileCAPTCHA.Enabled}}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>
{{end}}
<script>
window.addEventListener("DOMContentLoaded", (event) => {
// Set up username checking script.

View File

@ -1,6 +1,7 @@
{{define "title"}}Untitled{{end}}
{{define "content"}}{{end}}
{{define "scripts"}}{{end}}
{{define "head-scripts"}}{{end}}
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
@ -20,6 +21,7 @@
<link rel="stylesheet" href="/static/css/theme.css?build={{.BuildHash}}">
<link rel="manifest" href="/manifest.json">
<title>{{template "title" .}} - {{ .Title }}</title>
{{template "head-scripts" .}}
</head>
<body>
<nav class="navbar" role="navigation" aria-label="main navigation">