From f5bf40bd9b67d4b00023cfbac72233ffb9753064 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 2 Nov 2016 02:43:16 +0000 Subject: [PATCH] Proof of concept discourse authentication --- PyRIGS/settings.py | 60 +++++++++++++++++++++++++ PyRIGS/urls.py | 1 + RIGS/discourse/__init__.py | 0 RIGS/discourse/discourse.py | 89 +++++++++++++++++++++++++++++++++++++ RIGS/discourse/sso.py | 46 +++++++++++++++++++ requirements.txt | 8 +++- 6 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 RIGS/discourse/__init__.py create mode 100644 RIGS/discourse/discourse.py create mode 100644 RIGS/discourse/sso.py diff --git a/PyRIGS/settings.py b/PyRIGS/settings.py index 2e82807e..8eee78c7 100644 --- a/PyRIGS/settings.py +++ b/PyRIGS/settings.py @@ -58,6 +58,7 @@ INSTALLED_APPS = ( 'captcha', 'widget_tweaks', 'raven.contrib.django.raven_compat', + 'social.apps.django_app.default', ) MIDDLEWARE_CLASSES = ( @@ -73,6 +74,63 @@ MIDDLEWARE_CLASSES = ( 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) +AUTHENTICATION_BACKENDS = ( + 'RIGS.discourse.discourse.DiscourseAuth', + 'django.contrib.auth.backends.ModelBackend', +) + +# Environ +# DISCOURSE_HOST='http://localhost:4000' +# DISCOURSE_SSO_SECRET='ABCDEFGHIJKLMNOP' + +SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/rigboard' +SOCIAL_AUTH_LOGIN_URL = '/' + +SOCIAL_AUTH_PIPELINE = ( + # Get the information we can about the user and return it in a simple + # format to create the user instance later. On some cases the details are + # already part of the auth response from the provider, but sometimes this + # could hit a provider API. + 'social.pipeline.social_auth.social_details', + + # Get the social uid from whichever service we're authing thru. The uid is + # the unique identifier of the given user in the provider. + 'social.pipeline.social_auth.social_uid', + + # Verifies that the current auth process is valid within the current + # project, this is were emails and domains whitelists are applied (if + # defined). + 'social.pipeline.social_auth.auth_allowed', + + # Checks if the current social-account is already associated in the site. + 'social.pipeline.social_auth.social_user', + + # Make up a username for this person, appends a random string at the end if + # there's any collision. + # 'social.pipeline.user.get_username', + + # Send a validation email to the user to verify its email address. + # Disabled by default. + # 'social.pipeline.mail.mail_validation', + + # Associates the current social details with another user account with + # a similar email address. Disabled by default. + 'social.pipeline.social_auth.associate_by_email', + + # Create a user account if we haven't found one yet. + #'social.pipeline.user.create_user', + + # Create the record that associated the social account with this user. + #'social.pipeline.social_auth.associate_user', + + # Populate the extra_data field in the social record with the values + # specified by settings (and the default ones like access_token, etc). + 'social.pipeline.social_auth.load_extra_data', + + # Update the user record with any changed info from the auth service. + 'social.pipeline.user.user_details', +) + ROOT_URLCONF = 'PyRIGS.urls' WSGI_APPLICATION = 'PyRIGS.wsgi.application' @@ -202,6 +260,8 @@ TEMPLATE_CONTEXT_PROCESSORS = ( "django.core.context_processors.tz", "django.core.context_processors.request", "django.contrib.messages.context_processors.messages", + 'social.apps.django_app.context_processors.backends', + 'social.apps.django_app.context_processors.login_redirect', ) diff --git a/PyRIGS/urls.py b/PyRIGS/urls.py index 9821ae20..731a0b6d 100644 --- a/PyRIGS/urls.py +++ b/PyRIGS/urls.py @@ -18,6 +18,7 @@ urlpatterns = patterns('', url('^user/', include('registration.backends.default.urls')), url(r'^admin/', include(admin.site.urls)), + url('', include('social.apps.django_app.urls', namespace='social')) ) if settings.DEBUG: diff --git a/RIGS/discourse/__init__.py b/RIGS/discourse/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/RIGS/discourse/discourse.py b/RIGS/discourse/discourse.py new file mode 100644 index 00000000..dea26039 --- /dev/null +++ b/RIGS/discourse/discourse.py @@ -0,0 +1,89 @@ +from __future__ import unicode_literals +import os +from social.backends.base import BaseAuth +from social.exceptions import AuthException + +from .sso import DiscourseSSO + + +class DiscourseAssociation(object): + """ Use Association model to save the nonce by force. """ + + def __init__(self, handle, secret='', issued=0, lifetime=0, assoc_type=''): + self.handle = handle # as nonce + self.secret = secret.encode() # not use + self.issued = issued # not use + self.lifetime = lifetime # not use + self.assoc_type = assoc_type # as state + + +class DiscourseAuth(BaseAuth): + """Discourse authentication backend""" + name = 'discourse' + secret = os.environ['DISCOURSE_SSO_SECRET'] + host = os.environ['DISCOURSE_HOST'] + + EXTRA_DATA = [ + ('username', 'username'), + ('email', 'email'), + ('external_id', 'external_id') + ] + + sso = DiscourseSSO(secret) + + def get_and_store_nonce(self, url): + # Create a nonce + nonce = self.strategy.random_string(64) + # Store the nonce + association = DiscourseAssociation(nonce) + self.strategy.storage.association.store(url, association) + return nonce + + def get_nonce(self, nonce): + try: + return self.strategy.storage.association.get( + server_url=self.host, + handle=nonce + )[0] + except IndexError: + pass + + def remove_nonce(self, nonce_id): + self.strategy.storage.association.remove([nonce_id]) + + def get_user_id(self, details, response): + """Return current user id.""" + + return int(response['external_id']) + + def get_user_details(self, response): + """Return user basic information (id and email only).""" + + return {'username': response['username'], + 'email': response['email'], + 'fullname': response['name'].replace('+', ' ') if 'name' in response else '', + 'first_name': '', + 'last_name': ''} + + def auth_url(self): + """Build and return complete URL.""" + nonce = self.get_and_store_nonce(self.host) + + return self.host + self.sso.build_login_URL(nonce, self.redirect_uri) + + def auth_complete(self, *args, **kwargs): + """Completes login process, must return user instance.""" + + if not self.sso.validate(self.data['sso'], self.data['sig']): + raise Exception("Someone wants to hack us!") + + nonce = self.sso.get_nonce(self.data['sso']) + nonce_obj = self.get_nonce(nonce) + if nonce_obj: + self.remove_nonce(nonce_obj.id) + else: + raise Exception("Nonce does not match!") + + kwargs.update({'response': self.sso.get_data( + self.data['sso']), 'backend': self}) + return self.strategy.authenticate(*args, **kwargs) diff --git a/RIGS/discourse/sso.py b/RIGS/discourse/sso.py new file mode 100644 index 00000000..9b477693 --- /dev/null +++ b/RIGS/discourse/sso.py @@ -0,0 +1,46 @@ +import urllib +from hashlib import sha256 +import hmac +from base64 import b64decode, b64encode + + +class DiscourseSSO: + def __init__(self, secret_key): + self.__secret_key = secret_key + + def validate(self, payload, sig): + payload = urllib.unquote(payload) + computed_sig = hmac.new( + self.__secret_key.encode(), + payload.encode(), + sha256 + ).hexdigest() + print(type(computed_sig), type(sig)) + return hmac.compare_digest(unicode(computed_sig), sig) + + def get_nonce(self, payload): + payload = b64decode(urllib.unquote(payload)).decode() + d = dict(nonce.split("=") for nonce in payload.split('&')) + + if 'nonce' in d and d['nonce'] != '': + return d['nonce'] + else: + raise Exception("Nonce could not be found in payload") + + def get_data(self, payload): + payload = urllib.unquote(b64decode(urllib.unquote(payload)).decode()) + d = dict(data.split("=") for data in payload.split('&')) + + return d + + def build_login_URL(self, nonce, redirect_uri): + data = { + 'nonce': nonce, + 'return_sso_url': redirect_uri + } + + payload = urllib.urlencode(data) + payload = b64encode(payload.encode()) + sig = hmac.new(self.__secret_key.encode(), payload, sha256).hexdigest() + + return '/session/sso_provider?' + urllib.urlencode({'sso': payload, 'sig': sig}) diff --git a/requirements.txt b/requirements.txt index 995d2afd..19a89b77 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,17 +12,23 @@ django-widget-tweaks==1.3 gunicorn==19.3.0 icalendar==3.9.0 lxml==3.4.4 +oauthlib==2.0.0 Pillow==2.8.1 psycopg2==2.6 Pygments==2.0.2 +PyJWT==1.4.2 PyPDF2==1.24 python-dateutil==2.4.2 +python-openid==2.2.5 +python-social-auth==0.2.21 pytz==2015.4 raven==5.8.1 reportlab==3.1.44 +requests==2.11.1 +requests-oauthlib==0.7.0 selenium==2.53.6 simplejson==3.7.2 -six==1.9.0 +six==1.10.0 sqlparse==0.1.15 static3==0.6.1 svg2rlg==0.3