From 5a0dec6128d917fe84cfc220f67871fe530dd26b Mon Sep 17 00:00:00 2001 From: "aditya.siregar" Date: Tue, 23 Jul 2024 01:36:25 +0700 Subject: [PATCH] add forget password --- config/brevo.go | 9 + config/configs.go | 6 + config/crypto.go | 23 ++- config/email.go | 32 ++++ config/jwt.go | 5 +- go.mod | 5 +- go.sum | 8 + infra/furtuna.development.yaml | 14 +- internal/common/errors/errors.go | 12 +- internal/entity/auth.go | 27 +-- internal/entity/email.go | 13 ++ internal/entity/user.go | 30 ++-- internal/handlers/http/auth/auth.go | 75 +++++++- internal/handlers/request/auth.go | 67 ++++++- internal/handlers/response/auth.go | 11 +- internal/repository/auth/init.go | 49 +++++- internal/repository/brevo/init.go | 88 ++++++++++ internal/repository/crypto/init.go | 46 +++++ internal/repository/repository.go | 71 ++++---- internal/services/auth/init.go | 128 +++++++++++++- internal/services/service.go | 4 +- templates/reset_password.html | 260 ++++++++++++++++++++++++++++ 22 files changed, 907 insertions(+), 76 deletions(-) create mode 100644 config/brevo.go create mode 100644 config/email.go create mode 100644 internal/entity/email.go create mode 100644 internal/repository/brevo/init.go create mode 100644 templates/reset_password.html diff --git a/config/brevo.go b/config/brevo.go new file mode 100644 index 0000000..41facd5 --- /dev/null +++ b/config/brevo.go @@ -0,0 +1,9 @@ +package config + +type Brevo struct { + APIKey string `mapstructure:"api_key"` +} + +func (b *Brevo) GetApiKey() string { + return b.APIKey +} diff --git a/config/configs.go b/config/configs.go index 9b14242..177e169 100644 --- a/config/configs.go +++ b/config/configs.go @@ -29,6 +29,8 @@ type Config struct { Jwt Jwt `mapstructure:"jwt"` OSSConfig OSSConfig `mapstructure:"oss"` Midtrans Midtrans `mapstructure:"midtrans"` + Brevo Brevo `mapstructure:"brevo"` + Email Email `mapstructure:"email"` } var ( @@ -67,5 +69,9 @@ func (c *Config) Auth() *AuthConfig { jwtTokenExpiresTTL: c.Jwt.Token.ExpiresTTL, jwtOrderSecret: c.Jwt.TokenOrder.Secret, jwtOrderExpiresTTL: c.Jwt.TokenOrder.ExpiresTTL, + jwtSecretResetPassword: JWT{ + secret: c.Jwt.TokenResetPassword.Secret, + expireTTL: c.Jwt.TokenResetPassword.ExpiresTTL, + }, } } diff --git a/config/crypto.go b/config/crypto.go index c465b70..abab087 100644 --- a/config/crypto.go +++ b/config/crypto.go @@ -3,10 +3,16 @@ package config import "time" type AuthConfig struct { - jwtTokenExpiresTTL int - jwtTokenSecret string - jwtOrderSecret string - jwtOrderExpiresTTL int + jwtTokenExpiresTTL int + jwtTokenSecret string + jwtOrderSecret string + jwtOrderExpiresTTL int + jwtSecretResetPassword JWT +} + +type JWT struct { + secret string + expireTTL int } func (c *AuthConfig) AccessTokenSecret() string { @@ -26,3 +32,12 @@ func (c *AuthConfig) AccessTokenExpiresDate() time.Time { duration := time.Duration(c.jwtTokenExpiresTTL) return time.Now().UTC().Add(time.Minute * duration) } + +func (c *AuthConfig) AccessTokenResetPasswordSecret() string { + return c.jwtSecretResetPassword.secret +} + +func (c *AuthConfig) AccessTokenResetPasswordExpire() time.Time { + duration := time.Duration(c.jwtSecretResetPassword.expireTTL) + return time.Now().UTC().Add(time.Minute * duration) +} diff --git a/config/email.go b/config/email.go new file mode 100644 index 0000000..863d2e9 --- /dev/null +++ b/config/email.go @@ -0,0 +1,32 @@ +package config + +type Email struct { + Sender string `mapstructure:"sender"` + CustomReceiver string `mapstructure:"custom_receiver"` + ResetPassword EmailConfig `mapstructure:"reset_password"` +} + +type EmailConfig struct { + Subject string `mapstructure:"subject"` + OpeningWord string `mapstructure:"opening_word"` + Link string `mapstructure:"link"` + Notes string `mapstructure:"note"` + ClosingWord string `mapstructure:"closing_word"` + TemplateName string `mapstructure:"template_name"` + TemplatePath string `mapstructure:"template_path"` +} + +type EmailMemberRequestActionConfig struct { + TemplateName string `mapstructure:"template_name"` + TemplatePath string `mapstructure:"template_path"` + Subject string `mapstructure:"subject"` + Content string `mapstructure:"content"` +} + +func (e *Email) GetSender() string { + return e.Sender +} + +func (e *Email) GetCustomReceiver() string { + return e.CustomReceiver +} diff --git a/config/jwt.go b/config/jwt.go index a58d2b1..f7a59ef 100644 --- a/config/jwt.go +++ b/config/jwt.go @@ -1,8 +1,9 @@ package config type Jwt struct { - Token Token `mapstructure:"token"` - TokenOrder Token `mapstructure:"token-order"` + Token Token `mapstructure:"token"` + TokenOrder Token `mapstructure:"token-order"` + TokenResetPassword Token `mapstructure:"token-reset-password"` } type Token struct { diff --git a/go.mod b/go.mod index 011326b..6baf80e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module furtuna-be -go 1.19 +go 1.20 require ( github.com/gin-gonic/gin v1.9.1 @@ -20,6 +20,7 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/antihax/optional v1.0.0 // indirect github.com/bytedance/sonic v1.10.2 // indirect github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect @@ -63,6 +64,7 @@ require ( go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.8.0 // indirect golang.org/x/arch v0.7.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sys v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.17.0 // indirect @@ -73,6 +75,7 @@ require ( require ( github.com/aws/aws-sdk-go v1.50.0 + github.com/getbrevo/brevo-go v1.0.0 github.com/veritrans/go-midtrans v0.0.0-20210616100512-16326c5eeb00 go.uber.org/zap v1.21.0 golang.org/x/net v0.20.0 diff --git a/go.sum b/go.sum index a1d8d7f..f7afa6b 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/aws/aws-sdk-go v1.50.0 h1:HBtrLeO+QyDKnc3t1+5DR1RxodOHCGr8ZcrHudpv7jI= github.com/aws/aws-sdk-go v1.50.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= @@ -80,6 +82,10 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/getbrevo/brevo-go v1.0.0 h1:E/pRCsQeExvZeTCJU5vy+xHWcLaL5axWQ9QkxjlFke4= +github.com/getbrevo/brevo-go v1.0.0/go.mod h1:2TBMEnaDqq/oiAXUYtn6eykiEdHcEoS7tc63+YoFibw= +github.com/getbrevo/brevo-go v1.1.1 h1:6/SXEQ7ZfUjetPnJ4EncfLSUgXjQv4qUj1EQgLXnDto= +github.com/getbrevo/brevo-go v1.1.1/go.mod h1:ExhytIoPxt/cOBl6ZEMeEZNLUKrWEYA5U3hM/8WP2bg= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= @@ -400,6 +406,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/infra/furtuna.development.yaml b/infra/furtuna.development.yaml index 1ec9530..2bf3e87 100644 --- a/infra/furtuna.development.yaml +++ b/infra/furtuna.development.yaml @@ -35,4 +35,16 @@ oss: midtrans: server_key: "SB-Mid-server-YOIvuaIlRw3In9SymCuFz-hB" client_key: "SB-Mid-client-ulkZGFiS8PqBNOZz" - env: 1 \ No newline at end of file + env: 1 + +brevo: + api_key: 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 + +email: + sender: "siregaraditya@gmail.com" + reset_password: + template_name: "reset_password" + template_path: "templates/reset_password.html" + subject: "Reset Password" + opening_word: "Terima kasih sudah menjadi bagian dari Furtuna. Anda telah berhasil melakukan reset password, silakan masukan unik password yang dibuat oleh sistem dibawah ini:" + closing_word: "Silakan login kembali menggunakan email dan password anda diatas, sistem akan secara otomatis meminta anda untuk membuat password baru setelah berhasil login. Mohon maaf atas kendala yang dialami." \ No newline at end of file diff --git a/internal/common/errors/errors.go b/internal/common/errors/errors.go index 55da8b4..336aaae 100644 --- a/internal/common/errors/errors.go +++ b/internal/common/errors/errors.go @@ -53,6 +53,13 @@ func NewServiceException(errType ErrType) *ServiceException { } } +func NewError(errType ErrType, message string) *ServiceException { + return &ServiceException{ + errorType: errType, + message: message, + } +} + func (s *ServiceException) ErrorType() ErrType { return s.errorType } @@ -95,7 +102,10 @@ func (s *ServiceException) MapErrorsToCode() Code { case errUserIsNotFound: return BadRequest + case errInvalidLogin: + return BadRequest + default: - return ServerError + return BadRequest } } diff --git a/internal/entity/auth.go b/internal/entity/auth.go index d8bc519..6448110 100644 --- a/internal/entity/auth.go +++ b/internal/entity/auth.go @@ -34,6 +34,7 @@ type UserDB struct { DeletedAt *time.Time `gorm:"column:deleted_at" json:"deleted_at"` CreatedBy int64 `gorm:"column:created_by" json:"created_by"` UpdatedBy int64 `gorm:"column:updated_by" json:"updated_by"` + ResetPassword bool `gorm:"column:reset_password" json:"reset_password"` } func (u *UserDB) ToUser() *User { @@ -42,18 +43,19 @@ func (u *UserDB) ToUser() *User { } userEntity := &User{ - ID: u.ID, - Name: u.Name, - Email: u.Email, - Status: u.Status, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, - RoleID: role.Role(u.RoleID), - RoleName: u.RoleName, - PartnerID: u.PartnerID, - PartnerName: u.PartnerName, - SiteID: u.SiteID, - SiteName: u.SiteName, + ID: u.ID, + Name: u.Name, + Email: u.Email, + Status: u.Status, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + RoleID: role.Role(u.RoleID), + RoleName: u.RoleName, + PartnerID: u.PartnerID, + PartnerName: u.PartnerName, + SiteID: u.SiteID, + SiteName: u.SiteName, + ResetPassword: u.ResetPassword, } return userEntity @@ -92,6 +94,7 @@ func (u *UserDB) ToUserAuthenticate(signedToken string) *AuthenticateUser { PartnerStatus: u.PartnerStatus, SiteID: u.SiteID, SiteName: u.SiteName, + ResetPassword: u.ResetPassword, } } diff --git a/internal/entity/email.go b/internal/entity/email.go new file mode 100644 index 0000000..0cf883d --- /dev/null +++ b/internal/entity/email.go @@ -0,0 +1,13 @@ +package entity + +type ( + SendEmailNotificationParam struct { + Sender string + Recipient string + CcEmails []string + Subject string + TemplateName string + TemplatePath string + Data interface{} + } +) diff --git a/internal/entity/user.go b/internal/entity/user.go index d48e919..d9a8ad7 100644 --- a/internal/entity/user.go +++ b/internal/entity/user.go @@ -10,20 +10,21 @@ import ( ) type User struct { - ID int64 - Name string - Email string - Password string - Status userstatus.UserStatus - NIK string - CreatedAt time.Time - UpdatedAt time.Time - RoleID role.Role - RoleName string - PartnerID *int64 - SiteID *int64 - SiteName string - PartnerName string + ID int64 + Name string + Email string + Password string + Status userstatus.UserStatus + NIK string + CreatedAt time.Time + UpdatedAt time.Time + RoleID role.Role + RoleName string + PartnerID *int64 + SiteID *int64 + SiteName string + PartnerName string + ResetPassword bool } type AuthenticateUser struct { @@ -36,6 +37,7 @@ type AuthenticateUser struct { PartnerStatus string SiteID *int64 SiteName string + ResetPassword bool } type UserRoleDB struct { diff --git a/internal/handlers/http/auth/auth.go b/internal/handlers/http/auth/auth.go index 91bc44a..d3a849d 100644 --- a/internal/handlers/http/auth/auth.go +++ b/internal/handlers/http/auth/auth.go @@ -1,6 +1,7 @@ package auth import ( + "fmt" "furtuna-be/internal/constants/role" "net/http" @@ -19,6 +20,8 @@ type AuthHandler struct { func (a *AuthHandler) Route(group *gin.RouterGroup, jwt gin.HandlerFunc) { authRoute := group.Group("/auth") authRoute.POST("/login", a.AuthLogin) + authRoute.POST("/forgot-password", a.ForgotPassword) + authRoute.POST("/reset-password", jwt, a.ResetPassword) } func NewAuthHandler(service services.Auth) *AuthHandler { @@ -77,7 +80,8 @@ func (h *AuthHandler) AuthLogin(c *gin.Context) { ID: int64(authUser.RoleID), Role: authUser.RoleName, }, - Site: site, + Site: site, + ResetPassword: authUser.ResetPassword, } c.JSON(http.StatusOK, response.BaseResponse{ @@ -87,3 +91,72 @@ func (h *AuthHandler) AuthLogin(c *gin.Context) { Data: resp, }) } + +// ForgotPassword handles the request for password reset. +// @Summary Request password reset +// @Description Sends a password reset link to the user's email. +// @Accept json +// @Produce json +// @Param bodyParam body auth2.ForgotPasswordRequest true "User email" +// @Success 200 {object} response.BaseResponse "Password reset link sent" +// @Failure 400 {object} response.BaseResponse{data=errors.Error} "Bad request" +// @Router /api/v1/auth/forgot-password [post] +// @Tags Auth Password API's +func (h *AuthHandler) ForgotPassword(c *gin.Context) { + var bodyParam auth2.ResetPasswordRequest + if err := c.ShouldBindJSON(&bodyParam); err != nil { + response.ErrorWrapper(c, errors.ErrorBadRequest) + return + } + + err := h.service.SendPasswordResetLink(c, bodyParam.Email) + if err != nil { + response.ErrorWrapper(c, err) + return + } + + c.JSON(http.StatusOK, response.BaseResponse{ + Success: true, + Status: http.StatusOK, + Message: "Password reset link sent", + }) +} + +// ResetPassword handles the password reset process. +// @Summary Reset user password +// @Description Resets the user's password using the provided token. +// @Accept json +// @Produce json +// @Param bodyParam body auth2.ResetPasswordRequest true "Reset password details" +// @Success 200 {object} response.BaseResponse "Password reset successful" +// @Failure 400 {object} response.BaseResponse{data=errors.Error} "Bad request" +// @Router /api/v1/auth/reset-password [post] +// @Tags Auth Password API's +func (h *AuthHandler) ResetPassword(c *gin.Context) { + ctx := auth2.GetMyContext(c) + + var req auth2.ResetPasswordChangeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.ErrorWrapper(c, errors.ErrorBadRequest) + return + } + + if err := req.Validate(); err != nil { + response.ErrorWrapper(c, errors.NewError( + errors.ErrorBadRequest.ErrorType(), + fmt.Sprintf("invalid request %v", err.Error()))) + return + } + + err := h.service.ResetPassword(ctx, req.OldPassword, req.NewPassword) + if err != nil { + response.ErrorWrapper(c, err) + return + } + + c.JSON(http.StatusOK, response.BaseResponse{ + Success: true, + Status: http.StatusOK, + Message: "Password reset successful", + }) +} diff --git a/internal/handlers/request/auth.go b/internal/handlers/request/auth.go index 2999abe..8288875 100644 --- a/internal/handlers/request/auth.go +++ b/internal/handlers/request/auth.go @@ -1,5 +1,11 @@ package request +import ( + "errors" + "fmt" + "github.com/go-playground/validator/v10" +) + type LoginRequest struct { Email string `json:"email"` Password string `json:"password"` @@ -10,6 +16,63 @@ type ResetPasswordRequest struct { } type ResetPasswordChangeRequest struct { - Token string `json:"token" validate:"required"` - Password string `json:"password" validate:"required"` + OldPassword string `json:"old_password" validate:"required"` + NewPassword string `json:"new_password" validate:"required,strongpwd"` +} + +func (e *ResetPasswordChangeRequest) Validate() error { + validate := validator.New() + + validate.RegisterValidation("strongpwd", validateStrongPassword) + + if err := validate.Struct(e); err != nil { + // Handle the validation errors + for _, err := range err.(validator.ValidationErrors) { + switch err.Field() { + case "NewPassword": + return fmt.Errorf("%w", validatePasswordError(err.Tag())) + default: + return fmt.Errorf("validation failed: %w", err) + } + } + } + + return nil +} + +func validateStrongPassword(fl validator.FieldLevel) bool { + password := fl.Field().String() + + var ( + hasMinLen = len(password) >= 8 + ) + + return hasMinLen +} + +// Error messages for password validation +var ( + ErrPasswordTooShort = errors.New("password must be at least 8 characters long") + ErrPasswordNoUpper = errors.New("password must contain at least one uppercase letter") + ErrPasswordNoLower = errors.New("password must contain at least one lowercase letter") + ErrPasswordNoNumber = errors.New("password must contain at least one digit") + ErrPasswordNoSpecial = errors.New("password must contain at least one special character (!@#$%^&*)") + ErrPasswordValidation = errors.New("password does not meet the strength requirements") +) + +func validatePasswordError(tag string) error { + switch tag { + case "min": + return ErrPasswordTooShort + case "uppercase": + return ErrPasswordNoUpper + case "lowercase": + return ErrPasswordNoLower + case "number": + return ErrPasswordNoNumber + case "special": + return ErrPasswordNoSpecial + default: + return ErrPasswordValidation + } } diff --git a/internal/handlers/response/auth.go b/internal/handlers/response/auth.go index 11d1ca0..8c2d674 100644 --- a/internal/handlers/response/auth.go +++ b/internal/handlers/response/auth.go @@ -1,11 +1,12 @@ package response type LoginResponse struct { - Token string `json:"token"` - Name string `json:"name"` - Role Role `json:"role"` - Partner *Partner `json:"partner"` - Site *SiteName `json:"site,omitempty"` + Token string `json:"token"` + Name string `json:"name"` + Role Role `json:"role"` + Partner *Partner `json:"partner"` + Site *SiteName `json:"site,omitempty"` + ResetPassword bool `json:"reset_password"` } type Role struct { diff --git a/internal/repository/auth/init.go b/internal/repository/auth/init.go index cb50c3b..11db05a 100644 --- a/internal/repository/auth/init.go +++ b/internal/repository/auth/init.go @@ -26,7 +26,8 @@ func (r *AuthRepository) CheckExistsUserAccount(ctx context.Context, email strin err := r.db. Table("users"). - Select("users.*, user_roles.role_id, user_roles.partner_id, user_roles.site_id, sites.name, roles.role_name, partners.name as partner_name, partners.status as partner_status"). + Select("users.*, user_roles.role_id, user_roles.partner_id, user_roles.site_id,"+ + " sites.name, roles.role_name, partners.name as partner_name, partners.status as partner_status, users.reset_password"). Where("users.email = ?", email). Joins("left join user_roles on users.id = user_roles.user_id"). Joins("left join roles on user_roles.role_id = roles.role_id"). @@ -49,3 +50,49 @@ func (r *AuthRepository) CheckExistsUserAccount(ctx context.Context, email strin return &user, nil } + +func (r *AuthRepository) CheckExistsUserAccountByID(ctx context.Context, userID int64) (*entity.UserDB, error) { + var user entity.UserDB + + err := r.db. + Table("users"). + Select("users.*, user_roles.role_id, user_roles.partner_id, user_roles.site_id, sites.name, roles.role_name, partners.name as partner_name, partners.status as partner_status"). + Where("users.id = ?", userID). + Joins("left join user_roles on users.id = user_roles.user_id"). + Joins("left join roles on user_roles.role_id = roles.role_id"). + Joins("left join partners on user_roles.partner_id = partners.id"). + Joins("left join sites on user_roles.site_id = sites.id"). + First(&user).Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("user with not exist") // or use a custom error type + } + + logger.ContextLogger(ctx).Error(fmt.Sprintf("Failed to get user"), zap.Error(err)) + return nil, err + } + + return &user, nil +} + +func (r *AuthRepository) UpdatePassword(ctx context.Context, trx *gorm.DB, newHashedPassword string, userID int64, resetPassword bool) error { + // Perform the update using a single Updates call + err := trx.Model(&entity.UserDB{}). + Where("id = ?", userID). + Updates(map[string]interface{}{ + "password": newHashedPassword, + "reset_password": resetPassword, + }).Error + + if err != nil { + logger.ContextLogger(ctx).Error(fmt.Sprintf("Failed to update password for user with id: %d", userID), zap.Error(err)) + return err + } + + return nil +} diff --git a/internal/repository/brevo/init.go b/internal/repository/brevo/init.go new file mode 100644 index 0000000..4e4e86b --- /dev/null +++ b/internal/repository/brevo/init.go @@ -0,0 +1,88 @@ +package brevo + +import ( + "bytes" + "context" + "fmt" + "furtuna-be/internal/entity" + "html/template" + "io/ioutil" + "log" + + brevo "github.com/getbrevo/brevo-go/lib" +) + +type Config interface { + GetApiKey() string +} + +type ServiceImpl struct { + brevoConn *brevo.APIClient +} + +func (s ServiceImpl) SendEmailTransactional(ctx context.Context, param entity.SendEmailNotificationParam) error { + templateFile, err := ioutil.ReadFile(param.TemplatePath) + if err != nil { + log.Println(err) + return err + } + + renderedTemplate, err := template.New(param.TemplateName).Parse(string(templateFile)) + if err != nil { + log.Println(err) + return err + } + + return s.sendEmail(ctx, renderedTemplate, param) +} + +func (s ServiceImpl) sendEmail(ctx context.Context, tmpl *template.Template, param entity.SendEmailNotificationParam) error { + var body bytes.Buffer + err := tmpl.Execute(&body, param.Data) + if err != nil { + log.Println(err) + return err + } + + payload := brevo.SendSmtpEmail{ + Sender: &brevo.SendSmtpEmailSender{ + Email: "siregaraditya@gmail.com", + }, + To: []brevo.SendSmtpEmailTo{ + { + Email: "avranata01@gmail.com", + }, + }, + Subject: param.Subject, + HtmlContent: body.String(), + } + + if len(param.CcEmails) != 0 { + for _, email := range param.CcEmails { + payload.Cc = append(payload.Cc, brevo.SendSmtpEmailCc{ + Email: email, + }) + } + } + + _, _, err = s.brevoConn.TransactionalEmailsApi.SendTransacEmail(ctx, payload) + if err != nil { + return err + } + + return nil +} + +func New(conf Config) *ServiceImpl { + cfg := brevo.NewConfiguration() + cfg.AddDefaultHeader("api-key", conf.GetApiKey()) + client := brevo.NewAPIClient(cfg) + result, resp, err := client.AccountApi.GetAccount(context.Background()) + if err != nil { + fmt.Println("Error when calling AccountApi->get_account: ", err.Error()) + log.Fatal("error") + } + fmt.Println("GetAccount Object:", result, " GetAccount Response: ", resp) + + return &ServiceImpl{brevoConn: client} +} diff --git a/internal/repository/crypto/init.go b/internal/repository/crypto/init.go index ea809f3..b55a4d0 100644 --- a/internal/repository/crypto/init.go +++ b/internal/repository/crypto/init.go @@ -23,6 +23,8 @@ type CryptoConfig interface { AccessTokenOrderSecret() string AccessTokenOrderExpiresDate() time.Time AccessTokenExpiresDate() time.Time + AccessTokenResetPasswordSecret() string + AccessTokenResetPasswordExpire() time.Time } type CryptoImpl struct { @@ -83,6 +85,30 @@ func (c *CryptoImpl) GenerateJWT(user *entity.User) (string, error) { return token, nil } +func (c *CryptoImpl) GenerateJWTReseetPassword(user *entity.User) (string, error) { + claims := &entity.JWTAuthClaims{ + StandardClaims: jwt.StandardClaims{ + Subject: strconv.FormatInt(user.ID, 10), + ExpiresAt: c.Config.AccessTokenResetPasswordExpire().Unix(), + IssuedAt: time.Now().Unix(), + NotBefore: time.Now().Unix(), + }, + UserID: user.ID, + Name: user.Name, + Email: user.Email, + } + + token, err := jwt. + NewWithClaims(jwt.SigningMethodHS256, claims). + SignedString([]byte(c.Config.AccessTokenResetPasswordSecret())) + + if err != nil { + return "", err + } + + return token, nil +} + func (c *CryptoImpl) ParseAndValidateJWT(tokenString string) (*entity.JWTAuthClaims, error) { token, err := jwt.ParseWithClaims(tokenString, &entity.JWTAuthClaims{}, func(token *jwt.Token) (interface{}, error) { return []byte(c.Config.AccessTokenSecret()), nil @@ -140,3 +166,23 @@ func (c *CryptoImpl) ValidateJWTOrder(tokenString string) (int64, int64, error) return claims.PartnerID, claims.OrderID, nil } + +func (c *CryptoImpl) ValidateResetPassword(tokenString string) (int64, error) { + token, err := jwt.ParseWithClaims(tokenString, &entity.JWTOrderClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(c.Config.AccessTokenResetPasswordSecret()), nil + }) + + if err != nil { + return 0, err + } + + claims, ok := token.Claims.(*entity.JWTAuthClaims) + if !ok || !token.Valid { + return 0, fmt.Errorf("invalid token %v", token.Header["alg"]) + } + + return claims.UserID, nil +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 23be698..309aeef 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "furtuna-be/internal/repository/branches" + "furtuna-be/internal/repository/brevo" mdtrns "furtuna-be/internal/repository/midtrans" "furtuna-be/internal/repository/orders" "furtuna-be/internal/repository/oss" @@ -26,45 +27,49 @@ import ( ) type RepoManagerImpl struct { - Crypto Crypto - Auth Auth - Event Event - User User - Branch Branch - Studio Studio - Product Product - Order Order - OSS OSSRepository - Partner PartnerRepository - Site SiteRepository - Trx TransactionManager - Wallet WalletRepository - Midtrans Midtrans - Payment Payment + Crypto Crypto + Auth Auth + Event Event + User User + Branch Branch + Studio Studio + Product Product + Order Order + OSS OSSRepository + Partner PartnerRepository + Site SiteRepository + Trx TransactionManager + Wallet WalletRepository + Midtrans Midtrans + Payment Payment + EmailService EmailService } func NewRepoManagerImpl(db *gorm.DB, cfg *config.Config) *RepoManagerImpl { return &RepoManagerImpl{ - Crypto: crypto.NewCrypto(cfg.Auth()), - Auth: auth.NewAuthRepository(db), - Event: event.NewEventRepo(db), - User: users.NewUserRepository(db), - Branch: branches.NewBranchRepository(db), - Studio: studios.NewStudioRepository(db), - Product: products.NewProductRepository(db), - Order: orders.NewOrderRepository(db), - OSS: oss.NewOssRepositoryImpl(cfg.OSSConfig), - Partner: partners.NewPartnerRepository(db), - Site: sites.NewSiteRepository(db), - Trx: trx.NewGormTransactionManager(db), - Wallet: repository.NewWalletRepository(db), - Midtrans: mdtrns.New(&cfg.Midtrans), - Payment: payment.NewPaymentRepository(db), + Crypto: crypto.NewCrypto(cfg.Auth()), + Auth: auth.NewAuthRepository(db), + Event: event.NewEventRepo(db), + User: users.NewUserRepository(db), + Branch: branches.NewBranchRepository(db), + Studio: studios.NewStudioRepository(db), + Product: products.NewProductRepository(db), + Order: orders.NewOrderRepository(db), + OSS: oss.NewOssRepositoryImpl(cfg.OSSConfig), + Partner: partners.NewPartnerRepository(db), + Site: sites.NewSiteRepository(db), + Trx: trx.NewGormTransactionManager(db), + Wallet: repository.NewWalletRepository(db), + Midtrans: mdtrns.New(&cfg.Midtrans), + Payment: payment.NewPaymentRepository(db), + EmailService: brevo.New(&cfg.Brevo), } } type Auth interface { CheckExistsUserAccount(ctx context.Context, email string) (*entity.UserDB, error) + CheckExistsUserAccountByID(ctx context.Context, userID int64) (*entity.UserDB, error) + UpdatePassword(ctx context.Context, trx *gorm.DB, newHashedPassword string, userID int64, resetPassword bool) error } type Event interface { @@ -79,8 +84,10 @@ type Crypto interface { CompareHashAndPassword(hash string, password string) bool ValidateWT(tokenString string) (*jwt.Token, error) GenerateJWT(user *entity.User) (string, error) + GenerateJWTReseetPassword(user *entity.User) (string, error) GenerateJWTOrder(order *entity.Order) (string, error) ValidateJWTOrder(tokenString string) (int64, int64, error) + ValidateResetPassword(tokenString string) (int64, error) ParseAndValidateJWT(token string) (*entity.JWTAuthClaims, error) } @@ -171,3 +178,7 @@ type Payment interface { FindByOrderAndPartnerID(ctx context.Context, orderID, partnerID int64) (*entity.Payment, error) FindByReferenceID(ctx context.Context, db *gorm.DB, referenceID string) (*entity.Payment, error) } + +type EmailService interface { + SendEmailTransactional(ctx context.Context, param entity.SendEmailNotificationParam) error +} diff --git a/internal/services/auth/init.go b/internal/services/auth/init.go index be1f033..a797b8c 100644 --- a/internal/services/auth/init.go +++ b/internal/services/auth/init.go @@ -2,7 +2,10 @@ package auth import ( "context" + "furtuna-be/config" + "furtuna-be/internal/common/mycontext" "furtuna-be/internal/entity" + "furtuna-be/internal/utils" "go.uber.org/zap" @@ -14,12 +17,23 @@ import ( type AuthServiceImpl struct { authRepo repository.Auth crypto repository.Crypto + user repository.User + emailSvc repository.EmailService + emailCfg config.Email + trxRepo repository.TransactionManager } -func New(authRepo repository.Auth, crypto repository.Crypto) *AuthServiceImpl { +func New(authRepo repository.Auth, + crypto repository.Crypto, user repository.User, emailSvc repository.EmailService, + emailCfg config.Email, trxRepo repository.TransactionManager, +) *AuthServiceImpl { return &AuthServiceImpl{ authRepo: authRepo, crypto: crypto, + user: user, + emailSvc: emailSvc, + emailCfg: emailCfg, + trxRepo: trxRepo, } } @@ -46,3 +60,115 @@ func (u *AuthServiceImpl) AuthenticateUser(ctx context.Context, email, password return user.ToUserAuthenticate(signedToken), nil } + +func (u *AuthServiceImpl) SendPasswordResetLink(ctx context.Context, email string) error { + // Check if the user exists + user, err := u.authRepo.CheckExistsUserAccount(ctx, email) + if err != nil { + logger.ContextLogger(ctx).Error("error when getting user", zap.Error(err)) + return errors.ErrorInternalServer + } + + if user == nil { + return errors.ErrorUserIsNotFound + } + + // Begin a transaction + trx, err := u.trxRepo.Begin(ctx) + if err != nil { + logger.ContextLogger(ctx).Error("error when beginning transaction", zap.Error(err)) + return errors.ErrorInternalServer + } + + defer func() { + if r := recover(); r != nil { + u.trxRepo.Rollback(trx) + logger.ContextLogger(ctx).Error("panic recovered in SendPasswordResetLink", zap.Any("recover", r)) + err = errors.ErrorInternalServer + } + }() + + // Generate a new password + generatedPassword := utils.GenerateRandomString(10) + hashPassword, err := user.ToUser().HashedPassword(generatedPassword) + if err != nil { + logger.ContextLogger(ctx).Error("error when generating hashed password", zap.Error(err)) + u.trxRepo.Rollback(trx) + return errors.ErrorInternalServer + } + + // Update the user's password in the database + if err := u.authRepo.UpdatePassword(ctx, trx, hashPassword, user.ID, true); err != nil { + logger.ContextLogger(ctx).Error("error when updating user password", zap.Error(err)) + u.trxRepo.Rollback(trx) + return errors.ErrorInternalServer + } + + // If a custom receiver is specified, override the email + if u.emailCfg.CustomReceiver != "" { + email = u.emailCfg.CustomReceiver + } + + // Prepare the email notification parameters + renewPasswordRequest := entity.SendEmailNotificationParam{ + Recipient: email, + Subject: u.emailCfg.ResetPassword.Subject, + TemplateName: u.emailCfg.ResetPassword.TemplateName, + TemplatePath: u.emailCfg.ResetPassword.TemplatePath, + Data: map[string]interface{}{ + "Name": user.Name, + "OpeningWord": u.emailCfg.ResetPassword.OpeningWord, + "Password": generatedPassword, + "ClosingWord": u.emailCfg.ResetPassword.ClosingWord, + "Note": u.emailCfg.ResetPassword.Notes, + "Link": u.emailCfg.ResetPassword.Link, + }, + } + + defer func() { + if r := recover(); r != nil { + u.trxRepo.Rollback(trx) + logger.ContextLogger(ctx).Error("panic recovered in SendPasswordResetLink", zap.Any("recover", r)) + err = errors.ErrorInternalServer + } + }() + + // Send the email notification + err = u.emailSvc.SendEmailTransactional(ctx, renewPasswordRequest) + if err != nil { + u.trxRepo.Rollback(trx) + logger.ContextLogger(ctx).Error("error when sending password reset email", zap.Error(err)) + return errors.ErrorInternalServer + } + + trx.Commit() + + return nil +} + +func (u *AuthServiceImpl) ResetPassword(ctx mycontext.Context, oldPassword, newPassword string) error { + user, err := u.authRepo.CheckExistsUserAccountByID(ctx, ctx.RequestedBy()) + if err != nil { + return errors.ErrorInvalidRequest + } + + if ok := u.crypto.CompareHashAndPassword(user.Password, oldPassword); !ok { + return errors.ErrorUserInvalidLogin + } + + password, err := user.ToUser().HashedPassword(newPassword) + if err != nil { + return errors.ErrorInvalidRequest + } + + trx, _ := u.trxRepo.Begin(ctx) + + err = u.authRepo.UpdatePassword(ctx, trx, password, user.ID, false) + if err != nil { + return errors.ErrorInternalServer + } + + trx.Commit() + + return nil +} diff --git a/internal/services/service.go b/internal/services/service.go index 8255e2f..679d847 100644 --- a/internal/services/service.go +++ b/internal/services/service.go @@ -35,7 +35,7 @@ type ServiceManagerImpl struct { func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl) *ServiceManagerImpl { return &ServiceManagerImpl{ - AuthSvc: auth.New(repo.Auth, repo.Crypto), + AuthSvc: auth.New(repo.Auth, repo.Crypto, repo.User, repo.EmailService, cfg.Email, repo.Trx), EventSvc: event.NewEventService(repo.Event), UserSvc: users.NewUserService(repo.User, repo.Branch), BranchSvc: branch.NewBranchService(repo.Branch), @@ -52,6 +52,8 @@ func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl) type Auth interface { AuthenticateUser(ctx context.Context, email, password string) (*entity.AuthenticateUser, error) + SendPasswordResetLink(ctx context.Context, email string) error + ResetPassword(ctx mycontext.Context, oldPassword, newPassword string) error } type Event interface { diff --git a/templates/reset_password.html b/templates/reset_password.html new file mode 100644 index 0000000..1a225bb --- /dev/null +++ b/templates/reset_password.html @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
Halo {{ .Name }},
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
{{ .OpeningWord }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
Password: {{ .Password }}
+
+
{{ .ClosingWord }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
Terima kasih
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+

+

+ +
+
Email ini tergenerate oleh sistem secara otomatis, anda tidak perlu membalas email ini.
+
+
+ +
+
+ +
+
+ +
+ + \ No newline at end of file