From bf81a038aa640f69bbfea8a8d0f9f34e5b611d44 Mon Sep 17 00:00:00 2001 From: Leonhard Kuboschek Date: Fri, 10 Mar 2017 13:43:38 +0100 Subject: [PATCH] Switch authentication to Dreamjub Previously, authentication worked by transmitting the password to the legacy OpenJUB API. Furthermore user data was also received from this API. This caused problem, as Jay was able potentially able to intercept user passwords and also depended on a legacy system. This commit updates the authentication to use the new dreamjub api via OAuth. Furthermore, it updates the permission system to move away from UserProfiles and SuperAdmin models. Instead, this commit makes use of internal Django mechanisms and stores user data directly with the model. See also issue #26. --- README.md | 5 + core/views.py | 7 +- filters/models.py | 4 +- filters/views.py | 18 ++-- jay/settings.py | 19 +++- jay/urls.py | 3 +- jay/utils.py | 63 +++++++++++- requirements.txt | 12 ++- settings/models.py | 2 +- settings/urls.py | 10 +- templates/auth/login.html | 50 --------- templates/base/base.html | 6 +- templates/vote/fragments/vote_list.html | 4 +- users/models.py | 91 +---------------- users/ojub_auth.py | 129 +++++------------------- votes/models.py | 11 +- votes/views.py | 9 +- 17 files changed, 155 insertions(+), 288 deletions(-) delete mode 100644 templates/auth/login.html diff --git a/README.md b/README.md index 9abc0d5..083a30d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ Jay is a simple secret voting system for Jacobs University. Just in case you wer see [doc/](doc) for minimal developer documentation +## Setup + +1. Configure database and apply migrations +2. Create a Social Application pointing to `dreamjub` + ## License Jay is © 2015-17 Leonhard Kuboschek, Tom Wiesing & Contributors. Licensed under MIT license. See [LICENSE.md](LICENSE.md) for details. diff --git a/core/views.py b/core/views.py index 1cfb20e..37d628d 100644 --- a/core/views.py +++ b/core/views.py @@ -4,6 +4,7 @@ from settings.models import VotingSystem from votes.models import Vote, Status +from jay.utils import get_user_details # Create your views here. @@ -14,8 +15,10 @@ def home(request): systems = VotingSystem.objects.all() if request.user.is_authenticated(): - details = json.loads(request.user.profile.details) - votes_shown = [v for v in votes if v.filter.matches(details)] + details = get_user_details(request.user) + + votes_shown = [v for v in votes if v.filter.matches(json.loads( + details))] ctx["vote_list_title"] = "Your votes" diff --git a/filters/models.py b/filters/models.py index dca5c8a..7c8f7c0 100644 --- a/filters/models.py +++ b/filters/models.py @@ -9,6 +9,7 @@ from settings.models import VotingSystem import filters.forest as forest +from jay import utils # Create your models here. @@ -58,8 +59,7 @@ def canEdit(self, user): """ Checks if a user can edit this UserFilter. """ - - return self.system.isAdmin(user) + return utils.is_admin_for(user, self.system) def get_absolute_url(self): from django.core.urlresolvers import reverse diff --git a/filters/views.py b/filters/views.py index deec2a2..4a16f5a 100644 --- a/filters/views.py +++ b/filters/views.py @@ -12,11 +12,12 @@ FilterTestUserForm import filters.forest as forest -import json - from votes.models import VotingSystem -from jay.utils import priviliged +from jay.utils import priviliged, is_elevated, get_all_systems, \ + get_user_details + +import json FILTER_FOREST_TEMPLATE = "filters/filter_forest.html" FILTER_EDIT_TEMPLATE = "filters/filter_edit.html" @@ -27,7 +28,7 @@ @priviliged def Forest(request, alert_type=None, alert_head=None, alert_text=None): # if the user does not have enough priviliges, throw an exception - if not request.user.profile.isElevated(): + if not is_elevated(request.user): raise PermissionDenied # build a new context @@ -40,7 +41,7 @@ def Forest(request, alert_type=None, alert_head=None, alert_text=None): {'url': reverse('filters:forest'), 'text': 'Filters', 'active': True}) ctx['breadcrumbs'] = bc - (admin_systems, other_systems) = request.user.profile.getSystems() + (admin_systems, other_systems) = get_all_systems(request.user) # give those to the view ctx['admin_systems'] = admin_systems @@ -79,7 +80,7 @@ def FilterNew(request): # check if the user can edit it. # if not, go back to the overview - if not system.isAdmin(request.user.profile): + if not system.isAdmin(request.user): return Forest(request, alert_head="Creation failed", alert_text="Nice try. You are not allowed to edit " "this VotingSystem. ") @@ -149,7 +150,7 @@ def FilterEdit(request, filter_id): ctx["filter"] = filter # check if the user can edit it - if not filter.canEdit(request.user.profile): + if not filter.canEdit(request.user): raise PermissionDenied # Set up the breadcrumbs @@ -259,7 +260,8 @@ def FilterTestUser(request, filter_id): form = FilterTestUserForm(request.POST) if form.is_valid(): obj = form.cleaned_data["user"] - obj = User.objects.filter(username=obj)[0].profile.details + obj = json.dumps(get_user_details( + User.objects.filter(username=obj)[0])) except Exception as e: print(e) pass diff --git a/jay/settings.py b/jay/settings.py index 825a9c0..0547b85 100644 --- a/jay/settings.py +++ b/jay/settings.py @@ -25,7 +25,15 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django.contrib.sites', + 'django_forms_bootstrap', + + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'dreamjub.providers.oauth', + 'filters', 'settings', 'users', @@ -65,12 +73,14 @@ WSGI_APPLICATION = 'jay.wsgi.application' # OpenJUB auth -AUTHENTICATION_BACKENDS = ('django.contrib.auth.backends.ModelBackend', - 'users.ojub_auth.OjubBackend') +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +) # Default after login redirect # These are named URL routes -LOGIN_URL = "login" +LOGIN_URL = "accounts/dreamjub/login" LOGOUT_URL = "logout" LOGIN_REDIRECT_URL = "home" @@ -91,3 +101,6 @@ ) STATIC_URL = '/static/' +SITE_ID = 1 + +ACCOUNT_EMAIL_VERIFICATION = "none" diff --git a/jay/urls.py b/jay/urls.py index 34ffe79..6e77dd9 100644 --- a/jay/urls.py +++ b/jay/urls.py @@ -49,8 +49,7 @@ name="filter_help"), # Authentication - url(r'^login/', auth_views.login, {'template_name': 'auth/login.html'}, - name="login"), + url(r'^accounts/', include('allauth.urls'), name='login'), url(r'^logout/', auth_views.logout, {'template_name': 'auth/logout.html', 'next_page': 'home'}, name="logout"), diff --git a/jay/utils.py b/jay/utils.py index 698bda5..fd5e2e0 100644 --- a/jay/utils.py +++ b/jay/utils.py @@ -17,20 +17,77 @@ def superadmin(handler): """ Checks if a user is a super admin. """ def helper(request, *args, **kwargs): - if not request.user.profile.isSuperAdmin(): + if not request.user.is_superuser: raise PermissionDenied return handler(request, *args, **kwargs) return helper +def is_elevated(user): + if not user.is_superuser: + if not user.admin_set.count() > 0: + return False + return True + + def priviliged(handler): - """ Checks that a user has elevated privileges. """ + """ + Checks that a user has elevated priviliges. + """ def helper(request, *args, **kwargs): - if not request.user.profile.isElevated(): + if not is_elevated(request.user): raise PermissionDenied return handler(request, *args, **kwargs) return helper + + +def get_user_details(user): + import json + + try: + data = user.socialaccount_set.get(provider="dreamjub").extra_data + return data + except: + return {} + + +def is_admin_for(user, system): + """ + Checks if this user can administer a certain voting system. + """ + return system in get_administrated_systems(user) + + +def get_administrated_systems(user): + from settings.models import VotingSystem, Admin + """ + Returns all voting systems this user can administer. + """ + # if we are a superadmin we can manage all systems + if user.is_superuser: + return VotingSystem.objects.all() + + # else return only the systems we are an admin for. + else: + return list(map(lambda x: x.system, Admin.objects.filter( + user=user))) + + +def get_all_systems(user): + from settings.models import VotingSystem + """ + Gets the editable filters for this user. + """ + + # get all the voting systems for this user + admin_systems = get_administrated_systems(user) + + # and all the other ones also + other_systems = list( + filter(lambda a: a not in admin_systems, VotingSystem.objects.all())) + + return admin_systems, other_systems diff --git a/requirements.txt b/requirements.txt index 8b63007..6665921 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,8 +10,12 @@ Markdown==2.6.5 # For filters PyExecJS==1.1.0 -# For OpenJUB auth backend -requests==2.6.0 - # For testing pep -pep8 >= 1.7.0 \ No newline at end of file +pep8 >= 1.7.0 + +# For reaching out to OpenJUB +requests==2.12.3 + +# For authing against dreamjub +django-allauth==0.29.0 +django-allauth-dreamjub==0.1.3 \ No newline at end of file diff --git a/settings/models.py b/settings/models.py index cfeea0a..9e51e91 100644 --- a/settings/models.py +++ b/settings/models.py @@ -3,6 +3,7 @@ from django.contrib import admin from jay.restricted import is_restricted_word +from users.models import Admin class VotingSystem(models.Model): @@ -27,5 +28,4 @@ def isAdmin(self, user): """ return user.isAdminFor(self) - admin.site.register(VotingSystem) diff --git a/settings/urls.py b/settings/urls.py index db501ca..a0de000 100644 --- a/settings/urls.py +++ b/settings/urls.py @@ -1,15 +1,15 @@ from django.conf.urls import url from django.views.generic import TemplateView -from settings.views import superadmins, systems +from settings.views import systems urlpatterns = [ # Superadmin management - url(r'^$', superadmins.settings, name="settings"), - url(r'^superadmins/add$', superadmins.superadmin_add, name="add"), - url(r'^superadmins/(?P[\w-]+)/remove$', - superadmins.superadmin_remove, name="remove"), + # url(r'^$', superadmins.settings, name="settings"), + # url(r'^superadmins/add$', superadmins.superadmin_add, name="add"), + # url(r'^superadmins/(?P[\w-]+)/remove$', + # superadmins.superadmin_remove, name="remove"), # System management url(r'^systems$', systems.systems, name='systems'), diff --git a/templates/auth/login.html b/templates/auth/login.html deleted file mode 100644 index aadffe3..0000000 --- a/templates/auth/login.html +++ /dev/null @@ -1,50 +0,0 @@ -{% extends "base/base.html" %} - -{% block page_title %}Login{% endblock %} - -{% block content %} - -{% if form.errors %} - -{% endif %} - -{% if next %} - {% if user.is_authenticated %} - - {% else %} - - {% endif %} -{% endif %} - -
- -
-
- -
- -
-
- -

