feat!: drop the built-in auth in favour of Authelia
check / check (push) Successful in 46s

BREAKING CHANGE: PASSWORD is gone and AUTH and CERTRESOLVER are required.
The server's compose.yaml and .env must be updated in the same deploy — the
new image ignores PASSWORD, and the old one refuses to start without it.

Authelia now sits in front of Traefik, so the app was asking for a second
password at the same door. Two prompts, and the weaker of the two was the one
holding a single shared secret with no sessions, no MFA and no revocation.
Deleting it is the whole change: Authelia already does this properly, once,
for every service on the host.

Gone: auth(), challenge(), the whole of throttle.go and its tests, and
golang.org/x/time with them. routes() returns the bare mux, /healthz is an
ordinary route on it, and the smoke script drops sixty -u flags. Roughly 230
lines removed and nothing written to replace them.

What holds the app up now, both asserted in compose.yaml:

- The router names the Authelia middleware through AUTH. Traefik takes a
  router out of service when its middleware does not resolve, so a typo or an
  unset variable fails shut rather than serving the app open.
- The container still publishes no ports, so the proxy is the only thing that
  can reach it. Publishing 8080 would now bypass authentication outright, not
  merely TLS — the comment there says so.

certresolver replaces the bare tls=true, parameterised as CERTRESOLVER: the
server had been carrying that label by hand since the first deploy. Naming a
resolver implies tls=true, so it stays one label.

