Mercurial > gemma
view pkg/controllers/pwreset.go @ 5652:9b3779f649c2 v5.7.1
Bumped version for release.
author | Sascha Wilde <wilde@sha-bang.de> |
---|---|
date | Mon, 07 Aug 2023 17:59:18 +0200 |
parents | 5f47eeea988d |
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) } }