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.7 KB · 243 lines · Go Raw History
  1package main
  2
  3import (
  4	"crypto/rand"
  5	"database/sql"
  6	"encoding/base64"
  7	"errors"
  8	"net/http"
  9	"sync"
 10	"time"
 11)
 12
 13const (
 14	pendingCookie = "bw_pending"
 15
 16	codeTTL = 10 * time.Minute
 17	// Wrong codes against one outstanding six digit value. Five tries out of a
 18	// million, inside ten minutes, with the row destroyed at the fifth.
 19	maxAttempts = 5
 20
 21	// The ceiling that actually bounds the damage, because it counts published
 22	// notifications for the account and ignores where the request came from.
 23	// Per-IP limiting is bypassed by sending one request from each of a
 24	// thousand proxies; this is what still holds when that happens.
 25	sendCeiling = 5
 26	sendWindow  = time.Hour
 27
 28	// A speed bump on top, not the limit: the sleep is per goroutine, so
 29	// concurrent attempts all serve it at once.
 30	failedDelay = 500 * time.Millisecond
 31)
 32
 33// One global bucket rather than per-IP state, which grows with every prober,
 34// and there is exactly one legitimate user.
 35var (
 36	loginBucket = &tokenBucket{tokens: 5, burst: 5, refill: 2 * time.Second, last: time.Now()}
 37	codeBucket  = &tokenBucket{tokens: 10, burst: 10, refill: time.Second, last: time.Now()}
 38)
 39
 40type tokenBucket struct {
 41	mu     sync.Mutex
 42	tokens float64
 43	burst  float64
 44	refill time.Duration
 45	last   time.Time
 46}
 47
 48func (b *tokenBucket) take() bool {
 49	b.mu.Lock()
 50	defer b.mu.Unlock()
 51
 52	now := time.Now()
 53	b.tokens += now.Sub(b.last).Seconds() / b.refill.Seconds()
 54	if b.tokens > b.burst {
 55		b.tokens = b.burst
 56	}
 57	b.last = now
 58
 59	if b.tokens < 1 {
 60		return false
 61	}
 62	b.tokens--
 63	return true
 64}
 65
 66var (
 67	errCeiling     = errors.New("too many codes have been sent recently")
 68	errOutstanding = errors.New("a code is already outstanding")
 69	errNoPending   = errors.New("that code has expired, start again")
 70	errBadCode     = errors.New("that code is not right")
 71)
 72
 73// pendingBrowser reads the cookie that binds an outstanding code to the browser
 74// that asked for it.
 75func pendingBrowser(r *http.Request) string {
 76	c, err := r.Cookie(pendingCookie)
 77	if err != nil {
 78		return ""
 79	}
 80	return c.Value
 81}
 82
 83func writePendingCookie(w http.ResponseWriter, value string) {
 84	http.SetCookie(w, &http.Cookie{
 85		Name:  pendingCookie,
 86		Value: value,
 87		Path:  "/",
 88		// Host-only. This one never leaves auth.bythewood.me, so it takes no
 89		// Domain and is not sent anywhere else.
 90		SameSite: http.SameSiteStrictMode,
 91		HttpOnly: true,
 92		Secure:   true,
 93		MaxAge:   int(codeTTL / time.Second),
 94	})
 95}
 96
 97func clearPendingCookie(w http.ResponseWriter) {
 98	http.SetCookie(w, &http.Cookie{
 99		Name:     pendingCookie,
100		Value:    "",
101		Path:     "/",
102		SameSite: http.SameSiteStrictMode,
103		HttpOnly: true,
104		Secure:   true,
105		MaxAge:   -1,
106	})
107}
108
109// livePending returns the one unconsumed, unexpired row, if there is one.
110func livePending(db *sql.DB) (id int64, browser []byte, ok bool, err error) {
111	err = db.QueryRow(`
112        SELECT id, browser_hash FROM pending_logins
113        WHERE consumed = 0 AND expires > ?
114        ORDER BY id DESC LIMIT 1`, time.Now().Unix()).Scan(&id, &browser)
115	if errors.Is(err, sql.ErrNoRows) {
116		return 0, nil, false, nil
117	}
118	if err != nil {
119		return 0, nil, false, err
120	}
121	return id, browser, true, nil
122}
123
124// sendsInWindow counts what has actually been published lately.
125func sendsInWindow(db *sql.DB) (int, error) {
126	var n int
127	err := db.QueryRow(`SELECT COUNT(*) FROM sends WHERE ts > ?`,
128		time.Now().Add(-sendWindow).Unix()).Scan(&n)
129	return n, err
130}
131
132// startLogin mints a code and stores it, returning the code to publish and the
133// browser token to set as a cookie.
134//
135// It publishes nothing itself. The caller does that, and only records the send
136// once ntfy accepted it, so a failed publish does not spend the ceiling.
137func startLogin(db *sql.DB, r *http.Request) (code, browser string, err error) {
138	// One outstanding at a time, for the account rather than per browser. A
139	// repeat request inside the window publishes nothing, which is what
140	// collapses a flood of requests into one notification per window.
141	if _, _, ok, err := livePending(db); err != nil {
142		return "", "", err
143	} else if ok {
144		return "", "", errOutstanding
145	}
146
147	n, err := sendsInWindow(db)
148	if err != nil {
149		return "", "", err
150	}
151	if n >= sendCeiling {
152		return "", "", errCeiling
153	}
154
155	if code, err = newCode(); err != nil {
156		return "", "", err
157	}
158	raw := make([]byte, 32)
159	if _, err := rand.Read(raw); err != nil {
160		return "", "", err
161	}
162	browser = base64.RawURLEncoding.EncodeToString(raw)
163
164	hash, salt, err := hashSecret(code)
165	if err != nil {
166		return "", "", err
167	}
168
169	now := time.Now()
170	c := requestContext(r)
171	_, err = db.Exec(`
172        INSERT INTO pending_logins
173            (code_hash, code_salt, browser_hash, created, expires, ip, country, city, ua)
174        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
175		hash, salt, sessionHash(browser), now.Unix(), now.Add(codeTTL).Unix(),
176		c.IP, c.Country, c.City, c.UA)
177	if err != nil {
178		return "", "", err
179	}
180	return code, browser, nil
181}
182
183// recordSend spends one of the hour's notifications. Called after ntfy accepted
184// the publish, never before.
185func recordSend(db *sql.DB) error {
186	_, err := db.Exec(`INSERT INTO sends (ts) VALUES (?)`, time.Now().Unix())
187	return err
188}
189
190// finishLogin checks a code against the outstanding row for this browser. A
191// code pushed to the phone is worthless in a browser that did not ask for it,
192// which is what stops somebody who can see the notification from using it.
193func finishLogin(db *sql.DB, r *http.Request, code string) error {
194	browser := pendingBrowser(r)
195	if browser == "" {
196		return errNoPending
197	}
198
199	var (
200		id         int64
201		hash, salt []byte
202		attempts   int
203	)
204	err := db.QueryRow(`
205        SELECT id, code_hash, code_salt, attempts FROM pending_logins
206        WHERE consumed = 0 AND expires > ? AND browser_hash = ?
207        ORDER BY id DESC LIMIT 1`,
208		time.Now().Unix(), sessionHash(browser)).Scan(&id, &hash, &salt, &attempts)
209	if errors.Is(err, sql.ErrNoRows) {
210		return errNoPending
211	}
212	if err != nil {
213		return err
214	}
215
216	if attempts+1 >= maxAttempts {
217		// Burn the row on the last attempt whether or not this one is right, so
218		// a wrong fifth guess cannot be followed by a sixth.
219		defer func() { _, _ = db.Exec(`UPDATE pending_logins SET consumed = 1 WHERE id = ?`, id) }()
220	}
221	if _, err := db.Exec(`UPDATE pending_logins SET attempts = attempts + 1 WHERE id = ?`, id); err != nil {
222		return err
223	}
224
225	if !secretMatches(code, hash, salt) {
226		return errBadCode
227	}
228
229	_, err = db.Exec(`UPDATE pending_logins SET consumed = 1 WHERE id = ?`, id)
230	return err
231}
232
233// sweepPending drops spent and expired rows, and the send counters that have
234// aged past the window they are counted in.
235func sweepPending(db *sql.DB) error {
236	cutoff := time.Now().Add(-24 * time.Hour).Unix()
237	if _, err := db.Exec(`DELETE FROM pending_logins WHERE expires < ?`, cutoff); err != nil {
238		return err
239	}
240	_, err := db.Exec(`DELETE FROM sends WHERE ts < ?`, cutoff)
241	return err
242}