102 lines
3.2 KiB
Go
102 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)
|
||
|
|
}
|
||
|
|
}
|