From 3505ec5a20a8894c0530e249f5be54f5168bbf60 Mon Sep 17 00:00:00 2001 From: MaysWind Date: Sun, 3 Sep 2023 23:45:12 +0800 Subject: [PATCH] fix cannot build docker image when using gitea action --- cmd/user_data.go | 33 +++ cmd/webserver.go | 14 + conf/ezbookkeeping.ini | 12 + pkg/api/authorizations.go | 35 ++- pkg/api/forget_passwords.go | 5 +- pkg/api/users.go | 154 ++++++++++- pkg/cli/user_data.go | 41 ++- pkg/core/token_claims.go | 1 + pkg/errs/error.go | 14 + pkg/errs/token.go | 1 + pkg/errs/user.go | 1 + pkg/locales/base.go | 10 + pkg/locales/en.go | 7 + pkg/locales/zh_hans.go | 9 +- pkg/middlewares/authorization.go | 19 ++ pkg/middlewares/server_settings_cookie.go | 1 + pkg/models/auth_response.go | 7 +- pkg/models/user.go | 19 ++ pkg/services/forget_passwords.go | 2 +- pkg/services/tokens.go | 17 ++ pkg/services/users.go | 71 ++++++ pkg/settings/setting.go | 18 +- pkg/templates/known_template.go | 9 + pkg/templates/template_cache.go | 6 +- pkg/utils/api.go | 10 +- src/lib/server_settings.js | 4 + src/lib/services.js | 21 ++ src/locales/en.js | 13 +- src/locales/zh_Hans.js | 15 +- src/router/desktop.js | 9 + src/stores/index.js | 87 +++++++ src/views/desktop/LoginPage.vue | 5 + src/views/desktop/VerifyEmailPage.vue | 241 ++++++++++++++++++ .../settings/tabs/UserBasicSettingTab.vue | 15 +- templates/email/verify_email.tmpl | 40 +++ 35 files changed, 931 insertions(+), 35 deletions(-) create mode 100644 pkg/templates/known_template.go create mode 100644 src/views/desktop/VerifyEmailPage.vue create mode 100644 templates/email/verify_email.tmpl diff --git a/cmd/user_data.go b/cmd/user_data.go index fdfcd881..fb8b30fb 100644 --- a/cmd/user_data.go +++ b/cmd/user_data.go @@ -112,6 +112,19 @@ var UserData = &cli.Command{ }, }, }, + { + Name: "user-resend-verify-email", + Usage: "Resend user verify email", + Action: resendUserVerifyEmail, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"n"}, + Required: true, + Usage: "Specific user name", + }, + }, + }, { Name: "user-set-email-verified", Usage: "Set user email address verified", @@ -364,6 +377,26 @@ func disableUser(c *cli.Context) error { return nil } +func resendUserVerifyEmail(c *cli.Context) error { + _, err := initializeSystem(c) + + if err != nil { + return err + } + + username := c.String("username") + err = clis.UserData.ResendVerifyEmail(c, username) + + if err != nil { + log.BootErrorf("[user_data.resendUserVerifyEmail] error occurs when resending user verify email") + return err + } + + log.BootInfof("[user_data.resendUserVerifyEmail] verify email for user \"%s\" has been resent", username) + + return nil +} + func setUserEmailVerified(c *cli.Context) error { _, err := initializeSystem(c) diff --git a/cmd/webserver.go b/cmd/webserver.go index c20e9cad..31b4484a 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.EnableUserVerifyEmail { + apiRoute.POST("/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByUnloginUserHandler)) + + emailVerifyRoute := apiRoute.Group("/verify_email") + emailVerifyRoute.Use(bindMiddleware(middlewares.JWTEmailVerifyAuthorization)) + { + emailVerifyRoute.POST("/by_token.json", bindApi(api.Users.UserEmailVerifyHandler)) + } + } + if config.EnableUserForgetPassword { apiRoute.POST("/forget_password/request.json", bindApi(api.ForgetPasswords.UserForgetPasswordRequestHandler)) @@ -226,6 +236,10 @@ func startWebServer(c *cli.Context) error { apiV1Route.GET("/users/profile/get.json", bindApi(api.Users.UserProfileHandler)) apiV1Route.POST("/users/profile/update.json", bindApiWithTokenUpdate(api.Users.UserUpdateProfileHandler, config)) + if config.EnableUserVerifyEmail { + apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler)) + } + // Two Factor Authorization if config.EnableTwoFactor { apiV1Route.GET("/users/2fa/status.json", bindApi(api.TwoFactorAuthorizations.TwoFactorStatusHandler)) diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index edccfc06..e9256876 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -112,6 +112,9 @@ token_expired_time = 2592000 # Temporary token expired seconds (0 - 4294967295), default is 300 (5 minutes) temporary_token_expired_time = 300 +# Email verify token expired seconds (0 - 4294967295), default is 3600 (60 minutes) +email_verify_token_expired_time = 3600 + # Password reset token expired seconds (0 - 4294967295), default is 3600 (60 minutes) password_reset_token_expired_time = 3600 @@ -122,9 +125,18 @@ 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 verify email address +enable_email_verify = false + +# Set to true to require email must be verified when login +enable_force_email_verify = false + # Set to true to allow users to reset password enable_forget_password = true +# Set to true to require email must be verified when use forget password +forget_password_require_email_verify = false + # User avatar provider, supports the following types: # "gravatar": https://gravatar.com # Leave blank if you want to disable user avatar diff --git a/pkg/api/authorizations.go b/pkg/api/authorizations.go index a562d95c..9719c84c 100644 --- a/pkg/api/authorizations.go +++ b/pkg/api/authorizations.go @@ -8,6 +8,7 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/services" + "github.com/mayswind/ezbookkeeping/pkg/settings" ) // AuthorizationsApi represents authorization api @@ -48,6 +49,13 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (interface{}, *err return nil, errs.ErrUserIsDisabled } + if settings.Container.Current.EnableUserForceVerifyEmail && !user.EmailVerified { + log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because user has not verified email", credential.LoginName) + return nil, errs.NewErrorWithContext(errs.ErrEmailIsNotVerified, map[string]string{ + "email": user.Email, + }) + } + err = a.users.UpdateUserLastLoginTime(c, user.Uid) if err != nil { @@ -121,6 +129,16 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.Context) (interfac return nil, errs.ErrUserNotFound } + if user.Disabled { + log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid) + return nil, errs.ErrUserIsDisabled + } + + if settings.Container.Current.EnableUserForceVerifyEmail && !user.EmailVerified { + log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid) + return nil, errs.ErrEmailIsNotVerified + } + oldTokenClaims := c.GetTokenClaims() err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims) @@ -173,6 +191,16 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Cont return nil, errs.ErrUserNotFound } + if user.Disabled { + log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" is disabled", user.Uid) + return nil, errs.ErrUserIsDisabled + } + + if settings.Container.Current.EnableUserForceVerifyEmail && !user.EmailVerified { + log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has not verified email", user.Uid) + return nil, errs.ErrEmailIsNotVerified + } + err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(c, uid, credential.RecoveryCode, user.Salt) if err != nil { @@ -205,8 +233,9 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Cont func (a *AuthorizationsApi) getAuthResponse(token string, need2FA bool, user *models.User) *models.AuthResponse { return &models.AuthResponse{ - Token: token, - Need2FA: need2FA, - User: user.ToUserBasicInfo(), + Token: token, + Need2FA: need2FA, + NeedVerifyEmail: false, + User: user.ToUserBasicInfo(), } } diff --git a/pkg/api/forget_passwords.go b/pkg/api/forget_passwords.go index 39ad23ce..70856f08 100644 --- a/pkg/api/forget_passwords.go +++ b/pkg/api/forget_passwords.go @@ -8,6 +8,7 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/services" + "github.com/mayswind/ezbookkeeping/pkg/settings" ) // ForgetPasswordsApi represents user forget password api @@ -51,7 +52,7 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.Context) ( return nil, errs.ErrUserIsDisabled } - if !user.EmailVerified { + if settings.Container.Current.ForgetPasswordRequireVerifyEmail && !user.EmailVerified { log.WarnfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" has not verified email", user.Uid) return nil, errs.ErrEmailIsNotVerified } @@ -99,7 +100,7 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.Context) (interfac return nil, errs.ErrUserIsDisabled } - if !user.EmailVerified { + if settings.Container.Current.ForgetPasswordRequireVerifyEmail && !user.EmailVerified { log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" has not verified email", user.Uid) return nil, errs.ErrEmailIsNotVerified } diff --git a/pkg/api/users.go b/pkg/api/users.go index 34bcd772..b9e15dc8 100644 --- a/pkg/api/users.go +++ b/pkg/api/users.go @@ -73,8 +73,13 @@ func (a *UsersApi) UserRegisterHandler(c *core.Context) (interface{}, *errs.Erro log.InfofWithRequestId(c, "[users.UserRegisterHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid) authResp := &models.AuthResponse{ - Need2FA: false, - User: user.ToUserBasicInfo(), + Need2FA: false, + NeedVerifyEmail: settings.Container.Current.EnableUserForceVerifyEmail, + User: user.ToUserBasicInfo(), + } + + if authResp.NeedVerifyEmail { + return authResp, nil } token, claims, err := a.tokens.CreateToken(c, user) @@ -93,6 +98,69 @@ func (a *UsersApi) UserRegisterHandler(c *core.Context) (interface{}, *errs.Erro return authResp, nil } +// UserEmailVerifyHandler sets user email address verified +func (a *UsersApi) UserEmailVerifyHandler(c *core.Context) (interface{}, *errs.Error) { + var userVerifyEmailReq models.UserVerifyEmailRequest + err := c.ShouldBindJSON(&userVerifyEmailReq) + + uid := c.GetCurrentUid() + user, err := a.users.GetUserById(c, uid) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[users.UserEmailVerifyHandler] failed to get user, because %s", err.Error()) + } + + return nil, errs.ErrUserNotFound + } + + if user.Disabled { + log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" is disabled", user.Uid) + return nil, errs.ErrUserIsDisabled + } + + if user.EmailVerified { + log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" email has been verified", user.Uid) + return nil, errs.ErrEmailIsVerified + } + + err = a.users.SetUserEmailVerified(c, user.Username) + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserEmailVerifyHandler] failed to update user \"uid:%d\" email address verified, because %s", user.Uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + now := time.Now().Unix() + err = a.tokens.DeleteTokensByTypeBeforeTime(c, uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY, now) + + if err == nil { + log.InfofWithRequestId(c, "[users.UserEmailVerifyHandler] revoke old email verify tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid) + } else { + log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] failed to revoke old email verify tokens for user \"uid:%d\", because %s", user.Uid, err.Error()) + } + + resp := &models.UserVerifyEmailResponse{} + + if userVerifyEmailReq.RequestNewToken { + token, claims, err := a.tokens.CreateToken(c, user) + + if err != nil { + log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return resp, nil + } + + resp.NewToken = token + resp.User = user.ToUserBasicInfo() + c.SetTextualToken(token) + c.SetTokenClaims(claims) + + log.InfofWithRequestId(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" token created, new token will be expired at %d", user.Uid, claims.ExpiresAt) + } + + return resp, nil +} + // UserProfileHandler returns user profile of current user func (a *UsersApi) UserProfileHandler(c *core.Context) (interface{}, *errs.Error) { uid := c.GetCurrentUid() @@ -283,3 +351,85 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs return resp, nil } + +// UserSendVerifyEmailByUnloginUserHandler sends unlogin user verify email +func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.Context) (interface{}, *errs.Error) { + var userResendVerifyEmailReq models.UserResendVerifyEmailRequest + err := c.ShouldBindJSON(&userResendVerifyEmailReq) + + user, err := a.users.GetUserByEmail(c, userResendVerifyEmailReq.Email) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to get user, because %s", err.Error()) + } + + return nil, errs.ErrUserNotFound + } + + if !a.users.IsPasswordEqualsUserPassword(userResendVerifyEmailReq.Password, user) { + log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] request password not equals to the user password") + return nil, errs.ErrUserPasswordWrong + } + + if user.Disabled { + log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" is disabled", user.Uid) + return nil, errs.ErrUserIsDisabled + } + + if user.EmailVerified { + log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" email has been verified", user.Uid) + return nil, errs.ErrEmailIsVerified + } + + token, _, err := a.tokens.CreateEmailVerifyToken(c, user) + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.ErrTokenGenerating + } + + err = a.users.SendVerifyEmail(user, token, c.GetClientLocale()) + + if err != nil { + log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + return true, nil +} + +// UserSendVerifyEmailByLoginedUserHandler sends logined user verify email +func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.Context) (interface{}, *errs.Error) { + uid := c.GetCurrentUid() + user, err := a.users.GetUserById(c, uid) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to get user, because %s", err.Error()) + } + + return nil, errs.ErrUserNotFound + } + + if user.EmailVerified { + log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] user \"uid:%d\" email has been verified", user.Uid) + return nil, errs.ErrEmailIsVerified + } + + token, _, err := a.tokens.CreateEmailVerifyToken(c, user) + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.ErrTokenGenerating + } + + err = a.users.SendVerifyEmail(user, token, c.GetClientLocale()) + + if err != nil { + log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + return true, nil +} diff --git a/pkg/cli/user_data.go b/pkg/cli/user_data.go index 1fc3caf1..503bf3d9 100644 --- a/pkg/cli/user_data.go +++ b/pkg/cli/user_data.go @@ -10,6 +10,7 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/services" + "github.com/mayswind/ezbookkeeping/pkg/settings" "github.com/mayswind/ezbookkeeping/pkg/validators" ) @@ -177,9 +178,9 @@ func (l *UserDataCli) SendPasswordResetMail(c *cli.Context, username string) err return err } - if !user.EmailVerified { + if settings.Container.Current.ForgetPasswordRequireVerifyEmail && !user.EmailVerified { log.BootWarnf("[user_data.SendPasswordResetMail] user \"uid:%d\" has not verified email", user.Uid) - return errs.ErrEmptyIsNotVerified + return errs.ErrEmailIsNotVerified } token, _, err := l.tokens.CreatePasswordResetToken(nil, user) @@ -233,6 +234,42 @@ func (l *UserDataCli) DisableUser(c *cli.Context, username string) error { return nil } +// ResendVerifyEmail resends an email with account activation link +func (l *UserDataCli) ResendVerifyEmail(c *cli.Context, username string) error { + if username == "" { + log.BootErrorf("[user_data.ResendVerifyEmail] user name is empty") + return errs.ErrUsernameIsEmpty + } + + user, err := l.users.GetUserByUsername(nil, username) + + if err != nil { + log.BootErrorf("[user_data.ResendVerifyEmail] failed to get user by user name \"%s\", because %s", username, err.Error()) + return err + } + + if user.EmailVerified { + log.BootWarnf("[user_data.ResendVerifyEmail] user \"uid:%d\" email has been verified", user.Uid) + return errs.ErrEmailIsVerified + } + + token, _, err := l.tokens.CreateEmailVerifyToken(nil, user) + + if err != nil { + log.BootErrorf("[user_data.ResendVerifyEmail] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error()) + return errs.ErrTokenGenerating + } + + err = l.users.SendVerifyEmail(user, token, "") + + if err != nil { + log.BootErrorf("[user_data.ResendVerifyEmail] cannot send email to \"%s\", because %s", user.Email, err.Error()) + return err + } + + return nil +} + // SetUserEmailVerified sets user email address verified func (l *UserDataCli) SetUserEmailVerified(c *cli.Context, username string) error { if username == "" { diff --git a/pkg/core/token_claims.go b/pkg/core/token_claims.go index c41c53be..33dca977 100644 --- a/pkg/core/token_claims.go +++ b/pkg/core/token_claims.go @@ -13,6 +13,7 @@ type TokenType byte const ( USER_TOKEN_TYPE_NORMAL TokenType = 1 USER_TOKEN_TYPE_REQUIRE_2FA TokenType = 2 + USER_TOKEN_TYPE_EMAIL_VERIFY TokenType = 3 USER_TOKEN_TYPE_PASSWORD_RESET TokenType = 4 ) diff --git a/pkg/errs/error.go b/pkg/errs/error.go index 08bc341a..11c546c0 100644 --- a/pkg/errs/error.go +++ b/pkg/errs/error.go @@ -38,6 +38,7 @@ type Error struct { HttpStatusCode int Message string BaseError []error + Context interface{} } // Error returns the error message @@ -81,6 +82,19 @@ func NewIncompleteOrIncorrectSubmissionError(err error) *Error { ErrIncompleteOrIncorrectSubmission.Message, err) } +// NewErrorWithContext returns a new error instance with specified context +func NewErrorWithContext(baseError *Error, context interface{}) *Error { + return &Error{ + Category: baseError.Category, + SubCategory: baseError.SubCategory, + Index: baseError.Index, + HttpStatusCode: baseError.HttpStatusCode, + Message: baseError.Message, + BaseError: baseError.BaseError, + Context: context, + } +} + // Or would return the error from err parameter if the this error is defined in this project, // or return the default error func Or(err error, defaultErr *Error) *Error { diff --git a/pkg/errs/token.go b/pkg/errs/token.go index 5c6728ee..14c4a8de 100644 --- a/pkg/errs/token.go +++ b/pkg/errs/token.go @@ -19,5 +19,6 @@ var ( 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") + ErrEmailVerifyTokenIsInvalidOrExpired = NewNormalError(NormalSubcategoryToken, 13, http.StatusBadRequest, "email verify token is invalid or expired") ErrPasswordResetTokenIsInvalidOrExpired = NewNormalError(NormalSubcategoryToken, 14, http.StatusBadRequest, "password reset token is invalid or expired") ) diff --git a/pkg/errs/user.go b/pkg/errs/user.go index 67ea747d..677bb35d 100644 --- a/pkg/errs/user.go +++ b/pkg/errs/user.go @@ -27,4 +27,5 @@ var ( ErrEmailIsEmptyOrInvalid = NewNormalError(NormalSubcategoryUser, 18, http.StatusBadRequest, "email is empty or invalid") ErrNewPasswordEqualsOldInvalid = NewNormalError(NormalSubcategoryUser, 19, http.StatusBadRequest, "new password equals old password") ErrEmailIsNotVerified = NewNormalError(NormalSubcategoryUser, 20, http.StatusBadRequest, "email is not verified") + ErrEmailIsVerified = NewNormalError(NormalSubcategoryUser, 21, http.StatusBadRequest, "email is verified") ) diff --git a/pkg/locales/base.go b/pkg/locales/base.go index d19e8406..f982e7d0 100644 --- a/pkg/locales/base.go +++ b/pkg/locales/base.go @@ -2,9 +2,19 @@ package locales // LocaleTextItems represents all text items need to be translated type LocaleTextItems struct { + VerifyEmailTextItems *VerifyEmailTextItems ForgetPasswordMailTextItems *ForgetPasswordMailTextItems } +// VerifyEmailTextItems represents text items need to be translated in verify mail +type VerifyEmailTextItems struct { + Title string + SalutationFormat string + DescriptionAboveBtn string + VerifyEmail string + DescriptionBelowBtnFormat string +} + // ForgetPasswordMailTextItems represents text items need to be translated in forget password mail type ForgetPasswordMailTextItems struct { Title string diff --git a/pkg/locales/en.go b/pkg/locales/en.go index f9af6773..733eba62 100644 --- a/pkg/locales/en.go +++ b/pkg/locales/en.go @@ -1,6 +1,13 @@ package locales var en = &LocaleTextItems{ + VerifyEmailTextItems: &VerifyEmailTextItems{ + Title: "Verify Email", + SalutationFormat: "Hi %s,", + DescriptionAboveBtn: "Please click the link below to confirm your email address.", + VerifyEmail: "Verify Email", + DescriptionBelowBtnFormat: "If you did not sign up for %s account, please simply disregard this email. If you cannot click the link above, please copy the above url and paste it into your browser. The verify email link will be expired after %v minutes.", + }, ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{ Title: "Reset Your Password", SalutationFormat: "Hi %s,", diff --git a/pkg/locales/zh_hans.go b/pkg/locales/zh_hans.go index 93e7d9a6..050a4a40 100644 --- a/pkg/locales/zh_hans.go +++ b/pkg/locales/zh_hans.go @@ -1,9 +1,16 @@ package locales var zhHans = &LocaleTextItems{ + VerifyEmailTextItems: &VerifyEmailTextItems{ + Title: "验证邮箱", + SalutationFormat: "%s 您好,", + DescriptionAboveBtn: "请点击下方的链接确认您的邮箱地址。", + VerifyEmail: "验证邮箱", + DescriptionBelowBtnFormat: "如果您没有注册 %s 账户,请直接忽略本邮件。如果您无法点击上述链接,请复制下方的地址然后在您的浏览器中粘贴。邮箱验证链接将在 %v 分钟后过期。", + }, ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{ Title: "重置密码", - SalutationFormat: "%s 你好,", + SalutationFormat: "%s 您好,", DescriptionAboveBtn: "我们刚才收到重置您密码的请求。您可以点击下方链接重置您的密码。", ResetPassword: "重置密码", DescriptionBelowBtnFormat: "如果您没有请求重置密码,请直接忽略本邮件。如果您无法点击上述链接,请复制下方的地址然后在您的浏览器中粘贴。重置密码链接将在 %v 分钟后过期。", diff --git a/pkg/middlewares/authorization.go b/pkg/middlewares/authorization.go index 618998a9..7ddea3ce 100644 --- a/pkg/middlewares/authorization.go +++ b/pkg/middlewares/authorization.go @@ -56,6 +56,25 @@ func JWTTwoFactorAuthorization(c *core.Context) { c.Next() } +// JWTEmailVerifyAuthorization verifies whether current request is email verification +func JWTEmailVerifyAuthorization(c *core.Context) { + claims, err := getTokenClaims(c, TOKEN_SOURCE_TYPE_ARGUMENT) + + if err != nil { + utils.PrintJsonErrorResult(c, errs.ErrEmailVerifyTokenIsInvalidOrExpired) + return + } + + if claims.Type != core.USER_TOKEN_TYPE_EMAIL_VERIFY { + log.WarnfWithRequestId(c, "[authorization.JWTEmailVerifyAuthorization] user \"uid:%d\" token is not for email verification", claims.Uid) + utils.PrintJsonErrorResult(c, errs.ErrCurrentInvalidToken) + return + } + + c.SetTokenClaims(claims) + c.Next() +} + // JWTResetPasswordAuthorization verifies whether current request is password reset func JWTResetPasswordAuthorization(c *core.Context) { claims, err := getTokenClaims(c, TOKEN_SOURCE_TYPE_ARGUMENT) diff --git a/pkg/middlewares/server_settings_cookie.go b/pkg/middlewares/server_settings_cookie.go index fb88552e..6eba2745 100644 --- a/pkg/middlewares/server_settings_cookie.go +++ b/pkg/middlewares/server_settings_cookie.go @@ -18,6 +18,7 @@ func ServerSettingsCookie(config *settings.Config) core.MiddlewareHandlerFunc { settingsArr := []string{ buildBooleanSetting("r", config.EnableUserRegister), buildBooleanSetting("f", config.EnableUserForgetPassword), + buildBooleanSetting("v", config.EnableUserVerifyEmail), buildBooleanSetting("e", config.EnableDataExport), buildStringSetting("m", strings.Replace(config.MapProvider, "_", "-", -1)), } diff --git a/pkg/models/auth_response.go b/pkg/models/auth_response.go index 10234861..0d634cc9 100644 --- a/pkg/models/auth_response.go +++ b/pkg/models/auth_response.go @@ -2,7 +2,8 @@ package models // AuthResponse returns a view-object of user authorization type AuthResponse struct { - Token string `json:"token"` - Need2FA bool `json:"need2FA"` - User *UserBasicInfo `json:"user"` + Token string `json:"token"` + Need2FA bool `json:"need2FA"` + NeedVerifyEmail bool `json:"needVerifyEmail"` + User *UserBasicInfo `json:"user"` } diff --git a/pkg/models/user.go b/pkg/models/user.go index 17de38e3..336ee440 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -108,6 +108,23 @@ type UserRegisterRequest struct { FirstDayOfWeek WeekDay `json:"firstDayOfWeek" binding:"min=0,max=6"` } +// UserVerifyEmailRequest represents all parameters of user verify email request +type UserVerifyEmailRequest struct { + RequestNewToken bool `json:"requestNewToken" binding:"omitempty"` +} + +// UserVerifyEmailResponse represents all response parameters after user have verified email +type UserVerifyEmailResponse struct { + NewToken string `json:"newToken,omitempty"` + User *UserBasicInfo `json:"user"` +} + +// UserResendVerifyEmailRequest represents all parameters of user resend verify email request +type UserResendVerifyEmailRequest struct { + Email string `json:"email" binding:"omitempty,max=100,validEmail"` + Password string `json:"password" binding:"omitempty,min=6,max=128"` +} + // UserProfileUpdateRequest represents all parameters of user updating profile request type UserProfileUpdateRequest struct { Email string `json:"email" binding:"omitempty,notBlank,max=100,validEmail"` @@ -147,6 +164,7 @@ type UserProfileResponse struct { ShortDateFormat ShortDateFormat `json:"shortDateFormat"` LongTimeFormat LongTimeFormat `json:"longTimeFormat"` ShortTimeFormat ShortTimeFormat `json:"shortTimeFormat"` + EmailVerified bool `json:"emailVerified"` LastLoginAt int64 `json:"lastLoginAt"` } @@ -229,6 +247,7 @@ func (u *User) ToUserProfileResponse() *UserProfileResponse { ShortDateFormat: u.ShortDateFormat, LongTimeFormat: u.LongTimeFormat, ShortTimeFormat: u.ShortTimeFormat, + EmailVerified: u.EmailVerified, LastLoginAt: u.LastLoginUnixTime, } } diff --git a/pkg/services/forget_passwords.go b/pkg/services/forget_passwords.go index ebe60fdd..36658366 100644 --- a/pkg/services/forget_passwords.go +++ b/pkg/services/forget_passwords.go @@ -52,7 +52,7 @@ func (s *ForgetPasswordService) SendPasswordResetEmail(c *core.Context, user *mo expireTimeInMinutes := s.CurrentConfig().PasswordResetTokenExpiredTimeDuration.Minutes() passwordResetUrl := fmt.Sprintf(passwordResetUrlFormat, s.CurrentConfig().RootUrl, url.QueryEscape(passwordResetToken)) - tmpl, err := templates.GetTemplate("email/password_reset") + tmpl, err := templates.GetTemplate(templates.TEMPLATE_PASSWORD_RESET) if err != nil { return err diff --git a/pkg/services/tokens.go b/pkg/services/tokens.go index 87134a7e..ea25be64 100644 --- a/pkg/services/tokens.go +++ b/pkg/services/tokens.go @@ -88,6 +88,11 @@ func (s *TokenService) CreateRequire2FAToken(c *core.Context, user *models.User) return s.createToken(c, user, core.USER_TOKEN_TYPE_REQUIRE_2FA, s.getUserAgent(c), s.CurrentConfig().TemporaryTokenExpiredTimeDuration) } +// CreateEmailVerifyToken generates a new email verify token and saves to database +func (s *TokenService) CreateEmailVerifyToken(c *core.Context, user *models.User) (string, *core.UserTokenClaims, error) { + return s.createToken(c, user, core.USER_TOKEN_TYPE_EMAIL_VERIFY, s.getUserAgent(c), s.CurrentConfig().EmailVerifyTokenExpiredTimeDuration) +} + // CreatePasswordResetToken generates a new password reset token and saves to database func (s *TokenService) CreatePasswordResetToken(c *core.Context, user *models.User) (string, *core.UserTokenClaims, error) { return s.createToken(c, user, core.USER_TOKEN_TYPE_PASSWORD_RESET, s.getUserAgent(c), s.CurrentConfig().PasswordResetTokenExpiredTimeDuration) @@ -165,6 +170,18 @@ func (s *TokenService) DeleteTokensBeforeTime(c *core.Context, uid int64, expire }) } +// DeleteTokensByTypeBeforeTime deletes tokens that is specified type and created before specific time +func (s *TokenService) DeleteTokensByTypeBeforeTime(c *core.Context, uid int64, tokenType core.TokenType, expireTime int64) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + return s.TokenDB(uid).DoTransaction(c, func(sess *xorm.Session) error { + _, err := sess.Where("uid=? AND token_type=? AND created_unix_time { + return axios.post('verify_email/by_token.json?token=' + token, { + requestNewToken + }, { + noAuth: true, + ignoreError: true + }); + }, + resendVerifyEmailByUnloginUser: ({ email, password }) => { + return axios.post('verify_email/resend.json', { + email, + password + }, { + timeout: api.requestForgetPasswordTimeout + }); + }, requestResetPassword: ({ email }) => { return axios.post('forget_password/request.json', { email @@ -173,6 +189,11 @@ export default { shortTimeFormat }); }, + resendVerifyEmailByLoginedUser: () => { + return axios.post('v1/users/verify_email/resend.json', {}, { + timeout: api.requestForgetPasswordTimeout + }); + }, get2FAStatus: () => { return axios.get('v1/users/2fa/status.json'); }, diff --git a/src/locales/en.js b/src/locales/en.js index 4c79816e..20ab20eb 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -585,6 +585,7 @@ export default { 'email is empty or invalid': 'Email is empty or invalid', 'new password equals old password': 'New password equals old password', 'email is not verified': 'Email is not verified', + 'email is verified': 'Email is verified', 'unauthorized access': 'Unauthorized access', 'current token is invalid': 'Current token is invalid', 'current token is expired': 'Current token is expired', @@ -597,6 +598,7 @@ export default { 'token is not found': 'Token is not found', 'token is expired': 'Token is expired', 'token is empty': 'Token is empty', + 'email verify token is invalid or expired': 'Email verify token is invalid or expired', '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', @@ -847,6 +849,14 @@ export default { 'Use a passcode': 'Use a passcode', 'PIN code is invalid': 'PIN code is invalid', 'PIN code is wrong': 'PIN code is wrong', + 'Verify your email': 'Verify your email', + 'Verifying...': 'Verifying...', + 'Account activation link has been sent to your email address:': 'Account activation link has been sent to your email address:', + ', If you don\'t receive the mail, fill password and click the button below to resend the verify mail.': ', If you don\'t receive the mail, fill password and click the button below to resend the verify mail.', + 'Resend Validation Email': 'Resend Validation Email', + 'Validation email has been sent': 'Validation email has been sent', + 'Unable to verify email': 'Unable to verify email', + 'Unable to resend verify email': 'Unable to resend verify email', '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', @@ -1055,8 +1065,9 @@ export default { 'Basic Settings': 'Basic Settings', 'Security Settings': 'Security Settings', 'Two-Factor Authentication Settings': 'Two-Factor Authentication Settings', + 'Email has been verified': 'Email has been verified', + 'Email has not been verified': 'Email has not been verified', 'Username:': 'Username:', - 'Avatar Provider:': 'Avatar Provider:', 'Current Password': 'Current Password', 'New Password': 'New Password', 'Modify Password': 'Modify Password', diff --git a/src/locales/zh_Hans.js b/src/locales/zh_Hans.js index c11f6ec4..d7b1b4de 100644 --- a/src/locales/zh_Hans.js +++ b/src/locales/zh_Hans.js @@ -584,7 +584,8 @@ export default { 'email is invalid': '邮箱无效', 'email is empty or invalid': '邮箱为空或无效', 'new password equals old password': '新密码与旧密码相同', - 'email is not verified': '邮箱没有验证过', + 'email is not verified': '邮箱还未验证通过', + 'email is verified': '邮箱已经验证过', 'unauthorized access': '未授权的登录', 'current token is invalid': '当前认证令牌无效', 'current token is expired': '当前认证令牌已过期', @@ -597,6 +598,7 @@ export default { 'token is not found': '认证令牌不存在', 'token is expired': '认证令牌已过期', 'token is empty': '认证令牌为空', + 'email verify token is invalid or expired': '邮箱验证令牌无效或已过期', 'password reset token is invalid or expired': '密码重置令牌无效或已过期', 'passcode is invalid': '验证码无效', 'two factor backup code is invalid': '两步验证备用码无效', @@ -847,6 +849,14 @@ export default { 'Use a passcode': '使用验证码', 'PIN code is invalid': 'PIN码无效', 'PIN code is wrong': 'PIN码错误', + 'Verify your email': '验证您的邮箱', + 'Verifying...': '正在验证...', + 'Account activation link has been sent to your email address:': '账号激活链接已经发送到您的邮箱地址:', + ', If you don\'t receive the mail, fill password and click the button below to resend the verify mail.': ',如果您没有收到邮件,输入密码并点击下方的按钮重新发送验证邮件。', + 'Resend Validation Email': '重发验证邮件', + 'Validation email has been sent': '验证邮件已发送', + 'Unable to verify email': '无法验证邮箱', + 'Unable to resend verify email': '无法重新发送验证邮件', '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': '重置密码邮件已发送', @@ -1055,8 +1065,9 @@ export default { 'Basic Settings': '基本设置', 'Security Settings': '安全设置', 'Two-Factor Authentication Settings': '两步验证设置', + 'Email has been verified': '邮箱地址已验证', + 'Email has not been verified': '邮箱地址未验证', 'Username:': '用户名:', - 'Avatar Provider:': '头像提供方:', 'Current Password': '当前密码', 'New Password': '新密码', 'Modify Password': '修改密码', diff --git a/src/router/desktop.js b/src/router/desktop.js index 15964df7..1f4bc342 100644 --- a/src/router/desktop.js +++ b/src/router/desktop.js @@ -5,6 +5,7 @@ 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 VerifyEmailPage from '@/views/desktop/VerifyEmailPage.vue'; import ForgetPasswordPage from '@/views/desktop/ForgetPasswordPage.vue'; import ResetPasswordPage from '@/views/desktop/ResetPasswordPage.vue'; import UnlockPage from '@/views/desktop/UnlockPage.vue'; @@ -159,6 +160,14 @@ const router = createRouter({ component: SignUpPage, beforeEnter: checkNotLogin }, + { + path: '/verify_email', + component: VerifyEmailPage, + props: route => ({ + email: route.query.email, + token: route.query.token + }) + }, { path: '/forgetpassword', component: ForgetPasswordPage, diff --git a/src/stores/index.js b/src/stores/index.js index e3099ef3..0e5f1205 100644 --- a/src/stores/index.js +++ b/src/stores/index.js @@ -247,6 +247,69 @@ export const useRootStore = defineStore('root', { userState.clearWebAuthnConfig(); this.resetAllStates(true); }, + verifyEmail({ token, requestNewToken }) { + return new Promise((resolve, reject) => { + services.verifyEmail({ + token, + requestNewToken + }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to verify email' }); + return; + } + + if (data.result.newToken && isString(data.result.newToken)) { + userState.updateToken(data.result.newToken); + } + + if (data.result.user && isObject(data.result.user)) { + const userStore = useUserStore(); + userStore.storeUserInfo(data.result.user); + } + + resolve(data.result); + }).catch(error => { + logger.error('failed to verify 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 verify email' }); + } + }); + }); + }, + resendVerifyEmailByUnloginUser({ email, password }) { + return new Promise((resolve, reject) => { + services.resendVerifyEmailByUnloginUser({ + email, + password + }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to resend verify email' }); + return; + } + + resolve(data.result); + }).catch(error => { + logger.error('failed to resend verify 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 resend verify email' }); + } + }); + }); + }, requestResetPassword({ email }) { return new Promise((resolve, reject) => { services.requestResetPassword({ @@ -363,6 +426,30 @@ export const useRootStore = defineStore('root', { }); }); }, + resendVerifyEmailByLoginedUser() { + return new Promise((resolve, reject) => { + services.resendVerifyEmailByLoginedUser().then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to resend verify email' }); + return; + } + + resolve(data.result); + }).catch(error => { + logger.error('failed to resend verify 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 resend verify email' }); + } + }); + }); + }, clearUserData({ password }) { return new Promise((resolve, reject) => { services.clearData({ diff --git a/src/views/desktop/LoginPage.vue b/src/views/desktop/LoginPage.vue index 8b9484a6..5a5e5390 100644 --- a/src/views/desktop/LoginPage.vue +++ b/src/views/desktop/LoginPage.vue @@ -312,6 +312,11 @@ export default { }).catch(error => { self.logining = false; + if (error.error && error.error.errorCode === 201020 && error.error.context && error.error.context.email) { + self.$router.push('/verify_email?email=' + encodeURIComponent(error.error.context.email)); + return; + } + if (!error.processed) { self.$refs.snackbar.showError(error); } diff --git a/src/views/desktop/VerifyEmailPage.vue b/src/views/desktop/VerifyEmailPage.vue new file mode 100644 index 00000000..1f5694f2 --- /dev/null +++ b/src/views/desktop/VerifyEmailPage.vue @@ -0,0 +1,241 @@ + + + diff --git a/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue b/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue index 2bbd4ded..20ba9a96 100644 --- a/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue +++ b/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue @@ -25,9 +25,9 @@ {{ oldProfile.username }}
- {{ $t('Avatar Provider:') }} - - {{ currentUserAvatarProvider }} + {{ $t('Email has been verified') }} + {{ $t('Email has not been verified') }} +
@@ -268,6 +268,7 @@ export default { longTimeFormat: 0, shortTimeFormat: 0 }, + emailVerified: false, loading: true, saving: false, icons: { @@ -310,13 +311,6 @@ export default { allTransactionEditScopeTypes() { return this.$locale.getAllTransactionEditScopeTypes(); }, - currentUserAvatarProvider() { - if (this.oldProfile.avatarProvider === 'gravatar') { - return 'Gravatar'; - } else { - return this.$t('None'); - } - }, inputIsNotChanged() { return !!this.inputIsNotChangedProblemMessage; }, @@ -383,6 +377,7 @@ export default { Promise.all(promises).then(responses => { const profile = responses[1]; self.setCurrentUserProfile(profile); + self.emailVerified = profile.emailVerified; self.loading = false; }).catch(error => { self.oldProfile.nickname = ''; diff --git a/templates/email/verify_email.tmpl b/templates/email/verify_email.tmpl new file mode 100644 index 00000000..813838eb --- /dev/null +++ b/templates/email/verify_email.tmpl @@ -0,0 +1,40 @@ + + + + + + + + {{.VerifyEmail.Title}} + + + + + + + + + + + + + + + + + + +
{{.AppName}}
+

{{.VerifyEmail.Salutation}}

+

{{.VerifyEmail.DescriptionAboveBtn}}

+
+ + {{.VerifyEmail.VerifyEmail}} + +
+

{{.VerifyEmail.DescriptionBelowBtn}}

+
+ {{.VerifyEmail.VerifyEmailUrl}} +
+ +