From f404f7b92881157d223d5fb843fb97aa0835d4cb Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Mon, 25 Sep 2023 18:48:14 -0400 Subject: [PATCH 1/7] 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]) +} From c18987705c20f6e8d52775b8ecd45bc67eacc6f1 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Tue, 3 Oct 2023 11:15:33 -0400 Subject: [PATCH 2/7] Display friendly message on /reset if email is disabled --- account.go | 24 +++++++++++++----------- pages/reset.tmpl | 20 +++++++++++++------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/account.go b/account.go index e88c1fd..0c83ad4 100644 --- a/account.go +++ b/account.go @@ -1278,18 +1278,20 @@ func viewResetPassword(app *App, w http.ResponseWriter, r *http.Request) error { // Show reset password page d := struct { page.StaticPage - Flashes []string - CSRFField template.HTML - Token string - IsResetting bool - IsSent bool + Flashes []string + EmailEnabled bool + 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", + StaticPage: pageForReq(app, r), + Flashes: f, + EmailEnabled: app.cfg.Email.Enabled(), + CSRFField: csrf.TemplateField(r), + Token: token, + IsResetting: resetting, + IsSent: r.FormValue("sent") == "1", } err := pages["reset.tmpl"].ExecuteTemplate(w, "base", d) if err != nil { diff --git a/pages/reset.tmpl b/pages/reset.tmpl index 6deac41..3db2418 100644 --- a/pages/reset.tmpl +++ b/pages/reset.tmpl @@ -14,6 +14,11 @@ label {

Reset your password

+{{ if not .EmailEnabled }} +
+

Email is not configured on this server! Please contact your admin to reset your password.

+
+{{ else }} {{if .Flashes}}
    {{range .Flashes}}
  • {{.}}
  • {{end}}
{{end}} @@ -26,7 +31,7 @@ label { - {{ .CSRFField }} + {{ .CSRFField }} {{else if not .IsSent}}
@@ -39,10 +44,11 @@ label {
{{end}} - + +{{ end }} {{end}} From 1e37f60d501d4533476bd8334cfeb01e3b8f7e2f Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Tue, 3 Oct 2023 11:16:11 -0400 Subject: [PATCH 3/7] Hide "Reset?" link on login page when email disabled --- account.go | 2 ++ pages/login.tmpl | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/account.go b/account.go index 0c83ad4..dd27ee2 100644 --- a/account.go +++ b/account.go @@ -325,6 +325,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { To string Message template.HTML Flashes []template.HTML + EmailEnabled bool LoginUsername string }{ StaticPage: pageForReq(app, r), @@ -332,6 +333,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { To: r.FormValue("to"), Message: template.HTML(""), Flashes: []template.HTML{}, + EmailEnabled: app.cfg.Email.Enabled(), LoginUsername: getTempInfo(app, "login-user", r, w), } diff --git a/pages/login.tmpl b/pages/login.tmpl index 29cc6d6..ae11edb 100644 --- a/pages/login.tmpl +++ b/pages/login.tmpl @@ -22,7 +22,7 @@ p.forgot {


-

Forgot password?

+ {{if .EmailEnabled}}

Forgot password?

{{end}} {{if .To}}{{end}}
From 8f02449ee8f081aef28d18d238d0d171e9cc85c3 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Tue, 3 Oct 2023 11:19:47 -0400 Subject: [PATCH 4/7] Show friendly message on /reset when password-based login is disabled --- pages/reset.tmpl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pages/reset.tmpl b/pages/reset.tmpl index 3db2418..bc18377 100644 --- a/pages/reset.tmpl +++ b/pages/reset.tmpl @@ -14,7 +14,11 @@ label {

Reset your password

-{{ if not .EmailEnabled }} +{{ if .DisablePasswordAuth }} +
+

Password login is disabled on this server, so it's not possible to reset your password.

+
+{{ else if not .EmailEnabled }}

Email is not configured on this server! Please contact your admin to reset your password.

From ed60aea39e76d46825c1dd716ba7de574bd6656e Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Tue, 3 Oct 2023 11:25:05 -0400 Subject: [PATCH 5/7] Catch and log emailPasswordReset errors --- account.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/account.go b/account.go index dd27ee2..ce15a41 100644 --- a/account.go +++ b/account.go @@ -1359,7 +1359,12 @@ func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) e return returnLoc } - emailPasswordReset(app, u.EmailClear(app.keys), token) + err = emailPasswordReset(app, u.EmailClear(app.keys), token) + if err != nil { + log.Error("Error emailing password reset: %s", err) + addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil) + return returnLoc + } addSessionFlash(app, w, r, "We sent an email to the address associated with this account.", nil) returnLoc.Message += "?sent=1" From 7b84dafea79f36dfa3521445d9b2ea29e2813f40 Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Tue, 3 Oct 2023 11:28:24 -0400 Subject: [PATCH 6/7] Correctly return on /reset submission when email isn't configured --- account.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/account.go b/account.go index ce15a41..c280a8a 100644 --- a/account.go +++ b/account.go @@ -1321,6 +1321,11 @@ func doAutomatedPasswordChange(app *App, userID int64, newPass string) error { func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) error { returnLoc := impart.HTTPError{http.StatusFound, "/reset"} + if !app.cfg.Email.Enabled() { + // Email isn't configured, so there's nothing to do; send back to the reset form, where they'll get an explanation + return returnLoc + } + ip := spam.GetIP(r) alias := r.FormValue("alias") From 8207a25fa91ea4d5344bc6854a182f58bc56e88a Mon Sep 17 00:00:00 2001 From: Matt Baer Date: Tue, 3 Oct 2023 11:39:41 -0400 Subject: [PATCH 7/7] Tweak style of "Forgot" link on login page --- pages/login.tmpl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pages/login.tmpl b/pages/login.tmpl index ae11edb..d0c0d22 100644 --- a/pages/login.tmpl +++ b/pages/login.tmpl @@ -4,7 +4,10 @@ {{end}}