repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

6.2 KB · 208 lines · Go Raw History
  1package main
  2
  3import (
  4	"crypto/rand"
  5	"crypto/sha256"
  6	"database/sql"
  7	"encoding/base64"
  8	"errors"
  9	"net/http"
 10	"os"
 11	"time"
 12)
 13
 14const (
 15	sessionCookie = "bw_session"
 16	sessionTTL    = 30 * 24 * time.Hour
 17
 18	// How long after proving possession of the phone a session may still change
 19	// the credentials. Long enough to finish what the login was for, short
 20	// enough that a cookie stolen later cannot rotate the recovery codes.
 21	sudoWindow = 10 * time.Minute
 22
 23	// last_seen is a display field, so writing it on every request would cost a
 24	// database write per page load to move a number a person reads once.
 25	lastSeenResolution = 5 * time.Minute
 26)
 27
 28// cookieDomain is what makes one login cover every bythewood.me host. Empty
 29// leaves the cookie host-only, which is what development on localhost needs,
 30// since a Domain of .bythewood.me is simply not sent to localhost and the login
 31// would appear to succeed and then not stick.
 32func cookieDomain() string { return os.Getenv("SESSION_DOMAIN") }
 33
 34// Session is one row of the list on the sessions page.
 35type Session struct {
 36	ID       int64
 37	Created  time.Time
 38	LastSeen time.Time
 39	Expires  time.Time
 40	IP       string
 41	Country  string
 42	City     string
 43	UA       string
 44	Current  bool
 45}
 46
 47var errNoSession = errors.New("no live session")
 48
 49// sessionHash is what the database holds. A leaked copy of this file is then a
 50// list of when somebody logged in and from where, and not a set of cookies
 51// somebody can paste into a browser.
 52func sessionHash(value string) []byte {
 53	sum := sha256.Sum256([]byte(value))
 54	return sum[:]
 55}
 56
 57// newSession mints an opaque identifier, records it, and returns the value for
 58// the cookie. The value is never stored and cannot be recovered.
 59func newSession(db *sql.DB, r *http.Request) (string, error) {
 60	raw := make([]byte, 32)
 61	if _, err := rand.Read(raw); err != nil {
 62		return "", err
 63	}
 64	value := base64.RawURLEncoding.EncodeToString(raw)
 65
 66	now := time.Now()
 67	c := requestContext(r)
 68	_, err := db.Exec(`
 69        INSERT INTO sessions (hash, created, last_seen, expires, sudo_at, ip, country, city, ua, cf_ray)
 70        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
 71		sessionHash(value), now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), now.Unix(),
 72		c.IP, c.Country, c.City, c.UA, c.Ray)
 73	if err != nil {
 74		return "", err
 75	}
 76	return value, nil
 77}
 78
 79func writeSessionCookie(w http.ResponseWriter, value string) {
 80	http.SetCookie(w, &http.Cookie{
 81		Name:   sessionCookie,
 82		Value:  value,
 83		Path:   "/",
 84		Domain: cookieDomain(),
 85		// SameSite=Strict rather than a CSRF token: every state-changing route
 86		// here is a POST, and a subdomain of the same registrable domain is
 87		// same-site, so moving between the sites keeps the cookie.
 88		SameSite: http.SameSiteStrictMode,
 89		HttpOnly: true,
 90		Secure:   true,
 91		MaxAge:   int(sessionTTL / time.Second),
 92	})
 93}
 94
 95func clearSessionCookie(w http.ResponseWriter) {
 96	http.SetCookie(w, &http.Cookie{
 97		Name:     sessionCookie,
 98		Value:    "",
 99		Path:     "/",
100		Domain:   cookieDomain(),
101		SameSite: http.SameSiteStrictMode,
102		HttpOnly: true,
103		Secure:   true,
104		MaxAge:   -1,
105	})
106}
107
108// live holds what a validated session can answer without a second query.
109type live struct {
110	ID     int64
111	Hash   []byte
112	SudoAt time.Time
113}
114
115// lookupSession validates the cookie against the table. Expiry is checked in
116// SQL so a row that has aged out is never returned, revoked or not.
117func lookupSession(db *sql.DB, r *http.Request) (live, error) {
118	c, err := r.Cookie(sessionCookie)
119	if err != nil || c.Value == "" {
120		return live{}, errNoSession
121	}
122	h := sessionHash(c.Value)
123
124	var (
125		id       int64
126		sudoAt   int64
127		lastSeen int64
128	)
129	err = db.QueryRow(`
130        SELECT id, sudo_at, last_seen FROM sessions
131        WHERE hash = ? AND revoked = 0 AND expires > ?`,
132		h, time.Now().Unix()).Scan(&id, &sudoAt, &lastSeen)
133	if errors.Is(err, sql.ErrNoRows) {
134		return live{}, errNoSession
135	}
136	if err != nil {
137		return live{}, err
138	}
139
140	now := time.Now()
141	if now.Sub(time.Unix(lastSeen, 0)) > lastSeenResolution {
142		// Best effort. A page must not fail because this write did.
143		_, _ = db.Exec(`UPDATE sessions SET last_seen = ? WHERE id = ?`, now.Unix(), id)
144	}
145
146	return live{ID: id, Hash: h, SudoAt: time.Unix(sudoAt, 0)}, nil
147}
148
149// inSudo reports whether this session proved possession recently enough to
150// change credentials.
151func (l live) inSudo() bool { return time.Since(l.SudoAt) < sudoWindow }
152
153// listSessions returns the live sessions, newest first, marking the caller's own.
154func listSessions(db *sql.DB, current []byte) ([]Session, error) {
155	rows, err := db.Query(`
156        SELECT id, hash, created, last_seen, expires, ip, country, city, ua
157        FROM sessions WHERE revoked = 0 AND expires > ?
158        ORDER BY last_seen DESC`, time.Now().Unix())
159	if err != nil {
160		return nil, err
161	}
162	defer rows.Close()
163
164	var out []Session
165	for rows.Next() {
166		var (
167			s                          Session
168			hash                       []byte
169			created, lastSeen, expires int64
170		)
171		if err := rows.Scan(&s.ID, &hash, &created, &lastSeen, &expires,
172			&s.IP, &s.Country, &s.City, &s.UA); err != nil {
173			return nil, err
174		}
175		s.Created = time.Unix(created, 0).UTC()
176		s.LastSeen = time.Unix(lastSeen, 0).UTC()
177		s.Expires = time.Unix(expires, 0).UTC()
178		s.Current = len(hash) == len(current) && string(hash) == string(current)
179		out = append(out, s)
180	}
181	return out, rows.Err()
182}
183
184func revokeSession(db *sql.DB, id int64) error {
185	_, err := db.Exec(`UPDATE sessions SET revoked = 1 WHERE id = ?`, id)
186	return err
187}
188
189// revokeOthers signs out everything but the caller. Sessions the other sites
190// are holding die with it, because they validate against this table rather than
191// carrying a signature of their own.
192func revokeOthers(db *sql.DB, keep int64) (int64, error) {
193	res, err := db.Exec(`UPDATE sessions SET revoked = 1 WHERE id != ? AND revoked = 0`, keep)
194	if err != nil {
195		return 0, err
196	}
197	return res.RowsAffected()
198}
199
200// sweepSessions drops rows that expired or were revoked long enough ago that
201// nobody is going to ask about them. Without it the table only grows.
202func sweepSessions(db *sql.DB) error {
203	cutoff := time.Now().Add(-30 * 24 * time.Hour).Unix()
204	_, err := db.Exec(`DELETE FROM sessions WHERE expires < ? OR (revoked = 1 AND last_seen < ?)`,
205		time.Now().Unix(), cutoff)
206	return err
207}