Repository: CTFd/CTFd

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
```


Discussion:
I just took a look at this and I think this might be an issue in ItsDangerous (https://github.com/pallets/itsdangerous/issues/89). 

Resolving patch:
diff --git a/CTFd/auth.py b/CTFd/auth.py
--- a/CTFd/auth.py
+++ b/CTFd/auth.py
@@ -2,7 +2,6 @@
 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 @@ def reset_password(data=None):
 
 {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
--- 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/utils.py b/CTFd/utils.py
--- 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 sys
 import tempfile
 import time
-import urllib
 import dataset
 import zipfile
 import io
@@ -25,8 +25,8 @@
 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
@@ -492,11 +492,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 +520,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:


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- 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)