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

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

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

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

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

@ -35,4 +35,16 @@ oss:
midtrans:
server_key: "SB-Mid-server-YOIvuaIlRw3In9SymCuFz-hB"
client_key: "SB-Mid-client-ulkZGFiS8PqBNOZz"
env: 1
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 {
@ -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,
}
}

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

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

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 {
@ -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",
})
}

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

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

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

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>