Repository: uclapi/uclapi

Issue:
[Feature Request] Expose all Shibboleth UCL Intranet Groups via OAuth
**Is your feature request related to a problem? Please describe.**
It is not possible to determine which faculty a user is in, or whather they have special access (e.g. to do the Bloomsbury Theatre builings), each of which places them in a new UCL Intranet Group.

Without this feature in place it is not possible to migrate EngHub from raw Shibboleth to UCL API OAuth.

**Describe the solution you'd like**
A list of each UCL Intranet Group should be provided via the `/user/data` endpoint.

**Describe alternatives you've considered (optional)**
Parsing this data on the API's side and providing metadata (such as the Faculty a user is in) instead of a raw list of groups. This might be hard to do without a lot of help from ISD though.



Discussion:
Does the shibboleth response give us all the group information?

Resolving patch:
diff --git a/backend/uclapi/dashboard/views.py b/backend/uclapi/dashboard/views.py
--- a/backend/uclapi/dashboard/views.py
+++ b/backend/uclapi/dashboard/views.py
@@ -4,6 +4,7 @@
 
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.serializers.json import DjangoJSONEncoder
+from django.http import HttpResponse
 from django.shortcuts import redirect, render
 from django.utils.http import quote
 from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
@@ -19,6 +20,21 @@
 
 @csrf_exempt
 def shibboleth_callback(request):
+    # We first check whether the user is a member of any UCL Intranet Groups.
+    # This is a quick litmus test to determine whether they should have
+    # access to the dashboard.
+    # We deny access to test accounts and alumni, neither of which have
+    # this Shibboleth attribute.
+    if 'HTTP_UCLINTRANETGROUPS' not in request.META:
+        response = HttpResponse(
+            (
+                "Error 403 - denied. <br>"
+                "The API Dashboard is only assessible to active UCL users."
+            )
+        )
+        response.status_code = 403
+        return response
+
     # should auth user login or signup
     # then redirect to dashboard homepage
     eppn = request.META['HTTP_EPPN']
diff --git a/backend/uclapi/oauth/views.py b/backend/uclapi/oauth/views.py
--- a/backend/uclapi/oauth/views.py
+++ b/backend/uclapi/oauth/views.py
@@ -6,6 +6,7 @@
 from django.core import signing
 from django.core.serializers.json import DjangoJSONEncoder
 from django.core.signing import TimestampSigner
