From 0dcf2b3e95b610e42f103fa8e93f117108b72b64 Mon Sep 17 00:00:00 2001 From: Ivaylo Novakov Date: Fri, 3 Jun 2022 16:28:18 +0200 Subject: [PATCH] Introduce an Email type that handles capitalization. --- api/auth_test.go | 5 +-- api/handlers.go | 33 ++++++++--------- api/upload.go | 3 +- database/user.go | 21 +++++------ email/mailer.go | 13 +++---- jwt/jwt.go | 7 ++-- jwt/jwt_test.go | 7 ++-- test/api/api_test.go | 13 +++---- test/api/apikeys_test.go | 18 ++++++---- test/api/challenge_test.go | 17 ++++----- test/api/handlers_test.go | 73 ++++++++++++++++++++++++++------------ test/api/upload_test.go | 9 ++--- test/database/user_test.go | 25 ++++++------- test/email/sender_test.go | 9 +++-- test/utils.go | 9 ++--- types/email.go | 48 +++++++++++++++++++++++++ types/email_test.go | 50 ++++++++++++++++++++++++++ 17 files changed, 252 insertions(+), 108 deletions(-) create mode 100644 types/email.go create mode 100644 types/email_test.go diff --git a/api/auth_test.go b/api/auth_test.go index 4f6f11d7..b9e21a73 100644 --- a/api/auth_test.go +++ b/api/auth_test.go @@ -10,6 +10,7 @@ import ( "github.com/SkynetLabs/skynet-accounts/database" "github.com/SkynetLabs/skynet-accounts/jwt" + "github.com/SkynetLabs/skynet-accounts/types" "github.com/sirupsen/logrus" "gitlab.com/NebulousLabs/errors" "gitlab.com/NebulousLabs/fastrand" @@ -47,7 +48,7 @@ func TestTokenFromRequest(t *testing.T) { if err != nil { t.Fatal(err) } - tk, err := jwt.TokenForUser(t.Name()+"@siasky.net", t.Name()+"_sub") + tk, err := jwt.TokenForUser(types.NewEmail(t.Name()+"@siasky.net"), t.Name()+"_sub") if err != nil { t.Fatal(err) } @@ -97,7 +98,7 @@ func TestTokenFromRequest(t *testing.T) { // Token from request with a header and a cookie. Expect the header to take // precedence. - tk2, err := jwt.TokenForUser(t.Name()+"2@siasky.net", t.Name()+"2_sub") + tk2, err := jwt.TokenForUser(types.NewEmail(t.Name()+"2@siasky.net"), t.Name()+"2_sub") if err != nil { t.Fatal(err) } diff --git a/api/handlers.go b/api/handlers.go index dac4ed9a..f95ec391 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -19,6 +19,7 @@ import ( "github.com/SkynetLabs/skynet-accounts/lib" "github.com/SkynetLabs/skynet-accounts/metafetcher" "github.com/SkynetLabs/skynet-accounts/skynet" + "github.com/SkynetLabs/skynet-accounts/types" "github.com/julienschmidt/httprouter" jwt2 "github.com/lestrrat-go/jwx/jwt" "gitlab.com/NebulousLabs/errors" @@ -125,16 +126,16 @@ type ( // credentialsPOST defines the standard credentials package we expect. credentialsPOST struct { - Email string `json:"email"` - Password string `json:"password"` + Email types.Email `json:"email"` + Password string `json:"password"` } // userUpdatePUT defines the fields of the User record that can be changed // externally, e.g. by calling `PUT /user`. userUpdatePUT struct { - Email string `json:"email,omitempty"` - Password string `json:"password,omitempty"` - StripeID string `json:"stripeCustomerId,omitempty"` + Email types.Email `json:"email,omitempty"` + Password string `json:"password,omitempty"` + StripeID string `json:"stripeCustomerId,omitempty"` } ) @@ -231,7 +232,7 @@ func (api *API) loginPOSTChallengeResponse(w http.ResponseWriter, req *http.Requ } // loginPOSTCredentials is a helper that handles logins with credentials. -func (api *API) loginPOSTCredentials(w http.ResponseWriter, req *http.Request, email, password string) { +func (api *API) loginPOSTCredentials(w http.ResponseWriter, req *http.Request, email types.Email, password string) { // Fetch the user with that email, if they exist. u, err := api.staticDB.UserByEmail(req.Context(), email) if err != nil { @@ -388,8 +389,8 @@ func (api *API) registerPOST(_ *database.User, w http.ResponseWriter, req *http. api.WriteError(w, errors.AddContext(err, "failed to parse request body"), http.StatusBadRequest) return } - parsed, err := mail.ParseAddress(payload.Email) - if err != nil || payload.Email != parsed.Address { + parsed, err := mail.ParseAddress(payload.Email.String()) + if err != nil || payload.Email.String() != parsed.Address { api.WriteError(w, errors.New("invalid email provided"), http.StatusBadRequest) return } @@ -616,8 +617,8 @@ func (api *API) userPOST(_ *database.User, w http.ResponseWriter, req *http.Requ api.WriteError(w, errors.New("email is required"), http.StatusBadRequest) return } - parsed, err := mail.ParseAddress(payload.Email) - if err != nil || payload.Email != parsed.Address { + parsed, err := mail.ParseAddress(payload.Email.String()) + if err != nil || payload.Email.String() != parsed.Address { api.WriteError(w, errors.New("invalid email provided"), http.StatusBadRequest) return } @@ -714,8 +715,8 @@ func (api *API) userPUT(u *database.User, w http.ResponseWriter, req *http.Reque var changedEmail bool if payload.Email != "" { - parsed, err := mail.ParseAddress(payload.Email) - if err != nil || payload.Email != parsed.Address { + parsed, err := mail.ParseAddress(payload.Email.String()) + if err != nil || payload.Email.String() != parsed.Address { api.WriteError(w, errors.New("invalid email provided"), http.StatusBadRequest) return } @@ -995,10 +996,10 @@ func (api *API) userRecoverRequestPOST(_ *database.User, w http.ResponseWriter, return } - // Read and parse the request body. - var payload struct { - Email string `json:"email"` - } + // Read and parse the request body. We do not expect a password but we want + // to use the same email parsing approach in all cases where we get an email + // address from the user. + var payload credentialsPOST err = parseRequestBodyJSON(req.Body, LimitBodySizeSmall, &payload) if err != nil { err = errors.AddContext(err, "failed to parse request body") diff --git a/api/upload.go b/api/upload.go index 8b3d39eb..d4ec6dd6 100644 --- a/api/upload.go +++ b/api/upload.go @@ -5,6 +5,7 @@ import ( "time" "github.com/SkynetLabs/skynet-accounts/database" + "github.com/SkynetLabs/skynet-accounts/types" "github.com/julienschmidt/httprouter" "gitlab.com/NebulousLabs/errors" "go.mongodb.org/mongo-driver/bson/primitive" @@ -14,7 +15,7 @@ type ( // UploaderInfo gives information about a user who created an upload. UploaderInfo struct { UserID primitive.ObjectID - Email string + Email types.Email Sub string StripeID string } diff --git a/database/user.go b/database/user.go index 3eee8732..7750f766 100644 --- a/database/user.go +++ b/database/user.go @@ -11,6 +11,7 @@ import ( "github.com/SkynetLabs/skynet-accounts/hash" "github.com/SkynetLabs/skynet-accounts/lib" "github.com/SkynetLabs/skynet-accounts/skynet" + "github.com/SkynetLabs/skynet-accounts/types" "gitlab.com/NebulousLabs/errors" "gitlab.com/SkynetLabs/skyd/build" "go.mongodb.org/mongo-driver/bson" @@ -110,7 +111,7 @@ type ( // ID is auto-generated by Mongo on insert. We will usually use it in // its ID.Hex() form. ID primitive.ObjectID `bson:"_id,omitempty" json:"-"` - Email string `bson:"email" json:"email"` + Email types.Email `bson:"email" json:"email"` EmailConfirmationToken string `bson:"email_confirmation_token,omitempty" json:"-"` EmailConfirmationTokenExpiration time.Time `bson:"email_confirmation_token_expiration,omitempty" json:"-"` PasswordHash string `bson:"password_hash" json:"-"` @@ -155,8 +156,8 @@ type ( ) // UserByEmail returns the user with the given username. -func (db *DB) UserByEmail(ctx context.Context, email string) (*User, error) { - users, err := db.managedUsersByField(ctx, "email", email) +func (db *DB) UserByEmail(ctx context.Context, email types.Email) (*User, error) { + users, err := db.managedUsersByField(ctx, "email", email.String()) if err != nil { return nil, err } @@ -278,14 +279,14 @@ func (db *DB) UserConfirmEmail(ctx context.Context, token string) (*User, error) // // The new user is created as "unconfirmed" and a confirmation email is sent to // the address they provided. -func (db *DB) UserCreate(ctx context.Context, emailAddr, pass, sub string, tier int) (*User, error) { +func (db *DB) UserCreate(ctx context.Context, emailAddr types.Email, pass, sub string, tier int) (*User, error) { // Ensure the email is valid if it's passed. We allow empty emails. if emailAddr != "" { - addr, err := mail.ParseAddress(emailAddr) + addr, err := mail.ParseAddress(emailAddr.String()) if err != nil { return nil, errors.AddContext(err, "invalid email address") } - emailAddr = addr.Address + emailAddr = types.NewEmail(addr.Address) } if sub == "" { return nil, errors.New("empty sub is not allowed") @@ -382,14 +383,14 @@ func (db *DB) UserCreateEmailConfirmation(ctx context.Context, uID primitive.Obj // // The new user is created as "unconfirmed" and a confirmation email is sent to // the address they provided. -func (db *DB) UserCreatePK(ctx context.Context, emailAddr, pass, sub string, pk PubKey, tier int) (*User, error) { +func (db *DB) UserCreatePK(ctx context.Context, emailAddr types.Email, pass, sub string, pk PubKey, tier int) (*User, error) { // Validate the email. - parsed, err := mail.ParseAddress(emailAddr) - if err != nil || parsed.Address != emailAddr { + parsed, err := mail.ParseAddress(emailAddr.String()) + if err != nil || parsed.Address != emailAddr.String() { return nil, errors.AddContext(err, "invalid email address") } // Check for an existing user with this email. - users, err := db.managedUsersByField(ctx, "email", emailAddr) + users, err := db.managedUsersByField(ctx, "email", emailAddr.String()) if err != nil && !errors.Contains(err, ErrUserNotFound) { return nil, errors.AddContext(err, "failed to query DB") } diff --git a/email/mailer.go b/email/mailer.go index 98cb1976..1ef70a24 100644 --- a/email/mailer.go +++ b/email/mailer.go @@ -4,6 +4,7 @@ import ( "context" "github.com/SkynetLabs/skynet-accounts/database" + "github.com/SkynetLabs/skynet-accounts/types" ) /** @@ -35,15 +36,15 @@ func (em Mailer) Send(ctx context.Context, m database.EmailMessage) error { // SendAddressConfirmationEmail sends a new email to the given email address // with a link to confirm the ownership of the address. -func (em Mailer) SendAddressConfirmationEmail(ctx context.Context, email, token string) error { - m := confirmEmailEmail(email, token) +func (em Mailer) SendAddressConfirmationEmail(ctx context.Context, email types.Email, token string) error { + m := confirmEmailEmail(email.String(), token) return em.Send(ctx, *m) } // SendRecoverAccountEmail sends a new email to the given email address // with a link to recover the account. -func (em Mailer) SendRecoverAccountEmail(ctx context.Context, email, token string) error { - m := recoverAccountEmail(email, token) +func (em Mailer) SendRecoverAccountEmail(ctx context.Context, email types.Email, token string) error { + m := recoverAccountEmail(email.String(), token) return em.Send(ctx, *m) } @@ -52,7 +53,7 @@ func (em Mailer) SendRecoverAccountEmail(ctx context.Context, email, token strin // recover a Skynet account but their email is not in our system. The main // reason to do that is because the user might have forgotten which email they // used for signing up. -func (em Mailer) SendAccountAccessAttemptedEmail(ctx context.Context, email string) error { - m := accountAccessAttemptedEmail(email) +func (em Mailer) SendAccountAccessAttemptedEmail(ctx context.Context, email types.Email) error { + m := accountAccessAttemptedEmail(email.String()) return em.Send(ctx, *m) } diff --git a/jwt/jwt.go b/jwt/jwt.go index 988ca6e0..4d6f737d 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -6,6 +6,7 @@ import ( "io/ioutil" "time" + "github.com/SkynetLabs/skynet-accounts/types" "github.com/lestrrat-go/jwx/jwa" "github.com/lestrrat-go/jwx/jwk" "github.com/lestrrat-go/jwx/jwt" @@ -74,7 +75,7 @@ func ContextWithToken(ctx context.Context, token jwt.Token) context.Context { // // The tokens generated by this function are a slimmed down version of the ones // described in ValidateToken's docstring. -func TokenForUser(email, sub string) (jwt.Token, error) { +func TokenForUser(email types.Email, sub string) (jwt.Token, error) { sigAlgo, key, err := signatureAlgoAndKey() if err != nil { return nil, err @@ -252,7 +253,7 @@ func signatureAlgoAndKey() (jwa.SignatureAlgorithm, jwk.Key, error) { // tokenForUser is a helper method that puts together an unsigned token based // on the provided values. -func tokenForUser(emailAddr, sub string) (jwt.Token, error) { +func tokenForUser(emailAddr types.Email, sub string) (jwt.Token, error) { if emailAddr == "" || sub == "" { return nil, errors.New("email and sub cannot be empty") } @@ -260,7 +261,7 @@ func tokenForUser(emailAddr, sub string) (jwt.Token, error) { Active: true, Identity: tokenIdentity{ Traits: tokenTraits{ - Email: emailAddr, + Email: emailAddr.String(), }, }, } diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go index b422ea2e..5ce97dfd 100644 --- a/jwt/jwt_test.go +++ b/jwt/jwt_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/SkynetLabs/skynet-accounts/types" "github.com/lestrrat-go/jwx/jwa" "github.com/lestrrat-go/jwx/jwt" "github.com/sirupsen/logrus" @@ -19,7 +20,7 @@ func TestJWT(t *testing.T) { if err != nil { t.Fatal(err) } - email := t.Name() + "@siasky.net" + email := types.NewEmail(t.Name() + "@siasky.net") sub := "this is a sub" fakeSub := "fake sub" tk, err := TokenForUser(email, sub) @@ -59,7 +60,7 @@ func TestValidateToken_Expired(t *testing.T) { if err != nil { t.Fatal(err) } - email := t.Name() + "@siasky.net" + email := types.NewEmail(t.Name() + "@siasky.net") sub := "this is a sub" // Fetch the tools we need in order to craft a custom token. key, found := AccountsJWKS.Get(0) @@ -81,7 +82,7 @@ func TestValidateToken_Expired(t *testing.T) { Active: true, Identity: tokenIdentity{ Traits: tokenTraits{ - Email: email, + Email: email.String(), }, }, } diff --git a/test/api/api_test.go b/test/api/api_test.go index fc043c45..3e359a25 100644 --- a/test/api/api_test.go +++ b/test/api/api_test.go @@ -11,6 +11,7 @@ import ( "github.com/SkynetLabs/skynet-accounts/api" "github.com/SkynetLabs/skynet-accounts/database" "github.com/SkynetLabs/skynet-accounts/test" + "github.com/SkynetLabs/skynet-accounts/types" "gitlab.com/NebulousLabs/fastrand" "go.sia.tech/siad/build" @@ -33,9 +34,9 @@ func TestWithDBSession(t *testing.T) { t.Fatal("Failed to instantiate API.", err) } - emailSuccess := t.Name() + "success@siasky.net" - emailSuccessJSON := t.Name() + "success_json@siasky.net" - emailFailure := t.Name() + "failure@siasky.net" + emailSuccess := types.NewEmail(t.Name() + "success@siasky.net") + emailSuccessJSON := types.NewEmail(t.Name() + "success_json@siasky.net") + emailFailure := types.NewEmail(t.Name() + "failure@siasky.net") // This handler successfully creates a user in the DB and exits with // a success status code. We expect the user to exist in the DB after @@ -52,7 +53,7 @@ func TestWithDBSession(t *testing.T) { t.Fatal("Failed to fetch user from DB.", err) } if u.Email != emailSuccess { - t.Fatalf("Expected email %s, got %s.", emailSuccess, u.Email) + t.Fatalf("Expected email '%v', got '%v'.", emailSuccess, u.Email) } testAPI.WriteSuccess(w) } @@ -147,7 +148,7 @@ func TestUserTierCache(t *testing.T) { } }() - emailAddr := test.DBNameForTest(t.Name()) + "@siasky.net" + emailAddr := types.NewEmail(test.DBNameForTest(t.Name()) + "@siasky.net") password := hex.EncodeToString(fastrand.Bytes(16)) u, err := test.CreateUser(at, emailAddr, password) if err != nil { @@ -165,7 +166,7 @@ func TestUserTierCache(t *testing.T) { if err != nil { t.Fatal(err) } - r, _, err := at.LoginCredentialsPOST(emailAddr, password) + r, _, err := at.LoginCredentialsPOST(emailAddr.String(), password) if err != nil { t.Fatal(err) } diff --git a/test/api/apikeys_test.go b/test/api/apikeys_test.go index dfbc05ea..7df5b154 100644 --- a/test/api/apikeys_test.go +++ b/test/api/apikeys_test.go @@ -8,6 +8,7 @@ import ( "github.com/SkynetLabs/skynet-accounts/api" "github.com/SkynetLabs/skynet-accounts/database" "github.com/SkynetLabs/skynet-accounts/test" + "github.com/SkynetLabs/skynet-accounts/types" "gitlab.com/NebulousLabs/fastrand" "go.sia.tech/siad/modules" ) @@ -16,7 +17,8 @@ import ( // API keys. func testPrivateAPIKeysFlow(t *testing.T, at *test.AccountsTester) { name := test.DBNameForTest(t.Name()) - r, body, err := at.UserPOST(name+"@siasky.net", name+"_pass") + email := types.NewEmail(name + "@siasky.net") + r, body, err := at.UserPOST(email.String(), name+"_pass") if err != nil { t.Fatal(err, string(body)) } @@ -96,8 +98,8 @@ func testPrivateAPIKeysFlow(t *testing.T, at *test.AccountsTester) { func testPrivateAPIKeysUsage(t *testing.T, at *test.AccountsTester) { name := test.DBNameForTest(t.Name()) // Create a test user. - email := name + "@siasky.net" - r, _, err := at.UserPOST(email, name+"_pass") + email := types.NewEmail(name + "@siasky.net") + r, _, err := at.UserPOST(email.String(), name+"_pass") if err != nil { t.Fatal(err) } @@ -138,7 +140,8 @@ func testPrivateAPIKeysUsage(t *testing.T, at *test.AccountsTester) { // API keys. func testPublicAPIKeysFlow(t *testing.T, at *test.AccountsTester) { name := test.DBNameForTest(t.Name()) - r, body, err := at.UserPOST(name+"@siasky.net", name+"_pass") + email := types.NewEmail(name + "@siasky.net") + r, body, err := at.UserPOST(email.String(), name+"_pass") if err != nil { t.Fatal(err, string(body)) } @@ -234,8 +237,8 @@ func testPublicAPIKeysFlow(t *testing.T, at *test.AccountsTester) { func testPublicAPIKeysUsage(t *testing.T, at *test.AccountsTester) { name := test.DBNameForTest(t.Name()) // Create a test user. - email := name + "@siasky.net" - r, _, err := at.UserPOST(email, name+"_pass") + email := types.NewEmail(name + "@siasky.net") + r, _, err := at.UserPOST(email.String(), name+"_pass") if err != nil { t.Fatal(err) } @@ -318,7 +321,8 @@ func testPublicAPIKeysUsage(t *testing.T, at *test.AccountsTester) { func testAPIKeysAcceptance(t *testing.T, at *test.AccountsTester) { name := test.DBNameForTest(t.Name()) // Create a test user. - r, _, err := at.UserPOST(name+"@siasky.net", name+"_pass") + email := types.NewEmail(name + "@siasky.net") + r, _, err := at.UserPOST(email.String(), name+"_pass") if err != nil { t.Fatal(err) } diff --git a/test/api/challenge_test.go b/test/api/challenge_test.go index be8315b5..5842c684 100644 --- a/test/api/challenge_test.go +++ b/test/api/challenge_test.go @@ -9,6 +9,7 @@ import ( "github.com/SkynetLabs/skynet-accounts/database" "github.com/SkynetLabs/skynet-accounts/test" + "github.com/SkynetLabs/skynet-accounts/types" "gitlab.com/NebulousLabs/errors" "gitlab.com/NebulousLabs/fastrand" "go.sia.tech/siad/crypto" @@ -45,8 +46,8 @@ func testRegistration(t *testing.T, at *test.AccountsTester) { // Solve the challenge. response := append(chBytes, append([]byte(database.ChallengeTypeRegister), []byte(database.PortalName)...)...) sig := ed25519.Sign(sk[:], response) - emailStr := name + "@siasky.net" - u, status, err := at.RegisterPOST(response, sig, emailStr) + emailStr := types.NewEmail(name + "@siasky.net") + u, status, err := at.RegisterPOST(response, sig, emailStr.String()) if err != nil { t.Fatalf("Failed to register. Status %d, error '%s'", status, err) } @@ -89,8 +90,8 @@ func testLogin(t *testing.T, at *test.AccountsTester) { } response := append(chBytes, append([]byte(database.ChallengeTypeRegister), []byte(database.PortalName)...)...) sig := ed25519.Sign(sk[:], response) - emailStr := name + "@siasky.net" - u, status, err := at.RegisterPOST(response, sig, emailStr) + emailStr := types.NewEmail(name + "@siasky.net") + u, status, err := at.RegisterPOST(response, sig, emailStr.String()) if err != nil { t.Fatalf("Failed to validate the response. Status %d, error '%s'", status, err) } @@ -119,8 +120,8 @@ func testLogin(t *testing.T, at *test.AccountsTester) { // Solve the challenge. response = append(chBytes, append([]byte(database.ChallengeTypeLogin), []byte(database.PortalName)...)...) sig = ed25519.Sign(sk[:], response) - emailStr = name + "@siasky.net" - r, b, err := at.LoginPubKeyPOST(response, sig, emailStr) + emailStr = types.NewEmail(name + "@siasky.net") + r, b, err := at.LoginPubKeyPOST(response, sig, emailStr.String()) if err != nil { t.Fatalf("Failed to login. Status %d, body '%s', error '%s'", r.StatusCode, string(b), err) } @@ -164,7 +165,7 @@ func testUserAddPubKey(t *testing.T, at *test.AccountsTester) { // Request a challenge with a pubKey that belongs to another user. _, pk2 := crypto.GenerateKeyPair() - _, err = at.DB.UserCreatePK(at.Ctx, name+"_other@siasky.net", "", name+"_other_sub", pk2[:], database.TierFree) + _, err = at.DB.UserCreatePK(at.Ctx, types.NewEmail(name+"_other@siasky.net"), "", name+"_other_sub", pk2[:], database.TierFree) if err != nil { t.Fatal(err) } @@ -198,7 +199,7 @@ func testUserAddPubKey(t *testing.T, at *test.AccountsTester) { // Try to solve the challenge while logged in as a different user. // NOTE: This will consume the challenge and the user will need to request // a new one. - r, b, err := at.UserPOST(name+"_user3@siasky.net", name+"_pass") + r, b, err := at.UserPOST(types.NewEmail(name+"_user3@siasky.net").String(), name+"_pass") if err != nil || r.StatusCode != http.StatusOK { t.Fatal(r.Status, err, string(b)) } diff --git a/test/api/handlers_test.go b/test/api/handlers_test.go index 52933d16..21814c25 100644 --- a/test/api/handlers_test.go +++ b/test/api/handlers_test.go @@ -17,6 +17,7 @@ import ( "github.com/SkynetLabs/skynet-accounts/jwt" "github.com/SkynetLabs/skynet-accounts/skynet" "github.com/SkynetLabs/skynet-accounts/test" + "github.com/SkynetLabs/skynet-accounts/types" "gitlab.com/NebulousLabs/errors" "gitlab.com/NebulousLabs/fastrand" "gitlab.com/SkynetLabs/skyd/skymodules" @@ -101,7 +102,7 @@ func testHandlerHealthGET(t *testing.T, at *test.AccountsTester) { func testHandlerUserPOST(t *testing.T, at *test.AccountsTester) { // Use the test's name as an email-compatible identifier. name := test.DBNameForTest(t.Name()) - emailAddr := name + "@siasky.net" + emailAddr := types.NewEmail(name + "@siasky.net") password := hex.EncodeToString(fastrand.Bytes(16)) // Try to create a user with a missing email. _, _, err := at.UserPOST("", password) @@ -119,12 +120,12 @@ func testHandlerUserPOST(t *testing.T, at *test.AccountsTester) { t.Fatalf("Expected user creation to fail with '%s', got '%s'. Body: '%s'", badRequest, err, string(b)) } // Try to create a user with an empty password. - _, b, err = at.UserPOST(emailAddr, "") + _, b, err = at.UserPOST(emailAddr.String(), "") if err == nil || !strings.Contains(err.Error(), badRequest) { t.Fatalf("Expected user creation to fail with '%s', got '%s'. Body: '%s", badRequest, err, string(b)) } // Create a user. - _, b, err = at.UserPOST(emailAddr, password) + _, b, err = at.UserPOST(emailAddr.String(), password) if err != nil { t.Fatalf("User creation failed. Error: '%s'. Body: '%s' ", err.Error(), string(b)) } @@ -147,12 +148,12 @@ func testHandlerUserPOST(t *testing.T, at *test.AccountsTester) { } }(u) // Log in with that user in order to make sure it exists. - _, b, err = at.LoginCredentialsPOST(emailAddr, password) + _, b, err = at.LoginCredentialsPOST(emailAddr.String(), password) if err != nil { t.Fatalf("Login failed. Error: '%s'. Body: '%s'", err.Error(), string(b)) } // try to create a user with an already taken email - _, b, err = at.UserPOST(emailAddr, "password") + _, b, err = at.UserPOST(emailAddr.String(), "password") if err == nil || !strings.Contains(err.Error(), badRequest) { t.Fatalf("Expected user creation to fail with '%s', got '%s'. Body: '%s'", badRequest, err, string(b)) } @@ -160,10 +161,10 @@ func testHandlerUserPOST(t *testing.T, at *test.AccountsTester) { // testHandlerLoginPOST tests the /login endpoint. func testHandlerLoginPOST(t *testing.T, at *test.AccountsTester) { - emailAddr := test.DBNameForTest(t.Name()) + "@siasky.net" + emailAddr := types.NewEmail(test.DBNameForTest(t.Name()) + "@siasky.net") password := hex.EncodeToString(fastrand.Bytes(16)) // Try logging in with a non-existent user. - _, _, err := at.LoginCredentialsPOST(emailAddr, password) + _, _, err := at.LoginCredentialsPOST(emailAddr.String(), password) if err == nil || !strings.Contains(err.Error(), unauthorized) { t.Fatalf("Expected '%s', got '%s'", unauthorized, err) } @@ -177,7 +178,7 @@ func testHandlerLoginPOST(t *testing.T, at *test.AccountsTester) { } }() // Login with an existing user. - r, _, err := at.LoginCredentialsPOST(emailAddr, password) + r, _, err := at.LoginCredentialsPOST(emailAddr.String(), password) if err != nil { t.Fatal(err) } @@ -186,6 +187,12 @@ func testHandlerLoginPOST(t *testing.T, at *test.AccountsTester) { if c == nil { t.Fatal("Expected a cookie.") } + // Login with an email with a different capitalisation. + // Expect this to succeed. + _, _, err = at.LoginCredentialsPOST(strings.ToUpper(emailAddr.String()), password) + if err != nil { + t.Fatal(err) + } // Make sure the returned cookie is usable for making requests. at.SetCookie(c) defer at.ClearCredentials() @@ -222,7 +229,7 @@ func testHandlerLoginPOST(t *testing.T, at *test.AccountsTester) { t.Fatalf("Expected %s, got %s", unauthorized, err) } // Try logging in with a bad password. - _, _, err = at.LoginCredentialsPOST(emailAddr, "bad password") + _, _, err = at.LoginCredentialsPOST(emailAddr.String(), "bad password") if err == nil || !strings.Contains(err.Error(), unauthorized) { t.Fatalf("Expected '%s', got '%s'", unauthorized, err) } @@ -295,17 +302,17 @@ func testUserPUT(t *testing.T, at *test.AccountsTester) { } // Check if we can login with the new password. params := url.Values{} - params.Set("email", u.Email) + params.Set("email", u.Email.String()) params.Set("password", pw) // Try logging in with a non-existent user. - _, _, err = at.LoginCredentialsPOST(u.Email, pw) + _, _, err = at.LoginCredentialsPOST(u.Email.String(), pw) if err != nil { t.Fatal(err) } // Update the user's email. - emailAddr := name + "_new@siasky.net" - _, status, err = at.UserPUT(emailAddr, "", "") + emailAddr := types.NewEmail(name + "_new@siasky.net") + _, status, err = at.UserPUT(emailAddr.String(), "", "") if err != nil || status != http.StatusOK { t.Fatal(status, err) } @@ -323,7 +330,7 @@ func testUserPUT(t *testing.T, at *test.AccountsTester) { t.Fatalf("Expected the user to have a non-empty confirmation token, got '%s'", u3.EmailConfirmationToken) } // Expect to find a confirmation email queued for sending. - filer := bson.M{"to": emailAddr} + filer := bson.M{"to": emailAddr.String()} _, msgs, err := at.DB.FindEmails(at.Ctx, filer, &options.FindOptions{}) if err != nil { t.Fatal(err) @@ -331,6 +338,26 @@ func testUserPUT(t *testing.T, at *test.AccountsTester) { if len(msgs) != 1 || msgs[0].Subject != "Please verify your email address" { t.Fatal("Expected to find a single confirmation email but didn't.") } + // Update the user's email to a mixed-case string, expect it to be persisted + // as lowercase only. + emailStr := name + "_ThIsIsMiXeDcAsE@siasky.net" + _, status, err = at.UserPUT(emailStr, "", "") + if err != nil || status != http.StatusOK { + t.Fatal(status, err) + } + // Fetch the user by the mixed-case email. Expect this to succeed because we + // cast the email to lowercase in the UserPUT handler. + u4, err := at.DB.UserByEmail(at.Ctx, types.NewEmail(emailStr)) + if err != nil { + t.Fatal(err) + } + // Make sure the email field is lowercase. Make sure to not use String() + // because that will cast it to lowercase even if it's not. + // We disable gocritic here, so it doesn't suggest to use strings.EqualFold(). + //nolint:gocritic + if string(u4.Email) != strings.ToLower(emailStr) { + t.Fatalf("Expected the email to be '%s', got '%s", strings.ToLower(emailStr), u4.Email) + } } // testUserDELETE tests the DELETE /user endpoint. @@ -719,8 +746,8 @@ func testUserAccountRecovery(t *testing.T, at *test.AccountsTester) { // person requesting a recovery and they just forgot which email they used // to sign up. While we can't tell them that, we can indicate tht recovery // process works as expected and they should try their other emails. - attemptedEmail := hex.EncodeToString(fastrand.Bytes(16)) + "@siasky.net" - _, err = at.UserRecoverRequestPOST(attemptedEmail) + attemptedEmail := types.NewEmail(hex.EncodeToString(fastrand.Bytes(16)) + "@siasky.net") + _, err = at.UserRecoverRequestPOST(attemptedEmail.String()) if err != nil { t.Fatal(err) } @@ -736,8 +763,8 @@ func testUserAccountRecovery(t *testing.T, at *test.AccountsTester) { // Request recovery with a valid email. We expect there to be a single email // with the recovery token. The email is unconfirmed but we don't mind that. bodyParams := url.Values{} - bodyParams.Set("email", u.Email) - _, err = at.UserRecoverRequestPOST(u.Email) + bodyParams.Set("email", u.Email.String()) + _, err = at.UserRecoverRequestPOST(u.Email.String()) if err != nil { t.Fatal(err) } @@ -809,7 +836,7 @@ func testUserAccountRecovery(t *testing.T, at *test.AccountsTester) { t.Fatal(err) } // Make sure the user's password is now successfully changed. - _, b, err := at.LoginCredentialsPOST(u.Email, newPassword) + _, b, err := at.LoginCredentialsPOST(u.Email.String(), newPassword) if err != nil { t.Fatal(err, string(b)) } @@ -933,7 +960,7 @@ func testUserFlow(t *testing.T, at *test.AccountsTester) { queryParams.Set("email", emailAddr) queryParams.Set("password", password) // Create a user. - u, err := test.CreateUser(at, queryParams.Get("email"), queryParams.Get("password")) + u, err := test.CreateUser(at, types.NewEmail(queryParams.Get("email")), queryParams.Get("password")) if err != nil { t.Fatal(err) } @@ -968,14 +995,14 @@ func testUserFlow(t *testing.T, at *test.AccountsTester) { } at.SetCookie(c) // Change the user's email. - newEmail := name + "_new@siasky.net" - _, _, err = at.UserPUT(newEmail, "", "") + newEmail := types.NewEmail(name + "_new@siasky.net") + _, _, err = at.UserPUT(newEmail.String(), "", "") if err != nil { t.Fatalf("Failed to update user. Error: %s", err.Error()) } // Grab the new cookie. It has changed because of the user edit. at.ClearCredentials() - r, _, err = at.LoginCredentialsPOST(newEmail, password) + r, _, err = at.LoginCredentialsPOST(newEmail.String(), password) if err != nil { t.Fatal(err) } diff --git a/test/api/upload_test.go b/test/api/upload_test.go index 98e34e07..65d5f7ec 100644 --- a/test/api/upload_test.go +++ b/test/api/upload_test.go @@ -6,6 +6,7 @@ import ( "github.com/SkynetLabs/skynet-accounts/database" "github.com/SkynetLabs/skynet-accounts/test" + "github.com/SkynetLabs/skynet-accounts/types" "go.mongodb.org/mongo-driver/bson/primitive" ) @@ -14,14 +15,14 @@ func testUploadInfo(t *testing.T, at *test.AccountsTester) { // Create two test users. name := test.DBNameForTest(t.Name()) name2 := name + "2" - email := name + "@siasky.net" - email2 := name2 + "@siasky.net" - r, _, err := at.UserPOST(email, name+"_pass") + email := types.NewEmail(name + "@siasky.net") + email2 := types.NewEmail(name2 + "@siasky.net") + r, _, err := at.UserPOST(email.String(), name+"_pass") if err != nil { t.Fatal(err) } c1 := test.ExtractCookie(r) - r, _, err = at.UserPOST(email2, name2+"_pass") + r, _, err = at.UserPOST(email2.String(), name2+"_pass") if err != nil { t.Fatal(err) } diff --git a/test/database/user_test.go b/test/database/user_test.go index 7918426f..c836c706 100644 --- a/test/database/user_test.go +++ b/test/database/user_test.go @@ -11,6 +11,7 @@ import ( "github.com/SkynetLabs/skynet-accounts/lib" "github.com/SkynetLabs/skynet-accounts/skynet" "github.com/SkynetLabs/skynet-accounts/test" + "github.com/SkynetLabs/skynet-accounts/types" "gitlab.com/NebulousLabs/errors" "gitlab.com/NebulousLabs/fastrand" "go.mongodb.org/mongo-driver/bson/primitive" @@ -27,7 +28,7 @@ func TestUserByEmail(t *testing.T) { t.Fatal(err) } - email := t.Name() + "@siasky.net" + email := types.NewEmail(t.Name() + "@siasky.net") pass := t.Name() + "password" sub := t.Name() + "sub" // Ensure we don't have a user with this email and the method handles that @@ -122,7 +123,7 @@ func TestUserByPubKey(t *testing.T) { } // Create a user with this pubkey. - u, err := db.UserCreatePK(ctx, name+"@siasky.net", name+"pass", name+"sub", pk, database.TierFree) + u, err := db.UserCreatePK(ctx, types.NewEmail(name+"@siasky.net"), name+"pass", name+"sub", pk, database.TierFree) if err != nil { t.Fatal(err) } @@ -172,7 +173,7 @@ func TestUserByStripeID(t *testing.T) { t.Fatalf("Expected error %v, got %v.\n", database.ErrUserNotFound, err) } // Create a test user with the respective StripeID. - u, err := db.UserCreate(ctx, t.Name()+"@siasky.net", t.Name()+"pass", t.Name()+"sub", database.TierFree) + u, err := db.UserCreate(ctx, types.NewEmail(t.Name()+"@siasky.net"), t.Name()+"pass", t.Name()+"sub", database.TierFree) if err != nil { t.Fatal(err) } @@ -248,7 +249,7 @@ func TestUserConfirmEmail(t *testing.T) { if err != nil { t.Fatal("Failed to connect to the DB:", err) } - emailAddr := t.Name() + "@siasky.net" + emailAddr := types.NewEmail(t.Name() + "@siasky.net") // Create a user with this email. u, err := db.UserCreate(ctx, emailAddr, "password", "sub", database.TierFree) if err != nil { @@ -287,7 +288,7 @@ func TestUserCreate(t *testing.T) { t.Fatal(err) } - email := t.Name() + "@siasky.net" + email := types.NewEmail(t.Name() + "@siasky.net") pass := t.Name() + "pass" sub := t.Name() + "sub" @@ -315,7 +316,7 @@ func TestUserCreate(t *testing.T) { if fu == nil { t.Fatal("Expected to find a user but didn't.") } - newEmail := t.Name() + "_new@siasky.net" + newEmail := types.NewEmail(t.Name() + "_new@siasky.net") newPass := t.Name() + "pass_new" newSub := t.Name() + "sub_new" // Try to create a user with an email which is already in use. @@ -338,7 +339,7 @@ func TestUserCreateEmailConfirmation(t *testing.T) { if err != nil { t.Fatal(err) } - u, err := db.UserCreate(ctx, t.Name()+"@siasky.net", t.Name()+"pass", t.Name()+"sub", database.TierFree) + u, err := db.UserCreate(ctx, types.NewEmail(t.Name()+"@siasky.net"), t.Name()+"pass", t.Name()+"sub", database.TierFree) if err != nil { t.Fatal(err) } @@ -408,7 +409,7 @@ func TestUserSave(t *testing.T) { username := t.Name() // Case: save a user that doesn't exist in the DB. u := &database.User{ - Email: username + "@siasky.net", + Email: types.NewEmail(username + "@siasky.net"), Sub: t.Name() + "sub", Tier: database.TierFree, } @@ -424,7 +425,7 @@ func TestUserSave(t *testing.T) { t.Fatalf("Expected user id %s, got %s.", u.ID.Hex(), u1.ID.Hex()) } // Case: save a user that does exist in the DB. - u.Email = username + "_changed@siasky.net" + u.Email = types.NewEmail(username + "_changed@siasky.net") u.Tier = database.TierPremium80 err = db.UserSave(ctx, u) if err != nil { @@ -453,7 +454,7 @@ func TestUserSetStripeID(t *testing.T) { stripeID := t.Name() + "stripeid" // Create a test user with the respective StripeID. - u, err := db.UserCreate(ctx, t.Name()+"@siasky.net", t.Name()+"pass", t.Name()+"sub", database.TierFree) + u, err := db.UserCreate(ctx, types.NewEmail(t.Name()+"@siasky.net"), t.Name()+"pass", t.Name()+"sub", database.TierFree) if err != nil { t.Fatal(err) } @@ -485,7 +486,7 @@ func TestUserPubKey(t *testing.T) { t.Fatal(err) } // Create a test user. - u, err := db.UserCreate(ctx, t.Name()+"@siasky.net", t.Name()+"pass", t.Name()+"sub", database.TierFree) + u, err := db.UserCreate(ctx, types.NewEmail(t.Name()+"@siasky.net"), t.Name()+"pass", t.Name()+"sub", database.TierFree) if err != nil { t.Fatal(err) } @@ -569,7 +570,7 @@ func TestUserSetTier(t *testing.T) { t.Fatal(err) } // Create a test user with the respective StripeID. - u, err := db.UserCreate(ctx, t.Name()+"@siasky.net", t.Name()+"pass", t.Name()+"sub", database.TierFree) + u, err := db.UserCreate(ctx, types.NewEmail(t.Name()+"@siasky.net"), t.Name()+"pass", t.Name()+"sub", database.TierFree) if err != nil { t.Fatal(err) } diff --git a/test/email/sender_test.go b/test/email/sender_test.go index 83d30bff..8a501512 100644 --- a/test/email/sender_test.go +++ b/test/email/sender_test.go @@ -11,6 +11,7 @@ import ( "github.com/SkynetLabs/skynet-accounts/email" "github.com/SkynetLabs/skynet-accounts/test" + "github.com/SkynetLabs/skynet-accounts/types" "github.com/sirupsen/logrus" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo/options" @@ -43,7 +44,7 @@ func TestSender(t *testing.T) { mailer := email.NewMailer(db) // Send an email. - to := t.Name() + "@siasky.net" + to := types.NewEmail(t.Name() + "@siasky.net") token := t.Name() err = mailer.SendAddressConfirmationEmail(ctx, to, token) if err != nil { @@ -101,7 +102,7 @@ func TestContendingSenders(t *testing.T) { t.Fatal("Failed to purge email collection:", err) } }() - targetAddr := t.Name() + "@siasky.net" + targetAddr := types.NewEmail(t.Name() + "@siasky.net") numMsgs := 200 // count will hold the total number of messages sent. var count int32 @@ -111,7 +112,9 @@ func TestContendingSenders(t *testing.T) { generator := func(n int) { m := email.NewMailer(db) for i := 0; i < n; i++ { - err1 := m.SendAddressConfirmationEmail(ctx, targetAddr, targetAddr) + // We'll use the target email address as token because it doesn't + // matter what we use. + err1 := m.SendAddressConfirmationEmail(ctx, targetAddr, targetAddr.String()) if err1 != nil { t.Error("Failed to send email.", err1) return diff --git a/test/utils.go b/test/utils.go index f406311a..9adea979 100644 --- a/test/utils.go +++ b/test/utils.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/SkynetLabs/skynet-accounts/database" + "github.com/SkynetLabs/skynet-accounts/types" "gitlab.com/NebulousLabs/errors" "gitlab.com/NebulousLabs/fastrand" "gitlab.com/SkynetLabs/skyd/skymodules" @@ -78,9 +79,9 @@ func DBTestCredentials() database.DBCredentials { } // CreateUser is a helper method which simplifies the creation of test users -func CreateUser(at *AccountsTester, emailAddr, password string) (*User, error) { +func CreateUser(at *AccountsTester, emailAddr types.Email, password string) (*User, error) { // Create a user. - _, _, err := at.UserPOST(emailAddr, password) + _, _, err := at.UserPOST(emailAddr.String(), password) if err != nil { return nil, errors.AddContext(err, "user creation failed") } @@ -97,7 +98,7 @@ func CreateUser(at *AccountsTester, emailAddr, password string) (*User, error) { // function that deletes the user. func CreateUserAndLogin(at *AccountsTester, name string) (*User, *http.Cookie, error) { // Use the test's name as an email-compatible identifier. - email := DBNameForTest(name) + "@siasky.net" + email := types.NewEmail(DBNameForTest(name) + "@siasky.net") password := hex.EncodeToString(fastrand.Bytes(16)) // Create a user. u, err := CreateUser(at, email, password) @@ -105,7 +106,7 @@ func CreateUserAndLogin(at *AccountsTester, name string) (*User, *http.Cookie, e return nil, nil, err } // Log in with that user in order to make sure it exists. - r, _, err := at.LoginCredentialsPOST(email, password) + r, _, err := at.LoginCredentialsPOST(email.String(), password) if err != nil { return nil, nil, err } diff --git a/types/email.go b/types/email.go new file mode 100644 index 00000000..e3c3c23c --- /dev/null +++ b/types/email.go @@ -0,0 +1,48 @@ +/** +Package types provides various service-wide types. + +These types are used by more than one subsystem and should not provide +subsystem-specific behaviours, e.g. database-specific serialization. +The exception to this rule should be test methods which should allow testing +with all kinds of input. +*/ + +package types + +import ( + "encoding/json" + "strings" +) + +type ( + // Email is a string type with some extra rules about its casing (it always + // gets converted to lowercase). All subsystems working with emails should + // use this type in the signatures of their exported methods and functions. + Email string +) + +// NewEmail creates a new Email. +func NewEmail(s string) Email { + return Email(strings.ToLower(s)) +} + +// MarshalJSON defines a custom marshaller for this type. +func (e Email) MarshalJSON() ([]byte, error) { + return json.Marshal(e.String()) +} + +// UnmarshalJSON defines a custom unmarshaller for this type. +// The only custom part is the fact that we cast the email to lower case. +func (e *Email) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *e = NewEmail(s) + return nil +} + +// String is a fmt.Stringer implementation for Email. +func (e Email) String() string { + return strings.ToLower(string(e)) +} diff --git a/types/email_test.go b/types/email_test.go new file mode 100644 index 00000000..4d666e95 --- /dev/null +++ b/types/email_test.go @@ -0,0 +1,50 @@ +package types + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +// TestEmail_String ensures that stringifying an Email will result in a +// lowercase string. +func TestEmail_String(t *testing.T) { + s := "mIxEdCaSeStRiNg" + e := Email(s) + if e.String() != strings.ToLower(s) { + t.Fatalf("Expected '%s', got '%s'", strings.ToLower(s), e) + } +} + +// TestEmail_MarshalJSON ensures that marshalling an Email will result in a +// lower case representation. +func TestEmail_MarshalJSON(t *testing.T) { + s := "EmAiL@eXaMpLe.CoM" + e := Email(s) + b, err := json.Marshal(e) + if err != nil { + t.Fatal(err) + } + // We expect these bytes to match the source. + expectedJSON := fmt.Sprintf("\"%s\"", strings.ToLower(s)) + if string(b) != expectedJSON { + t.Fatalf("Expected '%s', got '%s'", expectedJSON, string(b)) + } +} + +// TestEmail_UnmarshalJSON ensures that unmarshalling an Email will result in a +// lower case string, even if the marshalled data was mixed-case. +func TestEmail_UnmarshalJSON(t *testing.T) { + // Manually craft a mixed-case JSON representation of an Email. + b := []byte(`"EmAiL@eXaMpLe.CoM"`) + var e Email + err := json.Unmarshal(b, &e) + if err != nil { + t.Fatal(err) + } + // We expect the unmarshalled email to be lowercase only. + if string(e) != strings.ToLower(string(b[1:len(b)-1])) { + t.Fatalf("Expected to get a lowercase version of '%s', i.e. '%s' but got '%s'", e, strings.ToLower(string(e)), e) + } +}