mirror of
https://github.com/knadh/listmonk.git
synced 2025-10-06 05:16:48 +08:00
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:
parent
38387d0079
commit
09d291e119
18 changed files with 374 additions and 65 deletions
20
cmd/admin.go
20
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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
23
cmd/init.go
23
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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
6
frontend/package.json
vendored
6
frontend/package.json
vendored
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -91,9 +91,15 @@ export default Vue.extend({
|
|||
|
||||
// Captcha?
|
||||
if (this.serverConfig.public_subscription.captcha_enabled) {
|
||||
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`;
|
||||
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'
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
@ -98,19 +98,38 @@
|
|||
<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">
|
||||
<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-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 />
|
||||
<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']['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']['hcaptcha']['secret']" name="hcaptcha_secret" type="password"
|
||||
:maxlength="200" required />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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
19
frontend/yarn.lock
vendored
|
@ -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
1
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
|
||||
|
|
2
go.sum
2
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=
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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>
|
||||
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
|
||||
{{ 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>
|
||||
|
|
Loading…
Add table
Reference in a new issue