Repository: supabase/gotrue
Pull request #192 (2021-08-29 05:41:40)

feat: [alpha] basic hcaptcha support
## What kind of change does this PR introduce?

Adds support for HCaptcha protection on routes that send out emails/SMS. Should be considered alpha - API might be changed in the future.

## What is the current behavior?

n/a

## What is the new behavior?

If HCaptcha support is enabled, and one is not provided as part of the request, the request is rejected.




Patch:
diff --git a/README.md b/README.md
index f32f93a7..3b9fa718 100644
--- a/README.md
+++ b/README.md
@@ -440,6 +440,21 @@ Then you can use your [twilio credentials](https://www.twilio.com/docs/usage/req
 - `SMS_TWILIO_AUTH_TOKEN`
 - `SMS_TWILIO_MESSAGE_SERVICE_SID` - can be set to your twilio sender mobile number
 
+### CAPTCHA
+* If enabled, CAPTCHA will check the request body for the `hcaptcha_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`
+
+`SECURITY_CAPTCHA_SECRET` - `string`
+
+Retrieve from hcaptcha account
+
 ## Endpoints
 
 GoTrue exposes the following endpoints:
diff --git a/api/api.go b/api/api.go
index 20905f84..fdf88a44 100644
--- a/api/api.go
+++ b/api/api.go
@@ -116,10 +116,10 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 		r.With(api.requireAdminCredentials).Post("/invite", api.Invite)
 
-		r.Post("/signup", api.Signup)
-		r.With(api.requireEmailProvider).Post("/recover", api.Recover)
-		r.Post("/magiclink", api.MagicLink)
-		r.Post("/otp", api.Otp)
+		r.With(api.verifyCaptcha).Post("/signup", api.Signup)
+		r.With(api.verifyCaptcha).With(api.requireEmailProvider).Post("/recover", api.Recover)
+		r.With(api.verifyCaptcha).Post("/magiclink", api.MagicLink)
+		r.With(api.verifyCaptcha).Post("/otp", api.Otp)
 
 		r.With(api.requireEmailProvider).With(api.limitHandler(
 			// Allow requests at a rate of 30 per 5 minutes.
diff --git a/api/middleware.go b/api/middleware.go
index a6e080e1..e2b7d1d5 100644
--- a/api/middleware.go
+++ b/api/middleware.go
@@ -4,9 +4,12 @@ import (
 	"bytes"
 	"context"
 	"encoding/json"
+	"github.com/netlify/gotrue/security"
+	"github.com/sirupsen/logrus"
 	"io"
 	"io/ioutil"
 	"net/http"
+	"strings"
 
 	"github.com/didip/tollbooth/v5"
 	"github.com/didip/tollbooth/v5/limiter"
@@ -176,3 +179,33 @@ func (a *API) requireEmailProvider(w http.ResponseWriter, req *http.Request) (co
 
 	return ctx, nil
 }
+
+func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.Context, error) {
+	ctx := req.Context()
+	config := a.getConfig(ctx)
+	if !config.Security.Captcha.Enabled {
+		return ctx, nil
+	}
+	if config.Security.Captcha.Provider != "hcaptcha" {
+		logrus.WithField("provider", config.Security.Captcha.Provider).Warn("Unsupported captcha provider")
+		return nil, internalServerError("server misconfigured")
+	}
+	secret := strings.TrimSpace(config.Security.Captcha.Secret)
+	if secret == "" {
+		return nil, internalServerError("server misconfigured")
+	}
+	verificationResult, err := security.VerifyRequest(req, secret)
+	if err != nil {
+		logrus.WithField("err", err).Infof("failed to validate result")
+		return nil, internalServerError("request validation failure")
+	}
+	if verificationResult == security.VerificationProcessFailure {
+		return nil, internalServerError("request validation failure")
+	} else if verificationResult == security.UserRequestFailed {
+		return nil, badRequestError("request disallowed")
+	}
+	if verificationResult == security.SuccessfullyVerified {
+		return ctx, nil
+	}
+	return nil, internalServerError("")
+}
diff --git a/conf/configuration.go b/conf/configuration.go
index 13295150..e2cace74 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -132,6 +132,16 @@ type TwilioProviderConfiguration struct {
 	MessageServiceSid string `json:"message_service_sid" split_words:"true"`
 }
 
+type CaptchaConfiguration struct {
+	Enabled  bool   `json:"enabled" default:"false"`
+	Provider string `json:"provider" default:"hcaptcha"`
+	Secret   string `json:"provider_secret"`
+}
+
+type SecurityConfiguration struct {
+	Captcha CaptchaConfiguration `json:"captcha"`
+}
+
 // Configuration holds all the per-instance configuration.
 type Configuration struct {
 	SiteURL           string                   `json:"site_url" split_words:"true" required:"true"`
@@ -144,6 +154,7 @@ type Configuration struct {
 	Sms               SmsProviderConfiguration `json:"sms"`
 	DisableSignup     bool                     `json:"disable_signup" split_words:"true"`
 	Webhook           WebhookConfig            `json:"webhook" split_words:"true"`
+	Security          SecurityConfiguration    `json:"security"`
 	Cookie            struct {
 		Key      string `json:"key"`
 		Duration int    `json:"duration"`
diff --git a/example.env b/example.env
index 72e335d9..0b4ada65 100644
--- a/example.env
+++ b/example.env
@@ -23,3 +23,7 @@ GOTRUE_WEBHOOK_SECRET=test_secret
 GOTRUE_WEBHOOK_RETRIES=5
 GOTRUE_WEBHOOK_TIMEOUT_SEC=3
 GOTRUE_WEBHOOK_EVENTS=validate,signup,login
+
+GOTRUE_SECURITY_CAPTCHA_ENABLED="true"
+GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha"
+GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000"
diff --git a/security/hcaptcha.go b/security/hcaptcha.go
new file mode 100644
index 00000000..93418fc2
--- /dev/null
+++ b/security/hcaptcha.go
@@ -0,0 +1,88 @@
+package security
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"github.com/pkg/errors"
+	"github.com/sirupsen/logrus"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type CaptchaResponse struct {
+	Token string `json:"hcaptcha_token"`
+}
+
+type VerificationResponse struct {
+	Success    bool     `json:"success"`
+	ErrorCodes []string `json:"error-codes"`
+	Hostname   string   `json:"hostname"`
+}
+
+type VerificationResult int
+const (
+	UserRequestFailed VerificationResult = iota
+	VerificationProcessFailure
+	SuccessfullyVerified
+)
+var Client *http.Client
+
+func init() {
+	// TODO (darora): make timeout configurable
+	Client = &http.Client{Timeout: 10 * time.Second}
+}
+
+func VerifyRequest(r *http.Request, secretKey string) (VerificationResult, error) {
+	res := CaptchaResponse{}
+	bodyBytes, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		return UserRequestFailed, err
+	}
+	r.Body.Close()
+	// re-init body so downstream route handlers don't get borked
+	r.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
+
+	jsonDecoder := json.NewDecoder(bytes.NewBuffer(bodyBytes))
+	err = jsonDecoder.Decode(&res)
+	if err != nil || strings.TrimSpace(res.Token) == "" {
+		return UserRequestFailed, errors.Wrap(err, "couldn't decode captcha info")
+	}
+	clientIP := strings.Split(r.RemoteAddr, ":")[0]
+	return verifyCaptchaCode(res.Token, secretKey, clientIP)
+}
+
+func verifyCaptchaCode(token string, secretKey string, clientIP string) (VerificationResult, 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()))
+	if err != nil {
+		return VerificationProcessFailure, errors.Wrap(err, "couldn't initialize request object for hcaptcha 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 VerificationProcessFailure, errors.Wrap(err, "failed to verify hcaptcha token")
+	}
+	verResult := VerificationResponse{}
+	defer res.Body.Close()
+	decoder := json.NewDecoder(res.Body)
+	err = decoder.Decode(&verResult)
+	if err != nil {
+		return VerificationProcessFailure, errors.Wrap(err, "failed to decode hcaptcha response")
+	}
+	logrus.WithField("result", verResult).Info("obtained hcaptcha verification result")
+	if !verResult.Success {
+		return UserRequestFailed, fmt.Errorf("user request suppressed by hcaptcha")
+	}
+	return SuccessfullyVerified, nil
+}


Test patch:
diff --git a/api/middleware_test.go b/api/middleware_test.go
index 28552d51..01b25828 100644
--- a/api/middleware_test.go
+++ b/api/middleware_test.go
@@ -1,12 +1,147 @@
 package api
 
 import (
+	"bytes"
 	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
 	"testing"
 
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/conf"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
 )
 
+const (
+	HCaptchaSecret   string = "0x0000000000000000000000000000000000000000"
+	HCaptchaResponse string = "10000000-aaaa-bbbb-cccc-000000000001"
+)
+
+type MiddlewareTestSuite struct {
+	suite.Suite
+	API    *API
+	Config *conf.Configuration
+
+	instanceID uuid.UUID
+}
+
+func TestHCaptcha(t *testing.T) {
+	api, config, instanceID, err := setupAPIForTestForInstance()
+	require.NoError(t, err)
+
+	ts := &MiddlewareTestSuite{
+		API:        api,
+		Config:     config,
+		instanceID: instanceID,
+	}
+	defer api.db.Close()
+
+	suite.Run(t, ts)
+}
+
+func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() {
+	var buffer bytes.Buffer
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"email":          "test@example.com",
+		"password":       "secret",
+		"hcaptcha_token": HCaptchaResponse,
+	}))
+
+	ts.Config.Security.Captcha.Enabled = true
+	ts.Config.Security.Captcha.Provider = "hcaptcha"
+	ts.Config.Security.Captcha.Secret = HCaptchaSecret
+
+	req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+	beforeCtx, err := WithInstanceConfig(req.Context(), ts.Config, ts.instanceID)
+	require.NoError(ts.T(), err)
+
+	req = req.WithContext(beforeCtx)
+
+	w := httptest.NewRecorder()
+
+	afterCtx, err := ts.API.verifyCaptcha(w, req)
+	require.NoError(ts.T(), err)
+
+	body, err := ioutil.ReadAll(req.Body)
+
+	// re-initialize buffer
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"email":          "test@example.com",
+		"password":       "secret",
+		"hcaptcha_token": HCaptchaResponse,
+	}))
+
+	// check if body is the same
+	require.Equal(ts.T(), body, buffer.Bytes())
+	require.Equal(ts.T(), afterCtx, beforeCtx)
+}
+
+func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
+	cases := []struct {
+		desc         string
+		captchaConf  *conf.CaptchaConfiguration
+		expectedCode int
+		expectedMsg  string
+	}{
+		{
+			"Unsupported provider",
+			&conf.CaptchaConfiguration{
+				Enabled:  true,
+				Provider: "test",
+			},
+			http.StatusInternalServerError,
+			"server misconfigured",
+		},
+		{
+			"Missing secret",
+			&conf.CaptchaConfiguration{
+				Enabled:  true,
+				Provider: "hcaptcha",
+				Secret:   "",
+			},
+			http.StatusInternalServerError,
+			"server misconfigured",
+		},
+		{
+			"Captcha validation failed",
+			&conf.CaptchaConfiguration{
+				Enabled:  true,
+				Provider: "hcaptcha",
+				Secret:   "test",
+			},
+			http.StatusInternalServerError,
+			"request validation failure",
+		},
+	}
+	for _, c := range cases {
+		ts.Run(c.desc, func() {
+			ts.Config.Security.Captcha = *c.captchaConf
+			var buffer bytes.Buffer
+			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+				"email":          "test@example.com",
+				"password":       "secret",
+				"hcaptcha_token": HCaptchaResponse,
+			}))
+			req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer)
+			req.Header.Set("Content-Type", "application/json")
+			ctx, err := WithInstanceConfig(req.Context(), ts.Config, ts.instanceID)
+			require.NoError(ts.T(), err)
+
+			req = req.WithContext(ctx)
+
+			w := httptest.NewRecorder()
+
+			_, err = ts.API.verifyCaptcha(w, req)
+			require.Equal(ts.T(), err.(*HTTPError).Code, c.expectedCode)
+			require.Equal(ts.T(), err.(*HTTPError).Message, c.expectedMsg)
+		})
+	}
+}
+
 func TestFunctionHooksUnmarshalJSON(t *testing.T) {
 	tests := []struct {
 		in string
diff --git a/hack/test.env b/hack/test.env
index bad1aef9..5eb7b502 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -49,3 +49,6 @@ GOTRUE_TRACING_ENABLED=false
 GOTRUE_TRACING_HOST=127.0.0.1
 GOTRUE_TRACING_PORT=8126
 GOTRUE_TRACING_TAGS="env:test"
+GOTRUE_SECURITY_CAPTCHA_ENABLED="false"
+GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha"
+GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000"