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.
This commit is contained in:
Kevin Chung
2020-06-02 11:22:01 -04:00
committed by GitHub
parent 1a85658678
commit 7cf6d2b43a
25 changed files with 297 additions and 59 deletions

View File

@@ -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 2.4.3 / 2020-05-24
================== ==================

View File

@@ -31,7 +31,7 @@ if sys.version_info[0] < 3:
reload(sys) # noqa: F821 reload(sys) # noqa: F821
sys.setdefaultencoding("utf-8") sys.setdefaultencoding("utf-8")
__version__ = "2.4.3" __version__ = "2.5.0"
class CTFdRequest(Request): class CTFdRequest(Request):

View File

@@ -47,6 +47,11 @@ class ChallengeList(Resource):
# This can return None (unauth) if visibility is set to public # This can return None (unauth) if visibility is set to public
user = get_current_user() user = get_current_user()
# 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 = (
Challenges.query.filter( Challenges.query.filter(
and_(Challenges.state != "hidden", Challenges.state != "locked") and_(Challenges.state != "hidden", Challenges.state != "locked")

View File

@@ -22,7 +22,11 @@ teams_namespace = Namespace("teams", description="Endpoint to retrieve Teams")
class TeamList(Resource): class TeamList(Resource):
@check_account_visibility @check_account_visibility
def get(self): def get(self):
if is_admin() and request.args.get("view") == "admin":
teams = Teams.query.filter_by()
else:
teams = Teams.query.filter_by(hidden=False, banned=False) teams = Teams.query.filter_by(hidden=False, banned=False)
user_type = get_current_user_type(fallback="user") user_type = get_current_user_type(fallback="user")
view = copy.deepcopy(TeamSchema.views.get(user_type)) view = copy.deepcopy(TeamSchema.views.get(user_type))
view.remove("members") view.remove("members")

View File

@@ -22,6 +22,7 @@ from CTFd.utils.decorators.visibility import (
check_score_visibility, check_score_visibility,
) )
from CTFd.utils.email import sendmail, user_created_notification 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 from CTFd.utils.user import get_current_user, get_current_user_type, is_admin
users_namespace = Namespace("users", description="Endpoint to retrieve Users") users_namespace = Namespace("users", description="Endpoint to retrieve Users")
@@ -31,7 +32,11 @@ users_namespace = Namespace("users", description="Endpoint to retrieve Users")
class UserList(Resource): class UserList(Resource):
@check_account_visibility @check_account_visibility
def get(self): def get(self):
if is_admin() and request.args.get("view") == "admin":
users = Users.query.filter_by()
else:
users = Users.query.filter_by(banned=False, hidden=False) users = Users.query.filter_by(banned=False, hidden=False)
response = UserSchema(view="user", many=True).dump(users) response = UserSchema(view="user", many=True).dump(users)
if response.errors: if response.errors:
@@ -151,7 +156,9 @@ class UserPrivate(Resource):
db.session.commit() 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) response = schema.dump(response.data)
db.session.close() db.session.close()

View File

@@ -26,6 +26,7 @@ def clear_config():
def clear_standings(): def clear_standings():
from CTFd.models import Users, Teams
from CTFd.utils.scores import get_standings, get_team_standings, get_user_standings from CTFd.utils.scores import get_standings, get_team_standings, get_user_standings
from CTFd.api.v1.scoreboard import ScoreboardDetail, ScoreboardList from CTFd.api.v1.scoreboard import ScoreboardDetail, ScoreboardList
from CTFd.api import api from CTFd.api import api
@@ -33,6 +34,10 @@ def clear_standings():
cache.delete_memoized(get_standings) cache.delete_memoized(get_standings)
cache.delete_memoized(get_team_standings) cache.delete_memoized(get_team_standings)
cache.delete_memoized(get_user_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="scoreboard.listing"))
cache.delete(make_cache_key(path=api.name + "." + ScoreboardList.endpoint)) cache.delete(make_cache_key(path=api.name + "." + ScoreboardList.endpoint))
cache.delete(make_cache_key(path=api.name + "." + ScoreboardDetail.endpoint)) cache.delete(make_cache_key(path=api.name + "." + ScoreboardDetail.endpoint))

View File

