mirror of
https://github.com/knadh/listmonk.git
synced 2025-10-12 08:17:07 +08:00
The existing hCaptcha implementation as the only CAPTCHA option isn't ideal as hCaptcha is a proprietary SaaS provider. This commit adds supports for ALTCHA (altcha.org) a self-contained "proof-of-work" based CAPTCHA option. Closes #2243.
195 lines
4.3 KiB
Go
195 lines
4.3 KiB
Go
package captcha
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/altcha-org/altcha-lib-go"
|
|
)
|
|
|
|
const (
|
|
hCaptchaURL = "https://hcaptcha.com/siteverify"
|
|
)
|
|
|
|
type hCaptchaResp struct {
|
|
Success bool `json:"success"`
|
|
ErrorCodes []string `json:"error_codes"`
|
|
}
|
|
|
|
const (
|
|
ProviderNone = ""
|
|
ProviderHCaptcha = "hcaptcha"
|
|
ProviderAltcha = "altcha"
|
|
)
|
|
|
|
// Captcha is a captcha client supporting multiple providers.
|
|
type Captcha struct {
|
|
provider string
|
|
hCaptcha hCaptchaOpt
|
|
altcha altchaOpt
|
|
client *http.Client
|
|
}
|
|
|
|
type Opt struct {
|
|
HCaptcha struct {
|
|
Enabled bool `json:"enabled"`
|
|
Key string `json:"key"`
|
|
Secret string `json:"secret"`
|
|
} `json:"hcaptcha"`
|
|
Altcha struct {
|
|
Enabled bool `json:"enabled"`
|
|
Complexity int `json:"complexity"`
|
|
} `json:"altcha"`
|
|
}
|
|
|
|
type hCaptchaOpt struct {
|
|
Secret string
|
|
}
|
|
|
|
type altchaOpt struct {
|
|
Complexity int
|
|
HMACKey string
|
|
}
|
|
|
|
// New returns a new instance of the CAPTCHA client.
|
|
func New(o Opt) *Captcha {
|
|
timeout := time.Second * 5
|
|
|
|
c := &Captcha{
|
|
client: &http.Client{
|
|
Timeout: timeout,
|
|
Transport: &http.Transport{
|
|
MaxIdleConnsPerHost: 10,
|
|
MaxConnsPerHost: 100,
|
|
ResponseHeaderTimeout: timeout,
|
|
IdleConnTimeout: timeout,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Determine which provider is enabled
|
|
if o.Altcha.Enabled {
|
|
c.provider = ProviderAltcha
|
|
|
|
// Generate an random HMAC key for Altcha.
|
|
b := make([]byte, 24) // 24 bytes will give 32 characters when base64 encoded
|
|
_, err := rand.Read(b)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("error generating Altcha HMAC key: %v", err))
|
|
}
|
|
hmacKey := base64.URLEncoding.EncodeToString(b)[:32]
|
|
|
|
c.altcha = altchaOpt{
|
|
Complexity: o.Altcha.Complexity,
|
|
HMACKey: hmacKey,
|
|
}
|
|
} else if o.HCaptcha.Enabled {
|
|
c.provider = ProviderHCaptcha
|
|
c.hCaptcha = hCaptchaOpt{
|
|
Secret: o.HCaptcha.Secret,
|
|
}
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
// IsEnabled returns true if any captcha provider is enabled.
|
|
func (c *Captcha) IsEnabled() bool {
|
|
return c.provider != ProviderNone
|
|
}
|
|
|
|
// GetProvider returns the active captcha provider.
|
|
func (c *Captcha) GetProvider() string {
|
|
return c.provider
|
|
}
|
|
|
|
// GenerateChallenge generates a challenge for the active provider.
|
|
// For hCaptcha, this returns empty string as challenges are generated client-side.
|
|
// For Altcha, this returns a JSON challenge.
|
|
func (c *Captcha) GenerateChallenge() (string, error) {
|
|
switch c.provider {
|
|
case ProviderAltcha:
|
|
challenge, err := altcha.CreateChallenge(altcha.ChallengeOptions{
|
|
Algorithm: altcha.SHA256,
|
|
MaxNumber: int64(c.altcha.Complexity),
|
|
SaltLength: 12,
|
|
HMACKey: c.altcha.HMACKey,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create Altcha challenge: %w", err)
|
|
}
|
|
|
|
challengeJSON, err := json.Marshal(challenge)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal Altcha challenge: %w", err)
|
|
}
|
|
|
|
return string(challengeJSON), nil
|
|
case ProviderHCaptcha:
|
|
// hCaptcha generates challenges client-side.
|
|
return "", nil
|
|
default:
|
|
return "", fmt.Errorf("no captcha provider enabled")
|
|
}
|
|
}
|
|
|
|
// Verify verifies a CAPTCHA response.
|
|
func (c *Captcha) Verify(token string) (error, bool) {
|
|
switch c.provider {
|
|
case ProviderAltcha:
|
|
return c.verifyAltcha(token)
|
|
case ProviderHCaptcha:
|
|
return c.verifyHCaptcha(token)
|
|
default:
|
|
return fmt.Errorf("no captcha provider enabled"), false
|
|
}
|
|
}
|
|
|
|
// verifyHCaptcha verifies an hCaptcha response.
|
|
func (c *Captcha) verifyHCaptcha(token string) (error, bool) {
|
|
resp, err := c.client.PostForm(hCaptchaURL, url.Values{
|
|
"secret": {c.hCaptcha.Secret},
|
|
"response": {token},
|
|
})
|
|
if err != nil {
|
|
return err, false
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err, false
|
|
}
|
|
|
|
var r hCaptchaResp
|
|
if err := json.Unmarshal(body, &r); err != nil {
|
|
return err, true
|
|
}
|
|
|
|
if !r.Success {
|
|
return fmt.Errorf("hCaptcha failed: %s", strings.Join(r.ErrorCodes, ",")), false
|
|
}
|
|
|
|
return nil, true
|
|
}
|
|
|
|
// verifyAltcha verifies an Altcha response.
|
|
func (c *Captcha) verifyAltcha(payload string) (error, bool) {
|
|
valid, err := altcha.VerifySolution(payload, c.altcha.HMACKey, false)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to verify captcha solution: %w", err), false
|
|
}
|
|
|
|
if !valid {
|
|
return fmt.Errorf("captcha verification failed"), false
|
|
}
|
|
|
|
return nil, true
|
|
}
|