add forget password

This commit is contained in:
aditya.siregar 2024-07-23 01:36:25 +07:00
parent 7ea809cc09
commit 5a0dec6128
22 changed files with 907 additions and 76 deletions

9
config/brevo.go Normal file
View File

@ -0,0 +1,9 @@
package config
type Brevo struct {
APIKey string `mapstructure:"api_key"`
}
func (b *Brevo) GetApiKey() string {
return b.APIKey
}

View File

@ -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,
},
}
}

View File

@ -7,6 +7,12 @@ type AuthConfig struct {
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)
}

32
config/email.go Normal file
View File

@ -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
}

View File

@ -3,6 +3,7 @@ package config
type Jwt struct {
Token Token `mapstructure:"token"`
TokenOrder Token `mapstructure:"token-order"`
TokenResetPassword Token `mapstructure:"token-reset-password"`
}
type Token struct {

5
go.mod
View File

@ -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

8
go.sum
View File

@ -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=

View File

@ -36,3 +36,15 @@ midtrans:
server_key: "SB-Mid-server-YOIvuaIlRw3In9SymCuFz-hB"
client_key: "SB-Mid-client-ulkZGFiS8PqBNOZz"
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."

View File

@ -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
}
}

View File

@ -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 {
@ -54,6 +55,7 @@ func (u *UserDB) ToUser() *User {
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,
}
}

13
internal/entity/email.go Normal file
View File

@ -0,0 +1,13 @@
package entity
type (
SendEmailNotificationParam struct {
Sender string
Recipient string
CcEmails []string
Subject string
TemplateName string
TemplatePath string
Data interface{}
}
)

View File

@ -24,6 +24,7 @@ type User struct {
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 {

View File

@ -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 {
@ -78,6 +81,7 @@ func (h *AuthHandler) AuthLogin(c *gin.Context) {
Role: authUser.RoleName,
},
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",
})
}

View File

@ -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
}
}

View File

@ -6,6 +6,7 @@ type LoginResponse struct {
Role Role `json:"role"`
Partner *Partner `json:"partner"`
Site *SiteName `json:"site,omitempty"`
ResetPassword bool `json:"reset_password"`
}
type Role struct {

View File

@ -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
}

View File

@ -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}
}

View File

@ -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
}

View File

@ -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"
@ -41,6 +42,7 @@ type RepoManagerImpl struct {
Wallet WalletRepository
Midtrans Midtrans
Payment Payment
EmailService EmailService
}
func NewRepoManagerImpl(db *gorm.DB, cfg *config.Config) *RepoManagerImpl {
@ -60,11 +62,14 @@ func NewRepoManagerImpl(db *gorm.DB, cfg *config.Config) *RepoManagerImpl {
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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -0,0 +1,260 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
</style>
<style type="text/css">
@media (max-width: 480px) {
.bodyStyle {
padding: 0px !important;
}
}
</style>
</head>
<body style="word-spacing:normal;background-color:#f1f0f7;">
<div class="bodyStyle" style="padding: 50px; background-color: #f1f0f7;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:20px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:24px;line-height:25px;text-align:left;color:#0a2062;">Halo {{ .Name }},</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:25px;text-align:left;color:#000000;">{{ .OpeningWord }}</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:25px;text-align:left;color:#000000;">Password: <b style="font-size:19px">{{ .Password }}</b></div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:25px;text-align:left;color:#000000;">{{ .ClosingWord }}</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:25px;text-align:left;color:#000000;">Terima kasih</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<p style="border-top:solid 1px #808080;font-size:1px;margin:0px auto;width:100%;">
</p>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #808080;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:12px;line-height:15px;text-align:left;color:#808080;">Email ini tergenerate oleh sistem secara otomatis, anda tidak perlu membalas email ini.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>