diff --git a/Dockerfile b/Dockerfile index d474fb06..1e78ed94 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,7 @@ WORKDIR /ezbookkeeping COPY --from=be-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/ezbookkeeping /ezbookkeeping/ezbookkeeping COPY --from=fe-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/dist /ezbookkeeping/public COPY --chown=1000:1000 conf /ezbookkeeping/conf +COPY --chown=1000:1000 templates /ezbookkeeping/templates COPY --chown=1000:1000 LICENSE /ezbookkeeping/LICENSE USER 1000:1000 EXPOSE 8080 diff --git a/cmd/initializer.go b/cmd/initializer.go index d7185083..fcbf4e34 100644 --- a/cmd/initializer.go +++ b/cmd/initializer.go @@ -9,6 +9,7 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/datastore" "github.com/mayswind/ezbookkeeping/pkg/exchangerates" "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/mail" "github.com/mayswind/ezbookkeeping/pkg/settings" "github.com/mayswind/ezbookkeeping/pkg/utils" "github.com/mayswind/ezbookkeeping/pkg/uuid" @@ -83,6 +84,15 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) { return nil, err } + err = mail.InitializeMailer(config) + + if err != nil { + if !isDisableBootLog { + log.BootErrorf("[initializer.initializeSystem] initializes mailer failed, because %s", err.Error()) + } + return nil, err + } + err = exchangerates.InitializeExchangeRatesDataSource(config) if err != nil { @@ -110,6 +120,7 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config { } clonedConfig.DatabaseConfig.DatabasePassword = "****" + clonedConfig.SmtpConfig.SmtpPasswd = "****" clonedConfig.SecretKey = "****" return clonedConfig diff --git a/cmd/webserver.go b/cmd/webserver.go index b2ec53ac..96ad75b9 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -201,6 +201,16 @@ func startWebServer(c *cli.Context) error { apiRoute.POST("/register.json", bindApiWithTokenUpdate(api.Users.UserRegisterHandler, config)) } + if config.EnableUserForgetPassword { + apiRoute.POST("/forget_password/request.json", bindApiWithTokenUpdate(api.ForgetPasswords.UserForgetPasswordRequestHandler, config)) + + resetPasswordRoute := apiRoute.Group("/forget_password/reset") + resetPasswordRoute.Use(bindMiddleware(middlewares.JWTResetPasswordAuthorization)) + { + resetPasswordRoute.POST("/by_token.json", bindApiWithTokenUpdate(api.ForgetPasswords.UserResetPasswordHandler, config)) + } + } + apiRoute.GET("/logout.json", bindApiWithTokenUpdate(api.Tokens.TokenRevokeCurrentHandler, config)) apiV1Route := apiRoute.Group("/v1") diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index 66fcda7b..7eb98f34 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -68,6 +68,19 @@ log_query = false # Set to true to automatically update database structure when starting web server auto_update_database = true +[mail] +# Set to true to enable sending mail by smtp server +enable_smtp = false + +# Smtp Server connection configuration +smtp_host = 127.0.0.1:25 +smtp_user = +smtp_passwd = +smtp_skip_tls_verify = false + +# Mail from address. This can be just an email address, or the "Name" format. +from_address = + [log] # Either "console", "file", default is "console" # Use space to separate multiple modes, e.g. "console file" @@ -99,6 +112,9 @@ token_expired_time = 2592000 # Temporary token expired seconds (0 - 4294967295), default is 300 (5 minutes) temporary_token_expired_time = 300 +# Forget password token expired seconds (0 - 4294967295), default is 3600 (60 minutes) +forget_password_token_expired_time = 3600 + # Add X-Request-Id header to response to track user request or error, default is true request_id_header = true @@ -106,6 +122,9 @@ request_id_header = true # Set to true to allow users to register account by themselves enable_register = true +# Set to true to allow users to reset password by email verification code +enable_forget_password = true + # User avatar provider, supports the following types: # "gravatar": https://gravatar.com # Leave blank if you want to disable user avatar diff --git a/go.mod b/go.mod index 440c0a7e..1d986163 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.8 golang.org/x/crypto v0.12.0 gopkg.in/ini.v1 v1.67.0 + gopkg.in/mail.v2 v2.3.1 xorm.io/xorm v1.3.2 ) @@ -58,6 +59,7 @@ require ( golang.org/x/sys v0.11.0 // indirect golang.org/x/text v0.12.0 // indirect google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/yaml.v3 v3.0.1 // indirect xorm.io/builder v0.3.12 // indirect ) diff --git a/go.sum b/go.sum index 55be991b..c87aebb7 100644 --- a/go.sum +++ b/go.sum @@ -626,6 +626,8 @@ google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -638,6 +640,8 @@ gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/pkg/api/forget_passwords.go b/pkg/api/forget_passwords.go new file mode 100644 index 00000000..e6df9b31 --- /dev/null +++ b/pkg/api/forget_passwords.go @@ -0,0 +1,126 @@ +package api + +import ( + "time" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/services" +) + +// ForgetPasswordsApi represents user forget password api +type ForgetPasswordsApi struct { + users *services.UserService + tokens *services.TokenService + forgetPasswords *services.ForgetPasswordService +} + +// Initialize a user api singleton instance +var ( + ForgetPasswords = &ForgetPasswordsApi{ + users: services.Users, + tokens: services.Tokens, + forgetPasswords: services.ForgetPasswords, + } +) + +// UserForgetPasswordRequestHandler generates password reset link and send user an email with this link +func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.Context) (interface{}, *errs.Error) { + var request models.ForgetPasswordRequest + err := c.ShouldBindJSON(&request) + + if err != nil { + log.WarnfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] parse request failed, because %s", err.Error()) + return nil, errs.ErrEmailIsEmptyOrInvalid + } + + user, err := a.users.GetUserByEmail(request.Email) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to get user, because %s", err.Error()) + } + + return nil, errs.ErrUserNotFound + } + + token, _, err := a.tokens.CreatePasswordResetToken(user, c) + + if err != nil { + log.ErrorfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.ErrTokenGenerating + } + + err = a.forgetPasswords.SendPasswordResetEmail(user, token) + + if err != nil { + log.WarnfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send email to \"%s\", because %s", user.Email, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + return true, nil +} + +// UserResetPasswordHandler resets user password by request parameters +func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.Context) (interface{}, *errs.Error) { + var request models.PasswordResetRequest + err := c.ShouldBindJSON(&request) + + if err != nil { + log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] parse request failed, because %s", err.Error()) + return nil, errs.NewIncompleteOrIncorrectSubmissionError(err) + } + + uid := c.GetCurrentUid() + user, err := a.users.GetUserById(uid) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to get user, because %s", err.Error()) + } + + return nil, errs.ErrUserNotFound + } + + if user.Email != request.Email { + log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] request email not equals the user email") + return nil, errs.ErrEmptyIsInvalid + } + + if a.users.IsPasswordEqualsUserPassword(request.Password, user) { + oldTokenClaims := c.GetTokenClaims() + err = a.tokens.DeleteTokenByClaims(oldTokenClaims) + + if err != nil { + log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke password reset token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error()) + } + + return nil, errs.ErrNewPasswordEqualsOldInvalid + } + + userNew := &models.User{ + Uid: user.Uid, + Salt: user.Salt, + Password: request.Password, + } + + _, err = a.users.UpdateUser(userNew, false) + + if err != nil { + log.ErrorfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + now := time.Now().Unix() + err = a.tokens.DeleteTokensBeforeTime(uid, now) + + if err == nil { + log.InfofWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid) + } else { + log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error()) + } + + return true, nil +} diff --git a/pkg/core/token_claims.go b/pkg/core/token_claims.go index ac05a9f0..7e2fef83 100644 --- a/pkg/core/token_claims.go +++ b/pkg/core/token_claims.go @@ -11,8 +11,9 @@ type TokenType byte // Token types const ( - USER_TOKEN_TYPE_NORMAL TokenType = 1 - USER_TOKEN_TYPE_REQUIRE_2FA TokenType = 2 + USER_TOKEN_TYPE_NORMAL TokenType = 1 + USER_TOKEN_TYPE_REQUIRE_2FA TokenType = 2 + USER_TOKEN_TYPE_RESET_PASSWORD TokenType = 3 ) // UserTokenClaims represents user token diff --git a/pkg/errs/error.go b/pkg/errs/error.go index e8a9b5e3..08bc341a 100644 --- a/pkg/errs/error.go +++ b/pkg/errs/error.go @@ -14,6 +14,7 @@ const ( SystemSubcategoryDefault = 0 SystemSubcategorySetting = 1 SystemSubcategoryDatabase = 2 + SystemSubcategoryMail = 3 ) // Sub categories of normal error diff --git a/pkg/errs/mail.go b/pkg/errs/mail.go new file mode 100644 index 00000000..480d315e --- /dev/null +++ b/pkg/errs/mail.go @@ -0,0 +1,9 @@ +package errs + +import "net/http" + +// Error codes related to mail +var ( + ErrSmtpServerNotEnabled = NewSystemError(SystemSubcategoryMail, 0, http.StatusInternalServerError, "smtp server is not enabled") + ErrSmtpServerHostInvalid = NewSystemError(SystemSubcategoryMail, 1, http.StatusInternalServerError, "smtp server host is invalid") +) diff --git a/pkg/errs/token.go b/pkg/errs/token.go index cd5a7c41..09214241 100644 --- a/pkg/errs/token.go +++ b/pkg/errs/token.go @@ -6,17 +6,18 @@ import ( // Error codes related to tokens var ( - ErrTokenGenerating = NewNormalError(NormalSubcategoryToken, 0, http.StatusInternalServerError, "failed to generate token") - ErrUnauthorizedAccess = NewNormalError(NormalSubcategoryToken, 1, http.StatusUnauthorized, "unauthorized access") - ErrCurrentInvalidToken = NewNormalError(NormalSubcategoryToken, 2, http.StatusUnauthorized, "current token is invalid") - ErrCurrentTokenExpired = NewNormalError(NormalSubcategoryToken, 3, http.StatusUnauthorized, "current token is expired") - ErrCurrentInvalidTokenType = NewNormalError(NormalSubcategoryToken, 4, http.StatusUnauthorized, "current token type is invalid") - ErrCurrentTokenRequire2FA = NewNormalError(NormalSubcategoryToken, 5, http.StatusUnauthorized, "current token requires two factor authorization") - ErrCurrentTokenNotRequire2FA = NewNormalError(NormalSubcategoryToken, 6, http.StatusUnauthorized, "current token does not require two factor authorization") - ErrInvalidToken = NewNormalError(NormalSubcategoryToken, 7, http.StatusBadRequest, "token is invalid") - ErrInvalidTokenId = NewNormalError(NormalSubcategoryToken, 8, http.StatusBadRequest, "token id is invalid") - ErrInvalidUserTokenId = NewNormalError(NormalSubcategoryToken, 9, http.StatusBadRequest, "user token id is invalid") - ErrTokenRecordNotFound = NewNormalError(NormalSubcategoryToken, 10, http.StatusBadRequest, "token is not found") - ErrTokenExpired = NewNormalError(NormalSubcategoryToken, 11, http.StatusBadRequest, "token is expired") - ErrTokenIsEmpty = NewNormalError(NormalSubcategoryToken, 12, http.StatusBadRequest, "token is empty") + ErrTokenGenerating = NewNormalError(NormalSubcategoryToken, 0, http.StatusInternalServerError, "failed to generate token") + ErrUnauthorizedAccess = NewNormalError(NormalSubcategoryToken, 1, http.StatusUnauthorized, "unauthorized access") + ErrCurrentInvalidToken = NewNormalError(NormalSubcategoryToken, 2, http.StatusUnauthorized, "current token is invalid") + ErrCurrentTokenExpired = NewNormalError(NormalSubcategoryToken, 3, http.StatusUnauthorized, "current token is expired") + ErrCurrentInvalidTokenType = NewNormalError(NormalSubcategoryToken, 4, http.StatusUnauthorized, "current token type is invalid") + ErrCurrentTokenRequire2FA = NewNormalError(NormalSubcategoryToken, 5, http.StatusUnauthorized, "current token requires two factor authorization") + ErrCurrentTokenNotRequire2FA = NewNormalError(NormalSubcategoryToken, 6, http.StatusUnauthorized, "current token does not require two factor authorization") + ErrInvalidToken = NewNormalError(NormalSubcategoryToken, 7, http.StatusBadRequest, "token is invalid") + ErrInvalidTokenId = NewNormalError(NormalSubcategoryToken, 8, http.StatusBadRequest, "token id is invalid") + ErrInvalidUserTokenId = NewNormalError(NormalSubcategoryToken, 9, http.StatusBadRequest, "user token id is invalid") + ErrTokenRecordNotFound = NewNormalError(NormalSubcategoryToken, 10, http.StatusBadRequest, "token is not found") + ErrTokenExpired = NewNormalError(NormalSubcategoryToken, 11, http.StatusBadRequest, "token is expired") + ErrTokenIsEmpty = NewNormalError(NormalSubcategoryToken, 12, http.StatusBadRequest, "token is empty") + ErrPasswordResetTokenIsInvalidOrExpired = NewNormalError(NormalSubcategoryToken, 13, http.StatusBadRequest, "password reset token is invalid or expired") ) diff --git a/pkg/errs/user.go b/pkg/errs/user.go index 54159b7c..68d6d581 100644 --- a/pkg/errs/user.go +++ b/pkg/errs/user.go @@ -23,4 +23,7 @@ var ( ErrUserRegistrationNotAllowed = NewNormalError(NormalSubcategoryUser, 14, http.StatusBadRequest, "user registration not allowed") ErrUserDefaultAccountIsInvalid = NewNormalError(NormalSubcategoryUser, 15, http.StatusBadRequest, "user default account is invalid") ErrUserIsDisabled = NewNormalError(NormalSubcategoryUser, 16, http.StatusBadRequest, "user is disabled") + ErrEmptyIsInvalid = NewNormalError(NormalSubcategoryUser, 17, http.StatusBadRequest, "email is invalid") + ErrEmailIsEmptyOrInvalid = NewNormalError(NormalSubcategoryUser, 18, http.StatusBadRequest, "email is empty or invalid") + ErrNewPasswordEqualsOldInvalid = NewNormalError(NormalSubcategoryUser, 19, http.StatusBadRequest, "new password equals old password") ) diff --git a/pkg/locales/all_locales.go b/pkg/locales/all_locales.go new file mode 100644 index 00000000..04ccc83c --- /dev/null +++ b/pkg/locales/all_locales.go @@ -0,0 +1,24 @@ +package locales + +// DefaultLanguage represents the default language +var DefaultLanguage = en + +// AllLanguages represents all the supported language +var AllLanguages = map[string]*LocaleInfo{ + "en": { + Content: en, + }, + "zh-Hans": { + Content: zhHans, + }, +} + +func GetLocaleTextItems(locale string) *LocaleTextItems { + localeInfo, exists := AllLanguages[locale] + + if exists { + return localeInfo.Content + } + + return DefaultLanguage +} diff --git a/pkg/locales/base.go b/pkg/locales/base.go new file mode 100644 index 00000000..d19e8406 --- /dev/null +++ b/pkg/locales/base.go @@ -0,0 +1,15 @@ +package locales + +// LocaleTextItems represents all text items need to be translated +type LocaleTextItems struct { + ForgetPasswordMailTextItems *ForgetPasswordMailTextItems +} + +// ForgetPasswordMailTextItems represents text items need to be translated in forget password mail +type ForgetPasswordMailTextItems struct { + Title string + SalutationFormat string + DescriptionAboveBtn string + ResetPassword string + DescriptionBelowBtnFormat string +} diff --git a/pkg/locales/en.go b/pkg/locales/en.go new file mode 100644 index 00000000..0690ee4d --- /dev/null +++ b/pkg/locales/en.go @@ -0,0 +1,11 @@ +package locales + +var en = &LocaleTextItems{ + ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{ + Title: "Reset Your Password", + SalutationFormat: "Hi %s,", + DescriptionAboveBtn: "We recently received a request to reset your password. You can click the below link to reset your password.", + ResetPassword: "Reset Password", + DescriptionBelowBtnFormat: "If you did not request to reset your password, please simply disregard this email. If you cannot click the above link, please copy the above url and paste it into your browser. The password reset link will be expired after %v minutes.", + }, +} diff --git a/pkg/locales/locale_info.go b/pkg/locales/locale_info.go new file mode 100644 index 00000000..5a7ca20b --- /dev/null +++ b/pkg/locales/locale_info.go @@ -0,0 +1,7 @@ +package locales + +// LocaleInfo represents locale info +type LocaleInfo struct { + Aliases []string + Content *LocaleTextItems +} diff --git a/pkg/locales/zh_hans.go b/pkg/locales/zh_hans.go new file mode 100644 index 00000000..93e7d9a6 --- /dev/null +++ b/pkg/locales/zh_hans.go @@ -0,0 +1,11 @@ +package locales + +var zhHans = &LocaleTextItems{ + ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{ + Title: "重置密码", + SalutationFormat: "%s 你好,", + DescriptionAboveBtn: "我们刚才收到重置您密码的请求。您可以点击下方链接重置您的密码。", + ResetPassword: "重置密码", + DescriptionBelowBtnFormat: "如果您没有请求重置密码,请直接忽略本邮件。如果您无法点击上述链接,请复制下方的地址然后在您的浏览器中粘贴。重置密码链接将在 %v 分钟后过期。", + }, +} diff --git a/pkg/mail/default_mailer.go b/pkg/mail/default_mailer.go new file mode 100644 index 00000000..9d71fddd --- /dev/null +++ b/pkg/mail/default_mailer.go @@ -0,0 +1,63 @@ +package mail + +import ( + "crypto/tls" + "net" + + "gopkg.in/mail.v2" + + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/settings" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +// DefaultMailer represents default mailer +type DefaultMailer struct { + dialer *mail.Dialer + fromAddress string +} + +// NewDefaultMailer returns a new default mailer +func NewDefaultMailer(smtpConfig *settings.SmtpConfig) (*DefaultMailer, error) { + host, portStr, err := net.SplitHostPort(smtpConfig.SmtpHost) + + if err != nil { + return nil, errs.ErrSmtpServerHostInvalid + } + + port, err := utils.StringToInt(portStr) + + if err != nil { + return nil, errs.ErrSmtpServerHostInvalid + } + + dialer := mail.NewDialer(host, port, smtpConfig.SmtpUser, smtpConfig.SmtpPasswd) + dialer.TLSConfig = &tls.Config{ + ServerName: host, + InsecureSkipVerify: smtpConfig.SmtpSkipTLSVerify, + } + + mailer := &DefaultMailer{ + dialer: dialer, + fromAddress: smtpConfig.FromAddress, + } + + return mailer, nil +} + +// SendMail sends an email according to argument +func (m *DefaultMailer) SendMail(message *MailMessage) error { + if m.dialer == nil { + return errs.ErrSmtpServerNotEnabled + } + + mailMessage := mail.NewMessage() + mailMessage.SetHeader("From", m.fromAddress) + mailMessage.SetHeader("To", message.To) + mailMessage.SetHeader("Subject", message.Subject) + mailMessage.SetBody("text/html", message.Body) + + err := m.dialer.DialAndSend(mailMessage) + + return err +} diff --git a/pkg/mail/mail_message.go b/pkg/mail/mail_message.go new file mode 100644 index 00000000..5f5d2a92 --- /dev/null +++ b/pkg/mail/mail_message.go @@ -0,0 +1,8 @@ +package mail + +// MailMessage represents an email entity +type MailMessage struct { + To string + Subject string + Body string +} diff --git a/pkg/mail/mailer.go b/pkg/mail/mailer.go new file mode 100644 index 00000000..d6151e98 --- /dev/null +++ b/pkg/mail/mailer.go @@ -0,0 +1,6 @@ +package mail + +// Mailer is email sender interface +type Mailer interface { + SendMail(message *MailMessage) error +} diff --git a/pkg/mail/mailer_container.go b/pkg/mail/mailer_container.go new file mode 100644 index 00000000..351a030d --- /dev/null +++ b/pkg/mail/mailer_container.go @@ -0,0 +1,37 @@ +package mail + +import ( + "github.com/mayswind/ezbookkeeping/pkg/settings" +) + +// MailerContainer contains the current mailer +type MailerContainer struct { + Current Mailer +} + +// Initialize a mailer container singleton instance +var ( + Container = &MailerContainer{} +) + +// InitializeMailer initializes the current mailer according to the config +func InitializeMailer(config *settings.Config) error { + if !config.EnableSmtp { + Container.Current = nil + return nil + } + + mailer, err := NewDefaultMailer(config.SmtpConfig) + + if err != nil { + return err + } + + Container.Current = mailer + return nil +} + +// SendMail sends an email according to argument +func (u *MailerContainer) SendMail(message *MailMessage) error { + return u.Current.SendMail(message) +} diff --git a/pkg/middlewares/authorization.go b/pkg/middlewares/authorization.go index 68c44e0d..3feb6c6f 100644 --- a/pkg/middlewares/authorization.go +++ b/pkg/middlewares/authorization.go @@ -56,6 +56,25 @@ func JWTTwoFactorAuthorization(c *core.Context) { c.Next() } +// JWTResetPasswordAuthorization verifies whether current request is password reset +func JWTResetPasswordAuthorization(c *core.Context) { + claims, err := getTokenClaims(c, TOKEN_SOURCE_TYPE_ARGUMENT) + + if err != nil { + utils.PrintJsonErrorResult(c, errs.ErrPasswordResetTokenIsInvalidOrExpired) + return + } + + if claims.Type != core.USER_TOKEN_TYPE_RESET_PASSWORD { + log.WarnfWithRequestId(c, "[authorization.JWTResetPasswordAuthorization] user \"uid:%d\" token is not for password request", claims.Uid) + utils.PrintJsonErrorResult(c, errs.ErrCurrentInvalidToken) + return + } + + c.SetTokenClaims(claims) + c.Next() +} + func jwtAuthorization(c *core.Context, source TokenSourceType) { claims, err := getTokenClaims(c, source) diff --git a/pkg/middlewares/server_settings_cookie.go b/pkg/middlewares/server_settings_cookie.go index f165db65..fb88552e 100644 --- a/pkg/middlewares/server_settings_cookie.go +++ b/pkg/middlewares/server_settings_cookie.go @@ -17,6 +17,7 @@ func ServerSettingsCookie(config *settings.Config) core.MiddlewareHandlerFunc { return func(c *core.Context) { settingsArr := []string{ buildBooleanSetting("r", config.EnableUserRegister), + buildBooleanSetting("f", config.EnableUserForgetPassword), buildBooleanSetting("e", config.EnableDataExport), buildStringSetting("m", strings.Replace(config.MapProvider, "_", "-", -1)), } diff --git a/pkg/models/forget_password.go b/pkg/models/forget_password.go new file mode 100644 index 00000000..fcc7bb9b --- /dev/null +++ b/pkg/models/forget_password.go @@ -0,0 +1,12 @@ +package models + +// ForgetPasswordRequest represents all parameters of forget password request +type ForgetPasswordRequest struct { + Email string `json:"email" binding:"required,notBlank,max=100,validEmail"` +} + +// PasswordResetRequest represents all parameters of reset password request +type PasswordResetRequest struct { + Email string `json:"email" binding:"required,notBlank,max=100,validEmail"` + Password string `json:"password" binding:"required,min=6,max=128"` +} diff --git a/pkg/services/base.go b/pkg/services/base.go index e9cc267d..c42080e0 100644 --- a/pkg/services/base.go +++ b/pkg/services/base.go @@ -2,6 +2,8 @@ package services import ( "github.com/mayswind/ezbookkeeping/pkg/datastore" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/mail" "github.com/mayswind/ezbookkeeping/pkg/settings" "github.com/mayswind/ezbookkeeping/pkg/uuid" ) @@ -36,6 +38,20 @@ func (s *ServiceUsingConfig) CurrentConfig() *settings.Config { return s.container.Current } +// ServiceUsingMailer represents a service that need to use mailer +type ServiceUsingMailer struct { + container *mail.MailerContainer +} + +// SendMail sends an email according to argument +func (s *ServiceUsingMailer) SendMail(message *mail.MailMessage) error { + if s.container.Current == nil { + return errs.ErrSmtpServerNotEnabled + } + + return s.container.Current.SendMail(message) +} + // ServiceUsingUuid represents a service that need to use uuid type ServiceUsingUuid struct { container *uuid.UuidContainer diff --git a/pkg/services/forget_passwords.go b/pkg/services/forget_passwords.go new file mode 100644 index 00000000..799c9486 --- /dev/null +++ b/pkg/services/forget_passwords.go @@ -0,0 +1,81 @@ +package services + +import ( + "bytes" + "fmt" + "net/url" + + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/locales" + "github.com/mayswind/ezbookkeeping/pkg/mail" + "github.com/mayswind/ezbookkeeping/pkg/models" + "github.com/mayswind/ezbookkeeping/pkg/settings" + "github.com/mayswind/ezbookkeeping/pkg/templates" +) + +const passwordResetUrlFormat = "%sdesktop/#/resetpassword?token=%s" + +// ForgetPasswordService represents forget password service +type ForgetPasswordService struct { + ServiceUsingConfig + ServiceUsingMailer +} + +// Initialize a forget password service singleton instance +var ( + ForgetPasswords = &ForgetPasswordService{ + ServiceUsingConfig: ServiceUsingConfig{ + container: settings.Container, + }, + ServiceUsingMailer: ServiceUsingMailer{ + container: mail.Container, + }, + } +) + +// SendPasswordResetEmail sends password reset email according to specified parameters +func (s *ForgetPasswordService) SendPasswordResetEmail(user *models.User, passwordResetToken string) error { + if !s.CurrentConfig().EnableSmtp { + return errs.ErrSmtpServerNotEnabled + } + + localeTextItems := locales.GetLocaleTextItems(user.Language) + forgetPasswordTextItems := localeTextItems.ForgetPasswordMailTextItems + + expireTimeInMinutes := s.CurrentConfig().ForgetPasswordTokenExpiredTimeDuration.Minutes() + passwordResetUrl := fmt.Sprintf(passwordResetUrlFormat, s.CurrentConfig().RootUrl, url.QueryEscape(passwordResetToken)) + + tmpl, err := templates.GetTemplate("email/password_reset") + + if err != nil { + return err + } + + templateParams := map[string]interface{}{ + "ForgetPasswordMail": map[string]interface{}{ + "Title": forgetPasswordTextItems.Title, + "Salutation": fmt.Sprintf(forgetPasswordTextItems.SalutationFormat, user.Nickname), + "DescriptionAboveBtn": forgetPasswordTextItems.DescriptionAboveBtn, + "ResetPasswordUrl": passwordResetUrl, + "ResetPassword": forgetPasswordTextItems.ResetPassword, + "DescriptionBelowBtn": fmt.Sprintf(forgetPasswordTextItems.DescriptionBelowBtnFormat, expireTimeInMinutes), + }, + } + + var bodyBuffer bytes.Buffer + err = tmpl.Execute(&bodyBuffer, templateParams) + + if err != nil { + return err + } + + message := &mail.MailMessage{ + To: user.Email, + Subject: forgetPasswordTextItems.Title, + Body: bodyBuffer.String(), + } + + err = s.SendMail(message) + + return err +} diff --git a/pkg/services/tokens.go b/pkg/services/tokens.go index ba7905ee..e9f36aa6 100644 --- a/pkg/services/tokens.go +++ b/pkg/services/tokens.go @@ -88,6 +88,11 @@ func (s *TokenService) CreateRequire2FAToken(user *models.User, ctx *core.Contex return s.createToken(user, core.USER_TOKEN_TYPE_REQUIRE_2FA, s.getUserAgent(ctx), s.CurrentConfig().TemporaryTokenExpiredTimeDuration) } +// CreatePasswordResetToken generates a new password reset token and saves to database +func (s *TokenService) CreatePasswordResetToken(user *models.User, ctx *core.Context) (string, *core.UserTokenClaims, error) { + return s.createToken(user, core.USER_TOKEN_TYPE_RESET_PASSWORD, s.getUserAgent(ctx), s.CurrentConfig().ForgetPasswordTokenExpiredTimeDuration) +} + // DeleteToken deletes given token from database func (s *TokenService) DeleteToken(tokenRecord *models.TokenRecord) error { if tokenRecord.Uid <= 0 { diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index d086fa5f..85ec51f4 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -113,9 +113,10 @@ const ( defaultLogMode string = "console" defaultLoglevel Level = LOGLEVEL_INFO - defaultSecretKey string = "ezbookkeeping" - defaultTokenExpiredTime uint32 = 604800 // 7 days - defaultTemporaryTokenExpiredTime uint32 = 300 // 5 minutes + defaultSecretKey string = "ezbookkeeping" + defaultTokenExpiredTime uint32 = 604800 // 7 days + defaultTemporaryTokenExpiredTime uint32 = 300 // 5 minutes + defaultForgetPasswordTokenExpiredTime uint32 = 3600 // 60 minutes defaultExchangeRatesDataRequestTimeout uint32 = 10000 // 10 seconds ) @@ -137,6 +138,15 @@ type DatabaseConfig struct { ConnectionMaxLifeTime uint32 } +// SmtpConfig represents the smtp setting config +type SmtpConfig struct { + SmtpHost string + SmtpUser string + SmtpPasswd string + SmtpSkipTLSVerify bool + FromAddress string +} + // Config represents the global setting config type Config struct { // Global @@ -167,6 +177,10 @@ type Config struct { EnableQueryLog bool AutoUpdateDatabase bool + // Mail + EnableSmtp bool + SmtpConfig *SmtpConfig + // Log LogModes []string EnableConsoleLog bool @@ -180,17 +194,20 @@ type Config struct { UuidServerId uint8 // Secret - SecretKey string - EnableTwoFactor bool - TokenExpiredTime uint32 - TokenExpiredTimeDuration time.Duration - TemporaryTokenExpiredTime uint32 - TemporaryTokenExpiredTimeDuration time.Duration - EnableRequestIdHeader bool + SecretKey string + EnableTwoFactor bool + TokenExpiredTime uint32 + TokenExpiredTimeDuration time.Duration + TemporaryTokenExpiredTime uint32 + TemporaryTokenExpiredTimeDuration time.Duration + ForgetPasswordTokenExpiredTime uint32 + ForgetPasswordTokenExpiredTimeDuration time.Duration + EnableRequestIdHeader bool // User - EnableUserRegister bool - AvatarProvider string + EnableUserRegister bool + EnableUserForgetPassword bool + AvatarProvider string // Data EnableDataExport bool @@ -246,6 +263,12 @@ func LoadConfiguration(configFilePath string) (*Config, error) { return nil, err } + err = loadMailConfiguration(config, cfgFile, "mail") + + if err != nil { + return nil, err + } + err = loadLogConfiguration(config, cfgFile, "log") if err != nil { @@ -394,6 +417,22 @@ func loadDatabaseConfiguration(config *Config, configFile *ini.File, sectionName return nil } +func loadMailConfiguration(config *Config, configFile *ini.File, sectionName string) error { + config.EnableSmtp = getConfigItemBoolValue(configFile, sectionName, "enable_smtp", false) + + smtpConfig := &SmtpConfig{} + smtpConfig.SmtpHost = getConfigItemStringValue(configFile, sectionName, "smtp_host") + smtpConfig.SmtpUser = getConfigItemStringValue(configFile, sectionName, "smtp_user") + smtpConfig.SmtpPasswd = getConfigItemStringValue(configFile, sectionName, "smtp_passwd") + smtpConfig.SmtpSkipTLSVerify = getConfigItemBoolValue(configFile, sectionName, "smtp_skip_tls_verify", false) + + smtpConfig.FromAddress = getConfigItemStringValue(configFile, sectionName, "from_address") + + config.SmtpConfig = smtpConfig + + return nil +} + func loadLogConfiguration(config *Config, configFile *ini.File, sectionName string) error { config.LogModes = strings.Split(getConfigItemStringValue(configFile, sectionName, "mode", defaultLogMode), " ") @@ -442,6 +481,9 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName config.TemporaryTokenExpiredTime = getConfigItemUint32Value(configFile, sectionName, "temporary_token_expired_time", defaultTemporaryTokenExpiredTime) config.TemporaryTokenExpiredTimeDuration = time.Duration(config.TemporaryTokenExpiredTime) * time.Second + config.ForgetPasswordTokenExpiredTime = getConfigItemUint32Value(configFile, sectionName, "forget_password_token_expired_time", defaultForgetPasswordTokenExpiredTime) + config.ForgetPasswordTokenExpiredTimeDuration = time.Duration(config.ForgetPasswordTokenExpiredTime) * time.Second + config.EnableRequestIdHeader = getConfigItemBoolValue(configFile, sectionName, "request_id_header", true) return nil @@ -449,6 +491,7 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName func loadUserConfiguration(config *Config, configFile *ini.File, sectionName string) error { config.EnableUserRegister = getConfigItemBoolValue(configFile, sectionName, "enable_register", false) + config.EnableUserForgetPassword = getConfigItemBoolValue(configFile, sectionName, "enable_forget_password", false) if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == "" { config.AvatarProvider = "" diff --git a/pkg/templates/template_cache.go b/pkg/templates/template_cache.go new file mode 100644 index 00000000..af4f4968 --- /dev/null +++ b/pkg/templates/template_cache.go @@ -0,0 +1,42 @@ +package templates + +import ( + "fmt" + "html/template" + "path/filepath" +) + +const templateBasePath = "templates" +const templateFileExtension = "tmpl" + +var templateCache = make(map[string]*CachedTemplate) + +// CachedTemplate represents a cached template +type CachedTemplate struct { + templateName string + templateContent *template.Template +} + +// GetTemplate returns a cached template instance according to the template name +func GetTemplate(templateName string) (*template.Template, error) { + fullPath := filepath.Join(templateBasePath, fmt.Sprintf("%s.%s", templateName, templateFileExtension)) + + cachedTemplate, exists := templateCache[templateName] + + if exists { + return cachedTemplate.templateContent, nil + } + + tmpl, err := template.ParseFiles(fullPath) + + if err != nil { + return nil, err + } + + templateCache[templateName] = &CachedTemplate{ + templateName: templateName, + templateContent: tmpl, + } + + return tmpl, err +} diff --git a/src/consts/api.js b/src/consts/api.js index 07d5dc10..b9929791 100644 --- a/src/consts/api.js +++ b/src/consts/api.js @@ -1,3 +1,5 @@ +const defaultTimeout = 10000; // 10s +const requestForgetPasswordTimeout = 30000; // 30s const baseApiUrlPath = '/api'; const baseQrcodePath = '/qrcode'; const baseProxyUrlPath = '/proxy'; @@ -7,6 +9,8 @@ const baiduMapJavascriptUrl = 'https://api.map.baidu.com/api?v=3.0'; const amapJavascriptUrl = 'https://webapi.amap.com/maps?v=2.0'; export default { + defaultTimeout: defaultTimeout, + requestForgetPasswordTimeout: requestForgetPasswordTimeout, baseApiUrlPath: baseApiUrlPath, baseQrcodePath: baseQrcodePath, baseProxyUrlPath: baseProxyUrlPath, diff --git a/src/lib/server_settings.js b/src/lib/server_settings.js index 867afbd2..755bd574 100644 --- a/src/lib/server_settings.js +++ b/src/lib/server_settings.js @@ -33,6 +33,10 @@ export function isUserRegistrationEnabled() { return getServerSetting('r') === '1'; } +export function isUserForgetPasswordEnabled() { + return getServerSetting('f') === '1'; +} + export function isDataExportingEnabled() { return getServerSetting('e') === '1'; } diff --git a/src/lib/services.js b/src/lib/services.js index aea9cad0..5af2b7d3 100644 --- a/src/lib/services.js +++ b/src/lib/services.js @@ -13,7 +13,7 @@ let needBlockRequest = false; let blockedRequests = []; axios.defaults.baseURL = api.baseApiUrlPath; -axios.defaults.timeout = 10000; // 10s +axios.defaults.timeout = api.defaultTimeout; axios.interceptors.request.use(config => { const token = userState.getToken(); @@ -102,6 +102,21 @@ export default { firstDayOfWeek }); }, + requestResetPassword: ({ email }) => { + return axios.post('forget_password/request.json', { + email + }, { + timeout: api.requestForgetPasswordTimeout + }); + }, + resetPassword: ({ email, token, password }) => { + return axios.post('forget_password/reset/by_token.json?token=' + token, { + email, + password + }, { + ignoreError: true + }); + }, logout: () => { return axios.get('logout.json'); }, diff --git a/src/locales/en.js b/src/locales/en.js index 610833f2..1b92d4b9 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -558,6 +558,7 @@ export default { 'api not found': 'Failed to request api', 'not implemented': 'Not implemented', 'database operation failed': 'Database operation failed', + 'smtp server is not enabled': 'Smtp server is not enabled', 'incomplete or incorrect submission': 'Incomplete or incorrect submission', 'operation failed': 'Operation failed', 'nothing will be updated': 'Nothing will be updated', @@ -580,6 +581,9 @@ export default { 'login name or password is invalid': 'Login name or password is invalid', 'login name or password is wrong': 'Login name or password is wrong', 'user is disabled': 'User is disabled', + 'email is invalid': 'Email is invalid', + 'email is empty or invalid': 'Email is empty or invalid', + 'new password equals old password': 'New password equals old password', 'unauthorized access': 'Unauthorized access', 'current token is invalid': 'Current token is invalid', 'current token is expired': 'Current token is expired', @@ -592,6 +596,7 @@ export default { 'token is not found': 'Token is not found', 'token is expired': 'Token is expired', 'token is empty': 'Token is empty', + 'password reset token is invalid or expired': 'Password reset token is invalid or expired', 'passcode is invalid': 'Passcode is invalid', 'two factor backup code is invalid': 'Two factor backup code is invalid', 'two factor is not enabled': 'Two factor is not enabled', @@ -812,7 +817,9 @@ export default { 'This year or later': 'This year or later', 'Log In': 'Log In', 'Click here to log in': 'Click here to log in', + 'Back to log in': 'Back to log in', 'Don\'t have an account?': 'Don\'t have an account?', + 'Forget Password?': 'Forget Password?', 'Create an account': 'Create an account', 'Username cannot be empty': 'Username cannot be empty', 'Password cannot be empty': 'Password cannot be empty', @@ -839,6 +846,15 @@ export default { 'Use a passcode': 'Use a passcode', 'PIN code is invalid': 'PIN code is invalid', 'PIN code is wrong': 'PIN code is wrong', + 'Send Reset Link': 'Send Reset Link', + 'Please input your email address used for registration and we\'ll send you an email with reset password link': 'Please input your email address used for registration and we\'ll send you an email with reset password link', + 'Password reset email has been sent': 'Password reset email has been sent', + 'Unable to send password reset email': 'Unable to send password reset email', + 'Reset Password': 'Reset Password', + 'Update Password': 'Update Password', + 'Please input your email address again, and input the new password.': 'Please input your email address again, and input the new password.', + 'Password has been updated': 'Password has been updated', + 'Unable to reset password': 'Unable to reset password', 'Sign Up': 'Sign Up', 'Overview': 'Overview', 'Asset Summary': 'Asset Summary', diff --git a/src/locales/zh_Hans.js b/src/locales/zh_Hans.js index 9549fe4e..798944c3 100644 --- a/src/locales/zh_Hans.js +++ b/src/locales/zh_Hans.js @@ -558,6 +558,7 @@ export default { 'api not found': '接口调用失败', 'not implemented': '未实现', 'database operation failed': '数据库操作失败', + 'smtp server is not enabled': 'Smtp 服务器没有启用', 'incomplete or incorrect submission': '提交不完整或不正确', 'operation failed': '操作失败', 'nothing will be updated': '没有内容更新', @@ -580,6 +581,9 @@ export default { 'login name or password is invalid': '登录名或密码无效', 'login name or password is wrong': '登录名或密码错误', 'user is disabled': '用户已禁用', + 'email is invalid': '邮箱无效', + 'email is empty or invalid': '邮箱为空或无效', + 'new password equals old password': '新密码与旧密码相同', 'unauthorized access': '未授权的登录', 'current token is invalid': '当前认证令牌无效', 'current token is expired': '当前认证令牌已过期', @@ -592,6 +596,7 @@ export default { 'token is not found': '认证令牌不存在', 'token is expired': '认证令牌已过期', 'token is empty': '认证令牌为空', + 'password reset token is invalid or expired': '密码重置令牌无效或已过期', 'passcode is invalid': '验证码无效', 'two factor backup code is invalid': '两步验证备用码无效', 'two factor is not enabled': '两步验证没有启用', @@ -812,7 +817,9 @@ export default { 'This year or later': '今年或更晚', 'Log In': '登录', 'Click here to log in': '点击这里登录', + 'Back to log in': '返回登录页', 'Don\'t have an account?': '还没有账号?', + 'Forget Password?': '忘记密码?', 'Create an account': '创建新账号', 'Username cannot be empty': '用户名不能为空', 'Password cannot be empty': '密码不能为空', @@ -839,6 +846,15 @@ export default { 'Use a passcode': '使用验证码', 'PIN code is invalid': 'PIN码无效', 'PIN code is wrong': 'PIN码错误', + 'Send Reset Link': '发送重置链接', + 'Please input your email address used for registration and we\'ll send you an email with reset password link': '请输入您注册时使用的电子邮箱地址,我们将发送一封包含重置密码链接的邮件给您', + 'Password reset email has been sent': '重置密码邮件已发送', + 'Unable to send password reset email': '无法发送重置密码邮件', + 'Reset Password': '重置密码', + 'Update Password': '更新密码', + 'Please input your email address again, and input the new password.': '请再次输入您的邮箱,然后输入新的密码。', + 'Password has been updated': '密码已经更新', + 'Unable to reset password': '无法重置密码', 'Sign Up': '注册', 'Overview': '总览', 'Asset Summary': '资产概要', diff --git a/src/router/desktop.js b/src/router/desktop.js index 20975728..15964df7 100644 --- a/src/router/desktop.js +++ b/src/router/desktop.js @@ -5,6 +5,8 @@ import userState from '@/lib/userstate.js'; import MainLayout from '@/views/desktop/MainLayout.vue'; import LoginPage from '@/views/desktop/LoginPage.vue'; import SignUpPage from '@/views/desktop/SignupPage.vue'; +import ForgetPasswordPage from '@/views/desktop/ForgetPasswordPage.vue'; +import ResetPasswordPage from '@/views/desktop/ResetPasswordPage.vue'; import UnlockPage from '@/views/desktop/UnlockPage.vue'; import HomePage from '@/views/desktop/HomePage.vue'; @@ -157,6 +159,18 @@ const router = createRouter({ component: SignUpPage, beforeEnter: checkNotLogin }, + { + path: '/forgetpassword', + component: ForgetPasswordPage, + beforeEnter: checkNotLogin + }, + { + path: '/resetpassword', + component: ResetPasswordPage, + props: route => ({ + token: route.query.token + }) + }, { path: '/unlock', component: UnlockPage, diff --git a/src/stores/index.js b/src/stores/index.js index 881f7184..e3099ef3 100644 --- a/src/stores/index.js +++ b/src/stores/index.js @@ -247,6 +247,60 @@ export const useRootStore = defineStore('root', { userState.clearWebAuthnConfig(); this.resetAllStates(true); }, + requestResetPassword({ email }) { + return new Promise((resolve, reject) => { + services.requestResetPassword({ + email + }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to send password reset email' }); + return; + } + + resolve(data.result); + }).catch(error => { + logger.error('failed to send password reset email', error); + + if (error && error.processed) { + reject(error); + } else if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else { + reject({ message: 'Unable to send password reset email' }); + } + }); + }); + }, + resetPassword({ email, token, password }) { + return new Promise((resolve, reject) => { + services.resetPassword({ + token, + email, + password + }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to reset password' }); + return; + } + + resolve(data.result); + }).catch(error => { + logger.error('failed to reset password', error); + + if (error && error.processed) { + reject(error); + } else if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else { + reject({ message: 'Unable to reset password' }); + } + }); + }); + }, updateUserProfile({ profile, currentPassword }) { return new Promise((resolve, reject) => { services.updateProfile({ diff --git a/src/views/desktop/ForgetPasswordPage.vue b/src/views/desktop/ForgetPasswordPage.vue new file mode 100644 index 00000000..0c206f86 --- /dev/null +++ b/src/views/desktop/ForgetPasswordPage.vue @@ -0,0 +1,187 @@ + + + diff --git a/src/views/desktop/LoginPage.vue b/src/views/desktop/LoginPage.vue index 2a7a65d0..8b9484a6 100644 --- a/src/views/desktop/LoginPage.vue +++ b/src/views/desktop/LoginPage.vue @@ -95,6 +95,11 @@ {{ $t('Use on Mobile Device') }} + + + {{ $t('Forget Password?') }} + @@ -176,7 +181,7 @@ import { useSettingsStore } from '@/stores/setting.js'; import { useExchangeRatesStore } from '@/stores/exchangeRates.js'; import assetConstants from '@/consts/asset.js'; -import { isUserRegistrationEnabled } from '@/lib/server_settings.js'; +import { isUserRegistrationEnabled, isUserForgetPasswordEnabled } from '@/lib/server_settings.js'; import { mdiEyeOutline, @@ -221,6 +226,9 @@ export default { isUserRegistrationEnabled() { return isUserRegistrationEnabled(); }, + isUserForgetPasswordEnabled() { + return isUserForgetPasswordEnabled(); + }, inputIsEmpty() { return !this.username || !this.password; }, diff --git a/src/views/desktop/ResetPasswordPage.vue b/src/views/desktop/ResetPasswordPage.vue new file mode 100644 index 00000000..4397aa55 --- /dev/null +++ b/src/views/desktop/ResetPasswordPage.vue @@ -0,0 +1,248 @@ + + + diff --git a/src/views/mobile/LoginPage.vue b/src/views/mobile/LoginPage.vue index c4c03902..4f5f2179 100644 --- a/src/views/mobile/LoginPage.vue +++ b/src/views/mobile/LoginPage.vue @@ -35,6 +35,9 @@ @@ -127,6 +130,37 @@ + + + +
+
{{ $t('Forget Password?') }}
+
+
+

