Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/supabase/auth/internal/models"
"github.com/supabase/auth/internal/observability"
"github.com/supabase/auth/internal/sbff"
"github.com/supabase/auth/internal/security"
"github.com/supabase/auth/internal/storage"
"github.com/supabase/auth/internal/tokens"
"github.com/supabase/auth/internal/utilities"
Expand Down Expand Up @@ -48,6 +49,8 @@ type API struct {
mailer mailer.Mailer
oidcCache *provider.OIDCProviderCache

captchaVerifier security.CaptchaVerifier

// overrideTime can be used to override the clock used by handlers. Should only be used in tests!
overrideTime func() time.Time

Expand Down Expand Up @@ -103,6 +106,9 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
for _, o := range opt {
o.apply(api)
}
if api.captchaVerifier == nil {
api.captchaVerifier = security.NewCaptchaVerifier(&globalConfig.Security.Captcha)
}
if api.limiterOpts == nil {
api.limiterOpts = NewLimiterOptions(globalConfig)
}
Expand Down Expand Up @@ -308,6 +314,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne

r.Route("/authentication", func(r *router) {
r.With(api.limitHandler(api.limiterOpts.PasskeyAuthentication)).
With(api.verifyCaptcha).
Post("/options", api.PasskeyAuthenticationOptions)
r.Post("/verify", api.PasskeyAuthenticationVerify)
})
Expand Down
26 changes: 26 additions & 0 deletions internal/api/captcha_mock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package api

import (
"context"

"github.com/supabase/auth/internal/security"
)

// MockCaptchaVerifier is a mock implementation of security.CaptchaVerifier.
type MockCaptchaVerifier struct {
Result *security.VerificationResponse
Err error
LastToken string
LastClientIP string
}

func (m *MockCaptchaVerifier) Verify(ctx context.Context, token, clientIP string) (*security.VerificationResponse, error) {
m.LastToken = token
m.LastClientIP = clientIP

if m.Err != nil {
return nil, m.Err
}

return m.Result, nil
}
3 changes: 1 addition & 2 deletions internal/api/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"github.com/supabase/auth/internal/api/shared"
"github.com/supabase/auth/internal/conf"
"github.com/supabase/auth/internal/models"
"github.com/supabase/auth/internal/security"

"github.com/supabase/auth/internal/utilities"
)
Expand Down Expand Up @@ -69,7 +68,7 @@ type RequestParams interface {
VerifyParams |
adminUserUpdateFactorParams |
adminUserDeleteParams |
security.GotrueRequest |
captchaRequest |
ChallengeFactorParams |

struct {
Expand Down
22 changes: 19 additions & 3 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,21 @@ import (
"github.com/supabase/auth/internal/models"
"github.com/supabase/auth/internal/observability"
"github.com/supabase/auth/internal/sbff"
"github.com/supabase/auth/internal/security"
"github.com/supabase/auth/internal/utilities"

"github.com/didip/tollbooth/v5"
"github.com/didip/tollbooth/v5/limiter"
jwt "github.com/golang-jwt/jwt/v5"
)

type captchaRequest struct {
Security captchaSecurity `json:"gotrue_meta_security"`
}

type captchaSecurity struct {
Token string `json:"captcha_token"`
}

type FunctionHooks map[string][]string

type AuthMicroserviceClaims struct {
Expand Down Expand Up @@ -217,12 +224,21 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
return ctx, nil
}

body := &security.GotrueRequest{}
body := &captchaRequest{}
if err := retrieveRequestParams(req, body); err != nil {
return nil, err
}

verificationResult, err := security.VerifyRequest(body, utilities.GetIPAddress(req), strings.TrimSpace(config.Security.Captcha.Secret), config.Security.Captcha.Provider)
token := strings.TrimSpace(body.Security.Token)
if token == "" {
return nil, apierrors.NewBadRequestError(apierrors.ErrorCodeCaptchaFailed, "captcha protection: request disallowed (no captcha_token found)")
}

verificationResult, err := a.captchaVerifier.Verify(
ctx,
token,
utilities.GetIPAddress(req),
)
if err != nil {
return nil, apierrors.NewInternalServerError("captcha verification process failed").WithInternalError(err)
}
Expand Down
94 changes: 46 additions & 48 deletions internal/api/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,28 @@ import (
"github.com/supabase/auth/internal/api/apierrors"
"github.com/supabase/auth/internal/conf"
"github.com/supabase/auth/internal/sbff"
"github.com/supabase/auth/internal/security"
"github.com/supabase/auth/internal/storage"
)

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

type MiddlewareTestSuite struct {
suite.Suite
API *API
Config *conf.GlobalConfiguration
API *API
Config *conf.GlobalConfiguration
CaptchaVerifier *MockCaptchaVerifier
}

func TestMiddlewareFunctions(t *testing.T) {
api, config, err := setupAPIForTest()
mockCaptcha := &MockCaptchaVerifier{}
api, config, err := setupAPIForTest(WithCaptchaVerifier(mockCaptcha))
require.NoError(t, err)

ts := &MiddlewareTestSuite{
API: api,
Config: config,
API: api,
Config: config,
CaptchaVerifier: mockCaptcha,
}
defer api.db.Close()

Expand All @@ -50,50 +50,41 @@ 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 = "test-secret"

// Configure mock to return success
ts.CaptchaVerifier.Result = &security.VerificationResponse{Success: true}
ts.CaptchaVerifier.Err = nil

adminClaims := &AccessTokenClaims{
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
captcha_provider string
desc string
adminJwt string
captcha_token string
expectVerify bool
}{
{
"Valid captcha response",
"",
CaptchaResponse,
"hcaptcha",
},
{
"Valid captcha response",
"",
CaptchaResponse,
"turnstile",
},
{
"Ignore captcha if admin role is present",
adminJwt,
"",
"hcaptcha",
captchaResponse,
true,
},
{
"Ignore captcha if admin role is present",
adminJwt,
"",
"turnstile",
false,
},
}
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
}
// Reset mock state between cases
ts.CaptchaVerifier.LastToken = ""
ts.CaptchaVerifier.LastClientIP = ""

var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
Expand All @@ -117,6 +108,12 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() {
afterCtx, err := ts.API.verifyCaptcha(w, req)
require.NoError(ts.T(), err)

if c.expectVerify {
require.Equal(ts.T(), c.captcha_token, ts.CaptchaVerifier.LastToken)
} else {
require.Empty(ts.T(), ts.CaptchaVerifier.LastToken)
}

body, err := io.ReadAll(req.Body)
require.NoError(ts.T(), err)

Expand All @@ -138,40 +135,41 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() {
func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
cases := []struct {
desc string
captchaConf *conf.CaptchaConfiguration
errorCodes []string
expectedCode int
expectedMsg string
}{
{
"Captcha validation failed",
&conf.CaptchaConfiguration{
Enabled: true,
Provider: "hcaptcha",
Secret: "test",
},
[]string{"not-using-dummy-secret"},
http.StatusBadRequest,
"captcha protection: request disallowed (not-using-dummy-secret)",
},
{
"Captcha validation failed",
&conf.CaptchaConfiguration{
Enabled: true,
Provider: "turnstile",
Secret: "anothertest",
},
[]string{"invalid-input-secret"},
http.StatusBadRequest,
"captcha protection: request disallowed (invalid-input-secret)",
},
}
for _, c := range cases {
ts.Run(c.desc, func() {
ts.Config.Security.Captcha = *c.captchaConf
ts.Config.Security.Captcha.Enabled = true
ts.Config.Security.Captcha.Provider = "hcaptcha"
ts.Config.Security.Captcha.Secret = "test-secret"

ts.CaptchaVerifier.Result = &security.VerificationResponse{
Success: false,
ErrorCodes: c.errorCodes,
}
ts.CaptchaVerifier.Err = nil

var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"email": "test@example.com",
"password": "secret",
"gotrue_meta_security": map[string]interface{}{
"captcha_token": CaptchaResponse,
"captcha_token": captchaResponse,
},
}))
req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer)
Expand Down
7 changes: 7 additions & 0 deletions internal/api/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/supabase/auth/internal/conf"
"github.com/supabase/auth/internal/mailer"
"github.com/supabase/auth/internal/ratelimit"
"github.com/supabase/auth/internal/security"
"github.com/supabase/auth/internal/tokens"
)

Expand All @@ -31,6 +32,12 @@ func WithTokenService(service *tokens.Service) Option {
})
}

func WithCaptchaVerifier(v security.CaptchaVerifier) Option {
return optionFunc(func(a *API) {
a.captchaVerifier = v
})
}

type LimiterOptions struct {
Email ratelimit.Limiter
Phone ratelimit.Limiter
Expand Down
Loading
Loading