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 "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}