+from django.http import HttpResponse
 from django.shortcuts import redirect, render
 from django.utils.http import quote
 from django.views.decorators.csrf import (
@@ -131,13 +132,34 @@ def shibcallback(request):
     app = App.objects.get(client_id=client_id)
 
     eppn = request.META['HTTP_EPPN']
-    groups = request.META['HTTP_UCLINTRANETGROUPS']
     cn = request.META['HTTP_CN']
     department = request.META['HTTP_DEPARTMENT']
     given_name = request.META['HTTP_GIVENNAME']
     display_name = request.META['HTTP_DISPLAYNAME']
     employee_id = request.META['HTTP_EMPLOYEEID']
 
+    # We check whether the user is a member of any UCL Intranet Groups.
+    # This is a quick litmus test to determine whether they should be able to
+    # use an OAuth application.
+    # We deny access to alumni, which does not have this Shibboleth attribute.
+    # Test accounts also do not have this attribute, but we can check the
+    # department attribute for the Shibtests department.
+    # This lets App Store reviewers log in to apps that use the UCL API.
+    if 'HTTP_UCLINTRANETGROUPS' in request.META:
+        groups = request.META['HTTP_UCLINTRANETGROUPS']
+    else:
+        if department == "Shibtests":
+            groups = "shibtests"
+        else:
+            response = HttpResponse(
+                (
+                    "Error 403 - denied. <br>"
+                    "Unfortunately, alumni are not permitted to use UCL Apps."
+                )
+            )
+            response.status_code = 403
+            return response
+
     # If a user has never used the API before then we need to sign them up
     try:
         user = User.objects.get(email=eppn)
@@ -488,16 +510,16 @@ def userdata(request, *args, **kwargs):
 
     user_data = {
         "ok": True,
-        "full_name": token.user.full_name,
-        "email": token.user.email,
-        "given_name": token.user.given_name,
         "cn": token.user.cn,
         "department": token.user.department,
+        "email": token.user.email,
+        "full_name": token.user.full_name,
+        "given_name": token.user.given_name,
         "upi": token.user.employee_id,
         "scope_number": token.scope.scope_number,
-        "is_student": is_student
+        "is_student": is_student,
+        "ucl_groups": token.user.raw_intranet_groups.split(';')
     }
-    print("Is student: " + str(is_student))
 
     return PrettyJsonResponse(
         user_data,
@@ -563,6 +585,7 @@ def get_student_number(request, *args, **kwargs):
         custom_header_data=kwargs
     )
 
+
 @csrf_exempt
 def myapps_shibboleth_callback(request):
     # should auth user login or signup
@@ -577,7 +600,7 @@ def myapps_shibboleth_callback(request):
 
     try:
         user = User.objects.get(email=eppn)
-    except ObjectDoesNotExist:
+    except User.DoesNotExist:
         # create a new user
         new_user = User(
             email=eppn,
@@ -590,7 +613,6 @@ def myapps_shibboleth_callback(request):
         )
 
         new_user.save()
-        add_user_to_mailing_list_task.delay(new_user.email, new_user.full_name)
 
         request.session["user_id"] = new_user.id
         keen_add_event.delay("signup", {
@@ -658,7 +680,7 @@ def my_apps(request):
         })
 
     initial_data_dict = {
-        "status" : "ONLINE",
+        "status": "ONLINE",
         "fullname": user.full_name,
         "user_id": user.id,
         "department": user.department,


Test patch:
diff --git a/backend/uclapi/oauth/tests.py b/backend/uclapi/oauth/tests.py
--- a/backend/uclapi/oauth/tests.py
+++ b/backend/uclapi/oauth/tests.py
@@ -1,8 +1,10 @@
 import json
-import unittest.mock
 import os
+import random
+import string
+import unittest.mock
 
-from django.test import TestCase
+from django.test import Client, TestCase
 from rest_framework.test import APIRequestFactory
 from django.core import signing
 
@@ -355,6 +357,7 @@ def test_no_callback_url(self):
 class ViewsTestCase(TestCase):
     def setUp(self):
         self.factory = APIRequestFactory()
+        self.client = Client()
 
     def test_no_parameters(self):
         request = self.factory.get(
@@ -472,6 +475,190 @@ def test_expired_signature(self, TimestampSigner):
              "Team to rectify this.")
         )
 
+    def test_valid_shibcallback_real_account(self):
+        dev_user_ = User.objects.create(
+            email="testdev@ucl.ac.uk",
+            cn="test",
+            given_name="Test Dev",
+            employee_id='testdev01'
+        )
+        app_ = App.objects.create(
+            user=dev_user_,
+            name="An App",
+            callback_url="www.somecallbackurl.com/callback"
+        )
+        test_user_ = User.objects.create(
+            email="testxxx@ucl.ac.uk",
+            cn="testxxx",
+            given_name="Test User",
+            employee_id='xxxtest01'
+        )
+
+        signer = signing.TimestampSigner()
+        # Generate a random state for testing
+        state = ''.join(
+            random.choices(string.ascii_letters + string.digits, k=32)
+        )
+        data = app_.client_id + state
+        signed_data = signer.sign(data)
+
+        response = self.client.get(
+            '/oauth/shibcallback',
+            {
+                'appdata': signed_data
+            },
+            HTTP_EPPN='testxxx@ucl.ac.uk',
+            HTTP_CN='testxxx',
+            HTTP_DEPARTMENT='Dept of Tests',
+            HTTP_GIVENNAME='Test New Name',
+            HTTP_DISPLAYNAME='Test User',
+            HTTP_EMPLOYEEID='xxxtest01',
+            HTTP_UCLINTRANETGROUPS='ucl-all;ucl-tests-all'
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(self.client.session['user_id'], test_user_.id)
+
+        initial_data = json.loads(response.context['initial_data'])
+        self.assertEqual(
+            initial_data['app_name'],
+            app_.name
+        )
+        self.assertEqual(
+            initial_data['client_id'],
+            app_.client_id
+        )
+        self.assertEqual(
+            initial_data['state'],
+            state
+        )
+        self.assertDictEqual(
+            initial_data['user'],
+            {
+                "full_name": "Test User",
+                "cn": "testxxx",
+                "email": "testxxx@ucl.ac.uk",
+                "department": "Dept of Tests",
+                "upi": "xxxtest01"
+            }
+        )
+
+        # Reload the test user from DB
+        test_user_ = User.objects.get(id=test_user_.id)
+
+        self.assertEqual(
+            test_user_.given_name,
+            "Test New Name"
+        )
+
+    def test_valid_shibcallback_test_account(self):
+        dev_user_ = User.objects.create(
+            email="testdev@ucl.ac.uk",
+            cn="test",
+            given_name="Test Dev",
+            employee_id='testdev01'
+        )
+        app_ = App.objects.create(
+            user=dev_user_,
+            name="An App",
+            callback_url="www.somecallbackurl.com/callback"
+        )
+        test_user_ = User.objects.create(
+            email="testxxx@ucl.ac.uk",
+            cn="testxxx",
+            given_name="Test User",
+            employee_id='xxxtest01'
+        )
+
+        signer = signing.TimestampSigner()
+        # Generate a random state for testing
+        state = ''.join(
+            random.choices(string.ascii_letters + string.digits, k=32)
+        )
+        data = app_.client_id + state
+        signed_data = signer.sign(data)
+
+        response = self.client.get(
+            '/oauth/shibcallback',
+            {
+                'appdata': signed_data
+            },
+            HTTP_EPPN='testxxx@ucl.ac.uk',
+            HTTP_CN='testxxx',
+            HTTP_DEPARTMENT='Shibtests',
+            HTTP_GIVENNAME='Test',
+            HTTP_DISPLAYNAME='Test User',
+            HTTP_EMPLOYEEID='xxxtest01',
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(self.client.session['user_id'], test_user_.id)
+
+        initial_data = json.loads(response.context['initial_data'])
+        self.assertEqual(
+            initial_data['app_name'],
+            app_.name
+        )
+        self.assertEqual(
+            initial_data['client_id'],
+            app_.client_id
+        )
+        self.assertEqual(
+            initial_data['state'],
+            state
+        )
+        self.assertDictEqual(
+            initial_data['user'],
+            {
+                "full_name": "Test User",
+                "cn": "testxxx",
+                "email": "testxxx@ucl.ac.uk",
+                "department": "Shibtests",
+                "upi": "xxxtest01"
+            }
+        )
+
+        # Reload the test user from DB
+        test_user_ = User.objects.get(id=test_user_.id)
+
+        self.assertEqual(
+            test_user_.raw_intranet_groups,
+            "shibtests"
+        )
+
+    def test_invalid_or_alumni_account(self):
+        dev_user_ = User.objects.create(
+            email="testdev@ucl.ac.uk",
+            cn="test",
+            given_name="Test Dev",
+            employee_id='testdev01'
+        )
+        app_ = App.objects.create(
+            user=dev_user_,
+            name="An App",
+            callback_url="www.somecallbackurl.com/callback"
+        )
+
+        signer = signing.TimestampSigner()
+        # Generate a random state for testing
+        state = ''.join(
+            random.choices(string.ascii_letters + string.digits, k=32)
+        )
+        data = app_.client_id + state
+        signed_data = signer.sign(data)
+
+        response = self.client.get(
+            '/oauth/shibcallback',
+            {
+                'appdata': signed_data
+            },
+            HTTP_EPPN='testxxx@ucl.ac.uk',
+            HTTP_CN='testxxx',
+            HTTP_DEPARTMENT='Dept of Alumni',
+            HTTP_GIVENNAME='Test',
+            HTTP_DISPLAYNAME='Test User',
+            HTTP_EMPLOYEEID='xxxtest01',
+        )
+        self.assertEqual(response.status_code, 403)
+
 
 class AppHelpersTestCase(TestCase):
     def test_generate_random_verification_code(self):