Repository: CTFd/CTFd (python)

Issue:
Log failed login attempts
During CTF competitions, mischievous participants or rogue strangers may try to exploit CTFd by brute forcing the login page or attempt SQL injections, etc...

It'll be useful if we could add the failed login attempts into the existing /opt/CTFd/CTFd/logs/logins.log or maybe a new file named failed-logins.log.

Pull request description:
Adds more logging to auth.py
Closes #308

Patch:
diff --git a/CTFd/admin/__init__.py b/CTFd/admin/__init__.py
index cd40e896..1448fa97 100644
--- a/CTFd/admin/__init__.py
+++ b/CTFd/admin/__init__.py
@@ -49,6 +49,8 @@ def admin_plugin_config(plugin):
             if k == "nonce":
                 continue
             utils.set_config(k, v)
+        with app.app_context():
+            cache.clear()
         return '1'
 
 
diff --git a/CTFd/auth.py b/CTFd/auth.py
index eeb06a91..ab9aeff5 100644
--- a/CTFd/auth.py
+++ b/CTFd/auth.py
@@ -21,6 +21,7 @@ def confirm_user(data=None):
         # If the CTF doesn't care about confirming email addresses then redierct to challenges
         return redirect(url_for('challenges.challenges_view'))
 
+    logger = logging.getLogger('logins')
     # User is confirming email account
     if data and request.method == "GET":
         try:
@@ -33,9 +34,13 @@ def confirm_user(data=None):
         team = Teams.query.filter_by(email=email).first_or_404()
         team.verified = True
         db.session.commit()
-        logger = logging.getLogger('regs')
-        logger.warn("[{0}] {1} confirmed {2}".format(time.strftime("%m/%d/%Y %X"), team.name.encode('utf-8'), team.email.encode('utf-8')))
         db.session.close()
+        logger.warn("[{date}] {ip} - {username} confirmed their account".format(
+            date=time.strftime("%m/%d/%Y %X"),
+            ip=utils.get_ip(),
+            username=team.name.encode('utf-8'),
+            email=team.email.encode('utf-8')
+        ))
         if utils.authed():
             return redirect(url_for('challenges.challenges_view'))
         return redirect(url_for('auth.login'))
@@ -53,6 +58,12 @@ def confirm_user(data=None):
                 return redirect(url_for('views.profile'))
             else:
                 utils.verify_email(team.email)
+                logger.warn("[{date}] {ip} - {username} initiated a confirmation email resend".format(
+                    date=time.strftime("%m/%d/%Y %X"),
+                    ip=utils.get_ip(),
+                    username=team.name.encode('utf-8'),
+                    email=team.email.encode('utf-8')
+                ))
             return render_template('confirm.html', team=team, infos=['Your confirmation email has been resent!'])
         elif request.method == "GET":
             # User has been directed to the confirm page
@@ -80,6 +91,11 @@ def reset_password(data=None):
         team.password = bcrypt_sha256.encrypt(request.form['password'].strip())
         db.session.commit()
         db.session.close()
+        logger.warn("[{date}] {ip} -  successful password reset for {username}".format(
+            date=time.strftime("%m/%d/%Y %X"),
+            ip=utils.get_ip(),
+            username=team.name.encode('utf-8')
+        ))
         return redirect(url_for('auth.login'))
 
     if request.method == 'POST':
@@ -104,6 +120,7 @@ Did you initiate a password reset?
 
 @auth.route('/register', methods=['POST', 'GET'])
 def register():
+    logger = logging.getLogger('regs')
     if not utils.can_register():
         return redirect(url_for('auth.login'))
     if request.method == 'POST':
@@ -149,9 +166,12 @@ def register():
                 if utils.can_send_mail() and utils.get_config('verify_emails'):  # Confirming users is enabled and we can send email.
                     db.session.close()
                     logger = logging.getLogger('regs')
-                    logger.warn("[{0}] {1} registered (UNCONFIRMED) with {2}".format(time.strftime("%m/%d/%Y %X"),
-                                                                                     request.form['name'].encode('utf-8'),
-                                                                                     request.form['email'].encode('utf-8')))
+                    logger.warn("[{date}] {ip} - {username} registered (UNCONFIRMED) with {email}".format(
+                        date=time.strftime("%m/%d/%Y %X"),
+                        ip=utils.get_ip(),
+                        username=request.form['name'].encode('utf-8'),
+                        email=request.form['email'].encode('utf-8')
+                    ))
 
                     utils.verify_email(team.email)
 
