Sessions are stored as hashes, so a database dump contains nothing that can be pasted into a browser. A login that has passed the password but not the second factor is modelled as a pending session: it expires, is revocable, and reaches exactly one endpoint. Signup is invite-only and takes the email address from the invite rather than the form, so a link cannot be redirected to somebody else. The first admin is created by an endpoint that refuses once any account exists, checked and inserted in one transaction because it is necessarily unauthenticated. Login failures and forgotten-password requests answer identically whether or not the address has an account — otherwise the form is an account-existence oracle. Password rules are a length floor and nothing else: composition rules push people towards worse passwords. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
122 lines
3.0 KiB
Go
122 lines
3.0 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// limiter is a fixed-window counter, per key, held in memory.
|
|
//
|
|
// In memory is the right scope here: verdande is one process with one SQLite file
|
|
// beneath it, so there is no second instance for a shared counter to coordinate
|
|
// with. Resetting the limits on restart is a real weakness, and an acceptable one —
|
|
// this exists to make online password guessing impractical, not to survive a
|
|
// determined attacker who can also restart the server.
|
|
type limiter struct {
|
|
mu sync.Mutex
|
|
windows map[string]*window
|
|
limit int
|
|
period time.Duration
|
|
}
|
|
|
|
type window struct {
|
|
count int
|
|
until time.Time
|
|
}
|
|
|
|
func newLimiter(limit int, period time.Duration) *limiter {
|
|
l := &limiter{windows: map[string]*window{}, limit: limit, period: period}
|
|
go l.sweep()
|
|
return l
|
|
}
|
|
|
|
// allow records an attempt and reports whether it may proceed, with how long to
|
|
// wait if not.
|
|
func (l *limiter) allow(key string) (bool, time.Duration) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
w, ok := l.windows[key]
|
|
if !ok || now.After(w.until) {
|
|
l.windows[key] = &window{count: 1, until: now.Add(l.period)}
|
|
return true, 0
|
|
}
|
|
if w.count >= l.limit {
|
|
return false, time.Until(w.until)
|
|
}
|
|
w.count++
|
|
return true, 0
|
|
}
|
|
|
|
// reset clears a key. Called after a successful login, so somebody who mistyped
|
|
// their password four times is not still rate-limited once they get it right.
|
|
func (l *limiter) reset(key string) {
|
|
l.mu.Lock()
|
|
delete(l.windows, key)
|
|
l.mu.Unlock()
|
|
}
|
|
|
|
// sweep drops expired windows. Without it the map is a slow memory leak keyed by
|
|
// every address that ever failed a login.
|
|
func (l *limiter) sweep() {
|
|
for range time.Tick(5 * time.Minute) {
|
|
l.mu.Lock()
|
|
now := time.Now()
|
|
for k, w := range l.windows {
|
|
if now.After(w.until) {
|
|
delete(l.windows, k)
|
|
}
|
|
}
|
|
l.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// rateLimit guards an endpoint by client address.
|
|
func (s *Server) rateLimit(l *limiter, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if ok, retry := l.allow(clientIP(r)); !ok {
|
|
w.Header().Set("Retry-After", retryAfterSeconds(retry))
|
|
writeError(w, http.StatusTooManyRequests, CodeRateLimited,
|
|
"too many attempts; wait a moment and try again")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// clientIP is the address the middleware chain has already resolved through any
|
|
// proxy headers — behind a Cloudflare Tunnel that is the caller, not the tunnel.
|
|
// The port is dropped so repeated attempts from one machine share a bucket.
|
|
func clientIP(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
func retryAfterSeconds(d time.Duration) string {
|
|
secs := int(d.Seconds())
|
|
if secs < 1 {
|
|
secs = 1
|
|
}
|
|
return itoa(secs)
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var b [20]byte
|
|
i := len(b)
|
|
for n > 0 {
|
|
i--
|
|
b[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
return string(b[i:])
|
|
}
|