From 6461a7f0c75ad98c675d5523c18d84f93d9b4f87 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 24 Feb 2026 14:28:28 -0500 Subject: [PATCH] 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 --- .env.example | 9 ++ client/app.js | 44 ++++++++++ client/index.html | 5 +- client/style.css | 7 ++ server/config.py | 8 ++ server/main.py | 13 ++- server/middleware/ratelimit.py | 8 +- server/routers/auth.py | 94 +++++++++++++++++++-- server/services/ratelimit.py | 146 ++++++++++++++++++++++++++++++++- 9 files changed, 320 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 40451f7..9dc4381 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/client/app.js b/client/app.js index f785a51..f54a975 100644 --- a/client/app.js +++ b/client/app.js @@ -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(); diff --git a/client/index.html b/client/index.html index f1dd7ed..42386a2 100644 --- a/client/index.html +++ b/client/index.html @@ -893,8 +893,9 @@ TOTAL: 0 + 8 + 16 = 24 points