@@ -162,8 +182,12 @@ def register():
 
         db.session.close()
 
-        logger = logging.getLogger('regs')
-        logger.warn("[{0}] {1} registered with {2}".format(time.strftime("%m/%d/%Y %X"), request.form['name'].encode('utf-8'), request.form['email'].encode('utf-8')))
+        logger.warn("[{date}] {ip} - {username} registered with {email}".format(
+            date=time.strftime("%m/%d/%Y %X"),
+            ip=utils.get_ip(),
+            username=request.form['name'].encode('utf-8'),
+            email=request.form['email'].encode('utf-8')
+        ))
         return redirect(url_for('challenges.challenges_view'))
     else:
         return render_template('register.html')
@@ -171,6 +195,7 @@ def register():
 
 @auth.route('/login', methods=['POST', 'GET'])
 def login():
+    logger = logging.getLogger('logins')
     if request.method == 'POST':
         errors = []
         name = request.form['name']
@@ -187,20 +212,35 @@ def login():
                 session['nonce'] = utils.sha512(os.urandom(10))
                 db.session.close()
 
-                logger = logging.getLogger('logins')
-                logger.warn("[{0}] {1} logged in".format(time.strftime("%m/%d/%Y %X"), session['username'].encode('utf-8')))
+                logger.warn("[{date}] {ip} - {username} logged in".format(
+                    date=time.strftime("%m/%d/%Y %X"),
+                    ip=utils.get_ip(),
+                    username=session['username'].encode('utf-8')
+                ))
 
                 if request.args.get('next') and utils.is_safe_url(request.args.get('next')):
                     return redirect(request.args.get('next'))
                 return redirect(url_for('challenges.challenges_view'))
+
             else:  # This user exists but the password is wrong
+                logger.warn("[{date}] {ip} - submitted invalid password for {username}".format(
+                    date=time.strftime("%m/%d/%Y %X"),
+                    ip=utils.get_ip(),
+                    username=team.name.encode('utf-8')
+                ))
                 errors.append("Your username or password is incorrect")
                 db.session.close()
                 return render_template('login.html', errors=errors)
+
         else:  # This user just doesn't exist
+            logger.warn("[{date}] {ip} - submitted invalid account information".format(
+                date=time.strftime("%m/%d/%Y %X"),
+                ip=utils.get_ip()
+            ))
             errors.append("Your username or password is incorrect")
             db.session.close()
             return render_template('login.html', errors=errors)
+
     else:
         db.session.close()
         return render_template('login.html')
diff --git a/CTFd/models.py b/CTFd/models.py
index 70f24bad..70a9ca5d 100644
--- a/CTFd/models.py
+++ b/CTFd/models.py
@@ -8,6 +8,7 @@ from struct import unpack, pack, error as struct_error
 from flask_sqlalchemy import SQLAlchemy
 from passlib.hash import bcrypt_sha256
 from sqlalchemy.exc import DatabaseError
+from sqlalchemy.sql.expression import union_all
 
 
 def sha512(string):
@@ -198,22 +199,44 @@ class Teams(db.Model):
             return 0
 
     def place(self, admin=False):
-        score = db.func.sum(Challenges.value).label('score')
-        quickest = db.func.max(Solves.date).label('quickest')
-        teams = db.session.query(Solves.teamid).join(Teams).join(Challenges).filter(Teams.banned == False)
+        scores = db.session.query(
+            Solves.teamid.label('teamid'),
+            db.func.sum(Challenges.value).label('score'),
+            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.date).label('date')
+        ).group_by(Awards.teamid)
 
         if not admin:
             freeze = Config.query.filter_by(key='freeze').first()
             if freeze and freeze.value:
                 freeze = int(freeze.value)
                 freeze = datetime.datetime.utcfromtimestamp(freeze)
-                teams = teams.filter(Solves.date < freeze)
+                scores = scores.filter(Solves.date < freeze)
+                awards = awards.filter(Awards.date < freeze)
+
+        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')
+
+        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)
 
-        teams = teams.group_by(Solves.teamid).order_by(score.desc(), quickest).all()
+        standings = standings_query.all()
 
         # http://codegolf.stackexchange.com/a/4712
         try:
-            i = teams.index((self.id,)) + 1
+            i = standings.index((self.id,)) + 1
             k = i % 10
             return "%d%s" % (i, "tsnrhtdd"[(i / 10 % 10 != 1) * (k < 4) * k::4])
         except ValueError:
