Files
ferrum/internal/api/ratelimit.go
T
Anand 61ee806af6 Add realtime polling defaults, Gotify/SMTP notifications, UI-configurable OIDC, and expanded admin settings
- Default all queries to a 20s poll + refetch-on-focus (main.tsx) instead of
  a per-page opt-in, so every page/widget stays live without manual tuning.
- New internal/notify package: Gotify and SMTP (stdlib net/smtp, STARTTLS
  and implicit-TLS-on-465) notifications, each independently optional. Fires
  from the alert evaluator on new alert triggers; admin-configurable from
  Settings with a send-test-notification action per channel.
- OIDC/SSO moved from config.yaml-only to a DB-backed, admin-editable
  Settings card — swaps the live client with no restart. config.yaml is
  used to seed the database once on first boot after upgrading.
- New Security settings: session TTL, login lockout policy, and a real
  "require 2FA for admins" enforcement (requireTOTPEnrolled middleware)
  that blocks non-enrolled admins from everything but /profile and logout.
- New org-wide default preferences (theme/accent/look/landing page) for
  brand-new accounts, plus a personal landing-page picker and an
  email-me-alerts opt-in on Profile.
- Storage page: separate Local vs Shared/External storage tables and
  capacity donuts, fixing shared-storage totals that were being summed once
  per node that mounts them (e.g. a 2TB NFS share on 4 nodes read as 8TB).
- RankedBarChart: stop the longest bar's value label wrapping onto two
  lines (recharts auto-wraps LabelList when space is tight).
2026-09-03 22:00:41 +05:30

122 lines
3.1 KiB
Go

package api
import (
"sync"
"time"
)
// loginLimiter throttles authentication attempts per client IP + username.
// After too many failures inside the window the key is locked out until the
// window elapses, blunting online password guessing without new
// dependencies. State is in-memory: per-instance throttling is the right
// scope for a single-binary deployment.
type loginLimiter struct {
mu sync.Mutex
failed map[string]*failRecord
maxFailures int
window time.Duration
sweepInterval time.Duration
lastSweep time.Time
}
type failRecord struct {
failures int
firstFailure time.Time
lockedUntil time.Time
}
const (
loginMaxFailures = 5
loginWindow = 15 * time.Minute
loginSweepInterval = 5 * time.Minute
)
func newLoginLimiter() *loginLimiter {
return &loginLimiter{
failed: map[string]*failRecord{},
maxFailures: loginMaxFailures,
window: loginWindow,
sweepInterval: loginSweepInterval,
lastSweep: time.Now(),
}
}
// Allowed reports whether an attempt for key may proceed. When locked, the
// returned retryAfter tells the client how long to wait.
func (l *loginLimiter) Allowed(key string) (allowed bool, retryAfter time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
l.sweepLocked()
rec, ok := l.failed[key]
if !ok {
return true, 0
}
now := time.Now()
if now.Before(rec.lockedUntil) {
return false, rec.lockedUntil.Sub(now)
}
// Window elapsed since the failures began — start fresh.
if now.Sub(rec.firstFailure) > l.window {
delete(l.failed, key)
return true, 0
}
if rec.failures >= l.maxFailures {
rec.lockedUntil = rec.firstFailure.Add(l.window)
return false, time.Until(rec.lockedUntil)
}
return true, 0
}
// RecordFailure counts a failed attempt, engaging the lockout once
// maxFailures is reached inside the window.
func (l *loginLimiter) RecordFailure(key string) {
l.mu.Lock()
defer l.mu.Unlock()
l.sweepLocked()
now := time.Now()
rec, ok := l.failed[key]
if !ok || now.Sub(rec.firstFailure) > l.window {
l.failed[key] = &failRecord{failures: 1, firstFailure: now}
return
}
rec.failures++
if rec.failures >= l.maxFailures {
rec.lockedUntil = rec.firstFailure.Add(l.window)
}
}
// RecordSuccess clears the failure history after a successful login.
func (l *loginLimiter) RecordSuccess(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.failed, key)
}
// SetPolicy changes the failure threshold and window — applied to attempts
// evaluated from this point on; a key already locked out keeps its existing
// lockedUntil rather than being retroactively reinterpreted.
func (l *loginLimiter) SetPolicy(maxFailures int, window time.Duration) {
l.mu.Lock()
l.maxFailures = maxFailures
l.window = window
l.mu.Unlock()
}
// sweepLocked prunes stale entries so the map can't grow without bound.
// Caller must hold l.mu.
func (l *loginLimiter) sweepLocked() {
now := time.Now()
if now.Sub(l.lastSweep) < l.sweepInterval {
return
}
l.lastSweep = now
for key, rec := range l.failed {
if now.Sub(rec.firstFailure) > l.window && now.After(rec.lockedUntil) {
delete(l.failed, key)
}
}
}