diff --git a/cmd/admin.go b/cmd/admin.go
index ae8bd178..329cecb8 100644
--- a/cmd/admin.go
+++ b/cmd/admin.go
@@ -7,6 +7,7 @@ import (
"syscall"
"time"
+ "github.com/knadh/listmonk/internal/captcha"
"github.com/labstack/echo/v4"
null "gopkg.in/volatiletech/null.v6"
)
@@ -15,9 +16,11 @@ type serverConfig struct {
RootURL string `json:"root_url"`
FromEmail string `json:"from_email"`
PublicSubscription struct {
- Enabled bool `json:"enabled"`
- CaptchaEnabled bool `json:"captcha_enabled"`
- CaptchaKey null.String `json:"captcha_key"`
+ 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.
diff --git a/cmd/handlers.go b/cmd/handlers.go
index 7d201a8b..12b98ad0 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -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)
}
diff --git a/cmd/init.go b/cmd/init.go
index 5fb9c7c7..b1f02e7b 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -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.
diff --git a/cmd/public.go b/cmd/public.go
index c452c4c8..e78f57d9 100644
--- a/cmd/public.go
+++ b/cmd/public.go
@@ -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"
@@ -88,8 +89,13 @@ type msgTpl struct {
type subFormTpl struct {
publicTpl
- Lists []models.List
- CaptchaKey string
+ Lists []models.List
+ 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 {
diff --git a/cmd/settings.go b/cmd/settings.go
index 214604de..d9ffb5b1 100644
--- a/cmd/settings.go
+++ b/cmd/settings.go
@@ -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
diff --git a/frontend/package.json b/frontend/package.json
index e337eed1..86ed5509 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
@@ -61,4 +63,4 @@
"jackspeak": "2.1.1"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
-}
+}
\ No newline at end of file
diff --git a/frontend/src/views/Forms.vue b/frontend/src/views/Forms.vue
index a66213a7..626d80ac 100644
--- a/frontend/src/views/Forms.vue
+++ b/frontend/src/views/Forms.vue
@@ -91,9 +91,15 @@ export default Vue.extend({
// Captcha?
if (this.serverConfig.public_subscription.captcha_enabled) {
- h += '\n'
- + `
\n`
- + ` <${'script'} src="https://js.hcaptcha.com/1/api.js" async defer>${'script'}>\n`;
+ if (this.serverConfig.public_subscription.captcha_provider === 'altcha') {
+ h += '\n'
+ + ` \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'
+ + ` \n`
+ + ` <${'script'} src="https://js.hcaptcha.com/1/api.js" async defer>${'script'}>\n`;
+ }
}
h += '\n'
diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue
index df83856e..5a33de55 100644
--- a/frontend/src/views/Settings.vue
+++ b/frontend/src/views/Settings.vue
@@ -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';
}
diff --git a/frontend/src/views/settings/security.vue b/frontend/src/views/settings/security.vue
index a16f4525..4d644721 100644
--- a/frontend/src/views/settings/security.vue
+++ b/frontend/src/views/settings/security.vue
@@ -98,19 +98,38 @@
-
+
-
-
-
-
-
-
+
+
+
+ ALTCHA
+
+
+ hCaptcha (deprecated)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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]);
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index c90751f0..aa1dc841 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -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"
diff --git a/go.mod b/go.mod
index fc3abd1f..314cfd8e 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 349dd645..281471fe 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/i18n/en.json b/i18n/en.json
index d6c2bb03..ca8e9906 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -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.",
diff --git a/internal/captcha/captcha.go b/internal/captcha/captcha.go
index deec8bb4..c336e5a1 100644
--- a/internal/captcha/captcha.go
+++ b/internal/captcha/captcha.go
@@ -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
- client *http.Client
+ 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
diff --git a/internal/migrations/v5.1.0.go b/internal/migrations/v5.1.0.go
index f137322a..5b93e9e0 100644
--- a/internal/migrations/v5.1.0.go
+++ b/internal/migrations/v5.1.0.go
@@ -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
}
diff --git a/models/settings.go b/models/settings.go
index e736c973..6daa046b 100644
--- a/models/settings.go
+++ b/models/settings.go
@@ -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"`
diff --git a/schema.sql b/schema.sql
index b1c67f37..b12e5641 100644
--- a/schema.sql
+++ b/schema.sql
@@ -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'),
diff --git a/static/public/templates/subscription-form.html b/static/public/templates/subscription-form.html
index a99e034f..92582130 100644
--- a/static/public/templates/subscription-form.html
+++ b/static/public/templates/subscription-form.html
@@ -29,10 +29,15 @@
{{ end }}
- {{ if .Data.CaptchaKey }}
+ {{ if .Data.Captcha.Enabled }}
-
-
+ {{ if eq .Data.Captcha.Provider "hcaptcha" }}
+
+
+ {{ else if eq .Data.Captcha.Provider "altcha" }}
+
+
+ {{ end }}
{{ end }}