orchard
mirrorEvery 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
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}