Adopting a challenge type layout from deckar01 (#399)

* Adopting a challenge type layout from deckar01
* Move standard challenge modals into the plugin
* Migration to change challenge type id to a string
* Travis testing now builds with MySQL, SQLite, and Postgres
* Rework get_standings to use the row ID instead of the saved time because of differences in database time precision
selenium-screenshot-testing
Kevin Chung 2017-10-05 21:39:28 -04:00 committed by GitHub
parent faa84ff1e5
commit 608d4f43d9
26 changed files with 275 additions and 102 deletions

View File

@ -1,9 +1,18 @@
language: python
services:
- mysql
- postgresql
env:
- TESTING_DATABASE_URL='mysql+pymysql://root@localhost/ctfd'
- TESTING_DATABASE_URL='sqlite://'
- TESTING_DATABASE_URL='postgres://postgres@localhost/ctfd'
python:
- 2.7
- 3.6
install:
- pip install -r development.txt
before_script:
- psql -c 'create database ctfd;' -U postgres
script:
- pep8 --ignore E501,E712 CTFd/ tests/
- nosetests

View File

@ -11,7 +11,6 @@ from sqlalchemy.exc import IntegrityError
from CTFd.utils import admins_only, is_admin, cache, export_ctf, import_ctf
from CTFd.models import db, Teams, Solves, Awards, Challenges, WrongKeys, Keys, Tags, Files, Tracking, Pages, Config, DatabaseError
from CTFd.scoreboard import get_standings
from CTFd.plugins.keys import get_key_class, KEY_CLASSES
from CTFd.admin.statistics import admin_statistics

View File

@ -16,7 +16,13 @@ admin_challenges = Blueprint('admin_challenges', __name__)
def admin_chal_types():
data = {}
for class_id in CHALLENGE_CLASSES:
data[class_id] = CHALLENGE_CLASSES.get(class_id).name
challenge_class = CHALLENGE_CLASSES.get(class_id)
data[challenge_class.id] = {
'id': challenge_class.id,
'name': challenge_class.name,
'templates': challenge_class.templates,
'scripts': challenge_class.scripts,
}
return jsonify(data)
@ -52,7 +58,13 @@ def admin_chals():
'max_attempts': x.max_attempts,
'type': x.type,
'type_name': type_name,
'percentage_solved': percentage
'percentage_solved': percentage,
'type_data': {
'id': type_class.id,
'name': type_class.name,
'templates': type_class.templates,
'scripts': type_class.scripts,
}
})
db.session.close()
@ -225,7 +237,14 @@ def admin_create_chal():
files = request.files.getlist('files[]')
# Create challenge
chal = Challenges(request.form['name'], request.form['desc'], request.form['value'], request.form['category'], int(request.form['chaltype']))
chal = Challenges(
name=request.form['name'],
description=request.form['desc'],
value=request.form['value'],
category=request.form['category'],
type=request.form['chaltype']
)
if 'hidden' in request.form:
chal.hidden = True
else:

View File

@ -130,7 +130,9 @@ def chals():
'category': x.category,
'files': files,
'tags': tags,
'hints': hints
'hints': hints,
'template': chal_type.templates['modal'],
'script': chal_type.scripts['modal'],
})
db.session.close()

View File

@ -127,5 +127,5 @@ class TestingConfig(Config):
PRESERVE_CONTEXT_ON_EXCEPTION = False
TESTING = True
DEBUG = True
SQLALCHEMY_DATABASE_URI = 'sqlite://'
SQLALCHEMY_DATABASE_URI = os.environ.get('TESTING_DATABASE_URL') or 'sqlite://'
SERVER_NAME = 'localhost'

View File

