Support resetting password via email Closes T508fix-fedi-followers
@@ -14,6 +14,7 @@ import ( | |||||
"encoding/json" | "encoding/json" | ||||
"fmt" | "fmt" | ||||
"github.com/mailgun/mailgun-go" | "github.com/mailgun/mailgun-go" | ||||
"github.com/writefreely/writefreely/spam" | |||||
"html/template" | "html/template" | ||||
"net/http" | "net/http" | ||||
"regexp" | "regexp" | ||||
@@ -324,6 +325,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { | |||||
To string | To string | ||||
Message template.HTML | Message template.HTML | ||||
Flashes []template.HTML | Flashes []template.HTML | ||||
EmailEnabled bool | |||||
LoginUsername string | LoginUsername string | ||||
}{ | }{ | ||||
StaticPage: pageForReq(app, r), | StaticPage: pageForReq(app, r), | ||||
@@ -331,6 +333,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { | |||||
To: r.FormValue("to"), | To: r.FormValue("to"), | ||||
Message: template.HTML(""), | Message: template.HTML(""), | ||||
Flashes: []template.HTML{}, | Flashes: []template.HTML{}, | ||||
EmailEnabled: app.cfg.Email.Enabled(), | |||||
LoginUsername: getTempInfo(app, "login-user", r, w), | 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 | 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+" <noreply-password@"+app.cfg.Email.Domain+">", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail)) | |||||
m.AddTag("Password Reset") | |||||
m.SetHtml(fmt.Sprintf(`<html> | |||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;"> | |||||
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;"> | |||||
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1> | |||||
<p>We received a request to reset your password on %s. Please click the following link to continue:</p> | |||||
<p style="font-size:1.2em;margin-bottom:1.5em;"><a href="%s/reset?t=%s">Reset your password</a></p> | |||||
<p style="font-size: 0.86em;margin:1em auto">%s</p> | |||||
</div> | |||||
</body> | |||||
</html>`, 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 { | func loginViaEmail(app *App, alias, redirectTo string) error { | ||||
if !app.cfg.Email.Enabled() { | if !app.cfg.Email.Enabled() { | ||||
return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server") | return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server") | ||||
@@ -586,6 +586,37 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int, | |||||
return u.String(), nil | 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) { | func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) { | ||||
var userID, collID int64 = -1, -1 | var userID, collID int64 = -1, -1 | ||||
var coll *Collection | var coll *Collection | ||||
@@ -831,6 +831,9 @@ input { | |||||
margin: 0 auto 3em; | margin: 0 auto 3em; | ||||
font-size: 1.2em; | font-size: 1.2em; | ||||
&.toosmall { | |||||
max-width: 25em; | |||||
} | |||||
&.tight { | &.tight { | ||||
max-width: 30em; | max-width: 30em; | ||||
} | } | ||||
@@ -61,6 +61,13 @@ func (db *datastore) typeVarChar(l int) string { | |||||
return fmt.Sprintf("VARCHAR(%d)", l) | 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 { | func (db *datastore) typeBool() string { | ||||
if db.driverName == driverSQLite { | if db.driverName == driverSQLite { | ||||
return "INTEGER" | return "INTEGER" | ||||
@@ -69,6 +69,7 @@ var migrations = []Migration{ | |||||
New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11 | New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11 | ||||
New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0) | New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0) | ||||
New("support newsletters", supportLetters), // V12 -> V13 | New("support newsletters", supportLetters), // V12 -> V13 | ||||
New("support password resetting", supportPassReset), // V13 -> V14 | |||||
} | } | ||||
// CurrentVer returns the current migration version the application is on | // CurrentVer returns the current migration version the application is on | ||||
@@ -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 | |||||
} |
@@ -3,6 +3,12 @@ | |||||
<meta itemprop="description" content="Log into {{.SiteName}}."> | <meta itemprop="description" content="Log into {{.SiteName}}."> | ||||
<style> | <style> | ||||
input{margin-bottom:0.5em;} | input{margin-bottom:0.5em;} | ||||
p.forgot { | |||||
font-size: 0.8em; | |||||
margin: 0 auto 1.5rem; | |||||
text-align: left; | |||||
max-width: 16rem; | |||||
} | |||||
</style> | </style> | ||||
{{end}} | {{end}} | ||||
{{define "content"}} | {{define "content"}} | ||||
@@ -19,6 +25,7 @@ input{margin-bottom:0.5em;} | |||||
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()"> | <form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()"> | ||||
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br /> | <input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br /> | ||||
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br /> | <input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br /> | ||||
{{if .EmailEnabled}}<p class="forgot"><a href="/reset">Forgot password?</a></p>{{end}} | |||||
{{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}} | {{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}} | ||||
<input type="submit" id="btn-login" value="Login" /> | <input type="submit" id="btn-login" value="Login" /> | ||||
</form> | </form> | ||||
@@ -0,0 +1,58 @@ | |||||
{{define "head"}}<title>Reset password — {{.SiteName}}</title> | |||||
<style> | |||||
input { | |||||
margin-bottom: 0.5em; | |||||
width: 100%; | |||||
box-sizing: border-box; | |||||
} | |||||
label { | |||||
display: block; | |||||
} | |||||
</style> | |||||
{{end}} | |||||
{{define "content"}} | |||||
<div class="toosmall content-container clean"> | |||||
<h1>Reset your password</h1> | |||||
{{ if .DisablePasswordAuth }} | |||||
<div class="alert info"> | |||||
<p><strong>Password login is disabled on this server</strong>, so it's not possible to reset your password.</p> | |||||
</div> | |||||
{{ else if not .EmailEnabled }} | |||||
<div class="alert info"> | |||||
<p><strong>Email is not configured on this server!</strong> Please <a href="/contact">contact your admin</a> to reset your password.</p> | |||||
</div> | |||||
{{ else }} | |||||
{{if .Flashes}}<ul class="errors"> | |||||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} | |||||
</ul>{{end}} | |||||
{{if .IsResetting}} | |||||
<form method="post" action="/reset" onsubmit="disableSubmit()"> | |||||
<label> | |||||
<p>New Password</p> | |||||
<input type="password" name="new-pass" autocomplete="new-password" placeholder="New password" tabindex="1" /> | |||||
</label> | |||||
<input type="hidden" name="t" value="{{.Token}}" /> | |||||
<input type="submit" id="btn-login" value="Reset Password" /> | |||||
{{ .CSRFField }} | |||||
</form> | |||||
{{else if not .IsSent}} | |||||
<form action="/reset" method="post" onsubmit="disableSubmit()"> | |||||
<label> | |||||
<p>Username</p> | |||||
<input type="text" name="alias" placeholder="Username" autofocus /> | |||||
</label> | |||||
{{ .CSRFField }} | |||||
<input type="submit" id="btn-login" value="Reset Password" /> | |||||
</form> | |||||
{{end}} | |||||
<script type="text/javascript"> | |||||
var $btn = document.getElementById("btn-login"); | |||||
function disableSubmit() { | |||||
$btn.disabled = true; | |||||
} | |||||
</script> | |||||
{{ end }} | |||||
{{end}} |
@@ -184,6 +184,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { | |||||
write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET") | write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET") | ||||
// Handle special pages first | // 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("/login", handler.Web(viewLogin, UserLevelNoneRequired)) | ||||
write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired)) | write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired)) | ||||
write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") | write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET") | ||||
@@ -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]) | |||||
} |