Skip to content

Commit

Permalink
feat: support captcha config locally
Browse files Browse the repository at this point in the history
  • Loading branch information
sweatybridge committed Feb 5, 2025
1 parent 32e0ec1 commit 90fd942
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 1 deletion.
9 changes: 9 additions & 0 deletions internal/start/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,15 @@ EOF
)
}

if captcha := utils.Config.Auth.Captcha; captcha != nil {
env = append(
env,
fmt.Sprintf("GOTRUE_SECURITY_CAPTCHA_ENABLED=%v", captcha.Enabled),
fmt.Sprintf("GOTRUE_SECURITY_CAPTCHA_PROVIDER=%v", captcha.Provider),
fmt.Sprintf("GOTRUE_SECURITY_CAPTCHA_SECRET=%v", captcha.Secret.Value),
)
}

if hook := utils.Config.Auth.Hook.MFAVerificationAttempt; hook != nil && hook.Enabled {
env = append(
env,
Expand Down
43 changes: 43 additions & 0 deletions pkg/config/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ func NewPasswordRequirement(c v1API.UpdateAuthConfigBodyPasswordRequiredCharacte
return NoRequirements
}

type CaptchaProvider string

const (
HCaptchaProvider CaptchaProvider = "hcaptcha"
TurnstileProvider CaptchaProvider = "turnstile"
)

type (
auth struct {
Enabled bool `toml:"enabled"`
Expand All @@ -59,6 +66,7 @@ type (
MinimumPasswordLength uint `toml:"minimum_password_length"`
PasswordRequirements PasswordRequirements `toml:"password_requirements"`

Captcha *captcha `toml:"captcha"`
Hook hook `toml:"hook"`
MFA mfa `toml:"mfa"`
Sessions sessions `toml:"sessions"`
Expand Down Expand Up @@ -144,6 +152,12 @@ type (
MaxFrequency time.Duration `toml:"max_frequency"`
}

captcha struct {
Enabled bool `toml:"enabled"`
Provider CaptchaProvider `toml:"provider"`
Secret Secret `toml:"secret"`
}

hook struct {
MFAVerificationAttempt *hookConfig `toml:"mfa_verification_attempt"`
PasswordVerificationAttempt *hookConfig `toml:"password_verification_attempt"`
Expand Down Expand Up @@ -231,6 +245,10 @@ func (a *auth) ToUpdateAuthConfigBody() v1API.UpdateAuthConfigBody {
PasswordMinLength: cast.UintToIntPtr(&a.MinimumPasswordLength),
PasswordRequiredCharacters: cast.Ptr(a.PasswordRequirements.ToChar()),
}
// When local config is not set, we assume platform defaults should not change
if a.Captcha != nil {
a.Captcha.toAuthConfigBody(&body)
}
a.Hook.toAuthConfigBody(&body)
a.MFA.toAuthConfigBody(&body)
a.Sessions.toAuthConfigBody(&body)
Expand All @@ -252,6 +270,7 @@ func (a *auth) FromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponse) {
a.MinimumPasswordLength = cast.IntToUint(cast.Val(remoteConfig.PasswordMinLength, 0))
prc := cast.Val(remoteConfig.PasswordRequiredCharacters, "")
a.PasswordRequirements = NewPasswordRequirement(v1API.UpdateAuthConfigBodyPasswordRequiredCharacters(prc))
a.Captcha.fromAuthConfig(remoteConfig)
a.Hook.fromAuthConfig(remoteConfig)
a.MFA.fromAuthConfig(remoteConfig)
a.Sessions.fromAuthConfig(remoteConfig)
Expand All @@ -260,6 +279,30 @@ func (a *auth) FromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponse) {
a.External.fromAuthConfig(remoteConfig)
}

func (c captcha) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) {
if body.SecurityCaptchaEnabled = &c.Enabled; c.Enabled {
body.SecurityCaptchaProvider = cast.Ptr(string(c.Provider))
if len(c.Secret.SHA256) > 0 {
body.SecurityCaptchaSecret = &c.Secret.Value
}
}
}

func (c *captcha) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) {
// When local config is not set, we assume platform defaults should not change
if c == nil {
return
}
// Ignore disabled captcha fields to minimise config diff
if c.Enabled {
c.Provider = CaptchaProvider(cast.Val(remoteConfig.SecurityCaptchaProvider, ""))
if len(c.Secret.SHA256) > 0 {
c.Secret.SHA256 = cast.Val(remoteConfig.SecurityCaptchaSecret, "")
}
}
c.Enabled = cast.Val(remoteConfig.SecurityCaptchaEnabled, false)
}

func (h hook) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) {
// When local config is not set, we assume platform defaults should not change
if hook := h.CustomAccessToken; hook != nil {
Expand Down
92 changes: 92 additions & 0 deletions pkg/config/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,98 @@ func TestAuthDiff(t *testing.T) {
})
}

func TestCaptchaDiff(t *testing.T) {
t.Run("local and remote enabled", func(t *testing.T) {
c := newWithDefaults()
c.Captcha = &captcha{
Enabled: true,
Provider: HCaptchaProvider,
Secret: Secret{
Value: "test-secret",
SHA256: "ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252",
},
}
// Run test
diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{
SecurityCaptchaEnabled: cast.Ptr(true),
SecurityCaptchaProvider: cast.Ptr("hcaptcha"),
SecurityCaptchaSecret: cast.Ptr("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"),
})
// Check error
assert.NoError(t, err)
assert.Empty(t, string(diff))
})

t.Run("local disabled remote enabled", func(t *testing.T) {
c := newWithDefaults()
c.Captcha = &captcha{
Enabled: false,
Provider: TurnstileProvider,
Secret: Secret{
Value: "test-key",
SHA256: "ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e",
},
}
// Run test
diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{
SecurityCaptchaEnabled: cast.Ptr(true),
SecurityCaptchaProvider: cast.Ptr("hcaptcha"),
SecurityCaptchaSecret: cast.Ptr("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"),
})
// Check error
assert.NoError(t, err)
assertSnapshotEqual(t, diff)
})

t.Run("local enabled remote disabled", func(t *testing.T) {
c := newWithDefaults()
c.Captcha = &captcha{
Enabled: true,
Provider: TurnstileProvider,
Secret: Secret{
Value: "test-key",
SHA256: "ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e",
},
}
// Run test
diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{
SecurityCaptchaEnabled: cast.Ptr(false),
SecurityCaptchaProvider: cast.Ptr("hcaptcha"),
SecurityCaptchaSecret: cast.Ptr("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"),
})
// Check error
assert.NoError(t, err)
assertSnapshotEqual(t, diff)
})