diff --git a/README.md b/README.md
index 23389c61..c44d15a4 100644
--- a/README.md
+++ b/README.md
@@ -19,8 +19,11 @@ CTFd is a Capture The Flag in a can. It's easy to customize with plugins and the
 ## Live Demo
 https://demo.ctfd.io/
 
-## Reverse Engineering Module
-https://reversing.ctfd.io/
+## Managed Hosting
+Looking to use CTFd but don't want to deal with managing infrastructure? Check out [the CTFd website](https://ctfd.io/) for managed CTFd deployments. 
+
+## HackerFire
+Looking for CTF challenges to work on? [HackerFire](https://hackerfire.com/) is a learning focused CTF built using CTFd. It features a wide variety of challenges and is updated with new content frequently. It also contains custom knowledge resources to teach newcomers about the techniques used to solve a challenge. 
 
 ## Credits
 * Logo by [Laura Barbera](http://www.laurabb.com/)


Test patch:
diff --git a/tests/helpers.py b/tests/helpers.py
index dcd0c95c..864fc606 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -56,6 +56,12 @@ def login_as_user(app, name="user", password="password"):
             return client
 
 
+def get_scores(user):
+    scores = user.get('/scores')
+    scores = json.loads(scores.get_data(as_text=True))
+    return scores['standings']
+
+
 def gen_challenge(db, name='chal_name', description='chal_description', value=100, category='chal_category', type=0):
     chal = Challenges(name, description, value, category)
     db.session.add(chal)
diff --git a/tests/test_user_facing.py b/tests/test_user_facing.py
index 38abf10a..1140a915 100644
--- a/tests/test_user_facing.py
+++ b/tests/test_user_facing.py
@@ -302,6 +302,7 @@ def test_submitting_unicode_flag():
 
 
 def test_submitting_flags_with_large_ips():
+    '''Test that users with high octect IP addresses can submit flags'''
     app = create_ctfd()
     with app.app_context():
         register_user(app)
@@ -344,6 +345,108 @@ def test_submitting_flags_with_large_ips():
     destroy_ctfd(app)
 
 
+def test_scoring_logic():
+    """Test that scoring logic is correct"""
+    app = create_ctfd()
+    with app.app_context():
+        admin = login_as_user(app, name="admin", password="password")
+
+        register_user(app, name="user1", email="user1@ctfd.io", password="password")
+        client1 = login_as_user(app, name="user1", password="password")
+        register_user(app, name="user2", email="user2@ctfd.io", password="password")
+        client2 = login_as_user(app, name="user2", password="password")
+
+        chal1 = gen_challenge(app.db)
+        flag1 = gen_flag(app.db, chal=chal1.id, flag='flag')
+        chal1_id = chal1.id
+
+        chal2 = gen_challenge(app.db)
+        flag2 = gen_flag(app.db, chal=chal2.id, flag='flag')
+        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)
+
+        # 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)
+
+        # 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)
+
+        # user should still be on top because they solved chal2 first
+        scores = get_scores(admin)
+        assert scores[0]['team'] == 'user2'
+
+
+def test_user_score_is_correct():
+    '''Test that a user's score is correct'''
+    app = create_ctfd()
+    with app.app_context():
+        # create user1
+        register_user(app, name="user1", email="user1@ctfd.io")
+
+        # create challenge
+        chal = gen_challenge(app.db, value=100)
+        flag = gen_flag(app.db, chal=chal.id, flag='flag')
+        chal_id = chal.id
+
+        # create a solve for the challenge for user1. (the id is 2 because of the admin)
+        gen_solve(app.db, 2, chal_id)
+        user1 = Teams.query.filter_by(id=2).first()
+
+        # assert that user1's score is 100
+        assert user1.score() == 100
+        assert user1.place() == '1st'
+
+        # create user2
+        register_user(app, name="user2", email="user2@ctfd.io")
+
+        # user2 solves the challenge
+        gen_solve(app.db, 3, chal_id)
+
+        # assert that user2's score is 100 but is in 2nd place
+        user2 = Teams.query.filter_by(id=3).first()
+        assert user2.score() == 100
+        assert user2.place() == '2nd'
+
+        # create an award for user2
+        gen_award(app.db, 3, value=5)
+
+        # assert that user2's score is now 105 and is in 1st place
+        assert user2.score() == 105
+        assert user2.place() == '1st'
+
+
 def test_pages_routing_and_rendering():
     """Test that pages are routing and rendering"""
     app = create_ctfd()