Skip to content

Commit

Permalink
feat: anonymous sign-ins (#1460)
Browse files Browse the repository at this point in the history
## What kind of change does this PR introduce?
* Implements #68 
* An anonymous user is defined as a user that doesn't have an email or
phone in the `auth.users` table. This is tracked by using a generated
column called `auth.users.is_anonymous`
* When an anonymous user signs-in, the JWT payload will contain an
`is_anonymous` claim which can be used in RLS policies as mentioned in
[Option
3](#68 (comment)).
```json
{
  ...
  "is_anonymous": true
}
```
* Allows anonymous sign-ins if `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED`
is enabled
* Anonymous sign-ins are rate limited on a per hourly basis and
controlled by `GOTRUE_RATE_LIMIT_ANONYMOUS_USERS`. This is an ip-based
rate limit.
* You can also configure silent captcha / turnstile to prevent abuse
* There are 2 ways to upgrade an anonymous user to a permanent user:
  1. Link an email / phone identity to an anonymous user `PUT /user` 
2. Link an oauth identity using `GET
/user/identities/authorize?provider=xxx`

## Example
```bash
# Sign in as an anonymous user
curl -X POST 'http://localhost:9999/signup' \
-H 'Content-Type: application/json' \
-d '{}'

# Upgrade an anonymous user to a permanent user with an email identity
curl -X PUT 'http://localhost:9999/user' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token_of_anonymous_user>' \
-d '{"email": "user@example.com"}'

# Upgrade an anonymous to a permanent user with an oauth identity
curl -X GET 'http://localhost:9999/user/identities/authorize?provider=google' \
-H 'Authorization: Bearer <access_token_of_anonymous_user>
```

## Follow-ups
* Cleanup logic for anonymous users will be made in a separate PR
  • Loading branch information
kangmingtay authored Mar 3, 2024
1 parent e9f38e7 commit 130df16
Show file tree
Hide file tree
Showing 19 changed files with 517 additions and 133 deletions.
2 changes: 2 additions & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI=https://identity.services.netlify.com/callback
GOTRUE_EXTERNAL_FLOW_STATE_EXPIRY_DURATION="300s"
GOTRUE_RATE_LIMIT_VERIFY="100000"
GOTRUE_RATE_LIMIT_TOKEN_REFRESH="30"
GOTRUE_RATE_LIMIT_ANONYMOUS_USERS="5"
GOTRUE_RATE_LIMIT_HEADER="My-Custom-Header"
GOTRUE_TRACING_ENABLED=true
GOTRUE_TRACING_EXPORTER=default
GOTRUE_TRACING_HOST=127.0.0.1
Expand Down
58 changes: 58 additions & 0 deletions internal/api/anonymous.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package api

import (
"net/http"

"github.com/supabase/auth/internal/metering"
"github.com/supabase/auth/internal/models"
"github.com/supabase/auth/internal/storage"
)

func (a *API) SignupAnonymously(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
config := a.config
db := a.db.WithContext(ctx)
aud := a.requestAud(ctx, r)

if config.DisableSignup {
return forbiddenError("Signups not allowed for this instance")
}

params, err := retrieveSignupParams(r)
if err != nil {
return err
}
params.Aud = aud
params.Provider = "anonymous"

newUser, err := params.ToUserModel(false /* <- isSSOUser */)
if err != nil {
return err
}

var grantParams models.GrantParams
grantParams.FillGrantParams(r)

var token *AccessTokenResponse
err = db.Transaction(func(tx *storage.Connection) error {
var terr error
newUser, terr = a.signupNewUser(ctx, tx, newUser)
if terr != nil {
return terr
}
token, terr = a.issueRefreshToken(ctx, tx, newUser, models.Anonymous, grantParams)
if terr != nil {
return terr
}
if terr := a.setCookieTokens(config, token, false, w); terr != nil {
return terr
}
return nil
})
if err != nil {
return internalServerError("Database error creating anonymous user").WithInternalError(err)
}

metering.RecordLogin("anonymous", newUser.ID)
return sendJSON(w, http.StatusOK, token)
}
177 changes: 177 additions & 0 deletions internal/api/anonymous_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package api

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/supabase/auth/internal/conf"
"github.com/supabase/auth/internal/models"
)

type AnonymousTestSuite struct {
suite.Suite
API *API
Config *conf.GlobalConfiguration
}

func TestAnonymous(t *testing.T) {
api, config, err := setupAPIForTest()
require.NoError(t, err)

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

suite.Run(t, ts)
}

func (ts *AnonymousTestSuite) SetupTest() {
models.TruncateAll(ts.API.db)

// Create anonymous user
params := &SignupParams{
Aud: ts.Config.JWT.Aud,
Provider: "anonymous",
}
u, err := params.ToUserModel(false)
require.NoError(ts.T(), err, "Error creating test user model")
require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new anonymous test user")
}

func (ts *AnonymousTestSuite) TestAnonymousLogins() {
ts.Config.External.AnonymousUsers.Enabled = true
// Request body
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"data": map[string]interface{}{
"field": "foo",
},
}))

