Compare commits

..

6 Commits

Author SHA1 Message Date
David Taylor
adc94820bb Added discourse profile pictures. Will fallback to gravatar if not linked to forum account 2016-11-03 01:55:27 +00:00
David Taylor
f3947d89ca Commented registration test - since registration is now disabled 2016-11-03 00:09:09 +00:00
David Taylor
0ad3aa7d3f Fixed penguins of death due to infinite loop of SSO login redirects 2016-11-02 23:50:49 +00:00
David Taylor
01f754ad53 Removed unused views 2016-11-02 21:27:04 +00:00
David Taylor
793f1d4e05 Made it work :) 2016-11-02 21:01:40 +00:00
David Taylor
f5bf40bd9b Proof of concept discourse authentication 2016-11-02 02:43:16 +00:00
31 changed files with 526 additions and 584 deletions

View File

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

View File

@@ -1,22 +0,0 @@
# -*- 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

@@ -1,19 +0,0 @@
# -*- 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

@@ -1,19 +0,0 @@
# -*- 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

@@ -1,24 +0,0 @@
# -*- 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

@@ -1,19 +0,0 @@
# -*- 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

@@ -1,47 +0,0 @@
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

@@ -1,14 +0,0 @@
{% 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

@@ -1,16 +0,0 @@
{% 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 %}

View File

@@ -1,11 +0,0 @@
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')
)

View File

