blog

Photo by Onlineprinters on Unsplash

Email Validation with Django and python-social-auth

by

So you’re asking yourself – robot or real person?

When it comes to user accounts, the standard litmus test is email validation. Besides the immediate benefits – of offering us a straightforward unique identifier for users, and making it more difficult to automate creating a mass of accounts on our service – by requiring that each account have an email address and interact therewith to confirm the addresses validity, it also offers us the chance to associate a known-working email account with a user account. This is important for transactional emails such as password resets or for potential two-factor authentication use… and if you’re a little less ethical, for sending marketing desirable and informative emails about interesting products and services.

"But," you whine piteously, "the whole reason I integrated python-social-auth into my project was to let the OAuth providers look after this sort of thing for me!"

Tough rocks. Twitter ain’t gonna give you their users’ email addresses. Just look at all those angry comments. If their whining didn’t get through to Twitter, yours isn’t going to do the trick either. Besides, eventually you’ll probably want to allow the user to login the good old-fashioned way, with a username (which may or may not be their email address) and a password – in which case, you’ll want to handle validating their email address yourself anyways.

So, given that we’ve already integrated python-social-auth into our Django project (some of the same principles will apply to other frameworks, but many of the details presented herein are specified to Django) – let’s get email validation working as part of our user creation/authentication pipeline.

The python-social-auth docs have a rather threadbare section on email validation, so we won’t be reproducing the details discussed there. Instead, let’s take a look at some examples of what an implementation should look like, and some workarounds for issues encountered.

Step 1 – Acquiring the Email

To start, we’ll need to be able to send emails of our own, so drop the details of your email server/service into settings.py:

EMAIL_HOST = 'smtp.yourmailserver.com'
EMAIL_HOST_USER = 'smtp-username'
EMAIL_HOST_PASSWORD = 'smtp-password'
# Port 587 is the default port for smtp submission, but some will use 25, 465, or an arbitrary port #
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_NOREPLY = "noreply@yourserver.com"  # Not required, but potentially useful to have defined

We’ll also need to have setup our social auth pipeline. An example:

SOCIAL_AUTH_PIPELINE = (
    'social.pipeline.social_auth.social_details',
    'social.pipeline.social_auth.social_uid',
    'social.pipeline.social_auth.auth_allowed',
    'social.pipeline.social_auth.social_user',
    'social.pipeline.user.get_username',
    'app.auth.user_details.require_email',
    'social.pipeline.mail.mail_validation',
    'social.pipeline.social_auth.associate_by_email',
    'social.pipeline.social_auth.associate_user',
    'social.pipeline.social_auth.load_extra_data'
)

The exact makeup and order of this pipeline will depend on the needs of your project. The elements of interest to us for the sake of setting up email validation are the lines app.auth.user_details.require_email and social.pipeline.mail.mail_validation. Note also that we include these lines before associate_by_email – we don’t want to associate users by email until we’ve confirmed that the user actually owns the email account they’ve entered, or they could gain control over someone’s else’s account (see the warnings regarding associate_by_email in the python-social-auth docs for more details).

Returning to the two entries in the pipeline I’ve called out – the former is a partial pipeline function that we create ourselves, to decide whether we need to ask the user for their email at this point in the process. This allows us to proceed seamlessly when we’re logging the user in with a service that provides the email (like Google+ or Facebook), while allowing us to present the user with a form to fill in the necessary details otherwise. This fucntion could look something like:

from django.shortcuts import redirect
from social.pipeline.partial import partial
from social.pipeline.user import USER_FIELDS
def require_email(strategy, details, user=None, is_new=False, *args, **kwargs):
    backend = kwargs.get('backend')
    if user and user.email:
        return # The user we're logging in already has their email attribute set
    elif is_new and and not details.get('email'):
        # If we're creating a new user, and we can't find the email in the details
        # we'll attempt to request it from the data returned from our backend strategy
        userEmail = strategy.request_data().get('email')
        if userEmail:
            details['email'] = userEmail
        else:
            # If there's no email information to be had, we need to ask the user to fill it in
            # This should redirect us to a view
            return redirect('acquire_email')

The acquire_email view we’re redirecting to can look something like the following:

def acquire_email(request, template_name="email/acquire.html"):
    """
    Request email for the create user flow for logins that don't specify their email address.
    """
    backend = request.session['partial_pipeline']['backend']
    return render(request, template_name, {"backend": backend})

And somewhere within the template, add a form that will submit the required data into the pipeline:

<form action="{% url "social:complete" backend=backend %}" method="post" role="form">
    {% csrf_token %}
    <div class="form-group">
        <label class="control-label" for="email">Email address:</label>
        <input class="form-control" id="email" type="email" name="email" value="" required />
    </div>
    <button type="submit">Submit</button>
</form>

That’s step 1 complete…

Step 2 – Validating the email

The latter of the two pipeline functions called out is the email validation partial pipeline discussed in the docs. In order to effectively use it, we need to include a few additional entries in settings.py, as well as create some further custom functions.

SOCIAL_AUTH_EMAIL_VALIDATION_FUNCTION = 'app.email.SendVerificationEmail'
SOCIAL_AUTH_EMAIL_VALIDATION_URL = '/email_verify_sent/'

The latter of these two is the url that the user will be redirected to once the validation email has been sent – you should add a view and url entry for this as per usual. For the former, we’ll need to create a function that actually sends the verifiction email. Here we’ve created it in a module email within the application app, but those are details that can be altered according to your project’s needs.

The function itself needs to build the email text you’re going to send, and include some details that will enable the email validation partial_pipeline to resume. We’ll be including more than the standard recommended details for reasons we’ll discuss momentarily.