req := httptest.NewRequest(http.MethodPost, "/signup", &buffer)
req.Header.Set("Content-Type", "application/json")

w := httptest.NewRecorder()

ts.API.handler.ServeHTTP(w, req)
require.Equal(ts.T(), http.StatusOK, w.Code)

data := &AccessTokenResponse{}
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
assert.NotEmpty(ts.T(), data.User.ID)
assert.Equal(ts.T(), ts.Config.JWT.Aud, data.User.Aud)
assert.Empty(ts.T(), data.User.GetEmail())
assert.Empty(ts.T(), data.User.GetPhone())
assert.True(ts.T(), data.User.IsAnonymous)
assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"field": "foo"}), data.User.UserMetaData)
}

func (ts *AnonymousTestSuite) TestConvertAnonymousUserToPermanent() {
ts.Config.External.AnonymousUsers.Enabled = true
// Request body
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))

req := httptest.NewRequest(http.MethodPost, "/signup", &buffer)
req.Header.Set("Content-Type", "application/json")

w := httptest.NewRecorder()

ts.API.handler.ServeHTTP(w, req)
require.Equal(ts.T(), http.StatusOK, w.Code)

signupResponse := &AccessTokenResponse{}
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&signupResponse))

// Add email to anonymous user
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"email": "test@example.com",
}))

req = httptest.NewRequest(http.MethodPut, "/user", &buffer)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signupResponse.Token))

w = httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
require.Equal(ts.T(), http.StatusOK, w.Code)

// Check if anonymous user is still anonymous
user, err := models.FindUserByID(ts.API.db, signupResponse.User.ID)
require.NoError(ts.T(), err)
require.NotEmpty(ts.T(), user)
require.True(ts.T(), user.IsAnonymous)

// Verify email change
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"token_hash": user.EmailChangeTokenNew,
"type": "email_change",
}))

req = httptest.NewRequest(http.MethodPost, "/verify", &buffer)
req.Header.Set("Content-Type", "application/json")

w = httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
require.Equal(ts.T(), http.StatusOK, w.Code)

data := &AccessTokenResponse{}
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))

// User is a permanent user and not anonymous anymore
assert.Equal(ts.T(), signupResponse.User.ID, data.User.ID)
assert.Equal(ts.T(), ts.Config.JWT.Aud, data.User.Aud)
assert.Equal(ts.T(), "test@example.com", data.User.GetEmail())
assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"provider": "email", "providers": []interface{}{"email"}}), data.User.AppMetaData)
assert.False(ts.T(), data.User.IsAnonymous)
assert.NotEmpty(ts.T(), data.User.EmailConfirmedAt)

// User should have an email identity
assert.Len(ts.T(), data.User.Identities, 1)
}

func (ts *AnonymousTestSuite) TestRateLimitAnonymousSignups() {
var buffer bytes.Buffer
ts.Config.External.AnonymousUsers.Enabled = true

// It rate limits after 30 requests
for i := 0; i < int(ts.Config.RateLimitAnonymousUsers); i++ {
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))
req := httptest.NewRequest(http.MethodPost, "http://localhost/signup", &buffer)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("My-Custom-Header", "1.2.3.4")
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
assert.Equal(ts.T(), http.StatusOK, w.Code)
}

require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))
req := httptest.NewRequest(http.MethodPost, "http://localhost/signup", &buffer)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("My-Custom-Header", "1.2.3.4")
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
assert.Equal(ts.T(), http.StatusTooManyRequests, w.Code)

// It ignores X-Forwarded-For by default
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))
req.Header.Set("X-Forwarded-For", "1.1.1.1")
w = httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
assert.Equal(ts.T(), http.StatusTooManyRequests, w.Code)

// It doesn't rate limit a new value for the limited header
req.Header.Set("My-Custom-Header", "5.6.7.8")
w = httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
assert.Equal(ts.T(), http.StatusBadRequest, w.Code)
}
24 changes: 23 additions & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,28 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati

sharedLimiter := api.limitEmailOrPhoneSentHandler()
r.With(sharedLimiter).With(api.requireAdminCredentials).Post("/invite", api.Invite)
r.With(sharedLimiter).With(api.verifyCaptcha).Post("/signup", api.Signup)
r.With(sharedLimiter).With(api.verifyCaptcha).Route("/signup", func(r *router) {
// rate limit per hour
limiter := tollbooth.NewLimiter(api.config.RateLimitAnonymousUsers/(60*60), &limiter.ExpirableOptions{
DefaultExpirationTTL: time.Hour,
}).SetBurst(int(api.config.RateLimitAnonymousUsers)).SetMethods([]string{"POST"})
r.Post("/", func(w http.ResponseWriter, r *http.Request) error {
params, err := retrieveSignupParams(r)
if err != nil {
return err
}
if params.Email == "" && params.Phone == "" {
if !api.config.External.AnonymousUsers.Enabled {
return unprocessableEntityError("Anonymous sign-ins are disabled")
}
if _, err := api.limitHandler(limiter)(w, r); err != nil {
return err
}
return api.SignupAnonymously(w, r)
}
return api.Signup(w, r)
})
})
r.With(sharedLimiter).With(api.verifyCaptcha).With(api.requireEmailProvider).Post("/recover", api.Recover)
r.With(sharedLimiter).With(api.verifyCaptcha).Post("/resend", api.Resend)
r.With(sharedLimiter).With(api.verifyCaptcha).Post("/magiclink", api.MagicLink)
Expand Down Expand Up @@ -185,6 +206,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
})