If you lost your password, reset it in CampusNet.

- -{% endblock %} diff --git a/templates/base/base.html b/templates/base/base.html index dfa528a..7b039a0 100644 --- a/templates/base/base.html +++ b/templates/base/base.html @@ -43,11 +43,11 @@ {{ user.username }} {% else %} - Login + Login {% endif %} diff --git a/templates/vote/fragments/vote_list.html b/templates/vote/fragments/vote_list.html index f73dc3b..6c7cf1f 100644 --- a/templates/vote/fragments/vote_list.html +++ b/templates/vote/fragments/vote_list.html @@ -12,13 +12,13 @@  {{ vote.name }} - {% if vote|can_delete:request.user.profile %} + {% if vote|can_delete:request.user %} {% endif %} - {% if vote|can_edit:request.user.profile %} + {% if vote|can_edit:request.user %} diff --git a/users/models.py b/users/models.py index b8f9d93..f7e15ac 100644 --- a/users/models.py +++ b/users/models.py @@ -1,19 +1,13 @@ from django.db import models from django.contrib.auth.models import User -from django.core.exceptions import ValidationError - from django.contrib import admin -from settings.models import VotingSystem - -import json - # Create your models here. class Admin(models.Model): user = models.ForeignKey(User) - system = models.ForeignKey(VotingSystem) + system = models.ForeignKey("settings.VotingSystem") class Meta(): unique_together = (("system", "user")) @@ -22,87 +16,4 @@ def __str__(self): return u'[%s] %s' % (self.system.machine_name, self.user) -class SuperAdmin(models.Model): - user = models.ForeignKey(User) - - class Meta(): - unique_together = (("user",),) - - def __str__(self): - return u'%s' % (self.user) - - -class UserProfile(models.Model): - user = models.OneToOneField(User, related_name="profile") - details = models.TextField() - - def __str__(self): - return u'[Profile] %s' % (self.user.username) - - def clean(self): - # make sure that the details are a valid json object - try: - json.loads(self.details) - except: - raise ValidationError({ - 'details': ValidationError( - 'Details needs to be a valid JSON object', code='invalid') - }) - - def isSuperAdmin(self): - """ - Returns if this user is a SuperAdmin. - """ - return self.user.superadmin_set.count() > 0 - - def isAdminFor(self, system): - """ - Checks if this user can administer a certain voting system. - """ - return system in self.getAdministratedSystems() - - def getAdministratedSystems(self): - """ - Returns all voting systems this user can administer. - """ - # if we are a superadmin we can manage all systems - if self.isSuperAdmin(): - return VotingSystem.objects.all() - - # else return only the systems we are an admin for. - else: - return list( - map(lambda x: x.system, Admin.objects.filter(user=self.user))) - - def isElevated(self): - """ - Checks if this user is an elevated user. - - (i. e. if they are a superadmin or admin for some voting system) - """ - - # thy are a superadmin - if self.isSuperAdmin(): - return True - - # they administer some voting system - return self.user.admin_set.count() > 0 - - def getSystems(self): - """ - Gets the editable filters for this user. - """ - - # get all the voting systems for this user - admin_systems = self.getAdministratedSystems() - - # and all the other ones also - other_systems = list(filter(lambda a: a not in admin_systems, - VotingSystem.objects.all())) - - return (admin_systems, other_systems) - - admin.site.register(Admin) -admin.site.register(SuperAdmin) -admin.site.register(UserProfile) diff --git a/users/ojub_auth.py b/users/ojub_auth.py index af7a211..1c73fe4 100644 --- a/users/ojub_auth.py +++ b/users/ojub_auth.py @@ -1,113 +1,34 @@ from django.conf import settings -from django.contrib.auth.models import User -from users.models import UserProfile +from requests_oauthlib import OAuth2Session +from oauthlib.oauth2 import BackendApplicationClient -import requests -OPENJUB_BASE = "https://api.jacobs.university/" +def get_all(): + client = BackendApplicationClient(client_id=settings.DREAMJUB_CLIENT_ID) + dreamjub = OAuth2Session(client=client) + dreamjub.fetch_token( + token_url=settings.DREAMJUB_CLIENT_URL + 'login/o/token/', + client_id=settings.DREAMJUB_CLIENT_ID, + client_secret=settings.DREAMJUB_CLIENT_SECRET) + # iterate over the pages (while there is a next) + results = [] + next = settings.DREAMJUB_CLIENT_URL + 'api/v1/users/' -class OjubBackend(object): - """ - Authenticates credentials against the OpenJUB database. + while next: + res = dreamjub.get(next) + if not res.ok: + raise Exception( + 'Unable to retrieve current list of students, ' + 'please try again later. ') - The URL for the server is configured by OPENJUB_BASE in the settings. + res = res.json() + results += res['results'] + next = res['next'] if 'next' in res else None - This class does not fill in user profiles, this has to be handled - in other places - """ + # replace http with https at most one + if next is not None and next.startswith('http://') and settings.DREAMJUB_CLIENT_URL.startswith('https://'): + next = next.replace('http://', 'https://', 1) - def authenticate(self, username=None, password=None): - r = requests.post(OPENJUB_BASE + "auth/signin", - data={'username': username, 'password': password}) - - if r.status_code != requests.codes.ok: - return None - - resp = r.json() - - uname = resp['user'] - token = resp['token'] - - details = requests.get(OPENJUB_BASE + "user/me", - params={'token': token}) - - if details.status_code != requests.codes.ok: - print("Could not get user details") - return None - - try: - user = User.objects.get(username=uname) - except User.DoesNotExist: - user = User(username=uname) - - user.set_unusable_password() - - # TODO Don't hardcode this - if user.username in ["lkuboschek", "twiesing", "jinzhang", - "rdeliallis"]: - user.is_staff = True - user.is_superuser = True - - data = details.json() - - user.first_name = data['firstName'] - user.last_name = data['lastName'] - user.email = data['email'] - - user.save() - - # Make a user profile if there isn't one already - try: - profile = UserProfile.objects.get(user=user) - except UserProfile.DoesNotExist: - profile = UserProfile(user=user) - - profile.details = details.text - profile.save() - - return user - - def get_user(self, user_id): - try: - return User.objects.get(pk=user_id) - except User.DoesNotExist: - return None - - -def get_all(username, password): - r = requests.post(OPENJUB_BASE + "auth/signin", - data={'username': username, 'password': password}) - - if r.status_code != requests.codes.ok: - return None - - resp = r.json() - - uname = resp['user'] - token = resp['token'] - - users = [] - - TIMEOUT = 60 - - request = requests.get(OPENJUB_BASE + "query", - params={'token': token, 'limit': 20000}, - timeout=TIMEOUT) - - while True: - if request.status_code != requests.codes.ok: - return None - else: - # read json - resjson = request.json() - - # load all the users - users += resjson["data"] - - # if there was no data or no next field, continue - if len(resjson["data"]) == 0 or not resjson["next"]: - return users - else: - request = requests.get(resjson["next"], timeout=TIMEOUT) + return results diff --git a/votes/models.py b/votes/models.py index 07c5af9..4dcd177 100644 --- a/votes/models.py +++ b/votes/models.py @@ -13,6 +13,7 @@ from users.ojub_auth import get_all from jay.restricted import is_restricted_word +from jay import utils # Create your models here. @@ -47,15 +48,15 @@ def canEdit(self, user): """ Checks if a user can edit this vote. """ - return user.isAdminFor( - self.system) and self.status.stage != Status.PUBLIC + return utils.is_admin_for(user, self.system) and \ + self.status.stage != Status.PUBLIC def canDelete(self, user): """ Check if a user can delete this vote. """ - return user.isAdminFor( - self.system) and self.status.stage == Status.INIT + return utils.is_admin_for(user, self.system) and \ + self.status.stage == Status.INIT def canBeModified(self): """ @@ -69,7 +70,7 @@ def update_eligibility(self, username, password): 'num_voters': 0, 'num_eligible': 0}) - if self.filter is not None: + if self.filter is None: raise Exception("Missing filter. ") # this will take really long diff --git a/votes/views.py b/votes/views.py index fb8971e..d5eb9f7 100644 --- a/votes/views.py +++ b/votes/views.py @@ -52,7 +52,7 @@ def system_home(request, system_name): all_votes = Vote.objects.filter(system=vs) - if request.user.is_authenticated() and vs.isAdmin(request.user.profile): + if request.user.is_authenticated() and vs.isAdmin(request.user): ctx['votes'] = all_votes ctx['results'] = Vote.objects.filter(system=vs, status__stage__in=[Status.PUBLIC, @@ -228,6 +228,7 @@ def get_vote_props(ctx, vote): def vote_edit_context(request, system_name, vote_name): + from jay import utils """ Returns context and basic parameters for vote editing. """ @@ -237,14 +238,14 @@ def vote_edit_context(request, system_name, vote_name): vote.touch() # raise an error if the user trying to access is not an admin - if not system.isAdmin(request.user.profile): + if not system.isAdmin(request.user): raise PermissionDenied # make a context ctx = {} # get all the systems this user can edit - (admin_systems, other_systems) = request.user.profile.getSystems() + (admin_systems, other_systems) = utils.get_all_systems(request.user) # add the vote to the system ctx['vote'] = vote @@ -281,7 +282,7 @@ def vote_add(request, system_name): vs = get_object_or_404(VotingSystem, machine_name=system_name) # raise an error if the user trying to access is not an admin - if not vs.isAdmin(request.user.profile): + if not vs.isAdmin(request.user): raise PermissionDenied v = Vote()