+ {{ $t('Please input your email address used for registration and we\'ll send you an email with reset password link') }} +

+ + + + +
+
+
@@ -137,7 +171,7 @@ import { useSettingsStore } from '@/stores/setting.js'; import { useExchangeRatesStore } from '@/stores/exchangeRates.js'; import assetConstants from '@/consts/asset.js'; -import { isUserRegistrationEnabled } from '@/lib/server_settings.js'; +import { isUserRegistrationEnabled, isUserForgetPasswordEnabled } from '@/lib/server_settings.js'; import { getDesktopVersionPath } from '@/lib/version.js'; import { isModalShowing } from '@/lib/ui.mobile.js'; @@ -152,9 +186,12 @@ export default { passcode: '', backupCode: '', tempToken: '', + forgetPasswordEmail: '', logining: false, verifying: false, + requestingForgetPassword: false, show2faSheet: false, + showForgetPasswordSheet: false, twoFAVerifyType: 'passcode' }; }, @@ -175,6 +212,9 @@ export default { isUserRegistrationEnabled() { return isUserRegistrationEnabled(); }, + isUserForgetPasswordEnabled() { + return isUserForgetPasswordEnabled(); + }, inputIsEmpty() { return !this.username || !this.password; }, @@ -308,6 +348,33 @@ export default { } }); }, + requestResetPassword() { + const self = this; + + if (!self.forgetPasswordEmail) { + self.$alert('Email address cannot be empty'); + return; + } + + self.requestingForgetPassword = true; + self.$showLoading(() => self.requestingForgetPassword); + + self.rootStore.requestResetPassword({ + email: self.forgetPasswordEmail + }).then(() => { + self.requestingForgetPassword = false; + self.$hideLoading(); + + self.$toast('Password reset email has been sent'); + }).catch(error => { + self.requestingForgetPassword = false; + self.$hideLoading(); + + if (!error.processed) { + self.$toast(error.message || error); + } + }); + }, switch2FAVerifyType() { if (this.twoFAVerifyType === 'passcode') { this.twoFAVerifyType = 'backupcode'; diff --git a/templates/email/password_reset.tmpl b/templates/email/password_reset.tmpl new file mode 100644 index 00000000..6640642c --- /dev/null +++ b/templates/email/password_reset.tmpl @@ -0,0 +1,40 @@ + + + + + + + + {{.ForgetPasswordMail.Title}} + + + + + + + + + + + + + + + + + + +
ezBookkeeping
+

{{.ForgetPasswordMail.Salutation}}

+

{{.ForgetPasswordMail.DescriptionAboveBtn}}

+
+ + {{.ForgetPasswordMail.ResetPassword}} + +
+

{{.ForgetPasswordMail.DescriptionBelowBtn}}

+
+ {{.ForgetPasswordMail.ResetPasswordUrl}} +
+ + diff --git a/third-patry-dependencies.json b/third-patry-dependencies.json index 1b75eb63..cbfbe640 100644 --- a/third-patry-dependencies.json +++ b/third-patry-dependencies.json @@ -81,6 +81,12 @@ "url": "https://golang.org/x/crypto", "licenseUrl": "https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.12.0:LICENSE" }, + { + "name": "Gomail", + "copyright": "Copyright (c) 2014 Alexandre Cesaro", + "url": "https://github.com/go-mail/mail", + "licenseUrl": "https://github.com/go-mail/mail/blob/v2.3.1/LICENSE" + }, { "name": "barcode", "copyright": "Copyright (c) 2014 Florian Sundermann",