From ded2c8321689997080c8d201fa077058fa93d1af Mon Sep 17 00:00:00 2001 From: Mahdi Khanzadi Date: Sun, 23 Jun 2024 13:43:28 +0200 Subject: [PATCH] introduce user registeration API --- backend/application/auth/auth.go | 1 + backend/application/auth/register/request.go | 23 ++++ backend/application/auth/register/response.go | 5 + backend/application/auth/register/usecase.go | 92 +++++++++++++++ .../application/auth/register/usecase_test.go | 1 + backend/application/auth/verify/request.go | 15 +++ backend/application/auth/verify/response.go | 5 + backend/application/auth/verify/usecase.go | 110 ++++++++++++++++++ .../application/auth/verify/usecase_test.go | 1 + backend/infrastructure/jwt/jwt_test.go | 4 +- backend/main.go | 6 + .../presentation/http/api/auth/register.go | 43 +++++++ backend/presentation/http/api/auth/verify.go | 43 +++++++ 13 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 backend/application/auth/register/request.go create mode 100644 backend/application/auth/register/response.go create mode 100644 backend/application/auth/register/usecase.go create mode 100644 backend/application/auth/register/usecase_test.go create mode 100644 backend/application/auth/verify/request.go create mode 100644 backend/application/auth/verify/response.go create mode 100644 backend/application/auth/verify/usecase.go create mode 100644 backend/application/auth/verify/usecase_test.go create mode 100644 backend/presentation/http/api/auth/register.go create mode 100644 backend/presentation/http/api/auth/verify.go diff --git a/backend/application/auth/auth.go b/backend/application/auth/auth.go index 24581885..2d0093b3 100644 --- a/backend/application/auth/auth.go +++ b/backend/application/auth/auth.go @@ -10,6 +10,7 @@ const ( AccessToken = "access" RefreshToken = "refresh" ResetPasswordToken = "reset-password" + RegistrationToken = "registration" ) type authKey struct{} diff --git a/backend/application/auth/register/request.go b/backend/application/auth/register/request.go new file mode 100644 index 00000000..582df98a --- /dev/null +++ b/backend/application/auth/register/request.go @@ -0,0 +1,23 @@ +package register + +import "regexp" + +var ( + emailRegex = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`) +) + +type validationErrors map[string]string + +type Request struct { + Identity string `json:"identity"` +} + +func (r *Request) Validate() (bool, validationErrors) { + if !emailRegex.MatchString(r.Identity) { + return false, validationErrors{ + "identity": "identity is not a valid email address", + } + } + + return true, nil +} diff --git a/backend/application/auth/register/response.go b/backend/application/auth/register/response.go new file mode 100644 index 00000000..82f20729 --- /dev/null +++ b/backend/application/auth/register/response.go @@ -0,0 +1,5 @@ +package register + +type Response struct { + ValidationErrors validationErrors `json:"errors,omitempty"` +} diff --git a/backend/application/auth/register/usecase.go b/backend/application/auth/register/usecase.go new file mode 100644 index 00000000..4dcdeb56 --- /dev/null +++ b/backend/application/auth/register/usecase.go @@ -0,0 +1,92 @@ +package register + +import ( + "bytes" + "encoding/base64" + "errors" + "github.com/khanzadimahdi/testproject/application/auth" + "github.com/khanzadimahdi/testproject/domain" + "github.com/khanzadimahdi/testproject/domain/user" + "github.com/khanzadimahdi/testproject/infrastructure/jwt" + "time" +) + +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) Register(request Request) (*Response, error) { + if ok, validation := request.Validate(); !ok { + return &Response{ + ValidationErrors: validation, + }, nil + } + + exists, err := uc.IdentityExists(request.Identity) + if err != nil { + return nil, err + } + + if exists { + return &Response{ + ValidationErrors: map[string]string{ + "identity": "user with given email already exists", + }, + }, nil + } + + resetPasswordToken, err := uc.registrationToken(request.Identity) + 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, request.Identity, "Registration", msg.Bytes()); err != nil { + return nil, err + } + + return &Response{}, nil +} + +func (uc *UseCase) IdentityExists(identity string) (bool, error) { + _, err := uc.userRepository.GetOneByIdentity(identity) + if errors.Is(err, domain.ErrNotExists) { + return true, nil + } + + return false, err +} + +func (uc *UseCase) registrationToken(identity string) (string, error) { + b := jwt.NewClaimsBuilder() + b.SetSubject(identity) + b.SetNotBefore(time.Now()) + b.SetExpirationTime(time.Now().Add(24 * time.Hour)) + b.SetIssuedAt(time.Now()) + b.SetAudience([]string{auth.RegistrationToken}) + + return uc.jwt.Generate(b.Build()) +} diff --git a/backend/application/auth/register/usecase_test.go b/backend/application/auth/register/usecase_test.go new file mode 100644 index 00000000..6399f0b2 --- /dev/null +++ b/backend/application/auth/register/usecase_test.go @@ -0,0 +1 @@ +package register diff --git a/backend/application/auth/verify/request.go b/backend/application/auth/verify/request.go new file mode 100644 index 00000000..a5d9cb95 --- /dev/null +++ b/backend/application/auth/verify/request.go @@ -0,0 +1,15 @@ +package verify + +type validationErrors map[string]string + +type Request struct { + Token string `json:"token"` + Name string `json:"name"` + Username string `json:"username"` + Password string `json:"password"` + Repassword string `json:"repassword"` +} + +func (r *Request) Validate() (bool, validationErrors) { + return true, nil +} diff --git a/backend/application/auth/verify/response.go b/backend/application/auth/verify/response.go new file mode 100644 index 00000000..cccb7bd2 --- /dev/null +++ b/backend/application/auth/verify/response.go @@ -0,0 +1,5 @@ +package verify + +type Response struct { + ValidationErrors validationErrors `json:"errors,omitempty"` +} diff --git a/backend/application/auth/verify/usecase.go b/backend/application/auth/verify/usecase.go new file mode 100644 index 00000000..234d4f3a --- /dev/null +++ b/backend/application/auth/verify/usecase.go @@ -0,0 +1,110 @@ +package verify + +import ( + "crypto/rand" + "errors" + + "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/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) Verify(request Request) (*Response, error) { + if ok, validation := request.Validate(); !ok { + return &Response{ + ValidationErrors: validation, + }, nil + } + + claims, err := uc.jwt.Verify(request.Token) + if err != nil { + return &Response{ + ValidationErrors: validationErrors{ + "token": err.Error(), + }, + }, nil + } + + if audiences, err := claims.GetAudience(); err != nil || len(audiences) == 0 || audiences[0] != auth.RegistrationToken { + return &Response{ + ValidationErrors: validationErrors{ + "token": "registration token is not valid", + }, + }, nil + } + + identity, err := claims.GetSubject() + if err != nil { + return &Response{ + ValidationErrors: validationErrors{ + "token": err.Error(), + }, + }, nil + } + + if exists, err := uc.IdentityExists(identity); err != nil { + return nil, err + } else if exists { + return &Response{ + ValidationErrors: map[string]string{ + "identity": "user with given email already exists", + }, + }, nil + } + + if exists, err := uc.IdentityExists(request.Username); err != nil { + return nil, err + } else if exists { + return &Response{ + ValidationErrors: map[string]string{ + "user": "user with given username already exists", + }, + }, nil + } + + salt := make([]byte, 64) + if _, err := rand.Read(salt); err != nil { + return nil, err + } + + u := user.User{ + Name: request.Name, + Username: request.Username, + Email: identity, + 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 +} + +func (uc *UseCase) IdentityExists(identity string) (bool, error) { + _, err := uc.userRepository.GetOneByIdentity(identity) + if errors.Is(err, domain.ErrNotExists) { + return true, nil + } + + return false, err +} diff --git a/backend/application/auth/verify/usecase_test.go b/backend/application/auth/verify/usecase_test.go new file mode 100644 index 00000000..efc7e18c --- /dev/null +++ b/backend/application/auth/verify/usecase_test.go @@ -0,0 +1 @@ +package verify diff --git a/backend/infrastructure/jwt/jwt_test.go b/backend/infrastructure/jwt/jwt_test.go index 1a58a155..54e792eb 100644 --- a/backend/infrastructure/jwt/jwt_test.go +++ b/backend/infrastructure/jwt/jwt_test.go @@ -19,7 +19,7 @@ func TestJWT(t *testing.T) { // Here, we extract the public key from the private key just for demonstration purposes. publicKey := privateKey.Public() - // Generate a JWT token + // Generate a jwt token claims := jwt.MapClaims{ "sub": "1234567890", "name": "John Doe", @@ -34,7 +34,7 @@ func TestJWT(t *testing.T) { t.Error("unexpected error", err) } - // Verify the JWT token + // Verify the jwt token tokenClaims, err := JWTToken.Verify(tokenString) if err != nil { t.Error("unexpected error", err) diff --git a/backend/main.go b/backend/main.go index a43fc645..608f41a3 100644 --- a/backend/main.go +++ b/backend/main.go @@ -19,7 +19,9 @@ import ( "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/register" "github.com/khanzadimahdi/testproject/application/auth/resetpassword" + "github.com/khanzadimahdi/testproject/application/auth/verify" 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" @@ -141,6 +143,8 @@ func httpHandler() http.Handler { refreshUseCase := refresh.NewUseCase(userRepository, j) forgetPasswordUseCase := forgetpassword.NewUseCase(userRepository, j, mailer, mailFromAddress) resetPasswordUseCase := resetpassword.NewUseCase(userRepository, hasher, j) + registerUseCase := register.NewUseCase(userRepository, j, mailer, mailFromAddress) + verifyUseCase := verify.NewUseCase(userRepository, hasher, j) getArticleUsecase := getArticle.NewUseCase(articlesRepository, elementsRepository) getArticlesUsecase := getArticles.NewUseCase(articlesRepository) @@ -155,6 +159,8 @@ func httpHandler() http.Handler { 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)) + router.Handler(http.MethodPost, "/api/auth/register", auth.NewRegisterHandler(registerUseCase)) + router.Handler(http.MethodPost, "/api/auth/verify", auth.NewVerifyHandler(verifyUseCase)) // articles router.Handler(http.MethodGet, "/api/articles", articleAPI.NewIndexHandler(getArticlesUsecase)) diff --git a/backend/presentation/http/api/auth/register.go b/backend/presentation/http/api/auth/register.go new file mode 100644 index 00000000..53745675 --- /dev/null +++ b/backend/presentation/http/api/auth/register.go @@ -0,0 +1,43 @@ +package auth + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/khanzadimahdi/testproject/application/auth/register" + "github.com/khanzadimahdi/testproject/domain" +) + +type registerHandler struct { + useCase *register.UseCase +} + +func NewRegisterHandler(useCase *register.UseCase) *registerHandler { + return ®isterHandler{ + useCase: useCase, + } +} + +func (h *registerHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + var request register.Request + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + + response, err := h.useCase.Register(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/verify.go b/backend/presentation/http/api/auth/verify.go new file mode 100644 index 00000000..eafb4c2a --- /dev/null +++ b/backend/presentation/http/api/auth/verify.go @@ -0,0 +1,43 @@ +package auth + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/khanzadimahdi/testproject/application/auth/verify" + "github.com/khanzadimahdi/testproject/domain" +) + +type verifyHandler struct { + useCase *verify.UseCase +} + +func NewVerifyHandler(useCase *verify.UseCase) *verifyHandler { + return &verifyHandler{ + useCase: useCase, + } +} + +func (h *verifyHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + var request verify.Request + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + + response, err := h.useCase.Verify(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) + } +}