Email Validation with Django and python-social-auth

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:

[code lang=”python”]
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
[/code]

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

[code lang=python]
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'
)
[/code]

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:

[code lang=python]
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')
[/code]

The

acquire_email

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

[code lang=python]
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})
[/code]

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

[code lang=html]
<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>
[/code]

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.

[code lang=python]
SOCIAL_AUTH_EMAIL_VALIDATION_FUNCTION = 'app.email.SendVerificationEmail'
SOCIAL_AUTH_EMAIL_VALIDATION_URL = '/email_verify_sent/'
[/code]

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:

[code lang=python]
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 <noreply@myapp.com>",
"to": ["recipient@email.address"],
}

email = EmailMultiAlternatives(**kwargs)
email.attach_alternative(emailHTML, "text/html")
email.send()
[/code]

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:

[code lang=text]
Partial pipeline can not resume
[/code]

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.

Step 4 – Relax with hot beverage of choice

Optional, but recommended.

Christopher Keefer

Christopher Keefer

Christopher Keefer is a Senior Software Engineer at Art+Logic. He generally spends his spare time on the computer too, so there isn't much hope for him.
Christopher Keefer

Latest posts by Christopher Keefer (see all)

Tags:

Creative Commons License

This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

14 Comments

  1. Ajeet

    Action in form is incomplete in this article. I am not able to send the verification code

    • Christopher Keefer

      Looks like there was a formatting issue that was cutting off some of the code samples – should be fixed now.

  2. Ashish

    Where should I add this monkey patch ?

    • Christopher Keefer

      You could place it anywhere you’d like, so long as you can be certain the monkey patch code will be the first to import social.utils from python-social-auth – as per usual with monkey-patching, existing references aren’t altered. I’d suggest heading to Stack Overflow if you need more help with monkey-patching in general.

      • Codey

        Thanks Christopher for this tutorial. You saved a few days :). I am new to Django and was hoping if you could provide some more information for monkey patching for this particular scenario. I read about Monkey Patching on SO and found some useful content however, I am still unable to figure it out.

        • Christopher Keefer

          The specifics will be different for every project, but here’s an approach that might work for you:

          1) Place the monkey patch code from step 3 into a file named `monkey.py`, in the application directory (so, if your Django app is named ‘myapp’, place it in the ‘myapp’ directory). Keep in mind that a Django project can have multiple ‘apps’.

          2) In the __init__.py file within that directory, add the line “`import monkey“`.

          The chances are fairly good that your application’s init will be called before any other references to the social library is made, thus successfully monkey-patching the library for your application’s use.

          • codey

            Thanks for ton Christopher. I have a big issue which is not allowing me to use the methods you have mentioned. I am using a custom user manager and via ModelBackend have enabled it. The problem is that when I register/login via this model none of the pipeline works. Via the PSA IRC channel I am told that Modelbackend does not execute the pipeline now if that is true how can get this code to run?

          • Christopher Keefer

            Sorry Codey, it sounds like your issue is outside the scope of the article – and the comments aren’t really the best place to address it either. I’d suggest taking your questions to stackoverflow, and maybe replying back with the link to your post so others can benefit from whatever answer you receive. Best of luck!

    • Christopher Keefer

      The method for fetching the session detailed above is the same as one of the two methods outlined in the Django docs you linked to (read the section that starts “If you’re using the django.contrib.sessions.backends.db backend …”)

      That said, nothing wrong with taking another approach if you’re so inclined.

  3. Ashish Gupta

    Shouldn’t you first log out the user who is currently logged in and continue the process ? I used this monkey-patch and it worked well in case of cross browser email verification. But in case, if a user is logged in it changes the attributes of logged in user

  4. Maor Levy

    Thanks Christopher for this tutorial. I have a question about the second step. When generating the signature, my session key is empty and I get error in the third step

    DoesNotExist at /complete/email/
    Session matching query does not exist.

    Any idea why the strategy.session.session_key is empty?

  5. A well wisher

    Thanks. This is pretty helpful. And it works. I think there is a bug though (perhaps a security bug).. if anyone gets hold of the email validation url, and clicks on it, they are automatically logged into the django app. As a logged in user, django app might expose sensitive information. How does one force login after email validation?

  6. Ankit Nayan

    I am facing a serious bug in email validation. If 2nd user signs up from the same browser as the 1st one while the 1st user has not validated the email. The new user never receives the mail. The mail is always send to the 1st user until he verifies the email. Seems like a pretty important bug. Can you please help?