view pkg/controllers/pwreset.go @ 501:c10c76c92797 metamorph-for-all

Use metamorphic database connections for auth.RunAs().
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Fri, 24 Aug 2018 15:30:31 +0200
parents b2dc9c2f69e0
children b96b1b258cfa
line wrap: on
line source

package controllers

import (
	"bytes"
	"context"
	"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/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 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 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", "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.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()

	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 much password reset request",
				}
			}

			err := conn.QueryRowContext(ctx, 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 := conn.QueryRowContext(
				ctx, 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 = 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 {
			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
}