Skip to content
quarry Artifact [e75e698fed]
Login

Artifact [e75e698fed]

Artifact e75e698fed4c97062f20a6f35989899ae142f1cf9924b03ce1f3aecdda7d88ce:


// 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"
	"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
)

// 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))
	if _, err := d.w.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
	}
	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
	}
	var expires, revoked string
	err := d.db.QueryRow(`SELECT expires, revoked FROM token WHERE id = ? AND account = ?`, tokenID, account).Scan(&expires, &revoked)
	if errors.Is(err, sql.ErrNoRows) {
		return false, true
	}
	if err != nil {
		return false, false
	}
	exp, perr := time.Parse(time.RFC3339, expires)
	if revoked != "" || perr != nil || !time.Now().Before(exp) {
		return false, true
	}
	return true, false
}