Cloudflare CAPTCHA for account signup page
This commit is contained in:
parent
8ed489c264
commit
af76c251c6
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
73
pkg/spam/captcha.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -204,4 +204,8 @@ img {
|
|||
background-repeat: no-repeat;
|
||||
background-color: #400040;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.nonshy-htsignup {
|
||||
display: none;
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Reference in New Issue
Block a user