Add metered open signups, per-IP limits, and auth security hardening
Enables public beta signup metering: DAILY_OPEN_SIGNUPS env var controls how many users can register without an invite code per day (0=disabled, -1=unlimited, N=daily cap). Invite codes always bypass the limit. Also adds per-IP signup throttling (DAILY_SIGNUPS_PER_IP, default 3/day) and fail-closed rate limiting on auth endpoints when Redis is down. Client dynamically fetches /api/auth/signup-info to show invite field as optional with remaining slots when open signups are enabled. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -91,9 +91,42 @@ class RateLimiter:
|
||||
|
||||
except redis.RedisError as e:
|
||||
# If Redis is unavailable, fail open (allow request)
|
||||
# For auth-critical paths, callers should use fail_closed=True
|
||||
logger.error(f"Rate limiter Redis error: {e}")
|
||||
return True, {"remaining": limit, "reset": window_seconds, "limit": limit}
|
||||
|
||||
async def is_allowed_strict(
|
||||
self,
|
||||
key: str,
|
||||
limit: int,
|
||||
window_seconds: int,
|
||||
) -> tuple[bool, dict]:
|
||||
"""
|
||||
Like is_allowed but fails closed (denies) when Redis is unavailable.
|
||||
Use for security-critical paths like auth endpoints.
|
||||
"""
|
||||
now = int(time.time())
|
||||
window_key = f"ratelimit:{key}:{now // window_seconds}"
|
||||
|
||||
try:
|
||||
async with self.redis.pipeline(transaction=True) as pipe:
|
||||
pipe.incr(window_key)
|
||||
pipe.expire(window_key, window_seconds + 1)
|
||||
results = await pipe.execute()
|
||||
|
||||
current_count = results[0]
|
||||
remaining = max(0, limit - current_count)
|
||||
reset = window_seconds - (now % window_seconds)
|
||||
|
||||
return current_count <= limit, {
|
||||
"remaining": remaining,
|
||||
"reset": reset,
|
||||
"limit": limit,
|
||||
}
|
||||
except redis.RedisError as e:
|
||||
logger.error(f"Rate limiter Redis error (fail-closed): {e}")
|
||||
return False, {"remaining": 0, "reset": window_seconds, "limit": limit}
|
||||
|
||||
def get_client_key(
|
||||
self,
|
||||
request: Request | WebSocket,
|
||||
@@ -197,8 +230,110 @@ class ConnectionMessageLimiter:
|
||||
self.timestamps = []
|
||||
|
||||
|
||||
class SignupLimiter:
|
||||
"""
|
||||
Daily signup metering for public beta.
|
||||
|
||||
Tracks two counters in Redis:
|
||||
- Global daily open signups (no invite code)
|
||||
- Per-IP daily signups (with or without invite code)
|
||||
|
||||
Keys auto-expire after 24 hours.
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client: redis.Redis):
|
||||
self.redis = redis_client
|
||||
|
||||
def _today_key(self, prefix: str) -> str:
|
||||
"""Generate a Redis key scoped to today's date (UTC)."""
|
||||
from datetime import datetime, timezone
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
return f"signup:{prefix}:{today}"
|
||||
|
||||
async def check_daily_limit(self, daily_limit: int) -> tuple[bool, int]:
|
||||
"""
|
||||
Check if global daily open signup limit allows another registration.
|
||||
|
||||
Args:
|
||||
daily_limit: Max open signups per day. -1 = unlimited, 0 = disabled.
|
||||
|
||||
Returns:
|
||||
Tuple of (allowed, remaining). remaining is -1 when unlimited.
|
||||
"""
|
||||
if daily_limit == 0:
|
||||
return False, 0
|
||||
if daily_limit < 0:
|
||||
return True, -1
|
||||
|
||||
key = self._today_key("daily_open")
|
||||
try:
|
||||
count = await self.redis.get(key)
|
||||
current = int(count) if count else 0
|
||||
remaining = max(0, daily_limit - current)
|
||||
return current < daily_limit, remaining
|
||||
except redis.RedisError as e:
|
||||
logger.error(f"Signup limiter Redis error (daily check): {e}")
|
||||
return False, 0 # Fail closed
|
||||
|
||||
async def check_ip_limit(self, ip_hash: str, ip_limit: int) -> tuple[bool, int]:
|
||||
"""
|
||||
Check if per-IP daily signup limit allows another registration.
|
||||
|
||||
Args:
|
||||
ip_hash: Hashed client IP.
|
||||
ip_limit: Max signups per IP per day. 0 = unlimited.
|
||||
|
||||
Returns:
|
||||
Tuple of (allowed, remaining).
|
||||
"""
|
||||
if ip_limit <= 0:
|
||||
return True, -1
|
||||
|
||||
key = self._today_key(f"ip:{ip_hash}")
|
||||
try:
|
||||
count = await self.redis.get(key)
|
||||
current = int(count) if count else 0
|
||||
remaining = max(0, ip_limit - current)
|
||||
return current < ip_limit, remaining
|
||||
except redis.RedisError as e:
|
||||
logger.error(f"Signup limiter Redis error (IP check): {e}")
|
||||
return False, 0 # Fail closed
|
||||
|
||||
async def increment_daily(self) -> None:
|
||||
"""Increment the global daily open signup counter."""
|
||||
key = self._today_key("daily_open")
|
||||
try:
|
||||
async with self.redis.pipeline(transaction=True) as pipe:
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, 86400 + 60) # 24h + 1min buffer
|
||||
await pipe.execute()
|
||||
except redis.RedisError as e:
|
||||
logger.error(f"Signup limiter Redis error (daily incr): {e}")
|
||||
|
||||
async def increment_ip(self, ip_hash: str) -> None:
|
||||
"""Increment the per-IP daily signup counter."""
|
||||
key = self._today_key(f"ip:{ip_hash}")
|
||||
try:
|
||||
async with self.redis.pipeline(transaction=True) as pipe:
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, 86400 + 60)
|
||||
await pipe.execute()
|
||||
except redis.RedisError as e:
|
||||
logger.error(f"Signup limiter Redis error (IP incr): {e}")
|
||||
|
||||
async def get_daily_count(self) -> int:
|
||||
"""Get current daily open signup count."""
|
||||
key = self._today_key("daily_open")
|
||||
try:
|
||||
count = await self.redis.get(key)
|
||||
return int(count) if count else 0
|
||||
except redis.RedisError:
|
||||
return 0
|
||||
|
||||
|
||||
# Global rate limiter instance
|
||||
_rate_limiter: Optional[RateLimiter] = None
|
||||
_signup_limiter: Optional[SignupLimiter] = None
|
||||
|
||||
|
||||
async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
|
||||
@@ -217,7 +352,16 @@ async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
|
||||
return _rate_limiter
|
||||
|
||||
|
||||
async def get_signup_limiter(redis_client: redis.Redis) -> SignupLimiter:
|
||||
"""Get or create the global signup limiter instance."""
|
||||
global _signup_limiter
|
||||
if _signup_limiter is None:
|
||||
_signup_limiter = SignupLimiter(redis_client)
|
||||
return _signup_limiter
|
||||
|
||||
|
||||
def close_rate_limiter():
|
||||
"""Close the global rate limiter."""
|
||||
global _rate_limiter
|
||||
global _rate_limiter, _signup_limiter
|
||||
_rate_limiter = None
|
||||
_signup_limiter = None
|
||||
|
||||
Reference in New Issue
Block a user