From f404f7b92881157d223d5fb843fb97aa0835d4cb Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Mon, 25 Sep 2023 18:48:14 -0400 Subject: [PATCH] Support resetting password via email This adds a self-serve password reset page. Users can enter their username and receive an email with a link that will let them create a new password. If they've never set a password, it will send them a one-time login link (building on #776) that will then take them to their Account Settings page. If they don't have an email associated with their account, they'll be instructed to contact the admin, so they can manually reset the password. Includes changes to the stylesheet and database, so run: make ui writefreely db migrate Closes T508 --- account.go | 146 +++++++++++++++++++++++++++++++++++++++ database.go | 31 +++++++++ less/core.less | 3 + migrations/drivers.go | 7 ++ migrations/migrations.go | 1 + migrations/v14.go | 37 ++++++++++ pages/login.tmpl | 4 ++ pages/reset.tmpl | 48 +++++++++++++ routes.go | 1 + spam/ip.go | 25 +++++++ 10 files changed, 303 insertions(+) create mode 100644 migrations/v14.go create mode 100644 pages/reset.tmpl create mode 100644 spam/ip.go diff --git a/account.go b/account.go index ecc39b4..e88c1fd 100644 --- a/account.go +++ b/account.go @@ -14,6 +14,7 @@ import ( "encoding/json" "fmt" "github.com/mailgun/mailgun-go" + "github.com/writefreely/writefreely/spam" "html/template" "net/http" "regexp" @@ -1238,6 +1239,151 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err return nil } +func viewResetPassword(app *App, w http.ResponseWriter, r *http.Request) error { + token := r.FormValue("t") + resetting := false + var userID int64 = 0 + if token != "" { + // Show new password page + userID = app.db.GetUserFromPasswordReset(token) + if userID == 0 { + return impart.HTTPError{http.StatusNotFound, ""} + } + resetting = true + } + + if r.Method == http.MethodPost { + newPass := r.FormValue("new-pass") + if newPass == "" { + // Send password reset email + return handleResetPasswordInit(app, w, r) + } + + // Do actual password reset + // Assumes token has been validated above + err := doAutomatedPasswordChange(app, userID, newPass) + if err != nil { + return err + } + err = app.db.ConsumePasswordResetToken(token) + if err != nil { + log.Error("Couldn't consume token %s for user %d!!! %s", token, userID, err) + } + addSessionFlash(app, w, r, "Your password was reset. Now you can log in below.", nil) + return impart.HTTPError{http.StatusFound, "/login"} + } + + f, _ := getSessionFlashes(app, w, r, nil) + + // Show reset password page + d := struct { + page.StaticPage + Flashes []string + CSRFField template.HTML + Token string + IsResetting bool + IsSent bool + }{ + StaticPage: pageForReq(app, r), + Flashes: f, + CSRFField: csrf.TemplateField(r), + Token: token, + IsResetting: resetting, + IsSent: r.FormValue("sent") == "1", + } + err := pages["reset.tmpl"].ExecuteTemplate(w, "base", d) + if err != nil { + log.Error("Unable to render password reset page: %v", err) + return err + } + return err +} + +func doAutomatedPasswordChange(app *App, userID int64, newPass string) error { + // Do password reset + hashedPass, err := auth.HashPass([]byte(newPass)) + if err != nil { + return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} + } + + // Do update + err = app.db.ChangePassphrase(userID, true, "", hashedPass) + if err != nil { + return err + } + return nil +} + +func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) error { + returnLoc := impart.HTTPError{http.StatusFound, "/reset"} + + ip := spam.GetIP(r) + alias := r.FormValue("alias") + + u, err := app.db.GetUserForAuth(alias) + if err != nil { + if strings.IndexAny(alias, "@") > 0 { + addSessionFlash(app, w, r, ErrUserNotFoundEmail.Message, nil) + return returnLoc + } + addSessionFlash(app, w, r, ErrUserNotFound.Message, nil) + return returnLoc + } + if u.IsAdmin() { + // Prevent any reset emails on admin accounts + log.Error("Admin reset attempt", `Someone just tried to reset the password for an admin (ID %d - %s). IP address: %s`, u.ID, u.Username, ip) + return returnLoc + } + if u.Email.String == "" { + err := impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Please contact us (" + app.cfg.App.Host + "/contact) to reset your password."} + addSessionFlash(app, w, r, err.Message, nil) + return returnLoc + } + if isSet, _ := app.db.IsUserPassSet(u.ID); !isSet { + err = loginViaEmail(app, u.Username, "/me/settings") + if err != nil { + return err + } + addSessionFlash(app, w, r, "We've emailed you a link to log in with.", nil) + return returnLoc + } + + token, err := app.db.CreatePasswordResetToken(u.ID) + if err != nil { + log.Error("Error resetting password: %s", err) + addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil) + return returnLoc + } + + emailPasswordReset(app, u.EmailClear(app.keys), token) + + addSessionFlash(app, w, r, "We sent an email to the address associated with this account.", nil) + returnLoc.Message += "?sent=1" + return returnLoc +} + +func emailPasswordReset(app *App, toEmail, token string) error { + // Send email + gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate) + footerPara := "Didn't request this password reset? Your account is still safe, and you can safely ignore this email." + + plainMsg := fmt.Sprintf("We received a request to reset your password on %s. Please click the following link to continue (or copy and paste it into your browser): %s/reset?t=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara) + m := mailgun.NewMessage(app.cfg.App.SiteName+" ", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail)) + m.AddTag("Password Reset") + m.SetHtml(fmt.Sprintf(` + +
+

%s

+

We received a request to reset your password on %s. Please click the following link to continue:

+

Reset your password

+

%s

+
+ +`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara)) + _, _, err := gun.Send(m) + return err +} + func loginViaEmail(app *App, alias, redirectTo string) error { if !app.cfg.Email.Enabled() { return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server") diff --git a/database.go b/database.go index 85cce90..d238642 100644 --- a/database.go +++ b/database.go @@ -586,6 +586,37 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int, return u.String(), nil } +func (db *datastore) CreatePasswordResetToken(userID int64) (string, error) { + t := id.Generate62RandomString(32) + + _, err := db.Exec("INSERT INTO password_resets (user_id, token, used, created) VALUES (?, ?, 0, "+db.now()+")", userID, t) + if err != nil { + log.Error("Couldn't INSERT password_resets: %v", err) + return "", err + } + + return t, nil +} + +func (db *datastore) GetUserFromPasswordReset(token string) int64 { + var userID int64 + err := db.QueryRow("SELECT user_id FROM password_resets WHERE token = ? AND used = 0 AND created > "+db.dateSub(3, "HOUR"), token).Scan(&userID) + if err != nil { + return 0 + } + return userID +} + +func (db *datastore) ConsumePasswordResetToken(t string) error { + _, err := db.Exec("UPDATE password_resets SET used = 1 WHERE token = ?", t) + if err != nil { + log.Error("Couldn't UPDATE password_resets: %v", err) + return err + } + + return nil +} + func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) { var userID, collID int64 = -1, -1 var coll *Collection diff --git a/less/core.less b/less/core.less index 1b418ba..a64056a 100644 --- a/less/core.less +++ b/less/core.less @@ -831,6 +831,9 @@ input { margin: 0 auto 3em; font-size: 1.2em; + &.toosmall { + max-width: 25em; + } &.tight { max-width: 30em; } diff --git a/migrations/drivers.go b/migrations/drivers.go index 800d2a6..5c6958a 100644 --- a/migrations/drivers.go +++ b/migrations/drivers.go @@ -61,6 +61,13 @@ func (db *datastore) typeVarChar(l int) string { return fmt.Sprintf("VARCHAR(%d)", l) } +func (db *datastore) typeVarBinary(l int) string { + if db.driverName == driverSQLite { + return "BLOB" + } + return fmt.Sprintf("VARBINARY(%d)", l) +} + func (db *datastore) typeBool() string { if db.driverName == driverSQLite { return "INTEGER" diff --git a/migrations/migrations.go b/migrations/migrations.go index d4f9d4b..3597bbd 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -69,6 +69,7 @@ var migrations = []Migration{ New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11 New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0) New("support newsletters", supportLetters), // V12 -> V13 + New("support password resetting", supportPassReset), // V13 -> V14 } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v14.go b/migrations/v14.go new file mode 100644 index 0000000..2883001 --- /dev/null +++ b/migrations/v14.go @@ -0,0 +1,37 @@ +/* + * Copyright © 2023 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func supportPassReset(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`CREATE TABLE password_resets ( + user_id ` + db.typeInt() + ` not null, + token ` + db.typeChar(32) + ` not null primary key, + used ` + db.typeBool() + ` default 0 not null, + created ` + db.typeDateTime() + ` not null +)`) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + return nil +} diff --git a/pages/login.tmpl b/pages/login.tmpl index f0a54eb..29cc6d6 100644 --- a/pages/login.tmpl +++ b/pages/login.tmpl @@ -3,6 +3,9 @@ {{end}} {{define "content"}} @@ -19,6 +22,7 @@ input{margin-bottom:0.5em;}


+

Forgot password?

{{if .To}}{{end}}
diff --git a/pages/reset.tmpl b/pages/reset.tmpl new file mode 100644 index 0000000..6deac41 --- /dev/null +++ b/pages/reset.tmpl @@ -0,0 +1,48 @@ +{{define "head"}}Reset password — {{.SiteName}} + +{{end}} +{{define "content"}} +
+

Reset your password

+ + {{if .Flashes}}
    + {{range .Flashes}}
  • {{.}}
  • {{end}} +
{{end}} + + {{if .IsResetting}} +
+ + + + {{ .CSRFField }} +
+ {{else if not .IsSent}} +
+ + {{ .CSRFField }} + +
+ {{end}} + + +{{end}} diff --git a/routes.go b/routes.go index f17a72d..52ed0a6 100644 --- a/routes.go +++ b/routes.go @@ -184,6 +184,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET") // Handle special pages first + write.Path("/reset").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.Web(viewResetPassword, UserLevelNoneRequired))) write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired)) write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") diff --git a/spam/ip.go b/spam/ip.go new file mode 100644 index 0000000..89e317f --- /dev/null +++ b/spam/ip.go @@ -0,0 +1,25 @@ +/* + * Copyright © 2023 Musing Studio LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package spam + +import ( + "net/http" + "strings" +) + +func GetIP(r *http.Request) string { + h := r.Header.Get("X-Forwarded-For") + if h == "" { + return "" + } + ips := strings.Split(h, ",") + return strings.TrimSpace(ips[0]) +}