Repository: CTFd/CTFd (python)

Issue:
Error in email verification with python3
I'm running CTFd 1.0.3 with python3. I turned on email verification and now, when registering an account, I get a 500 error and the following backtrace:

```
[2017-08-01 18:49:31,526] ERROR in app: Exception on /confirm [POST]
Traceback (most recent call last):
File "/opt/CTFd-1.0.3/venv/lib/python3.4/site-packages/flask/app.py", line 1982, in wsgi_app
response = self.full_dispatch_request()
File "/opt/CTFd-1.0.3/venv/lib/python3.4/site-packages/flask/app.py", line 1614, in full_dispatch_request
rv = self.handle_user_exception(e)
File "/opt/CTFd-1.0.3/venv/lib/python3.4/site-packages/flask/app.py", line 1517, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "/opt/CTFd-1.0.3/venv/lib/python3.4/site-packages/flask/_compat.py", line 33, in reraise
raise value
File "/opt/CTFd-1.0.3/venv/lib/python3.4/site-packages/flask/app.py", line 1612, in full_dispatch_request
rv = self.dispatch_request()
File "/opt/CTFd-1.0.3/venv/lib/python3.4/site-packages/flask/app.py", line 1598, in dispatch_request
return self.view_functions[rule.endpoint](**req.view_args)
File "/opt/CTFd-1.0.3/CTFd/auth.py", line 55, in confirm_user
utils.verify_email(team.email)
File "/opt/CTFd-1.0.3/CTFd/utils.py", line 499, in verify_email
token = s.sign(addr)
File "/opt/CTFd-1.0.3/venv/lib/python3.4/site-packages/itsdangerous.py", line 353, in sign
return value + want_bytes(self.sep) + self.get_signature(value)
TypeError: Can't convert 'bytes' object to str implicitly
```

Pull request description:
Using a Python 3 compatible base64 encoder and fixing verify_emails
This closes #329. 

Patch:
diff --git a/CTFd/admin/__init__.py b/CTFd/admin/__init__.py
index 1448fa97..36cd996e 100644
--- a/CTFd/admin/__init__.py
+++ b/CTFd/admin/__init__.py
@@ -113,6 +113,7 @@ def admin_config():
             verify_emails = bool(request.form.get('verify_emails', None))
             mail_tls = bool(request.form.get('mail_tls', None))
             mail_ssl = bool(request.form.get('mail_ssl', None))
+            mail_useauth = bool(request.form.get('mail_useauth', None))
         except (ValueError, TypeError):
             view_challenges_unregistered = None
             view_scoreboard_if_authed = None
@@ -123,6 +124,7 @@ def admin_config():
             verify_emails = None
             mail_tls = None
             mail_ssl = None
+            mail_useauth = None
         finally:
             view_challenges_unregistered = utils.set_config('view_challenges_unregistered', view_challenges_unregistered)
             view_scoreboard_if_authed = utils.set_config('view_scoreboard_if_authed', view_scoreboard_if_authed)
@@ -133,6 +135,7 @@ def admin_config():
             verify_emails = utils.set_config('verify_emails', verify_emails)
             mail_tls = utils.set_config('mail_tls', mail_tls)
             mail_ssl = utils.set_config('mail_ssl', mail_ssl)
+            mail_useauth = utils.set_config('mail_useauth', mail_useauth)
 
         mail_server = utils.set_config("mail_server", request.form.get('mail_server', None))
         mail_port = utils.set_config("mail_port", request.form.get('mail_port', None))
@@ -186,6 +189,7 @@ def admin_config():
 
     mail_tls = utils.get_config('mail_tls')
     mail_ssl = utils.get_config('mail_ssl')
+    mail_useauth = utils.get_config('mail_useauth')
 
     view_challenges_unregistered = utils.get_config('view_challenges_unregistered')
     view_scoreboard_if_authed = utils.get_config('view_scoreboard_if_authed')
@@ -208,6 +212,7 @@ def admin_config():
                            hide_scores=hide_scores,
                            mail_server=mail_server,
                            mail_port=mail_port,
