mirror of
https://github.com/writefreely/writefreely
synced 2024-11-10 11:24:13 +00:00
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
This commit is contained in:
parent
7dda53146d
commit
f404f7b928
10 changed files with 303 additions and 0 deletions
146
account.go
146
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+" <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")
|
||||
|
|
31
database.go
31
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
|
||||
|
|
|
@ -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
|
||||
|
|
37
migrations/v14.go
Normal file
37
migrations/v14.go
Normal file
|
@ -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,9 @@
|
|||
<meta itemprop="description" content="Log in to {{.SiteName}}.">
|
||||
<style>
|
||||
input{margin-bottom:0.5em;}
|
||||
p.forgot {
|
||||
font-size: 0.86em;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
|
@ -19,6 +22,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 />
|
||||
<p class="forgot"><a href="/reset">Forgot password?</a></p>
|
||||
{{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}}
|
||||
<input type="submit" id="btn-login" value="Login" />
|
||||
</form>
|
||||
|
|
48
pages/reset.tmpl
Normal file
48
pages/reset.tmpl
Normal file
|
@ -0,0 +1,48 @@
|
|||
{{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 .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}}
|
|
@ -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")
|
||||
|
|
25
spam/ip.go
Normal file
25
spam/ip.go
Normal file
|
@ -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])
|
||||
}
|
Loading…
Reference in a new issue