From a72e659f88a82203ede5cf234c0dfeff82160b48 Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Fri, 29 Sep 2023 16:37:23 +0100 Subject: [PATCH] Start adding a reset password system --- database/db-types.go | 6 ++- database/tx.go | 37 ++++++++++++---- database/tx_test.go | 2 +- pages/login.go.html | 10 +++++ pages/reset-password.go.html | 32 ++++++++++++++ password/password.go | 13 ++++-- server/login.go | 25 +++++++++++ server/mail.go | 84 +++++++++++++++++++++++++++++++++++- server/server.go | 1 + 9 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 pages/reset-password.go.html diff --git a/database/db-types.go b/database/db-types.go index 7887a9b..6e0e0e4 100644 --- a/database/db-types.go +++ b/database/db-types.go @@ -15,7 +15,6 @@ type User struct { Sub uuid.UUID `json:"sub"` Name string `json:"name,omitempty"` Username string `json:"username"` - Password string `json:"password"` Picture NullStringScanner `json:"picture,omitempty"` Website NullStringScanner `json:"website,omitempty"` Email string `json:"email"` @@ -104,6 +103,11 @@ func (u *UserPatch) ParseFromForm(v url.Values) (safeErrs []error) { return } +type ClientInfoDbOutput struct { + Sub, Name, Secret, Domain, Owner string + SSO, Active bool +} + var _ oauth2.ClientInfo = &ClientInfoDbOutput{} func (c *ClientInfoDbOutput) GetID() string { return c.Sub } diff --git a/database/tx.go b/database/tx.go index a8ff289..818fb52 100644 --- a/database/tx.go +++ b/database/tx.go @@ -49,13 +49,14 @@ func (t *Tx) InsertUser(name, un, pw, email string, role UserRole, active bool) func (t *Tx) CheckLogin(un, pw string) (*User, bool, bool, error) { var u User + var pwHash password.HashString var hasOtp, hasVerify bool row := t.tx.QueryRow(`SELECT subject, password, EXISTS(SELECT 1 FROM otp WHERE otp.subject = users.subject), email, email_verified FROM users WHERE username = ?`, un) - err := row.Scan(&u.Sub, &u.Password, &hasOtp, &u.Email, &hasVerify) + err := row.Scan(&u.Sub, &pwHash, &hasOtp, &u.Email, &hasVerify) if err != nil { return nil, false, false, err } - err = password.CheckPasswordHash(u.Password, pw) + err = password.CheckPasswordHash(pwHash, pw) return &u, hasOtp, hasVerify, err } @@ -76,8 +77,8 @@ func (t *Tx) GetUserRole(sub uuid.UUID) (UserRole, error) { func (t *Tx) GetUser(sub uuid.UUID) (*User, error) { var u User - row := t.tx.QueryRow(`SELECT name, username, password, picture, website, email, email_verified, pronouns, birthdate, zoneinfo, locale, updated_at, active FROM users WHERE subject = ?`, sub.String()) - err := row.Scan(&u.Name, &u.Username, &u.Password, &u.Picture, &u.Website, &u.Email, &u.EmailVerified, &u.Pronouns, &u.Birthdate, &u.ZoneInfo, &u.Locale, &u.UpdatedAt, &u.Active) + row := t.tx.QueryRow(`SELECT name, username, picture, website, email, email_verified, pronouns, birthdate, zoneinfo, locale, updated_at, active FROM users WHERE subject = ?`, sub.String()) + err := row.Scan(&u.Name, &u.Username, &u.Picture, &u.Website, &u.Email, &u.EmailVerified, &u.Pronouns, &u.Birthdate, &u.ZoneInfo, &u.Locale, &u.UpdatedAt, &u.Active) u.Sub = sub return &u, err } @@ -94,7 +95,7 @@ func (t *Tx) ChangeUserPassword(sub uuid.UUID, pwOld, pwNew string) error { if err != nil { return err } - var pwHash string + var pwHash password.HashString if q.Next() { err = q.Scan(&pwHash) if err != nil { @@ -275,7 +276,27 @@ func (t *Tx) VerifyUserEmail(sub uuid.UUID) error { return err } -type ClientInfoDbOutput struct { - Sub, Name, Secret, Domain, Owner string - SSO, Active bool +func (t *Tx) UserResetPassword(sub uuid.UUID, pw string) error { + hashPassword, err := password.HashPassword(pw) + if err != nil { + return err + } + exec, err := t.tx.Exec(`UPDATE users SET password = ?, updated_at = ? WHERE subject = ?`, hashPassword, updatedAt(), sub.String()) + if err != nil { + return err + } + affected, err := exec.RowsAffected() + if err != nil { + return err + } + if affected != 1 { + return fmt.Errorf("row wasn't updated") + } + return nil +} + +func (t *Tx) UserEmailExists(email string) (exists bool, err error) { + row := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE email = ? and email_verified = 1)`, email) + err = row.Scan(&exists) + return } diff --git a/database/tx_test.go b/database/tx_test.go index 789d1da..09fef36 100644 --- a/database/tx_test.go +++ b/database/tx_test.go @@ -26,7 +26,7 @@ func TestTx_ChangeUserPassword(t *testing.T) { query, err := d.db.Query(`SELECT password FROM users WHERE subject = ? AND username = ?`, u.String(), "test") assert.NoError(t, err) assert.True(t, query.Next()) - var oldPw string + var oldPw password.HashString assert.NoError(t, query.Scan(&oldPw)) assert.NoError(t, password.CheckPasswordHash(oldPw, "new")) assert.NoError(t, query.Err()) diff --git a/pages/login.go.html b/pages/login.go.html index 4802a16..49cd4d7 100644 --- a/pages/login.go.html +++ b/pages/login.go.html @@ -26,6 +26,16 @@ + +
+

Enter your email address below to receive an email with instructions on how to reset your password.

+

Please note this only works if your email address is already verified.

+
+ + +
+ +
diff --git a/pages/reset-password.go.html b/pages/reset-password.go.html new file mode 100644 index 0000000..0b6fca7 --- /dev/null +++ b/pages/reset-password.go.html @@ -0,0 +1,32 @@ + + + + {{.ServiceName}} + + +
+

{{.ServiceName}}

+
+
+
+
+ + +
+
+ + +
+ +
+
+ + diff --git a/password/password.go b/password/password.go index 36111be..d7f57e6 100644 --- a/password/password.go +++ b/password/password.go @@ -1,12 +1,17 @@ package password -import "golang.org/x/crypto/bcrypt" +import ( + "golang.org/x/crypto/bcrypt" +) -func HashPassword(password string) (string, error) { +// HashString is used to represent a string containing a password hash +type HashString string + +func HashPassword(password string) (HashString, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) - return string(bytes), err + return HashString(bytes), err } -func CheckPasswordHash(hash, password string) error { +func CheckPasswordHash(hash HashString, password string) error { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) } diff --git a/server/login.go b/server/login.go index 77c9242..dbaecd3 100644 --- a/server/login.go +++ b/server/login.go @@ -121,3 +121,28 @@ func (h *HttpServer) LoginPost(rw http.ResponseWriter, req *http.Request, _ http h.SafeRedirect(rw, req) } + +func (h *HttpServer) LoginResetPasswordPost(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + email := req.PostFormValue("email") + address, err := mail.ParseAddress(email) + if err != nil || address.Name != "" { + http.Error(rw, "Invalid email address format", http.StatusBadRequest) + return + } + + var emailExists bool + if h.DbTx(rw, func(tx *database.Tx) (err error) { + emailExists, err = tx.UserEmailExists(email) + return err + }) { + return + } + + go h.possiblySendPasswordResetEmail(email, emailExists) + + http.Error(rw, "An email will be send to your inbox if an account with that email address is found", http.StatusOK) +} + +func (h *HttpServer) possiblySendPasswordResetEmail(email string, exists bool) { + // TODO(Melon): Send reset password email template +} diff --git a/server/mail.go b/server/mail.go index f3efddc..f88cd9a 100644 --- a/server/mail.go +++ b/server/mail.go @@ -2,7 +2,9 @@ package server import ( "github.com/1f349/tulip/database" + "github.com/1f349/tulip/pages" "github.com/emersion/go-message/mail" + "github.com/go-session/session" "github.com/google/uuid" "github.com/julienschmidt/httprouter" "net/http" @@ -35,7 +37,87 @@ func (h *HttpServer) MailVerify(rw http.ResponseWriter, req *http.Request, param } func (h *HttpServer) MailPassword(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { - http.Error(rw, "Reset password is not functional yet", http.StatusNotImplemented) + code := params.ByName("code") + parse, err := uuid.Parse(code) + if err != nil { + http.Error(rw, "Invalid password reset code", http.StatusBadRequest) + return + } + + k := mailLinkKey{mailLinkResetPassword, parse} + + userSub, ok := h.mailLinkCache.Get(k) + if !ok { + http.Error(rw, "Invalid password reset code", http.StatusBadRequest) + return + } + + h.mailLinkCache.Delete(k) + + ss, err := session.Start(req.Context(), rw, req) + if err != nil { + http.Error(rw, "Error loading session", http.StatusInternalServerError) + return + } + + ss.Set("mail-reset-password-user", userSub) + err = ss.Save() + if err != nil { + http.Error(rw, "Error saving session", http.StatusInternalServerError) + return + } + + pages.RenderPageTemplate(rw, "reset-password", map[string]any{ + "ServiceName": h.serviceName, + }) +} + +func (h *HttpServer) MailPasswordPost(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + pw := req.PostFormValue("new_password") + rpw := req.PostFormValue("confirm_password") + + // reverse passwords are possible + if len(pw) == 0 { + http.Error(rw, "Cannot set an empty password", http.StatusBadRequest) + return + } + // bcrypt only allows up to 72 bytes anyway + if len(pw) > 64 { + http.Error(rw, "Security by extremely long password is a weird flex", http.StatusBadRequest) + return + } + if rpw != pw { + http.Error(rw, "Passwords do not match", http.StatusBadRequest) + return + } + + // start session + ss, err := session.Start(req.Context(), rw, req) + if err != nil { + http.Error(rw, "Error loading session", http.StatusInternalServerError) + return + } + + // get user to reset password for from session + userRaw, found := ss.Get("mail-reset-password-user") + if !found { + http.Error(rw, "Invalid password reset code", http.StatusBadRequest) + return + } + userSub, ok := userRaw.(uuid.UUID) + if !ok { + http.Error(rw, "Invalid password reset code", http.StatusBadRequest) + return + } + + // reset password database call + if h.DbTx(rw, func(tx *database.Tx) error { + return tx.UserResetPassword(userSub, pw) + }) { + return + } + + http.Error(rw, "Reset password successfully, you can login now.", http.StatusOK) } func (h *HttpServer) MailDelete(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { diff --git a/server/server.go b/server/server.go index e218efb..1489f6d 100644 --- a/server/server.go +++ b/server/server.go @@ -170,6 +170,7 @@ func NewHttpServer(listen, domain, otpIssuer, serviceName string, mailer mail.Ma // mail codes r.GET("/mail/verify/:code", hs.MailVerify) r.GET("/mail/password/:code", hs.MailPassword) + r.POST("/mail/password", hs.MailPasswordPost) r.GET("/mail/delete/:code", hs.MailDelete) // edit profile pages