From 532423bbc9b7678d76d547b0aeabd723cf290655 Mon Sep 17 00:00:00 2001 From: mimoham24 <69579255+mimoham24@users.noreply.github.com> Date: Mon, 6 Dec 2021 09:52:07 +0300 Subject: [PATCH] feat: user verification (#83) --- go.mod | 1 + go.sum | 1 + internal/adapter/http/user.go | 27 +++ internal/app/public.go | 26 +++ internal/infrastructure/memory/user.go | 17 ++ .../infrastructure/mongo/mongodoc/user.go | 24 ++ internal/infrastructure/mongo/user.go | 5 + internal/usecase/interactor/user.go | 52 +++++ internal/usecase/interfaces/user.go | 2 + internal/usecase/repo/user.go | 1 + pkg/user/builder.go | 5 + pkg/user/builder_test.go | 35 +++ pkg/user/user.go | 23 +- pkg/user/user_test.go | 35 +++ pkg/user/verification.go | 71 ++++++ pkg/user/verification_test.go | 215 ++++++++++++++++++ 16 files changed, 533 insertions(+), 7 deletions(-) create mode 100644 pkg/user/verification.go create mode 100644 pkg/user/verification_test.go diff --git a/go.mod b/go.mod index 00f51985..018d69f5 100644 --- a/go.mod +++ b/go.mod @@ -81,6 +81,7 @@ require ( github.com/golang/snappy v0.0.3 // indirect github.com/google/go-cmp v0.5.6 // indirect github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9 // indirect + github.com/google/uuid v1.3.0 // indirect github.com/googleapis/gax-go/v2 v2.0.5 // indirect github.com/gorilla/handlers v1.5.1 // indirect github.com/gorilla/schema v1.2.0 // indirect diff --git a/go.sum b/go.sum index 0485d0ab..e0ece2fb 100644 --- a/go.sum +++ b/go.sum @@ -267,6 +267,7 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go v2.0.0+incompatible h1:j0GKcs05QVmm7yesiZq2+9cxHkNK9YM6zKx4D2qucQU= github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= diff --git a/internal/adapter/http/user.go b/internal/adapter/http/user.go index 414ca04e..6b4624d6 100644 --- a/internal/adapter/http/user.go +++ b/internal/adapter/http/user.go @@ -17,6 +17,15 @@ func NewUserController(usecase interfaces.User) *UserController { } } +type CreateVerificationInput struct { + Email string `json:"email"` +} + +type VerifyUserOutput struct { + UserID string `json:"userId"` + Verified bool `json:"verified"` +} + type CreateUserInput struct { Sub string `json:"sub"` Secret string `json:"secret"` @@ -47,3 +56,21 @@ func (c *UserController) CreateUser(ctx context.Context, input CreateUserInput) Email: u.Email(), }, nil } + +func (c *UserController) CreateVerification(ctx context.Context, input CreateVerificationInput) error { + if err := c.usecase.CreateVerification(ctx, input.Email); err != nil { + return err + } + return nil +} + +func (c *UserController) VerifyUser(ctx context.Context, code string) (interface{}, error) { + u, err := c.usecase.VerifyUser(ctx, code) + if err != nil { + return nil, err + } + return VerifyUserOutput{ + UserID: u.ID().String(), + Verified: u.Verification().IsVerified(), + }, nil +} diff --git a/internal/app/public.go b/internal/app/public.go index c536605a..56f060aa 100644 --- a/internal/app/public.go +++ b/internal/app/public.go @@ -39,6 +39,32 @@ func publicAPI( return c.JSON(http.StatusOK, output) }) + r.POST("/signup/verify", func(c echo.Context) error { + var inp http1.CreateVerificationInput + if err := c.Bind(&inp); err != nil { + return &echo.HTTPError{Code: http.StatusBadRequest, Message: fmt.Errorf("failed to parse request body: %w", err)} + } + if err := controller.CreateVerification(c.Request().Context(), inp); err != nil { + return err + } + + return c.NoContent(http.StatusOK) + }) + + r.POST("/signup/verify/:code", func(c echo.Context) error { + code := c.Param("code") + if len(code) == 0 { + return echo.ErrBadRequest + } + + output, err := controller.VerifyUser(c.Request().Context(), code) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, output) + }) + r.GET("/published/:name", func(c echo.Context) error { name := c.Param("name") if name == "" { diff --git a/internal/infrastructure/memory/user.go b/internal/infrastructure/memory/user.go index 76d4383f..8444b498 100644 --- a/internal/infrastructure/memory/user.go +++ b/internal/infrastructure/memory/user.go @@ -113,3 +113,20 @@ func (r *User) Remove(ctx context.Context, user id.UserID) error { delete(r.data, user) return nil } + +func (r *User) FindByVerification(ctx context.Context, code string) (*user.User, error) { + r.lock.Lock() + defer r.lock.Unlock() + + if code == "" { + return nil, rerror.ErrInvalidParams + } + + for _, u := range r.data { + if u.Verification() != nil && u.Verification().Code() == code { + return &u, nil + } + } + + return nil, rerror.ErrNotFound +} diff --git a/internal/infrastructure/mongo/mongodoc/user.go b/internal/infrastructure/mongo/mongodoc/user.go index de3030c2..f44ddfe0 100644 --- a/internal/infrastructure/mongo/mongodoc/user.go +++ b/internal/infrastructure/mongo/mongodoc/user.go @@ -1,6 +1,8 @@ package mongodoc import ( + "time" + "go.mongodb.org/mongo-driver/bson" "github.com/reearth/reearth-backend/pkg/id" @@ -17,6 +19,13 @@ type UserDocument struct { Team string Lang string Theme string + Verification *UserVerificationDoc +} + +type UserVerificationDoc struct { + Code string + Expiration time.Time + Verified bool } type UserConsumer struct { @@ -47,6 +56,14 @@ func NewUser(user *user1.User) (*UserDocument, string) { for _, a := range auths { authsdoc = append(authsdoc, a.Sub) } + var v *UserVerificationDoc + if user.Verification() != nil { + v = &UserVerificationDoc{ + Code: user.Verification().Code(), + Expiration: user.Verification().Expiration(), + Verified: user.Verification().IsVerified(), + } + } return &UserDocument{ ID: id, @@ -56,6 +73,7 @@ func NewUser(user *user1.User) (*UserDocument, string) { Team: user.Team().String(), Lang: user.Lang().String(), Theme: string(user.Theme()), + Verification: v, }, id } @@ -75,6 +93,11 @@ func (d *UserDocument) Model() (*user1.User, error) { if d.Auth0Sub != "" { auths = append(auths, user.AuthFromAuth0Sub(d.Auth0Sub)) } + var v *user.Verification + if d.Verification != nil { + v = user.VerificationFrom(d.Verification.Code, d.Verification.Expiration, d.Verification.Verified) + } + user, err := user1.New(). ID(uid). Name(d.Name). @@ -82,6 +105,7 @@ func (d *UserDocument) Model() (*user1.User, error) { Auths(auths). Team(tid). LangFrom(d.Lang). + Verification(v). Theme(user.Theme(d.Theme)). Build() if err != nil { diff --git a/internal/infrastructure/mongo/user.go b/internal/infrastructure/mongo/user.go index 1a8cf727..945685f4 100644 --- a/internal/infrastructure/mongo/user.go +++ b/internal/infrastructure/mongo/user.go @@ -73,6 +73,11 @@ func (r *userRepo) FindByNameOrEmail(ctx context.Context, nameOrEmail string) (* return r.findOne(ctx, filter) } +func (r *userRepo) FindByVerification(ctx context.Context, code string) (*user.User, error) { + filter := bson.D{{Key: "verification.code", Value: code}} + return r.findOne(ctx, filter) +} + func (r *userRepo) Save(ctx context.Context, user *user.User) error { doc, id := mongodoc.NewUser(user) return r.client.SaveOne(ctx, id, doc) diff --git a/internal/usecase/interactor/user.go b/internal/usecase/interactor/user.go index 0e916f39..73fb2a56 100644 --- a/internal/usecase/interactor/user.go +++ b/internal/usecase/interactor/user.go @@ -28,6 +28,7 @@ type User struct { transaction repo.Transaction file gateway.File authenticator gateway.Authenticator + mailer gateway.Mailer signupSecret string } @@ -46,6 +47,7 @@ func NewUser(r *repo.Container, g *gateway.Container, signupSecret string) inter file: g.File, authenticator: g.Authenticator, signupSecret: signupSecret, + mailer: g.Mailer, } } @@ -397,3 +399,53 @@ func (i *User) DeleteMe(ctx context.Context, userID id.UserID, operator *usecase tx.Commit() return nil } + +func (i *User) CreateVerification(ctx context.Context, email string) error { + tx, err := i.transaction.Begin() + if err != nil { + return err + } + u, err := i.userRepo.FindByEmail(ctx, email) + if err != nil { + return err + } + u.SetVerification(user.NewVerification()) + err = i.userRepo.Save(ctx, u) + if err != nil { + return err + } + + err = i.mailer.SendMail([]gateway.Contact{ + { + Email: u.Email(), + Name: u.Name(), + }, + }, "email verification", "", "") + if err != nil { + return err + } + tx.Commit() + return nil +} + +func (i *User) VerifyUser(ctx context.Context, code string) (*user.User, error) { + tx, err := i.transaction.Begin() + if err != nil { + return nil, err + } + u, err := i.userRepo.FindByVerification(ctx, code) + if err != nil { + return nil, err + } + if u.Verification().IsExpired() { + return nil, errors.New("verification expired") + } + u.Verification().SetVerified(true) + err = i.userRepo.Save(ctx, u) + if err != nil { + return nil, err + } + + tx.Commit() + return u, nil +} diff --git a/internal/usecase/interfaces/user.go b/internal/usecase/interfaces/user.go index 00ffb1da..f476a79a 100644 --- a/internal/usecase/interfaces/user.go +++ b/internal/usecase/interfaces/user.go @@ -44,6 +44,8 @@ type UpdateMeParam struct { type User interface { Fetch(context.Context, []id.UserID, *usecase.Operator) ([]*user.User, error) Signup(context.Context, SignupParam) (*user.User, *user.Team, error) + CreateVerification(context.Context, string) error + VerifyUser(context.Context, string) (*user.User, error) GetUserByCredentials(context.Context, GetUserByCredentials) (*user.User, error) GetUserBySubject(context.Context, string) (*user.User, error) UpdateMe(context.Context, UpdateMeParam, *usecase.Operator) (*user.User, error) diff --git a/internal/usecase/repo/user.go b/internal/usecase/repo/user.go index 2b415278..472aef9d 100644 --- a/internal/usecase/repo/user.go +++ b/internal/usecase/repo/user.go @@ -13,6 +13,7 @@ type User interface { FindByAuth0Sub(context.Context, string) (*user.User, error) FindByEmail(context.Context, string) (*user.User, error) FindByNameOrEmail(context.Context, string) (*user.User, error) + FindByVerification(context.Context, string) (*user.User, error) Save(context.Context, *user.User) error Remove(context.Context, id.UserID) error } diff --git a/pkg/user/builder.go b/pkg/user/builder.go index 495983d5..d9a3f31e 100644 --- a/pkg/user/builder.go +++ b/pkg/user/builder.go @@ -76,3 +76,8 @@ func (b *Builder) Auths(auths []Auth) *Builder { b.u.auths = append([]Auth{}, auths...) return b } + +func (b *Builder) Verification(v *Verification) *Builder { + b.u.verification = v + return b +} diff --git a/pkg/user/builder_test.go b/pkg/user/builder_test.go index 01a106e5..d87ef112 100644 --- a/pkg/user/builder_test.go +++ b/pkg/user/builder_test.go @@ -3,6 +3,7 @@ package user import ( "errors" "testing" + "time" "github.com/reearth/reearth-backend/pkg/id" "github.com/stretchr/testify/assert" @@ -198,3 +199,37 @@ func TestBuilder_MustBuild(t *testing.T) { }) } } + +func TestBuilder_Verification(t *testing.T) { + tests := []struct { + name string + input *Verification + want *Builder + }{ + { + name: "should return verification", + input: &Verification{ + verified: true, + code: "xxx", + expiration: time.Time{}, + }, + + want: &Builder{ + u: &User{ + verification: &Verification{ + verified: true, + code: "xxx", + expiration: time.Time{}, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := New() + b.Verification(tt.input) + assert.Equal(t, tt.want, b) + }) + } +} diff --git a/pkg/user/user.go b/pkg/user/user.go index c75937c6..f5946b02 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -6,13 +6,14 @@ import ( ) type User struct { - id id.UserID - name string - email string - team id.TeamID - auths []Auth - lang language.Tag - theme Theme + id id.UserID + name string + email string + team id.TeamID + auths []Auth + lang language.Tag + theme Theme + verification *Verification } func (u *User) ID() id.UserID { @@ -59,6 +60,10 @@ func (u *User) UpdateTheme(t Theme) { u.theme = t } +func (u *User) Verification() *Verification { + return u.verification +} + func (u *User) Auths() []Auth { if u == nil { return nil @@ -130,3 +135,7 @@ func (u *User) RemoveAuthByProvider(provider string) bool { func (u *User) ClearAuths() { u.auths = []Auth{} } + +func (u *User) SetVerification(v *Verification) { + u.verification = v +} diff --git a/pkg/user/user_test.go b/pkg/user/user_test.go index 500dad68..fcebb5fa 100644 --- a/pkg/user/user_test.go +++ b/pkg/user/user_test.go @@ -2,6 +2,7 @@ package user import ( "testing" + "time" "github.com/reearth/reearth-backend/pkg/id" "github.com/stretchr/testify/assert" @@ -315,3 +316,37 @@ func TestUser_GetAuthByProvider(t *testing.T) { }) } } + +func TestUser_SetVerification(t *testing.T) { + input := &User{} + v := &Verification{ + verified: false, + code: "xxx", + expiration: time.Time{}, + } + input.SetVerification(v) + assert.Equal(t, v, input.verification) +} + +func TestUser_Verification(t *testing.T) { + v := NewVerification() + tests := []struct { + name string + verification *Verification + want *Verification + }{ + { + name: "should return the same verification", + verification: v, + want: v, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := &User{ + verification: tt.verification, + } + assert.Equal(t, tt.want, u.Verification()) + }) + } +} diff --git a/pkg/user/verification.go b/pkg/user/verification.go new file mode 100644 index 00000000..2b7215f0 --- /dev/null +++ b/pkg/user/verification.go @@ -0,0 +1,71 @@ +package user + +import ( + "time" + + uuid "github.com/google/uuid" +) + +type Verification struct { + verified bool + code string + expiration time.Time +} + +func (v *Verification) IsVerified() bool { + if v == nil { + return false + } + return v.verified +} + +func (v *Verification) Code() string { + if v == nil { + return "" + } + return v.code +} + +func (v *Verification) Expiration() time.Time { + if v == nil { + return time.Time{} + } + return v.expiration +} + +func generateCode() string { + return uuid.NewString() +} + +func (v *Verification) IsExpired() bool { + if v == nil { + return true + } + now := time.Now() + return now.After(v.expiration) +} + +func (v *Verification) SetVerified(b bool) { + if v == nil { + return + } + v.verified = b +} + +func NewVerification() *Verification { + v := &Verification{ + verified: false, + code: generateCode(), + expiration: time.Now().Add(time.Hour * 24), + } + return v +} + +func VerificationFrom(c string, e time.Time, b bool) *Verification { + v := &Verification{ + verified: b, + code: c, + expiration: e, + } + return v +} diff --git a/pkg/user/verification_test.go b/pkg/user/verification_test.go new file mode 100644 index 00000000..342c5937 --- /dev/null +++ b/pkg/user/verification_test.go @@ -0,0 +1,215 @@ +package user + +import ( + "testing" + "time" + + "github.com/google/uuid" + + "github.com/stretchr/testify/assert" +) + +func TestNewVerification(t *testing.T) { + type fields struct { + verified bool + code bool + expiration bool + } + + tests := []struct { + name string + want fields + }{ + { + name: "init verification struct", + + want: fields{ + verified: false, + code: true, + expiration: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewVerification() + assert.Equal(t, tt.want.verified, got.IsVerified()) + assert.Equal(t, tt.want.code, len(got.Code()) > 0) + assert.Equal(t, tt.want.expiration, !got.Expiration().IsZero()) + }) + } +} + +func TestVerification_Code(t *testing.T) { + tests := []struct { + name string + verification *Verification + want string + }{ + { + name: "should return a code string", + verification: &Verification{ + verified: false, + code: "xxx", + expiration: time.Time{}, + }, + want: "xxx", + }, + { + name: "should return a empty string", + want: "", + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(tt *testing.T) { + tt.Parallel() + + assert.Equal(tt, tc.want, tc.verification.Code()) + }) + } +} + +func TestVerification_Expiration(t *testing.T) { + e := time.Now() + + tests := []struct { + name string + verification *Verification + want time.Time + }{ + { + name: "should return now date", + verification: &Verification{ + verified: false, + code: "", + expiration: e, + }, + want: e, + }, + { + name: "should return zero time", + verification: nil, + want: time.Time{}, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(tt *testing.T) { + tt.Parallel() + assert.Equal(tt, tc.want, tc.verification.Expiration()) + }) + } +} + +func TestVerification_IsExpired(t *testing.T) { + tim, _ := time.Parse(time.RFC3339, "2021-03-16T04:19:57.592Z") + tim2 := time.Now().Add(time.Hour * 24) + + type fields struct { + verified bool + code string + expiration time.Time + } + tests := []struct { + name string + fields fields + want bool + }{ + { + name: "should be expired", + fields: fields{ + verified: false, + code: "xxx", + expiration: tim, + }, + want: true, + }, + { + name: "shouldn't be expired", + fields: fields{ + verified: false, + code: "xxx", + expiration: tim2, + }, + want: false, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(tt *testing.T) { + tt.Parallel() + v := &Verification{ + verified: tc.fields.verified, + code: tc.fields.code, + expiration: tc.fields.expiration, + } + assert.Equal(tt, tc.want, v.IsExpired()) + }) + } +} + +func TestVerification_IsVerified(t *testing.T) { + tests := []struct { + name string + verification *Verification + want bool + }{ + { + name: "should return true", + verification: &Verification{ + verified: true, + }, + want: true, + }, + { + name: "should return false", + verification: nil, + want: false, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(tt *testing.T) { + tt.Parallel() + assert.Equal(tt, tc.want, tc.verification.IsVerified()) + }) + } +} + +func TestVerification_SetVerified(t *testing.T) { + tests := []struct { + name string + verification *Verification + input bool + want bool + }{ + { + name: "should set true", + verification: &Verification{ + verified: false, + }, + input: true, + want: true, + }, + { + name: "should return false", + verification: nil, + want: false, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(tt *testing.T) { + tt.Parallel() + tc.verification.SetVerified(tc.input) + assert.Equal(tt, tc.want, tc.verification.IsVerified()) + }) + } +} + +func Test_generateCode(t *testing.T) { + str := generateCode() + _, err := uuid.Parse(str) + assert.NoError(t, err) +}