t.Run("local and remote disabled", func(t *testing.T) {
c := newWithDefaults()
c.Captcha = &captcha{
Enabled: false,
}
// Run test
diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{
SecurityCaptchaEnabled: cast.Ptr(false),
})
// Check error
assert.NoError(t, err)
assert.Empty(t, string(diff))
})

t.Run("ignores undefined config", func(t *testing.T) {
c := newWithDefaults()
// Run test
diff, err := c.DiffWithRemote(v1API.AuthConfigResponse{
SecurityCaptchaEnabled: cast.Ptr(true),
SecurityCaptchaProvider: cast.Ptr("hcaptcha"),
SecurityCaptchaSecret: cast.Ptr("ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"),
})
// Check error
assert.NoError(t, err)
assert.Empty(t, string(diff))
})
}

func TestHookDiff(t *testing.T) {
t.Run("local and remote enabled", func(t *testing.T) {
c := newWithDefaults()
Expand Down
18 changes: 17 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,16 @@ func (f function) IsEnabled() bool {

func (a *auth) Clone() auth {
copy := *a
if copy.Captcha != nil {
capt := *a.Captcha
copy.Captcha = &capt
}
copy.External = maps.Clone(a.External)
if a.Email.Smtp != nil {
mailer := *a.Email.Smtp
copy.Email.Smtp = &mailer
}
copy.Email.Template = maps.Clone(a.Email.Template)
if a.Hook.MFAVerificationAttempt != nil {
hook := *a.Hook.MFAVerificationAttempt
copy.Hook.MFAVerificationAttempt = &hook
Expand All @@ -244,7 +249,6 @@ func (a *auth) Clone() auth {
hook := *a.Hook.SendEmail
copy.Hook.SendEmail = &hook
}
copy.Email.Template = maps.Clone(a.Email.Template)
copy.Sms.TestOTP = maps.Clone(a.Sms.TestOTP)
return copy
}
Expand Down Expand Up @@ -710,6 +714,18 @@ func (c *config) Validate(fsys fs.FS) error {
if !sliceContains(allowed, c.Auth.PasswordRequirements) {
return errors.Errorf("Invalid config for auth.password_requirements. Must be one of: %v", allowed)
}
if c.Auth.Captcha != nil && c.Auth.Captcha.Enabled {
allowed := []CaptchaProvider{HCaptchaProvider, TurnstileProvider}
if !sliceContains(allowed, c.Auth.Captcha.Provider) {
return errors.Errorf("Invalid config for auth.captcha.provider. Must be one of: %v", allowed)
}
if len(c.Auth.Captcha.Secret.Value) == 0 {
return errors.Errorf("Missing required field in config: auth.captcha.secret")
}
if err := assertEnvLoaded(c.Auth.Captcha.Secret.Value); err != nil {
return err
}
}
if err := c.Auth.Hook.validate(); err != nil {
return err
}
Expand Down
6 changes: 6 additions & 0 deletions pkg/config/templates/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ minimum_password_length = 6
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""

# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""

[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
diff remote[auth] local[auth]
--- remote[auth]
+++ local[auth]
@@ -10,7 +10,7 @@
password_requirements = ""

[captcha]
-enabled = true
+enabled = false
provider = "turnstile"
secret = "hash:ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e"

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
diff remote[auth] local[auth]
--- remote[auth]
+++ local[auth]
@@ -10,9 +10,9 @@
password_requirements = ""

[captcha]
-enabled = false
-provider = "hcaptcha"
-secret = "hash:ce62bb9bcced294fd4afe668f8ab3b50a89cf433093c526fffa3d0e46bf55252"
+enabled = true
+provider = "turnstile"
+secret = "hash:ed64b7695a606bc6ab4fcb41fe815b5ddf1063ccbc87afe1fa89756635db520e"

[hook]

6 changes: 6 additions & 0 deletions pkg/config/testdata/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ minimum_password_length = 6
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""

# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
[auth.captcha]
enabled = true
provider = "hcaptcha"
secret = "env(HCAPTCHA_SECRET)"

[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
Expand Down

0 comments on commit 90fd942

Please sign in to comment.