Artifact 7827bbc0f8096ae1c4b2dd856bbc8342dbf58040406dc880c2b6df93099096d8:
- File
internal/oltp/session.go
— part of check-in
[7bdf7a189b]
at
2026-08-17 00:10:56
on branch trunk
— web: cookie sessions and a login page
Basic auth works and stays for the API, but a browser has no way to log out of it and the credential lives in the browser's keeping. A gated page now redirects to /login instead of raising a Basic prompt, the header names who is signed in and offers sign-out, and Basic remains for scripts.
Session tokens are stored as SHA-256 digests, so the table cannot be read back into working cookies. Cookies are HttpOnly, Path=/, SameSite=Lax — Lax being what stops another origin POSTing quarry's write forms on a reader's behalf — and Secure under TLS.
A password change or an account deletion ends that account's sessions; otherwise 'change your password' would leave the browser already holding one still signed in. Authorization for a cookie runs the same RoleOf lookup the password path uses, so a cookie is never worth more than a password.
safeNext keeps the post-login redirect on this site: a reader is at their most trusting on the hop straight after authenticating, which is exactly when an open redirect pays. (user: dmestas, size: 3437) [annotate] [blame] [check-ins using]
// 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 }