1
\$\begingroup\$

I wanted to put this out here and see if there were any security vulnerabilities that I'm not seeing. Also, if I can improve performance without sacrificing security, that's always a plus. Thanks in advance!

Typically, when you want to validate an e-mail address, you store an activation key and expiration date in the user profile table. So you can validate it one time. Then what? Just keep storing it forever? Clear it out and have the empty columns? Write handlers so this data doesn't show up the user profile? I wanted to use a disposable key that didn't have to be stored in a database. This is my solution; which is also available on gist ...

# user/helpers.py
from django.core.signing import BadSignature, Signer

import base64
import json
import time

def email_validation_encode(payload):
    """
    I expect the payload to be something like: 
        {
            'email': 'me@example.com', 
            'expires': (a few days from now),
        }
    However, I don't care enough to validate
    """
    payload_json = json.dumps(payload)
    payload_b64 = base64.b64encode(payload_json.encode('utf-8'))

    signer = Signer()

    return signer.sign(payload_b64.decode('utf-8'))


def email_validation_decode(string):
    """
    Expects the result from the `email_validation_encode` function.
    If the string looks valid: returns the original payload
    If anything is changed/invalid: returns False
    """
    signer = Signer()
    try:
        string_b64 = signer.unsign(string)
    except BadSignature:
        return False

    json_decoded = base64.b64decode(string_b64).decode('utf-8')
    return json.loads(json_decoded)


def create_email_validation_key(email):
    """
    Makes an object:
        {
            'email': 'me@example.com', 
            'expires': (two or three days from now),
        }
    returns the key to put in the link to send to email
    """
    return email_validation_encode({
        'email': email,
        'expires': (time.time() + 60*60*24*2),
    })


def decode_email_validation_key(key):
    """
    This takes the key (from link sent to email), sends it to be decoded.
    Then, it checks the expiration.
    If the key is valid and hasn't expired: returns just the email address
    If anything is changed/invalid: returns False
    """
    decoded = email_validation_decode(key)
    if not decoded or time.time() > decoded['expires']:
        return False
    else:
        return decoded['email']


def get_uri(request, force_secure=False):
    """
    Get the current URI; ie: http://localhost
    """
    if force_secure or request.is_secure():
        return 'https://%s' % request.get_host()
    else:
        return 'http://%s' % request.get_host()

Implementation

Implementation would obviously be within my user/views.py file, but you'll get the idea from this excerpt, I think:

