diff --git a/CTFd/auth.py b/CTFd/auth.py index 739558ea..aacea14b 100644 --- a/CTFd/auth.py +++ b/CTFd/auth.py @@ -189,6 +189,14 @@ def register(): if current_user.authed(): return redirect(url_for("challenges.listing")) + num_users_limit = int(get_config("num_users", default=0)) + num_users = Users.query.filter_by(banned=False, hidden=False).count() + if num_users_limit and num_users >= num_users_limit: + abort( + 403, + description=f"Reached the maximum number of users ({num_users_limit}).", + ) + if request.method == "POST": name = request.form.get("name", "").strip() email_address = request.form.get("email", "").strip().lower() @@ -490,6 +498,15 @@ def oauth_redirect(): user = Users.query.filter_by(email=user_email).first() if user is None: + # Respect the user count limit + num_users_limit = int(get_config("num_users", default=0)) + num_users = Users.query.filter_by(banned=False, hidden=False).count() + if num_users_limit and num_users >= num_users_limit: + abort( + 403, + description=f"Reached the maximum number of users ({num_users_limit}).", + ) + # Check if we are allowing registration before creating users if registration_visible() or mlc_registration(): user = Users( diff --git a/CTFd/forms/config.py b/CTFd/forms/config.py index da6e00ec..bc68dee6 100644 --- a/CTFd/forms/config.py +++ b/CTFd/forms/config.py @@ -48,6 +48,9 @@ class AccountSettingsForm(BaseForm): widget=NumberInput(min=0), description="Max number of teams (Teams mode only)", ) + num_users = IntegerField( + widget=NumberInput(min=0), description="Max number of users", + ) verify_emails = SelectField( "Verify Emails", description="Control whether users must confirm their email addresses before playing", diff --git a/CTFd/themes/admin/templates/configs/accounts.html b/CTFd/themes/admin/templates/configs/accounts.html index 756551a6..f67dd60d 100644 --- a/CTFd/themes/admin/templates/configs/accounts.html +++ b/CTFd/themes/admin/templates/configs/accounts.html @@ -46,6 +46,14 @@ +
+ {{ form.num_users.label }} + {{ form.num_users(class="form-control", value=num_users) }} + + {{ form.num_users.description }} + +
+
{{ form.team_disbanding.label }} {{ form.team_disbanding(class="form-control", value=team_disbanding) }} diff --git a/tests/oauth/test_users.py b/tests/oauth/test_users.py new file mode 100644 index 00000000..a53ecccd --- /dev/null +++ b/tests/oauth/test_users.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from CTFd.models import Users +from CTFd.utils import set_config +from tests.helpers import create_ctfd, destroy_ctfd, login_with_mlc, register_user + + +def test_num_users_oauth_limit(): + """Only num_users users can be created even via MLC""" + app = create_ctfd() + app.config.update( + { + "OAUTH_CLIENT_ID": "ctfd_testing_client_id", + "OAUTH_CLIENT_SECRET": "ctfd_testing_client_secret", + "OAUTH_AUTHORIZATION_ENDPOINT": "http://auth.localhost/oauth/authorize", + "OAUTH_TOKEN_ENDPOINT": "http://auth.localhost/oauth/token", + "OAUTH_API_ENDPOINT": "http://api.localhost/user", + } + ) + with app.app_context(): + register_user(app) + # There should be the admin and our registered user + assert Users.query.count() == 2 + set_config("num_users", 1) + + # This registration should fail and we should still have 2 users + login_with_mlc( + app, + name="foobarbaz", + email="foobarbaz@a.com", + oauth_id=111, + scope="profile", + raise_for_error=False, + ) + assert Users.query.count() == 2 + + # We increment num_users to 2 and then login again + set_config("num_users", 2) + login_with_mlc( + app, + name="foobarbaz", + email="foobarbaz@a.com", + oauth_id=111, + scope="profile", + ) + # The above login should have succeeded + assert Users.query.count() == 3 + destroy_ctfd(app) diff --git a/tests/users/test_users.py b/tests/users/test_users.py index 3419843b..22e3c883 100644 --- a/tests/users/test_users.py +++ b/tests/users/test_users.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- from CTFd.models import Users +from CTFd.utils import set_config from tests.helpers import ( create_ctfd, destroy_ctfd, @@ -107,3 +108,44 @@ def test_hidden_user_visibility(): response = r.get_data(as_text=True) assert user_name in response destroy_ctfd(app) + + +def test_num_users_limit(): + """Only num_users users can be created""" + app = create_ctfd() + with app.app_context(): + set_config("num_users", 1) + + register_user(app) + with app.test_client() as client: + r = client.get("/register") + assert r.status_code == 403 + + # team should be blocked from creation + with client.session_transaction() as sess: + data = { + "name": "user", + "email": "user@examplectf.com", + "password": "password", + "nonce": sess.get("nonce"), + } + r = client.post("/register", data=data) + resp = r.get_data(as_text=True) + # This number is 2 to account for the admin and the registered user + assert Users.query.count() == 2 + assert "Reached the maximum number of users" in resp + + # Can the team be created after the num has been bumped + set_config("num_users", 2) + with client.session_transaction() as sess: + data = { + "name": "user1", + "email": "user1@examplectf.com", + "password": "password", + "nonce": sess.get("nonce"), + } + r = client.post("/register", data=data) + resp = r.get_data(as_text=True) + assert r.status_code == 302 + assert Users.query.count() == 3 + destroy_ctfd(app)