r.With(api.requireAuthentication).Route("/factors", func(r *router) {
r.Use(api.requireNotAnonymous)
r.Post("/", api.EnrollFactor)
r.Route("/{factor_id}", func(r *router) {
r.Use(api.loadFactor)
Expand Down
9 changes: 9 additions & 0 deletions internal/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ func (a *API) requireAuthentication(w http.ResponseWriter, r *http.Request) (con
return ctx, err
}

func (a *API) requireNotAnonymous(w http.ResponseWriter, r *http.Request) (context.Context, error) {
ctx := r.Context()
claims := getClaims(ctx)
if claims.IsAnonymous {
return nil, forbiddenError("Anonymous user not allowed to perform these actions")
}
return ctx, nil
}

func (a *API) requireAdmin(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, error) {
// Find the administrative user
claims := getClaims(ctx)
Expand Down
2 changes: 1 addition & 1 deletion internal/api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
err = db.Transaction(func(tx *storage.Connection) error {
var terr error
if targetUser := getTargetUser(ctx); targetUser != nil {
if user, terr = a.linkIdentityToUser(ctx, tx, userData, providerType); terr != nil {
if user, terr = a.linkIdentityToUser(r, ctx, tx, userData, providerType); terr != nil {
return terr
}
} else if inviteToken := getInviteToken(ctx); inviteToken != "" {
Expand Down
38 changes: 36 additions & 2 deletions internal/api/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ package api

import (
"context"
"fmt"
"net/http"

"github.com/fatih/structs"
"github.com/go-chi/chi"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/supabase/auth/internal/api/provider"
"github.com/supabase/auth/internal/models"
"github.com/supabase/auth/internal/storage"
"github.com/supabase/auth/internal/utilities"
)

func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error {
Expand Down Expand Up @@ -57,7 +60,7 @@ func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error {
if terr := tx.Destroy(identityToBeDeleted); terr != nil {
return internalServerError("Database error deleting identity").WithInternalError(terr)
}
if terr := user.UpdateUserEmail(tx); terr != nil {
if terr := user.UpdateUserEmailFromIdentities(tx); terr != nil {
if models.IsUniqueConstraintViolatedError(terr) {
return forbiddenError("Unable to unlink identity due to email conflict").WithInternalError(terr)
}
Expand Down Expand Up @@ -92,7 +95,7 @@ func (a *API) LinkIdentity(w http.ResponseWriter, r *http.Request) error {
return nil
}

func (a *API) linkIdentityToUser(ctx context.Context, tx *storage.Connection, userData *provider.UserProvidedData, providerType string) (*models.User, error) {
func (a *API) linkIdentityToUser(r *http.Request, ctx context.Context, tx *storage.Connection, userData *provider.UserProvidedData, providerType string) (*models.User, error) {
targetUser := getTargetUser(ctx)
identity, terr := models.FindIdentityByIdAndProvider(tx, userData.Metadata.Subject, providerType)
if terr != nil {
Expand All @@ -109,6 +112,37 @@ func (a *API) linkIdentityToUser(ctx context.Context, tx *storage.Connection, us
if _, terr := a.createNewIdentity(tx, targetUser, providerType, structs.Map(userData.Metadata)); terr != nil {
return nil, terr
}

if targetUser.GetEmail() == "" {
if terr := targetUser.UpdateUserEmailFromIdentities(tx); terr != nil {
if models.IsUniqueConstraintViolatedError(terr) {
return nil, badRequestError(DuplicateEmailMsg)
}
return nil, terr
}
if !userData.Metadata.EmailVerified {
mailer := a.Mailer(ctx)
referrer := utilities.GetReferrer(r, a.config)
externalURL := getExternalHost(ctx)
if terr := sendConfirmation(tx, targetUser, mailer, a.config.SMTP.MaxFrequency, referrer, externalURL, a.config.Mailer.OtpLength, models.ImplicitFlow); terr != nil {
if errors.Is(terr, MaxFrequencyLimitError) {
return nil, tooManyRequestsError("For security purposes, you can only request this once every minute")
}
}
return nil, storage.NewCommitWithError(unauthorizedError(fmt.Sprintf("Unverified email with %v. A confirmation email has been sent to your %v email", providerType, providerType)))
}
if terr := targetUser.Confirm(tx); terr != nil {
return nil, terr
}

if targetUser.IsAnonymous {
targetUser.IsAnonymous = false
if terr := tx.UpdateOnly(targetUser, "is_anonymous"); terr != nil {
return nil, terr
}
}
}

if terr := targetUser.UpdateAppMetaDataProviders(tx); terr != nil {
return nil, terr
}
Expand Down
Loading

0 comments on commit 130df16

Please sign in to comment.