Add support for built-in ALTCHA CAPTCHA implementation.

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.
This commit is contained in:
Kailash Nadh 2025-08-14 23:02:40 +05:30
parent 38387d0079
commit 09d291e119
18 changed files with 374 additions and 65 deletions

View file

@ -7,6 +7,7 @@ import (
"syscall"
"time"
"github.com/knadh/listmonk/internal/captcha"
"github.com/labstack/echo/v4"
null "gopkg.in/volatiletech/null.v6"
)
@ -17,7 +18,9 @@ type serverConfig struct {
PublicSubscription struct {
Enabled bool `json:"enabled"`
CaptchaEnabled bool `json:"captcha_enabled"`
CaptchaProvider null.String `json:"captcha_provider"`
CaptchaKey null.String `json:"captcha_key"`
AltchaComplexity int `json:"altcha_complexity"`
} `json:"public_subscription"`
Messengers []string `json:"messengers"`
Langs []i18nLang `json:"langs"`
@ -39,9 +42,16 @@ func (a *App) GetServerConfig(c echo.Context) error {
HasLegacyUser: a.cfg.HasLegacyUser,
}
out.PublicSubscription.Enabled = a.cfg.EnablePublicSubPage
if a.cfg.Security.EnableCaptcha {
// CAPTCHA.
if a.cfg.Security.Captcha.Altcha.Enabled {
out.PublicSubscription.CaptchaEnabled = true
out.PublicSubscription.CaptchaKey = null.StringFrom(a.cfg.Security.CaptchaKey)
out.PublicSubscription.CaptchaProvider = null.StringFrom(captcha.ProviderAltcha)
out.PublicSubscription.AltchaComplexity = a.cfg.Security.Captcha.Altcha.Complexity
} else if a.cfg.Security.Captcha.HCaptcha.Enabled {
out.PublicSubscription.CaptchaEnabled = true
out.PublicSubscription.CaptchaProvider = null.StringFrom(captcha.ProviderHCaptcha)
out.PublicSubscription.CaptchaKey = null.StringFrom(a.cfg.Security.Captcha.HCaptcha.Key)
}
// Language list.

View file

@ -234,6 +234,7 @@ func initHTTPHandlers(e *echo.Echo, a *App) {
// Public APIs.
g.GET("/api/public/lists", a.GetPublicLists)
g.POST("/api/public/subscription", a.PublicSubscription)
g.GET("/api/public/captcha/altcha", a.AltchaChallenge)
if a.cfg.EnablePublicArchive {
g.GET("/api/public/archive", a.GetCampaignArchives)
}

View file

@ -106,9 +106,17 @@ type Config struct {
DefaultListRoleID int `koanf:"default_list_role_id"`
} `koanf:"oidc"`
EnableCaptcha bool `koanf:"enable_captcha"`
CaptchaKey string `koanf:"captcha_key"`
CaptchaSecret string `koanf:"captcha_secret"`
Captcha struct {
Altcha struct {
Enabled bool `koanf:"enabled"`
Complexity int `koanf:"complexity"`
} `koanf:"altcha"`
HCaptcha struct {
Enabled bool `koanf:"enabled"`
Key string `koanf:"key"`
Secret string `koanf:"secret"`
} `koanf:"hcaptcha"`
} `koanf:"captcha"`
} `koanf:"security"`
Appearance struct {
@ -905,9 +913,12 @@ func initHTTPServer(cfg *Config, urlCfg *UrlConfig, i *i18n.I18n, fs stuffbin.Fi
// initCaptcha initializes the captcha service.
func initCaptcha() *captcha.Captcha {
return captcha.New(captcha.Opt{
CaptchaSecret: ko.String("security.captcha_secret"),
})
var opt captcha.Opt
if err := ko.Unmarshal("security.captcha", &opt); err != nil {
lo.Fatalf("error loading captcha config: %v", err)
}
return captcha.New(opt)
}
// initCron initializes the cron job for refreshing slow query cache.

View file

@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"github.com/knadh/listmonk/internal/captcha"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/notifs"
@ -89,7 +90,12 @@ type msgTpl struct {
type subFormTpl struct {
publicTpl
Lists []models.List
CaptchaKey string
Captcha struct {
Enabled bool
Provider string
Key string
Complexity int
}
}
var (
@ -427,9 +433,15 @@ func (a *App) SubscriptionFormPage(c echo.Context) error {
out.Title = a.i18n.T("public.sub")
out.Lists = lists
// Captcha is enabled. Set the key for the template to render.
if a.cfg.Security.EnableCaptcha {
out.CaptchaKey = a.cfg.Security.CaptchaKey
// Captcha configuration for template rendering.
if a.cfg.Security.Captcha.Altcha.Enabled {
out.Captcha.Enabled = true
out.Captcha.Provider = "altcha"
out.Captcha.Complexity = a.cfg.Security.Captcha.Altcha.Complexity
} else if a.cfg.Security.Captcha.HCaptcha.Enabled {
out.Captcha.Enabled = true
out.Captcha.Provider = "hcaptcha"
out.Captcha.Key = a.cfg.Security.Captcha.HCaptcha.Key
}
return c.Render(http.StatusOK, "subscription-form", out)
@ -449,10 +461,28 @@ func (a *App) SubscriptionForm(c echo.Context) error {
}
// Process CAPTCHA.
if a.cfg.Security.EnableCaptcha {
err, ok := a.captcha.Verify(c.FormValue("h-captcha-response"))
if a.captcha.IsEnabled() {
var val string
// Get the appropriate captcha response field based on provider.
switch a.captcha.GetProvider() {
case captcha.ProviderHCaptcha:
val = c.FormValue("h-captcha-response")
case captcha.ProviderAltcha:
val = c.FormValue("altcha")
default:
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("public.invalidCaptcha")))
}
if val == "" {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.T("public.invalidCaptcha")))
}
err, ok := a.captcha.Verify(val)
if err != nil {
a.log.Printf("Captcha request failed: %v", err)
a.log.Printf("captcha request failed: %v", err)
}
if !ok {
@ -623,6 +653,25 @@ func (a *App) WipeSubscriberData(c echo.Context) error {
makeMsgTpl(a.i18n.T("public.dataRemovedTitle"), "", a.i18n.T("public.dataRemoved")))
}
// AltchaChallenge generates a challenge for Altcha captcha.
func (a *App) AltchaChallenge(c echo.Context) error {
// Check if Altcha is enabled.
if !a.captcha.IsEnabled() || a.captcha.GetProvider() != captcha.ProviderAltcha {
return echo.NewHTTPError(http.StatusNotFound, "captcha not enabled")
}
// Generate challenge.
out, err := a.captcha.GenerateChallenge()
if err != nil {
a.log.Printf("error generating altcha challenge: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, "Error generating challenge")
}
// Return the challenge as JSON.
c.Response().Header().Set("Content-Type", "application/json")
return c.String(http.StatusOK, out)
}
// drawTransparentImage draws a transparent PNG of given dimensions
// and returns the PNG bytes.
func drawTransparentImage(h, w int) []byte {

View file

@ -74,7 +74,7 @@ func (a *App) GetSettings(c echo.Context) error {
s.SendgridKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SendgridKey))
s.BouncePostmark.Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BouncePostmark.Password))
s.BounceForwardEmail.Key = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BounceForwardEmail.Key))
s.SecurityCaptchaSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SecurityCaptchaSecret))
s.SecurityCaptcha.HCaptcha.Secret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SecurityCaptcha.HCaptcha.Secret))
s.OIDC.ClientSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.OIDC.ClientSecret))
return c.JSON(http.StatusOK, okResp{s})
@ -220,8 +220,8 @@ func (a *App) UpdateSettings(c echo.Context) error {
if set.BounceForwardEmail.Key == "" {
set.BounceForwardEmail.Key = cur.BounceForwardEmail.Key
}
if set.SecurityCaptchaSecret == "" {
set.SecurityCaptchaSecret = cur.SecurityCaptchaSecret
if set.SecurityCaptcha.HCaptcha.Secret == "" {
set.SecurityCaptcha.HCaptcha.Secret = cur.SecurityCaptcha.HCaptcha.Secret
}
if set.OIDC.ClientSecret == "" {
set.OIDC.ClientSecret = cur.OIDC.ClientSecret

View file

@ -7,7 +7,8 @@
"build": "vite build",
"serve": "vite preview",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore src",
"prebuild": "eslint --ext .js,.vue --ignore-path .gitignore src"
"prebuild": "eslint --ext .js,.vue --ignore-path .gitignore src",
"postinstall": "cp node_modules/altcha/dist/altcha.umd.cjs ../static/public/static/altcha.umd.js"
},
"dependencies": {
"@codemirror/commands": "^6.8.1",
@ -42,6 +43,7 @@
"vuex": "^3.6.2"
},
"devDependencies": {
"altcha": "^2.1.0",
"@types/js-beautify": "^1.14.3",
"@vitejs/plugin-vue2": "^2.3.1",
"@vue/eslint-config-airbnb": "^7.0.1",

View file

@ -91,10 +91,16 @@ export default Vue.extend({
// Captcha?
if (this.serverConfig.public_subscription.captcha_enabled) {
if (this.serverConfig.public_subscription.captcha_provider === 'altcha') {
h += '\n'
+ ` <altcha-widget challengeurl="${this.serverConfig.root_url}/api/captcha/altcha"></altcha-widget>\n`
+ ` <${'script'} type="module" src="${this.serverConfig.root_url}/public/static/altcha.umd.js" async defer></${'script'}>\n`;
} else if (this.serverConfig.public_subscription.captcha_provider === 'hcaptcha') {
h += '\n'
+ ` <div class="h-captcha" data-sitekey="${this.serverConfig.public_subscription.captcha_key}"></div>\n`
+ ` <${'script'} src="https://js.hcaptcha.com/1/api.js" async defer></${'script'}>\n`;
}
}
h += '\n'
+ ` <input type="submit" value="${this.$t('public.sub')} " />\n`

View file

@ -154,9 +154,9 @@ export default Vue.extend({
hasDummy = 'sendgrid';
}
if (this.isDummy(form['security.captcha_secret'])) {
form['security.captcha_secret'] = '';
} else if (this.hasDummy(form['security.captcha_secret'])) {
if (this.isDummy(form['security.captcha'].hcaptcha.secret)) {
form['security.captcha'].hcaptcha.secret = '';
} else if (this.hasDummy(form['security.captcha'].hcaptcha.secret)) {
hasDummy = 'captcha';
}

View file

@ -98,22 +98,41 @@
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.security.enableCaptcha')" :message="$t('settings.security.enableCaptchaHelp')">
<b-switch v-model="data['security.enable_captcha']" name="security.captcha" />
<b-switch v-model="captchaEnabled" name="security.captcha" />
</b-field>
</div>
<div class="column is-9">
<div class="column is-9" v-if="captchaEnabled">
<b-field :label="$t('settings.security.captchaProvider')">
<b-radio v-model="selectedProvider" native-value="altcha" name="captcha_provider">
ALTCHA
</b-radio>
<b-radio v-model="selectedProvider" native-value="hcaptcha" name="captcha_provider">
hCaptcha (deprecated)
</b-radio>
</b-field>
<!-- captcha settings -->
<div v-if="selectedProvider === 'altcha'">
<b-field :label="$t('settings.security.altchaComplexity')" label-position="on-border"
:message="$t('settings.security.altchaComplexityHelp')">
<b-input v-model.number="data['security.captcha']['altcha']['complexity']" name="altcha_complexity"
type="number" min="1000" max="1000000" required />
</b-field>
</div>
<div v-if="selectedProvider === 'hcaptcha'">
<b-field :label="$t('settings.security.captchaKey')" label-position="on-border"
:message="$t('settings.security.captchaKeyHelp')">
<b-input v-model="data['security.captcha_key']" name="captcha_key"
:disabled="!data['security.enable_captcha']" :maxlength="200" required />
<b-input v-model="data['security.captcha']['hcaptcha']['key']" name="hcaptcha_key" :maxlength="200"
required />
</b-field>
<b-field :label="$t('settings.security.captchaSecret')" label-position="on-border">
<b-input v-model="data['security.captcha_secret']" name="captcha_secret" type="password"
:disabled="!data['security.enable_captcha']" :maxlength="200" required />
<b-input v-model="data['security.captcha']['hcaptcha']['secret']" name="hcaptcha_secret" type="password"
:maxlength="200" required />
</b-field>
</div>
</div>
</div>
</div>
</template>
<script>
@ -142,6 +161,30 @@ export default Vue.extend({
computed: {
...mapState(['serverConfig', 'userRoles', 'listRoles']),
captchaEnabled: {
get() {
return this.data['security.captcha'].altcha.enabled || this.data['security.captcha'].hcaptcha.enabled;
},
set(value) {
this.data['security.captcha'].altcha.enabled = !!value;
this.data['security.captcha'].hcaptcha.enabled = false;
},
},
selectedProvider: {
get() {
if (this.data['security.captcha'].hcaptcha.enabled) {
return 'hcaptcha';
}
return 'altcha';
},
set(value) {
this.data['security.captcha'].hcaptcha.enabled = value === 'hcaptcha';
this.data['security.captcha'].altcha.enabled = value === 'altcha';
},
},
version() {
return import.meta.env.VUE_APP_VERSION;
},
@ -159,10 +202,12 @@ export default Vue.extend({
}
},
},
mounted() {
this.$api.getUserRoles();
this.$api.getListRoles();
},
methods: {
setProvider(provider) {
this.$set(this.data['security.oidc'], 'provider_url', OIDC_PROVIDERS[provider]);

19
frontend/yarn.lock vendored
View file

@ -2,6 +2,11 @@
# yarn lockfile v1
"@altcha/crypto@^0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@altcha/crypto/-/crypto-0.0.1.tgz#0e2f254559fb350c80ff56d29b8e3ab2e6bbea95"
integrity sha512-qZMdnoD3lAyvfSUMNtC2adRi666Pxdcw9zqfMU5qBOaJWqpN9K+eqQGWqeiKDMqL0SF+EytNG4kR/Pr/99GJ6g==
"@babel/helper-string-parser@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c"
@ -904,6 +909,11 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.30.1.tgz#c2dcd8a4b08b2f2778eceb7a5a5dfde6240ebdea"
integrity sha512-GWFs97Ruxo5Bt+cvVTQkOJ6TIx0xJDD/bMAOXWJg8TCSTEK8RnFeOeiFTxKniTc4vMIaWvCplMAFBt9miGxgkA==
"@rollup/rollup-linux-x64-gnu@4.18.0":
version "4.18.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz#1a7481137a54740bee1ded4ae5752450f155d942"
integrity sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==
"@rollup/rollup-linux-x64-gnu@4.30.1":
version "4.30.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.1.tgz#183637d91456877cb83d0a0315eb4788573aa588"
@ -1046,6 +1056,15 @@ ajv@^6.12.4:
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
altcha@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/altcha/-/altcha-2.1.0.tgz#8502afbdb34fa799474f203939d6de1cb622bc2b"
integrity sha512-V+Ug4Qr9oLR2WcDSgtzmwOzHGqoencaJoyRmUb1hqZ6Y91B3Xe8vHQ0Q3RwoKuBOYHX9yTYccJvaWHEp8+4UIQ==
dependencies:
"@altcha/crypto" "^0.0.1"
optionalDependencies:
"@rollup/rollup-linux-x64-gnu" "4.18.0"
ansi-colors@^4.1.1:
version "4.1.3"
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b"

1
go.mod
View file

@ -44,6 +44,7 @@ require (
dario.cat/mergo v1.0.2 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/altcha-org/altcha-lib-go v0.2.2 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect

2
go.sum
View file

@ -12,6 +12,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/altcha-org/altcha-lib-go v0.2.2 h1:KY7a7jFUf6tFKZF6MzuZMhSWuGMv0MtVkK/Kj4Oas38=
github.com/altcha-org/altcha-lib-go v0.2.2/go.mod h1:I8ESLVWR9C58uvGufB/AJDPhaSU4+4Oh3DLpVtgwDAk=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View file

@ -536,8 +536,12 @@
"settings.security.OIDCDefaultUserRole": "Default user role",
"settings.security.OIDCDefaultListRole": "Default list role",
"settings.security.OIDCDefaultRoleHelp": "Default role assigned to users auto-created from OIDC.",
"settings.security.altchaComplexity": "Altcha Complexity",
"settings.security.altchaComplexityHelp": "Higher values provide better security but slower solving (1000-1000000).",
"settings.security.altchaHMACNote": "HMAC key is automatically generated for security.",
"settings.security.captchaKey": "hCaptcha.com SiteKey",
"settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",
"settings.security.captchaProvider": "CAPTCHA Provider",
"settings.security.captchaSecret": "hCaptcha.com secret",
"settings.security.enableCaptcha": "Enable CAPTCHA",
"settings.security.enableCaptchaHelp": "Enable CAPTCHA on the public subscription form.",

View file

@ -1,6 +1,8 @@
package captcha
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@ -8,34 +10,59 @@ import (
"net/url"
"strings"
"time"
"github.com/altcha-org/altcha-lib-go"
)
const (
rootURL = "https://hcaptcha.com/siteverify"
hCaptchaURL = "https://hcaptcha.com/siteverify"
)
type captchaResp struct {
type hCaptchaResp struct {
Success bool `json:"success"`
ErrorCodes []string `json:"error_codes"`
}
// Captcha is a simple Captcha client.
// It currently implements hcaptcha.com
const (
ProviderNone = ""
ProviderHCaptcha = "hcaptcha"
ProviderAltcha = "altcha"
)
// Captcha is a captcha client supporting multiple providers.
type Captcha struct {
o Opt
provider string
hCaptcha hCaptchaOpt
altcha altchaOpt
client *http.Client
}
type Opt struct {
CaptchaSecret string `json:"captcha_secret"`
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"`
}
// New returns a new instance of the HTTP CAPTCHA client.
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
return &Captcha{
o: o,
c := &Captcha{
client: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
@ -44,13 +71,91 @@ func New(o Opt) *Captcha {
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
}
// Verify verifies a CAPTCHA request.
// 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) {
resp, err := c.client.PostForm(rootURL, url.Values{
"secret": {c.o.CaptchaSecret},
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 {
@ -63,13 +168,27 @@ func (c *Captcha) Verify(token string) (error, bool) {
return err, false
}
var r captchaResp
var r hCaptchaResp
if err := json.Unmarshal(body, &r); err != nil {
return err, true
}
if !r.Success {
return fmt.Errorf("captcha failed: %s", strings.Join(r.ErrorCodes, ",")), false
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

View file

@ -21,5 +21,34 @@ func V5_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
if err != nil {
return err
}
// Migrate old captcha settings to new JSON structure.
_, err = db.Exec(`
WITH old AS (
SELECT
COALESCE((SELECT (value#>>'{}')::BOOLEAN FROM settings WHERE key = 'security.enable_captcha'), false) AS enable_captcha,
COALESCE((SELECT value#>>'{}' FROM settings WHERE key = 'security.captcha_key'), '') AS captcha_key,
COALESCE((SELECT value#>>'{}' FROM settings WHERE key = 'security.captcha_secret'), '') AS captcha_secret
)
INSERT INTO settings (key, value, updated_at)
SELECT
'security.captcha',
JSON_BUILD_OBJECT(
'altcha', JSON_BUILD_OBJECT('enabled', false, 'complexity', 300000),
'hcaptcha', JSON_BUILD_OBJECT('enabled', enable_captcha, 'key', captcha_key, 'secret', captcha_secret)
),
NOW()
FROM old
ON CONFLICT (key) DO NOTHING
`)
if err != nil {
return err
}
// Remove old captcha settings.
if _, err = db.Exec(`DELETE FROM settings WHERE key IN ('security.enable_captcha', 'security.captcha_key', 'security.captcha_secret')`); err != nil {
return err
}
return nil
}

View file

@ -39,9 +39,17 @@ type Settings struct {
DomainBlocklist []string `json:"privacy.domain_blocklist"`
DomainAllowlist []string `json:"privacy.domain_allowlist"`
SecurityEnableCaptcha bool `json:"security.enable_captcha"`
SecurityCaptchaKey string `json:"security.captcha_key"`
SecurityCaptchaSecret string `json:"security.captcha_secret"`
SecurityCaptcha struct {
Altcha struct {
Enabled bool `json:"enabled"`
Complexity int `json:"complexity"`
} `json:"altcha"`
HCaptcha struct {
Enabled bool `json:"enabled"`
Key string `json:"key"`
Secret string `json:"secret"`
} `json:"hcaptcha"`
} `json:"security.captcha"`
OIDC struct {
Enabled bool `json:"enabled"`

View file

@ -253,9 +253,7 @@ INSERT INTO settings (key, value) VALUES
('privacy.domain_blocklist', '[]'),
('privacy.domain_allowlist', '[]'),
('privacy.record_optin_ip', 'false'),
('security.enable_captcha', 'false'),
('security.captcha_key', '""'),
('security.captcha_secret', '""'),
('security.captcha', '{"altcha": {"enabled": false, "complexity": 300000}, "hcaptcha": {"enabled": false, "key": "", "secret": ""}}'),
('security.oidc', '{"enabled": false, "provider_url": "", "provider_name": "", "client_id": "", "client_secret": "", "auto_create_users": false, "default_user_role_id": null, "default_list_role_id": null}'),
('upload.provider', '"filesystem"'),
('upload.max_file_size', '5000'),

View file

@ -29,10 +29,15 @@
{{ end }}
</ul>
{{ if .Data.CaptchaKey }}
{{ if .Data.Captcha.Enabled }}
<div class="captcha">
<div class="h-captcha" data-sitekey="{{ .Data.CaptchaKey }}"></div>
{{ if eq .Data.Captcha.Provider "hcaptcha" }}
<div class="h-captcha" data-sitekey="{{ .Data.Captcha.Key }}"></div>
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
{{ else if eq .Data.Captcha.Provider "altcha" }}
<altcha-widget challengeurl="{{ .RootURL }}/api/public/captcha/altcha"></altcha-widget>
<script type="module" src="{{ .RootURL }}/public/static/altcha.umd.js" async defer></script>
{{ end }}
</div>
{{ end }}
<p>