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)
|
||||
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)
|
||||
# Remove these after first login!
|
||||
# BOOTSTRAP_ADMIN_USERNAME=admin
|
||||
|
||||
@ -4762,11 +4762,14 @@ class AuthManager {
|
||||
this.signupFormContainer = document.getElementById('signup-form-container');
|
||||
this.signupForm = document.getElementById('signup-form');
|
||||
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.signupEmail = document.getElementById('signup-email');
|
||||
this.signupPassword = document.getElementById('signup-password');
|
||||
this.signupError = document.getElementById('signup-error');
|
||||
this.showSignupLink = document.getElementById('show-signup');
|
||||
this.signupInfo = null; // populated by fetchSignupInfo()
|
||||
this.showLoginLink = document.getElementById('show-login');
|
||||
this.showForgotLink = document.getElementById('show-forgot');
|
||||
this.forgotFormContainer = document.getElementById('forgot-form-container');
|
||||
@ -4815,6 +4818,9 @@ class AuthManager {
|
||||
// Check URL for reset token or invite code on page load
|
||||
this.checkResetToken();
|
||||
this.checkInviteCode();
|
||||
|
||||
// Fetch signup availability info (metered open signups)
|
||||
this.fetchSignupInfo();
|
||||
}
|
||||
|
||||
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) {
|
||||
e.preventDefault();
|
||||
this.clearErrors();
|
||||
|
||||
@ -893,8 +893,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
||||
<div id="signup-form-container" class="hidden">
|
||||
<h3>Sign Up</h3>
|
||||
<form id="signup-form">
|
||||
<div class="form-group">
|
||||
<input type="text" id="signup-invite-code" placeholder="Invite Code" required>
|
||||
<div class="form-group" id="invite-code-group">
|
||||
<input type="text" id="signup-invite-code" placeholder="Invite Code">
|
||||
<small id="invite-code-hint" class="form-hint"></small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
||||
|
||||
@ -3605,6 +3605,13 @@ input::placeholder {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
|
||||
@ -147,6 +147,12 @@ class ServerConfig:
|
||||
SECRET_KEY: str = ""
|
||||
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_USERNAME: str = ""
|
||||
BOOTSTRAP_ADMIN_PASSWORD: str = ""
|
||||
@ -194,6 +200,8 @@ class ServerConfig:
|
||||
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
||||
SECRET_KEY=get_env("SECRET_KEY", ""),
|
||||
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_PASSWORD=get_env("BOOTSTRAP_ADMIN_PASSWORD", ""),
|
||||
MATCHMAKING_ENABLED=get_env_bool("MATCHMAKING_ENABLED", True),
|
||||
|
||||
@ -84,7 +84,7 @@ async def _periodic_leaderboard_refresh():
|
||||
|
||||
|
||||
async def _init_redis():
|
||||
"""Initialize Redis client and rate limiter."""
|
||||
"""Initialize Redis client, rate limiter, and signup limiter."""
|
||||
global _redis_client, _rate_limiter
|
||||
try:
|
||||
_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
|
||||
_rate_limiter = await get_rate_limiter(_redis_client)
|
||||
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:
|
||||
logger.warning(f"Redis connection failed: {e} - rate limiting disabled")
|
||||
_redis_client = None
|
||||
|
||||
@ -81,10 +81,14 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
# Generate client key
|
||||
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)
|
||||
full_key = f"{endpoint_key}:{client_key}"
|
||||
|
||||
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
|
||||
|
||||
@ -5,6 +5,7 @@ Provides endpoints for user registration, login, password management,
|
||||
and session handling.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
@ -15,6 +16,7 @@ from config import config
|
||||
from models.user import User
|
||||
from services.auth_service import AuthService
|
||||
from services.admin_service import AdminService
|
||||
from services.ratelimit import SignupLimiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -115,6 +117,7 @@ class SessionResponse(BaseModel):
|
||||
# These will be set by main.py during startup
|
||||
_auth_service: Optional[AuthService] = None
|
||||
_admin_service: Optional[AdminService] = None
|
||||
_signup_limiter: Optional[SignupLimiter] = None
|
||||
|
||||
|
||||
def set_auth_service(service: AuthService) -> None:
|
||||
@ -129,6 +132,12 @@ def set_admin_service_for_auth(service: AdminService) -> None:
|
||||
_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:
|
||||
"""Dependency to get auth service."""
|
||||
if _auth_service is None:
|
||||
@ -211,15 +220,51 @@ async def register(
|
||||
auth_service: AuthService = Depends(get_auth_service_dep),
|
||||
):
|
||||
"""Register a new user account."""
|
||||
# Validate invite code when invite-only mode is enabled
|
||||
if config.INVITE_ONLY:
|
||||
if not request_body.invite_code:
|
||||
raise HTTPException(status_code=400, detail="Invite code required")
|
||||
has_invite = bool(request_body.invite_code)
|
||||
is_open_signup = not has_invite
|
||||
client_ip = get_client_ip(request)
|
||||
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:
|
||||
raise HTTPException(status_code=503, detail="Admin service not initialized")
|
||||
if not await _admin_service.validate_invite_code(request_body.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(
|
||||
username=request_body.username,
|
||||
password=request_body.password,
|
||||
@ -229,12 +274,19 @@ async def register(
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=400, detail=result.error)
|
||||
|
||||
# Consume the invite code after successful registration
|
||||
if config.INVITE_ONLY and request_body.invite_code:
|
||||
# --- Post-registration bookkeeping ---
|
||||
# Consume invite code if used
|
||||
if has_invite and _admin_service:
|
||||
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:
|
||||
# Return user info but note they need to verify
|
||||
return {
|
||||
"user": _user_to_response(result.user),
|
||||
"token": "",
|
||||
@ -247,7 +299,7 @@ async def register(
|
||||
username=request_body.username,
|
||||
password=request_body.password,
|
||||
device_info=get_device_info(request),
|
||||
ip_address=get_client_ip(request),
|
||||
ip_address=client_ip,
|
||||
)
|
||||
|
||||
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")
|
||||
async def verify_email(
|
||||
request_body: VerifyEmailRequest,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user