Cloudflare CAPTCHA for account signup page
This commit is contained in:
parent
8ed489c264
commit
af76c251c6
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
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["YYYY"] = time.Now().Year()
|
||||||
m["WebsiteTheme"] = ""
|
m["WebsiteTheme"] = ""
|
||||||
|
|
||||||
|
// Integrations
|
||||||
|
m["TurnstileCAPTCHA"] = config.Current.Turnstile
|
||||||
|
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
|
@ -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.
|
||||||
|
|
|
@ -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">
|
||||||
|
|
Loading…
Reference in New Issue
Block a user