@@ -5,6 +5,7 @@ from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import column_property, validates from sqlalchemy.orm import column_property, validates
from CTFd.cache import cache
from CTFd.utils.crypto import hash_password from CTFd.utils.crypto import hash_password
from CTFd.utils.humanize.numbers import ordinalize from CTFd.utils.humanize.numbers import ordinalize
@@ -322,6 +323,7 @@ class Users(db.Model):
awards = awards.filter(Awards.date < dt) awards = awards.filter(Awards.date < dt)
return awards.all() return awards.all()
@cache.memoize()
def get_score(self, admin=False): def get_score(self, admin=False):
score = db.func.sum(Challenges.value).label("score") score = db.func.sum(Challenges.value).label("score")
user = ( user = (
@@ -354,6 +356,7 @@ class Users(db.Model):
else: else:
return 0 return 0
@cache.memoize()
def get_place(self, admin=False, numeric=False): def get_place(self, admin=False, numeric=False):
""" """
This method is generally a clone of CTFd.scoreboard.get_standings. This method is generally a clone of CTFd.scoreboard.get_standings.
@@ -487,12 +490,14 @@ class Teams(db.Model):
return awards.all() return awards.all()
@cache.memoize()
def get_score(self, admin=False): def get_score(self, admin=False):
score = 0 score = 0
for member in self.members: for member in self.members:
score += member.get_score(admin=admin) score += member.get_score(admin=admin)
return score return score
@cache.memoize()
def get_place(self, admin=False, numeric=False): def get_place(self, admin=False, numeric=False):
""" """
This method is generally a clone of CTFd.scoreboard.get_standings. This method is generally a clone of CTFd.scoreboard.get_standings.

View File

@@ -23,7 +23,9 @@ def get_app_config(key, default=None):
@cache.memoize() @cache.memoize()
def _get_config(key): 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: if config and config.value:
value = config.value value = config.value
if value and value.isdigit(): if value and value.isdigit():

View File

@@ -1,5 +1,5 @@
from CTFd.cache import cache from CTFd.cache import cache
from CTFd.models import Pages from CTFd.models import db, Pages
@cache.memoize() @cache.memoize()
@@ -12,4 +12,8 @@ def get_pages():
@cache.memoize() @cache.memoize()
def get_page(route): 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()

View File

@@ -8,6 +8,7 @@ from CTFd.exceptions import UserNotFoundException, UserTokenExpiredException
from CTFd.models import UserTokens, db from CTFd.models import UserTokens, db
from CTFd.utils.encoding import hexencode from CTFd.utils.encoding import hexencode
from CTFd.utils.security.csrf import generate_nonce from CTFd.utils.security.csrf import generate_nonce
from CTFd.utils.security.signing import hmac
def login_user(user): def login_user(user):
@@ -15,6 +16,17 @@ def login_user(user):
session["name"] = user.name session["name"] = user.name
session["email"] = user.email session["email"] = user.email
session["nonce"] = generate_nonce() 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 out any currently cached user attributes
clear_user_session(user_id=user.id) clear_user_session(user_id=user.id)

View File

@@ -1,3 +1,7 @@
import hashlib
import hmac as _hmac
import six
from flask import current_app from flask import current_app
from itsdangerous import Signer from itsdangerous import Signer
from itsdangerous.exc import ( # noqa: F401 from itsdangerous.exc import ( # noqa: F401
@@ -7,6 +11,8 @@ from itsdangerous.exc import ( # noqa: F401
) )
from itsdangerous.url_safe import URLSafeTimedSerializer from itsdangerous.url_safe import URLSafeTimedSerializer
from CTFd.utils import string_types
def serialize(data, secret=None): def serialize(data, secret=None):
if secret is None: if secret is None:
@@ -34,3 +40,16 @@ def unsign(data, secret=None):
secret = current_app.config["SECRET_KEY"] secret = current_app.config["SECRET_KEY"]
s = Signer(secret) s = Signer(secret)
return s.unsign(data) 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()

View File

@@ -2,18 +2,28 @@ import datetime
import re import re
from flask import current_app as app 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.cache import cache
from CTFd.constants.users import UserAttrs from CTFd.constants.users import UserAttrs
from CTFd.constants.teams import TeamAttrs from CTFd.constants.teams import TeamAttrs
from CTFd.models import Fails, Users, db, Teams, Tracking from CTFd.models import Fails, Users, db, Teams, Tracking
from CTFd.utils import get_config 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(): def get_current_user():
if authed(): if authed():
user = Users.query.filter_by(id=session["id"]).first() 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 return user
else: else:
return None return None

49
conf/nginx/http.conf Normal file
View File

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

View File

@@ -15,6 +15,7 @@ services:
- LOG_FOLDER=/var/log/CTFd - LOG_FOLDER=/var/log/CTFd
- ACCESS_LOG=- - ACCESS_LOG=-
- ERROR_LOG=- - ERROR_LOG=-
- REVERSE_PROXY=true
volumes: volumes:
- .data/CTFd/logs:/var/log/CTFd - .data/CTFd/logs:/var/log/CTFd
- .data/CTFd/uploads:/var/uploads - .data/CTFd/uploads:/var/uploads
@@ -25,6 +26,15 @@ services:
default: default:
internal: internal:
nginx:
image: nginx:1.17
volumes:
- ./conf/nginx/http.conf:/etc/nginx/nginx.conf
ports:
- 80:80
depends_on:
- ctfd
db: db:
image: mariadb:10.4.12 image: mariadb:10.4.12
restart: always restart: always

View File

@@ -26,7 +26,7 @@ author = u"Kevin Chung"
# The short X.Y version # The short X.Y version
version = u"" version = u""
# The full version, including alpha/beta/rc tags # The full version, including alpha/beta/rc tags
release = u"2.4.3" release = u"2.5.0"
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------

View File

@@ -27,6 +27,7 @@ CTFd is written in Python and makes use of the Flask web framework.
deployment deployment
configuration configuration
management
scoring scoring
themes themes
plugins plugins

7
docs/management.rst Normal file
View File

@@ -0,0 +1,7 @@
Management
==========
Challenges
----------
The `ctfcli <https://github.com/CTFd/ctfcli>`_ tool can be used to manage challenges and sync them up to a specified CTFd instance via the CTFd API. It can be used as part of a build system or amongst multilpe users collaborating via version control.

View File

@@ -1 +0,0 @@
Generic single-database configuration.

35
migrations/env.py Executable file → Normal file
View File

@@ -1,30 +1,31 @@
from __future__ import with_statement from __future__ import with_statement
import logging import logging
from logging.config import fileConfig
# from logging.config import fileConfig from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context from alembic import context
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
from sqlalchemy import engine_from_config, pool
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
config = context.config config = context.config
# Interpret the config file for Python logging. # Interpret the config file for Python logging.
# This line sets up loggers basically. # This line sets up loggers basically.
# http://stackoverflow.com/questions/42427487/using-alembic-config-main-redirects-log-output fileConfig(config.config_file_name, disable_existing_loggers=False)
# fileConfig(config.config_file_name)
logger = logging.getLogger("alembic.env") logger = logging.getLogger("alembic.env")
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option( config.set_main_option(
"sqlalchemy.url", current_app.config.get("SQLALCHEMY_DATABASE_URI") "sqlalchemy.url",
str(current_app.extensions["migrate"].db.engine.url).replace("%", "%%"),
) )
target_metadata = current_app.extensions["migrate"].db.metadata target_metadata = current_app.extensions["migrate"].db.metadata
@@ -47,7 +48,7 @@ def run_migrations_offline():
""" """
url = config.get_main_option("sqlalchemy.url") url = config.get_main_option("sqlalchemy.url")
context.configure(url=url) context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
@@ -63,7 +64,7 @@ def run_migrations_online():
# this callback is used to prevent an auto-migration from being generated # this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema # when there are no changes to the schema
# reference: http://alembic.readthedocs.org/en/latest/cookbook.html # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives): def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, "autogenerate", False): if getattr(config.cmd_opts, "autogenerate", False):
script = directives[0] script = directives[0]
@@ -71,26 +72,22 @@ def run_migrations_online():
directives[:] = [] directives[:] = []
logger.info("No changes in schema detected.") logger.info("No changes in schema detected.")
engine = engine_from_config( connectable = engine_from_config(
config.get_section(config.config_ini_section), config.get_section(config.config_ini_section),
prefix="sqlalchemy.", prefix="sqlalchemy.",
poolclass=pool.NullPool, poolclass=pool.NullPool,
) )
connection = engine.connect() with connectable.connect() as connection:
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
compare_type=True,
process_revision_directives=process_revision_directives, process_revision_directives=process_revision_directives,
**current_app.extensions["migrate"].configure_args **current_app.extensions["migrate"].configure_args
) )
try:
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
finally:
connection.close()
if context.is_offline_mode(): if context.is_offline_mode():

