Files
photog/auth.go
T
puttaalu 434d2437bf Move upload auth from nginx into the app
Add HTTP Basic auth on /upload, reading an Apache htpasswd file (bcrypt
only, `htpasswd -B`) via a new -htpasswd flag. This lets nginx drop to a
plain TLS-terminating reverse proxy with no auth of its own; the existing
bcrypt htpasswd file is reused unchanged.

auth.go:
- LoadHtpasswd parses user:hash lines, keeps bcrypt entries ($2a/$2b/$2y,
  so Apache's $2y$ works), skips MD5/SHA/crypt with a warning.
- Unknown users still pay for one bcrypt comparison against a dummy hash,
  so response timing can't enumerate valid usernames.
- Failed attempts draw from a token-bucket limiter (x/time/rate, ~1/s,
  burst 10) returning 429 once drained; successful and bulk uploads never
  touch it.

server.go guards both /upload routes (nil authenticator = passthrough for
local --serve). /health stays open. Empty -htpasswd disables auth and logs
a warning.

module.nix: new top-level services.photogallery.htpasswdFile, delivered to
the service via systemd LoadCredential (so a root-owned 0400 secret works).
Removed nginx.htpasswdFile and basic_auth from the vhost.

Both uploader apps already send Basic credentials, so no client change.

Deps: golang.org/x/crypto/bcrypt, golang.org/x/time/rate (vendorHash bump).
2026-10-18 14:45:33 +02:00

103 lines
3.2 KiB
Go

package main
import (
"bufio"
"fmt"
"log"
"net/http"
"os"
"strings"
"golang.org/x/crypto/bcrypt"
"golang.org/x/time/rate"
)
// Authenticator checks HTTP Basic credentials against bcrypt hashes loaded from
// an Apache-style htpasswd file. Only bcrypt entries are supported — generate
// them with `htpasswd -B`. This replaces nginx's basic_auth so the app can run
// behind a plain TLS-terminating reverse proxy with no auth of its own.
type Authenticator struct {
creds map[string][]byte // username -> bcrypt hash
// A single bcrypt comparison runs on every failed lookup so that a request
// for an unknown user costs the same as one for a known user with a wrong
// password, closing a user-enumeration timing side channel.
dummyHash []byte
}
// LoadHtpasswd reads user:hash lines from path. Blank lines and #-comments are
// skipped, as is any entry whose hash is not bcrypt (with a warning). It is an
// error for the file to yield no usable entries.
func LoadHtpasswd(path string) (*Authenticator, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
a := &Authenticator{creds: make(map[string][]byte)}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
user, hash, ok := strings.Cut(line, ":")
if !ok || user == "" {
continue
}
// bcrypt hashes start $2a$, $2b$ or $2y$; everything else (MD5 $apr1$,
// SHA {SHA}, crypt) is unsupported here.
if !strings.HasPrefix(hash, "$2") {
log.Printf("auth: skipping non-bcrypt entry for %q — regenerate with `htpasswd -B`", user)
continue
}
a.creds[user] = []byte(hash)
}
if err := sc.Err(); err != nil {
return nil, err
}
if len(a.creds) == 0 {
return nil, fmt.Errorf("no bcrypt entries in %s (use `htpasswd -B`)", path)
}
// Cost 10 matches htpasswd -B's default, keeping the dummy comparison the
// same price as a real one.
a.dummyHash, _ = bcrypt.GenerateFromPassword([]byte("dummy"), 10)
return a, nil
}
// check reports whether user/pass matches a stored credential.
func (a *Authenticator) check(user, pass string) bool {
hash, ok := a.creds[user]
if !ok {
// Spend the same time as a real comparison to avoid leaking which
// usernames exist.
bcrypt.CompareHashAndPassword(a.dummyHash, []byte(pass))
return false
}
return bcrypt.CompareHashAndPassword(hash, []byte(pass)) == nil
}
// basicAuth wraps next, demanding valid HTTP Basic credentials. On a failed
// attempt it consumes a token from limiter; once the limiter is exhausted,
// further failures get 429 instead of 401, throttling brute-force guessing.
// The limiter is only touched on failure, so legitimate (even bursty, bulk)
// uploads are never throttled.
func (a *Authenticator) basicAuth(limiter *rate.Limiter, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if ok && a.check(user, pass) {
next(w, r)
return
}
if !limiter.Allow() {
http.Error(w, "too many failed attempts", http.StatusTooManyRequests)
return
}
w.Header().Set("WWW-Authenticate", `Basic realm="photog"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
}
}