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:
parent
3d02d739e5
commit
6461a7f0c7
@ -75,6 +75,15 @@ SECRET_KEY=
|
|||||||
# Enable invite-only mode (requires invitation to register)
|
# Enable invite-only mode (requires invitation to register)
|
||||||
INVITE_ONLY=true
|
INVITE_ONLY=true
|
||||||
|
|
||||||
|
# Metered open signups (public beta)
|
||||||
|
# 0 = disabled (invite-only enforced), -1 = unlimited, N = max open signups per day
|
||||||
|
# When set > 0, users can register without an invite code up to the daily limit.
|
||||||
|
# Invite codes always work regardless of this limit.
|
||||||
|
DAILY_OPEN_SIGNUPS=0
|
||||||
|
|
||||||
|
# Max signups per IP address per day (0 = unlimited)
|
||||||
|
DAILY_SIGNUPS_PER_IP=3
|
||||||
|
|
||||||
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
|
# Bootstrap admin account (for first-time setup with INVITE_ONLY=true)
|
||||||
# Remove these after first login!
|
# Remove these after first login!
|
||||||
# BOOTSTRAP_ADMIN_USERNAME=admin
|
# BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
|||||||
@ -4762,11 +4762,14 @@ class AuthManager {
|
|||||||
this.signupFormContainer = document.getElementById('signup-form-container');
|
this.signupFormContainer = document.getElementById('signup-form-container');
|
||||||
this.signupForm = document.getElementById('signup-form');
|
this.signupForm = document.getElementById('signup-form');
|
||||||
this.signupInviteCode = document.getElementById('signup-invite-code');
|
this.signupInviteCode = document.getElementById('signup-invite-code');
|
||||||
|
this.inviteCodeGroup = document.getElementById('invite-code-group');
|
||||||
|
this.inviteCodeHint = document.getElementById('invite-code-hint');
|
||||||
this.signupUsername = document.getElementById('signup-username');
|
this.signupUsername = document.getElementById('signup-username');
|
||||||
this.signupEmail = document.getElementById('signup-email');
|
this.signupEmail = document.getElementById('signup-email');
|
||||||
this.signupPassword = document.getElementById('signup-password');
|
this.signupPassword = document.getElementById('signup-password');
|
||||||
this.signupError = document.getElementById('signup-error');
|
this.signupError = document.getElementById('signup-error');
|
||||||
this.showSignupLink = document.getElementById('show-signup');
|
this.showSignupLink = document.getElementById('show-signup');
|
||||||
|
this.signupInfo = null; // populated by fetchSignupInfo()
|
||||||
this.showLoginLink = document.getElementById('show-login');
|
this.showLoginLink = document.getElementById('show-login');
|
||||||
this.showForgotLink = document.getElementById('show-forgot');
|
this.showForgotLink = document.getElementById('show-forgot');
|
||||||
this.forgotFormContainer = document.getElementById('forgot-form-container');
|
this.forgotFormContainer = document.getElementById('forgot-form-container');
|
||||||
@ -4815,6 +4818,9 @@ class AuthManager {
|
|||||||
// Check URL for reset token or invite code on page load
|
// Check URL for reset token or invite code on page load
|
||||||
this.checkResetToken();
|
this.checkResetToken();
|
||||||
this.checkInviteCode();
|
this.checkInviteCode();
|
||||||
|
|
||||||
|
// Fetch signup availability info (metered open signups)
|
||||||
|
this.fetchSignupInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
showModal(form = 'login') {
|
showModal(form = 'login') {
|
||||||
@ -4979,6 +4985,44 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchSignupInfo() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/signup-info');
|
||||||
|
if (resp.ok) {
|
||||||
|
this.signupInfo = await resp.json();
|
||||||
|
this.updateInviteCodeField();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Fail silently — invite field stays required by default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInviteCodeField() {
|
||||||
|
if (!this.signupInfo || !this.signupInviteCode) return;
|
||||||
|
|
||||||
|
const { invite_required, open_signups_enabled, remaining_today, unlimited } = this.signupInfo;
|
||||||
|
|
||||||
|
if (invite_required) {
|
||||||
|
this.signupInviteCode.required = true;
|
||||||
|
this.signupInviteCode.placeholder = 'Invite Code (required)';
|
||||||
|
if (this.inviteCodeHint) this.inviteCodeHint.textContent = '';
|
||||||
|
} else if (open_signups_enabled) {
|
||||||
|
this.signupInviteCode.required = false;
|
||||||
|
this.signupInviteCode.placeholder = 'Invite Code (optional)';
|
||||||
|
if (this.inviteCodeHint) {
|
||||||
|
if (unlimited) {
|
||||||
|
this.inviteCodeHint.textContent = 'Open registration — no invite needed';
|
||||||
|
} else if (remaining_today !== null && remaining_today > 0) {
|
||||||
|
this.inviteCodeHint.textContent = `${remaining_today} open signup${remaining_today !== 1 ? 's' : ''} left today`;
|
||||||
|
} else if (remaining_today === 0) {
|
||||||
|
this.signupInviteCode.required = true;
|
||||||
|
this.signupInviteCode.placeholder = 'Invite Code (required)';
|
||||||
|
this.inviteCodeHint.textContent = 'Daily signups full — invite code required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleForgotPassword(e) {
|
async handleForgotPassword(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.clearErrors();
|
this.clearErrors();
|
||||||
|
|||||||
@ -893,8 +893,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
|||||||
<div id="signup-form-container" class="hidden">
|
<div id="signup-form-container" class="hidden">
|
||||||
<h3>Sign Up</h3>
|
<h3>Sign Up</h3>
|
||||||
<form id="signup-form">
|
<form id="signup-form">
|
||||||
<div class="form-group">
|
<div class="form-group" id="invite-code-group">
|
||||||
<input type="text" id="signup-invite-code" placeholder="Invite Code" required>
|
<input type="text" id="signup-invite-code" placeholder="Invite Code">
|
||||||
|
<small id="invite-code-hint" class="form-hint"></small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
||||||
|
|||||||
@ -3605,6 +3605,13 @@ input::placeholder {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
.auth-switch {
|
.auth-switch {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
|
|||||||
@ -147,6 +147,12 @@ class ServerConfig:
|
|||||||
SECRET_KEY: str = ""
|
SECRET_KEY: str = ""
|
||||||
INVITE_ONLY: bool = True
|
INVITE_ONLY: bool = True
|
||||||
|
|
||||||
|
# Metered open signups (public beta)
|
||||||
|
# 0 = disabled (invite-only), -1 = unlimited, N = max per day
|
||||||
|
DAILY_OPEN_SIGNUPS: int = 0
|
||||||
|
# Max signups per IP per day (0 = unlimited)
|
||||||
|
DAILY_SIGNUPS_PER_IP: int = 3
|
||||||
|
|
||||||
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
|
# Bootstrap admin (for first-time setup when INVITE_ONLY=true)
|
||||||
BOOTSTRAP_ADMIN_USERNAME: str = ""
|
BOOTSTRAP_ADMIN_USERNAME: str = ""
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: str = ""
|
BOOTSTRAP_ADMIN_PASSWORD: str = ""
|
||||||
@ -194,6 +200,8 @@ class ServerConfig:
|
|||||||
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
||||||
SECRET_KEY=get_env("SECRET_KEY", ""),
|
SECRET_KEY=get_env("SECRET_KEY", ""),
|
||||||
INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
|
INVITE_ONLY=get_env_bool("INVITE_ONLY", True),
|
||||||
|
DAILY_OPEN_SIGNUPS=get_env_int("DAILY_OPEN_SIGNUPS", 0),
|
||||||
|
DAILY_SIGNUPS_PER_IP=get_env_int("DAILY_SIGNUPS_PER_IP", 3),
|
||||||
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
|
BOOTSTRAP_ADMIN_USERNAME=get_env("BOOTSTRAP_ADMIN_USERNAME", ""),
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
|
BOOTSTRAP_ADMIN_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
|
||||||
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
|
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
|
||||||
|
|||||||
@ -84,7 +84,7 @@ async def _periodic_leaderboard_refresh():
|
|||||||
|
|
||||||
|
|
||||||
async def _init_redis():
|
async def _init_redis():
|
||||||
"""Initialize Redis client and rate limiter."""
|
"""Initialize Redis client, rate limiter, and signup limiter."""
|
||||||
global _redis_client, _rate_limiter
|
global _redis_client, _rate_limiter
|
||||||
try:
|
try:
|
||||||
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
|
_redis_client = redis.from_url(config.REDIS_URL, decode_responses=False)
|
||||||
@ -95,6 +95,17 @@ async def _init_redis():
|
|||||||
from services.ratelimit import get_rate_limiter
|
from services.ratelimit import get_rate_limiter
|
||||||
_rate_limiter = await get_rate_limiter(_redis_client)
|
_rate_limiter = await get_rate_limiter(_redis_client)
|
||||||
logger.info("Rate limiter initialized")
|
logger.info("Rate limiter initialized")
|
||||||
|
|
||||||
|
# Initialize signup limiter for metered open signups
|
||||||
|
if config.DAILY_OPEN_SIGNUPS != 0 or config.DAILY_SIGNUPS_PER_IP > 0:
|
||||||
|
from services.ratelimit import get_signup_limiter
|
||||||
|
signup_limiter = await get_signup_limiter(_redis_client)
|
||||||
|
from routers.auth import set_signup_limiter
|
||||||
|
set_signup_limiter(signup_limiter)
|
||||||
|
logger.info(
|
||||||
|
f"Signup limiter initialized "
|
||||||
|
f"(daily={config.DAILY_OPEN_SIGNUPS}, per_ip={config.DAILY_SIGNUPS_PER_IP})"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
|
logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
|
||||||
_redis_client = None
|
_redis_client = None
|
||||||
|
|||||||
@ -81,11 +81,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
# Generate client key
|
# Generate client key
|
||||||
client_key = self.limiter.get_client_key(request, user_id)
|
client_key = self.limiter.get_client_key(request, user_id)
|
||||||
|
|
||||||
# Check rate limit
|
# Check rate limit (fail closed for auth endpoints)
|
||||||
endpoint_key = self._get_endpoint_key(path)
|
endpoint_key = self._get_endpoint_key(path)
|
||||||
full_key = f"{endpoint_key}:{client_key}"
|
full_key = f"{endpoint_key}:{client_key}"
|
||||||
|
|
||||||
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
|
is_auth_endpoint = path.startswith("/api/auth")
|
||||||
|
if is_auth_endpoint:
|
||||||
|
allowed, info = await self.limiter.is_allowed_strict(full_key, limit, window)
|
||||||
|
else:
|
||||||
|
allowed, info = await self.limiter.is_allowed(full_key, limit, window)
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
if allowed:
|
if allowed:
|
||||||
|
|||||||
@ -5,6 +5,7 @@ Provides endpoints for user registration, login, password management,
|
|||||||
and session handling.
|
and session handling.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ from config import config
|
|||||||
from models.user import User
|
from models.user import User
|
||||||
from services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
from services.admin_service import AdminService
|
from services.admin_service import AdminService
|
||||||
|
from services.ratelimit import SignupLimiter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -115,6 +117,7 @@ class SessionResponse(BaseModel):
|
|||||||
# These will be set by main.py during startup
|
# These will be set by main.py during startup
|
||||||
_auth_service: Optional[AuthService] = None
|
_auth_service: Optional[AuthService] = None
|
||||||
_admin_service: Optional[AdminService] = None
|
_admin_service: Optional[AdminService] = None
|
||||||
|
_signup_limiter: Optional[SignupLimiter] = None
|
||||||
|
|
||||||
|
|
||||||
def set_auth_service(service: AuthService) -> None:
|
def set_auth_service(service: AuthService) -> None:
|
||||||
@ -129,6 +132,12 @@ def set_admin_service_for_auth(service: AdminService) -> None:
|
|||||||
_admin_service = service
|
_admin_service = service
|
||||||
|
|
||||||
|
|
||||||
|
def set_signup_limiter(limiter: SignupLimiter) -> None:
|
||||||
|
"""Set the signup limiter instance (called from main.py)."""
|
||||||
|
global _signup_limiter
|
||||||
|
_signup_limiter = limiter
|
||||||
|
|
||||||
|
|
||||||
def get_auth_service_dep() -> AuthService:
|
def get_auth_service_dep() -> AuthService:
|
||||||
"""Dependency to get auth service."""
|
"""Dependency to get auth service."""
|
||||||
if _auth_service is None:
|
if _auth_service is None:
|
||||||
@ -211,15 +220,51 @@ async def register(
|
|||||||
auth_service: AuthService = Depends(get_auth_service_dep),
|
auth_service: AuthService = Depends(get_auth_service_dep),
|
||||||
):
|
):
|
||||||
"""Register a new user account."""
|
"""Register a new user account."""
|
||||||
# Validate invite code when invite-only mode is enabled
|
has_invite = bool(request_body.invite_code)
|
||||||
if config.INVITE_ONLY:
|
is_open_signup = not has_invite
|
||||||
if not request_body.invite_code:
|
client_ip = get_client_ip(request)
|
||||||
raise HTTPException(status_code=400, detail="Invite code required")
|
ip_hash = hashlib.sha256(client_ip.encode()).hexdigest()[:16] if client_ip else "unknown"
|
||||||
|
|
||||||
|
# --- Per-IP daily signup limit (applies to ALL signups) ---
|
||||||
|
if config.DAILY_SIGNUPS_PER_IP > 0 and _signup_limiter:
|
||||||
|
ip_allowed, ip_remaining = await _signup_limiter.check_ip_limit(
|
||||||
|
ip_hash, config.DAILY_SIGNUPS_PER_IP
|
||||||
|
)
|
||||||
|
if not ip_allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many signups from this address today. Please try again tomorrow.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Invite code validation ---
|
||||||
|
if has_invite:
|
||||||
if not _admin_service:
|
if not _admin_service:
|
||||||
raise HTTPException(status_code=503, detail="Admin service not initialized")
|
raise HTTPException(status_code=503, detail="Admin service not initialized")
|
||||||
if not await _admin_service.validate_invite_code(request_body.invite_code):
|
if not await _admin_service.validate_invite_code(request_body.invite_code):
|
||||||
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
|
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
|
||||||
|
else:
|
||||||
|
# No invite code — check if open signups are allowed
|
||||||
|
if config.INVITE_ONLY and config.DAILY_OPEN_SIGNUPS == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Invite code required")
|
||||||
|
|
||||||
|
# Check daily open signup limit
|
||||||
|
if config.DAILY_OPEN_SIGNUPS != 0 and _signup_limiter:
|
||||||
|
daily_allowed, daily_remaining = await _signup_limiter.check_daily_limit(
|
||||||
|
config.DAILY_OPEN_SIGNUPS
|
||||||
|
)
|
||||||
|
if not daily_allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Daily signup limit reached. Please try again tomorrow or use an invite code.",
|
||||||
|
)
|
||||||
|
elif config.DAILY_OPEN_SIGNUPS != 0 and not _signup_limiter:
|
||||||
|
# Signup limiter requires Redis — fail closed
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Registration temporarily unavailable. Please try again later.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Create the account ---
|
||||||
result = await auth_service.register(
|
result = await auth_service.register(
|
||||||
username=request_body.username,
|
username=request_body.username,
|
||||||
password=request_body.password,
|
password=request_body.password,
|
||||||
@ -229,12 +274,19 @@ async def register(
|
|||||||
if not result.success:
|
if not result.success:
|
||||||
raise HTTPException(status_code=400, detail=result.error)
|
raise HTTPException(status_code=400, detail=result.error)
|
||||||
|
|
||||||
# Consume the invite code after successful registration
|
# --- Post-registration bookkeeping ---
|
||||||
if config.INVITE_ONLY and request_body.invite_code:
|
# Consume invite code if used
|
||||||
|
if has_invite and _admin_service:
|
||||||
await _admin_service.use_invite_code(request_body.invite_code)
|
await _admin_service.use_invite_code(request_body.invite_code)
|
||||||
|
|
||||||
|
# Increment signup counters
|
||||||
|
if _signup_limiter:
|
||||||
|
if is_open_signup and config.DAILY_OPEN_SIGNUPS != 0:
|
||||||
|
await _signup_limiter.increment_daily()
|
||||||
|
if config.DAILY_SIGNUPS_PER_IP > 0:
|
||||||
|
await _signup_limiter.increment_ip(ip_hash)
|
||||||
|
|
||||||
if result.requires_verification:
|
if result.requires_verification:
|
||||||
# Return user info but note they need to verify
|
|
||||||
return {
|
return {
|
||||||
"user": _user_to_response(result.user),
|
"user": _user_to_response(result.user),
|
||||||
"token": "",
|
"token": "",
|
||||||
@ -247,7 +299,7 @@ async def register(
|
|||||||
username=request_body.username,
|
username=request_body.username,
|
||||||
password=request_body.password,
|
password=request_body.password,
|
||||||
device_info=get_device_info(request),
|
device_info=get_device_info(request),
|
||||||
ip_address=get_client_ip(request),
|
ip_address=client_ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not login_result.success:
|
if not login_result.success:
|
||||||
@ -260,6 +312,32 @@ async def register(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/signup-info")
|
||||||
|
async def signup_info():
|
||||||
|
"""
|
||||||
|
Public endpoint: returns signup availability info.
|
||||||
|
|
||||||
|
Tells the client whether invite codes are required,
|
||||||
|
and how many open signup slots remain today.
|
||||||
|
"""
|
||||||
|
open_signups_enabled = config.DAILY_OPEN_SIGNUPS != 0
|
||||||
|
invite_required = config.INVITE_ONLY and not open_signups_enabled
|
||||||
|
unlimited = config.DAILY_OPEN_SIGNUPS < 0
|
||||||
|
|
||||||
|
remaining = None
|
||||||
|
if open_signups_enabled and not unlimited and _signup_limiter:
|
||||||
|
daily_count = await _signup_limiter.get_daily_count()
|
||||||
|
remaining = max(0, config.DAILY_OPEN_SIGNUPS - daily_count)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"invite_required": invite_required,
|
||||||
|
"open_signups_enabled": open_signups_enabled,
|
||||||
|
"daily_limit": config.DAILY_OPEN_SIGNUPS if not unlimited else None,
|
||||||
|
"remaining_today": remaining,
|
||||||
|
"unlimited": unlimited,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-email")
|
@router.post("/verify-email")
|
||||||
async def verify_email(
|
async def verify_email(
|
||||||
request_body: VerifyEmailRequest,
|
request_body: VerifyEmailRequest,
|
||||||
|
|||||||
@ -91,9 +91,42 @@ class RateLimiter:
|
|||||||
|
|
||||||
except redis.RedisError as e:
|
except redis.RedisError as e:
|
||||||
# If Redis is unavailable, fail open (allow request)
|
# 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}")
|
logger.error(f"Rate limiter Redis error: {e}")
|
||||||
return True, {"remaining": limit, "reset": window_seconds, "limit": limit}
|
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(
|
def get_client_key(
|
||||||
self,
|
self,
|
||||||
request: Request | WebSocket,
|
request: Request | WebSocket,
|
||||||
@ -197,8 +230,110 @@ class ConnectionMessageLimiter:
|
|||||||
self.timestamps = []
|
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
|
# Global rate limiter instance
|
||||||
_rate_limiter: Optional[RateLimiter] = None
|
_rate_limiter: Optional[RateLimiter] = None
|
||||||
|
_signup_limiter: Optional[SignupLimiter] = None
|
||||||
|
|
||||||
|
|
||||||
async def get_rate_limiter(redis_client: redis.Redis) -> RateLimiter:
|
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
|
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():
|
def close_rate_limiter():
|
||||||
"""Close the global rate limiter."""
|
"""Close the global rate limiter."""
|
||||||
global _rate_limiter
|
global _rate_limiter, _signup_limiter
|
||||||
_rate_limiter = None
|
_rate_limiter = None
|
||||||
|
_signup_limiter = None
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user