view pkg/controllers/pwreset.go @ 5490:5f47eeea988d logging

Use own logging package.
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Mon, 20 Sep 2021 17:45:39 +0200
parents 4394daeea96a
children
line wrap: on
line source

// This is Free Software under GNU Affero General Public License v >= 3.0
// without warranty, see README.md and license for details.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// License-Filename: LICENSES/AGPL-3.0.txt
//
// Copyright (C) 2018 by via donau
//   – Österreichische Wasserstraßen-Gesellschaft mbH
// Software engineering by Intevation GmbH
//
// Author(s):
//  * Sascha L. Teichmann <sascha.teichmann@intevation.de>
//  * Bernhard E. Reiter <bernhard.reiter@intevation.de>
//  * Tom Gottfried <tom.gottfried.intevation.de>

package controllers

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/hex"
	"errors"
	"io"
	"net/http"
	"os/exec"
	"strconv"
	"strings"
	"time"

	htmlTemplate "html/template"
	textTemplate "text/template"

	"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"
	"gemma.intevation.de/gemma/pkg/models"

	"gemma.intevation.de/gemma/pkg/log"
	mw "gemma.intevation.de/gemma/pkg/middleware"
)

const (
	insertRequestSQL = `INSERT INTO sys_admin.password_reset_requests
    (hash, username) VALUES ($1, $2)
	ON CONFLICT (username) DO UPDATE SET hash = $1`

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

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

	findRequestSQL = `SELECT 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`

	deletePasswordResetRequestSQL = `
    DELETE FROM sys_admin.password_reset_requests
    WHERE username = $1`
)

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

const pwResetRole = "sys_admin"

var (
	errTooMuchPasswordResets = errors.New("too many password resets")
	errNoSuchUser            = errors.New("user does not exist")
	errInvalidUser           = errors.New("invalid user")
)

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

Please follow this link to have a new password generated:

{{ .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. Logging in with your old password
before following the link will cancel this password reset request, too.

Best regards
    Your service team`))

	passwordResetPage = htmlTemplate.Must(
		htmlTemplate.New("page").Parse(`<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Password reset done</title>
  </head>
  <body>
    <p>The password reset for user <strong><tt>{{ .User }}</tt></strong> successfully done.</p>
    <p>New password: <strong><tt>{{ .Password }}</tt></strong></p>
    <p><a href="/">Go to login page.</a></p>
  </body>
</html>
`))
)

func init() {
	go removeOutdated()
}

func removeOutdated() {
	config.WaitReady()
	for {
		time.Sleep(cleanupPause)
		ctx := context.Background()
		err := auth.RunAs(
			ctx, pwResetRole,
			func(conn *sql.Conn) error {
				good := time.Now().Add(-passwordResetValid)
				_, err := conn.ExecContext(
					ctx, cleanupRequestsSQL, good)
				return err
			})
		if err != nil {
			log.Errorf("%v\n", err)
		}
	}
}

func requestMessageBody(user, hash, server string) string {
	var content = struct {
		User   string
		Server string
		Hash   string
	}{
		User:   user,
		Server: server,
		Hash:   hash,
	}
	var buf bytes.Buffer
	if err := passwordResetRequestMailTmpl.Execute(&buf, &content); err != nil {
		log.Errorf("%v\n", err)
	}
	return buf.String()
}

func changedMessageBody(w io.Writer, user, password string) error {
	var content = struct {
		User     string
		Password string
	}{
		User:     user,
		Password: password,
	}
	return passwordResetPage.Execute(w, &content)
}

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 backgroundRequest(host string, user *models.PWResetUser) error {

	if user.User == "" {
		return errInvalidUser
	}

	var hash, email string

	ctx := context.Background()

	if err := auth.RunAs(
		ctx, pwResetRole,
		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 errTooMuchPasswordResets
			}

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

			switch {
			case err == sql.ErrNoRows:
				return errNoSuchUser
			case err != nil:
				return err
			}

			hash = generateHash()
			_, err = conn.ExecContext(ctx, insertRequestSQL, hash, user.User)
			return err
		},
	); err != nil {
		return err
	}

	body := requestMessageBody(user.User, hash, host)

	return misc.SendMail(email, "Password Reset Link", body)
}

func passwordResetRequest(req *http.Request) (jr mw.JSONResult, err error) {

	// We do the checks and the emailing in background
	// no reduce the risks of timing attacks.
	go func(user *models.PWResetUser) {
		config.WaitReady()
		host := config.ExternalURL()
		if err := backgroundRequest(host, user); err != nil {
			log.Errorf("%v\n", err)
		}
	}(mw.JSONInput(req).(*models.PWResetUser))

	// Send a neutral message to avoid being an user oracle.
	const neutralMessage = "If this account exists, a reset link will be mailed."

	jr.Result = &struct {
		Message string `json:"message"`
	}{neutralMessage}

	return
}

func passwordReset(rw http.ResponseWriter, req *http.Request) {

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

	var user, password string

	ctx := req.Context()

	err := auth.RunAs(
		ctx, pwResetRole,
		func(conn *sql.Conn) error {
			tx, err := conn.BeginTx(ctx, nil)
			if err != nil {
				return err
			}
			defer tx.Rollback()

			err = tx.QueryRowContext(ctx, findRequestSQL, hash).Scan(&user)
			switch {
			case err == sql.ErrNoRows:
				return errors.New("this URL is no longer valid")
			case err != nil:
				return err
			}
			password = generateNewPassword()
			res, err := tx.ExecContext(ctx, updatePasswordSQL, password, user)
			if err != nil {
				return err
			}
			if n, err2 := res.RowsAffected(); err2 == nil && n == 0 {
				return errors.New("user not found")
			}
			if _, err = tx.ExecContext(ctx, deleteRequestSQL, hash); err != nil {
				return err
			}
			return tx.Commit()
		},
	)

	switch {
	case err == sql.ErrNoRows:
		http.Error(rw, "No such request", http.StatusNotFound)
		return
	case err != nil:
		http.Error(rw, "Error: "+err.Error(), http.StatusInternalServerError)
		return
	}

	if err := changedMessageBody(rw, user, password); err != nil {
		log.Errorf("%v\n", err)
	}
}

func deletePasswordResetRequest(user string) {
	ctx := context.Background()
	if err := auth.RunAs(
		ctx,
		pwResetRole,
		func(conn *sql.Conn) error {
			_, err := conn.ExecContext(ctx, deletePasswordResetRequestSQL, user)
			return err
		},
	); err != nil {
		log.Errorf("%v\n", err)
	}
}