// 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.
//
// Proof is a short numeric CODE, read out of an inbox and typed back
// into the account's own page — never a link. A link can be forwarded,
// clicked by whoever a browser hands it to, or land in a chat log; a
// code that only the signed-in account itself may spend has nowhere to
// go but back into the session that asked for it.
package oltp
import (
"crypto/rand"
"crypto/subtle"
"database/sql"
"errors"
"fmt"
"math/big"
"time"
)
// EmailTTL is how long a confirmation code stays live: short, because a
// code is read and typed back at once or not at all, and a window long
// enough for "I'll get to it" is also long enough for a code sitting in
// an inbox or on a screen to still work for somebody else.
const EmailTTL = 10 * time.Minute
// maxCodeAttempts bounds a guess at the six digits to a handful of
// tries against any one code rather than the whole TTL window: the
// fifth wrong guess burns the code exactly as an expired one would, and
// the account asks for a fresh one the same way either time.
const maxCodeAttempts = 5
// emailMigrations: the address typed but not yet confirmed, and how
// many wrong codes have been tried against the pending one.
var emailMigrations = []string{
`ALTER TABLE account ADD COLUMN email_pending TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE email_token ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0`,
}
const emailSchema = `
CREATE TABLE IF NOT EXISTS email_token (
token TEXT PRIMARY KEY, -- SHA-256 of "account:code", never the code itself
account TEXT NOT NULL,
address TEXT NOT NULL,
created TEXT NOT NULL,
expires TEXT NOT NULL,
used TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS email_token_by_account ON email_token (account);`
// randomCode is a uniform six-digit code, zero-padded. crypto/rand
// rather than math/rand: a code guessable by anybody who can predict a
// seed would make maxCodeAttempts theatre.
func randomCode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
// codeToken is the row's primary key. The code alone would collide
// across two accounts who happen to land on the same six digits, so the
// account name is folded into the hash with it — the row's identity is
// "this account's live code", not "this code".
func codeToken(account, code string) string {
return hashToken(account + ":" + code)
}
// SetPendingEmail records the address an account wants to confirm and
// mints its one-time code. Calling it again before the code is used
// replaces both the pending address and the code — the same "latest
// wins" rule InviteAccount uses for a re-invite, and for the same
// reason: asking twice because the first code was lost or mistyped is
// the common case, not the exception. Any code already outstanding for
// this account is spent in the same breath, so a stale one cannot be
// confirmed after a newer one was requested.
func (d *DB) SetPendingEmail(name, address string) (code string, err error) {
if address == "" {
return "", errors.New("oltp: empty email address")
}
code, err = randomCode()
if err != nil {
return "", err
}
now := time.Now().UTC()
if _, err := d.db.Exec(`UPDATE account SET email_pending = ? WHERE name = ?`, address, name); err != nil {
return "", err
}
if _, err := d.db.Exec(`UPDATE email_token SET used = ? WHERE account = ? AND used = ''`,
now.Format(time.RFC3339), name); err != nil {
return "", err
}
_, err = d.db.Exec(`INSERT INTO email_token (token, account, address, created, expires) VALUES (?, ?, ?, ?, ?)`,
codeToken(name, code), name, address, now.Format(time.RFC3339), now.Add(EmailTTL).Format(time.RFC3339))
return code, err
}
// ConfirmEmail checks code against account's own live pending code —
// there is never a reason to check anybody else's, because the caller
// is always the signed-in account asking about itself. A wrong code
// counts an attempt against the row; the attempt that reaches
// maxCodeAttempts burns it outright, indistinguishable from an expired
// or already-used code, so the account's only way forward either way is
// a fresh me.email.set. A right code promotes the pending address to
// confirmed and spends the row.
func (d *DB) ConfirmEmail(account, code string) (address string, ok bool, err error) {
var token, addr, used, expires string
var attempts int
// rowid DESC breaks a tie in created, which is second-resolution
// RFC3339: a replace-in-the-same-second (SetPendingEmail called
// twice in a burst) must still resolve to the NEWEST row.
row := d.db.QueryRow(`SELECT token, address, used, expires, attempts FROM email_token
WHERE account = ? ORDER BY created DESC, rowid DESC LIMIT 1`, account)
if serr := row.Scan(&token, &addr, &used, &expires, &attempts); serr != nil {
if errors.Is(serr, sql.ErrNoRows) {
return "", false, nil
}
return "", false, serr
}
exp, perr := time.Parse(time.RFC3339, expires)
if used != "" || perr != nil || time.Now().UTC().After(exp) {
return "", false, nil
}
// Compared as the stored and submitted HASHES, both fixed-length
// hex — constant time regardless of where the six digits differ.
if subtle.ConstantTimeCompare([]byte(token), []byte(codeToken(account, code))) != 1 {
attempts++
if attempts >= maxCodeAttempts {
d.db.Exec(`UPDATE email_token SET used = ? WHERE token = ?`, time.Now().UTC().Format(time.RFC3339), token)
} else {
d.db.Exec(`UPDATE email_token SET attempts = ? WHERE token = ?`, attempts, token)
}
return "", false, nil
}
if _, err := d.db.Exec(`UPDATE account SET email = ?, email_pending = '' WHERE name = ?`, addr, account); err != nil {
return "", false, err
}
if _, err := d.db.Exec(`UPDATE email_token SET used = ? WHERE token = ?`,
time.Now().UTC().Format(time.RFC3339), token); err != nil {
return "", false, err
}
return addr, true, nil
}
// ClearEmail blanks both the confirmed and pending address. Live codes
// 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
}