Files
noema/internal/auth/limiter.go
T
Andy Dixon a002a2c100 feat: add authentication, audit log, HTTP API and base web UI
Phase 1 foundation. Local auth per ADR-0006: Argon2id passwords, opaque
hashed session tokens with idle/absolute expiry, CSRF token plus Origin
check on cookie sessions, per-IP token bucket and per-account exponential
lockout, three roles enforced by middleware, personal API tokens for
noemactl. Audit log writer used by every admin mutation.

API: request ids, panic recovery, strict CSP and security headers, body
limits, single JSON error envelope, health/ready/status, users, sessions,
tokens, audit, token-protected /metrics.

UI: embedded html/template pages (login, first-run setup, dashboard,
system, users, account, audit, interpretation boundary), light/dark
theme, dependency-free app.js with CSRF-aware fetch and SSE helper.

Binaries: noemad with graceful shutdown, migrations at start, bootstrap
admin from environment, supervised background tasks; noemactl with
status, migrate, user create and meaningful exit codes.
2026-06-15 20:22:19 +01:00

77 lines
1.7 KiB
Go

package auth
import (
"sync"
"time"
)
// ipLimiter is a token bucket per client address for login attempts. It lives
// in memory because it only needs to slow down a single daemon's attackers;
// per-account lockout is persisted in the database.
type ipLimiter struct {
mu sync.Mutex
buckets map[string]*bucket
rate float64 // tokens per second
burst float64
now func() time.Time
}
type bucket struct {
tokens float64
last time.Time
}
func newIPLimiter(perMinute, burst int) *ipLimiter {
return &ipLimiter{buckets: map[string]*bucket{}, rate: float64(perMinute) / 60, burst: float64(burst), now: time.Now}
}
// Allow consumes a token for key if one is available.
func (l *ipLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
b := l.buckets[key]
if b == nil {
b = &bucket{tokens: l.burst, last: now}
l.buckets[key] = b
}
b.tokens += now.Sub(b.last).Seconds() * l.rate
if b.tokens > l.burst {
b.tokens = l.burst
}
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// Sweep drops buckets that have refilled, bounding memory (T18).
func (l *ipLimiter) Sweep() {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
for k, b := range l.buckets {
if b.tokens+now.Sub(b.last).Seconds()*l.rate >= l.burst {
delete(l.buckets, k)
}
}
}
// lockoutDuration grows exponentially with consecutive failures, capped at
// one hour, and starts after five failures so typos are not punished.
func lockoutDuration(failed int) time.Duration {
if failed < 5 {
return 0
}
if failed > 12 { // 2^7 * 30s already exceeds the cap; avoid shift overflow
return time.Hour
}
d := time.Duration(1<<uint(failed-5)) * 30 * time.Second
if d > time.Hour {
d = time.Hour
}
return d
}