From 6ad33e3c3be109ee14f3e4c70764600fdcfa0787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Mon, 5 Apr 2021 22:57:57 +0200 Subject: [PATCH] feat: password resets (resolve #133) --- config/config.go | 8 ++- config/templates.go | 14 ++-- main.go | 5 +- models/user.go | 16 +++++ models/view/login.go | 5 ++ repositories/repositories.go | 2 + repositories/user.go | 22 ++++++ routes/home.go | 1 + routes/login.go | 125 +++++++++++++++++++++++++++++++++- services/mail/mail.go | 23 +++++++ services/services.go | 3 + services/user.go | 12 ++++ views/login.tpl.html | 21 ++++-- views/reset-password.tpl.html | 42 ++++++++++++ views/set-password.tpl.html | 44 ++++++++++++ views/settings.tpl.html | 2 +- views/signup.tpl.html | 9 ++- 17 files changed, 335 insertions(+), 19 deletions(-) create mode 100644 services/mail/mail.go create mode 100644 views/reset-password.tpl.html create mode 100644 views/set-password.tpl.html diff --git a/config/config.go b/config/config.go index 104edfb..f387ff7 100644 --- a/config/config.go +++ b/config/config.go @@ -49,7 +49,13 @@ const ( WakatimeApiMachineNamesUrl = "/users/current/machine_names" ) -var emailProviders = []string{"mailwhale"} +const ( + MailProviderMailWhale = "mailwhale" +) + +var emailProviders = []string{ + MailProviderMailWhale, +} var cfg *Config var cFlag = flag.String("config", defaultConfigPath, "config file location") diff --git a/config/templates.go b/config/templates.go index 0463154..244d1d7 100644 --- a/config/templates.go +++ b/config/templates.go @@ -1,10 +1,12 @@ package config const ( - IndexTemplate = "index.tpl.html" - LoginTemplate = "login.tpl.html" - ImprintTemplate = "imprint.tpl.html" - SignupTemplate = "signup.tpl.html" - SettingsTemplate = "settings.tpl.html" - SummaryTemplate = "summary.tpl.html" + IndexTemplate = "index.tpl.html" + LoginTemplate = "login.tpl.html" + ImprintTemplate = "imprint.tpl.html" + SignupTemplate = "signup.tpl.html" + SetPasswordTemplate = "set-password.tpl.html" + ResetPasswordTemplate = "reset-password.tpl.html" + SettingsTemplate = "settings.tpl.html" + SummaryTemplate = "summary.tpl.html" ) diff --git a/main.go b/main.go index 5cde124..dfb478e 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "github.com/muety/wakapi/migrations" "github.com/muety/wakapi/repositories" "github.com/muety/wakapi/routes/api" + "github.com/muety/wakapi/services/mail" "github.com/muety/wakapi/utils" "gorm.io/gorm/logger" "log" @@ -51,6 +52,7 @@ var ( languageMappingService services.ILanguageMappingService summaryService services.ISummaryService aggregationService services.IAggregationService + mailService services.IMailService keyValueService services.IKeyValueService miscService services.IMiscService ) @@ -134,6 +136,7 @@ func main() { heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService) summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService) aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService) + mailService = mail.NewMailService() keyValueService = services.NewKeyValueService(keyValueRepository) miscService = services.NewMiscService(userService, summaryService, keyValueService) @@ -159,7 +162,7 @@ func main() { summaryHandler := routes.NewSummaryHandler(summaryService, userService) settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService) homeHandler := routes.NewHomeHandler(keyValueService) - loginHandler := routes.NewLoginHandler(userService) + loginHandler := routes.NewLoginHandler(userService, mailService) imprintHandler := routes.NewImprintHandler(keyValueService) // Setup Routers diff --git a/models/user.go b/models/user.go index a8cc6e4..71bdd8c 100644 --- a/models/user.go +++ b/models/user.go @@ -30,6 +30,7 @@ type User struct { IsAdmin bool `json:"-" gorm:"default:false; type:bool"` HasData bool `json:"-" gorm:"default:false; type:bool"` WakatimeApiKey string `json:"-"` + ResetToken string `json:"-"` } type Login struct { @@ -44,6 +45,16 @@ type Signup struct { PasswordRepeat string `schema:"password_repeat"` } +type SetPasswordRequest struct { + Password string `schema:"password"` + PasswordRepeat string `schema:"password_repeat"` + Token string `schema:"token"` +} + +type ResetPasswordRequest struct { + Email string `schema:"email"` +} + type CredentialsReset struct { PasswordOld string `schema:"password_old"` PasswordNew string `schema:"password_new"` @@ -69,6 +80,11 @@ func (c *CredentialsReset) IsValid() bool { c.PasswordNew == c.PasswordRepeat } +func (c *SetPasswordRequest) IsValid() bool { + return ValidatePassword(c.Password) && + c.Password == c.PasswordRepeat +} + func (s *Signup) IsValid() bool { return ValidateUsername(s.Username) && ValidateEmail(s.Email) && diff --git a/models/view/login.go b/models/view/login.go index 57b62b0..450c5d0 100644 --- a/models/view/login.go +++ b/models/view/login.go @@ -6,6 +6,11 @@ type LoginViewModel struct { TotalUsers int } +type SetPasswordViewModel struct { + LoginViewModel + Token string +} + func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel { s.Success = m return s diff --git a/repositories/repositories.go b/repositories/repositories.go index 7bceac2..111197c 100644 --- a/repositories/repositories.go +++ b/repositories/repositories.go @@ -51,6 +51,8 @@ type IUserRepository interface { GetById(string) (*models.User, error) GetByIds([]string) ([]*models.User, error) GetByApiKey(string) (*models.User, error) + GetByEmail(string) (*models.User, error) + GetByResetToken(string) (*models.User, error) GetAll() ([]*models.User, error) GetByLoggedInAfter(time.Time) ([]*models.User, error) GetByLastActiveAfter(time.Time) ([]*models.User, error) diff --git a/repositories/user.go b/repositories/user.go index fb8f305..58e7aef 100644 --- a/repositories/user.go +++ b/repositories/user.go @@ -42,6 +42,28 @@ func (r *UserRepository) GetByApiKey(key string) (*models.User, error) { return u, nil } +func (r *UserRepository) GetByResetToken(resetToken string) (*models.User, error) { + if resetToken == "" { + return nil, errors.New("invalid input") + } + u := &models.User{} + if err := r.db.Where(&models.User{ResetToken: resetToken}).First(u).Error; err != nil { + return u, err + } + return u, nil +} + +func (r *UserRepository) GetByEmail(email string) (*models.User, error) { + if email == "" { + return nil, errors.New("invalid input") + } + u := &models.User{} + if err := r.db.Where(&models.User{Email: email}).First(u).Error; err != nil { + return u, err + } + return u, nil +} + func (r *UserRepository) GetAll() ([]*models.User, error) { var users []*models.User if err := r.db. diff --git a/routes/home.go b/routes/home.go index ab82bf1..f427327 100644 --- a/routes/home.go +++ b/routes/home.go @@ -20,6 +20,7 @@ type HomeHandler struct { var loginDecoder = schema.NewDecoder() var signupDecoder = schema.NewDecoder() +var resetPasswordDecoder = schema.NewDecoder() func NewHomeHandler(keyValueService services.IKeyValueService) *HomeHandler { return &HomeHandler{ diff --git a/routes/login.go b/routes/login.go index 9c140cb..2ac8bcb 100644 --- a/routes/login.go +++ b/routes/login.go @@ -2,6 +2,7 @@ package routes import ( "fmt" + "github.com/emvi/logbuch" "github.com/gorilla/mux" conf "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" @@ -9,18 +10,21 @@ import ( "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" "net/http" + "net/url" "time" ) type LoginHandler struct { config *conf.Config userSrvc services.IUserService + mailSrvc services.IMailService } -func NewLoginHandler(userService services.IUserService) *LoginHandler { +func NewLoginHandler(userService services.IUserService, mailService services.IMailService) *LoginHandler { return &LoginHandler{ config: conf.Get(), userSrvc: userService, + mailSrvc: mailService, } } @@ -30,6 +34,10 @@ func (h *LoginHandler) RegisterRoutes(router *mux.Router) { router.Path("/logout").Methods(http.MethodPost).HandlerFunc(h.PostLogout) router.Path("/signup").Methods(http.MethodGet).HandlerFunc(h.GetSignup) router.Path("/signup").Methods(http.MethodPost).HandlerFunc(h.PostSignup) + router.Path("/set-password").Methods(http.MethodGet).HandlerFunc(h.GetSetPassword) + router.Path("/set-password").Methods(http.MethodPost).HandlerFunc(h.PostSetPassword) + router.Path("/reset-password").Methods(http.MethodGet).HandlerFunc(h.GetResetPassword) + router.Path("/reset-password").Methods(http.MethodPost).HandlerFunc(h.PostResetPassword) } func (h *LoginHandler) GetIndex(w http.ResponseWriter, r *http.Request) { @@ -167,6 +175,121 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "account created successfully"), http.StatusFound) } +func (h *LoginHandler) GetResetPassword(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r)) +} + +func (h *LoginHandler) GetSetPassword(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + + values, _ := url.ParseQuery(r.URL.RawQuery) + token := values.Get("token") + if token == "" { + w.WriteHeader(http.StatusUnauthorized) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid or missing token")) + return + } + + vm := &view.SetPasswordViewModel{ + LoginViewModel: *h.buildViewModel(r), + Token: token, + } + + templates[conf.SetPasswordTemplate].Execute(w, vm) +} + +func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + + var setRequest models.SetPasswordRequest + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters")) + return + } + if err := signupDecoder.Decode(&setRequest, r.PostForm); err != nil { + w.WriteHeader(http.StatusBadRequest) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters")) + return + } + + user, err := h.userSrvc.GetUserByResetToken(setRequest.Token) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid token")) + return + } + + if !setRequest.IsValid() { + w.WriteHeader(http.StatusBadRequest) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters")) + return + } + + user.Password = setRequest.Password + if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil { + w.WriteHeader(http.StatusInternalServerError) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to set new password")) + return + } else { + user.Password = hash + } + + if _, err := h.userSrvc.Update(user); err != nil { + w.WriteHeader(http.StatusInternalServerError) + templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to save new password")) + return + } + + http.Redirect(w, r, fmt.Sprintf("%s/login?success=%s", h.config.Server.BasePath, "password updated successfully"), http.StatusFound) +} + +func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + + var resetRequest models.ResetPasswordRequest + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters")) + return + } + if err := resetPasswordDecoder.Decode(&resetRequest, r.PostForm); err != nil { + w.WriteHeader(http.StatusBadRequest) + templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters")) + return + } + + if user, err := h.userSrvc.GetUserByEmail(resetRequest.Email); user != nil && err == nil { + if u, err := h.userSrvc.GenerateResetToken(user); err != nil { + w.WriteHeader(http.StatusInternalServerError) + templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate password reset token")) + return + } else { + go func(user *models.User) { + link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken) + if err := h.mailSrvc.SendPasswordResetMail(user, link); err != nil { + logbuch.Error("failed to send password reset mail to %s", user.ID) + } else { + logbuch.Info("sent password reset mail to %s", user.ID) + } + }(u) + } + } else { + logbuch.Warn("password reset requested for unregistered address '%s'", resetRequest.Email) + } + + http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "an e-mail was sent to you in case your e-mail address was registered"), http.StatusFound) +} + func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel { numUsers, _ := h.userSrvc.Count() diff --git a/services/mail/mail.go b/services/mail/mail.go new file mode 100644 index 0000000..a40d1a6 --- /dev/null +++ b/services/mail/mail.go @@ -0,0 +1,23 @@ +package mail + +import ( + "github.com/emvi/logbuch" + conf "github.com/muety/wakapi/config" + "github.com/muety/wakapi/models" + "github.com/muety/wakapi/services" +) + +func NewMailService() services.IMailService { + config := conf.Get() + if config.Mail.Provider == conf.MailProviderMailWhale { + return NewMailWhaleService(config.Mail.MailWhale) + } + return &NoopMailService{} +} + +type NoopMailService struct{} + +func (n NoopMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error { + logbuch.Info("noop mail service doing nothing instead of sending password reset mail to %s", recipient.ID) + return nil +} diff --git a/services/services.go b/services/services.go index c4856da..0242cd7 100644 --- a/services/services.go +++ b/services/services.go @@ -64,6 +64,8 @@ type ISummaryService interface { type IUserService interface { GetUserById(string) (*models.User, error) GetUserByKey(string) (*models.User, error) + GetUserByEmail(string) (*models.User, error) + GetUserByResetToken(string) (*models.User, error) GetAll() ([]*models.User, error) GetActive() ([]*models.User, error) Count() (int64, error) @@ -73,6 +75,7 @@ type IUserService interface { ResetApiKey(*models.User) (*models.User, error) SetWakatimeApiKey(*models.User, string) (*models.User, error) MigrateMd5Password(*models.User, *models.Login) (*models.User, error) + GenerateResetToken(*models.User) (*models.User, error) FlushCache() } diff --git a/services/user.go b/services/user.go index 294c670..f8209e2 100644 --- a/services/user.go +++ b/services/user.go @@ -52,6 +52,14 @@ func (srv *UserService) GetUserByKey(key string) (*models.User, error) { return u, nil } +func (srv *UserService) GetUserByEmail(email string) (*models.User, error) { + return srv.repository.GetByEmail(email) +} + +func (srv *UserService) GetUserByResetToken(resetToken string) (*models.User, error) { + return srv.repository.GetByResetToken(resetToken) +} + func (srv *UserService) GetAll() ([]*models.User, error) { return srv.repository.GetAll() } @@ -110,6 +118,10 @@ func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Logi return srv.repository.UpdateField(user, "password", user.Password) } +func (srv *UserService) GenerateResetToken(user *models.User) (*models.User, error) { + return srv.repository.UpdateField(user, "reset_token", uuid.NewV4()) +} + func (srv *UserService) Delete(user *models.User) error { srv.cache.Flush() return srv.repository.Delete(user) diff --git a/views/login.tpl.html b/views/login.tpl.html index c8c439c..37fa54a 100644 --- a/views/login.tpl.html +++ b/views/login.tpl.html @@ -7,8 +7,12 @@ {{ template "header.tpl.html" . }} -
-

Login

+
+
+
← Go back
+

Login

+
+
{{ template "alerts.tpl.html" . }} @@ -28,11 +32,16 @@ type="password" id="password" name="password" placeholder="******" minlength="6" required>
-
- - +
diff --git a/views/reset-password.tpl.html b/views/reset-password.tpl.html new file mode 100644 index 0000000..aea3e77 --- /dev/null +++ b/views/reset-password.tpl.html @@ -0,0 +1,42 @@ + + + +{{ template "head.tpl.html" . }} + + + +{{ template "header.tpl.html" . }} + +
+
+
← Go back
+

Reset Password

+
+
+
+ +{{ template "alerts.tpl.html" . }} + +
+
+
+

If you forgot your password, enter the e-mail address you associated with Wakapi to reset it and choose a new one. You will receive an e-mail afterwards. Make sure to check your spam folder if it does not arrive after a few minutes.

+
+ + +
+
+ +
+
+
+
+ +{{ template "footer.tpl.html" . }} + +{{ template "foot.tpl.html" . }} + + + \ No newline at end of file diff --git a/views/set-password.tpl.html b/views/set-password.tpl.html new file mode 100644 index 0000000..df37b1e --- /dev/null +++ b/views/set-password.tpl.html @@ -0,0 +1,44 @@ + + + +{{ template "head.tpl.html" . }} + + + +{{ template "header.tpl.html" . }} + +
+

Choose a new password

+
+ +{{ template "alerts.tpl.html" . }} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +{{ template "footer.tpl.html" . }} + +{{ template "foot.tpl.html" . }} + + + \ No newline at end of file diff --git a/views/settings.tpl.html b/views/settings.tpl.html index 070d393..4f7769c 100644 --- a/views/settings.tpl.html +++ b/views/settings.tpl.html @@ -23,7 +23,7 @@
-
← Go back
+
← Go back

Settings

         
diff --git a/views/signup.tpl.html b/views/signup.tpl.html index a2c874a..f474caa 100644 --- a/views/signup.tpl.html +++ b/views/signup.tpl.html @@ -3,10 +3,13 @@ {{ template "head.tpl.html" . }} - + + +{{ template "header.tpl.html" . }} +
-
-
← Go back
+
+

Sign Up