forked from supabase/auth
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: anonymous sign-ins (supabase#1460)
## What kind of change does this PR introduce? * Implements supabase#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](supabase#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": "[email protected]"}' # 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
1 parent
e9f38e7
commit 130df16
Showing
19 changed files
with
517 additions
and
133 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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": "[email protected]", | ||
})) | ||
|
||
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(), "[email protected]", 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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.