Skip to content
quarry Artifact [7827bbc0f8]
Login

Artifact [7827bbc0f8]

Artifact 7827bbc0f8096ae1c4b2dd856bbc8342dbf58040406dc880c2b6df93099096d8:


// session.go backs the browser half of the write gate. HTTP Basic works
// and is what the API uses, but it gives a person no way to log out and
// leaves the credential in the browser's keeping.
package oltp

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"time"
)

const sessionSchema = `
CREATE TABLE IF NOT EXISTS session (
	token   TEXT PRIMARY KEY, -- SHA-256 of the cookie value, never the value
	account TEXT NOT NULL,
	expires TEXT NOT NULL,    -- RFC3339
	created TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS session_by_account ON session (account);`

// SessionTTL is how long a login lasts. Long enough to be usable, short
// enough that a forgotten browser stops being a standing key.
const SessionTTL = 14 * 24 * time.Hour

// hashToken is what the database stores. A session table that can be
// read back into working cookies turns a backup, a log or a stray SELECT
// into a set of live logins; storing the digest means the cookie itself
// exists only in the browser.
func hashToken(token string) string {
	sum := sha256.Sum256([]byte(token))
	return hex.EncodeToString(sum[:])
}

// NewSession authenticates and issues a session token. The token is
// returned once, to be set as a cookie; it is not recoverable later.
func (d *DB) NewSession(name, password string) (token string, expires time.Time, ok bool) {
	acct, ok := d.Authenticate(name, password)
	if !ok {
		return "", time.Time{}, false
	}
	var buf [32]byte
	if _, err := rand.Read(buf[:]); err != nil {
		return "", time.Time{}, false
	}
	token = hex.EncodeToString(buf[:])
	expires = time.Now().UTC().Add(SessionTTL)
	if _, err := d.db.Exec(`INSERT INTO session (token, account, expires, created)
		VALUES (?, ?, ?, ?)`, hashToken(token), acct.Name,
		expires.Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339)); err != nil {
		return "", time.Time{}, false
	}
	return token, expires, true
}

// SessionAccount resolves a cookie to its account, or reports that it is
// unknown or expired. An expired row is deleted on sight rather than
// left to a sweeper: the cheapest place to notice is where it is read.
func (d *DB) SessionAccount(token string) (Account, bool) {
	if token == "" {
		return Account{}, false
	}
	h := hashToken(token)
	var name, expires string
	if err := d.db.QueryRow(`SELECT account, expires FROM session WHERE token = ?`,
		h).Scan(&name, &expires); err != nil {
		return Account{}, false
	}
	exp, err := time.Parse(time.RFC3339, expires)
	if err != nil || time.Now().UTC().After(exp) {
		d.db.Exec(`DELETE FROM session WHERE token = ?`, h)
		return Account{}, false
	}
	var admin int
	var created string
	if err := d.db.QueryRow(`SELECT admin, created FROM account WHERE name = ?`,
		name).Scan(&admin, &created); err != nil {
		// The account was deleted while a session was live; the session
		// dies with it.
		d.db.Exec(`DELETE FROM session WHERE token = ?`, h)
		return Account{}, false
	}
	return Account{Name: name, Admin: admin != 0, Created: created}, true
}

// EndSession logs one cookie out.
func (d *DB) EndSession(token string) {
	if token != "" {
		d.db.Exec(`DELETE FROM session WHERE token = ?`, hashToken(token))
	}
}

// EndAccountSessions logs an account out everywhere — what a password
// change means, and the only way a stolen cookie stops working.
func (d *DB) EndAccountSessions(name string) error {
	_, err := d.db.Exec(`DELETE FROM session WHERE account = ?`, name)
	return err
}