c1cc781279
- argon2id (t=3, m=64MiB, p=2) PHC hashing honoring embedded params - token-bucket rate limiting (10/15min per IP+email) on login/register - opaque 32B session tokens in Mongo, 30-day sliding expiry, logout-all - CSRF double-submit cookie/header on authenticated mutations - bootstrap admin from env with forced first-login password change - requireAuth/requireRole middleware with disabled-account enforcement - OIDC code flow with PKCE: lazy discovery, account linking only on verified email, auto-created developer accounts - unit tests (RBAC matrix, CSRF, password, rate limiter) + integration suite covering the full auth matrix incl. an in-test fake IdP Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
71 lines
1.5 KiB
Go
71 lines
1.5 KiB
Go
package auth
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// RateLimiter is an in-memory token bucket per key (§7: 10 attempts per
|
|
// 15 min per IP+email). Buckets refill continuously and idle entries are
|
|
// swept opportunistically.
|
|
type RateLimiter struct {
|
|
mu sync.Mutex
|
|
buckets map[string]*bucket
|
|
capacity float64
|
|
window time.Duration
|
|
now func() time.Time // injectable for tests
|
|
lastGC time.Time
|
|
}
|
|
|
|
type bucket struct {
|
|
tokens float64
|
|
last time.Time
|
|
}
|
|
|
|
func NewRateLimiter(attempts int, window time.Duration) *RateLimiter {
|
|
return &RateLimiter{
|
|
buckets: make(map[string]*bucket),
|
|
capacity: float64(attempts),
|
|
window: window,
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
// Allow consumes one attempt for key, reporting whether it was within the
|
|
// limit.
|
|
func (rl *RateLimiter) Allow(key string) bool {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
now := rl.now()
|
|
rl.gcLocked(now)
|
|
|
|
b, ok := rl.buckets[key]
|
|
if !ok {
|
|
b = &bucket{tokens: rl.capacity, last: now}
|
|
rl.buckets[key] = b
|
|
}
|
|
refill := now.Sub(b.last).Seconds() * (rl.capacity / rl.window.Seconds())
|
|
b.tokens = min(rl.capacity, b.tokens+refill)
|
|
b.last = now
|
|
if b.tokens < 1 {
|
|
return false
|
|
}
|
|
b.tokens--
|
|
return true
|
|
}
|
|
|
|
// gcLocked drops buckets that have fully refilled (idle for >= one window);
|
|
// runs at most once per window to keep Allow O(1) amortized.
|
|
func (rl *RateLimiter) gcLocked(now time.Time) {
|
|
if now.Sub(rl.lastGC) < rl.window {
|
|
return
|
|
}
|
|
rl.lastGC = now
|
|
for k, b := range rl.buckets {
|
|
if now.Sub(b.last) >= rl.window {
|
|
delete(rl.buckets, k)
|
|
}
|
|
}
|
|
}
|