Skip to content

Commit

Permalink
feat: add turnstile support (supabase#1094)
Browse files Browse the repository at this point in the history
## Overview

Captcha providers are treated as generic in this PR. Users can swap out
the provider which in turn swaps out only the `siteverify` URL. This
approach generally works fine when considering `turnstile` and
`hcaptcha` since both have similar feature sets.

However, for other providers like `recaptcha` users might want to use
specialized features such as Android recaptcha and recaptcha V3 score.
Since the [responses slightly differ between an android response and a
generic response](https://developers.google.com/recaptcha/docs/verify),
we may need to introduce separate structs.

Another alternative considered was to initialize a new provider type for
each methods (similar to `SMSProvider`) and have corresponding
`verifyCaptcha` methods for each provider. This way there is clear
separation of decoding logic for response types for each provider but
there will be slightly more code to maintain.




### TODOs:
- [x] Manual testing with FE components

After PR:
- Update dashboard to reflect additional provider
- Update [hcaptcha
docs](https://supabase.com/docs/guides/auth/auth-captcha)

---------

Co-authored-by: joel@joellee.org <joel@joellee.org>
  • Loading branch information
2 people authored and LashaJini committed Nov 15, 2024
1 parent 1dfdfb9 commit daae58d
Show file tree
Hide file tree
Showing 5 changed files with 73 additions and 26 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -707,20 +707,20 @@ Or Messagebird credentials, which can be obtained in the [Dashboard](https://das

### CAPTCHA

- If enabled, CAPTCHA will check the request body for the `hcaptcha_token` field and make a verification request to the CAPTCHA provider.
- If enabled, CAPTCHA will check the request body for the `captcha_token` field and make a verification request to the CAPTCHA provider.

`SECURITY_CAPTCHA_ENABLED` - `string`

Whether captcha middleware is enabled

`SECURITY_CAPTCHA_PROVIDER` - `string`

for now the only option supported is: `hcaptcha`
for now the only options supported are: `hcaptcha` and `turnstile`

- `SECURITY_CAPTCHA_SECRET` - `string`
- `SECURITY_CAPTCHA_TIMEOUT` - `string`

Retrieve from hcaptcha account
Retrieve from hcaptcha or turnstile account

### Reauthentication

Expand Down
6 changes: 3 additions & 3 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,13 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
return ctx, nil
}

verificationResult, err := security.VerifyRequest(req, strings.TrimSpace(config.Security.Captcha.Secret))
verificationResult, err := security.VerifyRequest(req, strings.TrimSpace(config.Security.Captcha.Secret), config.Security.Captcha.Provider)
if err != nil {
return nil, internalServerError("hCaptcha verification process failed").WithInternalError(err)
return nil, internalServerError("captcha verification process failed").WithInternalError(err)
}

if !verificationResult.Success {
return nil, badRequestError("hCaptcha protection: request disallowed (%s)", strings.Join(verificationResult.ErrorCodes, ", "))
return nil, badRequestError("captcha protection: request disallowed (%s)", strings.Join(verificationResult.ErrorCodes, ", "))

}

Expand Down
51 changes: 41 additions & 10 deletions internal/api/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import (
)

const (
HCaptchaSecret string = "0x0000000000000000000000000000000000000000"
HCaptchaResponse string = "10000000-aaaa-bbbb-cccc-000000000001"
HCaptchaSecret string = "0x0000000000000000000000000000000000000000"
CaptchaResponse string = "10000000-aaaa-bbbb-cccc-000000000001"
TurnstileCaptchaSecret string = "1x0000000000000000000000000000000AA"
)

type MiddlewareTestSuite struct {
Expand All @@ -42,31 +43,51 @@ func TestMiddlewareFunctions(t *testing.T) {

func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() {
ts.Config.Security.Captcha.Enabled = true
ts.Config.Security.Captcha.Provider = "hcaptcha"
ts.Config.Security.Captcha.Secret = HCaptchaSecret

adminClaims := &GoTrueClaims{
Role: "supabase_admin",
}
adminJwt, err := jwt.NewWithClaims(jwt.SigningMethodHS256, adminClaims).SignedString([]byte(ts.Config.JWT.Secret))
require.NoError(ts.T(), err)
cases := []struct {
desc string
adminJwt string
captcha_token string
desc string
adminJwt string
captcha_token string
captcha_provider string
}{
{
"Valid captcha response",
"",
HCaptchaResponse,
CaptchaResponse,
"hcaptcha",
},
{
"Valid captcha response",
"",
CaptchaResponse,
"turnstile",
},
{
"Ignore captcha if admin role is present",
adminJwt,
"",
"hcaptcha",
},
{
"Ignore captcha if admin role is present",
adminJwt,
"",
"turnstile",
},
}
for _, c := range cases {
ts.Config.Security.Captcha.Provider = c.captcha_provider
if c.captcha_provider == "turnstile" {
ts.Config.Security.Captcha.Secret = TurnstileCaptchaSecret
} else if c.captcha_provider == "hcaptcha" {
ts.Config.Security.Captcha.Secret = HCaptchaSecret
}

var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"email": "test@example.com",
Expand Down Expand Up @@ -122,7 +143,17 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
Secret: "test",
},
http.StatusBadRequest,
"hCaptcha protection: request disallowed (not-using-dummy-secret)",
"captcha protection: request disallowed (not-using-dummy-secret)",
},
{
"Captcha validation failed",
&conf.CaptchaConfiguration{
Enabled: true,
Provider: "turnstile",
Secret: "anothertest",
},
http.StatusBadRequest,
"captcha protection: request disallowed (invalid-input-secret)",
},
}
for _, c := range cases {
Expand All @@ -133,7 +164,7 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
"email": "test@example.com",
"password": "secret",
"gotrue_meta_security": map[string]interface{}{
"captcha_token": HCaptchaResponse,
"captcha_token": CaptchaResponse,
},
}))
req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer)
Expand Down
4 changes: 2 additions & 2 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,14 @@ func (c *CaptchaConfiguration) Validate() error {
return nil
}

if c.Provider != "hcaptcha" {
if c.Provider != "hcaptcha" && c.Provider != "turnstile" {
return fmt.Errorf("unsupported captcha provider: %s", c.Provider)
}

c.Secret = strings.TrimSpace(c.Secret)

if c.Secret == "" {
return errors.New("hcaptcha provider secret is empty")
return errors.New("captcha provider secret is empty")
}

return nil
Expand Down
32 changes: 24 additions & 8 deletions internal/security/hcaptcha.go → internal/security/captcha.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"time"

"fmt"
"github.com/pkg/errors"
"github.com/supabase/gotrue/internal/utilities"
)
Expand Down Expand Up @@ -44,7 +45,7 @@ func init() {
Client = &http.Client{Timeout: defaultTimeout}
}

func VerifyRequest(r *http.Request, secretKey string) (VerificationResponse, error) {
func VerifyRequest(r *http.Request, secretKey, captchaProvider string) (VerificationResponse, error) {
bodyBytes, err := utilities.GetBodyBytes(r)
if err != nil {
return VerificationResponse{}, err
Expand All @@ -59,38 +60,53 @@ func VerifyRequest(r *http.Request, secretKey string) (VerificationResponse, err
captchaResponse := strings.TrimSpace(requestBody.Security.Token)

if captchaResponse == "" {
return VerificationResponse{}, errors.New("no hCaptcha response (captcha_token) found in request")
return VerificationResponse{}, errors.New("no captcha response (captcha_token) found in request")
}

clientIP := utilities.GetIPAddress(r)
captchaURL, err := GetCaptchaURL(captchaProvider)
if err != nil {
return VerificationResponse{}, err
}

return verifyCaptchaCode(captchaResponse, secretKey, clientIP)
return verifyCaptchaCode(captchaResponse, secretKey, clientIP, captchaURL)
}

func verifyCaptchaCode(token string, secretKey string, clientIP string) (VerificationResponse, error) {
func verifyCaptchaCode(token, secretKey, clientIP, captchaURL string) (VerificationResponse, error) {
data := url.Values{}
data.Set("secret", secretKey)
data.Set("response", token)
data.Set("remoteip", clientIP)
// TODO (darora): pipe through sitekey

r, err := http.NewRequest("POST", "https://hcaptcha.com/siteverify", strings.NewReader(data.Encode()))
r, err := http.NewRequest("POST", captchaURL, strings.NewReader(data.Encode()))
if err != nil {
return VerificationResponse{}, errors.Wrap(err, "couldn't initialize request object for hcaptcha check")
return VerificationResponse{}, errors.Wrap(err, "couldn't initialize request object for captcha check")
}
r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
r.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
res, err := Client.Do(r)
if err != nil {
return VerificationResponse{}, errors.Wrap(err, "failed to verify hcaptcha response")
return VerificationResponse{}, errors.Wrap(err, "failed to verify captcha response")
}
defer utilities.SafeClose(res.Body)

var verificationResponse VerificationResponse

if err := json.NewDecoder(res.Body).Decode(&verificationResponse); err != nil {
return VerificationResponse{}, errors.Wrap(err, "failed to decode hcaptcha response: not JSON")
return VerificationResponse{}, errors.Wrap(err, "failed to decode captcha response: not JSON")
}

return verificationResponse, nil
}

func GetCaptchaURL(captchaProvider string) (string, error) {
switch captchaProvider {
case "hcaptcha":
return "https://hcaptcha.com/siteverify", nil
case "turnstile":
return "https://challenges.cloudflare.com/turnstile/v0/siteverify", nil
default:
return "", fmt.Errorf("captcha Provider %q could not be found", captchaProvider)
}
}

0 comments on commit daae58d

Please sign in to comment.