TestAuth and TestHealthzSkipsAuth are replaced by one test asserting every
route answers without credentials — a 401 from here would now mean auth had
crept back in.
This commit is contained in:
Esa Kataja
2026-09-06 13:39:35 +03:00
parent 57d5faf65f
commit cf2cb0ce0a
13 changed files with 159 additions and 446 deletions
+12 -53
View File
@@ -7,8 +7,6 @@ package main
import (
"cmp"
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"embed"
"errors"
@@ -76,16 +74,11 @@ func run() error {
}
defer db.Close()
// Importing is an offline chore: no password needed, no server started.
// Importing is an offline chore: no server started, nothing to serve.
if *importPath != "" {
return runImport(db, *importPath)
}
password := os.Getenv("PASSWORD")
if password == "" {
return errors.New("PASSWORD is not set")
}
// Fail rather than fall back to UTC: a silently wrong zone shifts logged
// dinners onto the wrong calendar day, which is invisible until the
// history is already corrupt.
@@ -105,7 +98,7 @@ func run() error {
// response blocks every other request behind it.
srv := &http.Server{
Addr: addr,
Handler: routes(db, loc, password),
Handler: routes(db, loc),
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
@@ -168,7 +161,11 @@ func openDB(path string) (*sql.DB, error) {
return db, nil
}
func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
// routes serves the app unauthenticated. Access control is the reverse proxy's
// job: Traefik forwards every request to Authelia before it reaches here, so a
// second password in front of it only ever meant two prompts for one door. The
// container publishes no ports, so nothing but the proxy can reach it.
func routes(db *sql.DB, loc *time.Location) http.Handler {
// Go's mime table has no entry for .webmanifest, and a manifest served as
// octet-stream is ignored by the browser.
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
@@ -191,51 +188,13 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
mux.HandleFunc("POST /ruuat/tuonti", a.importDishes)
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
root := http.NewServeMux()
root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
// /healthz is an ordinary route now that the app has no auth of its own.
// It reveals only the version, so an Authelia bypass rule for it is safe if
// a monitor needs to poll from outside the container network.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, version)
})
root.Handle("/", auth(password, mux))
return root
}
// auth gates everything behind one shared household password. There are no
// accounts, so the username is ignored (PRD §9).
func auth(password string, next http.Handler) http.Handler {
want := sha256.Sum256([]byte(password))
guesses := newThrottle()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, given, ok := r.BasicAuth()
// A request with no Authorization header is the normal browser
// handshake, not a guess: every session opens with one. Challenge it
// without spending the address's allowance.
if !ok {
challenge(w)
return
}
// Hashing first keeps the comparison a fixed length, so neither the
// password nor its length leaks through timing.
got := sha256.Sum256([]byte(given))
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
if !guesses.allow(clientIP(r)) {
http.Error(w, "Liikaa yrityksiä.", http.StatusTooManyRequests)
return
}
challenge(w)
return
}
next.ServeHTTP(w, r)
})
}
func challenge(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return mux
}
// today is the current calendar day in the configured location, truncated to
+11 -51
View File
@@ -306,63 +306,23 @@ func TestDuplicateNamesAreCaseInsensitive(t *testing.T) {
}
}
func TestAuth(t *testing.T) {
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot) // proves we reached the wrapped handler
}))
cases := []struct {
name string
user string
pass string
withAuth bool
want int
}{
{"correct password", "", "hunter2", true, http.StatusTeapot},
{"username is ignored", "anyone", "hunter2", true, http.StatusTeapot},
{"wrong password", "", "wrong", true, http.StatusUnauthorized},
{"empty password", "", "", true, http.StatusUnauthorized},
{"no credentials", "", "", false, http.StatusUnauthorized},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
if c.withAuth {
r.SetBasicAuth(c.user, c.pass)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != c.want {
t.Errorf("status = %d, want %d", w.Code, c.want)
}
if c.want == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" {
t.Error("401 without a WWW-Authenticate header; the browser will not prompt")
}
})
}
}
func TestHealthzSkipsAuth(t *testing.T) {
// The app carries no authentication of its own — Authelia in front of Traefik
// does that — so the only thing left to assert is that every route answers
// without credentials. A 401 from here would mean auth crept back in.
func TestRoutesNeedNoCredentials(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("openDB: %v", err)
}
defer db.Close()
h := routes(db, time.UTC, "hunter2")
h := routes(db, time.UTC)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if w.Code != http.StatusOK {
t.Errorf("/healthz without credentials = %d, want 200", w.Code)
}
// Everything else must still be gated.
w = httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
if w.Code != http.StatusUnauthorized {
t.Errorf("/ without credentials = %d, want 401", w.Code)
for _, path := range []string{"/healthz", "/", "/ruuat"} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200", path, w.Code)
}
}
}
-111
View File
@@ -1,111 +0,0 @@
package main
import (
"net"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
)
// The app is reachable from the internet, so a shared password needs more
// than a sleep in front of it. These allow a family fumbling the password a
// handful of quick retries, then roughly six a minute — useless for guessing,
// unnoticeable to anyone who knows it.
//
// This buys time; it is not the defence. A strong password is.
const (
guessBurst = 5
guessInterval = 10 * time.Second
// Bounds on the per-IP table, so a spray across many addresses cannot
// grow it without limit.
throttleMaxEntries = 4096
throttleIdle = 15 * time.Minute
)
type visitor struct {
limiter *rate.Limiter
seen time.Time
}
// throttle rate-limits failed password attempts per client address.
//
// ponytail: one mutex over one map. At household traffic this will never be
// contended; shard it if that ever stops being true.
type throttle struct {
mu sync.Mutex
visitors map[string]*visitor
}
func newThrottle() *throttle {
return &throttle{visitors: make(map[string]*visitor)}
}
// allow reports whether another wrong guess from this address is permitted.
func (t *throttle) allow(ip string) bool {
now := time.Now()
t.mu.Lock()
defer t.mu.Unlock()
if len(t.visitors) >= throttleMaxEntries {
t.pruneLocked(now)
}
v := t.visitors[ip]
if v == nil {
v = &visitor{limiter: rate.NewLimiter(rate.Every(guessInterval), guessBurst)}
t.visitors[ip] = v
}
v.seen = now
return v.limiter.Allow()
}
func (t *throttle) pruneLocked(now time.Time) {
for ip, v := range t.visitors {
if now.Sub(v.seen) > throttleIdle {
delete(t.visitors, ip)
}
}
// Still full of live entries: a spray is in progress. Drop the lot rather
// than grow without bound. Everyone gets a fresh allowance, which is the
// safe direction to fail — the password is still required.
if len(t.visitors) >= throttleMaxEntries {
clear(t.visitors)
}
}
// clientIP resolves the address to rate-limit against.
//
// X-Forwarded-For is only believed when the connection itself came from a
// private address, meaning it arrived through the reverse proxy on the
// container network. A client connecting directly could otherwise forge a
// fresh address on every attempt and walk straight past the limiter.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
if ip == nil || !(ip.IsPrivate() || ip.IsLoopback()) {
return host
}
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded == "" {
return host
}
// The nearest proxy appends the address it saw, so the last entry is the
// trustworthy one; anything before it was supplied by the client.
parts := strings.Split(forwarded, ",")
last := strings.TrimSpace(parts[len(parts)-1])
if net.ParseIP(last) == nil {
return host
}
return last
}
-110
View File
@@ -1,110 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestThrottleBlocksRepeatedGuesses(t *testing.T) {
th := newThrottle()
for i := 0; i < guessBurst; i++ {
if !th.allow("198.51.100.7") {
t.Fatalf("guess %d refused inside the burst", i+1)
}
}
if th.allow("198.51.100.7") {
t.Error("guess allowed past the burst")
}
// A different address has its own allowance.
if !th.allow("198.51.100.8") {
t.Error("a second address was blocked by the first one's guesses")
}
}
func TestClientIPIgnoresForwardedHeaderFromDirectClients(t *testing.T) {
// Connecting straight from the internet: X-Forwarded-For is attacker
// input, so a forged value must not create a fresh rate-limit bucket.
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.9:44321"
r.Header.Set("X-Forwarded-For", "1.2.3.4")
if got := clientIP(r); got != "203.0.113.9" {
t.Errorf("clientIP = %q, want the real peer 203.0.113.9", got)
}
}
func TestClientIPTakesLastForwardedEntryBehindProxy(t *testing.T) {
// Arriving through Traefik on the container network. The proxy appends
// the address it saw, so the last entry is the trustworthy one and the
// forged entry in front of it must be ignored.
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "172.18.0.4:53000"
r.Header.Set("X-Forwarded-For", "1.2.3.4, 198.51.100.22")
if got := clientIP(r); got != "198.51.100.22" {
t.Errorf("clientIP = %q, want 198.51.100.22", got)
}
}
func TestClientIPFallsBackWhenNoForwardedHeader(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "172.18.0.4:53000"
if got := clientIP(r); got != "172.18.0.4" {
t.Errorf("clientIP = %q, want 172.18.0.4", got)
}
}
func TestAuthRateLimitsWrongPasswords(t *testing.T) {
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
send := func(pass string) int {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.5:40000"
r.SetBasicAuth("", pass)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w.Code
}
for i := 0; i < guessBurst; i++ {
if code := send("wrong"); code != http.StatusUnauthorized {
t.Fatalf("guess %d returned %d, want 401", i+1, code)
}
}
if code := send("wrong"); code != http.StatusTooManyRequests {
t.Errorf("guess past the burst returned %d, want 429", code)
}
}
func TestAuthDoesNotSpendAllowanceOnTheBrowserHandshake(t *testing.T) {
handler := auth("hunter2", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
// Every session opens with a credential-less request. Charging those
// would lock a family out by simply opening the app a few times.
for i := 0; i < guessBurst*4; i++ {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.6:40000"
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("handshake %d returned %d, want 401", i+1, w.Code)
}
}
// The correct password still works afterwards.
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "203.0.113.6:40000"
r.SetBasicAuth("", "hunter2")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusTeapot {
t.Errorf("correct password returned %d, want the wrapped handler", w.Code)
}
}
+3 -2
View File
@@ -143,8 +143,9 @@ templ page(title, current string) {
<title>{ pageTitle(title) }</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
<!-- use-credentials: the manifest is fetched behind Basic auth and
would otherwise come back 401 and be ignored. -->
<!-- use-credentials: the manifest fetch is anonymous by default, so
behind Authelia it would be redirected to the login page and
the manifest quietly ignored. -->
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials"/>
<link rel="stylesheet" href="/static/app.css"/>
<!-- Not deferred: it applies the stored theme before first paint. -->