feat: authentication with local accounts, sessions, CSRF, RBAC, and OIDC PKCE (phase 3)
- 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>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user