C:\Temp>env\Scripts\python.exe manage.py shell
Python 3.5.1 (v3.5.1:37a07cee5969, Dec  6 2015, 01:54:25) [MSC v.1900 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from user.helpers import create_email_validation_key, decode_email_validation_key, get_uri
>>> import urllib.parse
>>>
>>> key = create_email_validation_key('VertigoRay@example.com')
>>> key
'eyJlbWFpbCI6ICJWZXJ0aWdvUmF5QGV4YW1wbGUuY29tIiwgImV4cGlyZXMiOiAxNDYzNDU2MzgwLjA4NDAyNjN9:xzklS0YL3NT-GR33Nilo'
>>> 
>>> url = 'http://example.com/user/register/{0}'.format(
...   urllib.parse.quote(key),
... )
>>> url
'http://example.com/user/register/eyJlbWFpbCI6ICJWZXJ0aWdvUmF5QGV4YW1wbGUuY29tIiwgImV4cGlyZXMiOiAxNDYzNDU2MzgwLjA4NDAyNjN9%3AxzklS0YL3NT-GR33Nilo'
>>>
>>> # That's the url that we'll send via email
>>> # When a user clicks the link, `urls.py` will send the key portion as `key`; such as:
>>> #     url(r'^validate_email/(?P<key>[a-zA-Z0-9-_=]+[:][a-zA-Z0-9-_=]+)$', validate_email, name='validate_email'),
>>>
>>> key
'eyJlbWFpbCI6ICJWZXJ0aWdvUmF5QGV4YW1wbGUuY29tIiwgImV4cGlyZXMiOiAxNDYzNDU2MzgwLjA4NDAyNjN9:xzklS0YL3NT-GR33Nilo'
>>> email = decode_email_validation_key(key)
>>> email
'VertigoRay@example.com'
>>> 
>>> # At this point, we can lookup the account with this e-mail address 
>>> # and mark it as validated in the database.
>>>
>>> # One more thing, let's break the key:
>>> email = decode_email_validation_key(key[1:])
>>> email
False

Note: I would actually set the url as follows, but I wanted to just focus on the functions related to this demo. I'm only showing you this additional bit to demo the usage of the get_uri() function; in case you can give me a better way to do this:

url = '{0}/{1}/{2}'.format(
    get_uri(request),
    reverse('register'),
    urllib.parse.quote(key),
)

Update: changed from pickle to json (https://youtu.be/7KnfGDajDQw). I tend to prefer json, but I didn't want to convert datetime.datetime.now() to something json would parse. A good friend told me to use time.time(), and I felt like a n00b. I'm enjoying the shorter keys as well.

\$\endgroup\$
  • \$\begingroup\$ So, are you looking for the code to be reviewed? What do you want the review to focus on? (Security/performance/etc.)? \$\endgroup\$ – Kevin Brown May 15 '16 at 3:06
  • \$\begingroup\$ Security is paramount here, but I definitely would like to keep performance in mind. \$\endgroup\$ – VertigoRay May 15 '16 at 3:08
  • 2
    \$\begingroup\$ Why not use a timestamped signer? Seems like that's what you are emulating: docs.djangoproject.com/en/1.9/topics/signing/… \$\endgroup\$ – Sjoerd Job Postmus May 15 '16 at 6:34
4
\$\begingroup\$
  1. As Sjoerd points out in comments, the timestamp generation and checking logic in this code is reimplementing Django's built-in TimestampSigner. Standard implementations are more likely to be correct (they've had more review and testing) and they are more likely to get fixed quickly if problems are discovered.

  2. Similarly, the serialization and deserialization logic in this code is reimplementing Django's built-in signing.dumps and signing.loads.

  3. decode_email_validation_key returns a string (the e-mail address) if verification is successful, or False if it fails. This is risky: it would be easy to forget to check the result. It would be better to raise an exception in case of failure. Then if you forget to check the exception, it doesn't open a security hole.

  4. The expiry time (here, 60*60*24*2) should have a name, and it should be defined in the application settings file, since you'll probably want to use it in the construction of the e-mail text to tell the user when the link expires.

Revised code (settings file):

from datetime import timedelta

EMAIL_KEY_EXPIRY_TIME = timedelta(days=2)

Revised code (view file):

from myapp.settings import EMAIL_KEY_EXPIRY_TIME
from django.core import signing

create_email_validation_key = signing.dumps

def decode_email_validation_key(key):
    return signing.loads(key, max_age=EMAIL_KEY_EXPIRY_TIME)
\$\endgroup\$
  • \$\begingroup\$ This is the system we use for our activation, and it's been great. I would suggest adding something else to the payload that the user/attacker wouldn't know (e.g. the user id, or some other private data). It's not likely that your signing "secret" will be exposed though, and you probably have bigger problems to worry about than faked activation in that case. \$\endgroup\$ – Andrew Backer May 15 '16 at 15:16
  • \$\begingroup\$ @GarethRees Thanks for this incredible feedback. I should have read the doc completely, but I was just looking to protect against changes. I'll revise the code and update the gist. \$\endgroup\$ – VertigoRay May 15 '16 at 15:34
  • \$\begingroup\$ @AndrewBacker It's just base64 that's being signed (think of it as more obfuscated and shorter than urlencode). If I was worried about an attacker figuring out my signing secret, should django devs be worried about the same thing since it's the same process for signing cookies? \$\endgroup\$ – VertigoRay May 15 '16 at 15:34
  • \$\begingroup\$ Yes, hence why I said you had bigger things to worry about than faked activation emails if someone found out your secret. Generally it is a good idea to include something the user can't know (like their id, or a salt field on their user) rather than just the publicly available info. That way you can't "activate" an account without also knowing something that is a secret on the server. In our case we have a separate key we use for this; i'm not sure why, probably historical reasons. I can't see a need for it. \$\endgroup\$ – Andrew Backer May 16 '16 at 2:09
  • \$\begingroup\$ @AndrewBacker Now that I'm letting django do all of the encoding (most updated revision of solution), I don't think even an ID is necessary. The payload is no longer just base64, and the whole thing is salted with the SECRET_KEY it's way more secure than it was. I did look at generating a nonce to include in the payload, but I feel the current method alleviates the need for even that. \$\endgroup\$ – VertigoRay May 16 '16 at 19:15

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.