Files
photog/auth.go
T

102 lines
3.2 KiB
Go
Raw Normal View History

2026-10-18 14:45:33 +02:00
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)
}
}