Skip to content
quarry Artifact [484875e03b]
Login

Artifact [484875e03b]

Artifact 484875e03b7a8d6f37006b23c06a6ea9a533b29b0b62fc753c7fedf5e5414389:


// signinlink.go is RFC 0021's web sign-in: a token with web scope mints
// a one-time link, and opening it opens one session bound to that
// token.
//
// It is its own table, not a third kind of invite. InviteFor and
// RedeemInvite never look at what a row is for, so a sign-in row there
// would redeem as an invite and set a password on an agent. Here the
// invite readers cannot see it, and this table's reader cannot set a
// password.
package oltp

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

	"craftdesign.group/quarry/internal/ops"
)

const signinLinkSchema = `
CREATE TABLE IF NOT EXISTS signin_link (
	token    TEXT PRIMARY KEY, -- SHA-256 of the link's value, never the value
	account  TEXT NOT NULL,
	token_id TEXT NOT NULL,    -- the token that minted it; the session it opens is bound to it
	next     TEXT NOT NULL DEFAULT '',
	created  TEXT NOT NULL,
	expires  TEXT NOT NULL,
	used     TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS signin_link_by_account ON signin_link (account);`

const (
	// SigninLinkTTL is how long a link can still be opened. The URL is
	// a secret only for this long.
	SigninLinkTTL = 2 * time.Minute
	// TokenSessionTTL is how long a session opened by a link lasts: a
	// machine that needs one tomorrow mints another link.
	TokenSessionTTL = 24 * time.Hour
	// signinLinkKeep is how long a spent or lapsed row is kept before
	// the next mint sweeps it.
	signinLinkKeep = 24 * time.Hour
	maxSigninNext  = 512
	// SigninLinksPerHour bounds the links one token mints in an hour: a
	// session lasts a day, so a machine minting one a minute is looping.
	SigninLinksPerHour = 30
)

// MintSigninLink makes a link for the live token id of account, which
// must carry web scope. The value is returned the only time it is
// readable.
func (d *DB) MintSigninLink(account, tokenID, next string) (string, time.Time, error) {
	t, ok := d.LiveToken(account, tokenID)
	if !ok || t.Run != "" {
		return "", time.Time{}, Refusal{"a sign-in link is minted by a live token of an account"}
	}
	if !t.Has(ScopeWeb) {
		return "", time.Time{}, Refusal{"this token has no web scope, so it cannot mint a sign-in link"}
	}
	if len(next) > maxSigninNext || (next != "" && !ops.LocalPath(next)) {
		return "", time.Time{}, Refusal{"next is a path on this forge, such as /r/quarry"}
	}
	var buf [32]byte
	if _, err := rand.Read(buf[:]); err != nil {
		return "", time.Time{}, err
	}
	link := hex.EncodeToString(buf[:])
	now := time.Now().UTC()
	expires := now.Add(SigninLinkTTL)
	d.w.Exec(`DELETE FROM signin_link WHERE expires < ?`, now.Add(-signinLinkKeep).Format(time.RFC3339))
	// Counted and written in one transaction, so two mints racing at the
	// bound cannot both pass it.
	tx, err := d.w.Begin()
	if err != nil {
		return "", time.Time{}, err
	}
	defer tx.Rollback()
	var minted int
	if err := tx.QueryRow(`SELECT COUNT(*) FROM signin_link WHERE token_id = ? AND created > ?`,
		tokenID, now.Add(-time.Hour).Format(time.RFC3339)).Scan(&minted); err != nil {
		return "", time.Time{}, err
	}
	if minted >= SigninLinksPerHour {
		return "", time.Time{}, Refusal{fmt.Sprintf("this token has minted %d sign-in links in the last hour; a session lasts a day, so reuse one", SigninLinksPerHour)}
	}
	if _, err := tx.Exec(`INSERT INTO signin_link (token, account, token_id, next, created, expires)
		VALUES (?, ?, ?, ?, ?, ?)`, hashToken(link), account, tokenID, next,
		now.Format(time.RFC3339), expires.Format(time.RFC3339)); err != nil {
		return "", time.Time{}, err
	}
	if err := tx.Commit(); err != nil {
		return "", time.Time{}, err
	}
	return link, expires, nil
}

