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
}