diff --git a/account.go b/account.go index 4af1ba3..423dee2 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" @@ -324,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), @@ -331,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), } @@ -1238,6 +1241,163 @@ 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 + EmailEnabled bool + CSRFField template.HTML + Token string + IsResetting bool + IsSent bool + }{ + 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 { + 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"} + + 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") + + 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 + } + + 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" + 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 0fe29a1..c908613 100644 --- a/pages/login.tmpl +++ b/pages/login.tmpl @@ -3,6 +3,12 @@ {{end}} {{define "content"}} @@ -19,6 +25,7 @@ input{margin-bottom:0.5em;}


+ {{if .EmailEnabled}}

Forgot password?

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

Reset your password

+ +{{ 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.

+
+{{ else }} + {{if .Flashes}}{{end}} + + {{if .IsResetting}} +
+ + + + {{ .CSRFField }} +
+ {{else if not .IsSent}} +
+ + {{ .CSRFField }} + +
+ {{end}} + + +{{ end }} +{{end}} diff --git a/routes.go b/routes.go index dc2a3c1..2e4e8c2 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]) +}