From e4c2230b643502a47d72c212d65126111d2f4e39 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Fri, 14 Jun 2024 13:46:39 +0200 Subject: [PATCH] feat: password reset #32 --- .../mail/templates/passwordReset.html | 18 +++ adapters/secondary/gateways/models/user.go | 15 +- adapters/secondary/gateways/userRepository.go | 45 ++++++ .../gateways/web/templates/login.html | 102 +++++++------ .../web/templates/passwordResetRequest.html | 98 ++++++++++++ .../web/templates/passwordResetValidate.html | 140 ++++++++++++++++++ api/page.go | 17 +++ api/pageLogin.go | 8 +- api/pagePasswordResetRequest.go | 56 +++++++ api/pagePasswordResetValidate.go | 55 +++++++ domain/gateways/IUserRepository.go | 3 + domain/image/image.go | 10 +- domain/user/user.go | 11 +- useCases/CreateUserUseCase.go | 1 + useCases/GetUserUseCase.go | 4 + useCases/UpdateUserUseCase.go | 8 + 16 files changed, 527 insertions(+), 64 deletions(-) create mode 100644 adapters/secondary/gateways/mail/templates/passwordReset.html create mode 100644 adapters/secondary/gateways/web/templates/passwordResetRequest.html create mode 100644 adapters/secondary/gateways/web/templates/passwordResetValidate.html create mode 100644 api/pagePasswordResetRequest.go create mode 100644 api/pagePasswordResetValidate.go diff --git a/adapters/secondary/gateways/mail/templates/passwordReset.html b/adapters/secondary/gateways/mail/templates/passwordReset.html new file mode 100644 index 0000000..8fe8f05 --- /dev/null +++ b/adapters/secondary/gateways/mail/templates/passwordReset.html @@ -0,0 +1,18 @@ + + + +

+ G + GoCMS +

+
+

You or someone tried to create reset your GoCMS account with this e-mail address. If you are not responsible for + this, please do not validate the password reset.

+

If you want to reset your password, please click HERE. +

+

If you can't click the link:

+

{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}