View File

@@ -1,6 +1,6 @@
{ {
"name": "ctfd", "name": "ctfd",
"version": "2.4.3", "version": "2.5.0",
"description": "CTFd is a Capture The Flag framework focusing on ease of use and customizability. It comes with everything you need to run a CTF and it's easy to customize with plugins and themes.", "description": "CTFd is a Capture The Flag framework focusing on ease of use and customizability. It comes with everything you need to run a CTF and it's easy to customize with plugins and themes.",
"main": "index.js", "main": "index.js",
"directories": { "directories": {

View File

@@ -2,7 +2,7 @@ Flask==1.1.1
Werkzeug==0.16.0 Werkzeug==0.16.0
Flask-SQLAlchemy==2.4.1 Flask-SQLAlchemy==2.4.1
Flask-Caching==1.4.0 Flask-Caching==1.4.0
Flask-Migrate==2.5.2 Flask-Migrate==2.5.3
Flask-Script==2.0.6 Flask-Script==2.0.6
SQLAlchemy==1.3.11 SQLAlchemy==1.3.11
SQLAlchemy-Utils==0.36.0 SQLAlchemy-Utils==0.36.0

View File

@@ -135,6 +135,25 @@ def test_api_challenges_get_admin():
destroy_ctfd(app) destroy_ctfd(app)
def test_api_challenges_get_hidden_admin():
"""Can an admin see hidden challenges in API list response"""
app = create_ctfd()
with app.app_context():
gen_challenge(app.db, state="hidden")
gen_challenge(app.db)
with login_as_user(app, "admin") as admin:
challenges_list = admin.get("/api/v1/challenges", json="").get_json()[
"data"
]
assert len(challenges_list) == 1
challenges_list = admin.get(
"/api/v1/challenges?view=admin", json=""
).get_json()["data"]
assert len(challenges_list) == 2
destroy_ctfd(app)
def test_api_challenges_post_admin(): def test_api_challenges_post_admin():
"""Can a user post /api/v1/challenges if admin""" """Can a user post /api/v1/challenges if admin"""
app = create_ctfd() app = create_ctfd()

View File

@@ -696,6 +696,9 @@ def test_api_accessing_hidden_banned_users():
app.db.session.commit() app.db.session.commit()
with login_as_user(app, name="visible_user") as client: with login_as_user(app, name="visible_user") as client:
list_teams = client.get("/api/v1/teams").get_json()["data"]
assert len(list_teams) == 0
assert client.get("/api/v1/teams/1").status_code == 404 assert client.get("/api/v1/teams/1").status_code == 404
assert client.get("/api/v1/teams/1/solves").status_code == 404 assert client.get("/api/v1/teams/1/solves").status_code == 404
assert client.get("/api/v1/teams/1/fails").status_code == 404 assert client.get("/api/v1/teams/1/fails").status_code == 404
@@ -707,6 +710,10 @@ def test_api_accessing_hidden_banned_users():
assert client.get("/api/v1/teams/2/awards").status_code == 404 assert client.get("/api/v1/teams/2/awards").status_code == 404
with login_as_user(app, name="admin") as client: with login_as_user(app, name="admin") as client:
# Admins see hidden teams in lists
list_users = client.get("/api/v1/teams?view=admin").get_json()["data"]
assert len(list_users) == 2
assert client.get("/api/v1/teams/1").status_code == 200 assert client.get("/api/v1/teams/1").status_code == 200
assert client.get("/api/v1/teams/1/solves").status_code == 200 assert client.get("/api/v1/teams/1/solves").status_code == 200
assert client.get("/api/v1/teams/1/fails").status_code == 200 assert client.get("/api/v1/teams/1/fails").status_code == 200

View File

@@ -764,12 +764,19 @@ def test_api_accessing_hidden_users():
app.db.session.commit() app.db.session.commit()
with login_as_user(app, name="visible_user") as client: with login_as_user(app, name="visible_user") as client:
list_users = client.get("/api/v1/users").get_json()["data"]
assert len(list_users) == 1
assert client.get("/api/v1/users/3").status_code == 404 assert client.get("/api/v1/users/3").status_code == 404
assert client.get("/api/v1/users/3/solves").status_code == 404 assert client.get("/api/v1/users/3/solves").status_code == 404
assert client.get("/api/v1/users/3/fails").status_code == 404 assert client.get("/api/v1/users/3/fails").status_code == 404
assert client.get("/api/v1/users/3/awards").status_code == 404 assert client.get("/api/v1/users/3/awards").status_code == 404
with login_as_user(app, name="admin") as client: with login_as_user(app, name="admin") as client:
# Admins see the user in lists
list_users = client.get("/api/v1/users?view=admin").get_json()["data"]
assert len(list_users) == 3
assert client.get("/api/v1/users/3").status_code == 200 assert client.get("/api/v1/users/3").status_code == 200
assert client.get("/api/v1/users/3/solves").status_code == 200 assert client.get("/api/v1/users/3/solves").status_code == 200
assert client.get("/api/v1/users/3/fails").status_code == 200 assert client.get("/api/v1/users/3/fails").status_code == 200
@@ -788,12 +795,19 @@ def test_api_accessing_banned_users():
app.db.session.commit() app.db.session.commit()
with login_as_user(app, name="visible_user") as client: with login_as_user(app, name="visible_user") as client:
list_users = client.get("/api/v1/users").get_json()["data"]
assert len(list_users) == 1
assert client.get("/api/v1/users/3").status_code == 404 assert client.get("/api/v1/users/3").status_code == 404
assert client.get("/api/v1/users/3/solves").status_code == 404 assert client.get("/api/v1/users/3/solves").status_code == 404
assert client.get("/api/v1/users/3/fails").status_code == 404 assert client.get("/api/v1/users/3/fails").status_code == 404
assert client.get("/api/v1/users/3/awards").status_code == 404 assert client.get("/api/v1/users/3/awards").status_code == 404
with login_as_user(app, name="admin") as client: with login_as_user(app, name="admin") as client:
# Admins see the user in lists
list_users = client.get("/api/v1/users?view=admin").get_json()["data"]
assert len(list_users) == 3
assert client.get("/api/v1/users/3").status_code == 200 assert client.get("/api/v1/users/3").status_code == 200
assert client.get("/api/v1/users/3/solves").status_code == 200 assert client.get("/api/v1/users/3/solves").status_code == 200
assert client.get("/api/v1/users/3/fails").status_code == 200 assert client.get("/api/v1/users/3/fails").status_code == 200

View File

@@ -1,4 +1,4 @@
from tests.helpers import create_ctfd, destroy_ctfd from tests.helpers import create_ctfd, destroy_ctfd, login_as_user, register_user
def test_sessions_set_httponly(): def test_sessions_set_httponly():
@@ -19,3 +19,44 @@ def test_sessions_set_samesite():
cookie = dict(r.headers)["Set-Cookie"] cookie = dict(r.headers)["Set-Cookie"]
assert "SameSite=" in cookie assert "SameSite=" in cookie
destroy_ctfd(app) destroy_ctfd(app)
def test_session_invalidation_on_admin_password_change():
app = create_ctfd()
with app.app_context():
register_user(app)
with login_as_user(app, name="admin") as admin, login_as_user(app) as user:
r = user.get("/settings")
assert r.status_code == 200
r = admin.patch("/api/v1/users/2", json={"password": "password2"})
assert r.status_code == 200
r = user.get("/settings")
# User's password was changed
# They should be logged out
assert r.location.startswith("http://localhost/login")
assert r.status_code == 302
destroy_ctfd(app)
def test_session_invalidation_on_user_password_change():
app = create_ctfd()
with app.app_context():
register_user(app)
with login_as_user(app) as user:
r = user.get("/settings")
assert r.status_code == 200
data = {"confirm": "password", "password": "new_password"}
r = user.patch("/api/v1/users/me", json=data)
assert r.status_code == 200
r = user.get("/settings")
# User initiated their own password change
# They should not be logged out
assert r.status_code == 200
destroy_ctfd(app)