SSO example for Django


(James Potter) #1

I have this successfully running in production, with a Discourse instance hosted on a separate subdomain.

First add the SSO secret key (configured in the Discourse admin panel) and the base URL for your instance to your settings.py:

DISCOURSE_BASE_URL = 'http://your-discourse-site.com'
DISCOURSE_SSO_SECRET = 'paste_your_secret_here'

Add a route in your urls.py, like this:

from my_project.apps.discourse import views

urlpatterns = patterns('',
    url(r'^discourse/sso$', views.sso),
)

Python 2.x

Add a new app called discourse to your project and paste this into views.py:


import base64
import hmac
import hashlib
import urllib

from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.conf import settings

from urlparse import parse_qs

@login_required
def sso(request):
    payload = request.GET.get('sso')
    signature = request.GET.get('sig')

    if payload is None or signature is None:
        return HttpResponseBadRequest('No SSO payload or signature. Please contact support if this problem persists.')

    ## Validate the payload

    try:
        payload = urllib.unquote(payload)
        decoded = base64.decodestring(payload)
        assert 'nonce' in decoded
        assert len(payload) > 0
    except AssertionError:
        return HttpResponseBadRequest('Invalid payload. Please contact support if this problem persists.')

    key = str(settings.DISCOURSE_SSO_SECRET) # must not be unicode
    h = hmac.new(key, payload, digestmod=hashlib.sha256)
    this_signature = h.hexdigest()

    if not hmac.compare_digest(this_signature, str(signature)):
        return HttpResponseBadRequest('Invalid payload. Please contact support if this problem persists.')

    ## Build the return payload

    qs = parse_qs(decoded)
    params = {
        'nonce': qs['nonce'][0],
        'email': request.user.email,
        'external_id': request.user.id,
        'username': request.user.username,
        'require_activation': 'true'
    }

    return_payload = base64.encodestring(urllib.urlencode(params))
    h = hmac.new(key, return_payload, digestmod=hashlib.sha256)
    query_string = urllib.urlencode({'sso': return_payload, 'sig': h.hexdigest()})

    ## Redirect back to Discourse

    url = '%s/session/sso_login' % settings.DISCOURSE_BASE_URL
    return HttpResponseRedirect('%s?%s' % (url, query_string))

Python 3

Add a new app called discourse to your project and paste this into views.py:


import base64
import hmac
import hashlib
from urllib import parse

from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.conf import settings

@login_required
def sso(request):
    payload = request.GET.get('sso')
    signature = request.GET.get('sig')

    if payload is None or signature is None:
        return HttpResponseBadRequest('No SSO payload or signature. Please contact support if this problem persists.')

    ## Validate the payload

    try:
        payload = bytes(parse.unquote(payload), encoding='utf-8')
        decoded = base64.decodestring(payload).decode('utf-8')
        assert 'nonce' in decoded
        assert len(payload) > 0
    except AssertionError:
        return HttpResponseBadRequest('Invalid payload. Please contact support if this problem persists.')

    key = bytes(settings.DISCOURSE_SSO_SECRET, encoding='utf-8') # must not be unicode
    h = hmac.new(key, payload, digestmod=hashlib.sha256)
    this_signature = h.hexdigest()

    if not hmac.compare_digest(this_signature, signature):
        return HttpResponseBadRequest('Invalid payload. Please contact support if this problem persists.')

    ## Build the return payload

    qs = parse.parse_qs(decoded)
    params = {
        'nonce': qs['nonce'][0],
        'email': request.user.email,
        'external_id': request.user.id,
        'username': request.user.username,
        'require_activation': 'true'
    }

    return_payload = base64.encodestring(bytes(parse.urlencode(params), 'utf-8'))
    h = hmac.new(key, return_payload, digestmod=hashlib.sha256)
    query_string = parse.urlencode({'sso': return_payload, 'sig': h.hexdigest()})

    ## Redirect back to Discourse

    url = '%s/session/sso_login' % settings.DISCOURSE_BASE_URL
    return HttpResponseRedirect('%s?%s' % (url, query_string))

And bobs your uncle.

Edit: views.py has been updated, thanks to @pcorsaro and @longshun for their fixes.

Edit: fix for possible timing attack vulnerability, thanks @dknl and @jbzdak


RuntimeError Bad signature for payload during SSO login and signup
Integrating discourse into Django site?
A django app for easy sso simulation
(Cobe) #2

Thank you !
I have a try, run into one problem with unicode and 'hmac’
like below:

  Python HMAC: TypeError: character mapping must return integer, None or unicode

So I make some change under python2.7 , meanwhile , I think it will run well under python 3+

I changed like:

