- auth_login.templ: LoginPage + LoginFormFragment (mirrors signup shape) - LoginForm + LoginErrors types added to templates/auth_forms.go - LoginPageHandler + LoginPostHandler in handlers_auth.go - Rate-limit check before user lookup (D-16, T-2-14) - Single errInvalidCreds constant for D-20 enumeration defense - Session rotation via Store.Rotate on success (D-10, T-2-04) - HTMX-aware redirect and fragment responses (D-19, D-21) - AuthDeps extended with Limiter *auth.LimiterStore field - router.go: GET /login in RedirectIfAuthed group (D-23) - main.go: LimiterStore created with janitor goroutine (D-16) - Export NewLimiterStoreWithClock + SetLimiterClock for cross-package tests - 12 TestLogin_* integration tests all pass with real DB
130 lines
3.7 KiB
Go
130 lines
3.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// entry holds a per-key token-bucket limiter and the last time the key was
|
|
// accessed. lastSeen is used by the janitor to evict idle entries (D-16).
|
|
type entry struct {
|
|
lim *rate.Limiter
|
|
lastSeen time.Time
|
|
}
|
|
|
|
// LimiterStore is a map of token-bucket rate limiters keyed on an arbitrary
|
|
// string (e.g. lower(email)+":"+clientIP). Each key gets an independent
|
|
// Limiter so exhausting one key never affects another (D-16).
|
|
//
|
|
// The store uses an injectable clock (now) so unit tests can control time
|
|
// deterministically without sleeps (Pattern 8). In production, now is time.Now.
|
|
//
|
|
// Memory is bounded: a janitor goroutine (StartJanitor) removes entries idle
|
|
// longer than idleTTL, preventing unlimited growth in high-cardinality key
|
|
// spaces (D-16, Pitfall 11).
|
|
type LimiterStore struct {
|
|
mu sync.Mutex
|
|
limits map[string]*entry
|
|
r rate.Limit
|
|
burst int
|
|
idleTTL time.Duration
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewLimiterStore returns a LimiterStore configured for 5 requests per minute
|
|
// (rate.Every(12s)), burst=5, idleTTL=10min. The clock defaults to time.Now.
|
|
//
|
|
// Start the janitor with StartJanitor before the store is used in production
|
|
// to prevent unbounded memory growth.
|
|
func NewLimiterStore() *LimiterStore {
|
|
return &LimiterStore{
|
|
limits: make(map[string]*entry),
|
|
r: rate.Every(12 * time.Second), // 5 tokens/min
|
|
burst: 5,
|
|
idleTTL: 10 * time.Minute,
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
// newLimiterStoreWithClock creates a LimiterStore with an injectable clock.
|
|
// Used in same-package tests to drive time deterministically (Pattern 8).
|
|
func newLimiterStoreWithClock(now func() time.Time) *LimiterStore {
|
|
s := NewLimiterStore()
|
|
s.now = now
|
|
return s
|
|
}
|
|
|
|
// NewLimiterStoreWithClock creates a LimiterStore with an injectable clock.
|
|
// Exported for cross-package integration tests (e.g. internal/web handlers tests).
|
|
func NewLimiterStoreWithClock(now func() time.Time) *LimiterStore {
|
|
return newLimiterStoreWithClock(now)
|
|
}
|
|
|
|
// SetLimiterClock sets the clock function on an existing LimiterStore.
|
|
// Exported for cross-package tests that need to freeze time after construction.
|
|
func SetLimiterClock(s *LimiterStore, now func() time.Time) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.now = now
|
|
}
|
|
|
|
// Allow reports whether the key has a token available. It uses AllowN(t, 1)
|
|
// with the injectable clock (not Allow() which uses wall time internally and
|
|
// is untestable — Pitfall 8 / Pattern 8).
|
|
//
|
|
// Allow is safe for concurrent use.
|
|
func (s *LimiterStore) Allow(key string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
e, ok := s.limits[key]
|
|
if !ok {
|
|
e = &entry{lim: rate.NewLimiter(s.r, s.burst)}
|
|
s.limits[key] = e
|
|
}
|
|
t := s.now()
|
|
e.lastSeen = t
|
|
return e.lim.AllowN(t, 1)
|
|
}
|
|
|
|
// StartJanitor launches a background goroutine that calls cleanupNow on each
|
|
// tick. Send to stop to shut the goroutine down cleanly (e.g. on server
|
|
// shutdown). interval is typically 1 minute in production.
|
|
func (s *LimiterStore) StartJanitor(interval time.Duration, stop <-chan struct{}) {
|
|
go func() {
|
|
tick := time.NewTicker(interval)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-tick.C:
|
|
s.cleanupNow()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// cleanupNow removes all entries whose lastSeen is older than idleTTL.
|
|
// It is called by the janitor goroutine and is also exported for direct use
|
|
// in tests (same package).
|
|
func (s *LimiterStore) cleanupNow() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
cutoff := s.now().Add(-s.idleTTL)
|
|
for k, e := range s.limits {
|
|
if e.lastSeen.Before(cutoff) {
|
|
delete(s.limits, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// size returns the number of tracked keys. Used only in tests.
|
|
func (s *LimiterStore) size() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.limits)
|
|
}
|