Add a decorator for redirecting users if their profile isn't complete (#1933)

* Redirect users and teams whose profiles are incomplete to complete their profile
* Closes #1926
This commit is contained in:
Kevin Chung
2021-07-29 02:11:54 -04:00
committed by GitHub
parent 0dbe008011
commit 22a0c0b007
10 changed files with 388 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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