-
+
+
+
diff --git a/client/style.css b/client/style.css
index d7c7922..063c7cb 100644
--- a/client/style.css
+++ b/client/style.css
@@ -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;
diff --git a/server/config.py b/server/config.py
index f88170d..9b02125 100644
--- a/server/config.py
+++ b/server/config.py
@@ -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),
diff --git a/server/main.py b/server/main.py
index b947927..0a7bb0f 100644
--- a/server/main.py
+++ b/server/main.py
@@ -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
diff --git a/server/middleware/ratelimit.py b/server/middleware/ratelimit.py
index 3bf95eb..8d03a74 100644
--- a/server/middleware/ratelimit.py
+++ b/server/middleware/ratelimit.py
@@ -81,11 +81,15 @@ 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}"
- 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
if allowed:
diff --git a/server/routers/auth.py b/server/routers/auth.py
index eec10b6..eefa338 100644
--- a/server/routers/auth.py
+++ b/server/routers/auth.py
@@ -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,
diff --git a/server/services/ratelimit.py b/server/services/ratelimit.py
index 15b05cf..aee6251 100644
--- a/server/services/ratelimit.py
+++ b/server/services/ratelimit.py
@@ -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