changeset 310:4bee4ba6dc58

Password reset: Part III
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Wed, 01 Aug 2018 16:57:40 +0200
parents 0d2bdec1e637
children 74559e12a59f
files controllers/pwreset.go
diffstat 1 files changed, 183 insertions(+), 57 deletions(-) [+]
line wrap: on
line diff
--- a/controllers/pwreset.go	Wed Aug 01 16:53:31 2018 +0200
+++ b/controllers/pwreset.go	Wed Aug 01 16:57:40 2018 +0200
@@ -1,13 +1,16 @@
 package controllers
 
 import (
+	"bytes"
 	"database/sql"
 	"encoding/hex"
-	"fmt"
 	"log"
+	"math/big"
 	"net/http"
+	"os/exec"
 	"strings"
 	"sync"
+	"text/template"
 	"time"
 
 	"gemma.intevation.de/gemma/auth"
@@ -21,6 +24,9 @@
 	userExistsSQL = `SELECT email_address
     FROM users.list_users WHERE username = $1
     LIMIT 1`
+
+	updatePasswordSQL = `UPDATE users.list_users
+    SET pw = $1 WHERE username = $2`
 )
 
 const (
@@ -29,23 +35,38 @@
 	maxPasswordRequestsPerUser = 5
 )
 
-const (
-	mailTmpl = `You have requested a password change for your account on
-%s://%s
+var (
+	passwordResetRequestMailTmpl = template.Must(
+		template.New("request").Parse(`You have requested a password change
+for your account {{ .User }} on
+{{ .HTTPS }}://{{ .Server }}
 
 Please follow this link to get to the page where you can change your password.
 
-%s://%s/#/api/users/passwordreset/%s
+{{ .HTTPS }}://{{ .Server }}/api/users/passwordreset/{{ .Hash }}
 
 The link is only valid for one hour.
 
 Best regards
-    Your service team`
+    Your service team`))
+
+	passwordResetMailTmpl = template.Must(
+		template.New("reset").Parse(`Your password for your account {{ .User }} on
+{{ .HTTPS }}://{{ .Server }}
+
+has been changed to
+    {{ .Password }}
+
+Change it as soon as possible.
+
+Best regards
+    Your service team`))
 )
 
 type timedUser struct {
-	user string
-	time time.Time
+	user  string
+	email string
+	time  time.Time
 }
 
 type resetRequests struct {
@@ -85,25 +106,25 @@
 	return true
 }
 