+
+ + diff --git a/adapters/secondary/gateways/models/user.go b/adapters/secondary/gateways/models/user.go index 9fc1779..141a836 100644 --- a/adapters/secondary/gateways/models/user.go +++ b/adapters/secondary/gateways/models/user.go @@ -7,13 +7,14 @@ import ( type User struct { gorm.Model - ID uint32 `gorm:"primaryKey;autoIncrement"` - Username string `gorm:"unique;not null"` - Password string `gorm:"not null"` - Email string `gorm:"unique;not null"` - IsVerified bool `gorm:"default=false;not null"` - VerificationCode string `gorm:"unique;not null"` - VerificationExpiration time.Time `gorm:"not null"` + ID uint32 `gorm:"primaryKey;autoIncrement"` + Username string `gorm:"unique;not null"` + Password string `gorm:"not null"` + PasswordResetCode string `gorm:"unique"` + Email string `gorm:"unique;not null"` + IsVerified bool `gorm:"default=false;not null"` + VerificationCode string `gorm:"unique"` + VerificationExpiration time.Time CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` } diff --git a/adapters/secondary/gateways/userRepository.go b/adapters/secondary/gateways/userRepository.go index ebb6d2c..b0e5561 100644 --- a/adapters/secondary/gateways/userRepository.go +++ b/adapters/secondary/gateways/userRepository.go @@ -22,6 +22,7 @@ func mapUserToDomain(user entity.User) domain.User { user.ID, user.Username, user.Password, + user.PasswordResetCode, user.Email, user.IsVerified, user.VerificationCode, @@ -88,6 +89,16 @@ func (u *UserRepository) GetByUsername(username string) (domain.User, error) { return mapUserToDomain(localUser), nil } +func (u *UserRepository) GetByEmail(email string) (domain.User, error) { + var localUser entity.User + err := u.db.Model(&entity.User{}).Where("email = ?", email).First(&localUser).Error + if err != nil { + return domain.User{}, err + } + + return mapUserToDomain(localUser), nil +} + func (u *UserRepository) UpdateVerificationStatus(userId uint32, isVerified bool) (domain.User, error) { var localUser entity.User err := u.db.Model(&entity.User{}).First(&localUser, userId).Error @@ -104,4 +115,38 @@ func (u *UserRepository) UpdateVerificationStatus(userId uint32, isVerified bool return mapUserToDomain(localUser), nil } +func (u *UserRepository) UpdatePassword(userId uint32, password string) (domain.User, error) { + var localUser entity.User + err := u.db.Model(&entity.User{}).First(&localUser, userId).Error + if err != nil { + return domain.User{}, err + } + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), 12) + localUser.Password = string(hashedPassword) + err = u.db.Save(&localUser).Error + if err != nil { + return domain.User{}, err + } + + return mapUserToDomain(localUser), nil +} + +func (u *UserRepository) UpdatePasswordResetCode(userId uint32, code string) (domain.User, error) { + var localUser entity.User + err := u.db.Model(&entity.User{}).First(&localUser, userId).Error + if err != nil { + return domain.User{}, err + } + + hashedCode, _ := bcrypt.GenerateFromPassword([]byte(code), 12) + localUser.PasswordResetCode = string(hashedCode) + err = u.db.Save(&localUser).Error + if err != nil { + return domain.User{}, err + } + + return mapUserToDomain(localUser), nil +} + var _ gateways.IUserRepository = &UserRepository{} diff --git a/adapters/secondary/gateways/web/templates/login.html b/adapters/secondary/gateways/web/templates/login.html index 8923ee4..edab160 100644 --- a/adapters/secondary/gateways/web/templates/login.html +++ b/adapters/secondary/gateways/web/templates/login.html @@ -1,70 +1,82 @@ - GoCMS | Login - {{.Head}} + GoCMS | Login + {{.Head}} - +
-
-
-

GoCMS

-

Login

-
-
-
-
- - -
-
- - -
{{.PageError.Message}}
-
- -
-
-
- -
-
+ role="status"> + Log in + +
+ +
+ + First setup? Register + + + Forgot password + +
+ {{ if .Success }} + + {{ end }} + + + + diff --git a/adapters/secondary/gateways/web/templates/passwordResetValidate.html b/adapters/secondary/gateways/web/templates/passwordResetValidate.html new file mode 100644 index 0000000..d6b84bc --- /dev/null +++ b/adapters/secondary/gateways/web/templates/passwordResetValidate.html @@ -0,0 +1,140 @@ + + + + GoCMS | Password reset + {{.Head}} + + + + +
+
+
+

GoCMS

+

Admin account password reset

+
+
+
+
+ + +
+
+ + +
+
+ + +
The password must contain at least 8 characters.
+
+
+ + +
The passwords do not match.
+
+ +
+
+ {{ if .Error.Message }} + + {{ end }} +
+
+ + + + + diff --git a/api/page.go b/api/page.go index a8d9ba1..4370f47 100644 --- a/api/page.go +++ b/api/page.go @@ -105,30 +105,47 @@ func NewPageRouter() http.Handler { r.Handle("/static/*", http.StripPrefix("/static/", fileServer)) r.Get("/", GetLogin) + r.Get(LoginRoute, GetLoginPageHandler(EmptyLoginPage)) r.Post(LoginRoute, GetLoginPageHandler(EmptyLoginPage)) + r.Get("/register", GetRegisterPageHandler(EmptyRegisterPage)) r.Post("/register", GetRegisterPageHandler(EmptyRegisterPage)) + r.Get("/logout", GetLogout) + r.Get("/register/reset/request", GetPasswordResetRequest) + r.Post("/register/reset/request", PostPasswordResetRequest) + + r.Get("/register/reset/validate", GetPasswordResetValidate) + r.Post("/register/reset/validate", PostPasswordResetValidate) + r.Group(func(r chi.Router) { r.Use(IsLoggedInMiddleware) r.Use(IsNotVerifiedMiddleware) + r.Get("/register/pending", GetRegisterPendingPage) r.Post("/register/pending", PostRegisterPendingPage) + r.Get("/register/validate", GetRegisterValidatePage) }) r.Group(func(r chi.Router) { r.Use(IsLoggedInMiddleware) r.Use(IsVerifiedMiddleware) + r.Get("/home", GetHomePage) + r.Get("/post", GetPostsPage) + r.Get("/post/{id}/edit", GetPostEditPage) r.Post("/post/{id}/edit", PostPostEditPage) + r.Get("/post/{id}/delete", GetPostDeletePage) + r.Get("/post/create", GetPostCreatePage) r.Post("/post/create", PostPostCreatePage) + r.Post("/post/{id}/image/create", postImage) }) diff --git a/api/pageLogin.go b/api/pageLogin.go index 10c026d..0d4e862 100644 --- a/api/pageLogin.go +++ b/api/pageLogin.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "net/url" ) const LoginRoute = "/login" @@ -30,14 +31,13 @@ func GetLoginPageHandler(loginPage *LoginPage) http.HandlerFunc { http.Redirect(w, r, "/register", http.StatusSeeOther) return } - bs, err := Container.GetPageUseCase.GetPage("login", map[string]interface{}{ + success, _ := url.QueryUnescape(r.URL.Query().Get("success")) + bs, _ := Container.GetPageUseCase.GetPage("login", map[string]interface{}{ "PageError": loginPage.PageError, "Username": loginPage.Username, "Head": headTmpl, + "Success": success, }) - if err != nil { - _, _ = w.Write([]byte(err.Error())) - } _, _ = w.Write(bs) } } diff --git a/api/pagePasswordResetRequest.go b/api/pagePasswordResetRequest.go new file mode 100644 index 0000000..e65ff7c --- /dev/null +++ b/api/pagePasswordResetRequest.go @@ -0,0 +1,56 @@ +package api + +import ( + "github.com/google/uuid" + "net/http" + "os" +) + +type PasswordResetRequest struct { + Email string `json:"email"` +} + +var successMessage = "An email has been sent to the provided email address if it exists." + +func GetPasswordResetRequest(w http.ResponseWriter, r *http.Request) { + success := r.URL.Query().Get("success") + email := r.URL.Query().Get("email") + + bs, _ := Container.GetPageUseCase.GetPage("passwordResetRequest", map[string]interface{}{ + "Head": headTmpl, + "Email": email, + "Success": success, + }) + _, _ = w.Write(bs) +} + +func PostPasswordResetRequest(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + passResetReq := PasswordResetRequest{ + Email: r.FormValue("email"), + } + var getRedirectUrl = "/register/reset/request?success=" + successMessage + "&email=" + passResetReq.Email + + err := validate.Struct(passResetReq) + if err != nil { + http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther) + return + } + + fetchedUser, err := Container.GetUserUseCase.GetUserByEmail(passResetReq.Email) + if err != nil { + http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther) + return + } + + verificationCode := uuid.NewString() + updatedUser, _ := Container.UpdateUserUseCase.UpdatePasswordResetCode(fetchedUser.ID, verificationCode) + + _ = Container.SendMailUseCase.SendMail(updatedUser.Email, "passwordReset", map[string]string{ + "Host": os.Getenv("HOST"), + "VerificationCode": verificationCode, + "Email": updatedUser.Email, + }) + + http.Redirect(w, r, getRedirectUrl, http.StatusSeeOther) +} diff --git a/api/pagePasswordResetValidate.go b/api/pagePasswordResetValidate.go new file mode 100644 index 0000000..6218767 --- /dev/null +++ b/api/pagePasswordResetValidate.go @@ -0,0 +1,55 @@ +package api + +import ( + "golang.org/x/crypto/bcrypt" + "net/http" +) + +var PasswordLinkErrorMessage = "The reset password link you used is invalid." +var PasswordLinkSuccessMessage = "Your account password was successfully updated." + +func GetPasswordResetValidate(w http.ResponseWriter, r *http.Request) { + failure := r.URL.Query().Get("failure") + pageError := NewPageError("") + if failure != "" { + pageError = NewPageError(failure) + } + template, _ := Container.GetPageUseCase.GetPage("passwordResetValidate", map[string]interface{}{ + "Head": headTmpl, + "Error": pageError, + "Email": r.URL.Query().Get("email"), + "Code": r.URL.Query().Get("c"), + }) + _, _ = w.Write(template) +} + +func PostPasswordResetValidate(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + email := r.FormValue("email") + password := r.FormValue("password") + resetCode := r.FormValue("code") + + var redirectionErrorLink = "/register/reset/validate?email=" + email + "&c=" + resetCode + + if len(password) < 8 { + http.Redirect(w, r, redirectionErrorLink+"&failure=Password length should be at least 8 characters.", http.StatusSeeOther) + return + } + + fetchedUser, err := Container.GetUserUseCase.GetUserByEmail(email) + if err != nil { + http.Redirect(w, r, redirectionErrorLink+"&failure="+PasswordLinkErrorMessage, http.StatusSeeOther) + return + } + + err = bcrypt.CompareHashAndPassword([]byte(fetchedUser.PasswordResetCode), []byte(resetCode)) + if err != nil { + http.Redirect(w, r, redirectionErrorLink+"&failure="+PasswordLinkErrorMessage, http.StatusSeeOther) + return + } + + _, _ = Container.UpdateUserUseCase.UpdatePassword(fetchedUser.ID, password) + _, _ = Container.UpdateUserUseCase.UpdatePasswordResetCode(fetchedUser.ID, "") + + http.Redirect(w, r, "/login?success="+PasswordLinkSuccessMessage, http.StatusSeeOther) +} diff --git a/domain/gateways/IUserRepository.go b/domain/gateways/IUserRepository.go index 0213346..c96d084 100644 --- a/domain/gateways/IUserRepository.go +++ b/domain/gateways/IUserRepository.go @@ -5,8 +5,11 @@ import "GoCMS/domain/user" type IUserRepository interface { Get(id uint32) (user.User, error) GetByUsername(username string) (user.User, error) + GetByEmail(email string) (user.User, error) GetAll() []user.User Create(user user.User) (user.User, error) Delete(id uint32) error UpdateVerificationStatus(userId uint32, isVerified bool) (user.User, error) + UpdatePassword(userId uint32, password string) (user.User, error) + UpdatePasswordResetCode(userId uint32, code string) (user.User, error) } diff --git a/domain/image/image.go b/domain/image/image.go index 2f83483..4ffc779 100644 --- a/domain/image/image.go +++ b/domain/image/image.go @@ -5,11 +5,11 @@ import ( ) type Image struct { - ID uint32 - Path string - PostID uint32 - CreatedAt time.Time - UpdatedAt time.Time + ID uint32 `json:"id"` + Path string `json:"path"` + PostID uint32 `json:"post_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func FromDB(id uint32, path string, postId uint32, createdAt time.Time, updatedAt time.Time) Image { diff --git a/domain/user/user.go b/domain/user/user.go index 19cd249..ddb3689 100644 --- a/domain/user/user.go +++ b/domain/user/user.go @@ -8,10 +8,11 @@ type User struct { ID uint32 `json:"id"` Username string `json:"username"` Password string `json:"password"` + PasswordResetCode string `json:"password_reset_code"` Email string `json:"email"` - IsVerified bool `gorm:"default=false;not null"` - VerificationCode string `gorm:"unique;not null"` - VerificationExpiration time.Time `gorm:"not null"` + IsVerified bool `json:"is_verified"` + VerificationCode string `json:"verification_code"` + VerificationExpiration time.Time `json:"verification_expiration"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -19,6 +20,7 @@ type User struct { func FromApi( username string, password string, + passwordResetCode string, email string, verificationCode string, ) User { @@ -26,6 +28,7 @@ func FromApi( return User{ Username: username, Password: password, + PasswordResetCode: passwordResetCode, Email: email, IsVerified: false, VerificationCode: verificationCode, @@ -37,6 +40,7 @@ func FromDb( id uint32, username string, password string, + passwordResetCode string, email string, isVerified bool, verificationCode string, @@ -48,6 +52,7 @@ func FromDb( ID: id, Username: username, Password: password, + PasswordResetCode: passwordResetCode, Email: email, IsVerified: isVerified, VerificationCode: verificationCode, diff --git a/useCases/CreateUserUseCase.go b/useCases/CreateUserUseCase.go index c2335b8..a25ba1a 100644 --- a/useCases/CreateUserUseCase.go +++ b/useCases/CreateUserUseCase.go @@ -27,6 +27,7 @@ func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User, return g.userRepository.Create(user.FromApi( createUser.Username, createUser.Password, + "", createUser.Email, createUser.VerificationCode, )) diff --git a/useCases/GetUserUseCase.go b/useCases/GetUserUseCase.go index 2a4b351..9d9694c 100644 --- a/useCases/GetUserUseCase.go +++ b/useCases/GetUserUseCase.go @@ -23,3 +23,7 @@ func (g *GetUserUseCase) GetUser(id uint32) (user.User, error) { func (g *GetUserUseCase) GetUserByUsername(username string) (user.User, error) { return g.userRepository.GetByUsername(username) } + +func (g *GetUserUseCase) GetUserByEmail(email string) (user.User, error) { + return g.userRepository.GetByEmail(email) +} diff --git a/useCases/UpdateUserUseCase.go b/useCases/UpdateUserUseCase.go index 1005819..8fe26c4 100644 --- a/useCases/UpdateUserUseCase.go +++ b/useCases/UpdateUserUseCase.go @@ -19,3 +19,11 @@ func NewUpdateUserUseCase(db *gorm.DB) *UpdateUserUseCase { func (g *UpdateUserUseCase) UpdateVerificationStatus(userId uint32, isVerified bool) (user.User, error) { return g.userRepository.UpdateVerificationStatus(userId, isVerified) } + +func (g *UpdateUserUseCase) UpdatePasswordResetCode(userId uint32, code string) (user.User, error) { + return g.userRepository.UpdatePasswordResetCode(userId, code) +} + +func (g *UpdateUserUseCase) UpdatePassword(userId uint32, password string) (user.User, error) { + return g.userRepository.UpdatePassword(userId, password) +}