@@ -1,253 +0,0 @@
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,7 +51,6 @@ INSTALLED_APPS = (
'django.contrib.messages',
'django.contrib.staticfiles',
'RIGS',
'DiscourseAuth',
'debug_toolbar',
'registration',
@@ -59,6 +58,7 @@ INSTALLED_APPS = (
'captcha',
'widget_tweaks',
'raven.contrib.django.raven_compat',
'social.apps.django_app.default',
)
MIDDLEWARE_CLASSES = (
@@ -71,9 +71,34 @@ 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',
)
AUTHENTICATION_BACKENDS = (
'RIGS.discourse.discourse.DiscourseAuth',
'django.contrib.auth.backends.ModelBackend',
)
SOCIAL_AUTH_PIPELINE = (
'social.pipeline.social_auth.social_details', # Load remote details
'social.pipeline.social_auth.social_uid', # Load remote ID
'social.pipeline.social_auth.auth_allowed', # Check not blacklisted
'social.pipeline.social_auth.social_user', # If already associated, login
'RIGS.discourse.pipeline.new_connection', # Choose a user account, much UI
'social.pipeline.social_auth.associate_user', # Associate the social auth with the user
'social.pipeline.social_auth.load_extra_data', # Save all the social info we have on this user
'RIGS.discourse.pipeline.update_avatar', # Load the avatar URL from the API, and save to user model
'social.pipeline.user.user_details', # Save any details that changed
)
DISCOURSE_HOST = os.environ.get('DISCOURSE_HOST') if os.environ.get('DISCOURSE_HOST') else 'http://localhost:4000'
DISCOURSE_SSO_SECRET = os.environ.get('DISCOURSE_SSO_SECRET') if os.environ.get('DISCOURSE_SSO_SECRET') else 'ABCDEFGHIJKLMNOP'
DISCOURSE_API_KEY = os.environ.get('DISCOURSE_API_KEY') if os.environ.get('DISCOURSE_HOST') else None
DISCOURSE_API_USER = os.environ.get('DISCOURSE_API_USER') if os.environ.get('DISCOURSE_HOST') else 'system'
REGISTRATION_OPEN = False # Disable built-in django registration - must register using forum
ROOT_URLCONF = 'PyRIGS.urls'
WSGI_APPLICATION = 'PyRIGS.wsgi.application'
@@ -203,6 +228,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',
)
@@ -222,7 +249,3 @@ 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,7 +5,6 @@ from django.conf import settings
from registration.backends.default.views import RegistrationView
import RIGS
from RIGS import regbackend
import DiscourseAuth.urls
urlpatterns = patterns('',
# Examples:
@@ -19,7 +18,7 @@ urlpatterns = patterns('',
url('^user/', include('registration.backends.default.urls')),
url(r'^admin/', include(admin.site.urls)),
url(r'^discourse-auth/', include(DiscourseAuth.urls)),
url('', include('social.apps.django_app.urls', namespace='social'))
)
if settings.DEBUG:

View File

@@ -0,0 +1,91 @@
from __future__ import unicode_literals
from social.backends.base import BaseAuth
from django.conf import settings
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 = settings.DISCOURSE_SSO_SECRET
host = settings.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."""
try:
if not self.sso.validate(self.data['sso'], self.data['sig']):
raise Exception("Someone wants to hack us!")
except KeyError:
raise Exception("SSO Error, please try again")
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)

View File

@@ -0,0 +1,88 @@
from django.core.urlresolvers import reverse
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.shortcuts import render_to_response
from django.core.exceptions import ValidationError
from django.conf import settings
import json
import requests
from social.pipeline.partial import partial
from RIGS.models import Profile
from RIGS import forms
class SocialRegisterForm(forms.ProfileRegistrationFormUniqueEmail):
def __init__(self, *args, **kwargs):
super(SocialRegisterForm, self).__init__(*args, **kwargs)
self.fields.pop('password1')
self.fields.pop('password2')
self.fields.pop('captcha')
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']
@partial
def new_connection(backend, details, response, user=None, is_new=False, social=None, request=None, *args, **kwargs):
if social is not None:
return
data = backend.strategy.request_data()
if data.get('UseCurrentAccount') is not None:
return
alreadyLoggedIn = user is not None
context = {
'details': details,
'alreadyLoggedIn': alreadyLoggedIn,
'loggedInUser': user,
}
if not alreadyLoggedIn:
completeUrl = reverse('social:complete', kwargs={'backend': backend.name})
context['login_url'] = "{0}?{1}={2}".format(reverse('login'), REDIRECT_FIELD_NAME, completeUrl)
if data.get('username') is None:
form = SocialRegisterForm(initial=details)
else:
form = SocialRegisterForm(data, initial=details)
if form.is_valid():
new_user = Profile.objects.create_user(**form.cleaned_data)
return {'user': new_user}
context['form'] = form
return render_to_response('RIGS/social-associate.html', context)
def update_avatar(backend, details, response, user=None, social=None, *args, **kwargs):
host = settings.DISCOURSE_HOST
api_key = settings.DISCOURSE_API_KEY
api_user = settings.DISCOURSE_API_USER
if social is not None:
url = "{}/users/{}.json".format(host, details['username'])
params = {
'api_key': api_key,
'api_username': api_user
}
resp = requests.get(url=url, params=params)
extraData = json.loads(resp.text)
avatar_template = extraData['user']['avatar_template']
if avatar_template and user.avatar_template != avatar_template:
user.avatar_template = avatar_template
user.save()
return

46
RIGS/discourse/sso.py Normal file
View File

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

View File

@@ -122,7 +122,7 @@ class InvoiceArchive(generic.ListView):
class InvoiceWaiting(generic.ListView):
model = models.Event
paginate_by = 25
# paginate_by = 25
template_name = 'RIGS/event_invoice.html'
def get_context_data(self, **kwargs):

View File

@@ -3,6 +3,7 @@ from django import forms
from django.utils import formats
from django.conf import settings
from django.core import serializers
from django.core.exceptions import ValidationError
from django.contrib.auth.forms import UserCreationForm, UserChangeForm, AuthenticationForm, PasswordResetForm
from registration.forms import RegistrationFormUniqueEmail
from captcha.fields import ReCaptchaField
@@ -27,21 +28,15 @@ class ProfileRegistrationFormUniqueEmail(RegistrationFormUniqueEmail):
raise forms.ValidationError("These initials are already in use. Please supply different initials.")
return self.cleaned_data['initials']
def clean_first_name(self):
if self.cleaned_data["first_name"].strip() == '':
raise ValidationError("First name is required.")
return self.cleaned_data["first_name"]
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']
def clean_last_name(self):
if self.cleaned_data["last_name"].strip() == '':
raise ValidationError("Last name is required.")
return self.cleaned_data["last_name"]
# Login form

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 = [
('RIGS', '0024_auto_20160229_2042'),
]
operations = [
migrations.AddField(
model_name='profile',
name='avatar_template',
field=models.CharField(max_length=255, null=True, editable=False, blank=True),
),
]

View File

@@ -22,6 +22,7 @@ class Profile(AbstractUser):
initials = models.CharField(max_length=5, unique=True, null=True, blank=False)
phone = models.CharField(max_length=13, null=True, blank=True)
api_key = models.CharField(max_length=40, blank=True, editable=False, null=True)
avatar_template = models.CharField(max_length=255, blank=True, editable=False, null=True)
@classmethod
def make_api_key(cls):
@@ -33,8 +34,13 @@ class Profile(AbstractUser):
@property
def profile_picture(self):
url = ""
if settings.DISCOURSE_API_KEY is not None:
if self.avatar_template:
return settings.DISCOURSE_HOST+self.avatar_template.format(size=500)
if settings.USE_GRAVATAR or settings.USE_GRAVATAR is None:
url = "https://www.gravatar.com/avatar/" + hashlib.md5(self.email).hexdigest() + "?d=wavatar&s=500"
return url
@property

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

View File

@@ -133,6 +133,20 @@
{% endif %}
</dd>
</dl>
<h4>Linked to {{object.social_auth.count}} Forum Account(s)</h4>
{% if object.social_auth.count > 0 %}
<a href="{% url 'unlink_forum' %}" class="btn btn-danger">
Unlink Forum Account(s) <span class="glyphicon glyphicon-pencil"></span>
</a>
{% else %}
<a href="{% url "social:begin" "discourse" %}" class="btn btn-success">
Link Forum Account <span class="glyphicon glyphicon-pencil"></span>
</a>
{% endif %}
{% endif %}
{% endif %}
</div>

View File

@@ -0,0 +1,55 @@
{% extends 'base.html' %}
{% load widget_tweaks %}
{% block title %}Associate{% endblock %}
{% block content %}
<div class="col-sm-10 col-sm-offset-1">
<div class="text-center">
<h1>R<small>ig</small> I<small>nformation</small> G<small>athering</small> S<small>ystem</small></h1>
</div>
<h2 class="text-center">Welcome <strong>{{details.username}}</strong></h2>
<h4 class="text-center">This is the first time you've visited RIGS with your forum account, so we need a few details to get you set up</h4>
<hr/>
{% if alreadyLoggedIn %}
<h2 class="text-center">You are logged in to RIGS as <strong>{{loggedInUser.username}}</strong></h2>
<div class="col-sm-8 col-sm-offset-2">
<form action="", method="post">{% csrf_token %}
<input type="hidden" name="UseCurrentAccount" value="1"/>
<button type="submit" class="btn btn-lg btn-primary center btn-block">Link Forum account to RIGS Account</button>
</form>
<a class="btn btn-lg btn-warning center btn-block" href="{% url 'logout' %}" role="button">Logout</a>
</div>
{% else %}
<h4 class="text-center"><a class="btn btn-info" href="{{login_url}}" role="button">I already have a RIGS account</a></h4>
{% if form.errors or supplement_form.errors %}
<div class="alert alert-danger">
{{form.errors}}
{{supplement_form.errors}}
</div>
{% endif %}
<div class="col-sm-8 col-sm-offset-2">
<form action="" method="post" class="form-horizontal" role="form">{% csrf_token %}
{% for field in form %}
<div class="form-group">
<label for="{{ field.id_for_label }}" class="control-label col-sm-4">{{ field.label }}</label>
<div class="controls col-sm-8">
{% render_field field class+="form-control" placeholder=field.label %}
</div>
</div>
{% endfor %}
<p><input type="submit" value="Register" class="btn btn-primary pull-right"></p>
</form>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -16,136 +16,136 @@ from selenium.webdriver.support.ui import WebDriverWait
from RIGS import models
class UserRegistrationTest(LiveServerTestCase):
# class UserRegistrationTest(LiveServerTestCase):
def setUp(self):
self.browser = webdriver.Firefox()
self.browser.implicitly_wait(3) # Set implicit wait session wide
os.environ['RECAPTCHA_TESTING'] = 'True'
# def setUp(self):
# self.browser = webdriver.Firefox()
# self.browser.implicitly_wait(3) # Set implicit wait session wide
# os.environ['RECAPTCHA_TESTING'] = 'True'
def tearDown(self):
self.browser.quit()
os.environ['RECAPTCHA_TESTING'] = 'False'
# def tearDown(self):
# self.browser.quit()
# os.environ['RECAPTCHA_TESTING'] = 'False'
def test_registration(self):
# Navigate to the registration page
self.browser.get(self.live_server_url + '/user/register/')
title_text = self.browser.find_element_by_tag_name('h3').text
self.assertIn("User Registration", title_text)
# def test_registration(self):
# # Navigate to the registration page
# self.browser.get(self.live_server_url + '/user/register/')
# title_text = self.browser.find_element_by_tag_name('h3').text
# self.assertIn("User Registration", title_text)
# Check the form invites correctly
username = self.browser.find_element_by_id('id_username')
self.assertEqual(username.get_attribute('placeholder'), 'Username')
email = self.browser.find_element_by_id('id_email')
self.assertEqual(email.get_attribute('placeholder'), 'E-mail')
# If this is correct we don't need to test it later
self.assertEqual(email.get_attribute('type'), 'email')
password1 = self.browser.find_element_by_id('id_password1')
self.assertEqual(password1.get_attribute('placeholder'), 'Password')
self.assertEqual(password1.get_attribute('type'), 'password')
password2 = self.browser.find_element_by_id('id_password2')
self.assertEqual(
password2.get_attribute('placeholder'), 'Password confirmation')
self.assertEqual(password2.get_attribute('type'), 'password')
first_name = self.browser.find_element_by_id('id_first_name')
self.assertEqual(first_name.get_attribute('placeholder'), 'First name')
last_name = self.browser.find_element_by_id('id_last_name')
self.assertEqual(last_name.get_attribute('placeholder'), 'Last name')
initials = self.browser.find_element_by_id('id_initials')
self.assertEqual(initials.get_attribute('placeholder'), 'Initials')
phone = self.browser.find_element_by_id('id_phone')
self.assertEqual(phone.get_attribute('placeholder'), 'Phone')
# # Check the form invites correctly
# username = self.browser.find_element_by_id('id_username')
# self.assertEqual(username.get_attribute('placeholder'), 'Username')
# email = self.browser.find_element_by_id('id_email')
# self.assertEqual(email.get_attribute('placeholder'), 'E-mail')
# # If this is correct we don't need to test it later
# self.assertEqual(email.get_attribute('type'), 'email')
# password1 = self.browser.find_element_by_id('id_password1')
# self.assertEqual(password1.get_attribute('placeholder'), 'Password')
# self.assertEqual(password1.get_attribute('type'), 'password')
# password2 = self.browser.find_element_by_id('id_password2')
# self.assertEqual(
# password2.get_attribute('placeholder'), 'Password confirmation')
# self.assertEqual(password2.get_attribute('type'), 'password')
# first_name = self.browser.find_element_by_id('id_first_name')
# self.assertEqual(first_name.get_attribute('placeholder'), 'First name')
# last_name = self.browser.find_element_by_id('id_last_name')
# self.assertEqual(last_name.get_attribute('placeholder'), 'Last name')
# initials = self.browser.find_element_by_id('id_initials')
# self.assertEqual(initials.get_attribute('placeholder'), 'Initials')
# phone = self.browser.find_element_by_id('id_phone')
# self.assertEqual(phone.get_attribute('placeholder'), 'Phone')
# Fill the form out incorrectly
username.send_keys('TestUsername')
email.send_keys('test@example.com')
password1.send_keys('correcthorsebatterystaple')
# deliberate mistake
password2.send_keys('correcthorsebatterystapleerror')
first_name.send_keys('John')
last_name.send_keys('Smith')
initials.send_keys('JS')
phone.send_keys('0123456789')
self.browser.execute_script(
"return jQuery('#g-recaptcha-response').val('PASSED')")
# # Fill the form out incorrectly
# username.send_keys('TestUsername')
# email.send_keys('test@example.com')
# password1.send_keys('correcthorsebatterystaple')
# # deliberate mistake
# password2.send_keys('correcthorsebatterystapleerror')
# first_name.send_keys('John')
# last_name.send_keys('Smith')
# initials.send_keys('JS')
# phone.send_keys('0123456789')
# self.browser.execute_script(
# "return jQuery('#g-recaptcha-response').val('PASSED')")
# Submit incorrect form
submit = self.browser.find_element_by_xpath("//input[@type='submit']")
submit.click()
# # Submit incorrect form
# submit = self.browser.find_element_by_xpath("//input[@type='submit']")
# submit.click()
# Restablish error fields
password1 = self.browser.find_element_by_id('id_password1')
password2 = self.browser.find_element_by_id('id_password2')
# # Restablish error fields
# password1 = self.browser.find_element_by_id('id_password1')
# password2 = self.browser.find_element_by_id('id_password2')
# Read what the error is
alert = self.browser.find_element_by_css_selector(
'div.alert-danger').text
self.assertIn("password fields didn't match", alert)
# # Read what the error is
# alert = self.browser.find_element_by_css_selector(
# 'div.alert-danger').text
# self.assertIn("password fields didn't match", alert)
# Passwords should be empty
self.assertEqual(password1.get_attribute('value'), '')
self.assertEqual(password2.get_attribute('value'), '')
# # Passwords should be empty
# self.assertEqual(password1.get_attribute('value'), '')
# self.assertEqual(password2.get_attribute('value'), '')
# Correct error
password1.send_keys('correcthorsebatterystaple')
password2.send_keys('correcthorsebatterystaple')
self.browser.execute_script(
"return jQuery('#g-recaptcha-response').val('PASSED')")
# # Correct error
# password1.send_keys('correcthorsebatterystaple')
# password2.send_keys('correcthorsebatterystaple')
# self.browser.execute_script(
# "return jQuery('#g-recaptcha-response').val('PASSED')")
# Submit again
password2.send_keys(Keys.ENTER)
# # Submit again
# password2.send_keys(Keys.ENTER)
# Check we have a success message
alert = self.browser.find_element_by_css_selector(
'div.alert-success').text
self.assertIn('register', alert)
self.assertIn('email', alert)
# # Check we have a success message
# alert = self.browser.find_element_by_css_selector(
# 'div.alert-success').text
# self.assertIn('register', alert)
# self.assertIn('email', alert)
# Check Email
self.assertEqual(len(mail.outbox), 1)
email = mail.outbox[0]
self.assertIn('John Smith "JS" activation required', email.subject)
urls = re.findall(
'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', email.body)
self.assertEqual(len(urls), 1)
# # Check Email
# self.assertEqual(len(mail.outbox), 1)
# email = mail.outbox[0]
# self.assertIn('John Smith "JS" activation required', email.subject)
# urls = re.findall(
# 'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', email.body)
# self.assertEqual(len(urls), 1)
mail.outbox = [] # empty this for later
# mail.outbox = [] # empty this for later
# Follow link
self.browser.get(urls[0]) # go to the first link
# # Follow link
# self.browser.get(urls[0]) # go to the first link
# Complete registration
title_text = self.browser.find_element_by_tag_name('h2').text
self.assertIn('Complete', title_text)
# # Complete registration
# title_text = self.browser.find_element_by_tag_name('h2').text
# self.assertIn('Complete', title_text)
# Test login
self.browser.get(self.live_server_url + '/user/login')
username = self.browser.find_element_by_id('id_username')
self.assertEqual(username.get_attribute('placeholder'), 'Username')
password = self.browser.find_element_by_id('id_password')
self.assertEqual(password.get_attribute('placeholder'), 'Password')
self.assertEqual(password.get_attribute('type'), 'password')
# # Test login
# self.browser.get(self.live_server_url + '/user/login')
# username = self.browser.find_element_by_id('id_username')
# self.assertEqual(username.get_attribute('placeholder'), 'Username')
# password = self.browser.find_element_by_id('id_password')
# self.assertEqual(password.get_attribute('placeholder'), 'Password')
# self.assertEqual(password.get_attribute('type'), 'password')
username.send_keys('TestUsername')
password.send_keys('correcthorsebatterystaple')
self.browser.execute_script(
"return jQuery('#g-recaptcha-response').val('PASSED')")
password.send_keys(Keys.ENTER)
# username.send_keys('TestUsername')
# password.send_keys('correcthorsebatterystaple')
# self.browser.execute_script(
# "return jQuery('#g-recaptcha-response').val('PASSED')")
# password.send_keys(Keys.ENTER)
# Check we are logged in
udd = self.browser.find_element_by_class_name('navbar').text
self.assertIn('Hi John', udd)
# # Check we are logged in
# udd = self.browser.find_element_by_class_name('navbar').text
# self.assertIn('Hi John', udd)
# Check all the data actually got saved
profileObject = models.Profile.objects.all()[0]
self.assertEqual(profileObject.username, 'TestUsername')
self.assertEqual(profileObject.first_name, 'John')
self.assertEqual(profileObject.last_name, 'Smith')
self.assertEqual(profileObject.initials, 'JS')
self.assertEqual(profileObject.phone, '0123456789')
self.assertEqual(profileObject.email, 'test@example.com')
# # Check all the data actually got saved
# profileObject = models.Profile.objects.all()[0]
# self.assertEqual(profileObject.username, 'TestUsername')
# self.assertEqual(profileObject.first_name, 'John')
# self.assertEqual(profileObject.last_name, 'Smith')
# self.assertEqual(profileObject.initials, 'JS')
# self.assertEqual(profileObject.phone, '0123456789')
# self.assertEqual(profileObject.email, 'test@example.com')
# All is well
# # All is well
class EventTest(LiveServerTestCase):

View File

@@ -153,6 +153,7 @@ urlpatterns = patterns('',
url(r'^user/edit/$', login_required(views.ProfileUpdateSelf.as_view()),
name='profile_update_self'),
url(r'^user/reset_api_key$', login_required(views.ResetApiKey.as_view(permanent=False)), name='reset_api_key'),
url(r'^user/unlink_forum$', login_required(views.UnlinkForum.as_view(permanent=False)), name='unlink_forum'),
# ICS Calendar - API key authentication
url(r'^ical/(?P<api_pk>\d+)/(?P<api_key>\w+)/rigs.ics$', api_key_required(ical.CalendarICS()), name="ics_calendar"),

View File

@@ -382,3 +382,12 @@ class ResetApiKey(generic.RedirectView):
self.request.user.save()
return reverse_lazy('profile_detail')
class UnlinkForum(generic.RedirectView):
def get_redirect_url(self, *args, **kwargs):
for link in self.request.user.social_auth.all():
link.delete()
self.request.user.save()
return reverse_lazy('profile_detail')

View File

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

View File

@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load static from staticfiles %}
{% block title %}Login{% endblock %}
@@ -6,5 +7,43 @@
<div class="text-center">
<h1>R<small>ig</small> I<small>nformation</small> G<small>athering</small> S<small>ystem</small></h1>
</div>
<div class="panel-group">
{% url "social:complete" "discourse" as completeUrl %}
{% if not request.GET.next == completeUrl %}
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
Login with TEC Forum
</h4>
</div>
<div id="forumLogin">
<div class="panel-body" style="text-align:center;">
<a class="btn btn-default" href="{% url "social:begin" "discourse" %}?next={{request.GET.next}}">
<h4>Login using</h4>
<img src="{% static "imgs/forum-logo.gif" %}" width=200></img>
</a>
</div>
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
Login with RIGS Credentials
</h4>
</div>
<div class="panel-body">
<div class="panel-body">
{% include 'registration/loginform.html' %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -13,7 +13,7 @@
{% render_field form.password class+="form-control" placeholder=form.password.label %}
</div>
<div class="text-right">
<a href="{% url 'registration_register' %}" class="btn">Register</a>
{# <a href="{% url 'registration_register' %}" class="btn">Register</a> #}
<a href="{% url 'password_reset' %}" class="btn">Forgotten Password</a>
<input type="submit" value="Login" class="btn btn-primary"/>
<input type="hidden" name="next" value="{{ next }}"/>