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" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil"
"os" "os"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen" "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 // Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate. // to write the settings.toml to disk so new defaults populate.
var currentVersion = 2 var currentVersion = 3
// Current loaded settings.json // Current loaded settings.json
var Current = DefaultVariable() var Current = DefaultVariable()
@ -31,6 +30,7 @@ type Variable struct {
BareRTC BareRTC BareRTC BareRTC
Maintenance Maintenance Maintenance Maintenance
Encryption Encryption Encryption Encryption
Turnstile Turnstile
UseXForwardedFor bool UseXForwardedFor bool
} }
@ -62,7 +62,7 @@ func LoadSettings() {
if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) { if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) {
log.Info("Loading settings from %s", SettingsPath) log.Info("Loading settings from %s", SettingsPath)
content, err := ioutil.ReadFile(SettingsPath) content, err := os.ReadFile(SettingsPath)
if err != nil { if err != nil {
panic(fmt.Sprintf("LoadSettings: couldn't read settings.json: %s", err)) 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)) 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. // Mail settings.
@ -165,3 +165,10 @@ type Maintenance struct {
type Encryption struct { type Encryption struct {
AESKey []byte 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/models"
"code.nonshy.com/nonshy/website/pkg/redis" "code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/session" "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/templates"
"code.nonshy.com/nonshy/website/pkg/utility" "code.nonshy.com/nonshy/website/pkg/utility"
"github.com/google/uuid" "github.com/google/uuid"
@ -71,7 +72,6 @@ func Signup() http.HandlerFunc {
vars["SignupToken"] = tokenStr vars["SignupToken"] = tokenStr
vars["Email"] = token.Email vars["Email"] = token.Email
} }
log.Info("Vars: %+v", vars)
// Posting? // Posting?
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
@ -86,10 +86,33 @@ func Signup() http.HandlerFunc {
password2 = strings.TrimSpace(r.PostFormValue("password2")) password2 = strings.TrimSpace(r.PostFormValue("password2"))
dob = r.PostFormValue("dob") 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. // Validation errors but still show the form again.
hasError bool 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. // Don't let them sneakily change their verified email address on us.
if vars["SignupToken"] != "" && email != vars["Email"] { if vars["SignupToken"] != "" && email != vars["Email"] {
session.FlashError(w, r, "This email address is not verified. Please start over from the beginning.") 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 { 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 // 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. // address in case the user legitimately forgot, but flash the regular success message.
err := mail.Send(mail.Message{ if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil {
To: email, err := mail.Send(mail.Message{
Subject: "You already have a nonshy account", To: email,
Template: "email/already_signed_up.html", Subject: "You already have a nonshy account",
Data: map[string]interface{}{ Template: "email/already_signed_up.html",
"Title": config.Title, Data: map[string]interface{}{
"URL": config.Current.BaseURL + "/forgot-password", "Title": config.Title,
}, "URL": config.Current.BaseURL + "/forgot-password",
}) },
if err != nil { })
session.FlashError(w, r, "Error sending an email: %s", err) 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) 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 return
} }
err := mail.Send(mail.Message{ if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil {
To: email, err := mail.Send(mail.Message{
Subject: "Verify your e-mail address", To: email,
Template: "email/verify_email.html", Subject: "Verify your e-mail address",
Data: map[string]interface{}{ Template: "email/verify_email.html",
"Title": config.Title, Data: map[string]interface{}{
"URL": config.Current.BaseURL + "/signup?token=" + token.Token, "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 != 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) 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" "fmt"
"html/template" "html/template"
"strings" "strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config" "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/log"
"code.nonshy.com/nonshy/website/pkg/redis"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"gopkg.in/gomail.v2" "gopkg.in/gomail.v2"
) )
@ -23,6 +26,22 @@ type Message struct {
Data map[string]interface{} 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. // Send an email.
func Send(msg Message) error { func Send(msg Message) error {
conf := config.Current.Mail 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["YYYY"] = time.Now().Year()
m["WebsiteTheme"] = "" m["WebsiteTheme"] = ""
// Integrations
m["TurnstileCAPTCHA"] = config.Current.Turnstile
if r == nil { if r == nil {
return return
} }

View File

@ -204,4 +204,8 @@ img {
background-repeat: no-repeat; background-repeat: no-repeat;
background-color: #400040; background-color: #400040;
height: 64px; height: 64px;
}
.nonshy-htsignup {
display: none;
} }

View File

@ -166,6 +166,26 @@
</div> </div>
{{end}} {{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"> <div class="field">
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" name="confirm" value="true" required> <input type="checkbox" name="confirm" value="true" required>
@ -173,6 +193,16 @@
</label> </label>
</div> </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"> <div class="field">
<button type="submit" class="button is-primary">Continue and verify email</button> <button type="submit" class="button is-primary">Continue and verify email</button>
</div> </div>
@ -181,6 +211,12 @@
</div> </div>
{{end}} {{end}}
{{define "scripts"}} {{define "scripts"}}
<!-- Cloudflare Turnstile CAPTCHA -->
{{if .TurnstileCAPTCHA.Enabled}}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>
{{end}}
<script> <script>
window.addEventListener("DOMContentLoaded", (event) => { window.addEventListener("DOMContentLoaded", (event) => {
// Set up username checking script. // Set up username checking script.

View File

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