+                           mail_useauth=mail_useauth,
                            mail_username=mail_username,
                            mail_password=mail_password,
                            mail_tls=mail_tls,
diff --git a/CTFd/auth.py b/CTFd/auth.py
index ab9aeff5..97017aaa 100644
--- a/CTFd/auth.py
+++ b/CTFd/auth.py
@@ -2,7 +2,6 @@ import logging
 import os
 import re
 import time
-import urllib
 
 from flask import current_app as app, render_template, request, redirect, url_for, session, Blueprint
 from itsdangerous import TimedSerializer, BadTimeSignature, Signer, BadSignature
@@ -25,8 +24,10 @@ def confirm_user(data=None):
     # User is confirming email account
     if data and request.method == "GET":
         try:
-            s = Signer(app.config['SECRET_KEY'])
-            email = s.unsign(urllib.unquote_plus(data.decode('base64')))
+            s = TimedSerializer(app.config['SECRET_KEY'])
+            email = s.loads(utils.base64decode(data, urldecode=True), max_age=1800)
+        except BadTimeSignature:
+            return render_template('confirm.html', errors=['Your confirmation link has expired'])
         except BadSignature:
             return render_template('confirm.html', errors=['Your confirmation link seems wrong'])
         except:
@@ -82,7 +83,7 @@ def reset_password(data=None):
     if data is not None and request.method == "POST":
         try:
             s = TimedSerializer(app.config['SECRET_KEY'])
-            name = s.loads(urllib.unquote_plus(data.decode('base64')), max_age=1800)
+            name = s.loads(utils.base64decode(data, urldecode=True), max_age=1800)
         except BadTimeSignature:
             return render_template('reset_password.html', errors=['Your link has expired'])
         except:
@@ -110,7 +111,7 @@ Did you initiate a password reset?
 
 {0}/{1}
 
-""".format(url_for('auth.reset_password', _external=True), urllib.quote_plus(token.encode('base64')))
+""".format(url_for('auth.reset_password', _external=True), utils.base64encode(token, urlencode=True))
 
         utils.sendmail(email, text)
 
diff --git a/CTFd/config.py b/CTFd/config.py
index 7a7591eb..64c74c23 100644
--- a/CTFd/config.py
+++ b/CTFd/config.py
@@ -119,7 +119,9 @@ class Config(object):
 
 
 class TestingConfig(Config):
+    SECRET_KEY = 'AAAAAAAAAAAAAAAAAAAA'
     PRESERVE_CONTEXT_ON_EXCEPTION = False
     TESTING = True
     DEBUG = True
     SQLALCHEMY_DATABASE_URI = 'sqlite://'
+    SERVER_NAME = 'localhost'
diff --git a/CTFd/themes/admin/templates/config.html b/CTFd/themes/admin/templates/config.html
index a4024798..1658ea02 100644
--- a/CTFd/themes/admin/templates/config.html
+++ b/CTFd/themes/admin/templates/config.html
@@ -131,6 +131,12 @@
 									   placeholder="Mail Server Port"
 									   {% if mail_port is defined and mail_port != None %}value="{{ mail_port }}"{% endif %}>
 							</div>
+							<div class="checkbox">
+                                                                <label>
+                                                                        <input id="mail_useauth" name="mail_useauth" type="checkbox" {% if mail_useauth %}checked{% endif %}>
+                                                                        Use SMTP Authentication?
+                                                                </label>
+                                                        </div>
 							<div class="form-group">
 								<label for="start">Username:</label>
 								<input class="form-control" id='mail_username' name='mail_username' type='text'