Your email verification function should look something like the following:

from django.core import signing
from django.core.mail import EmailMultiAlternatives
def SendVerificationEmail(strategy, backend, code):
    """
    Send an email with an embedded verification code and the necessary details to restore the required session
    elements to complete the verification and sign-in, regardless of what browser the user completes the
    verification from.
    """
    signature = signing.dumps({"session_key": strategy.session.session_key, "email": code.email},
                              key=settings.EMAIL_SECRET_KEY)
    verifyURL = "{0}?verification_code={1}&signature={2}".format(
        reverse('social:complete', args=(backend.name,)),
        code.code, signature)
    verifyURL = strategy.request.build_absolute_uri(verifyURL)
    emailHTML = # Include your function that returns an html string here
    emailText = """Welcome to MyApp!
In order to login with your new user account, you need to verify your email address with us.
Please copy and paste the following into your browser's url bar: {verifyURL}
""".format(verifyURL=verifyURL)
    kwargs = {
        "subject": "Verify Your Account",
        "body": emailText,
        "from_email": "MyApp ",
        "to": ["recipient@email.address"],
    }
    email = EmailMultiAlternatives(**kwargs)
    email.attach_alternative(emailHTML, "text/html")
    email.send()

You’ll notice we used Django’s EmailMultiAlternatives to attach an html version of the email. You can find more details on sending email with Django in the Django docs.

You’ll also likely have noticed that we’re including some signed details in that verification email string we’re sending. What details are for what purpose? Let’s break it down, and discuss some necessary evil.

  • code.code is the email verification code generated by the mail validation pipeline.
  • code.email is the email address we’re attempting to verify.
  • signature is our own addition to the process – you’ll notice it include both the email we’re trying to verify, and a session_key, which contains the session_key stored in our current backend strategy. Finally, key is the secret we’re using to sign this signature with, to prevent tampering – you can find more details on signing in the Django docs.

Step 3 – Validating the email from any browser

What do we need that signature for, though? This may have been remedied in whatever version of python-social-auth your using, but at the time of this writing (July 2015), if your receiving user were to click/paste the link you included in your validation email into a different browser, they’d likely be confronted with a 500 error page, and you’ll have an entry in your log:

Partial pipeline can not resume

In fact, if they were to click that link in the same browser, but after clearing their cache, or after a long period of time causes their current session to expire, they’d run into the same problem. It turns out that python-social-auth relies on the session to store the details of a partial pipeline – which means that if a user tries to resume the pipeline (as in the case of validating their email) from a different browser, it will fail.

The expected user flow here is that the user should be able to validate their email address from any browser, indeed, from any device that can access their email account. They shouldn’t need to use the same browser within the arbitrary lifetime of their current server session. Let’s fix that.

Here comes the necessary evil – we’ll need to monkey-patch social.utils.partial_pipeline_data in order to allow us to restore the required session data from the stored session in order to resume the pipeline. Our solution takes advantage of the standard storage of Django sessions in a database table, allowing us to interact with the stored sessions in the same way we do other tables.

We confirm the cryptographic signature on the details within the url parameter, fetch the necessary table row for the session using the session_key, get the needed fields from the row, and presto, we can now properly resume the pipeline.

# Monkey patching - an occasionally necessary evil.
from social import utils
from social.exceptions import InvalidEmail
from django.core import signing
from django.core.signing import BadSignature
from django.contrib.sessions.models import Session
from django.conf import settings
def partial_pipeline_data(backend, user=None, *args, **kwargs):
    """
    Monkey-patch utils.partial_pipeline_data to enable us to retrieve session data by signature key in request.
    This is necessary to allow users to follow a link in an email to validate their account from a different
    browser than the one they were using to sign up for the account, or after they've closed/re-opened said
    browser and potentially flushed their cookies. By adding the session key to a signed base64 encoded signature
    on the email request, we can retrieve the necessary details from our Django session table.
    We fetch only the needed details to complete the pipeline authorization process from the session, to prevent
    nefarious use.
    """
    data = backend.strategy.request_data()
    if 'signature' in data:
        try:
            signed_details = signing.loads(data['signature'], key=settings.EMAIL_SECRET_KEY)
            session = Session.objects.get(pk=signed_details['session_key'])
        except BadSignature, Session.DoesNotExist:
            raise InvalidEmail(backend)
        session_details = session.get_decoded()
        backend.strategy.session_set('email_validation_address', session_details['email_validation_address'])
        backend.strategy.session_set('next', session_details.get('next'))
        backend.strategy.session_set('partial_pipeline', session_details['partial_pipeline'])
        backend.strategy.session_set(backend.name + '_state', session_details.get(backend.name + '_state'))
        backend.strategy.session_set(backend.name + 'unauthorized_token_name',
                                     session_details.get(backend.name + 'unauthorized_token_name'))
    partial = backend.strategy.session_get('partial_pipeline', None)
    if partial:
        idx, backend_name, xargs, xkwargs = \
            backend.strategy.partial_from_session(partial)
        if backend_name == backend.name:
            kwargs.setdefault('pipeline_index', idx)
            if user:  # don't update user if it's None
                kwargs.setdefault('user', user)
            kwargs.setdefault('request', backend.strategy.request_data())
            xkwargs.update(kwargs)
            return xargs, xkwargs
        else:
            backend.strategy.clean_partial_pipeline()
utils.partial_pipeline_data = partial_pipeline_data
Viewport
Window

Step 4 – Relax with hot beverage of choice

Optional, but recommended.

+ more