@ -48,16 +48,15 @@ class Challenges(db.Model):
max_attempts = db.Column(db.Integer, default=0)
value = db.Column(db.Integer)
category = db.Column(db.String(80))
type = db.Column(db.Integer)
type = db.Column(db.String(80))
hidden = db.Column(db.Boolean)
def __init__(self, name, description, value, category, type=0):
def __init__(self, name, description, value, category, type='standard'):
self.name = name
self.description = description
self.value = value
self.category = category
self.type = type
# self.flags = json.dumps(flags)
def __repr__(self):
return '<chal %r>' % self.name
@ -190,15 +189,23 @@ class Teams(db.Model):
return 0
def place(self, admin=False):
"""
This method is generally a clone of CTFd.scoreboard.get_standings.
The point being that models.py must be self-reliant and have little
to no imports within the CTFd application as importing from the
application itself will result in a circular import.
"""
scores = db.session.query(
Solves.teamid.label('teamid'),
db.func.sum(Challenges.value).label('score'),
db.func.max(Solves.id).label('id'),
db.func.max(Solves.date).label('date')
).join(Challenges).group_by(Solves.teamid)
awards = db.session.query(
Awards.teamid.label('teamid'),
db.func.sum(Awards.value).label('score'),
db.func.max(Awards.id).label('id'),
db.func.max(Awards.date).label('date')
).group_by(Awards.teamid)
@ -212,16 +219,26 @@ class Teams(db.Model):
results = union_all(scores, awards).alias('results')
sumscore = db.func.sum(results.columns.score).label('sumscore')
quickest = db.func.max(results.columns.date).label('quickest')
sumscores = db.session.query(
results.columns.teamid,
db.func.sum(results.columns.score).label('score'),
db.func.max(results.columns.id).label('id'),
db.func.max(results.columns.date).label('date')
).group_by(results.columns.teamid).subquery()
standings_query = db.session.query(results.columns.teamid)\
.join(Teams)\
.group_by(results.columns.teamid)\
.order_by(sumscore.desc(), quickest)
if not admin:
standings_query = standings_query.filter(Teams.banned == False)
if admin:
standings_query = db.session.query(
Teams.id.label('teamid'),
)\
.join(sumscores, Teams.id == sumscores.columns.teamid) \
.order_by(sumscores.columns.score.desc(), sumscores.columns.id)
else:
standings_query = db.session.query(
Teams.id.label('teamid'),
)\
.join(sumscores, Teams.id == sumscores.columns.teamid) \
.filter(Teams.banned == False) \
.order_by(sumscores.columns.score.desc(), sumscores.columns.id)
standings = standings_query.all()

View File