key_uni = settings.DISCOURSE_SSO_SECRET
key = str(key_uni)

it is a temp fix, for python 2.7


(Peter Corsaro) #3

This worked great until version 1.2.0.beta3

They added another URL param that comes back in the payload, return_sso_url, so this line no longer works:

I did this to fix it:

from urlparse import parse_qs
...
decoded = base64.decodestring(payload)
qs = parse_qs(decoded)
params = {
    'nonce': qs['nonce'][0],
    'email': request.user.email,
    'external_id': request.user.id,
    'username': request.user.username,
}

(Sam Saffron) #5

I made it a wiki so you should be able to edit it now :slight_smile:


#6

I’m trying to do a SSO for websites.
The first step confused me:
First add the SSO secret key (configured in the Discourse admin panel) and the base URL for your instance to your settings.py . Does the SSO based on the Discourse APP or what?
Can you show me how to add the SSO cecret key.
Thanks a lot !


(James Potter) #7

@done what steps have you taken so far?


#8

@drpancake

I did not start it yet.

I’m searching for a solution for my issue, which my two websites use the same user system(The user system stores in the third lib).
Now a SSO needed. But I don’t know how to start.
This SSO example needs to use the “Discourse” APP or what ? Am I right ?

Any suggestions ?
Thanks


(James Potter) #9

It creates a “discourse” app within Django. I’d recommend reading through
the Django docs more before you attempt this.


#10

OK, I’ll read more docs first.
Thanks a lot.


(Mike Nielsen) #11

Hello!

Thanks for a great example, however, I don’t suppose anyone has an example that works for Python 3 and Django 1.8+?

I’ve been trying to change the code to reflect the changes, but I simply cannot figure it out! :slightly_smiling:

It’s definitely rooted in the changes in unicode handling in Python 3, it’s especially around

decoded = base64.decodestring(payload)

I’ve tried with some encode/decode magic, but that breaks more stuff in the code further on.

Thanks in advance!


(Tadej Novak) #12

I have a working Discourse SSO with Django. I used python social auth and wrote a plugin for it. When I have time I will try to make a python package out of it, until then, you can look at backend/fmf/discourse/auth at master · FMF-studenti/backend · GitHub.


(Avorio) #13

Hi @Mike_Nielsen! I’m encountering the exact same issue right now.

Did you manage to find a fix?


(Mike Nielsen) #14

Unfortunately, I did not manage to get this to work and I’ve dropped my plans of SSO and I’m instead looking into an OAuth2 solution to allow login between our website and discourse (and linking accounts).


(Avorio) #15

And how is that going, @Mike_Nielsen?

I’ve started using django-allauth. Is that the path you’ve also chosen?


(Owen Brasier) #16

Thanks for this! This is awesome…

One question though, I want to send additional metadata about users. Things like their real name and if they are staff on the source site.

Here is my code:

user_profile = UserProfile.objects.get(user=request.user)
params = {
    'nonce' = qs['nonce'][0],
    'email' = request.user.email,
    'external_id': request.user.id,
    'username': request.user.username,
    'name': user_profile.name,
    'staff': user_profile.staff,
}

Where name is a string and staff is a boolean, I’d like to add their real name to their discourse profiles and automatically add staff to a specific group in Discourse.

Is that possible? I can do it manually anyway but it would be nice.


(Kane York) #17

Use ‘moderator’ or ‘admin’ instead of ‘staff’.


(Allen Lee) #18

I just finished adding a Django SSO endpoint for Discourse. The main changes that needed to be made to the Python 3 version listed in the original reply were to replace usages of urllib and urlparse with urllib.parse - after that everything worked like a charm!

I’ve made a github gist for the modified view as well:


(Jacek Bzdak) #19

Isn’t this susceptible to timing attacks:

if this_signature != signature:
        return HttpResponseBadRequest('Invalid payload. Please contact support if this problem persists.')

Technically since == does not do constant time compare, attacker could guess how many chars of singature he has right by measuring response time. Since this is python I’d say that risk is low.

But to be on the safe side, you might want to use constant_time_compare.

from django.utils.crypto import constant_time_compare
if constant_time_compare(this_signature, self.cleaned_data['sig']): 
 ...

(James Potter) #20

Wow, I’d never heard of this kind of attack.

In this case wouldn’t Django’s processing time (somewhat unpredictable) combined with network response times (very unpredictable) add too much noise for this attack to be feasible?


(Jacek Bzdak) #21

I’d wouldn’t panic, unless you are authenticating some very high-profile site. Timing attacks are not easiest one to pull of. But technically white noise could be filtered out by re-playing request mulitple time and then averaging.

Some information is here: Google Groups .