Skip to content
quarry Artifact [cb26c51206]
Login

Artifact [cb26c51206]

Artifact cb26c51206b6e41448626f65480bc398998d36f54f34f64518cb9a2ac83b6062:


// email.go is the self-serve half of an account's reachability: the
// address a person typed and PROVED receipt of, as opposed to the one
// account.go's email column already carries when an admin or an
// invite asserts it directly (that trust is the same trust as setting
// somebody's password for them). Confirmation exists because a typo,
// or someone else's inbox, must not quietly become where quarry's
// account mail — including the access-change mail transactional.go
// sends — goes from that moment on.
package oltp

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

// EmailTTL is shorter than InviteTTL: a confirmation link is acted on
// at once or not at all, and a day is generous for "I'll get to it"
// without leaving a stale link that changes an address live for a week.
const EmailTTL = 24 * time.Hour

// emailMigrations: the address typed but not yet confirmed. Separate
// from account.email, which is what mail actually goes to — a
// half-finished change must not overwrite the working address before
// the new one is proven.
var emailMigrations = []string{
	`ALTER TABLE account ADD COLUMN email_pending TEXT NOT NULL DEFAULT ''`,
}

const emailSchema = `
CREATE TABLE IF NOT EXISTS email_token (
	token   TEXT PRIMARY KEY, -- SHA-256 of the link's token, never the token
	account TEXT NOT NULL,
	address TEXT NOT NULL,
	created TEXT NOT NULL,
	expires TEXT NOT NULL,
	used    TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS email_token_by_account ON email_token (account);`

// SetPendingEmail records the address an account wants to confirm and
// mints the one-time token its link carries. Calling it again before
// the first token is used replaces both the pending address and the
// token — the same "latest wins" rule InviteAccount uses for a
// re-invite, and for the same reason: a lost email is the common case.
func (d *DB) SetPendingEmail(name, address string) (token string, err error) {
	if address == "" {
		return "", errors.New("oltp: empty email address")
	}
	now := time.Now().UTC()
	if _, err := d.db.Exec(`UPDATE account SET email_pending = ? WHERE name = ?`, address, name); err != nil {
		return "", err
	}
	var buf [32]byte
	if _, err := rand.Read(buf[:]); err != nil {
		return "", err
	}
	token = hex.EncodeToString(buf[:])
	_, err = d.db.Exec(`INSERT INTO email_token (token, account, address, created, expires) VALUES (?, ?, ?, ?, ?)`,
		hashToken(token), name, address, now.Format(time.RFC3339), now.Add(EmailTTL).Format(time.RFC3339))
	return token, err
}

// EmailTokenAccount names the account a live confirmation token
// belongs to, WITHOUT spending it — InviteFor's role for invites, and
// what lets a caller check ownership (this token is mine) before
// ConfirmEmail acts, rather than after.
func (d *DB) EmailTokenAccount(token string) (account string, ok bool) {
	var used, expires string
	if err := d.db.QueryRow(`SELECT account, used, expires FROM email_token WHERE token = ?`,
		hashToken(token)).Scan(&account, &used, &expires); err != nil {
		return "", false
	}
	exp, err := time.Parse(time.RFC3339, expires)
	if used != "" || err != nil || time.Now().UTC().After(exp) {
		return "", false
	}
	return account, true
}

// ConfirmEmail promotes a live token's pending address to confirmed
// and spends the token. The pending column is cleared either way this
// lands: once an address is confirmed, there is nothing left pending.
func (d *DB) ConfirmEmail(token string) (account, address string, ok bool, err error) {
	var used, expires string
	if err := d.db.QueryRow(`SELECT account, address, used, expires FROM email_token WHERE token = ?`,
		hashToken(token)).Scan(&account, &address, &used, &expires); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return "", "", false, nil
		}
		return "", "", false, err
	}
	exp, perr := time.Parse(time.RFC3339, expires)
	if used != "" || perr != nil || time.Now().UTC().After(exp) {
		return "", "", false, nil
	}
	if _, err := d.db.Exec(`UPDATE account SET email = ?, email_pending = '' WHERE name = ?`, address, account); err != nil {
		return "", "", false, err
	}
	if _, err := d.db.Exec(`UPDATE email_token SET used = ? WHERE token = ?`,
		time.Now().UTC().Format(time.RFC3339), hashToken(token)); err != nil {
		return "", "", false, err
	}
	return account, address, true, nil
}

// ClearEmail blanks both the confirmed and pending address. Live
// tokens are spent rather than left alone: a confirm arriving after a
// clear must not resurrect the address that was just removed.
func (d *DB) ClearEmail(name string) error {
	if _, err := d.db.Exec(`UPDATE account SET email = '', email_pending = '' WHERE name = ?`, name); err != nil {
		return err
	}
	_, err := d.db.Exec(`UPDATE email_token SET used = ? WHERE account = ? AND used = ''`,
		time.Now().UTC().Format(time.RFC3339), name)
	return err
}