From 6b7889d80e5cc5b9129b0543bb2d010ea044a44d Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 11 Apr 2017 00:28:35 +0100 Subject: [PATCH] Add copy of Discourse-Auth app, Probably should split into separate repo at some point, but committing here so I don't lose it --- DiscourseAuth/__init__.py | 0 DiscourseAuth/admin.py | 5 + DiscourseAuth/migrations/0001_initial.py | 22 ++ .../migrations/0002_auto_20170126_1513.py | 19 ++ .../migrations/0003_auto_20170126_1621.py | 19 ++ .../migrations/0004_discourseuserlink.py | 24 ++ .../migrations/0005_auto_20170128_1707.py | 19 ++ DiscourseAuth/migrations/__init__.py | 0 DiscourseAuth/models.py | 47 ++++ .../DiscourseAuth/associate_user.html | 14 + .../DiscourseAuth/disassociate_user.html | 16 ++ DiscourseAuth/urls.py | 11 + DiscourseAuth/views.py | 253 ++++++++++++++++++ PyRIGS/settings.py | 7 +- PyRIGS/urls.py | 2 + RIGS/forms.py | 16 ++ 16 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 DiscourseAuth/__init__.py create mode 100644 DiscourseAuth/admin.py create mode 100644 DiscourseAuth/migrations/0001_initial.py create mode 100644 DiscourseAuth/migrations/0002_auto_20170126_1513.py create mode 100644 DiscourseAuth/migrations/0003_auto_20170126_1621.py create mode 100644 DiscourseAuth/migrations/0004_discourseuserlink.py create mode 100644 DiscourseAuth/migrations/0005_auto_20170128_1707.py create mode 100644 DiscourseAuth/migrations/__init__.py create mode 100644 DiscourseAuth/models.py create mode 100644 DiscourseAuth/templates/DiscourseAuth/associate_user.html create mode 100644 DiscourseAuth/templates/DiscourseAuth/disassociate_user.html create mode 100644 DiscourseAuth/urls.py create mode 100644 DiscourseAuth/views.py diff --git a/DiscourseAuth/__init__.py b/DiscourseAuth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/DiscourseAuth/admin.py b/DiscourseAuth/admin.py new file mode 100644 index 00000000..bf31b4cd --- /dev/null +++ b/DiscourseAuth/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin +from models import AuthAttempt, DiscourseUserLink + +admin.site.register(AuthAttempt) +admin.site.register(DiscourseUserLink) diff --git a/DiscourseAuth/migrations/0001_initial.py b/DiscourseAuth/migrations/0001_initial.py new file mode 100644 index 00000000..bc9cac66 --- /dev/null +++ b/DiscourseAuth/migrations/0001_initial.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import DiscourseAuth.models + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AuthAttempt', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('nonce', models.CharField(default=DiscourseAuth.models.gen_nonce, max_length=25)), + ('created', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/DiscourseAuth/migrations/0002_auto_20170126_1513.py b/DiscourseAuth/migrations/0002_auto_20170126_1513.py new file mode 100644 index 00000000..d0001a71 --- /dev/null +++ b/DiscourseAuth/migrations/0002_auto_20170126_1513.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('DiscourseAuth', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='authattempt', + old_name='created', + new_name='created_at', + ), + ] diff --git a/DiscourseAuth/migrations/0003_auto_20170126_1621.py b/DiscourseAuth/migrations/0003_auto_20170126_1621.py new file mode 100644 index 00000000..1fe57cce --- /dev/null +++ b/DiscourseAuth/migrations/0003_auto_20170126_1621.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('DiscourseAuth', '0002_auto_20170126_1513'), + ] + + operations = [ + migrations.RenameField( + model_name='authattempt', + old_name='created_at', + new_name='created', + ), + ] diff --git a/DiscourseAuth/migrations/0004_discourseuserlink.py b/DiscourseAuth/migrations/0004_discourseuserlink.py new file mode 100644 index 00000000..54e292a6 --- /dev/null +++ b/DiscourseAuth/migrations/0004_discourseuserlink.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('DiscourseAuth', '0003_auto_20170126_1621'), + ] + + operations = [ + migrations.CreateModel( + name='DiscourseUserLink', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('discourse_user_id', models.IntegerField()), + ('django_user', models.OneToOneField(to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/DiscourseAuth/migrations/0005_auto_20170128_1707.py b/DiscourseAuth/migrations/0005_auto_20170128_1707.py new file mode 100644 index 00000000..becf15c3 --- /dev/null +++ b/DiscourseAuth/migrations/0005_auto_20170128_1707.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('DiscourseAuth', '0004_discourseuserlink'), + ] + + operations = [ + migrations.AlterField( + model_name='discourseuserlink', + name='discourse_user_id', + field=models.IntegerField(unique=True), + ), + ] diff --git a/DiscourseAuth/migrations/__init__.py b/DiscourseAuth/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/DiscourseAuth/models.py b/DiscourseAuth/models.py new file mode 100644 index 00000000..dbb580ee --- /dev/null +++ b/DiscourseAuth/models.py @@ -0,0 +1,47 @@ +import uuid +from django.db import models + +from datetime import datetime, timedelta + +from django.conf import settings + + +class AuthAttemptManager(models.Manager): + expiryMinutes = 10 + + def get_acceptable(self): + oldestAcceptableNonce = datetime.now() - timedelta(minutes=self.expiryMinutes) + return super(AuthAttemptManager, self).get_queryset().filter(created__gte=oldestAcceptableNonce) + + def purge_unacceptable(self): + oldestAcceptableNonce = datetime.now() - timedelta(minutes=self.expiryMinutes) + super(AuthAttemptManager, self).get_queryset().filter(created__lt=oldestAcceptableNonce).delete() + + +def gen_nonce(): + # return "THISISANONCETHATWEWILLREUSE" + return uuid.uuid4() + + +class AuthAttempt(models.Model): + nonce = models.CharField(max_length=25, default=gen_nonce) + created = models.DateTimeField(auto_now=True) + + + + objects = AuthAttemptManager() + + def __str__(self): + return "AuthAttempt at " + str(self.created) + + +class DiscourseUserLink(models.Model): + django_user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ) + + discourse_user_id = models.IntegerField(unique=True) + + def __str__(self): + return "{} - {}".format(self.discourse_user_id, str(self.django_user)) diff --git a/DiscourseAuth/templates/DiscourseAuth/associate_user.html b/DiscourseAuth/templates/DiscourseAuth/associate_user.html new file mode 100644 index 00000000..ba2c59d2 --- /dev/null +++ b/DiscourseAuth/templates/DiscourseAuth/associate_user.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Associate User" %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} +

You are currently logged in to django as "{{ djangouser }}". If this isn't you please log out

+

If you would like to link Discourse account "{{ discourseuser }}" to your django account, click below. This will remove any existing links.

+ +
+ +{% endblock %} \ No newline at end of file diff --git a/DiscourseAuth/templates/DiscourseAuth/disassociate_user.html b/DiscourseAuth/templates/DiscourseAuth/disassociate_user.html new file mode 100644 index 00000000..7f8ac514 --- /dev/null +++ b/DiscourseAuth/templates/DiscourseAuth/disassociate_user.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Associate User" %}{% endblock %} + +{% block content %} +{% if haslink %} +
+ {% csrf_token %} +

Your account is currently linked to a Discourse account. To remove this link, click below. You will no longer be able to login using Discourse.

+ +
+{% else %} +

Your account is not currently linked to Discourse.

+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/DiscourseAuth/urls.py b/DiscourseAuth/urls.py new file mode 100644 index 00000000..ec3297b3 --- /dev/null +++ b/DiscourseAuth/urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import patterns, url + +from views import StartDiscourseAuth, ContinueDiscourseAuth, NewDiscourseUser, AssociateDiscourseUser, DisassociateDiscourseUser + +urlpatterns = patterns('DiscourseAuth', + url(r'^start/$', StartDiscourseAuth.as_view(), name='start-auth'), + url(r'^continue/$', ContinueDiscourseAuth.as_view(), name='continue-auth'), + url(r'^new/$', NewDiscourseUser.as_view(), name='new-user'), + url(r'^associate/$', AssociateDiscourseUser.as_view(), name='associate-user'), + url(r'^disassociate/$', DisassociateDiscourseUser.as_view(), name='disassociate-user') +) diff --git a/DiscourseAuth/views.py b/DiscourseAuth/views.py new file mode 100644 index 00000000..4142aa5c --- /dev/null +++ b/DiscourseAuth/views.py @@ -0,0 +1,253 @@ + +from django.views.generic import View, FormView, TemplateView + +from django.http import HttpResponseRedirect +from django.contrib.auth import login +from django.contrib.auth.backends import ModelBackend +from django.core.urlresolvers import reverse + +from django.core.exceptions import PermissionDenied + +from django.contrib.auth import get_user_model +import urllib +from hashlib import sha256 +import hmac +from base64 import b64decode, b64encode +from django.db.models import Q + +from django.conf import settings + +from django.core.exceptions import ValidationError + +from django.contrib import messages + +from models import AuthAttempt, DiscourseUserLink + + +import time +from django.contrib.auth.decorators import login_required +from django.utils.decorators import method_decorator + +from registration.forms import RegistrationForm + + +class StartDiscourseAuth(View): + http_method_names = ['get'] + + def get(self, request, *args, **kwargs): + # Where do we want to go once authentication is complete? + request.session['discourse_next'] = request.GET.get('next', "/") + + # Generate random 'nonce' + attempt = AuthAttempt.objects.create() + nonce = attempt.nonce + + # Where do we want discourse to send authenticated users? + redirect_uri = reverse('continue-auth') + redirect_uri = request.build_absolute_uri(redirect_uri) + + # Data to sent to Discourse (payload) + data = { + 'nonce': nonce, + 'return_sso_url': redirect_uri + } + + payload = urllib.urlencode(data) + b64payload = b64encode(payload.encode()) + + sig = hmac.new(settings.DISCOURSE_SECRET_KEY.encode(), b64payload, sha256).hexdigest() + + return HttpResponseRedirect(settings.DISCOURSE_BASE_URL + '/session/sso_provider?' + urllib.urlencode({'sso': b64payload, 'sig': sig})) + + +class ContinueDiscourseAuth(View): + http_method_names = ['get'] + + def get(self, request, *args, **kwargs): + # Where do we want to go once authentication is complete? + nextUrl = request.session.get('discourse_next', "/") + + rawSig = request.GET['sig'] + rawSSO = request.GET['sso'] + + payload = urllib.unquote(rawSSO) + + computed_sig = hmac.new( + settings.DISCOURSE_SECRET_KEY.encode(), + payload.encode(), + sha256 + ).hexdigest() + + successful = hmac.compare_digest(computed_sig, rawSig.encode()) + + if not successful: # The signature doesn't match, not legit + raise ValidationError("Signature does not match, data has been manipulated") + + decodedPayload = urllib.unquote_plus(b64decode(urllib.unquote(payload)).decode()) + data = dict(data.split("=") for data in decodedPayload.split('&')) + + # Get the nonce that's been returned by discourse + returnedNonce = data['nonce'] + + try: # See if it's in the database + storedAttempt = AuthAttempt.objects.get_acceptable().get(nonce=returnedNonce) + except AuthAttempt.DoesNotExist: # If it's not, this attempt is not valid + raise ValidationError("Nonce does not exist in database") + + # Delete the nonce from the database - don't allow it to be reused + storedAttempt.delete() + # While we're at it, delete all the other expired attempts + AuthAttempt.objects.purge_unacceptable() + + # If we've got this far, the attempt is valid, so let's load user information + external_id = int(data['external_id']) + + # See if the user is already linked to a django user + try: + userLink = DiscourseUserLink.objects.get(discourse_user_id=external_id) + except DiscourseUserLink.DoesNotExist: + return self.linkNewUser(request, data) + + # Load the user + user = userLink.django_user + # Slightly hacky way to login user without calling authenticate() + user.backend = "%s.%s" % (ModelBackend.__module__, ModelBackend.__name__) + # Login the user + login(request, user) + + return HttpResponseRedirect(nextUrl) + + def linkNewUser(self, request, data): + # Great, let's save the new user info in the session + request.session['discourse_data'] = data + request.session['discourse_started_registration'] = time.time() + + if request.user is not None: + return HttpResponseRedirect(reverse('associate-user')) + else: + return HttpResponseRedirect(reverse('new-user')) + + +class SocialRegisterForm(RegistrationForm): + def __init__(self, *args, **kwargs): + super(SocialRegisterForm, self).__init__(*args, **kwargs) + self.fields.pop('password1') + self.fields.pop('password2') + + self.fields['email'].widget.attrs['readonly'] = True + + def clean_email(self): + initial = getattr(self, 'initial', None) + if(initial['email'] != self.cleaned_data['email']): + raise ValidationError("You cannot change the email") + + return initial['email'] + + +class AssociateDiscourseUser(TemplateView): + template_name = "DiscourseAuth/associate_user.html" + + @method_decorator(login_required) # Require user is logged in for associating their account + def dispatch(self, request, *args, **kwargs): + self.data = self.request.session.get('discourse_data', None) + timeStarted = self.request.session.get('discourse_started_registration', 0) + + max_reg_time = 20 * 60 # Seconds + + if timeStarted < (time.time() - max_reg_time): + raise PermissionDenied('The Discourse authentication has expired, please try again') + + if self.data is None: + raise PermissionDenied('Discourse authentication data is not present in this session') + + return super(AssociateDiscourseUser, self).dispatch(request, *args, **kwargs) + + def get(self, request, **kwargs): + return super(AssociateDiscourseUser, self).get(self, request, **kwargs) + + def get_context_data(self, *args, **kwargs): + c = super(AssociateDiscourseUser, self).get_context_data() + c['discourseuser'] = self.data['username'] + c['djangouser'] = self.request.user.username + + return c + + def post(self, request, **kwargs): + DiscourseUserLink.objects.filter(Q(django_user=request.user) | Q(discourse_user_id=self.data['external_id'])).delete() + DiscourseUserLink.objects.create(django_user=request.user, discourse_user_id=self.data['external_id']) + + messages.success(self.request, 'Accounts successfully linked, you are now logged in.') + + # Redirect them to the discourse login URL + nextUrl = "{}?next={}".format(reverse('start-auth'), request.session.get('discourse_next', "/")) + return HttpResponseRedirect(nextUrl) + + +class NewDiscourseUser(FormView): + template_name = 'registration/registration_form.html' + + def dispatch(self, request, *args, **kwargs): + self.data = self.request.session.get('discourse_data', None) + timeStarted = self.request.session.get('discourse_started_registration', 0) + + max_reg_time = 20 * 60 # Seconds + + if timeStarted < (time.time() - max_reg_time): + raise PermissionDenied('The Discourse authentication has expired, please try again') + + if self.data is None: + raise PermissionDenied('Discourse authentication data is not present in this session') + + return super(NewDiscourseUser, self).dispatch(request, *args, **kwargs) + + def get_initial(self): + data = self.data + initialForm = { + 'username': data['username'], + 'email': data['email'], + 'name': data['name'] + } + + return initialForm + + def get_form_class(self): + if settings.DISCOURSE_REGISTRATION_FORM: + return settings.DISCOURSE_REGISTRATION_FORM + else: + return SocialRegisterForm + + def form_valid(self, form): + # This method is called when valid form data has been POSTed. + user = get_user_model().objects.create_user(**form.cleaned_data) + + # Link the user to Discourse account + DiscourseUserLink.objects.filter(discourse_user_id=self.data['external_id']).delete() + DiscourseUserLink.objects.create(django_user=user, discourse_user_id=self.data['external_id']) + + messages.success(self.request, 'Account successfully created, you are now logged in.') + + # Redirect them to the discourse login URL + nextUrl = "{}?next={}".format(reverse('start-auth'), self.request.session.get('discourse_next', "/")) + return HttpResponseRedirect(nextUrl) + + +class DisassociateDiscourseUser(TemplateView): + template_name = "DiscourseAuth/disassociate_user.html" + + @method_decorator(login_required) # Require user is logged in for associating their account + def dispatch(self, request, *args, **kwargs): + return super(DisassociateDiscourseUser, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + c = super(DisassociateDiscourseUser, self).get_context_data() + + links = DiscourseUserLink.objects.filter(django_user=self.request.user) + + c['haslink'] = links.count() > 0 + + return c + + def post(self, request, **kwargs): + DiscourseUserLink.objects.filter(django_user=request.user).delete() + + return self.get(self, request, **kwargs) diff --git a/PyRIGS/settings.py b/PyRIGS/settings.py index 2e82807e..8ba235ce 100644 --- a/PyRIGS/settings.py +++ b/PyRIGS/settings.py @@ -51,6 +51,7 @@ INSTALLED_APPS = ( 'django.contrib.messages', 'django.contrib.staticfiles', 'RIGS', + 'DiscourseAuth', 'debug_toolbar', 'registration', @@ -70,7 +71,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) ROOT_URLCONF = 'PyRIGS.urls' @@ -221,3 +222,7 @@ TEMPLATE_DIRS = ( USE_GRAVATAR=True TERMS_OF_HIRE_URL = "http://www.nottinghamtec.co.uk/terms.pdf" + + +DISCOURSE_SECRET_KEY = 'pB1f94Mfky1Y0eOrk2UjB1VqnAZ52P7v' +DISCOURSE_BASE_URL = 'https://forum.nottinghamtec.co.uk' \ No newline at end of file diff --git a/PyRIGS/urls.py b/PyRIGS/urls.py index 9821ae20..6d3473e3 100644 --- a/PyRIGS/urls.py +++ b/PyRIGS/urls.py @@ -5,6 +5,7 @@ from django.conf import settings from registration.backends.default.views import RegistrationView import RIGS from RIGS import regbackend +import DiscourseAuth.urls urlpatterns = patterns('', # Examples: @@ -18,6 +19,7 @@ urlpatterns = patterns('', url('^user/', include('registration.backends.default.urls')), url(r'^admin/', include(admin.site.urls)), + url(r'^discourse-auth/', include(DiscourseAuth.urls)), ) if settings.DEBUG: diff --git a/RIGS/forms.py b/RIGS/forms.py index e1e95012..e028a211 100644 --- a/RIGS/forms.py +++ b/RIGS/forms.py @@ -28,6 +28,22 @@ class ProfileRegistrationFormUniqueEmail(RegistrationFormUniqueEmail): return self.cleaned_data['initials'] +class SocialRegisterForm(ProfileRegistrationFormUniqueEmail): + def __init__(self, *args, **kwargs): + super(SocialRegisterForm, self).__init__(*args, **kwargs) + self.fields.pop('password1') + self.fields.pop('password2') + + self.fields['email'].widget.attrs['readonly'] = True + + def clean_email(self): + initial = getattr(self, 'initial', None) + if(initial['email'] != self.cleaned_data['email']): + raise ValidationError("You cannot change the email") + + return initial['email'] + + # Login form class PasswordReset(PasswordResetForm): captcha = ReCaptchaField(label='Captcha')