Add copy of Discourse-Auth app,

Probably should split into separate repo at some point, but committing here so I don't lose it
This commit is contained in:
David Taylor
2017-04-11 00:28:35 +01:00
parent 337dbd74fd
commit 6b7889d80e
16 changed files with 473 additions and 1 deletions

View File

5
DiscourseAuth/admin.py Normal file
View File

@@ -0,0 +1,5 @@
from django.contrib import admin
from models import AuthAttempt, DiscourseUserLink
admin.site.register(AuthAttempt)
admin.site.register(DiscourseUserLink)

View File

@@ -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)),
],
),
]

View File

@@ -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',
),
]

View File

@@ -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',
),
]

View File

@@ -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)),
],
),
]

View File

@@ -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),
),
]

View File

47
DiscourseAuth/models.py Normal file
View File

@@ -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))

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Associate User" %}{% endblock %}
{% block content %}
<form method="post" action="">
{% csrf_token %}
<p>You are currently logged in to django as "{{ djangouser }}". If this isn't you please log out</p>
<p>If you would like to link Discourse account "{{ discourseuser }}" to your django account, click below. This will remove any existing links.</p>
<input type="submit" value="{% trans 'Link my accounts' %}" />
</form>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Associate User" %}{% endblock %}
{% block content %}
{% if haslink %}
<form method="post" action="">
{% csrf_token %}
<p>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.</p>
<input type="submit" value="{% trans 'Un-Link my accounts' %}" />
</form>
{% else %}
<p>Your account is not currently linked to Discourse.</p>
{% endif %}
{% endblock %}

11
DiscourseAuth/urls.py Normal file
View File

@@ -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')
)

253
DiscourseAuth/views.py Normal file
View File

@@ -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)

View File

@@ -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'

View File

@@ -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:

View File

@@ -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')