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.
77 lines
1.7 KiB
Go
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
|
|
}
|