Skip to content

Commit

Permalink
2.5.0 dev (CTFd#1453)
Browse files Browse the repository at this point in the history
2.5.0 / 2020-06-02
==================

**General**
* Use a session invalidation strategy inspired by Django. Newly generated user sessions will now include a HMAC of the user's password. When the user's password is changed by someone other than the user the previous HMACs will no longer be valid and the user will be logged out when they next attempt to perform an action.
* A user and team's place, and score are now cached and invalidated on score changes.

**API**
* Add `/api/v1/challenges?view=admin` to allow admin users to see all challenges regardless of their visibility state
* Add `/api/v1/users?view=admin` to allow admin users to see all users regardless of their hidden/banned state
* Add `/api/v1/teams?view=admin` to allow admin users to see all teams regardless of their hidden/banned state
* The scoreboard endpoints `/api/v1/scoreboard` & `/api/v1/scoreboard/top/[count]` should now be more performant because score and place for Users/Teams are now cached

**Deployment**
* `docker-compose` now provides a basic nginx configuration and deploys nginx on port 80

**Miscellaneous**
* The `get_config` and `get_page` config utilities now use SQLAlchemy Core instead of SQLAlchemy ORM for slight speedups
* Update Flask-Migrate to 2.5.3 and regenerate the migration environment. Fixes using `%` signs in database passwords.
  • Loading branch information
ColdHeat authored Jun 2, 2020
1 parent 1a85658 commit 7cf6d2b
Show file tree
Hide file tree
Showing 25 changed files with 297 additions and 59 deletions.
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
2.5.0 / 2020-06-02
==================

**General**
* Use a session invalidation strategy inspired by Django. Newly generated user sessions will now include a HMAC of the user's password. When the user's password is changed by someone other than the user the previous HMACs will no longer be valid and the user will be logged out when they next attempt to perform an action.
* A user and team's place, and score are now cached and invalidated on score changes.

**API**
* Add `/api/v1/challenges?view=admin` to allow admin users to see all challenges regardless of their visibility state
* Add `/api/v1/users?view=admin` to allow admin users to see all users regardless of their hidden/banned state
* Add `/api/v1/teams?view=admin` to allow admin users to see all teams regardless of their hidden/banned state
* The scoreboard endpoints `/api/v1/scoreboard` & `/api/v1/scoreboard/top/[count]` should now be more performant because score and place for Users/Teams are now cached

**Deployment**
* `docker-compose` now provides a basic nginx configuration and deploys nginx on port 80

**Miscellaneous**
* The `get_config` and `get_page` config utilities now use SQLAlchemy Core instead of SQLAlchemy ORM for slight speedups
* Update Flask-Migrate to 2.5.3 and regenerate the migration environment. Fixes using `%` signs in database passwords.


2.4.3 / 2020-05-24
==================

Expand Down
2 changes: 1 addition & 1 deletion CTFd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
reload(sys) # noqa: F821
sys.setdefaultencoding("utf-8")

__version__ = "2.4.3"
__version__ = "2.5.0"


class CTFdRequest(Request):
Expand Down
47 changes: 26 additions & 21 deletions CTFd/api/v1/challenges.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,31 +47,36 @@ def get(self):
# This can return None (unauth) if visibility is set to public
user = get_current_user()

challenges = (
Challenges.query.filter(
and_(Challenges.state != "hidden", Challenges.state != "locked")
)
.order_by(Challenges.value)
.all()
)

if user:
solve_ids = (
Solves.query.with_entities(Solves.challenge_id)
.filter_by(account_id=user.account_id)
.order_by(Solves.challenge_id.asc())
# Admins can request to see everything
if is_admin() and request.args.get("view") == "admin":
challenges = Challenges.query.order_by(Challenges.value).all()
solve_ids = set([challenge.id for challenge in challenges])
else:
challenges = (
Challenges.query.filter(
and_(Challenges.state != "hidden", Challenges.state != "locked")
)
.order_by(Challenges.value)
.all()
)
solve_ids = set([value for value, in solve_ids])

# TODO: Convert this into a re-useable decorator
if is_admin():
pass
if user:
solve_ids = (
Solves.query.with_entities(Solves.challenge_id)
.filter_by(account_id=user.account_id)
.order_by(Solves.challenge_id.asc())
.all()
)
solve_ids = set([value for value, in solve_ids])

# TODO: Convert this into a re-useable decorator
if is_admin():
pass
else:
if config.is_teams_mode() and get_current_team() is None:
abort(403)
else:
if config.is_teams_mode() and get_current_team() is None:
abort(403)
else:
solve_ids = set()
solve_ids = set()

response = []
tag_schema = TagSchema(view="user", many=True)
Expand Down
6 changes: 5 additions & 1 deletion CTFd/api/v1/teams.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
class TeamList(Resource):
@check_account_visibility
def get(self):
teams = Teams.query.filter_by(hidden=False, banned=False)
if is_admin() and request.args.get("view") == "admin":
teams = Teams.query.filter_by()
else:
teams = Teams.query.filter_by(hidden=False, banned=False)

user_type = get_current_user_type(fallback="user")
view = copy.deepcopy(TeamSchema.views.get(user_type))
view.remove("members")
Expand Down
11 changes: 9 additions & 2 deletions CTFd/api/v1/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
check_score_visibility,
)
from CTFd.utils.email import sendmail, user_created_notification
from CTFd.utils.security.auth import update_user
from CTFd.utils.user import get_current_user, get_current_user_type, is_admin

users_namespace = Namespace("users", description="Endpoint to retrieve Users")
Expand All @@ -31,7 +32,11 @@
class UserList(Resource):
@check_account_visibility
def get(self):
users = Users.query.filter_by(banned=False, hidden=False)
if is_admin() and request.args.get("view") == "admin":
users = Users.query.filter_by()
else:
users = Users.query.filter_by(banned=False, hidden=False)

response = UserSchema(view="user", many=True).dump(users)

if response.errors:
Expand Down Expand Up @@ -151,7 +156,9 @@ def patch(self):

db.session.commit()

clear_user_session(user_id=user.id)
# Update user's session for the new session hash
update_user(user)

response = schema.dump(response.data)
db.session.close()

Expand Down
5 changes: 5 additions & 0 deletions CTFd/cache/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,18 @@ def clear_config():


def clear_standings():
from CTFd.models import Users, Teams
from CTFd.utils.scores import get_standings, get_team_standings, get_user_standings
from CTFd.api.v1.scoreboard import ScoreboardDetail, ScoreboardList
from CTFd.api import api

cache.delete_memoized(get_standings)
cache.delete_memoized(get_team_standings)
cache.delete_memoized(get_user_standings)
cache.delete_memoized(Users.get_score)
cache.delete_memoized(Users.get_place)
cache.delete_memoized(Teams.get_score)
cache.delete_memoized(Teams.get_place)
cache.delete(make_cache_key(path="scoreboard.listing"))
cache.delete(make_cache_key(path=api.name + "." + ScoreboardList.endpoint))
cache.delete(make_cache_key(path=api.name + "." + ScoreboardDetail.endpoint))
Expand Down
5 changes: 5 additions & 0 deletions CTFd/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import column_property, validates

from CTFd.cache import cache
from CTFd.utils.crypto import hash_password
from CTFd.utils.humanize.numbers import ordinalize

Expand Down Expand Up @@ -322,6 +323,7 @@ def get_awards(self, admin=False):
awards = awards.filter(Awards.date < dt)
return awards.all()

@cache.memoize()
def get_score(self, admin=False):
score = db.func.sum(Challenges.value).label("score")
user = (
Expand Down Expand Up @@ -354,6 +356,7 @@ def get_score(self, admin=False):
else:
return 0

@cache.memoize()
def get_place(self, admin=False, numeric=False):
"""
This method is generally a clone of CTFd.scoreboard.get_standings.
Expand Down Expand Up @@ -487,12 +490,14 @@ def get_awards(self, admin=False):

return awards.all()

@cache.memoize()
def get_score(self, admin=False):
score = 0
for member in self.members:
score += member.get_score(admin=admin)
return score

@cache.memoize()
def get_place(self, admin=False, numeric=False):
"""
This method is generally a clone of CTFd.scoreboard.get_standings.
Expand Down
4 changes: 3 additions & 1 deletion CTFd/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ def get_app_config(key, default=None):

@cache.memoize()
def _get_config(key):
config = Configs.query.filter_by(key=key).first()
config = db.session.execute(
Configs.__table__.select().where(Configs.key == key)
).fetchone()
if config and config.value:
value = config.value
if value and value.isdigit():
Expand Down
8 changes: 6 additions & 2 deletions CTFd/utils/config/pages.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from CTFd.cache import cache
from CTFd.models import Pages
from CTFd.models import db, Pages


@cache.memoize()
Expand All @@ -12,4 +12,8 @@ def get_pages():

@cache.memoize()
def get_page(route):
return Pages.query.filter(Pages.route == route, Pages.draft.isnot(True)).first()
return db.session.execute(
Pages.__table__.select()
.where(Pages.route == route)
.where(Pages.draft.isnot(True))
).fetchone()
12 changes: 12 additions & 0 deletions CTFd/utils/security/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@
from CTFd.models import UserTokens, db
from CTFd.utils.encoding import hexencode
from CTFd.utils.security.csrf import generate_nonce
from CTFd.utils.security.signing import hmac


def login_user(user):
session["id"] = user.id
session["name"] = user.name
session["email"] = user.email
session["nonce"] = generate_nonce()
session["hash"] = hmac(user.password)

# Clear out any currently cached user attributes
clear_user_session(user_id=user.id)


def update_user(user):
session["id"] = user.id
session["name"] = user.name
session["email"] = user.email
session["hash"] = hmac(user.password)

# Clear out any currently cached user attributes
clear_user_session(user_id=user.id)
Expand Down
19 changes: 19 additions & 0 deletions CTFd/utils/security/signing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import hashlib
import hmac as _hmac
import six

from flask import current_app
from itsdangerous import Signer
from itsdangerous.exc import ( # noqa: F401
Expand All @@ -7,6 +11,8 @@
)
from itsdangerous.url_safe import URLSafeTimedSerializer

from CTFd.utils import string_types


def serialize(data, secret=None):
if secret is None:
Expand Down Expand Up @@ -34,3 +40,16 @@ def unsign(data, secret=None):
secret = current_app.config["SECRET_KEY"]
s = Signer(secret)
return s.unsign(data)


def hmac(data, secret=None, digest=hashlib.sha1):
if secret is None:
secret = current_app.config["SECRET_KEY"]

if six.PY3:
if isinstance(data, string_types):
data = data.encode("utf-8")
if isinstance(secret, string_types):
secret = secret.encode("utf-8")

return _hmac.new(key=secret, msg=data, digestmod=digest).hexdigest()
12 changes: 11 additions & 1 deletion CTFd/utils/user/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,28 @@
import re

from flask import current_app as app
from flask import request, session
from flask import abort, redirect, request, session, url_for

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, Tracking
from CTFd.utils import get_config
from CTFd.utils.security.signing import hmac
from CTFd.utils.security.auth import logout_user


def get_current_user():
if authed():
user = Users.query.filter_by(id=session["id"]).first()

# Check if the session is still valid
session_hash = session.get("hash")
if session_hash:
if session_hash != hmac(user.password):
logout_user()
abort(redirect(url_for("auth.login", next=request.full_path)))

return user
else:
return None
Expand Down
49 changes: 49 additions & 0 deletions conf/nginx/http.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
worker_processes 4;

events {

worker_connections 1024;
}

http {

# Configuration containing list of application servers
upstream app_servers {

server ctfd:8000;
}

server {

listen 80;

client_max_body_size 4G;

# Handle Server Sent Events for Notifications
location /events {

proxy_pass http://app_servers;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}

# Proxy connections to the application servers
location / {

proxy_pass http://app_servers;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}
}
}
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ services:
- LOG_FOLDER=/var/log/CTFd
- ACCESS_LOG=-
- ERROR_LOG=-
- REVERSE_PROXY=true
volumes:
- .data/CTFd/logs:/var/log/CTFd
- .data/CTFd/uploads:/var/uploads
Expand All @@ -25,6 +26,15 @@ services:
default:
internal:

nginx:
image: nginx:1.17
volumes:
- ./conf/nginx/http.conf:/etc/nginx/nginx.conf
ports:
- 80:80
depends_on:
- ctfd

db:
image: mariadb:10.4.12
restart: always
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
# The short X.Y version
version = u""
# The full version, including alpha/beta/rc tags
release = u"2.4.3"
release = u"2.5.0"


# -- General configuration ---------------------------------------------------
Expand Down
Loading

0 comments on commit 7cf6d2b

Please sign in to comment.