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).
This commit is contained in:
1 parent
3291ed67d5
commit
434d2437bf
9 files changed
+342
-27
No files matched your search
@@ -0,0 +1,102 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user