diff --git a/CTFd/themes/original/static/js/chalboard.js b/CTFd/themes/original/static/js/chalboard.js
index 7c6307a0..157a0fbe 100644
--- a/CTFd/themes/original/static/js/chalboard.js
+++ b/CTFd/themes/original/static/js/chalboard.js
@@ -104,7 +104,7 @@ function submitkey(chal, key, nonce) {
           return
         }
         else if (result.status == 0){ // Incorrect key
-            result_notification.addClass('alert alert-danger alert-dismissable');
+            result_notification.addClass('alert alert-danger alert-dismissable text-center');
             result_notification.slideDown();
 
             answer_input.removeClass("correct");
@@ -114,7 +114,7 @@ function submitkey(chal, key, nonce) {
             }, 3000);
         }
         else if (result.status == 1){ // Challenge Solved
-            result_notification.addClass('alert alert-success alert-dismissable');
+            result_notification.addClass('alert alert-success alert-dismissable text-center');
             result_notification.slideDown();
 
             $('.chal-solves').text((parseInt($('.chal-solves').text().split(" ")[0]) + 1 +  " Solves") );
@@ -124,13 +124,13 @@ function submitkey(chal, key, nonce) {
             answer_input.addClass("correct");
         }
         else if (result.status == 2){ // Challenge already solved
-            result_notification.addClass('alert alert-info alert-dismissable');
+            result_notification.addClass('alert alert-info alert-dismissable text-center');
             result_notification.slideDown();
 
             answer_input.addClass("correct");
         }
         else if (result.status == 3){ // Keys per minute too high
-            result_notification.addClass('alert alert-warning alert-dismissable');
+            result_notification.addClass('alert alert-warning alert-dismissable text-center');
             result_notification.slideDown();
 
             answer_input.addClass("too-fast");
diff --git a/CTFd/themes/original/static/js/templates/challenges/standard/standard-challenge-modal.hbs b/CTFd/themes/original/static/js/templates/challenges/standard/standard-challenge-modal.hbs
index 41a21b43..b833fa4b 100644
--- a/CTFd/themes/original/static/js/templates/challenges/standard/standard-challenge-modal.hbs
+++ b/CTFd/themes/original/static/js/templates/challenges/standard/standard-challenge-modal.hbs
@@ -12,9 +12,9 @@
             <div role="tabpanel">
                 <div class="tab-content">
                     <div role="tabpanel" class="tab-pane fade in active" id="challenge">
-                        <h3 class='chal-name'>{{ name }}</h3>
-                        <h4 class="chal-value">{{ value }}</h4>
-                        <div class="chal-tags">
+                        <h3 class='chal-name text-center'>{{ name }}</h3>
+                        <h4 class="chal-value text-center">{{ value }}</h4>
+                        <div class="chal-tags text-center">
                         {{#each tags }}
                             <span class='label label-primary chal-tag'>{{this}}</span>
                         {{/each}}
@@ -22,7 +22,7 @@
                         <span class="chal-desc">{{{ desc }}}</span>
                         <div class="chal-hints file-row row">
                             {{#each hints}}
-                            <div class='col-md-12 file-button-wrapper'>
+                            <div class='col-md-12 file-button-wrapper text-center'>
                                 <a onclick="javascript:loadhint({{this.id}})">
                                     {{#if this.hint }}
                                         <label class='challenge-wrapper file-wrapper hide-text'>
@@ -43,7 +43,7 @@
                             </div>
                             {{/each}}
                         </div>
-                        <div class="chal-files file-row row">
+                        <div class="chal-files file-row row text-center">
                             {{#each files}}
                             <div class='col-md-3 file-button-wrapper'>
                                 <a class='file-button' href='/files/{{this}}'>
@@ -68,13 +68,13 @@
                             </div>
                         </div>
                         <div class="row notification-row">
-                            <div id="result-notification" class="alert alert-dismissable" role="alert">
+                            <div id="result-notification" class="alert alert-dismissable text-center" role="alert">
                               <strong id="result-message"></strong>
                             </div>
                         </div>
                     </div>
                     <div role="tabpanel" class="tab-pane fade" id="solves">
-                        <table class="table table-striped">
+                        <table class="table table-striped text-center">
                             <thead>
                                 <tr>
                                     <td><b>Name</b>
diff --git a/CTFd/themes/original/templates/chals.html b/CTFd/themes/original/templates/chals.html
index dde75e7e..ad9c4821 100644
--- a/CTFd/themes/original/templates/chals.html
+++ b/CTFd/themes/original/templates/chals.html
@@ -40,16 +40,6 @@
 	.input--filled .too-fast {
 		border-color: rgb(252, 248, 227);
 	}
-	a, button {
-		color: #74716D;
-		text-decoration: none;
-		outline: none;
-	}
-
-	a:hover, a:focus {
-		color: #c94e50;
-		outline: none;
-	}
 
 	button {
 		padding: 1em 2em;
@@ -64,7 +54,6 @@
 		max-width: 1000px;
 		padding: 2em;
 		margin: 0 auto;
-		text-align: center;
 	}
 
 	.button-wrap {
diff --git a/CTFd/utils.py b/CTFd/utils.py
index 48dd825c..2c3860ab 100644
--- a/CTFd/utils.py
+++ b/CTFd/utils.py
@@ -1,3 +1,4 @@
+import base64
 import datetime
 import functools
 import hashlib
@@ -16,7 +17,6 @@ import subprocess
 import sys
 import tempfile
 import time
-import urllib
 import dataset
 import zipfile
 import io
@@ -25,8 +25,8 @@ from email.mime.text import MIMEText
 from flask import current_app as app, request, redirect, url_for, session, render_template, abort
 from flask_caching import Cache
 from flask_migrate import Migrate, upgrade as migrate_upgrade, stamp as migrate_stamp
-from itsdangerous import Signer
-from six.moves.urllib.parse import urlparse, urljoin
+from itsdangerous import TimedSerializer, BadTimeSignature, Signer, BadSignature
+from six.moves.urllib.parse import urlparse, urljoin, quote, unquote
 from werkzeug.utils import secure_filename
 
 from CTFd.models import db, WrongKeys, Pages, Config, Tracking, Teams, Files, Containers, ip2long, long2ip
@@ -436,13 +436,14 @@ def mailserver():
     return False
 
 
-def get_smtp(host, port, username=None, password=None, TLS=None, SSL=None):
+def get_smtp(host, port, username=None, password=None, TLS=None, SSL=None, auth=None):
     smtp = smtplib.SMTP(host, port)
     smtp.ehlo()
     if TLS:
         smtp.starttls()
         smtp.ehlo()
-    smtp.login(username, password)
+    if auth:
+        smtp.login(username, password)
     return smtp
 
 
@@ -477,6 +478,8 @@ def sendmail(addr, text):
             data['TLS'] = get_config('mail_tls')
         if get_config('mail_ssl'):
             data['SSL'] = get_config('mail_ssl')
+        if get_config('mail_useauth'):
+            data['auth'] = get_config('mail_useauth')
 
         smtp = get_smtp(**data)
         msg = MIMEText(text)
@@ -492,11 +495,12 @@ def sendmail(addr, text):
 
 
 def verify_email(addr):
-    s = Signer(app.config['SECRET_KEY'])
-    token = s.sign(addr)
-    text = """Please click the following link to confirm your email address for {}: {}""".format(
-        get_config('ctf_name'),
-        url_for('auth.confirm_user', _external=True) + '/' + urllib.quote_plus(token.encode('base64'))
+    s = TimedSerializer(app.config['SECRET_KEY'])
+    token = s.dumps(addr)
+    text = """Please click the following link to confirm your email address for {ctf_name}: {url}/{token}""".format(
+        ctf_name=get_config('ctf_name'),
+        url=url_for('auth.confirm_user', _external=True),
+        token=base64encode(token, urlencode=True)
     )
     sendmail(addr, text)
 
@@ -519,6 +523,28 @@ def sha512(string):
     return hashlib.sha512(string).hexdigest()
 
 
+def base64encode(s, urlencode=False):
+    if six.PY3 and isinstance(s, six.string_types):
+        s = s.encode('utf-8')
+    encoded = base64.urlsafe_b64encode(s)
+    if six.PY3:
+        encoded = encoded.decode('utf-8')
+    if urlencode:
+        encoded = quote(encoded)
+    return encoded
+
+
+def base64decode(s, urldecode=False):
+    if urldecode:
+        s = unquote(s)
+    if six.PY3 and isinstance(s, six.string_types):
+        s = s.encode('utf-8')
+    decoded = base64.urlsafe_b64decode(s)
+    if six.PY3:
+        decoded = decoded.decode('utf-8')
+    return decoded
+
+
 @cache.memoize()
 def can_create_container():
     try:
diff --git a/CTFd/views.py b/CTFd/views.py
index db7e8574..14ca89e8 100644
--- a/CTFd/views.py
+++ b/CTFd/views.py
@@ -75,6 +75,7 @@ def setup():
             mail_ssl = utils.set_config('mail_ssl', None)
             mail_username = utils.set_config('mail_username', None)
             mail_password = utils.set_config('mail_password', None)
+            mail_useauth = utils.set_config('mail_useauth', None)
 
             setup = utils.set_config('setup', True)
 


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f09b1ce8..16bbac12 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -3,7 +3,8 @@
 
 from tests.helpers import *
 from CTFd.models import ip2long, long2ip
-from CTFd.utils import get_config, set_config, override_template, sendmail
+from CTFd.utils import get_config, set_config, override_template, sendmail, verify_email
+from CTFd.utils import base64encode, base64decode
 from mock import patch
 import json
 
@@ -41,6 +42,20 @@ def test_long2ip_ipv6():
     assert long2ip(42540616829182469433547762482097946625) == '2001:658:22a:cafe:200::1'
 
 
+def test_base64encode():
+    """The base64encode wrapper works properly"""
+    assert base64encode('abc123') == 'YWJjMTIz'
+    assert base64encode('😆') == '8J-Yhg=='
+    assert base64encode('😆', urlencode=True) == '8J-Yhg%3D%3D'
+
+
+def test_base64decode():
+    """The base64decode wrapper works properly"""
+    assert base64decode('YWJjMTIz') == 'abc123'
+    assert base64decode('8J-Yhg==') == '😆'
+    assert base64decode('8J-Yhg%3D%3D', urldecode=True) == '😆'
+
+
 def test_override_template():
     """Does override_template work properly for regular themes"""
     app = create_ctfd()
@@ -93,3 +108,37 @@ def test_sendmail_with_smtp(mock_smtp):
 
         mock_smtp.return_value.sendmail.assert_called_once_with(from_addr, [to_addr], email_msg.as_string())
     destroy_ctfd(app)
+
+
+@patch('smtplib.SMTP')
+def test_verify_email(mock_smtp):
+    """Does verify_email send emails"""
+    from email.mime.text import MIMEText
+    app = create_ctfd()
+    with app.app_context():
+        set_config('mail_server', 'localhost')
+        set_config('mail_port', 25)
+        set_config('mail_username', 'username')
+        set_config('mail_password', 'password')
+        set_config('verify_emails', True)
+
+        from_addr = get_config('mailfrom_addr') or app.config.get('MAILFROM_ADDR')
+        to_addr = 'user@user.com'
+
+        verify_email(to_addr)
+
+        # This is currently not actually validated
+        msg = ("Please click the following link to confirm"
+               " your email address for CTFd:"
+               " http://localhost/confirm/InVzZXJAdXNlci5jb20iLkRHbGFZUS5XUURfQzBub3pGZkFMRlIyeGxDS1BCMjZETlk%3D")
+
+        ctf_name = get_config('ctf_name')
+        email_msg = MIMEText(msg)
+        email_msg['Subject'] = "Message from {0}".format(ctf_name)
+        email_msg['From'] = from_addr
+        email_msg['To'] = to_addr
+
+        # Need to freeze time to predict the value of the itsdangerous token.
+        # For now just assert that sendmail was called.
+        mock_smtp.return_value.sendmail.assert_called()
+    destroy_ctfd(app)