From 130df165270c69c8e28aaa1b9421342f997c1ff3 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Sun, 3 Mar 2024 11:51:39 +0800 Subject: [PATCH] feat: anonymous sign-ins (#1460) ## 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](https://github.com/supabase/gotrue/issues/68#issuecomment-1836671954). ```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 ' \ -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 ``` ## Follow-ups * Cleanup logic for anonymous users will be made in a separate PR --- hack/test.env | 2 + internal/api/anonymous.go | 58 ++++++ internal/api/anonymous_test.go | 177 ++++++++++++++++++ internal/api/api.go | 24 ++- internal/api/auth.go | 9 + internal/api/external.go | 2 +- internal/api/identity.go | 38 +++- internal/api/identity_test.go | 8 +- internal/api/settings.go | 94 +++++----- internal/api/signup.go | 63 ++++--- internal/api/token.go | 2 + internal/api/user.go | 8 + internal/api/verify.go | 26 +++ internal/conf/configuration.go | 90 ++++----- internal/hooks/auth_hooks.go | 1 + internal/models/factor.go | 3 + internal/models/user.go | 31 +-- internal/models/user_test.go | 6 +- ...40214120130_add_is_anonymous_column.up.sql | 8 + 19 files changed, 517 insertions(+), 133 deletions(-) create mode 100644 internal/api/anonymous.go create mode 100644 internal/api/anonymous_test.go create mode 100644 migrations/20240214120130_add_is_anonymous_column.up.sql diff --git a/hack/test.env b/hack/test.env index 1aad710f1..f4f3d0e6e 100644 --- a/hack/test.env +++ b/hack/test.env @@ -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 diff --git a/internal/api/anonymous.go b/internal/api/anonymous.go new file mode 100644 index 000000000..a3dfa93bd --- /dev/null +++ b/internal/api/anonymous.go @@ -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) +} diff --git a/internal/api/anonymous_test.go b/internal/api/anonymous_test.go new file mode 100644 index 000000000..1260774e3 --- /dev/null +++ b/internal/api/anonymous_test.go @@ -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) +} diff --git a/internal/api/api.go b/internal/api/api.go index e592a23d9..2c5a9a726 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) @@ -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) diff --git a/internal/api/auth.go b/internal/api/auth.go index c1f43d511..dc40391ce 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -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) diff --git a/internal/api/external.go b/internal/api/external.go index a47d201dc..919cced77 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -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 != "" { diff --git a/internal/api/identity.go b/internal/api/identity.go index 14f2c167d..4db43d8c2 100644 --- a/internal/api/identity.go +++ b/internal/api/identity.go @@ -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 { @@ -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) } @@ -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 { @@ -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 } diff --git a/internal/api/identity_test.go b/internal/api/identity_test.go index 8238bf6c2..a71c612d0 100644 --- a/internal/api/identity_test.go +++ b/internal/api/identity_test.go @@ -2,6 +2,8 @@ package api import ( "context" + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/require" @@ -56,7 +58,9 @@ func (ts *IdentityTestSuite) TestLinkIdentityToUser() { Subject: "test_subject", }, } - u, err = ts.API.linkIdentityToUser(ctx, ts.API.db, testValidUserData, "test") + // request is just used as a placeholder in the function + r := httptest.NewRequest(http.MethodGet, "/identities", nil) + u, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testValidUserData, "test") require.NoError(ts.T(), err) // load associated identities for the user @@ -71,7 +75,7 @@ func (ts *IdentityTestSuite) TestLinkIdentityToUser() { Subject: u.ID.String(), }, } - u, err = ts.API.linkIdentityToUser(ctx, ts.API.db, testExistingUserData, "email") + u, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testExistingUserData, "email") require.ErrorIs(ts.T(), err, badRequestError("Identity is already linked")) require.Nil(ts.T(), u) } diff --git a/internal/api/settings.go b/internal/api/settings.go index 43070a056..9ea93edb7 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -3,29 +3,30 @@ package api import "net/http" type ProviderSettings struct { - Apple bool `json:"apple"` - Azure bool `json:"azure"` - Bitbucket bool `json:"bitbucket"` - Discord bool `json:"discord"` - Facebook bool `json:"facebook"` - Figma bool `json:"figma"` - Fly bool `json:"fly"` - GitHub bool `json:"github"` - GitLab bool `json:"gitlab"` - Google bool `json:"google"` - Keycloak bool `json:"keycloak"` - Kakao bool `json:"kakao"` - Linkedin bool `json:"linkedin"` - LinkedinOIDC bool `json:"linkedin_oidc"` - Notion bool `json:"notion"` - Spotify bool `json:"spotify"` - Slack bool `json:"slack"` - WorkOS bool `json:"workos"` - Twitch bool `json:"twitch"` - Twitter bool `json:"twitter"` - Email bool `json:"email"` - Phone bool `json:"phone"` - Zoom bool `json:"zoom"` + AnonymousUsers bool `json:"anonymous_users"` + Apple bool `json:"apple"` + Azure bool `json:"azure"` + Bitbucket bool `json:"bitbucket"` + Discord bool `json:"discord"` + Facebook bool `json:"facebook"` + Figma bool `json:"figma"` + Fly bool `json:"fly"` + GitHub bool `json:"github"` + GitLab bool `json:"gitlab"` + Google bool `json:"google"` + Keycloak bool `json:"keycloak"` + Kakao bool `json:"kakao"` + Linkedin bool `json:"linkedin"` + LinkedinOIDC bool `json:"linkedin_oidc"` + Notion bool `json:"notion"` + Spotify bool `json:"spotify"` + Slack bool `json:"slack"` + WorkOS bool `json:"workos"` + Twitch bool `json:"twitch"` + Twitter bool `json:"twitter"` + Email bool `json:"email"` + Phone bool `json:"phone"` + Zoom bool `json:"zoom"` } type Settings struct { @@ -43,29 +44,30 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { return sendJSON(w, http.StatusOK, &Settings{ ExternalProviders: ProviderSettings{ - Apple: config.External.Apple.Enabled, - Azure: config.External.Azure.Enabled, - Bitbucket: config.External.Bitbucket.Enabled, - Discord: config.External.Discord.Enabled, - Facebook: config.External.Facebook.Enabled, - Figma: config.External.Figma.Enabled, - Fly: config.External.Fly.Enabled, - GitHub: config.External.Github.Enabled, - GitLab: config.External.Gitlab.Enabled, - Google: config.External.Google.Enabled, - Kakao: config.External.Kakao.Enabled, - Keycloak: config.External.Keycloak.Enabled, - Linkedin: config.External.Linkedin.Enabled, - LinkedinOIDC: config.External.LinkedinOIDC.Enabled, - Notion: config.External.Notion.Enabled, - Spotify: config.External.Spotify.Enabled, - Slack: config.External.Slack.Enabled, - Twitch: config.External.Twitch.Enabled, - Twitter: config.External.Twitter.Enabled, - WorkOS: config.External.WorkOS.Enabled, - Email: config.External.Email.Enabled, - Phone: config.External.Phone.Enabled, - Zoom: config.External.Zoom.Enabled, + AnonymousUsers: config.External.AnonymousUsers.Enabled, + Apple: config.External.Apple.Enabled, + Azure: config.External.Azure.Enabled, + Bitbucket: config.External.Bitbucket.Enabled, + Discord: config.External.Discord.Enabled, + Facebook: config.External.Facebook.Enabled, + Figma: config.External.Figma.Enabled, + Fly: config.External.Fly.Enabled, + GitHub: config.External.Github.Enabled, + GitLab: config.External.Gitlab.Enabled, + Google: config.External.Google.Enabled, + Kakao: config.External.Kakao.Enabled, + Keycloak: config.External.Keycloak.Enabled, + Linkedin: config.External.Linkedin.Enabled, + LinkedinOIDC: config.External.LinkedinOIDC.Enabled, + Notion: config.External.Notion.Enabled, + Spotify: config.External.Spotify.Enabled, + Slack: config.External.Slack.Enabled, + Twitch: config.External.Twitch.Enabled, + Twitter: config.External.Twitter.Enabled, + WorkOS: config.External.WorkOS.Enabled, + Email: config.External.Email.Enabled, + Phone: config.External.Phone.Enabled, + Zoom: config.External.Zoom.Enabled, }, DisableSignup: config.DisableSignup, MailerAutoconfirm: config.Mailer.Autoconfirm, diff --git a/internal/api/signup.go b/internal/api/signup.go index d7f289901..c2e5c2586 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -80,6 +80,9 @@ func (params *SignupParams) ToUserModel(isSSOUser bool) (user *models.User, err user, err = models.NewUser("", params.Email, params.Password, params.Aud, params.Data) case "phone": user, err = models.NewUser(params.Phone, "", params.Password, params.Aud, params.Data) + case "anonymous": + user, err = models.NewUser("", "", "", params.Aud, params.Data) + user.IsAnonymous = true default: // handles external provider case user, err = models.NewUser("", params.Email, params.Password, params.Aud, params.Data) @@ -95,15 +98,26 @@ func (params *SignupParams) ToUserModel(isSSOUser bool) (user *models.User, err user.Identities = make([]models.Identity, 0) - // TODO: Deprecate "provider" field - user.AppMetaData["provider"] = params.Provider + if params.Provider != "anonymous" { + // TODO: Deprecate "provider" field + user.AppMetaData["provider"] = params.Provider - user.AppMetaData["providers"] = []string{params.Provider} - if params.Password == "" { - user.EncryptedPassword = "" + user.AppMetaData["providers"] = []string{params.Provider} } - return + return user, nil +} + +func retrieveSignupParams(r *http.Request) (*SignupParams, error) { + params := &SignupParams{} + body, err := getBodyBytes(r) + if err != nil { + return nil, internalServerError("Could not read body").WithInternalError(err) + } + if err := json.Unmarshal(body, params); err != nil { + return nil, badRequestError("Could not read Signup params: %v", err) + } + return params, nil } // Signup is the endpoint for registering a new user @@ -116,15 +130,9 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { return forbiddenError("Signups not allowed for this instance") } - params := &SignupParams{} - - body, err := getBodyBytes(r) + params, err := retrieveSignupParams(r) if err != nil { - return badRequestError("Could not read body").WithInternalError(err) - } - - if err := json.Unmarshal(body, params); err != nil { - return badRequestError("Could not read Signup params: %v", err) + return err } params.ConfigureDefaults() @@ -192,22 +200,36 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { if (params.Provider == "email" && user.IsConfirmed()) || (params.Provider == "phone" && user.IsPhoneConfirmed()) { return UserExistsError } - // do not update the user because we can't be sure of their claimed identity } else { user, terr = a.signupNewUser(ctx, tx, signupUser) if terr != nil { return terr } - identity, terr := a.createNewIdentity(tx, user, params.Provider, structs.Map(provider.Claims{ + } + identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "email") + if terr != nil { + if !models.IsNotFoundError(terr) { + return terr + } + identityData := structs.Map(provider.Claims{ Subject: user.ID.String(), Email: user.GetEmail(), - })) + }) + for k, v := range params.Data { + if _, ok := identityData[k]; !ok { + identityData[k] = v + } + } + identity, terr = a.createNewIdentity(tx, user, params.Provider, identityData) if terr != nil { return terr } - user.Identities = []models.Identity{*identity} + if terr := user.RemoveUnconfirmedIdentities(tx, identity); terr != nil { + return terr + } } + user.Identities = []models.Identity{*identity} if params.Provider == "email" && !user.IsConfirmed() { if config.Mailer.Autoconfirm { @@ -385,11 +407,10 @@ func (a *API) signupNewUser(ctx context.Context, conn *storage.Connection, user return nil, err } - // sometimes there may be triggers in the database that will modify the + // there may be triggers or generated column values in the database that will modify the // user data as it is being inserted. thus we load the user object // again to fetch those changes. - err = conn.Eager().Load(user) - if err != nil { + if err := conn.Reload(user); err != nil { return nil, internalServerError("Database error loading user after sign-up").WithInternalError(err) } diff --git a/internal/api/token.go b/internal/api/token.go index b85959b36..1675a6579 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -34,6 +34,7 @@ type AccessTokenClaims struct { AuthenticatorAssuranceLevel string `json:"aal,omitempty"` AuthenticationMethodReference []models.AMREntry `json:"amr,omitempty"` SessionId string `json:"session_id,omitempty"` + IsAnonymous bool `json:"is_anonymous"` } // AccessTokenResponse represents an OAuth2 success response @@ -336,6 +337,7 @@ func (a *API) generateAccessToken(ctx context.Context, tx *storage.Connection, u SessionId: sid, AuthenticatorAssuranceLevel: aal, AuthenticationMethodReference: amr, + IsAnonymous: user.IsAnonymous, } var token *jwt.Token diff --git a/internal/api/user.go b/internal/api/user.go index 73991b464..34a687039 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -107,6 +107,14 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error { } } + if user.IsAnonymous { + updatingForbiddenFields := false + updatingForbiddenFields = updatingForbiddenFields || (params.Password != nil && *params.Password != "") + if updatingForbiddenFields { + return unprocessableEntityError("Updating password of an anonymous user is not possible") + } + } + if user.IsSSOUser { updatingForbiddenFields := false diff --git a/internal/api/verify.go b/internal/api/verify.go index 7ad5f1271..3e99dc9a4 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -177,6 +177,16 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa if terr != nil { return terr } + + if terr := user.UpdateAppMetaDataProviders(tx); terr != nil { + return terr + } + + // Reload user model from db. + // This is important for refreshing the data in any generated columns like IsAnonymous. + if terr := tx.Reload(user); err != nil { + return terr + } if isImplicitFlow(flowType) { token, terr = a.issueRefreshToken(ctx, tx, user, models.OTP, grantParams) @@ -268,6 +278,16 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP if terr != nil { return terr } + + if terr := user.UpdateAppMetaDataProviders(tx); terr != nil { + return terr + } + + // Reload user model from db. + // This is important for refreshing the data in any generated columns like IsAnonymous. + if terr := tx.Reload(user); terr != nil { + return terr + } token, terr = a.issueRefreshToken(ctx, tx, user, models.OTP, grantParams) if terr != nil { return terr @@ -511,6 +531,12 @@ func (a *API) emailChangeVerify(r *http.Request, ctx context.Context, conn *stor return terr } } + if user.IsAnonymous { + user.IsAnonymous = false + if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil { + return terr + } + } if terr := tx.Load(user, "Identities"); terr != nil { return internalServerError("Error refetching identities").WithInternalError(terr) } diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 7057472e3..b3a5b9613 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -61,6 +61,10 @@ type OAuthProviderConfiguration struct { SkipNonceCheck bool `json:"skip_nonce_check" split_words:"true"` } +type AnonymousProviderConfiguration struct { + Enabled bool `json:"enabled" default:"false"` +} + type EmailProviderConfiguration struct { Enabled bool `json:"enabled" default:"true"` } @@ -199,21 +203,22 @@ type PasswordConfiguration struct { // GlobalConfiguration holds all the configuration that applies to all instances. type GlobalConfiguration struct { - API APIConfiguration - DB DBConfiguration - External ProviderConfiguration - Logging LoggingConfig `envconfig:"LOG"` - Profiler ProfilerConfig `envconfig:"PROFILER"` - OperatorToken string `split_words:"true" required:"false"` - Tracing TracingConfig - Metrics MetricsConfig - SMTP SMTPConfiguration - RateLimitHeader string `split_words:"true"` - RateLimitEmailSent float64 `split_words:"true" default:"30"` - RateLimitSmsSent float64 `split_words:"true" default:"30"` - RateLimitVerify float64 `split_words:"true" default:"30"` - RateLimitTokenRefresh float64 `split_words:"true" default:"150"` - RateLimitSso float64 `split_words:"true" default:"30"` + API APIConfiguration + DB DBConfiguration + External ProviderConfiguration + Logging LoggingConfig `envconfig:"LOG"` + Profiler ProfilerConfig `envconfig:"PROFILER"` + OperatorToken string `split_words:"true" required:"false"` + Tracing TracingConfig + Metrics MetricsConfig + SMTP SMTPConfiguration + RateLimitHeader string `split_words:"true"` + RateLimitEmailSent float64 `split_words:"true" default:"30"` + RateLimitSmsSent float64 `split_words:"true" default:"30"` + RateLimitVerify float64 `split_words:"true" default:"30"` + RateLimitTokenRefresh float64 `split_words:"true" default:"150"` + RateLimitSso float64 `split_words:"true" default:"30"` + RateLimitAnonymousUsers float64 `split_words:"true" default:"30"` SiteURL string `json:"site_url" split_words:"true" required:"true"` URIAllowList []string `json:"uri_allow_list" split_words:"true"` @@ -271,33 +276,34 @@ type EmailContentConfiguration struct { } type ProviderConfiguration struct { - Apple OAuthProviderConfiguration `json:"apple"` - Azure OAuthProviderConfiguration `json:"azure"` - Bitbucket OAuthProviderConfiguration `json:"bitbucket"` - Discord OAuthProviderConfiguration `json:"discord"` - Facebook OAuthProviderConfiguration `json:"facebook"` - Figma OAuthProviderConfiguration `json:"figma"` - Fly OAuthProviderConfiguration `json:"fly"` - Github OAuthProviderConfiguration `json:"github"` - Gitlab OAuthProviderConfiguration `json:"gitlab"` - Google OAuthProviderConfiguration `json:"google"` - Kakao OAuthProviderConfiguration `json:"kakao"` - Notion OAuthProviderConfiguration `json:"notion"` - Keycloak OAuthProviderConfiguration `json:"keycloak"` - Linkedin OAuthProviderConfiguration `json:"linkedin"` - LinkedinOIDC OAuthProviderConfiguration `json:"linkedin_oidc" envconfig:"LINKEDIN_OIDC"` - Spotify OAuthProviderConfiguration `json:"spotify"` - Slack OAuthProviderConfiguration `json:"slack"` - Twitter OAuthProviderConfiguration `json:"twitter"` - Twitch OAuthProviderConfiguration `json:"twitch"` - WorkOS OAuthProviderConfiguration `json:"workos"` - Email EmailProviderConfiguration `json:"email"` - Phone PhoneProviderConfiguration `json:"phone"` - Zoom OAuthProviderConfiguration `json:"zoom"` - IosBundleId string `json:"ios_bundle_id" split_words:"true"` - RedirectURL string `json:"redirect_url"` - AllowedIdTokenIssuers []string `json:"allowed_id_token_issuers" split_words:"true"` - FlowStateExpiryDuration time.Duration `json:"flow_state_expiry_duration" split_words:"true"` + AnonymousUsers AnonymousProviderConfiguration `json:"anonymous_users" split_words:"true"` + Apple OAuthProviderConfiguration `json:"apple"` + Azure OAuthProviderConfiguration `json:"azure"` + Bitbucket OAuthProviderConfiguration `json:"bitbucket"` + Discord OAuthProviderConfiguration `json:"discord"` + Facebook OAuthProviderConfiguration `json:"facebook"` + Figma OAuthProviderConfiguration `json:"figma"` + Fly OAuthProviderConfiguration `json:"fly"` + Github OAuthProviderConfiguration `json:"github"` + Gitlab OAuthProviderConfiguration `json:"gitlab"` + Google OAuthProviderConfiguration `json:"google"` + Kakao OAuthProviderConfiguration `json:"kakao"` + Notion OAuthProviderConfiguration `json:"notion"` + Keycloak OAuthProviderConfiguration `json:"keycloak"` + Linkedin OAuthProviderConfiguration `json:"linkedin"` + LinkedinOIDC OAuthProviderConfiguration `json:"linkedin_oidc" envconfig:"LINKEDIN_OIDC"` + Spotify OAuthProviderConfiguration `json:"spotify"` + Slack OAuthProviderConfiguration `json:"slack"` + Twitter OAuthProviderConfiguration `json:"twitter"` + Twitch OAuthProviderConfiguration `json:"twitch"` + WorkOS OAuthProviderConfiguration `json:"workos"` + Email EmailProviderConfiguration `json:"email"` + Phone PhoneProviderConfiguration `json:"phone"` + Zoom OAuthProviderConfiguration `json:"zoom"` + IosBundleId string `json:"ios_bundle_id" split_words:"true"` + RedirectURL string `json:"redirect_url"` + AllowedIdTokenIssuers []string `json:"allowed_id_token_issuers" split_words:"true"` + FlowStateExpiryDuration time.Duration `json:"flow_state_expiry_duration" split_words:"true"` } type SMTPConfiguration struct { diff --git a/internal/hooks/auth_hooks.go b/internal/hooks/auth_hooks.go index 9b75ab5fb..bd3163085 100644 --- a/internal/hooks/auth_hooks.go +++ b/internal/hooks/auth_hooks.go @@ -97,6 +97,7 @@ type AccessTokenClaims struct { AuthenticatorAssuranceLevel string `json:"aal,omitempty"` AuthenticationMethodReference []models.AMREntry `json:"amr,omitempty"` SessionId string `json:"session_id,omitempty"` + IsAnonymous bool `json:"is_anonymous"` } type MFAVerificationAttemptInput struct { diff --git a/internal/models/factor.go b/internal/models/factor.go index 5af45772a..b99410984 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -45,6 +45,7 @@ const ( EmailSignup EmailChange TokenRefresh + Anonymous ) func (authMethod AuthenticationMethod) String() string { @@ -71,6 +72,8 @@ func (authMethod AuthenticationMethod) String() string { return "email_change" case TokenRefresh: return "token_refresh" + case Anonymous: + return "anonymous" } return "" } diff --git a/internal/models/user.go b/internal/models/user.go index 5a8996544..6111fa7af 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -66,6 +66,7 @@ type User struct { UpdatedAt time.Time `json:"updated_at" db:"updated_at"` BannedUntil *time.Time `json:"banned_until,omitempty" db:"banned_until"` DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"` + IsAnonymous bool `json:"is_anonymous" db:"is_anonymous"` DONTUSEINSTANCEID uuid.UUID `json:"-" db:"instance_id"` } @@ -229,7 +230,7 @@ func (u *User) UpdateAppMetaDataProviders(tx *storage.Connection) error { // UpdateUserEmail updates the user's email to one of the identity's email // if the current email used doesn't match any of the identities email -func (u *User) UpdateUserEmail(tx *storage.Connection) error { +func (u *User) UpdateUserEmailFromIdentities(tx *storage.Connection) error { identities, terr := FindIdentitiesByUserID(tx, u.ID) if terr != nil { return terr @@ -246,6 +247,8 @@ func (u *User) UpdateUserEmail(tx *storage.Connection) error { for _, i := range identities { if _, terr := FindUserByEmailAndAudience(tx, i.GetEmail(), u.Aud); terr != nil { if IsNotFoundError(terr) { + // the identity's email is not used by another user + // so we can set it as the primary identity primaryIdentity = i break } @@ -700,10 +703,12 @@ func (u *User) UpdateBannedUntil(tx *storage.Connection) error { // RemoveUnconfirmedIdentities removes potentially malicious unconfirmed identities from a user (if any) func (u *User) RemoveUnconfirmedIdentities(tx *storage.Connection, identity *Identity) error { - // user is unconfirmed so the password should be reset - u.EncryptedPassword = "" - if terr := tx.UpdateOnly(u, "encrypted_password"); terr != nil { - return terr + if identity.Provider != "email" && identity.Provider != "phone" { + // user is unconfirmed so the password should be reset + u.EncryptedPassword = "" + if terr := tx.UpdateOnly(u, "encrypted_password"); terr != nil { + return terr + } } // user is unconfirmed so existing user_metadata should be overwritten @@ -713,16 +718,6 @@ func (u *User) RemoveUnconfirmedIdentities(tx *storage.Connection, identity *Ide return terr } - // user is unconfirmed so none of the providers associated to it are verified yet - // only the current provider should be kept - if _, ok := u.AppMetaData["providers"].([]string); ok { - u.AppMetaData["providers"] = []string{identity.Provider} - u.AppMetaData["provider"] = identity.Provider - if terr := u.UpdateAppMetaData(tx, u.AppMetaData); terr != nil { - return terr - } - } - // finally, remove all identities except the current identity being authenticated for i := range u.Identities { if u.Identities[i].ID != identity.ID { @@ -731,6 +726,12 @@ func (u *User) RemoveUnconfirmedIdentities(tx *storage.Connection, identity *Ide } } } + + // user is unconfirmed so none of the providers associated to it are verified yet + // only the current provider should be kept + if terr := u.UpdateAppMetaDataProviders(tx); terr != nil { + return terr + } return nil } diff --git a/internal/models/user_test.go b/internal/models/user_test.go index 4371a690f..3b3438608 100644 --- a/internal/models/user_test.go +++ b/internal/models/user_test.go @@ -325,14 +325,14 @@ func (ts *UserTestSuite) TestUpdateUserEmailSuccess() { require.NoError(ts.T(), ts.db.Create(secondaryIdentity)) // UpdateUserEmail should not do anything and the user's email should still use the primaryIdentity - require.NoError(ts.T(), userA.UpdateUserEmail(ts.db)) + require.NoError(ts.T(), userA.UpdateUserEmailFromIdentities(ts.db)) require.Equal(ts.T(), primaryIdentity.GetEmail(), userA.GetEmail()) // remove primary identity require.NoError(ts.T(), ts.db.Destroy(primaryIdentity)) // UpdateUserEmail should update the user to use the secondary identity's email - require.NoError(ts.T(), userA.UpdateUserEmail(ts.db)) + require.NoError(ts.T(), userA.UpdateUserEmailFromIdentities(ts.db)) require.Equal(ts.T(), secondaryIdentity.GetEmail(), userA.GetEmail()) } @@ -364,7 +364,7 @@ func (ts *UserTestSuite) TestUpdateUserEmailFailure() { // UpdateUserEmail should fail with the email unique constraint violation error // since userB is using the secondary identity's email - require.ErrorIs(ts.T(), userA.UpdateUserEmail(ts.db), UserEmailUniqueConflictError{}) + require.ErrorIs(ts.T(), userA.UpdateUserEmailFromIdentities(ts.db), UserEmailUniqueConflictError{}) require.Equal(ts.T(), primaryIdentity.GetEmail(), userA.GetEmail()) } diff --git a/migrations/20240214120130_add_is_anonymous_column.up.sql b/migrations/20240214120130_add_is_anonymous_column.up.sql new file mode 100644 index 000000000..6ef963f57 --- /dev/null +++ b/migrations/20240214120130_add_is_anonymous_column.up.sql @@ -0,0 +1,8 @@ +do $$ +begin + alter table {{ index .Options "Namespace" }}.users + add column if not exists is_anonymous boolean not null default false; + + create index if not exists users_is_anonymous_idx on {{ index .Options "Namespace" }}.users using btree (is_anonymous); +end +$$;