view pkg/controllers/pwreset.go @ 414:c1047fd04a3a

Moved project specific Go packages to new pkg folder.
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Wed, 15 Aug 2018 17:30:50 +0200
parents controllers/pwreset.go@ac23905e64b1
children ffdb507d5b42
line wrap: on
line source

package controllers

import (
	"bytes"
	"database/sql"
	"encoding/hex"
	"log"
	"net/http"
	"os/exec"
	"strings"
	"text/template"
	"time"

	"github.com/gorilla/mux"

	"gemma.intevation.de/gemma/pkg/auth"
	"gemma.intevation.de/gemma/pkg/common"
	"gemma.intevation.de/gemma/pkg/config"
	"gemma.intevation.de/gemma/pkg/misc"
)

const (
	insertRequestSQL = `INSERT INTO pw_reset.password_reset_requests
    (hash, username) VALUES ($1, $2)`

	countRequestsSQL = `SELECT count(*) FROM pw_reset.password_reset_requests`

	countRequestsUserSQL = `SELECT count(*) FROM pw_reset.password_reset_requests
    WHERE username = $1`

	deleteRequestSQL = `DELETE FROM pw_reset.password_reset_requests
    WHERE hash = $1`

	findRequestSQL = `SELECT lu.email_address, lu.username
    FROM pw_reset.password_reset_requests prr
    JOIN pw_reset.list_users lu on prr.username = lu.username
    WHERE prr.hash = $1`

	cleanupRequestsSQL = `DELETE FROM pw_reset.password_reset_requests
    WHERE issued < $1`

	userExistsSQL = `SELECT email_address
    FROM pw_reset.list_users WHERE username = $1`

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

const (
	hashLength                 = 16
	passwordLength             = 20
	passwordResetValid         = 12 * time.Hour
	maxPasswordResets          = 1000
	maxPasswordRequestsPerUser = 5
	cleanupPause               = 15 * time.Minute
)

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 12 hours.

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

func asServiceUser(fn func(*sql.DB) error) error {
	db, err := auth.OpenDB(config.ServiceUser(), config.ServicePassword())
	if err == nil {
		defer db.Close()
		err = fn(db)
	}
	return err
}

func init() {
	go removeOutdated()
}

func removeOutdated() {
	for {
		time.Sleep(cleanupPause)
		err := asServiceUser(func(db *sql.DB) error {
			good := time.Now().Add(-passwordResetValid)
			_, err := db.Exec(cleanupRequestsSQL, good)
			return err
		})
		if err != nil {
			log.Printf("error: %v\n", err)
		}
	}
}

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

func generateHash() string {
	return hex.EncodeToString(common.GenerateRandomKey(hashLength))
}

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 common.RandomString(20)
}

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

	user := input.(*PWResetUser)

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

	var hash, email string

	if err = asServiceUser(func(db *sql.DB) error {

		var count int64
		if err := db.QueryRow(countRequestsSQL).Scan(&count); err != nil {
			return err
		}

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

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

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

		if err := db.QueryRow(countRequestsUserSQL, user.User).Scan(&count); err != nil {
			return err
		}

		// Limit requests per user
		if count >= maxPasswordRequestsPerUser {
			return JSONError{
				Code:    http.StatusServiceUnavailable,
				Message: "Too much password reset requests for user",
			}
		}

		hash = generateHash()
		_, err = db.Exec(insertRequestSQL, hash, user.User)
		return err
	}); err == nil {
		body := requestMessageBody(useHTTPS(req), user.User, hash, req.Host)

		if err = misc.SendMail(email, "Password Reset Link", body); err == nil {
			jr.Result = &struct {
				SendTo string `json:"send-to"`
			}{email}
		}
	}
	return
}

func passwordReset(
	_ interface{},
	req *http.Request,
	_ *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
	}

	var email, user, password string

	if err = asServiceUser(func(db *sql.DB) error {
		err := db.QueryRow(findRequestSQL, hash).Scan(&email, &user)
		switch {
		case err == sql.ErrNoRows:
			return JSONError{http.StatusNotFound, "No such hash"}
		case err != nil:
			return err
		}
		password = generateNewPassword()
		res, err := db.Exec(updatePasswordSQL, password, user)
		if err != nil {
			return err
		}
		if n, err2 := res.RowsAffected(); err2 == nil && n == 0 {
			return JSONError{http.StatusNotFound, "User not found"}
		}
		_, err = db.Exec(deleteRequestSQL, hash)
		return err
	}); err == nil {
		body := changedMessageBody(useHTTPS(req), user, password, req.Host)
		if err = misc.SendMail(email, "Password Reset Done", body); err == nil {
			jr.Result = &struct {
				SendTo string `json:"send-to"`
			}{email}
		}
	}
	return
}