Merge pull request #1363 from CTFd/is_admin_func_cache_hit

* Make the `is_admin()` function avoid DB hits and mostly hit cache. This is accomplished by creating a cached object that mimics the actual User model object. This object should be invalidated every time that the User object is modified.
* Add `get_current_user_attrs`, `get_current_team_attrs` and `get_ip` to Jinja
* Update `flask-profiler` to 1.8.1 and fix `flask-profiler` configuration to work better
better-spacing-without-solves
Kevin Chung 2020-04-30 03:07:59 -04:00 committed by GitHub
commit 431c35cb51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 195 additions and 17 deletions

3
.gitignore vendored
View File

@ -73,3 +73,6 @@ CTFd/uploads
# JS
node_modules/
# Flask Profiler files
flask_profiler.sql

View File

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

View File

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

View File

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

View File

@ -44,3 +44,15 @@ def clear_pages():
cache.delete_memoized(get_pages)
cache.delete_memoized(get_page)
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)

20
CTFd/constants/teams.py Normal file
View File

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

22
CTFd/constants/users.py Normal file
View File

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

View File

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

View File

@ -38,7 +38,13 @@ from CTFd.utils.plugins import (
)
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_ip, is_admin
from CTFd.utils.user import (
authed,
get_current_user_attrs,
get_current_team_attrs,
get_ip,
is_admin,
)
def init_template_filters(app):
@ -76,6 +82,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):
@ -191,8 +200,8 @@ def init_request_processors(app):
return
if authed():
user = get_current_user()
team = get_current_team()
user = get_current_user_attrs()
team = get_current_team_attrs()
if user and user.banned:
return (

View File

@ -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=value
)
token = UserTokens(user_id=user.id, expiration=expiration, value=value)
db.session.add(token)
db.session.commit()
return token

View File

@ -4,7 +4,10 @@ import re
from flask import current_app as app
from flask import request, session
from CTFd.models import Fails, Users, db
from CTFd.cache import cache
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
@ -16,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()
@ -24,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
@ -38,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
@ -46,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:

View File

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

View File

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

0
tests/cache/__init__.py vendored Normal file
View File

51
tests/cache/test_cache.py vendored Normal file
View File

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