120 lines
3.6 KiB
Go
120 lines
3.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// failLimiter throttles repeated login failures keyed by "username|ip". After
|
|
// max failures it imposes a cooldown of window before any further attempt for
|
|
// that key is accepted, blunting brute force against PAM/shadow.
|
|
//
|
|
// ponytail: in-memory, single-process, fixed cooldown - correct for a single
|
|
// app instance. State is lost on restart, but only an operator restarts the
|
|
// process (an attacker can't), so persisting it would buy nothing. Source
|
|
// spoofing is handled by the network (VPN-only + trusted proxy sets XFF), not
|
|
// here. Reach for pam_faillock only if a failed web login should also lock the
|
|
// OS account against ssh/console - a different layer we deliberately don't span.
|
|
type failLimiter struct {
|
|
mu sync.Mutex
|
|
attempts map[string]*attemptState
|
|
max int
|
|
window time.Duration
|
|
sync func(key string, s *attemptState) // optional: persist to DB
|
|
}
|
|
|
|
type attemptState struct {
|
|
count int
|
|
until time.Time
|
|
}
|
|
|
|
// maxTrackedKeys bounds memory: an attacker rotating username/IP can't grow the
|
|
// map without limit. When exceeded we fail closed instead of wiping state, so
|
|
// existing cooldowns are preserved.
|
|
const maxTrackedKeys = 10000
|
|
|
|
func newFailLimiter(max int, window time.Duration) *failLimiter {
|
|
return &failLimiter{attempts: map[string]*attemptState{}, max: max, window: window}
|
|
}
|
|
|
|
// blocked reports whether the key is currently in cooldown.
|
|
func (l *failLimiter) blocked(key string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
s := l.attempts[key]
|
|
return s != nil && time.Now().Before(s.until)
|
|
}
|
|
|
|
// fail records a failed attempt and starts a cooldown once max is reached.
|
|
// When the map is full we stop tracking new keys rather than wiping existing
|
|
// cooldowns (which an attacker could use to clear a target's throttle).
|
|
func (l *failLimiter) fail(key string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if len(l.attempts) >= maxTrackedKeys {
|
|
return
|
|
}
|
|
s := l.attempts[key]
|
|
if s == nil {
|
|
s = &attemptState{}
|
|
l.attempts[key] = s
|
|
}
|
|
s.count++
|
|
if s.count >= l.max {
|
|
s.until = time.Now().Add(l.window)
|
|
s.count = 0
|
|
}
|
|
if l.sync != nil {
|
|
l.sync(key, s)
|
|
}
|
|
}
|
|
|
|
// reset clears state for a key after a successful login.
|
|
func (l *failLimiter) reset(key string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
delete(l.attempts, key)
|
|
if l.sync != nil {
|
|
l.sync(key, nil)
|
|
}
|
|
}
|
|
|
|
type ctxKey int
|
|
|
|
const clientIPKey ctxKey = 0
|
|
|
|
// WithClientIP wraps a handler so the client's IP is available downstream via
|
|
// ClientIP, so the login throttle can key on source IP without coupling to a
|
|
// specific HTTP adapter.
|
|
//
|
|
// When trustProxy is set, the IP is taken from the first X-Forwarded-For hop
|
|
// (the original client behind the reverse proxy). X-Forwarded-For is fully
|
|
// caller-controlled, so this is only honored when an admin has opted in by
|
|
// putting nadir behind a proxy - and nadir must then be reachable only by that
|
|
// proxy. When trustProxy is false the header is ignored and RemoteAddr wins.
|
|
func WithClientIP(trustProxy bool, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
ip = r.RemoteAddr
|
|
}
|
|
if trustProxy {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
first, _, _ := strings.Cut(xff, ",")
|
|
ip = strings.TrimSpace(first)
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientIPKey, ip)))
|
|
})
|
|
}
|
|
|
|
// ClientIP returns the IP recorded by WithClientIP, or "" if absent.
|
|
func ClientIP(ctx context.Context) string {
|
|
ip, _ := ctx.Value(clientIPKey).(string)
|
|
return ip
|
|
}
|