diff --git a/CTFd/challenges.py b/CTFd/challenges.py index e61d84e6..b6364f2b 100644 --- a/CTFd/challenges.py +++ b/CTFd/challenges.py @@ -3,7 +3,11 @@ from flask import Blueprint, redirect, render_template, request, url_for from CTFd.constants.config import ChallengeVisibilityTypes, Configs from CTFd.utils.config import is_teams_mode from CTFd.utils.dates import ctf_ended, ctf_paused, ctf_started -from CTFd.utils.decorators import during_ctf_time_only, require_verified_emails +from CTFd.utils.decorators import ( + during_ctf_time_only, + require_complete_profile, + require_verified_emails, +) from CTFd.utils.decorators.visibility import check_challenge_visibility from CTFd.utils.helpers import get_errors, get_infos from CTFd.utils.user import authed, get_current_team @@ -12,6 +16,7 @@ challenges = Blueprint("challenges", __name__) @challenges.route("/challenges", methods=["GET"]) +@require_complete_profile @during_ctf_time_only @require_verified_emails @check_challenge_visibility diff --git a/CTFd/forms/self.py b/CTFd/forms/self.py index 13a32035..b5475e07 100644 --- a/CTFd/forms/self.py +++ b/CTFd/forms/self.py @@ -6,6 +6,7 @@ from CTFd.forms import BaseForm from CTFd.forms.fields import SubmitField from CTFd.forms.users import attach_custom_user_fields, build_custom_user_fields from CTFd.utils.countries import SELECT_COUNTRIES_LIST +from CTFd.utils.user import get_current_user def SettingsForm(*args, **kwargs): @@ -21,14 +22,25 @@ def SettingsForm(*args, **kwargs): @property def extra(self): + fields_kwargs = _SettingsForm.get_field_kwargs() return build_custom_user_fields( self, include_entries=True, - fields_kwargs={"editable": True}, + fields_kwargs=fields_kwargs, field_entries_kwargs={"user_id": session["id"]}, ) - attach_custom_user_fields(_SettingsForm, editable=True) + @staticmethod + def get_field_kwargs(): + user = get_current_user() + field_kwargs = {"editable": True} + if user.filled_all_required_fields is False: + # Show all fields + field_kwargs = {} + return field_kwargs + + field_kwargs = _SettingsForm.get_field_kwargs() + attach_custom_user_fields(_SettingsForm, **field_kwargs) return _SettingsForm(*args, **kwargs) diff --git a/CTFd/forms/teams.py b/CTFd/forms/teams.py index 5b0abfa0..cdffe2b0 100644 --- a/CTFd/forms/teams.py +++ b/CTFd/forms/teams.py @@ -6,6 +6,7 @@ from CTFd.forms import BaseForm from CTFd.forms.fields import SubmitField from CTFd.models import TeamFieldEntries, TeamFields from CTFd.utils.countries import SELECT_COUNTRIES_LIST +from CTFd.utils.user import get_current_team def build_custom_team_fields( @@ -122,13 +123,22 @@ def TeamSettingsForm(*args, **kwargs): @property def extra(self): + fields_kwargs = _TeamSettingsForm.get_field_kwargs() return build_custom_team_fields( self, include_entries=True, - fields_kwargs={"editable": True}, + fields_kwargs=fields_kwargs, field_entries_kwargs={"team_id": self.obj.id}, ) + def get_field_kwargs(): + team = get_current_team() + field_kwargs = {"editable": True} + if team.filled_all_required_fields is False: + # Show all fields + field_kwargs = {} + return field_kwargs + def __init__(self, *args, **kwargs): """ Custom init to persist the obj parameter to the rest of the form @@ -138,7 +148,9 @@ def TeamSettingsForm(*args, **kwargs): if obj: self.obj = obj - attach_custom_team_fields(_TeamSettingsForm) + field_kwargs = _TeamSettingsForm.get_field_kwargs() + attach_custom_team_fields(_TeamSettingsForm, **field_kwargs) + return _TeamSettingsForm(*args, **kwargs) diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py index f4652c0b..be7fe695 100644 --- a/CTFd/models/__init__.py +++ b/CTFd/models/__init__.py @@ -367,6 +367,22 @@ class Users(db.Model): else: return None + @property + def filled_all_required_fields(self): + required_user_fields = { + u.id + for u in UserFields.query.with_entities(UserFields.id) + .filter_by(required=True) + .all() + } + submitted_user_fields = { + u.field_id + for u in UserFieldEntries.query.with_entities(UserFieldEntries.field_id) + .filter_by(user_id=self.id) + .all() + } + return required_user_fields.issubset(submitted_user_fields) + def get_fields(self, admin=False): if admin: return self.field_entries @@ -538,6 +554,22 @@ class Teams(db.Model): else: return None + @property + def filled_all_required_fields(self): + required_team_fields = { + u.id + for u in TeamFields.query.with_entities(TeamFields.id) + .filter_by(required=True) + .all() + } + submitted_team_fields = { + u.field_id + for u in TeamFieldEntries.query.with_entities(TeamFieldEntries.field_id) + .filter_by(team_id=self.id) + .all() + } + return required_team_fields.issubset(submitted_team_fields) + def get_fields(self, admin=False): if admin: return self.field_entries diff --git a/CTFd/schemas/teams.py b/CTFd/schemas/teams.py index 017a7d54..2f8aab0e 100644 --- a/CTFd/schemas/teams.py +++ b/CTFd/schemas/teams.py @@ -259,22 +259,22 @@ class TeamSchema(ma.ModelSchema): # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce field = TeamFields.query.filter_by(id=field_id).first_or_404() + # Get the existing field entry if one exists + entry = TeamFieldEntries.query.filter_by( + field_id=field.id, team_id=current_team.id + ).first() + if field.required is True and value.strip() == "": raise ValidationError( f"Field '{field.name}' is required", field_names=["fields"] ) - if field.editable is False: + if field.editable is False and entry is not None: raise ValidationError( f"Field '{field.name}' cannot be editted", field_names=["fields"], ) - # Get the existing field entry if one exists - entry = TeamFieldEntries.query.filter_by( - field_id=field.id, team_id=current_team.id - ).first() - if entry: f["id"] = entry.id provided_ids.append(entry.id) diff --git a/CTFd/schemas/users.py b/CTFd/schemas/users.py index 76c55db4..04c33799 100644 --- a/CTFd/schemas/users.py +++ b/CTFd/schemas/users.py @@ -245,22 +245,22 @@ class UserSchema(ma.ModelSchema): # # Check that we have an existing field for this. May be unnecessary b/c the foriegn key should enforce field = UserFields.query.filter_by(id=field_id).first_or_404() + # Get the existing field entry if one exists + entry = UserFieldEntries.query.filter_by( + field_id=field.id, user_id=current_user.id + ).first() + if field.required is True and value.strip() == "": raise ValidationError( f"Field '{field.name}' is required", field_names=["fields"] ) - if field.editable is False: + if field.editable is False and entry is not None: raise ValidationError( f"Field '{field.name}' cannot be editted", field_names=["fields"], ) - # Get the existing field entry if one exists - entry = UserFieldEntries.query.filter_by( - field_id=field.id, user_id=current_user.id - ).first() - if entry: f["id"] = entry.id provided_ids.append(entry.id) diff --git a/CTFd/utils/decorators/__init__.py b/CTFd/utils/decorators/__init__.py index 8f979aa2..78201643 100644 --- a/CTFd/utils/decorators/__init__.py +++ b/CTFd/utils/decorators/__init__.py @@ -5,9 +5,9 @@ from flask import abort, jsonify, redirect, request, url_for from CTFd.cache import cache from CTFd.utils import config, get_config from CTFd.utils import user as current_user +from CTFd.utils.config import is_teams_mode from CTFd.utils.dates import ctf_ended, ctf_started, ctftime, view_after_ctf -from CTFd.utils.modes import TEAMS_MODE -from CTFd.utils.user import authed, get_current_team, is_admin +from CTFd.utils.user import authed, get_current_team, get_current_user, is_admin def during_ctf_time_only(f): @@ -143,7 +143,7 @@ def admins_only(f): def require_team(f): @functools.wraps(f) def require_team_wrapper(*args, **kwargs): - if get_config("user_mode") == TEAMS_MODE: + if is_teams_mode(): team = get_current_team() if team is None: if request.content_type == "application/json": @@ -186,3 +186,39 @@ def ratelimit(method="POST", limit=50, interval=300, key_prefix="rl"): return ratelimit_function return ratelimit_decorator + + +def require_complete_profile(f): + from CTFd.utils.helpers import info_for + + @functools.wraps(f) + def _require_complete_profile(*args, **kwargs): + if authed(): + if is_admin(): + return f(*args, **kwargs) + else: + user = get_current_user() + + if user.filled_all_required_fields is False: + info_for( + "views.settings", + "Please fill out all required profile fields before continuing", + ) + return redirect(url_for("views.settings")) + + if is_teams_mode(): + team = get_current_team() + + if team and team.filled_all_required_fields is False: + # This is an abort because it's difficult for us to flash information on the teams page + return abort( + 403, + description="Please fill in all required team profile fields", + ) + + return f(*args, **kwargs) + else: + # Fallback to whatever behavior the route defaults to + return f(*args, **kwargs) + + return _require_complete_profile diff --git a/CTFd/views.py b/CTFd/views.py index 782d1a4a..c14eb28a 100644 --- a/CTFd/views.py +++ b/CTFd/views.py @@ -304,6 +304,7 @@ def notifications(): @authed_only def settings(): infos = get_infos() + errors = get_errors() user = get_current_user() name = user.name @@ -336,6 +337,7 @@ def settings(): tokens=tokens, prevent_name_change=prevent_name_change, infos=infos, + errors=errors, ) diff --git a/tests/teams/test_fields.py b/tests/teams/test_fields.py index e9250025..3699a2bd 100644 --- a/tests/teams/test_fields.py +++ b/tests/teams/test_fields.py @@ -99,16 +99,9 @@ def test_team_fields_required_on_creation(): def test_team_fields_properties(): + """Test that custom fields for team can be set and editted""" app = create_ctfd(user_mode="teams") with app.app_context(): - register_user(app) - team = gen_team(app.db) - user = Users.query.filter_by(id=2).first() - user.team_id = team.id - team = Teams.query.filter_by(id=1).first() - team.captain_id = 2 - app.db.session.commit() - gen_field( app.db, name="CustomField1", @@ -142,6 +135,8 @@ def test_team_fields_properties(): editable=False, ) + register_user(app) + with login_as_user(app) as client: r = client.get("/teams/new") resp = r.get_data(as_text=True) @@ -150,6 +145,17 @@ def test_team_fields_properties(): assert "CustomField3" in resp assert "CustomField4" in resp + # Manually create team so that we can set the required profile field + with client.session_transaction() as sess: + data = { + "name": "team", + "password": "password", + "fields[1]": "custom_field_value", + "nonce": sess.get("nonce"), + } + r = client.post("/teams/new", data=data) + assert r.status_code == 302 + r = client.get("/team") resp = r.get_data(as_text=True) assert "CustomField1" in resp @@ -249,3 +255,142 @@ def test_teams_boolean_checkbox_field(): assert TeamFieldEntries.query.count() == 1 assert TeamFieldEntries.query.filter_by(id=1).first().value is False destroy_ctfd(app) + + +def test_team_needs_all_required_fields(): + """Test that teams need to complete profiles before seeing challenges""" + app = create_ctfd(user_mode="teams") + with app.app_context(): + # Create a user and team who haven't filled any of their fields + register_user(app) + team = gen_team(app.db) + user = Users.query.filter_by(id=2).first() + user.team_id = team.id + team = Teams.query.filter_by(id=1).first() + team.captain_id = 2 + app.db.session.commit() + + gen_field( + app.db, + name="CustomField1", + type="team", + required=True, + public=True, + editable=True, + ) + gen_field( + app.db, + name="CustomField2", + type="team", + required=False, + public=True, + editable=True, + ) + gen_field( + app.db, + name="CustomField3", + type="team", + required=False, + public=False, + editable=True, + ) + gen_field( + app.db, + name="CustomField4", + type="team", + required=False, + public=False, + editable=False, + ) + + with login_as_user(app) as client: + r = client.get("/teams/new") + resp = r.get_data(as_text=True) + assert "CustomField1" in resp + assert "CustomField2" in resp + assert "CustomField3" in resp + assert "CustomField4" in resp + + # We can't view challenges because we have an incomplete team profile + r = client.get("/challenges") + assert r.status_code == 403 + + # When we go to our profile we should see all fields + r = client.get("/team") + resp = r.get_data(as_text=True) + assert "CustomField1" in resp + assert "CustomField2" in resp + assert "CustomField3" in resp + assert "CustomField4" in resp + + # Set all non-required fields + r = client.patch( + "/api/v1/teams/me", + json={ + "fields": [ + # {"field_id": 1, "value": "CustomFieldEntry1"}, + {"field_id": 2, "value": "CustomFieldEntry2"}, + {"field_id": 3, "value": "CustomFieldEntry3"}, + {"field_id": 4, "value": "CustomFieldEntry4"}, + ] + }, + ) + assert r.status_code == 200 + + # We can't view challenges because we have an incomplete team profile + r = client.get("/challenges") + assert r.status_code == 403 + + # Set required fields + r = client.patch( + "/api/v1/teams/me", + json={"fields": [{"field_id": 1, "value": "CustomFieldEntry1"}]}, + ) + assert r.status_code == 200 + + # We can view challenges now + r = client.get("/challenges") + assert r.status_code == 200 + + # Attempts to edit a non-edittable field to field after completing profile + r = client.patch( + "/api/v1/teams/me", + json={"fields": [{"field_id": 4, "value": "CustomFieldEntry4"}]}, + ) + resp = r.get_json() + assert resp == { + "success": False, + "errors": {"fields": ["Field 'CustomField4' cannot be editted"]}, + } + + # I can edit edittable fields + r = client.patch( + "/api/v1/teams/me", + json={ + "fields": [ + {"field_id": 1, "value": "CustomFieldEntry1"}, + {"field_id": 2, "value": "CustomFieldEntry2"}, + {"field_id": 3, "value": "CustomFieldEntry3"}, + ] + }, + ) + assert r.status_code == 200 + + # I should see the correct fields in the private team profile + r = client.get("/team") + resp = r.get_data(as_text=True) + assert "CustomField1" in resp + assert "CustomField2" in resp + assert ( + "CustomField3" in resp + ) # This is here because /team contains team settings + assert "CustomField4" not in resp + + # I should see the correct fields in the public team profile + r = client.get("/teams/1") + resp = r.get_data(as_text=True) + assert "CustomField1" in resp + assert "CustomField2" in resp + assert "CustomField3" not in resp + assert "CustomField4" not in resp + destroy_ctfd(app) diff --git a/tests/users/test_fields.py b/tests/users/test_fields.py index dcac45c1..b118c651 100644 --- a/tests/users/test_fields.py +++ b/tests/users/test_fields.py @@ -85,10 +85,9 @@ def test_fields_required_on_register(): def test_fields_properties(): + """Test that users can set and edit custom fields""" app = create_ctfd() with app.app_context(): - register_user(app) - gen_field( app.db, name="CustomField1", required=True, public=True, editable=True ) @@ -110,6 +109,19 @@ def test_fields_properties(): assert "CustomField3" in resp assert "CustomField4" in resp + # Manually register user so that we can populate the required field + with client.session_transaction() as sess: + data = { + "name": "user", + "email": "user@examplectf.com", + "password": "password", + "fields[1]": "custom_field_value", + "nonce": sess.get("nonce"), + } + client.post("/register", data=data) + with client.session_transaction() as sess: + assert sess["id"] + with login_as_user(app) as client: r = client.get("/settings") resp = r.get_data(as_text=True) @@ -203,3 +215,105 @@ def test_boolean_checkbox_field(): assert UserFieldEntries.query.count() == 1 assert UserFieldEntries.query.filter_by(id=1).first().value is False destroy_ctfd(app) + + +def test_user_needs_all_required_fields(): + """Test that users need to submit all required fields before viewing challenges""" + app = create_ctfd() + with app.app_context(): + # Manually create a user who has no fields set + register_user(app) + + # Create the fields that we want + gen_field( + app.db, name="CustomField1", required=True, public=True, editable=True + ) + gen_field( + app.db, name="CustomField2", required=False, public=True, editable=True + ) + gen_field( + app.db, name="CustomField3", required=False, public=False, editable=True + ) + gen_field( + app.db, name="CustomField4", required=False, public=False, editable=False + ) + + # We can see all fields when we try to register + with app.test_client() as client: + r = client.get("/register") + resp = r.get_data(as_text=True) + assert "CustomField1" in resp + assert "CustomField2" in resp + assert "CustomField3" in resp + assert "CustomField4" in resp + + # When we login with our manually made user + # we should see all fields because we are missing a required field + with login_as_user(app) as client: + r = client.get("/settings") + resp = r.get_data(as_text=True) + assert "CustomField1" in resp + assert "CustomField2" in resp + assert "CustomField3" in resp + assert "CustomField4" in resp + + r = client.get("/challenges") + assert r.status_code == 302 + assert r.location.startswith("http://localhost/settings") + + # Populate the non-required fields + r = client.patch( + "/api/v1/users/me", + json={ + "fields": [ + {"field_id": 2, "value": "CustomFieldEntry2"}, + {"field_id": 3, "value": "CustomFieldEntry3"}, + {"field_id": 4, "value": "CustomFieldEntry4"}, + ] + }, + ) + assert r.status_code == 200 + + # I should still be restricted from seeing challenges + r = client.get("/challenges") + assert r.status_code == 302 + assert r.location.startswith("http://localhost/settings") + + # I should still see all fields b/c I don't have a complete profile + r = client.get("/settings") + resp = r.get_data(as_text=True) + assert "CustomField1" in resp + assert "CustomField2" in resp + assert "CustomField3" in resp + assert "CustomField4" in resp + + # Populate the required fields + r = client.patch( + "/api/v1/users/me", + json={"fields": [{"field_id": 1, "value": "CustomFieldEntry1"}]}, + ) + assert r.status_code == 200 + + # I can now go to challenges + r = client.get("/challenges") + assert r.status_code == 200 + + # I should only see edittable fields + r = client.get("/settings") + resp = r.get_data(as_text=True) + assert "CustomField1" in resp + assert "CustomField2" in resp + assert "CustomField3" in resp + assert "CustomField4" not in resp + + # I can't edit a non-editable field + r = client.patch( + "/api/v1/users/me", + json={"fields": [{"field_id": 4, "value": "CustomFieldEntry4"}]}, + ) + resp = r.get_json() + assert resp == { + "success": False, + "errors": {"fields": ["Field 'CustomField4' cannot be editted"]}, + } + destroy_ctfd(app)