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