diff --git a/.env b/.env index 7b94da02..43bb1045 100644 --- a/.env +++ b/.env @@ -16,6 +16,13 @@ MONGO_HOST=mongodb MONGO_PORT=27017 MONGO_DATABASE_NAME=blog +# mail +MAIL_SMTP_FROM=info@test.loc +MAIL_SMTP_USERNAME=test +MAIL_SMTP_PASSWORD=test +MAIL_SMTP_HOST=mailserver +MAIL_SMTP_PORT=1025 + ## frontend NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000 NUXT_INTERNAL_API_BASE_URL=http://app diff --git a/backend/.env b/backend/.env index 7438ad00..9ccc8a6c 100644 --- a/backend/.env +++ b/backend/.env @@ -15,3 +15,10 @@ MONGO_PASSWORD=test MONGO_HOST=mongodb MONGO_PORT=27017 MONGO_DATABASE_NAME=blog + +# mail +MAIL_SMTP_FROM=info@test.loc +MAIL_SMTP_USERNAME=test +MAIL_SMTP_PASSWORD=test +MAIL_SMTP_HOST=mailserver +MAIL_SMTP_PORT=1025 diff --git a/backend/application/auth/auth.go b/backend/application/auth/auth.go index cfc52c5c..24581885 100644 --- a/backend/application/auth/auth.go +++ b/backend/application/auth/auth.go @@ -7,8 +7,9 @@ import ( ) const ( - AccessToken = "access" - RefreshToken = "refresh" + AccessToken = "access" + RefreshToken = "refresh" + ResetPasswordToken = "reset-password" ) type authKey struct{} diff --git a/backend/application/auth/forgetpassword/request.go b/backend/application/auth/forgetpassword/request.go new file mode 100644 index 00000000..79bfea6f --- /dev/null +++ b/backend/application/auth/forgetpassword/request.go @@ -0,0 +1,11 @@ +package forgetpassword + +type validationErrors map[string]string + +type Request struct { + Identity string `json:"identity"` +} + +func (r *Request) Validate() (bool, validationErrors) { + return true, nil +} diff --git a/backend/application/auth/forgetpassword/response.go b/backend/application/auth/forgetpassword/response.go new file mode 100644 index 00000000..41f9f4c9 --- /dev/null +++ b/backend/application/auth/forgetpassword/response.go @@ -0,0 +1,5 @@ +package forgetpassword + +type ForgetResponse struct { + ValidationErrors validationErrors `json:"errors,omitempty"` +} diff --git a/backend/application/auth/forgetpassword/usecase.go b/backend/application/auth/forgetpassword/usecase.go new file mode 100644 index 00000000..78b26ce7 --- /dev/null +++ b/backend/application/auth/forgetpassword/usecase.go @@ -0,0 +1,75 @@ +package forgetpassword + +import ( + "bytes" + "encoding/base64" + "time" + + "github.com/khanzadimahdi/testproject/application/auth" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/jwt" +) + +type UseCase struct { + userRepository user.Repository + jwt *jwt.JWT + mailer domain.Mailer + mailFrom string +} + +func NewUseCase( + userRepository user.Repository, + JWT *jwt.JWT, + mailer domain.Mailer, + mailFrom string, +) *UseCase { + return &UseCase{ + userRepository: userRepository, + jwt: JWT, + mailer: mailer, + mailFrom: mailFrom, + } +} + +func (uc *UseCase) SendResetToken(request Request) (*ForgetResponse, error) { + if ok, validation := request.Validate(); !ok { + return &ForgetResponse{ + ValidationErrors: validation, + }, nil + } + + u, err := uc.userRepository.GetOneByIdentity(request.Identity) + if err != nil { + return nil, err + } + + resetPasswordToken, err := uc.resetPasswordToken(u) + if err != nil { + return nil, err + } + + resetPasswordToken = base64.URLEncoding.EncodeToString([]byte(resetPasswordToken)) + + var msg bytes.Buffer + if _, err := msg.WriteString(resetPasswordToken); err != nil { + return nil, err + } + + if err := uc.mailer.SendMail(uc.mailFrom, u.Email, "Reset Password", msg.Bytes()); err != nil { + return nil, err + } + + return &ForgetResponse{}, nil +} + +func (uc *UseCase) resetPasswordToken(u user.User) (string, error) { + b := jwt.NewClaimsBuilder() + b.SetSubject(u.UUID) + b.SetNotBefore(time.Now()) + b.SetExpirationTime(time.Now().Add(10 * time.Minute)) + b.SetIssuedAt(time.Now()) + b.SetAudience([]string{auth.ResetPasswordToken}) + + return uc.jwt.Generate(b.Build()) +} diff --git a/backend/application/auth/forgetpassword/usecase_test.go b/backend/application/auth/forgetpassword/usecase_test.go new file mode 100644 index 00000000..fdc64f15 --- /dev/null +++ b/backend/application/auth/forgetpassword/usecase_test.go @@ -0,0 +1,101 @@ +package forgetpassword + +import ( + "errors" + "testing" + + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/crypto/ecdsa" + "github.com/khanzadimahdi/testproject/infrastructure/jwt" +) + +func TestUseCase_SendResetToken(t *testing.T) { + privateKey, err := ecdsa.Generate() + if err != nil { + t.Error("unexpected error") + } + j := jwt.NewJWT(privateKey, privateKey.Public()) + + mailFrom := "info@noreply.nowhere.loc" + + request := Request{ + Identity: "something@somewhere.loc", + } + + t.Run("mails reset password token", func(t *testing.T) { + userRepository := &MockUserRepository{} + mailer := &MockMailer{} + + usecase := NewUseCase(userRepository, j, mailer, mailFrom) + response, err := usecase.SendResetToken(request) + + if err != nil { + t.Error("unexpected error") + } + + if response == nil { + t.Errorf("response not exists") + } + }) + + t.Run("error on finding user", func(t *testing.T) { + userRepository := &MockUserRepository{ + GetOneByIdentityErr: domain.ErrNotExists, + } + mailer := &MockMailer{} + + usecase := NewUseCase(userRepository, j, mailer, mailFrom) + response, err := usecase.SendResetToken(request) + + if err != userRepository.GetOneByIdentityErr { + t.Error("expected an error", userRepository.GetOneByIdentityErr) + } + + if response != nil { + t.Errorf("expected response to be nil but got %#v", response) + } + }) + + t.Run("sending email fails", func(t *testing.T) { + userRepository := &MockUserRepository{} + mailer := &MockMailer{SendMailErr: errors.New("can't send mail")} + + usecase := NewUseCase(userRepository, j, mailer, mailFrom) + response, err := usecase.SendResetToken(request) + + if err != mailer.SendMailErr { + t.Error("expected an error", mailer.SendMailErr) + } + + if response != nil { + t.Errorf("expected response to be nil but got %#v", response) + } + }) + +} + +type MockUserRepository struct { + user.Repository + + GetOneByIdentityErr error +} + +func (r *MockUserRepository) GetOneByIdentity(username string) (user.User, error) { + if r.GetOneByIdentityErr != nil { + return user.User{}, r.GetOneByIdentityErr + } + + return user.User{ + UUID: "018ead22-d9d3-7e78-8b52-174c06ee1528", + }, nil +} + +type MockMailer struct { + domain.Mailer + SendMailErr error +} + +func (r *MockMailer) SendMail(from string, to string, subject string, body []byte) error { + return r.SendMailErr +} diff --git a/backend/application/auth/login/request.go b/backend/application/auth/login/request.go index a35b3d8d..3d8a45b5 100644 --- a/backend/application/auth/login/request.go +++ b/backend/application/auth/login/request.go @@ -3,7 +3,7 @@ package login type validationErrors map[string]string type Request struct { - Username string `json:"username"` + Identity string `json:"identity"` Password string `json:"password"` } diff --git a/backend/application/auth/login/usecase.go b/backend/application/auth/login/usecase.go index 28007408..5e85c7e1 100644 --- a/backend/application/auth/login/usecase.go +++ b/backend/application/auth/login/usecase.go @@ -4,6 +4,7 @@ import ( "time" "github.com/khanzadimahdi/testproject/application/auth" + "github.com/khanzadimahdi/testproject/domain/password" "github.com/khanzadimahdi/testproject/domain/user" "github.com/khanzadimahdi/testproject/infrastructure/jwt" ) @@ -11,12 +12,14 @@ import ( type UseCase struct { userRepository user.Repository JWT *jwt.JWT + Hasher password.Hasher } -func NewUseCase(userRepository user.Repository, JWT *jwt.JWT) *UseCase { +func NewUseCase(userRepository user.Repository, JWT *jwt.JWT, hasher password.Hasher) *UseCase { return &UseCase{ userRepository: userRepository, JWT: JWT, + Hasher: hasher, } } @@ -27,15 +30,15 @@ func (uc *UseCase) Login(request Request) (*LoginResponse, error) { }, nil } - u, err := uc.userRepository.GetOneByUsername(request.Username) + u, err := uc.userRepository.GetOneByIdentity(request.Identity) if err != nil { return nil, err } - if !uc.passwordIsValid(u, request.Password) { + if !uc.passwordIsValid(u, []byte(request.Password)) { return &LoginResponse{ ValidationErrors: validationErrors{ - "username": "username or password is not wrong", + "identity": "your identity or password is wrong", }, }, nil } @@ -56,8 +59,8 @@ func (uc *UseCase) Login(request Request) (*LoginResponse, error) { }, nil } -func (uc *UseCase) passwordIsValid(u user.User, password string) bool { - return u.Password == password +func (uc *UseCase) passwordIsValid(u user.User, password []byte) bool { + return uc.Hasher.Equal(password, u.PasswordHash.Value, u.PasswordHash.Salt) } func (uc *UseCase) generateAccessToken(u user.User) (string, error) { diff --git a/backend/application/auth/login/usecase_test.go b/backend/application/auth/login/usecase_test.go index be89b7c7..2649b585 100644 --- a/backend/application/auth/login/usecase_test.go +++ b/backend/application/auth/login/usecase_test.go @@ -4,32 +4,34 @@ import ( "errors" "testing" + "github.com/khanzadimahdi/testproject/domain/password" "github.com/khanzadimahdi/testproject/domain/user" "github.com/khanzadimahdi/testproject/infrastructure/crypto/ecdsa" "github.com/khanzadimahdi/testproject/infrastructure/jwt" ) -func TestUseCase_GetArticle(t *testing.T) { +func TestUseCase_Login(t *testing.T) { privateKey, err := ecdsa.Generate() if err != nil { t.Error("unexpected error") } j := jwt.NewJWT(privateKey, privateKey.Public()) + h := &MockHasher{} t.Run("returns jwt tokens", func(t *testing.T) { repository := MockUserRepository{} - usecase := NewUseCase(&repository, j) + usecase := NewUseCase(&repository, j, h) request := Request{ - Username: "test-username", + Identity: "test-username", Password: "test-password", } response, err := usecase.Login(request) - if repository.GetOneByUsernameCount != 1 { + if repository.GetOneByIdentityCount != 1 { t.Error("unexpected number of calls") } @@ -74,16 +76,16 @@ func TestUseCase_GetArticle(t *testing.T) { GetOneErr: errors.New("user with given username found"), } - usecase := NewUseCase(&repository, j) + usecase := NewUseCase(&repository, j, h) request := Request{ - Username: "test-username", + Identity: "test-username", Password: "test-password", } response, err := usecase.Login(request) - if repository.GetOneByUsernameCount != 1 { + if repository.GetOneByIdentityCount != 1 { t.Error("unexpected number of calls") } @@ -100,12 +102,12 @@ func TestUseCase_GetArticle(t *testing.T) { type MockUserRepository struct { user.Repository - GetOneByUsernameCount uint + GetOneByIdentityCount uint GetOneErr error } -func (r *MockUserRepository) GetOneByUsername(username string) (user.User, error) { - r.GetOneByUsernameCount++ +func (r *MockUserRepository) GetOneByIdentity(username string) (user.User, error) { + r.GetOneByIdentityCount++ if r.GetOneErr != nil { return user.User{}, r.GetOneErr @@ -113,6 +115,20 @@ func (r *MockUserRepository) GetOneByUsername(username string) (user.User, error return user.User{ Username: username, - Password: "test-password", + PasswordHash: password.Hash{ + Value: []byte("test-password"), + Salt: []byte("test-salt"), + }, }, nil } + +type MockHasher struct { +} + +func (m *MockHasher) Hash(value, salt []byte) []byte { + return []byte("random hash") +} + +func (m *MockHasher) Equal(value, hash, salt []byte) bool { + return true +} diff --git a/backend/application/auth/resetpassword/request.go b/backend/application/auth/resetpassword/request.go new file mode 100644 index 00000000..dccdd6c6 --- /dev/null +++ b/backend/application/auth/resetpassword/request.go @@ -0,0 +1,12 @@ +package resetpassword + +type validationErrors map[string]string + +type Request struct { + Token string `json:"token"` + Password string `json:"password"` +} + +func (r *Request) Validate() (bool, validationErrors) { + return true, nil +} diff --git a/backend/application/auth/resetpassword/response.go b/backend/application/auth/resetpassword/response.go new file mode 100644 index 00000000..ac33aa41 --- /dev/null +++ b/backend/application/auth/resetpassword/response.go @@ -0,0 +1,5 @@ +package resetpassword + +type Response struct { + ValidationErrors validationErrors `json:"errors,omitempty"` +} diff --git a/backend/application/auth/resetpassword/usecase.go b/backend/application/auth/resetpassword/usecase.go new file mode 100644 index 00000000..55104bba --- /dev/null +++ b/backend/application/auth/resetpassword/usecase.go @@ -0,0 +1,75 @@ +package resetpassword + +import ( + "crypto/rand" + "encoding/base64" + "errors" + + "github.com/khanzadimahdi/testproject/application/auth" + "github.com/khanzadimahdi/testproject/domain/password" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/jwt" +) + +type UseCase struct { + userRepository user.Repository + hasher password.Hasher + jwt *jwt.JWT +} + +func NewUseCase(userRepository user.Repository, hasher password.Hasher, JWT *jwt.JWT) *UseCase { + return &UseCase{ + userRepository: userRepository, + hasher: hasher, + jwt: JWT, + } +} + +func (uc *UseCase) ResetPassword(request Request) (*Response, error) { + if ok, validation := request.Validate(); !ok { + return &Response{ + ValidationErrors: validation, + }, nil + } + + resetPasswordToken, err := base64.URLEncoding.DecodeString(request.Token) + if err != nil { + return nil, err + } + + claims, err := uc.jwt.Verify(string(resetPasswordToken)) + if err != nil { + return nil, err + } + + audiences, err := claims.GetAudience() + if err != nil || len(audiences) == 0 || audiences[0] != auth.ResetPasswordToken { + return nil, errors.New("token is not valid") + } + + userUUID, err := claims.GetSubject() + if err != nil || len(userUUID) == 0 { + return nil, errors.New("token is not valid") + } + + u, err := uc.userRepository.GetOne(userUUID) + if err != nil { + return nil, err + } + + salt := make([]byte, 1024) + if _, err := rand.Read(salt); err != nil { + return nil, err + } + + u.PasswordHash = password.Hash{ + Value: uc.hasher.Hash([]byte(request.Password), salt), + Salt: salt, + } + + if err := uc.userRepository.Save(&u); err != nil { + return nil, err + } + + return &Response{}, nil +} diff --git a/backend/application/auth/resetpassword/usecase_test.go b/backend/application/auth/resetpassword/usecase_test.go new file mode 100644 index 00000000..c3da2549 --- /dev/null +++ b/backend/application/auth/resetpassword/usecase_test.go @@ -0,0 +1,231 @@ +package resetpassword + +import ( + "encoding/base64" + "testing" + "time" + + "github.com/khanzadimahdi/testproject/application/auth" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/password" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/crypto/ecdsa" + "github.com/khanzadimahdi/testproject/infrastructure/jwt" +) + +func TestUseCase_ResetPassword(t *testing.T) { + privateKey, err := ecdsa.Generate() + if err != nil { + t.Error("unexpected error") + } + + j := jwt.NewJWT(privateKey, privateKey.Public()) + + t.Run("malformed base64 token", func(t *testing.T) { + repository := MockUserRepository{} + h := &MockHasher{} + + usecase := NewUseCase(&repository, h, j) + + request := Request{ + Token: "random.base64.token", + Password: "test-password", + } + + response, err := usecase.ResetPassword(request) + if err == nil { + t.Error("expected an error") + } + + if response != nil { + t.Errorf("unexpected response %#v", response) + } + }) + + t.Run("invalid token", func(t *testing.T) { + repository := MockUserRepository{} + h := &MockHasher{} + + usecase := NewUseCase(&repository, h, j) + + testCases := make(map[string]string) + testCases["expired token"], _ = resetPasswordToken(j, user.User{UUID: "test-uuid"}, time.Now().Add(-time.Second), auth.ResetPasswordToken) + testCases["invalid audience"], _ = resetPasswordToken(j, user.User{UUID: "test-uuid"}, time.Now().Add(-time.Second), auth.AccessToken) + testCases["expired token"], _ = resetPasswordToken(j, user.User{}, time.Now().Add(-time.Second), auth.ResetPasswordToken) + + for i := range testCases { + request := Request{ + Token: testCases[i], + Password: "test-password", + } + + response, err := usecase.ResetPassword(request) + if err == nil { + t.Error("expected an error") + } + + if response != nil { + t.Errorf("unexpected response %#v", response) + } + } + }) + + t.Run("error on fetching user", func(t *testing.T) { + repository := MockUserRepository{ + GetOneErr: domain.ErrNotExists, + } + h := &MockHasher{} + + usecase := NewUseCase(&repository, h, j) + + token, _ := resetPasswordToken(j, user.User{UUID: "test-uuid"}, time.Now().Add(1*time.Minute), auth.ResetPasswordToken) + request := Request{ + Token: token, + Password: "test-password", + } + + response, err := usecase.ResetPassword(request) + if err != domain.ErrNotExists { + t.Errorf("expected error is %q but got %q", domain.ErrNotExists, err) + } + + if repository.GetOneCount != 1 { + t.Errorf("fetching user should happen only once, but happend %d times", repository.GetOneCount) + } + + if response != nil { + t.Errorf("unexpected response %#v", response) + } + }) + + t.Run("error on persisting user's password", func(t *testing.T) { + repository := MockUserRepository{ + SaveErr: domain.ErrNotExists, + } + h := &MockHasher{} + + usecase := NewUseCase(&repository, h, j) + + token, _ := resetPasswordToken(j, user.User{UUID: "test-uuid"}, time.Now().Add(1*time.Minute), auth.ResetPasswordToken) + request := Request{ + Token: token, + Password: "test-password", + } + + response, err := usecase.ResetPassword(request) + if err != domain.ErrNotExists { + t.Errorf("expected error is %q but got %q", domain.ErrNotExists, err) + } + + if repository.GetOneCount != 1 { + t.Errorf("fetching user should happen only once, but happend %d times", repository.GetOneCount) + } + + if h.HashCount != 1 { + t.Errorf("hashing password should happen only once, but happend %d times", h.HashCount) + } + + if repository.SaveCount != 1 { + t.Errorf("persisting user should happen only once, but happend %d times", repository.SaveCount) + } + + if response != nil { + t.Errorf("unexpected response %#v", response) + } + }) + + t.Run("password successfully updates", func(t *testing.T) { + repository := MockUserRepository{ + SaveErr: domain.ErrNotExists, + } + h := &MockHasher{} + + usecase := NewUseCase(&repository, h, j) + + token, _ := resetPasswordToken(j, user.User{UUID: "test-uuid"}, time.Now().Add(1*time.Minute), auth.ResetPasswordToken) + request := Request{ + Token: token, + Password: "test-password", + } + + response, err := usecase.ResetPassword(request) + if err != domain.ErrNotExists { + t.Errorf("expected error is %q but got %q", domain.ErrNotExists, err) + } + + if repository.GetOneCount != 1 { + t.Errorf("fetching user should happen only once, but happend %d times", repository.GetOneCount) + } + + if h.HashCount != 1 { + t.Errorf("hashing password should happen only once, but happend %d times", h.HashCount) + } + + if repository.SaveCount != 1 { + t.Errorf("persisting user should happen only once, but happend %d times", repository.SaveCount) + } + + if response != nil { + t.Errorf("unexpected response %#v", response) + } + }) +} + +type MockUserRepository struct { + user.Repository + + GetOneCount uint + GetOneErr error + + SaveCount uint + SaveErr error +} + +func (r *MockUserRepository) GetOne(UUID string) (user.User, error) { + r.GetOneCount++ + + if r.GetOneErr != nil { + return user.User{}, r.GetOneErr + } + + return user.User{ + UUID: UUID, + PasswordHash: password.Hash{ + Value: []byte("test-password"), + Salt: []byte("test-salt"), + }, + }, nil +} + +func (r *MockUserRepository) Save(u *user.User) error { + r.SaveCount++ + + return r.SaveErr +} + +type MockHasher struct { + password.Hasher + HashCount uint +} + +func (m *MockHasher) Hash(value, salt []byte) []byte { + m.HashCount++ + + return []byte("random hash") +} + +func resetPasswordToken(j *jwt.JWT, u user.User, expiresAt time.Time, audience string) (string, error) { + b := jwt.NewClaimsBuilder() + b.SetSubject(u.UUID) + b.SetNotBefore(time.Now().Add(-time.Hour)) + b.SetExpirationTime(expiresAt) + b.SetIssuedAt(time.Now()) + b.SetAudience([]string{audience}) + + t, err := j.Generate(b.Build()) + if err != nil { + return t, err + } + + return base64.URLEncoding.EncodeToString([]byte(t)), nil +} diff --git a/backend/domain/domain.go b/backend/domain/domain.go index 878875b3..2f8bf412 100644 --- a/backend/domain/domain.go +++ b/backend/domain/domain.go @@ -5,3 +5,7 @@ import "errors" var ( ErrNotExists = errors.New("not exists") ) + +type Mailer interface { + SendMail(from string, to string, subject string, body []byte) error +} diff --git a/backend/domain/password/password.go b/backend/domain/password/password.go new file mode 100644 index 00000000..9d592b9e --- /dev/null +++ b/backend/domain/password/password.go @@ -0,0 +1,24 @@ +package password + +type Hash struct { + Value []byte + Salt []byte +} + +type Hasher interface { + Hash(value, salt []byte) []byte + Equal(value, hash, salt []byte) bool +} + +type Encrypter interface { + Encrypt([]byte) ([]byte, error) +} + +type Decrypter interface { + Decrypt([]byte) ([]byte, error) +} + +type EncryptDecrypter interface { + Encrypter + Decrypter +} diff --git a/backend/domain/user/user.go b/backend/domain/user/user.go index b9775ecd..7042ab8f 100644 --- a/backend/domain/user/user.go +++ b/backend/domain/user/user.go @@ -1,15 +1,18 @@ package user +import "github.com/khanzadimahdi/testproject/domain/password" + type User struct { - UUID string - Name string - Avatar string - Username string - Password string + UUID string + Name string + Avatar string + Email string + Username string + PasswordHash password.Hash } type Repository interface { GetOne(UUID string) (User, error) - GetOneByUsername(username string) (User, error) + GetOneByIdentity(username string) (User, error) Save(*User) error } diff --git a/backend/go.mod b/backend/go.mod index f500d58e..966d476c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -32,10 +32,10 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20201027041543-1326539a0a0a // indirect - golang.org/x/crypto v0.18.0 // indirect - golang.org/x/net v0.20.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/net v0.21.0 // indirect golang.org/x/sync v0.6.0 // indirect - golang.org/x/sys v0.16.0 // indirect + golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 16993f6d..c07aea3a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -72,6 +72,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20231226003508-02704c960a9b h1:kLiC65FbiHWFAOu+lxwNPujcsl8VYyTYYEZnsOO1WK4= golang.org/x/exp v0.0.0-20231226003508-02704c960a9b/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -82,6 +84,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= @@ -97,6 +101,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/backend/infrastructure/crypto/aes/aes.go b/backend/infrastructure/crypto/aes/aes.go new file mode 100644 index 00000000..dce96aea --- /dev/null +++ b/backend/infrastructure/crypto/aes/aes.go @@ -0,0 +1,60 @@ +package aes + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "io" + + "github.com/khanzadimahdi/testproject/domain/password" +) + +type aesGCM struct { + key []byte +} + +var _ password.EncryptDecrypter = NewAESGCM(nil) + +func NewAESGCM(key []byte) *aesGCM { + return &aesGCM{key: key} +} + +func (s *aesGCM) Encrypt(data []byte) ([]byte, error) { + block, err := aes.NewCipher(s.key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + return gcm.Seal(nonce, nonce, data, nil), nil +} + +func (s *aesGCM) Decrypt(cipherData []byte) ([]byte, error) { + block, err := aes.NewCipher(s.key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(cipherData) < nonceSize { + return nil, errors.New("cipher data too short") + } + + nonce, ciphertext := cipherData[:nonceSize], cipherData[nonceSize:] + return gcm.Open(nil, nonce, ciphertext, nil) +} diff --git a/backend/infrastructure/crypto/aes/aes_test.go b/backend/infrastructure/crypto/aes/aes_test.go new file mode 100644 index 00000000..5d7b2602 --- /dev/null +++ b/backend/infrastructure/crypto/aes/aes_test.go @@ -0,0 +1,60 @@ +package aes + +import ( + "bytes" + "crypto/rand" + "testing" +) + +func TestAESGCM(t *testing.T) { + data := make([]byte, 5*1024) + rand.Read(data) + + key := make([]byte, 32) + rand.Read(key) + + encryptDecrypter := NewAESGCM(key) + + t.Run("encryption and decryption", func(t *testing.T) { + encryptedData, err := encryptDecrypter.Encrypt(data) + if err != nil { + t.Error("unexpected error", err) + } + + decryptedData, err := encryptDecrypter.Decrypt(encryptedData) + if err != nil { + t.Error("unexpected error", err) + } + + if !bytes.Equal(data, decryptedData) { + t.Error("data and it's decrypted value are not equal") + } + }) + + t.Run("decryption with a non-identical key should fail", func(t *testing.T) { + wrongKey := make([]byte, 32) + rand.Read(wrongKey) + + encryptedData, err := encryptDecrypter.Encrypt(data) + if err != nil { + t.Error("unexpected error", err) + } + + if _, err := NewAESGCM(wrongKey).Decrypt(encryptedData); err == nil { + t.Error("decryption using a wrong key should not be possible") + } + }) + + t.Run("decryption of an interupted cipherdata should fail", func(t *testing.T) { + encryptedData, err := encryptDecrypter.Encrypt(data) + if err != nil { + t.Error("unexpected error", err) + } + + oneCharLost := encryptedData[:len(encryptedData)-1] + if _, err := encryptDecrypter.Decrypt(oneCharLost); err == nil { + t.Error("decryption of an interupted cipherdata should fail") + } + + }) +} diff --git a/backend/infrastructure/crypto/argon2/argon2.go b/backend/infrastructure/crypto/argon2/argon2.go new file mode 100644 index 00000000..e6bc7160 --- /dev/null +++ b/backend/infrastructure/crypto/argon2/argon2.go @@ -0,0 +1,43 @@ +package argon2 + +import ( + "bytes" + + "github.com/khanzadimahdi/testproject/domain/password" + "golang.org/x/crypto/argon2" +) + +type argon2id struct { + // time represents the number of + // passed over the specified memory. + time uint32 + // cpu memory to be used. + memory uint32 + // threads for parallelism aspect + // of the algorithm. + threads uint8 + // keyLen of the generate hash key. + keyLen uint32 +} + +var _ password.Hasher = NewArgon2id(1, 2, 3, 4) + +// NewArgon2id constructor function for argon2id. +func NewArgon2id(time, memory uint32, threads uint8, keyLen uint32) *argon2id { + return &argon2id{ + time: time, + memory: memory, + threads: threads, + keyLen: keyLen, + } +} + +// Hash using the value and provided salt. +func (a *argon2id) Hash(value, salt []byte) []byte { + return argon2.IDKey(value, salt, a.time, a.memory, a.threads, a.keyLen) +} + +// Equal reports whether a value and its hash match. +func (a *argon2id) Equal(value, hash, salt []byte) bool { + return bytes.Equal(hash, a.Hash(value, salt)) +} diff --git a/backend/infrastructure/crypto/argon2/argon2_test.go b/backend/infrastructure/crypto/argon2/argon2_test.go new file mode 100644 index 00000000..10e5ed7e --- /dev/null +++ b/backend/infrastructure/crypto/argon2/argon2_test.go @@ -0,0 +1,43 @@ +package argon2 + +import ( + "crypto/rand" + "testing" +) + +func TestArgon2(t *testing.T) { + var keyLen uint32 = 256 + + argon2id := NewArgon2id(1, 64*1024, 32, keyLen) + + value := make([]byte, 100) + rand.Read(value) + + t.Run("given value and its hash should match", func(t *testing.T) { + salt := []byte{2, 4, 6, 8, 10} + hash := argon2id.Hash(value, salt) + + if int(keyLen) != len(hash) { + t.Errorf("expected hash length %d, but got %d", keyLen, len(hash)) + } + + if equal := argon2id.Equal(value, hash, salt); !equal { + t.Error("value and it's hash doesn't match") + } + }) + + t.Run("not identical value should not match", func(t *testing.T) { + salt := []byte{2, 4, 6, 8, 10} + hash := argon2id.Hash(value, salt) + + if int(keyLen) != len(hash) { + t.Errorf("expected hash length %d, but got %d", keyLen, len(hash)) + } + + nonIdenticalValue := append(value, 0) + + if equal := argon2id.Equal(nonIdenticalValue, hash, salt); equal { + t.Error("hash should not match a non-identical value") + } + }) +} diff --git a/backend/infrastructure/email/smtp.go b/backend/infrastructure/email/smtp.go new file mode 100644 index 00000000..7bfc99da --- /dev/null +++ b/backend/infrastructure/email/smtp.go @@ -0,0 +1,52 @@ +package email + +import ( + "bytes" + "fmt" + "net/smtp" + + "github.com/khanzadimahdi/testproject/domain" +) + +type Config struct { + Auth Auth + Host string + Port string +} + +type Auth struct { + Username string + Password string +} + +type client struct { + config Config + addr string +} + +var _ domain.Mailer = NewSMTP(Config{}) + +func NewSMTP(config Config) *client { + return &client{ + config: config, + addr: fmt.Sprintf("%s:%s", config.Host, config.Port), + } +} + +func (s *client) SendMail(from string, to string, subject string, body []byte) error { + auth := smtp.PlainAuth("", s.config.Auth.Username, s.config.Auth.Password, s.config.Host) + + var msg bytes.Buffer + + mimeHeaders := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n" + + if _, err := msg.WriteString(fmt.Sprintf("From: %s\nTo: %s\nSubject: %s\n%s", from, to, subject, mimeHeaders)); err != nil { + return err + } + + if _, err := msg.Write(body); err != nil { + return err + } + + return smtp.SendMail(s.addr, auth, from, []string{to}, msg.Bytes()) +} diff --git a/backend/infrastructure/jwt/jwt_test.go b/backend/infrastructure/jwt/jwt_test.go index 2e61aca8..1a58a155 100644 --- a/backend/infrastructure/jwt/jwt_test.go +++ b/backend/infrastructure/jwt/jwt_test.go @@ -1,7 +1,6 @@ package jwt import ( - "fmt" "testing" "time" @@ -13,8 +12,7 @@ import ( func TestJWT(t *testing.T) { privateKey, err := ecdsa.Generate() if err != nil { - fmt.Println("Error generating private key:", err) - return + t.Error("unexpected error", err) } // For demonstration purposes, in a real scenario, you would typically use the public key from the corresponding private key. @@ -33,15 +31,13 @@ func TestJWT(t *testing.T) { tokenString, err := JWTToken.Generate(claims) if err != nil { - fmt.Println("Error generating token:", err) - return + t.Error("unexpected error", err) } // Verify the JWT token tokenClaims, err := JWTToken.Verify(tokenString) if err != nil { - fmt.Println("Error verifying token:", err) - return + t.Error("unexpected error", err) } // Access the claims diff --git a/backend/infrastructure/repository/mongodb/users/model.go b/backend/infrastructure/repository/mongodb/users/model.go index 36b52760..9dc9d5d4 100644 --- a/backend/infrastructure/repository/mongodb/users/model.go +++ b/backend/infrastructure/repository/mongodb/users/model.go @@ -5,12 +5,18 @@ import ( ) type UserBson struct { - UUID string `bson:"_id,omitempty"` - Name string `bson:"name,omitempty"` - Avatar string `bson:"avatar,omitempty"` - Username string `bson:"username,omitempty"` - Password string `bson:"password,omitempty"` - CreatedAt time.Time `bson:"created_at,omitempty"` + UUID string `bson:"_id,omitempty"` + Name string `bson:"name,omitempty"` + Avatar string `bson:"avatar,omitempty"` + Email string `bson:"email,omitempty"` + Username string `bson:"username,omitempty"` + PasswordHash PasswordHashBson `bson:"hash,omitempty"` + CreatedAt time.Time `bson:"created_at,omitempty"` +} + +type PasswordHashBson struct { + Value []byte `bson:"value,omitempty"` + Salt []byte `bson:"salt,omitempty"` } type SetWrapper struct { diff --git a/backend/infrastructure/repository/mongodb/users/repository.go b/backend/infrastructure/repository/mongodb/users/repository.go index 17f6a12e..1ebe34fb 100644 --- a/backend/infrastructure/repository/mongodb/users/repository.go +++ b/backend/infrastructure/repository/mongodb/users/repository.go @@ -7,6 +7,7 @@ import ( "github.com/gofrs/uuid/v5" "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/password" "github.com/khanzadimahdi/testproject/domain/user" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" @@ -50,17 +51,32 @@ func (r *UsersRepository) GetOne(UUID string) (user.User, error) { UUID: a.UUID, Name: a.Name, Avatar: a.Avatar, + Email: a.Email, Username: a.Username, - Password: a.Password, + PasswordHash: password.Hash{ + Value: a.PasswordHash.Value, + Salt: a.PasswordHash.Salt, + }, }, nil } -func (r *UsersRepository) GetOneByUsername(username string) (user.User, error) { +// GetOneByIdentity returns a user which its email or username matches given identity +func (r *UsersRepository) GetOneByIdentity(identity string) (user.User, error) { ctx, cancel := context.WithTimeout(context.Background(), queryTimeout) defer cancel() + filter := bson.D{ + { + Key: "$or", + Value: bson.A{ + bson.D{{Key: "email", Value: identity}}, + bson.D{{Key: "username", Value: identity}}, + }, + }, + } + var a UserBson - if err := r.collection.FindOne(ctx, bson.D{{Key: "username", Value: username}}, nil).Decode(&a); err != nil { + if err := r.collection.FindOne(ctx, filter, nil).Decode(&a); err != nil { if errors.Is(err, mongo.ErrNoDocuments) { err = domain.ErrNotExists } @@ -71,8 +87,12 @@ func (r *UsersRepository) GetOneByUsername(username string) (user.User, error) { UUID: a.UUID, Name: a.Name, Avatar: a.Avatar, + Email: a.Email, Username: a.Username, - Password: a.Password, + PasswordHash: password.Hash{ + Value: a.PasswordHash.Value, + Salt: a.PasswordHash.Salt, + }, }, nil } @@ -89,11 +109,15 @@ func (r *UsersRepository) Save(a *user.User) error { } update := UserBson{ - UUID: a.UUID, - Name: a.Name, - Avatar: a.Avatar, - Username: a.Username, - Password: a.Password, + UUID: a.UUID, + Name: a.Name, + Avatar: a.Avatar, + Email: a.Email, + Username: a.Username, + PasswordHash: PasswordHashBson{ + Value: a.PasswordHash.Value, + Salt: a.PasswordHash.Salt, + }, CreatedAt: time.Now(), } diff --git a/backend/main.go b/backend/main.go index d5f55891..98a9017e 100644 --- a/backend/main.go +++ b/backend/main.go @@ -16,8 +16,10 @@ import ( getArticle "github.com/khanzadimahdi/testproject/application/article/getArticle" getArticles "github.com/khanzadimahdi/testproject/application/article/getArticles" "github.com/khanzadimahdi/testproject/application/article/getArticlesByHashtag" + "github.com/khanzadimahdi/testproject/application/auth/forgetpassword" "github.com/khanzadimahdi/testproject/application/auth/login" "github.com/khanzadimahdi/testproject/application/auth/refresh" + "github.com/khanzadimahdi/testproject/application/auth/resetpassword" dashboardCreateArticle "github.com/khanzadimahdi/testproject/application/dashboard/article/createArticle" dashboardDeleteArticle "github.com/khanzadimahdi/testproject/application/dashboard/article/deleteArticle" dashboardGetArticle "github.com/khanzadimahdi/testproject/application/dashboard/article/getArticle" @@ -35,7 +37,9 @@ import ( getFile "github.com/khanzadimahdi/testproject/application/file/getFile" "github.com/khanzadimahdi/testproject/application/home" "github.com/khanzadimahdi/testproject/infrastructure/console" + "github.com/khanzadimahdi/testproject/infrastructure/crypto/argon2" "github.com/khanzadimahdi/testproject/infrastructure/crypto/ecdsa" + "github.com/khanzadimahdi/testproject/infrastructure/email" "github.com/khanzadimahdi/testproject/infrastructure/jwt" articlesrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/articles" elementsrepository "github.com/khanzadimahdi/testproject/infrastructure/repository/mongodb/elements" @@ -113,13 +117,27 @@ func httpHandler() http.Handler { } j := jwt.NewJWT(privateKey, privateKey.Public()) + hasher := argon2.NewArgon2id(2, 64*1024, 2, 64) + + mailFromAddress := os.Getenv("MAIL_SMTP_FROM") + mailer := email.NewSMTP(email.Config{ + Auth: email.Auth{ + Username: os.Getenv("MAIL_SMTP_USERNAME"), + Password: os.Getenv("MAIL_SMTP_PASSWORD"), + }, + Host: os.Getenv("MAIL_SMTP_HOST"), + Port: os.Getenv("MAIL_SMTP_PORT"), + }) homeUseCase := home.NewUseCase(articlesRepository, elementsRepository) router := httprouter.New() log.SetFlags(log.LstdFlags | log.Llongfile) - loginUseCase := login.NewUseCase(userRepository, j) + loginUseCase := login.NewUseCase(userRepository, j, hasher) refreshUseCase := refresh.NewUseCase(userRepository, j) + forgetPasswordUseCase := forgetpassword.NewUseCase(userRepository, j, mailer, mailFromAddress) + resetPasswordUseCase := resetpassword.NewUseCase(userRepository, hasher, j) + getArticleUsecase := getArticle.NewUseCase(articlesRepository, elementsRepository) getArticlesUsecase := getArticles.NewUseCase(articlesRepository) getArticlesByHashtagUseCase := getArticlesByHashtag.NewUseCase(articlesRepository) @@ -131,6 +149,8 @@ func httpHandler() http.Handler { // auth router.Handler(http.MethodPost, "/api/auth/login", auth.NewLoginHandler(loginUseCase)) router.Handler(http.MethodPost, "/api/auth/token/refresh", auth.NewRefreshHandler(refreshUseCase)) + router.Handler(http.MethodPost, "/api/auth/password/forget", auth.NewForgetPasswordHandler(forgetPasswordUseCase)) + router.Handler(http.MethodPost, "/api/auth/password/reset", auth.NewResetPasswordHandler(resetPasswordUseCase)) // articles router.Handler(http.MethodGet, "/api/articles", articleAPI.NewIndexHandler(getArticlesUsecase)) diff --git a/backend/presentation/http/api/auth/forgetpassword.go b/backend/presentation/http/api/auth/forgetpassword.go new file mode 100644 index 00000000..33418979 --- /dev/null +++ b/backend/presentation/http/api/auth/forgetpassword.go @@ -0,0 +1,43 @@ +package auth + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/khanzadimahdi/testproject/application/auth/forgetpassword" + "github.com/khanzadimahdi/testproject/domain" +) + +type forgetPasswordHandler struct { + useCase *forgetpassword.UseCase +} + +func NewForgetPasswordHandler(useCase *forgetpassword.UseCase) *forgetPasswordHandler { + return &forgetPasswordHandler{ + useCase: useCase, + } +} + +func (h *forgetPasswordHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + var request forgetpassword.Request + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + + response, err := h.useCase.SendResetToken(request) + + switch true { + case errors.Is(err, domain.ErrNotExists): + rw.WriteHeader(http.StatusNotFound) + case err != nil: + rw.WriteHeader(http.StatusInternalServerError) + case len(response.ValidationErrors) > 0: + rw.WriteHeader(http.StatusBadRequest) + json.NewEncoder(rw).Encode(response) + default: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusNoContent) + } +} diff --git a/backend/presentation/http/api/auth/resetpassword.go b/backend/presentation/http/api/auth/resetpassword.go new file mode 100644 index 00000000..216cf62b --- /dev/null +++ b/backend/presentation/http/api/auth/resetpassword.go @@ -0,0 +1,43 @@ +package auth + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/khanzadimahdi/testproject/application/auth/resetpassword" + "github.com/khanzadimahdi/testproject/domain" +) + +type resetPasswordHandler struct { + useCase *resetpassword.UseCase +} + +func NewResetPasswordHandler(useCase *resetpassword.UseCase) *resetPasswordHandler { + return &resetPasswordHandler{ + useCase: useCase, + } +} + +func (h *resetPasswordHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + var request resetpassword.Request + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + + response, err := h.useCase.ResetPassword(request) + + switch true { + case errors.Is(err, domain.ErrNotExists): + rw.WriteHeader(http.StatusNotFound) + case err != nil: + rw.WriteHeader(http.StatusInternalServerError) + case len(response.ValidationErrors) > 0: + rw.WriteHeader(http.StatusBadRequest) + json.NewEncoder(rw).Encode(response) + default: + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusNoContent) + } +} diff --git a/compose.yaml b/compose.yaml index b9e19dd9..c1fc9b20 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,4 @@ -version: "3.7" +version: "3.8" services: frontend: @@ -31,6 +31,7 @@ services: - "8000:80" volumes: - ./:/app + - ./docker/mailserver:/usr/local/share/ca-certificates/ environment: PRIVATE_KEY: ${PRIVATE_KEY} S3_ENDPOINT: ${S3_ENDPOINT} @@ -44,6 +45,11 @@ services: MONGO_HOST: ${MONGO_HOST} MONGO_PORT: ${MONGO_PORT} MONGO_DATABASE_NAME: ${MONGO_DATABASE_NAME} + MAIL_SMTP_FROM: ${MAIL_SMTP_FROM} + MAIL_SMTP_USERNAME: ${MAIL_SMTP_USERNAME} + MAIL_SMTP_PASSWORD: ${MAIL_SMTP_PASSWORD} + MAIL_SMTP_HOST: ${MAIL_SMTP_HOST} + MAIL_SMTP_PORT: ${MAIL_SMTP_PORT} mongodb: image: mongo diff --git a/frontend/pages/auth/login.vue b/frontend/pages/auth/login.vue index 06e1a02b..91b94d54 100644 --- a/frontend/pages/auth/login.vue +++ b/frontend/pages/auth/login.vue @@ -1,24 +1,24 @@