diff --git a/.gitignore b/.gitignore index 27f729a..59a3589 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,6 @@ CTFd/uploads # JS node_modules/ + +# Flask Profiler files +flask_profiler.sql diff --git a/CTFd/api/v1/teams.py b/CTFd/api/v1/teams.py index 070cbc3..d387f81 100644 --- a/CTFd/api/v1/teams.py +++ b/CTFd/api/v1/teams.py @@ -3,7 +3,7 @@ import copy from flask import abort, request, session from flask_restx import Namespace, Resource -from CTFd.cache import clear_standings +from CTFd.cache import clear_standings, clear_team_session, clear_user_session from CTFd.models import Awards, Submissions, Teams, Unlocks, Users, db from CTFd.schemas.awards import AwardSchema from CTFd.schemas.submissions import SubmissionSchema @@ -91,25 +91,31 @@ class TeamPublic(Resource): response = schema.dump(response.data) db.session.commit() - db.session.close() + clear_team_session(team_id=team.id) clear_standings() + db.session.close() + return {"success": True, "data": response.data} @admins_only def delete(self, team_id): team = Teams.query.filter_by(id=team_id).first_or_404() + team_id = team.id for member in team.members: member.team_id = None + clear_user_session(user_id=member.id) db.session.delete(team) db.session.commit() - db.session.close() + clear_team_session(team_id=team_id) clear_standings() + db.session.close() + return {"success": True} @@ -150,7 +156,7 @@ class TeamPrivate(Resource): return {"success": False, "errors": response.errors}, 400 db.session.commit() - + clear_team_session(team_id=team.id) response = TeamSchema("self").dump(response.data) db.session.close() diff --git a/CTFd/api/v1/users.py b/CTFd/api/v1/users.py index 895c9ea..20e3998 100644 --- a/CTFd/api/v1/users.py +++ b/CTFd/api/v1/users.py @@ -1,7 +1,7 @@ from flask import abort, request from flask_restx import Namespace, Resource -from CTFd.cache import clear_standings +from CTFd.cache import clear_standings, clear_user_session from CTFd.models import ( Awards, Notifications, @@ -107,6 +107,7 @@ class UserPublic(Resource): db.session.close() + clear_user_session(user_id=user_id) clear_standings() return {"success": True, "data": response} @@ -123,6 +124,7 @@ class UserPublic(Resource): db.session.commit() db.session.close() + clear_user_session(user_id=user_id) clear_standings() return {"success": True} @@ -149,6 +151,7 @@ class UserPrivate(Resource): db.session.commit() + clear_user_session(user_id=user.id) response = schema.dump(response.data) db.session.close() diff --git a/CTFd/auth.py b/CTFd/auth.py index 93ca3bb..4e77813 100644 --- a/CTFd/auth.py +++ b/CTFd/auth.py @@ -9,6 +9,7 @@ from itsdangerous.exc import BadSignature, BadTimeSignature, SignatureExpired from CTFd.models import Teams, Users, db from CTFd.utils import config, email, get_app_config, get_config from CTFd.utils import user as current_user +from CTFd.cache import clear_user_session, clear_team_session from CTFd.utils import validators from CTFd.utils.config import is_teams_mode from CTFd.utils.config.integrations import mlc_registration @@ -57,6 +58,7 @@ def confirm(data=None): name=user.name, ) db.session.commit() + clear_user_session(user_id=user.id) email.successful_registration_notification(user.email) db.session.close() if current_user.authed(): @@ -126,6 +128,7 @@ def reset_password(data=None): user.password = password db.session.commit() + clear_user_session(user_id=user.id) log( "logins", format="[{date}] {ip} - successful password reset for {name}", @@ -411,6 +414,7 @@ def oauth_redirect(): team = Teams(name=team_name, oauth_id=team_id, captain_id=user.id) db.session.add(team) db.session.commit() + clear_team_session(team_id=team.id) team_size_limit = get_config("team_size", default=0) if team_size_limit and len(team.members) >= team_size_limit: @@ -428,6 +432,7 @@ def oauth_redirect(): user.oauth_id = user_id user.verified = True db.session.commit() + clear_user_session(user_id=user.id) login_user(user) diff --git a/CTFd/cache/__init__.py b/CTFd/cache/__init__.py index 601616a..686e694 100644 --- a/CTFd/cache/__init__.py +++ b/CTFd/cache/__init__.py @@ -50,3 +50,15 @@ def clear_user_ips(user_id): from CTFd.utils.user import get_user_ips cache.delete_memoized(get_user_ips, user_id=user_id) + + +def clear_user_session(user_id): + from CTFd.utils.user import get_user_attrs + + cache.delete_memoized(get_user_attrs, user_id=user_id) + + +def clear_team_session(team_id): + from CTFd.utils.user import get_team_attrs + + cache.delete_memoized(get_team_attrs, team_id=team_id) diff --git a/CTFd/constants/teams.py b/CTFd/constants/teams.py new file mode 100644 index 0000000..d9de99d --- /dev/null +++ b/CTFd/constants/teams.py @@ -0,0 +1,20 @@ +from collections import namedtuple + +TeamAttrs = namedtuple( + "TeamAttrs", + [ + "id", + "oauth_id", + "name", + "email", + "secret", + "website", + "affiliation", + "country", + "bracket", + "hidden", + "banned", + "captain_id", + "created", + ], +) diff --git a/CTFd/constants/users.py b/CTFd/constants/users.py new file mode 100644 index 0000000..8fcf815 --- /dev/null +++ b/CTFd/constants/users.py @@ -0,0 +1,22 @@ +from collections import namedtuple + +UserAttrs = namedtuple( + "UserAttrs", + [ + "id", + "oauth_id", + "name", + "email", + "type", + "secret", + "website", + "affiliation", + "country", + "bracket", + "hidden", + "banned", + "verified", + "team_id", + "created", + ], +) diff --git a/CTFd/teams.py b/CTFd/teams.py index c26e41a..5030e54 100644 --- a/CTFd/teams.py +++ b/CTFd/teams.py @@ -1,5 +1,6 @@ from flask import Blueprint, redirect, render_template, request, url_for +from CTFd.cache import clear_user_session, clear_team_session from CTFd.models import Teams, db from CTFd.utils import config, get_config from CTFd.utils.crypto import verify_password @@ -63,7 +64,6 @@ def join(): passphrase = request.form.get("password", "").strip() team = Teams.query.filter_by(name=teamname).first() - user = get_current_user() if team and verify_password(passphrase, team.password): team_size_limit = get_config("team_size", default=0) @@ -77,6 +77,7 @@ def join(): "teams/join_team.html", infos=infos, errors=errors ) + user = get_current_user() user.team_id = team.id db.session.commit() @@ -84,6 +85,9 @@ def join(): team.captain_id = user.id db.session.commit() + clear_user_session(user_id=user.id) + clear_team_session(team_id=team.id) + return redirect(url_for("challenges.listing")) else: errors.append("That information is incorrect") @@ -130,6 +134,10 @@ def new(): user.team_id = team.id db.session.commit() + + clear_user_session(user_id=user.id) + clear_team_session(team_id=team.id) + return redirect(url_for("challenges.listing")) diff --git a/CTFd/utils/initialization/__init__.py b/CTFd/utils/initialization/__init__.py index 7e79721..bcbfcad 100644 --- a/CTFd/utils/initialization/__init__.py +++ b/CTFd/utils/initialization/__init__.py @@ -41,10 +41,9 @@ from CTFd.utils.security.auth import login_user, logout_user, lookup_user_token from CTFd.utils.security.csrf import generate_nonce from CTFd.utils.user import ( authed, - get_current_team, - get_current_user, + get_current_user_attrs, + get_current_team_attrs, get_ip, - get_current_user_ips, is_admin, ) @@ -84,6 +83,9 @@ def init_template_globals(app): app.jinja_env.globals.update(integrations=integrations) app.jinja_env.globals.update(authed=authed) app.jinja_env.globals.update(is_admin=is_admin) + app.jinja_env.globals.update(get_current_user_attrs=get_current_user_attrs) + app.jinja_env.globals.update(get_current_team_attrs=get_current_team_attrs) + app.jinja_env.globals.update(get_ip=get_ip) def init_logs(app): @@ -199,31 +201,34 @@ def init_request_processors(app): logout_user() clear_user_ips(user_id=session["id"]) - if authed(): - user = get_current_user() - team = get_current_team() - - if request.path.startswith("/themes") is False: - if user and user.banned: - return ( - render_template( - "errors/403.html", - error="You have been banned from this CTF", - ), - 403, - ) - - if team and team.banned: - return ( - render_template( - "errors/403.html", - error="Your team has been banned from this CTF", - ), - 403, - ) - db.session.close() + @app.before_request + def banned(): + if request.endpoint == "views.themes": + return + + if authed(): + user = get_current_user_attrs() + team = get_current_team_attrs() + + if user and user.banned: + return ( + render_template( + "errors/403.html", error="You have been banned from this CTF" + ), + 403, + ) + + if team and team.banned: + return ( + render_template( + "errors/403.html", + error="Your team has been banned from this CTF", + ), + 403, + ) + @app.before_request def tokens(): token = request.headers.get("Authorization") diff --git a/CTFd/utils/security/auth.py b/CTFd/utils/security/auth.py index 077e414..d008e75 100644 --- a/CTFd/utils/security/auth.py +++ b/CTFd/utils/security/auth.py @@ -26,9 +26,7 @@ def generate_user_token(user, expiration=None): value = hexencode(os.urandom(32)) temp_token = UserTokens.query.filter_by(value=value).first() - token = UserTokens( - user_id=user.id, expiration=expiration, value=hexencode(os.urandom(32)) - ) + token = UserTokens(user_id=user.id, expiration=expiration, value=value) db.session.add(token) db.session.commit() return token diff --git a/CTFd/utils/user/__init__.py b/CTFd/utils/user/__init__.py index 59c1a40..9a026a5 100644 --- a/CTFd/utils/user/__init__.py +++ b/CTFd/utils/user/__init__.py @@ -5,7 +5,9 @@ from flask import current_app as app from flask import request, session from CTFd.cache import cache -from CTFd.models import Fails, Users, db, Tracking +from CTFd.constants.users import UserAttrs +from CTFd.constants.teams import TeamAttrs +from CTFd.models import Fails, Users, db, Teams from CTFd.utils import get_config @@ -17,6 +19,24 @@ def get_current_user(): return None +def get_current_user_attrs(): + if authed(): + return get_user_attrs(user_id=session["id"]) + else: + return None + + +@cache.memoize(timeout=30) +def get_user_attrs(user_id): + user = Users.query.filter_by(id=user_id).first() + if user: + d = {} + for field in UserAttrs._fields: + d[field] = getattr(user, field) + return UserAttrs(**d) + return None + + def get_current_team(): if authed(): user = get_current_user() @@ -25,9 +45,28 @@ def get_current_team(): return None +def get_current_team_attrs(): + if authed(): + user = get_user_attrs(user_id=session["id"]) + if user.team_id: + return get_team_attrs(team_id=user.team_id) + return None + + +@cache.memoize(timeout=30) +def get_team_attrs(team_id): + team = Teams.query.filter_by(id=team_id).first() + if team: + d = {} + for field in TeamAttrs._fields: + d[field] = getattr(team, field) + return TeamAttrs(**d) + return None + + def get_current_user_type(fallback=None): if authed(): - user = Users.query.filter_by(id=session["id"]).first() + user = get_current_user_attrs() return user.type else: return fallback @@ -39,7 +78,7 @@ def authed(): def is_admin(): if authed(): - user = get_current_user() + user = get_current_user_attrs() return user.type == "admin" else: return False @@ -47,7 +86,7 @@ def is_admin(): def is_verified(): if get_config("verify_emails"): - user = get_current_user() + user = get_current_user_attrs() if user: return user.verified else: diff --git a/development.txt b/development.txt index 5335076..a88d966 100644 --- a/development.txt +++ b/development.txt @@ -10,7 +10,7 @@ psycopg2-binary==2.7.5 codecov==2.0.15 moto==1.3.7 bandit==1.5.1 -flask_profiler==1.7 +flask_profiler==1.8.1 pytest-xdist==1.28.0 pytest-cov==2.8.1 sphinx_rtd_theme==0.4.3 diff --git a/serve.py b/serve.py index 9d6beeb..962e577 100644 --- a/serve.py +++ b/serve.py @@ -18,6 +18,7 @@ if args.profile: "enabled": app.config["DEBUG"], "storage": {"engine": "sqlite"}, "basicAuth": {"enabled": False}, + "ignore": ["^/themes/.*", "^/events"], } flask_profiler.init_app(app) app.config["DEBUG_TB_PROFILER_ENABLED"] = True diff --git a/tests/cache/__init__.py b/tests/cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cache/test_cache.py b/tests/cache/test_cache.py new file mode 100644 index 0000000..0d01133 --- /dev/null +++ b/tests/cache/test_cache.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from CTFd.models import Users +from CTFd.utils.user import is_admin, get_current_user +from CTFd.utils.security.auth import login_user +from tests.helpers import create_ctfd, destroy_ctfd, register_user + +from CTFd.cache import clear_user_session + + +def test_clear_user_session(): + app = create_ctfd() + with app.app_context(): + register_user(app) + + # Users by default should have a non-admin type + user = Users.query.filter_by(id=2).first() + with app.test_request_context("/"): + login_user(user) + user = get_current_user() + assert user.id == 2 + assert user.type == "user" + assert is_admin() is False + + # Set the user's updated type + user = Users.query.filter_by(id=2).first() + user.type = "admin" + app.db.session.commit() + + # The user shouldn't be considered admin because their type is still cached + user = Users.query.filter_by(id=2).first() + with app.test_request_context("/"): + login_user(user) + user = get_current_user() + assert user.id == 2 + assert user.type == "admin" + assert is_admin() is False + + # Clear the user's cached session (for now just the type) + clear_user_session(user_id=2) + + # The user's type should now be admin + user = Users.query.filter_by(id=2).first() + with app.test_request_context("/"): + login_user(user) + user = get_current_user() + assert user.id == 2 + assert user.type == "admin" + assert is_admin() is True + destroy_ctfd(app) diff --git a/tests/teams/test_teams.py b/tests/teams/test_teams.py index fb5b972..27d3434 100644 --- a/tests/teams/test_teams.py +++ b/tests/teams/test_teams.py @@ -59,28 +59,31 @@ def test_hidden_teams_visibility(): register_user(app) with login_as_user(app) as client: user = Users.query.filter_by(id=2).first() + user_id = user.id team = gen_team(app.db, name="visible_team", hidden=True) + team_id = team.id + team_name = team.name team.members.append(user) user.team_id = team.id app.db.session.commit() r = client.get("/teams") response = r.get_data(as_text=True) - assert team.name not in response + assert team_name not in response r = client.get("/api/v1/teams") response = r.get_json() - assert team.name not in response + assert team_name not in response - gen_award(app.db, user.id, team_id=team.id) + gen_award(app.db, user_id, team_id=team_id) r = client.get("/scoreboard") response = r.get_data(as_text=True) - assert team.name not in response + assert team_name not in response r = client.get("/api/v1/scoreboard") response = r.get_json() - assert team.name not in response + assert team_name not in response # Team should re-appear after disabling hiding # Use an API call to cause a cache clear @@ -90,15 +93,15 @@ def test_hidden_teams_visibility(): r = client.get("/teams") response = r.get_data(as_text=True) - assert team.name in response + assert team_name in response r = client.get("/api/v1/teams") response = r.get_data(as_text=True) - assert team.name in response + assert team_name in response r = client.get("/api/v1/scoreboard") response = r.get_data(as_text=True) - assert team.name in response + assert team_name in response destroy_ctfd(app) diff --git a/tests/users/test_users.py b/tests/users/test_users.py index 2711607..d9a0288 100644 --- a/tests/users/test_users.py +++ b/tests/users/test_users.py @@ -48,6 +48,7 @@ def test_hidden_user_visibility(): with login_as_user(app, name="hidden_user") as client: user = Users.query.filter_by(id=2).first() + user_id = user.id user_name = user.name user.hidden = True app.db.session.commit() @@ -60,7 +61,7 @@ def test_hidden_user_visibility(): response = r.get_json() assert user_name not in response - gen_award(app.db, user.id) + gen_award(app.db, user_id) r = client.get("/scoreboard") response = r.get_data(as_text=True)