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>\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>\n`; + } else if (this.serverConfig.public_subscription.captcha_provider === 'hcaptcha') { + h += '\n' + + `
\n` + + ` <${'script'} src="https://js.hcaptcha.com/1/api.js" async defer>\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 }}