// RedeemSigninLink spends link and opens a session bound to the token
// that minted it, returning the cookie value, when it ends and where
// to go next. ok is false for a link that is unknown, spent, late, or
// whose token has since ended: one answer for all of them.
//
// The spend is the decision, as RedeemInvite's is: the conditional
// UPDATE runs first, so of two opens racing on one link exactly one
// proceeds. It stands whatever follows, so a link whose token was
// revoked is spent too.
func (d *DB) RedeemSigninLink(link, ip, agent string) (cookie string, expires time.Time, next string, ok bool) {
	tx, err := d.w.Begin()
	if err != nil {
		return "", time.Time{}, "", false
	}
	defer tx.Rollback()
	now := time.Now().UTC()
	stamp := now.Format(time.RFC3339)
	h := hashToken(link)
	res, err := tx.Exec(`UPDATE signin_link SET used = ? WHERE token = ? AND used = '' AND expires > ?`, stamp, h, stamp)
	if err != nil {
		return "", time.Time{}, "", false
	}
	if n, _ := res.RowsAffected(); n != 1 {
		return "", time.Time{}, "", false
	}
	var account, tokenID string
	if err := tx.QueryRow(`SELECT account, token_id, next FROM signin_link WHERE token = ?`, h).Scan(&account, &tokenID, &next); err != nil {
		return "", time.Time{}, "", false
	}
	t, err := scanToken(tx.QueryRow(`SELECT `+tokenColumns+` FROM token WHERE id = ? AND account = ?`, tokenID, account))
	if err != nil || !t.Live(now) || !t.Has(ScopeWeb) || !d.mayUseTokens(account) {
		tx.Commit() // the link stays spent
		return "", time.Time{}, "", false
	}
	var buf [32]byte
	if _, err := rand.Read(buf[:]); err != nil {
		return "", time.Time{}, "", false
	}
	cookie = hex.EncodeToString(buf[:])
	expires = now.Add(TokenSessionTTL)
	if tokenEnds, perr := time.Parse(time.RFC3339, t.Expires); perr == nil && tokenEnds.Before(expires) {
		expires = tokenEnds
	}
	if _, err := tx.Exec(`INSERT INTO session (token, account, expires, created, ip, agent, last_seen, token_id)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, hashToken(cookie), account, expires.Format(time.RFC3339), stamp,
		ip, trimAgent(agent), stamp, tokenID); err != nil {
		return "", time.Time{}, "", false
	}
	if _, err := tx.Exec(`UPDATE account SET last_login = ?, last_seen = ? WHERE name = ?`, stamp, stamp, account); err != nil {
		return "", time.Time{}, "", false
	}
	if err := tx.Commit(); err != nil {
		return "", time.Time{}, "", false
	}
	return cookie, expires, next, true
}

// SessionPrincipal resolves a cookie to its account and, for a session a
// sign-in link opened, the token it is bound to. A session a person
// opened is bound to none. The web layer puts the token on the request
// so the page gates read the token's letters, not the account's: a
// session from a link is never wider than the token that made it.
//
// ok is false when the session is not good. A bound session whose
// token does not resolve is not good either, and is ended: it must
// never be read as an unbound one.
func (d *DB) SessionPrincipal(cookie string) (acct Account, tok Token, bound, ok bool) {
	acct, tokenID, ok := d.sessionAccount(cookie)
	if !ok {
		return Account{}, Token{}, false, false
	}
	if tokenID == "" {
		return acct, Token{}, false, true
	}
	t, ended, ok := d.principalToken(acct.Name, tokenID)
	if !ok {
		if ended {
			d.EndSession(cookie)
		}
		return Account{}, Token{}, false, false
	}
	return acct, t, true, true
}

// principalToken is the live web token a session is bound to. When it
// is not, ended says whether the token is known gone, which is the only
// case that ends the session: a read that failed refuses it for now.
func (d *DB) principalToken(account, tokenID string) (t Token, ended, ok bool) {
	t, err := scanToken(d.db.QueryRow(`SELECT `+tokenColumns+` FROM token WHERE id = ? AND account = ?`, tokenID, account))
	if err != nil {
		return Token{}, errors.Is(err, sql.ErrNoRows), false
	}
	if !t.Live(time.Now()) || !t.Has(ScopeWeb) {
		return Token{}, true, false
	}
	if !d.mayUseTokens(account) {
		return Token{}, false, false
	}
	return t, false, true
}

// sessionTokenState says what became of the token a session is bound
// to. A read that fails says neither: the session is refused for now,
// not deleted for good.
func (d *DB) sessionTokenState(account, tokenID string) (live, ended bool) {
	if tokenID == "" {
		return true, false
	}
	t, err := scanToken(d.db.QueryRow(`SELECT `+tokenColumns+` FROM token WHERE id = ? AND account = ?`, tokenID, account))
	if errors.Is(err, sql.ErrNoRows) {
		return false, true
	}
	if err != nil {
		return false, false
	}
	// Live, not the expiry column: a token with no date lapses unused
	// (RFC 0026), and its sessions with it.
	if !t.Live(time.Now()) {
		return false, true
	}
	return true, false
}