Support resetting password via email Closes T508fix-fedi-followers
@@ -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+" <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 { | |||
if !app.cfg.Email.Enabled() { | |||
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 | |||
} | |||
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 | |||
@@ -831,6 +831,9 @@ input { | |||
margin: 0 auto 3em; | |||
font-size: 1.2em; | |||
&.toosmall { | |||
max-width: 25em; | |||
} | |||
&.tight { | |||
max-width: 30em; | |||
} | |||
@@ -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" | |||
@@ -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 | |||
@@ -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}}."> | |||
<style> | |||
input{margin-bottom:0.5em;} | |||
p.forgot { | |||
font-size: 0.8em; | |||
margin: 0 auto 1.5rem; | |||
text-align: left; | |||
max-width: 16rem; | |||
} | |||
</style> | |||
{{end}} | |||
{{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()"> | |||
<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 /> | |||
{{if .EmailEnabled}}<p class="forgot"><a href="/reset">Forgot password?</a></p>{{end}} | |||
{{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}} | |||
<input type="submit" id="btn-login" value="Login" /> | |||
</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") | |||
// 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") | |||
@@ -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]) | |||
} |