// 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
}