view controllers/pwreset.go @ 317:5cb18bedb3a9

Simplified internal password generator.
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Thu, 02 Aug 2018 10:18:25 +0200
parents 423d0f1d8ee0
children ac760b0f22a9
line wrap: on
line source

package controllers

import (
	"bytes"
	"crypto/rand"
	"database/sql"
	"encoding/hex"
	"log"
	"math/big"
	"net/http"
	"os/exec"
	"strings"
	"sync"
	"text/template"
	"time"

	"gemma.intevation.de/gemma/auth"
	"gemma.intevation.de/gemma/config"
	"github.com/gorilla/mux"

	gomail "gopkg.in/gomail.v2"
)

const (
	userExistsSQL = `SELECT email_address
    FROM users.list_users WHERE username = $1`

	updatePasswordSQL = `UPDATE users.list_users
    SET pw = $1 WHERE username = $2`
)

const (
	passwordResetValid         = time.Hour
	maxPasswordResets          = 1000
	maxPasswordRequestsPerUser = 5
)

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.

{{ .HTTPS }}://{{ .Server }}/api/users/passwordreset/{{ .Hash }}

The link is only valid for one hour.

Best regards
    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
	email string
	time  time.Time
}

type resetRequests struct {
	sync.Mutex
	reqs map[string]*timedUser
}

var passwordResetRequests = func() *resetRequests {
	rr := &resetRequests{reqs: map[string]*timedUser{}}
	go func() {
		for {
			time.Sleep(2 * time.Minute)
			rr.removeOutdated()
		}
	}()
	return rr
}()

func (rr *resetRequests) len() int {
	rr.Lock()
	l := len(rr.reqs)
	rr.Unlock()
	return l
}

func (rr *resetRequests) userAllowed(user string) bool {
	rr.Lock()
	defer rr.Unlock()
	var count int
	for _, v := range rr.reqs {
		if v.user == user {
			if count++; count >= maxPasswordRequestsPerUser {
				return false
			}
		}
	}
	return true
}

func (rr *resetRequests) store(hash, user, email string) {
	now := time.Now()
	rr.Lock()
	rr.reqs[hash] = &timedUser{user, email, now}
	rr.Unlock()
}

func (rr *resetRequests) fetch(hash string) *timedUser {
	rr.Lock()
	defer rr.Unlock()
	tu := rr.reqs[hash]
	if tu == nil {
		return nil
	}
	if tu.time.Before(time.Now().Add(-passwordResetValid)) {
		delete(rr.reqs, hash)
		return nil
	}
	return tu
}

func (rr *resetRequests) delete(hash string) {
	rr.Lock()
	delete(rr.reqs, hash)
	rr.Unlock()
}

func (rr *resetRequests) removeOutdated() {
	good := time.Now().Add(-passwordResetValid)
	rr.Lock()
	defer rr.Unlock()
	for k, v := range rr.reqs {
		if v.time.Before(good) {
			delete(rr.reqs, k)
		}
	}
}

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,
	}
	var buf bytes.Buffer
	if err := passwordResetRequestMailTmpl.Execute(&buf, &content); err != nil {
		log.Printf("error: %v\n", err)
	}
	return buf.String()
}

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))
}

func randomString(n int) string {

	const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
		"abcdefghijklmnopqrstuvwxyz" +
		"0123456789" +
		"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"

	max := big.NewInt(int64(len(alphabet)))
	out := make([]byte, n)

	for i := range out {
		v, err := rand.Int(rand.Reader, max)
		if err != nil {
			log.Panicf("error: %v\n", err)
		}
		out[i] = alphabet[v.Int64()]
	}

	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 generator.
	return randomString(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,
	db *sql.DB,
) (jr JSONResult, err error) {

	// Limit total number of password requests.
	if passwordResetRequests.len() >= maxPasswordResets {
		err = JSONError{
			Code:    http.StatusServiceUnavailable,
			Message: "Too much password reset request",
		}
		return
	}

	user := input.(*PWResetUser)

	if user.User == "" {
		err = JSONError{http.StatusBadRequest, "Invalid user name"}
		return
	}

	cfg := &config.Config
	if db, err = auth.OpenDB(cfg.ServiceUser, cfg.ServicePassword); err != nil {
		return
	}
	defer db.Close()

	var email string
	err = db.QueryRow(userExistsSQL, user.User).Scan(&email)

	switch {
	case err == sql.ErrNoRows:
		err = JSONError{http.StatusNotFound, "User does not exist."}
		return
	case err != nil:
		return
	}

	// Limit requests per user
	if !passwordResetRequests.userAllowed(user.User) {
		err = JSONError{
			Code:    http.StatusServiceUnavailable,
			Message: "Too much password reset requests for user",
		}
		return
	}

	hash := generateHash()

	passwordResetRequests.store(hash, user.User, email)

	body := requestMessageBody(useHTTPS(req), user.User, hash, req.Host)

	if err = sendMail(email, "Password Reset Link", body); err != nil {
		return
	}

	jr.Result = &struct {
		SendTo string `json:"send-to"`
	}{email}

	return
}

func passwordReset(
	_ interface{},
	req *http.Request,
	db *sql.DB,
) (jr JSONResult, err error) {

	hash := mux.Vars(req)["hash"]
	if _, err = hex.DecodeString(hash); err != nil {
		err = JSONError{http.StatusBadRequest, "Invalid hash"}
		return
	}

	tu := passwordResetRequests.fetch(hash)
	if tu == nil {
		err = JSONError{http.StatusNotFound, "No such hash"}
		return
	}

	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
	}

	passwordResetRequests.delete(hash)

	if n, err2 := res.RowsAffected(); err2 == nil && n == 0 {
		err = JSONError{http.StatusNotFound, "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
}