Skip to content
quarry Artifact [ca2342905a]
Login

Artifact [ca2342905a]

Artifact ca2342905abebeada5a278058a0b243fa9126e94fc840666472e7e2ce543a11f:


package oltp

import (
	"crypto/rand"
	"database/sql"
	"encoding/hex"
	"errors"
	"fmt"
	"time"
)

// A forge with no password reset has one recovery path: an operator
// choosing a password on somebody's behalf and repeating it to them
// down a channel neither of them controls. That is the worst way to
// move a credential, and it was quarry's only way.
//
// The machinery to do it properly already existed for INVITES — a
// one-time token, hashed at rest, spent on redemption, expiring on its
// own — and an invite's last step is exactly a reset's: the person
// chooses their own password and nobody else ever sees it. The only
// thing in the way was InviteAccount refusing an account that already
// had one.

// resetMigrations tell the two token kinds apart. Rows written before
// this are invites, which is what the default says.
var resetMigrations = []string{
	`ALTER TABLE invite ADD COLUMN kind TEXT NOT NULL DEFAULT 'invite'`,
}

// ResetTTL is how long a reset link stays good.
//
// An hour, where an invite gets seven days, because the two are asked
// for at different moments: an invite is sent to somebody who is not
// waiting for it, a reset to somebody who just asked and is sitting
// there. A live token is a credential, so the window is the one the
// person actually needs and not the one that is convenient.
const ResetTTL = time.Hour

// ResetPassword mints a one-time token that lets an existing account
// choose a new password.
//
// The old password KEEPS WORKING until the token is spent. A reset
// that blanked it would lock the person out harder the moment the link
// went astray, which is the failure this exists to end rather than to
// reproduce.
//
// Minting supersedes: any unspent token for the account is dropped
// first, so "reset it again" cannot leave two live credentials behind
// where the operator believes there is one.
func (d *DB) ResetPassword(name string) (token string, err error) {
	if name == "" {
		return "", errors.New("oltp: empty account name")
	}
	var pending bool
	err = d.db.QueryRow(`SELECT pwhash = '' FROM account WHERE name = ?`, name).Scan(&pending)
	switch {
	case errors.Is(err, sql.ErrNoRows):
		return "", fmt.Errorf("oltp: no such account %q", name)
	case err != nil:
		return "", err
	case pending:
		// Nothing to reset: this account has never been claimed, and
		// the thing it wants is the invite it is already waiting on.
		return "", fmt.Errorf("oltp: account %q has not set a password yet — invite it instead", name)
	}
	if _, err := d.db.Exec(`DELETE FROM invite WHERE account = ? AND used = ''`, name); err != nil {
		return "", err
	}
	var buf [32]byte
	if _, err := rand.Read(buf[:]); err != nil {
		return "", err
	}
	token = hex.EncodeToString(buf[:])
	now := time.Now().UTC()
	_, err = d.db.Exec(
		`INSERT INTO invite (token, account, created, expires, kind) VALUES (?, ?, ?, ?, 'reset')`,
		hashToken(token), name, now.Format(time.RFC3339), now.Add(ResetTTL).Format(time.RFC3339))
	return token, err
}

// TokenKind says whether a live token is an invite or a reset, so the
// page it opens can use the right words: "finish setting up" and
// "choose a new password" are different sentences to different people.
func (d *DB) TokenKind(token string) string {
	var kind string
	if err := d.db.QueryRow(`SELECT kind FROM invite WHERE token = ?`,
		hashToken(token)).Scan(&kind); err != nil {
		return "invite"
	}
	if kind == "" {
		return "invite"
	}
	return kind
}