-func (rr *resetRequests) store(hash, user string) {
+func (rr *resetRequests) store(hash, user, email string) {
 	now := time.Now()
 	rr.Lock()
-	rr.reqs[hash] = &timedUser{user, now}
+	rr.reqs[hash] = &timedUser{user, email, now}
 	rr.Unlock()
 }
 
-func (rr *resetRequests) fetch(hash string) (string, bool) {
+func (rr *resetRequests) fetch(hash string) *timedUser {
 	rr.Lock()
 	defer rr.Unlock()
 	tu := rr.reqs[hash]
 	if tu == nil {
-		return "", false
+		return nil
 	}
 	if tu.time.Before(time.Now().Add(-passwordResetValid)) {
 		delete(rr.reqs, hash)
-		return "", false
+		return nil
 	}
-	return tu.user, true
+	return tu
 }
 
 func (rr *resetRequests) delete(hash string) {
@@ -123,28 +144,121 @@
 	}
 }
 
-func messageBody(https bool, hash, serverName string) string {
-
-	var proto string
-
-	if https {
-		proto = "https"
-	} else {
-		proto = "http"
+func requestMessageBody(https, user, hash, server string) string {
+	var content = struct {
+		User   string
+		HTTPS  string
+		Server string
+		Hash   string
+	}{
+		User:   user,
+		HTTPS:  https,
+		Server: server,
+		Hash:   hash,
 	}
-
-	return fmt.Sprintf(mailTmpl,
-		proto, serverName,
-		proto, serverName,
-		hash)
+	var buf bytes.Buffer
+	if err := passwordResetRequestMailTmpl.Execute(&buf, &content); err != nil {
+		log.Printf("error: %v\n", err)
+	}
+	return buf.String()
 }
 
-const hashLength = 32
+func changedMessageBody(https, user, password, server string) string {
+	var content = struct {
+		User     string
+		HTTPS    string
+		Server   string
+		Password string
+	}{
+		User:     user,
+		HTTPS:    https,
+		Server:   server,
+		Password: password,
+	}
+	var buf bytes.Buffer
+	if err := passwordResetMailTmpl.Execute(&buf, &content); err != nil {
+		log.Printf("error: %v\n", err)
+	}
+	return buf.String()
+}
+
+func useHTTPS(req *http.Request) string {
+	if strings.ToLower(req.URL.Scheme) == "https" {
+		return "https"
+	}
+	return "http"
+}
+
+const (
+	hashLength     = 32
+	passwordLength = 20
+)
 
 func generateHash() string {
 	return hex.EncodeToString(auth.GenerateRandomKey(hashLength))
 }
 
+const (
+	base62alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+	special        = ",.!;"
+)
+
+var (
+	zero  = big.NewInt(0)
+	div62 = big.NewInt(62)
+)
+
+func encodeToString(src []byte, max int) string {
+	v := new(big.Int)
+	v.SetBytes(src[1:])
+	m := new(big.Int)
+	z := new(big.Int)
+	out := make([]byte, 0, max)
+	for {
+		z.DivMod(v, div62, m)
+		// reverse order but it doesnt matter.
+		out = append(out, base62alphabet[m.Int64()])
+		if len(out) == max-1 || z.Cmp(zero) == 0 {
+			break
+		}
+		v, z = z, v
+	}
+	out = append(out, special[int(src[0])%len(special)])
+	return string(out)
+}
+
+func generateNewPassword() string {
+	// First try pwgen
+	out, err := exec.Command("pwgen", "-y", "20", "1").Output()
+	if err == nil {
+		return strings.TrimSpace(string(out))
+	}
+
+	// Use internal base62 encoder.
+	return encodeToString(auth.GenerateRandomKey(20), 20)
+
+}
+
+func sendMail(email, subject, body string) error {
+	cfg := &config.Config
+	m := gomail.NewMessage()
+	m.SetHeader("From", cfg.MailFrom)
+	m.SetHeader("To", email)
+	m.SetHeader("Subject", subject)
+	m.SetBody("text/plain", body)
+
+	d := gomail.Dialer{
+		Host:      cfg.MailHost,
+		Port:      int(cfg.MailPort),
+		Username:  cfg.MailUser,
+		Password:  cfg.MailPassword,
+		LocalName: cfg.MailHelo,
+		SSL:       cfg.MailPort == 465,
+	}
+
+	return d.DialAndSend(m)
+}
+
 func passwordResetRequest(
 	input interface{},
 	req *http.Request,
@@ -168,7 +282,6 @@
 	}
 
 	cfg := &config.Config
-
 	if db, err = auth.OpenDB(cfg.ServiceUser, cfg.ServicePassword); err != nil {
 		return
 	}
@@ -196,37 +309,18 @@
 
 	hash := generateHash()
 
-	passwordResetRequests.store(hash, user.User)
-
-	serverName := req.Host
-	useHTTPS := strings.ToLower(req.URL.Scheme) == "https"
-
-	body := messageBody(useHTTPS, hash, serverName)
+	passwordResetRequests.store(hash, user.User, email)
 
-	m := gomail.NewMessage()
-	m.SetHeader("From", cfg.MailFrom)
-	m.SetHeader("To", email)
-	m.SetHeader("Subject", "Password Reset Link")
-	m.SetBody("text/plain", body)
+	body := requestMessageBody(useHTTPS(req), user.User, hash, req.Host)
 
-	d := gomail.Dialer{
-		Host:      cfg.MailHost,
-		Port:      int(cfg.MailPort),
-		Username:  cfg.MailUser,
-		Password:  cfg.MailPassword,
-		LocalName: cfg.MailHelo,
-		SSL:       cfg.MailPort == 465,
-	}
-
-	if err = d.DialAndSend(m); err != nil {
+	if err = sendMail(email, "Password Reset Link", body); err != nil {
 		return
 	}
 
 	jr.Result = &struct {
 		SendTo string `json:"send-to"`
-	}{
-		SendTo: email,
-	}
+	}{email}
+
 	return
 }
 
@@ -242,15 +336,47 @@
 		return
 	}
 
-	user, ok := passwordResetRequests.fetch(hash)
-	if !ok {
+	tu := passwordResetRequests.fetch(hash)
+	if tu == nil {
 		err = JSONError{http.StatusNotFound, "No such hash"}
 		return
 	}
 
-	log.Printf("password reset for %s\n", user)
+	password := generateNewPassword()
+
+	cfg := &config.Config
+	if db, err = auth.OpenDB(cfg.ServiceUser, cfg.ServicePassword); err != nil {
+		return
+	}
+	defer db.Close()
+
+	var res sql.Result
+	if res, err = db.Exec(
+		updatePasswordSQL,
+		password,
+		tu.user,
+	); err != nil {
+		return
+	}
 
-	// TODO: Reset PW and send email.
+	passwordResetRequests.delete(hash)
+
+	if n, err2 := res.RowsAffected(); err2 != nil && n == 0 {
+		err = JSONError{
+			Code:    http.StatusNotFound,
+			Message: "User not found",
+		}
+		return
+	}
+
+	body := changedMessageBody(useHTTPS(req), tu.user, password, req.Host)
+	if err = sendMail(tu.email, "Password Reset Done", body); err != nil {
+		return
+	}
+
+	jr.Result = &struct {
+		Message string `json:"message"`
+	}{"User password changed"}
 
 	return
 }