view pkg/controllers/pwreset.go @ 521:139214cecc8f

backend: add FIXMEs to password reset. * Add FIXMEs to write down knowledge about how the password request probably has to be improved.
author Bernhard Reiter <bernhard@intevation.de>
date Mon, 27 Aug 2018 17:09:51 +0200
parents 9b3db1d7a7eb
children da5f47a0941c
line wrap: on
line source

package controllers

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/hex"
	"log"
	"net/http"
	"os/exec"
	"strconv"
	"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/misc"
	"gemma.intevation.de/gemma/pkg/models"
)

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

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

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

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

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

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

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

	updatePasswordSQL = `UPDATE users.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
)

const pwResetRole = "sys_admin"

var (
	passwordResetRequestMailTmpl = template.Must(
		template.New("request").Parse(`You or someone else has requested a password change
for your account {{ .User }} on
{{ .HTTPS }}://{{ .Server }}

Please follow this link to have a new password generated and mailed to you:

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

The link is only valid for 12 hours.

If you did not initiate this password reset or do not want to reset the
password, just ignore this email.

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 init() {
	go removeOutdated()
}

func removeOutdated() {
	for {
		time.Sleep(cleanupPause)
		err := auth.RunAs(
			pwResetRole, context.Background(),
			func(conn *sql.Conn) error {
				good := time.Now().Add(-passwordResetValid)
				_, err := conn.ExecContext(
					context.Background(), 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", strconv.Itoa(passwordLength), "1").Output()
	if err == nil {
		return strings.TrimSpace(string(out))
	}
	// Use internal generator.
	return common.RandomString(passwordLength)
}

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

	user := input.(*models.PWResetUser)

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

	var hash, email string

	ctx := req.Context()

	// FIXME, we need to always answer with a neutral messages
	// to avoid becoming an oracle about which user exists to third parties.

	// Error messages need to be logged instead of being send to the user.
	//
	// const neutralMessage = "If this account exists, a reset link will be mailed."

	// FIXME responding should be done it a goroutine of its own so its
	// executing time is constant (to avoid becoming an oracle over the
	// response time).
	if err = auth.RunAs(
		pwResetRole, ctx,
		func(conn *sql.Conn) error {

			var count int64
			if err := conn.QueryRowContext(
				ctx, countRequestsSQL).Scan(&count); err != nil {
				return err
			}

			// Limit total number of password requests.
			if count >= maxPasswordResets {
				return JSONError{
					Code:    http.StatusServiceUnavailable,
					Message: "Too many requests for the server, please notify the administrator.",
				}
			}

			err := conn.QueryRowContext(ctx, userExistsSQL, user.User).Scan(&email)

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

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

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

			hash = generateHash()
			_, err = conn.ExecContext(ctx, 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 {
			//FIXME change to logging
			jr.Result = &struct {
				SendTo string `json:"send-to"`
			}{email}
		}
	}
	return
}

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

	ctx := req.Context()

	if err = auth.RunAs(
		pwResetRole, ctx, func(conn *sql.Conn) error {
			err := conn.QueryRowContext(ctx, 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 := conn.ExecContext(ctx, 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 = conn.ExecContext(ctx, 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
}