@ -57,7 +57,7 @@ def init_plugins(app):
:return:
"""
modules = glob.glob(os.path.dirname(__file__) + "/*")
blacklist = {'keys', 'challenges', '__pycache__'}
blacklist = {'keys', '__pycache__'}
for module in modules:
module_name = os.path.basename(module)
if os.path.isdir(module) and module_name not in blacklist:

View File

@ -1,3 +1,4 @@
from CTFd.plugins import register_plugin_assets_directory
from CTFd.plugins.keys import get_key_class
from CTFd.models import db, Solves, WrongKeys, Keys
from CTFd import utils
@ -6,14 +7,35 @@ from CTFd import utils
class BaseChallenge(object):
id = None
name = None
templates = {}
scripts = {}
class CTFdStandardChallenge(BaseChallenge):
id = 0
name = "standard"
id = "standard" # Unique identifier used to register challenges
name = "standard" # Name of a challenge type
templates = { # Handlebars templates used for each aspect of challenge editing & viewing
'create': '/plugins/challenges/assets/standard-challenge-create.hbs',
'update': '/plugins/challenges/assets/standard-challenge-update.hbs',
'modal': '/plugins/challenges/assets/standard-challenge-modal.hbs',
}
scripts = { # Scripts that are loaded when a template is loaded
'create': '/plugins/challenges/assets/standard-challenge-create.js',
'update': '/plugins/challenges/assets/standard-challenge-update.js',
'modal': '/plugins/challenges/assets/standard-challenge-modal.js',
}
@staticmethod
def attempt(chal, request):
"""
This method is used to check whether a given input is right or wrong. It does not make any changes and should
return a boolean for correctness and a string to be shown to the user. It is also in charge of parsing the
user's input from the request itself.
:param chal: The Challenge object from the database
:param request: The request the user submitted
:return: (boolean, string)
"""
provided_key = request.form['key'].strip()
chal_keys = Keys.query.filter_by(chal=chal.id).all()
for chal_key in chal_keys:
@ -23,6 +45,14 @@ class CTFdStandardChallenge(BaseChallenge):
@staticmethod
def solve(team, chal, request):
"""
This method is used to insert Solves into the database in order to mark a challenge as solved.
:param team: The Team object from the database
:param chal: The Challenge object from the database
:param request: The request the user submitted
:return:
"""
provided_key = request.form['key'].strip()
solve = Solves(teamid=team.id, chalid=chal.id, ip=utils.get_ip(req=request), flag=provided_key)
db.session.add(solve)
@ -31,6 +61,14 @@ class CTFdStandardChallenge(BaseChallenge):
@staticmethod
def fail(team, chal, request):
"""
This method is used to insert WrongKeys into the database in order to mark an answer incorrect.
:param team: The Team object from the database
:param chal: The Challenge object from the database
:param request: The request the user submitted
:return:
"""
provided_key = request.form['key'].strip()
wrong = WrongKeys(teamid=team.id, chalid=chal.id, ip=utils.get_ip(request), flag=provided_key)
db.session.add(wrong)
@ -38,13 +76,27 @@ class CTFdStandardChallenge(BaseChallenge):
db.session.close()
CHALLENGE_CLASSES = {
0: CTFdStandardChallenge
}
def get_chal_class(class_id):
"""
Utility function used to get the corresponding class from a class ID.
:param class_id: String representing the class ID
:return: Challenge class
"""
cls = CHALLENGE_CLASSES.get(class_id)
if cls is None:
raise KeyError
return cls
"""
Global dictionary used to hold all the Challenge Type classes used by CTFd. Insert into this dictionary to register
your Challenge Type.
"""
CHALLENGE_CLASSES = {
"standard": CTFdStandardChallenge
}
def load(app):
register_plugin_assets_directory(app, base_path='/plugins/challenges/assets/')

View File

@ -103,7 +103,7 @@
</div>
</div>
<input type="hidden" value="{{ nonce }}" name="nonce" id="nonce">
<input type="hidden" value="0" name="chaltype" id="chaltype">
<input type="hidden" value="standard" name="chaltype" id="chaltype">
<div style="text-align:center">
<button class="btn btn-theme btn-outlined create-challenge-submit" type="submit">Create</button>
</div>

View File

@ -12,28 +12,48 @@ def get_standings(admin=False, count=None):
scores = db.session.query(
Solves.teamid.label('teamid'),
db.func.sum(Challenges.value).label('score'),
db.func.max(Solves.id).label('id'),
db.func.max(Solves.date).label('date')
).join(Challenges).group_by(Solves.teamid)
awards = db.session.query(
Awards.teamid.label('teamid'),
db.func.sum(Awards.value).label('score'),
db.func.max(Awards.id).label('id'),
db.func.max(Awards.date).label('date')
).group_by(Awards.teamid)
"""
Filter out solves and awards that are before a specific time point.
"""
freeze = utils.get_config('freeze')
if not admin and freeze:
scores = scores.filter(Solves.date < utils.unix_time_to_utc(freeze))
awards = awards.filter(Awards.date < utils.unix_time_to_utc(freeze))
"""
Combine awards and solves with a union. They should have the same amount of columns
"""
results = union_all(scores, awards).alias('results')
"""
Sum each of the results by the team id to get their score.
"""
sumscores = db.session.query(
results.columns.teamid,
db.func.sum(results.columns.score).label('score'),
db.func.max(results.columns.id).label('id'),
db.func.max(results.columns.date).label('date')
).group_by(results.columns.teamid).subquery()
"""
Admins can see scores for all users but the public cannot see banned users.
Filters out banned users.
Properly resolves value ties by ID.
Different databases treat time precision differently so resolve by the row ID instead.
"""
if admin:
standings_query = db.session.query(
Teams.id.label('teamid'),
@ -41,7 +61,7 @@ def get_standings(admin=False, count=None):
Teams.banned, sumscores.columns.score
)\
.join(sumscores, Teams.id == sumscores.columns.teamid) \
.order_by(sumscores.columns.score.desc(), sumscores.columns.date)
.order_by(sumscores.columns.score.desc(), sumscores.columns.id)
else:
standings_query = db.session.query(
Teams.id.label('teamid'),
@ -50,13 +70,17 @@ def get_standings(admin=False, count=None):
)\
.join(sumscores, Teams.id == sumscores.columns.teamid) \
.filter(Teams.banned == False) \
.order_by(sumscores.columns.score.desc(), sumscores.columns.date)
.order_by(sumscores.columns.score.desc(), sumscores.columns.id)
"""
Only select a certain amount of users if asked.
"""
if count is None:
standings = standings_query.all()
else:
standings = standings_query.limit(count).all()
db.session.close()
return standings

View File

@ -1,24 +1,25 @@
function load_chal_template(chal_type_name){
$.get(script_root + '/themes/admin/static/js/templates/challenges/'+ chal_type_name +'/' + chal_type_name + '-challenge-create.hbs', function(template_data){
function load_chal_template(challenge){
$.get(script_root + challenge.templates.create, function(template_data){
var template = Handlebars.compile(template_data);
$("#create-chal-entry-div").html(template({'nonce':nonce, 'script_root':script_root}));
$.getScript(script_root + '/themes/admin/static/js/templates/challenges/'+chal_type_name+'/'+chal_type_name+'-challenge-create.js', function(){
$.getScript(script_root + challenge.scripts.create, function(){
console.log('loaded');
});
});
}
nonce = "{{ nonce }}";
$.get(script_root + '/admin/chal_types', function(data){
console.log(data);
$("#create-chals-select").empty();
var chal_type_amt = Object.keys(data).length;
if (chal_type_amt > 1){
var option = "<option> -- </option>";
$("#create-chals-select").append(option);
for (var key in data){
var option = "<option value='{0}'>{1}</option>".format(key, data[key]);
var challenge = data[key];
var option = $("<option/>");
option.attr('value', challenge.type);
option.text(challenge.name);
option.data('meta', challenge);
$("#create-chals-select").append(option);
}
} else if (chal_type_amt == 1) {
@ -28,6 +29,6 @@ $.get(script_root + '/admin/chal_types', function(data){
}
});
$('#create-chals-select').change(function(){
var chal_type_name = $(this).find("option:selected").text();
load_chal_template(chal_type_name);
var challenge = $(this).find("option:selected").data('meta');
load_chal_template(challenge);
});

View File

@ -43,11 +43,11 @@ function load_chal_template(id, success_cb){
obj = $.grep(challenges['game'], function (e) {
return e.id == id;
})[0]
$.get(script_root + '/themes/admin/static/js/templates/challenges/'+ obj['type_name'] +'/' + obj['type_name'] + '-challenge-update.hbs', function(template_data){
$.get(script_root + obj.type_data.templates.update, function(template_data){
var template = Handlebars.compile(template_data);
$("#update-modals-entry-div").html(template({'nonce':$('#nonce').val(), 'script_root':script_root}));
$.ajax({
url: script_root + '/themes/admin/static/js/templates/challenges/'+obj['type_name']+'/'+obj['type_name']+'-challenge-update.js',
url: script_root + obj.type_data.scripts.update,
dataType: "script",
success: success_cb,
cache: true,

View File

@ -34,39 +34,7 @@
<script src="{{ request.script_root }}/themes/admin/static/js/utils.js"></script>
<script src="{{ request.script_root }}/themes/admin/static/js/vendor/codemirror.min.js"></script>
<script>
function load_chal_template(chal_type_name){
$.get(script_root + '/themes/admin/static/js/templates/challenges/'+ chal_type_name +'/' + chal_type_name + '-challenge-create.hbs', function(template_data){
var template = Handlebars.compile(template_data);
$("#create-chal-entry-div").html(template({'nonce':nonce, 'script_root':script_root}));
$.getScript(script_root + '/themes/admin/static/js/templates/challenges/'+chal_type_name+'/'+chal_type_name+'-challenge-create.js', function(){
console.log('loaded');
});
});
}
nonce = "{{ nonce }}";
$.get(script_root + '/admin/chal_types', function(data){
console.log(data);
$("#create-chals-select").empty();
var chal_type_amt = Object.keys(data).length;
if (chal_type_amt > 1){
var option = "<option> -- </option>";
$("#create-chals-select").append(option);
for (var key in data){
var option = "<option value='{0}'>{1}</option>".format(key, data[key]);
$("#create-chals-select").append(option);
}
} else if (chal_type_amt == 1) {
var key = Object.keys(data)[0];
$("#create-chals-select").parent().parent().parent().empty();
load_chal_template(data[key]);
}
});
$('#create-chals-select').change(function(){
var chal_type_name = $(this).find("option:selected").text();
load_chal_template(chal_type_name);
});
</script>
<script src="{{ request.script_root }}/themes/admin/static/js/chal-new.js"></script>
{% endblock %}

View File

@ -24,7 +24,7 @@ function loadchalbyname(chalname) {
}
function updateChalWindow(obj) {
$.get(script_root + '/themes/original/static/js/templates/challenges/'+obj.type+'/'+obj.type+'-challenge-modal.hbs', function(template_data){
$.get(script_root + obj.template, function(template_data){
$('#chal-window').empty();
templates[obj.type] = template_data;
var template_data = templates[obj.type];
@ -46,7 +46,7 @@ function updateChalWindow(obj) {
};
$('#chal-window').append(template(wrapper));
$.getScript(script_root + '/themes/original/static/js/templates/challenges/'+obj.type+'/'+obj.type+'-challenge-script.js',
$.getScript(script_root + obj.script,
function() {
// Handle Solves tab
$('.chal-solves').click(function (e) {

View File

@ -5,3 +5,4 @@ nose>=1.3.7
rednose>=1.1.1
pep8>=1.7.0
freezegun>=0.3.9
psycopg2>=2.7.3.1

View File

@ -73,6 +73,7 @@ def run_migrations_online():
connection = engine.connect()
context.configure(connection=connection,
target_metadata=target_metadata,
compare_type=True,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args)

View File

@ -0,0 +1,64 @@
"""Use strings for challenge types
Revision ID: 7e9efd084c5a
Revises: cbf5620f8e15
Create Date: 2017-10-04 16:40:16.508879
"""
from CTFd.models import db, Challenges
from alembic import op
import sqlalchemy as sa
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import text, table, column
# revision identifiers, used by Alembic.
revision = '7e9efd084c5a'
down_revision = 'cbf5620f8e15'
branch_labels = None
depends_on = None
challenges_table = table('challenges',
column('type', db.Integer),
)
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
bind = op.get_bind()
url = str(bind.engine.url)
if url.startswith('mysql'):
op.alter_column('challenges', 'type',
existing_type=sa.INTEGER(),
type_=sa.String(length=80),
existing_nullable=True)
op.execute("UPDATE challenges set type='standard' WHERE type=0")
elif url.startswith('postgres'):
op.alter_column('challenges', 'type',
existing_type=sa.INTEGER(),
type_=sa.String(length=80),
existing_nullable=True,
postgresql_using="COALESCE(NULLIF(type, 0)::CHARACTER, 'standard')"
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
bind = op.get_bind()
url = str(bind.engine.url)
if url.startswith('mysql'):
op.execute("UPDATE challenges set type=0 WHERE type='standard'")
op.alter_column('challenges', 'type',
existing_type=sa.String(length=80),
type_=sa.INTEGER(),
existing_nullable=True)
elif url.startswith('postgres'):
op.alter_column('challenges', 'type',
existing_type=sa.String(length=80),
type_=sa.INTEGER(),
existing_nullable=True,
postgresql_using="COALESCE(NULLIF(type, 'standard')::NUMERIC, 0)"
)
# ### end Alembic commands ###

View File

@ -39,6 +39,10 @@ def setup_ctfd(app, ctf_name="CTFd", name="admin", email="admin@ctfd.io", passwo
def destroy_ctfd(app):
with app.app_context():
app.db.session.commit()
app.db.session.close_all()
app.db.drop_all()
drop_database(app.config['SQLALCHEMY_DATABASE_URI'])
@ -72,11 +76,13 @@ def login_as_user(app, name="user", password="password"):
def get_scores(user):
scores = user.get('/scores')
print(scores.get_data(as_text=True))
scores = json.loads(scores.get_data(as_text=True))
print(scores)
return scores['standings']
def gen_challenge(db, name='chal_name', description='chal_description', value=100, category='chal_category', type=0):
def gen_challenge(db, name='chal_name', description='chal_description', value=100, category='chal_category', type='standard'):
chal = Challenges(name, description, value, category)
db.session.add(chal)
db.session.commit()

View File

@ -309,6 +309,7 @@ def test_register_plugin_script():
output = r.get_data(as_text=True)
assert '/fake/script/path.js' in output
assert 'http://ctfd.io/fake/script/path.js' in output
destroy_ctfd(app)
def test_register_plugin_stylesheet():
@ -322,3 +323,4 @@ def test_register_plugin_stylesheet():
output = r.get_data(as_text=True)
assert '/fake/stylesheet/path.css' in output
assert 'http://ctfd.io/fake/stylesheet/path.css' in output
destroy_ctfd(app)

View File

@ -268,3 +268,4 @@ def test_that_view_challenges_unregistered_works():
data = r.get_data(as_text=True)
data = json.loads(data)
assert data['status'] == -1
destroy_ctfd(app)

View File

@ -229,47 +229,51 @@ def test_scoring_logic():
chal2_id = chal2.id
# user1 solves chal1
with client1.session_transaction() as sess:
data = {
"key": 'flag',
"nonce": sess.get('nonce')
}
r = client1.post('/chal/{}'.format(chal1_id), data=data)
with freeze_time("2017-10-3 03:21:34"):
with client1.session_transaction() as sess:
data = {
"key": 'flag',
"nonce": sess.get('nonce')
}
r = client1.post('/chal/{}'.format(chal1_id), data=data)
# user1 is now on top
scores = get_scores(admin)
assert scores[0]['team'] == 'user1'
# user2 solves chal1 and chal2
with client2.session_transaction() as sess:
# solve chal1
data = {
"key": 'flag',
"nonce": sess.get('nonce')
}
r = client2.post('/chal/{}'.format(chal1_id), data=data)
# solve chal2
data = {
"key": 'flag',
"nonce": sess.get('nonce')
}
r = client2.post('/chal/{}'.format(chal2_id), data=data)
with freeze_time("2017-10-4 03:30:34"):
with client2.session_transaction() as sess:
# solve chal1
data = {
"key": 'flag',
"nonce": sess.get('nonce')
}
r = client2.post('/chal/{}'.format(chal1_id), data=data)
# solve chal2
data = {
"key": 'flag',
"nonce": sess.get('nonce')
}
r = client2.post('/chal/{}'.format(chal2_id), data=data)
# user2 is now on top
scores = get_scores(admin)
assert scores[0]['team'] == 'user2'
# user1 solves chal2
with client1.session_transaction() as sess:
data = {
"key": 'flag',
"nonce": sess.get('nonce')
}
r = client1.post('/chal/{}'.format(chal2_id), data=data)
with freeze_time("2017-10-5 03:50:34"):
with client1.session_transaction() as sess:
data = {
"key": 'flag',
"nonce": sess.get('nonce')
}
r = client1.post('/chal/{}'.format(chal2_id), data=data)
# user should still be on top because they solved chal2 first
# user2 should still be on top because they solved chal2 first
scores = get_scores(admin)
assert scores[0]['team'] == 'user2'
destroy_ctfd(app)
def test_user_score_is_correct():
@ -309,6 +313,7 @@ def test_user_score_is_correct():
# assert that user2's score is now 105 and is in 1st place
assert user2.score() == 105
assert user2.place() == '1st'
destroy_ctfd(app)
def test_pages_routing_and_rendering():
@ -457,6 +462,7 @@ def test_user_can_confirm_email(mock_smtp):
# The team is now verified
team = Teams.query.filter_by(email='user@user.com').first()
assert team.verified == True
destroy_ctfd(app)
@patch('smtplib.SMTP')
@ -524,3 +530,4 @@ http://localhost/reset_password/InVzZXIxIi5BZktHUGcuTVhkTmZtOWU2U2xwSXZ1MlFwTjdw
# Make sure that the user's password changed
team = Teams.query.filter_by(email="user@user.com").first()
assert team.password != team_password_saved
destroy_ctfd(app)