Skip to content

Commit 90fd942

Browse files
committed
feat: support captcha config locally
1 parent 32e0ec1 commit 90fd942

File tree

8 files changed

+201
-1
lines changed

8 files changed

+201
-1
lines changed

internal/start/start.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,15 @@ EOF
585585
)
586586
}
587587

588+
if captcha := utils.Config.Auth.Captcha; captcha != nil {
589+
env = append(
590+
env,
591+
fmt.Sprintf("GOTRUE_SECURITY_CAPTCHA_ENABLED=%v", captcha.Enabled),
592+
fmt.Sprintf("GOTRUE_SECURITY_CAPTCHA_PROVIDER=%v", captcha.Provider),
593+
fmt.Sprintf("GOTRUE_SECURITY_CAPTCHA_SECRET=%v", captcha.Secret.Value),
594+
)
595+
}
596+
588597
if hook := utils.Config.Auth.Hook.MFAVerificationAttempt; hook != nil && hook.Enabled {
589598
env = append(
590599
env,

pkg/config/auth.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ func NewPasswordRequirement(c v1API.UpdateAuthConfigBodyPasswordRequiredCharacte
4343
return NoRequirements
4444
}
4545

46+
type CaptchaProvider string
47+
48+
const (
49+
HCaptchaProvider CaptchaProvider = "hcaptcha"
50+
TurnstileProvider CaptchaProvider = "turnstile"
51+
)
52+
4653
type (
4754
auth struct {
4855
Enabled bool `toml:"enabled"`
@@ -59,6 +66,7 @@ type (
5966
MinimumPasswordLength uint `toml:"minimum_password_length"`
6067
PasswordRequirements PasswordRequirements `toml:"password_requirements"`
6168

69+
Captcha *captcha `toml:"captcha"`
6270
Hook hook `toml:"hook"`
6371
MFA mfa `toml:"mfa"`
6472
Sessions sessions `toml:"sessions"`
@@ -144,6 +152,12 @@ type (
144152
MaxFrequency time.Duration `toml:"max_frequency"`
145153
}
146154

155+
captcha struct {
156+
Enabled bool `toml:"enabled"`
157+
Provider CaptchaProvider `toml:"provider"`
158+
Secret Secret `toml:"secret"`
159+
}
160+
147161
hook struct {
148162
MFAVerificationAttempt *hookConfig `toml:"mfa_verification_attempt"`
149163
PasswordVerificationAttempt *hookConfig `toml:"password_verification_attempt"`
@@ -231,6 +245,10 @@ func (a *auth) ToUpdateAuthConfigBody() v1API.UpdateAuthConfigBody {
231245
PasswordMinLength: cast.UintToIntPtr(&a.MinimumPasswordLength),
232246
PasswordRequiredCharacters: cast.Ptr(a.PasswordRequirements.ToChar()),
233247
}
248+
// When local config is not set, we assume platform defaults should not change
249+
if a.Captcha != nil {
250+
a.Captcha.toAuthConfigBody(&body)
251+
}
234252
a.Hook.toAuthConfigBody(&body)
235253
a.MFA.toAuthConfigBody(&body)
236254
a.Sessions.toAuthConfigBody(&body)
@@ -252,6 +270,7 @@ func (a *auth) FromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponse) {
252270
a.MinimumPasswordLength = cast.IntToUint(cast.Val(remoteConfig.PasswordMinLength, 0))
253271
prc := cast.Val(remoteConfig.PasswordRequiredCharacters, "")
254272
a.PasswordRequirements = NewPasswordRequirement(v1API.UpdateAuthConfigBodyPasswordRequiredCharacters(prc))
273+
a.Captcha.fromAuthConfig(remoteConfig)
255274
a.Hook.fromAuthConfig(remoteConfig)
256275
a.MFA.fromAuthConfig(remoteConfig)
257276
a.Sessions.fromAuthConfig(remoteConfig)
@@ -260,6 +279,30 @@ func (a *auth) FromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponse) {
260279
a.External.fromAuthConfig(remoteConfig)
261280
}
262281

282+
func (c captcha) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) {
283+
if body.SecurityCaptchaEnabled = &c.Enabled; c.Enabled {
284+
body.SecurityCaptchaProvider = cast.Ptr(string(c.Provider))
285+
if len(c.Secret.SHA256) > 0 {
286+
body.SecurityCaptchaSecret = &c.Secret.Value
287+
}
288+
}
289+
}
290+
291+
func (c *captcha) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) {
292+
// When local config is not set, we assume platform defaults should not change
293+
if c == nil {
294+
return
295+
}
296+
// Ignore disabled captcha fields to minimise config diff
297+
if c.Enabled {
298+
c.Provider = CaptchaProvider(cast.Val(remoteConfig.SecurityCaptchaProvider, ""))
299+
if len(c.Secret.SHA256) > 0 {
300+
c.Secret.SHA256 = cast.Val(remoteConfig.SecurityCaptchaSecret, "")
301+
}
302+
}
303+
c.Enabled = cast.Val(remoteConfig.SecurityCaptchaEnabled, false)
304+
}
305+
263306
func (h hook) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) {
264307
// When local config is not set, we assume platform defaults should not change
265308
if hook := h.CustomAccessToken; hook != nil {

pkg/config/auth_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,98 @@ func TestAuthDiff(t *testing.T) {
117117
})
118118
}
119119

120+
func TestCaptchaDiff(t *testing.T) {
121+
t.Run("local and remote enabled", func(t *testing.T) {
122+
c := newWithDefaults()
123+
c.Captcha = &captcha{
124+
Enabled: true,
125+
Provider: HCaptchaProvider,
126+
Secret: Secret{
127+
Value: "test-secret",
128+
SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252",
129+
},
130+
}
131+
// Run test
132+
diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{
133+
SecurityCaptchaEnabled: cast.Ptr(true),
134+
SecurityCaptchaProvider: cast.Ptr("hcaptcha"),
135+
SecurityCaptchaSecret: cast.Ptr("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"),
136+
})
137+
// Check error
138+
assert.NoError(t, err)
139+
assert.Empty(t, string(diff))
140+
})
141+
142+
t.Run("local disabled remote enabled", func(t *testing.T) {
143+
c := newWithDefaults()
144+
c.Captcha = &captcha{
145+
Enabled: false,
146+
Provider: TurnstileProvider,
147+
Secret: Secret{
148+
Value: "test-key",
149+
SHA256: "ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e",
150+
},
151+
}
152+
// Run test
153+
diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{
154+
SecurityCaptchaEnabled: cast.Ptr(true),
155+
SecurityCaptchaProvider: cast.Ptr("hcaptcha"),
156+
SecurityCaptchaSecret: cast.Ptr("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"),
157+
})
158+
// Check error
159+
assert.NoError(t, err)
160+
assertSnapshotEqual(t, diff)
161+
})
162+
163+
t.Run("local enabled remote disabled", func(t *testing.T) {
164+
c := newWithDefaults()
165+
c.Captcha = &captcha{
166+
Enabled: true,
167+
Provider: TurnstileProvider,
168+
Secret: Secret{
169+
Value: "test-key",
170+
SHA256: "ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e",
171+
},
172+
}
173+
// Run test
174+
diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{
175+
SecurityCaptchaEnabled: cast.Ptr(false),
176+
SecurityCaptchaProvider: cast.Ptr("hcaptcha"),
177+
SecurityCaptchaSecret: cast.Ptr("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"),
178+
})
179+
// Check error
180+
assert.NoError(t, err)
181+
assertSnapshotEqual(t, diff)
182+
})
183+
184+
t.Run("local and remote disabled", func(t *testing.T) {
185+
c := newWithDefaults()
186+
c.Captcha = &captcha{
187+
Enabled: false,
188+
}
189+
// Run test
190+
diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{
191+
SecurityCaptchaEnabled: cast.Ptr(false),
192+
})
193+
// Check error
194+
assert.NoError(t, err)
195+
assert.Empty(t, string(diff))
196+
})
197+
198+
t.Run("ignores undefined config", func(t *testing.T) {
199+
c := newWithDefaults()
200+
// Run test
201+
diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{
202+
SecurityCaptchaEnabled: cast.Ptr(true),
203+
SecurityCaptchaProvider: cast.Ptr("hcaptcha"),
204+
SecurityCaptchaSecret: cast.Ptr("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"),
205+
})
206+
// Check error
207+
assert.NoError(t, err)
208+
assert.Empty(t, string(diff))
209+
})
210+
}
211+
120212
func TestHookDiff(t *testing.T) {
121213
t.Run("local and remote enabled", func(t *testing.T) {
122214
c := newWithDefaults()

pkg/config/config.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,16 @@ func (f function) IsEnabled() bool {
219219

220220
func (a *auth) Clone() auth {
221221
copy := *a
222+
if copy.Captcha != nil {
223+
capt := *a.Captcha
224+
copy.Captcha = &capt
225+
}
222226
copy.External = maps.Clone(a.External)
223227
if a.Email.Smtp != nil {
224228
mailer := *a.Email.Smtp
225229
copy.Email.Smtp = &mailer
226230
}
231+
copy.Email.Template = maps.Clone(a.Email.Template)
227232
if a.Hook.MFAVerificationAttempt != nil {
228233
hook := *a.Hook.MFAVerificationAttempt
229234
copy.Hook.MFAVerificationAttempt = &hook
@@ -244,7 +249,6 @@ func (a *auth) Clone() auth {
244249
hook := *a.Hook.SendEmail
245250
copy.Hook.SendEmail = &hook
246251
}
247-
copy.Email.Template = maps.Clone(a.Email.Template)
248252
copy.Sms.TestOTP = maps.Clone(a.Sms.TestOTP)
249253
return copy
250254
}
@@ -710,6 +714,18 @@ func (c *config) Validate(fsys fs.FS) error {
710714
if !sliceContains(allowed, c.Auth.PasswordRequirements) {
711715
return errors.Errorf("Invalid config for auth.password_requirements. Must be one of: %v", allowed)
712716
}
717+
if c.Auth.Captcha != nil && c.Auth.Captcha.Enabled {
718+
allowed := []CaptchaProvider{HCaptchaProvider, TurnstileProvider}
719+
if !sliceContains(allowed, c.Auth.Captcha.Provider) {
720+
return errors.Errorf("Invalid config for auth.captcha.provider. Must be one of: %v", allowed)
721+
}
722+
if len(c.Auth.Captcha.Secret.Value) == 0 {
723+
return errors.Errorf("Missing required field in config: auth.captcha.secret")
724+
}
725+
if err := assertEnvLoaded(c.Auth.Captcha.Secret.Value); err != nil {
726+
return err
727+
}
728+
}
713729
if err := c.Auth.Hook.validate(); err != nil {
714730
return err
715731
}

pkg/config/templates/config.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ minimum_password_length = 6
122122
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
123123
password_requirements = ""
124124

125+
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
126+
# [auth.captcha]
127+
# enabled = true
128+
# provider = "hcaptcha"
129+
# secret = ""
130+
125131
[auth.email]
126132
# Allow/disallow new user signups via email to your project.
127133
enable_signup = true
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
diff remote[auth] local[auth]
2+
--- remote[auth]
3+
+++ local[auth]
4+
@@ -10,7 +10,7 @@
5+
password_requirements = ""
6+
7+
[captcha]
8+
-enabled = true
9+
+enabled = false
10+
provider = "turnstile"
11+
secret = "hash:ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e"
12+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
diff remote[auth] local[auth]
2+
--- remote[auth]
3+
+++ local[auth]
4+
@@ -10,9 +10,9 @@
5+
password_requirements = ""
6+
7+
[captcha]
8+
-enabled = false
9+
-provider = "hcaptcha"
10+
-secret = "hash:ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"
11+
+enabled = true
12+
+provider = "turnstile"
13+
+secret = "hash:ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e"
14+
15+
[hook]
16+

pkg/config/testdata/config.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ minimum_password_length = 6
122122
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
123123
password_requirements = ""
124124

125+
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
126+
[auth.captcha]
127+
enabled = true
128+
provider = "hcaptcha"
129+
secret = "env(HCAPTCHA_SECRET)"
130+
125131
[auth.email]
126132
# Allow/disallow new user signups via email to your project.
127133
enable_signup = true

0 commit comments

Comments
 (0)