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:
adlee-was-taken 2026-02-24 14:28:28 -05:00
parent 3d02d739e5
commit 6461a7f0c7
9 changed files with 320 additions and 14 deletions

View File

@ -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

View File

@ -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();

View File

@ -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">

View File

@ -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;

View File

@ -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),

View File

@ -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

View File

@ -81,10 +81,14 @@ 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}"
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) allowed, info = await self.limiter.is_allowed(full_key, limit, window)
# Build response # Build response

View File

@ -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,

View File

@ -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