diff --git a/private_sharing/api_authentication.py b/private_sharing/api_authentication.py index ccc9e5143..e210f729b 100644 --- a/private_sharing/api_authentication.py +++ b/private_sharing/api_authentication.py @@ -1,9 +1,15 @@ +from datetime import timedelta + import arrow from django.contrib.auth import get_user_model +from django.utils import timezone -from oauth2_provider.models import AccessToken from oauth2_provider.contrib.rest_framework import OAuth2Authentication +from oauth2_provider.models import AccessToken, RefreshToken +from oauth2_provider.settings import oauth2_settings + +from oauthlib import common as oauth2lib_common from rest_framework import exceptions from rest_framework.authentication import BaseAuthentication, get_authorization_header @@ -13,6 +19,36 @@ UserModel = get_user_model() +def make_oauth2_tokens(project, user): + """ + Returns a tuple, an AccessToken object and a RefreshToken object given a project and a user. + :param project: An oath2 project + :param user: The user for the access token and refresh token + If project is not a valid oauth2datarequestproject, returns None + """ + if not project.__class__ == OAuth2DataRequestProject: + return None + expires = timezone.now() + timedelta( + seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) + access_token = AccessToken( + user=user, + scope="", + expires=expires, + token=oauth2lib_common.generate_token(), + application=project.application, + ) + access_token.save() + refresh_token = RefreshToken( + user=user, + token=oauth2lib_common.generate_token(), + application=project.application, + access_token=access_token, + ) + refresh_token.save() + return (access_token, refresh_token) + + class MasterTokenAuthentication(BaseAuthentication): """ Master token based authentication. diff --git a/private_sharing/api_urls.py b/private_sharing/api_urls.py index 2d5658aef..57d00b931 100644 --- a/private_sharing/api_urls.py +++ b/private_sharing/api_urls.py @@ -24,6 +24,8 @@ "project/files/upload/complete/", api_views.ProjectFileDirectUploadCompletionView.as_view(), ), + path("project/oauth2/create/", api_views.ProjectCreateAPIView.as_view()), + path("project/oauth2/update/", api_views.ProjectUpdateAPIView.as_view()), ] urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/private_sharing/api_views.py b/private_sharing/api_views.py index b462eb29f..a2d1dba42 100644 --- a/private_sharing/api_views.py +++ b/private_sharing/api_views.py @@ -18,7 +18,11 @@ from data_import.serializers import DataFileSerializer from data_import.utils import get_upload_path -from .api_authentication import CustomOAuth2Authentication, MasterTokenAuthentication +from .api_authentication import ( + make_oauth2_tokens, + CustomOAuth2Authentication, + MasterTokenAuthentication, +) from .api_filter_backends import ProjectFilterBackend from .api_permissions import HasValidProjectToken from .forms import ( @@ -35,11 +39,27 @@ OAuth2DataRequestProject, ProjectDataFile, ) -from .serializers import ProjectDataSerializer, ProjectMemberDataSerializer +from .serializers import ( + ProjectAPISerializer, + ProjectDataSerializer, + ProjectMemberDataSerializer, +) UserModel = get_user_model() +def get_oauth2_member(request): + """ + Return project member if auth by OAuth2 user access token, else None. + """ + if request.auth.__class__ == OAuth2DataRequestProject: + proj_member = DataRequestProjectMember.objects.get( + member=request.user.member, project=request.auth + ) + return proj_member + return None + + class ProjectAPIView(NeverCacheMixin): """ The base class for all Project-related API views. @@ -49,15 +69,7 @@ class ProjectAPIView(NeverCacheMixin): permission_classes = (HasValidProjectToken,) def get_oauth2_member(self): - """ - Return project member if auth by OAuth2 user access token, else None. - """ - if self.request.auth.__class__ == OAuth2DataRequestProject: - proj_member = DataRequestProjectMember.objects.get( - member=self.request.user.member, project=self.request.auth - ) - return proj_member - return None + return get_oauth2_member(self.request) class ProjectDetailView(ProjectAPIView, RetrieveAPIView): @@ -458,3 +470,102 @@ def post(self, request): data_file.delete() return Response({"ids": ids}, status=status.HTTP_200_OK) + + +class ProjectCreateAPIView(APIView): + """ + Create a project via API + + Accepts project name, description, and redirect_url as (required) inputs + + The other required fields are auto-populated: + is_study: set to False + leader: set to member.name from oauth2 token + coordinator: get from oauth2 token + is_academic_or_nonprofit: False + add_data: false + explore_share: false + short_description: first 139 chars of long_description plus an ellipsis + active: True + """ + + authentication_classes = (CustomOAuth2Authentication,) + permission_classes = (HasValidProjectToken,) + + def get_short_description(self, long_description): + """ + Return first 139 chars of long_description plus an elipse. + """ + if len(long_description) > 140: + return "{0}…".format(long_description[0:139]) + return long_description + + def post(self, request): + """ + Take incoming json and create a project from it + """ + member = get_oauth2_member(request).member + serializer = ProjectAPISerializer(data=request.data) + if serializer.is_valid(): + coordinator_join = serializer.validated_data.get("coordinator_join", False) + project = serializer.save( + is_study=False, + is_academic_or_nonprofit=False, + add_data=False, + explore_share=False, + active=True, + short_description=self.get_short_description( + serializer.validated_data["long_description"] + ), + coordinator=member, + leader=member.name, + request_username_access=False, + diyexperiment=True, + ) + project.save() + + # Coordinator join project + if coordinator_join: + project_member = project.project_members.create(member=member) + project_member.joined = True + project_member.authorized = True + project_member.save() + + # Serialize project data for response + # Copy data dict so that we can easily append fields + serialized_project = ProjectDataSerializer(project).data + + # append tokens to the serialized_project data + serialized_project["client_id"] = project.application.client_id + serialized_project["client_secret"] = project.application.client_secret + + if coordinator_join: + access_token, refresh_token = make_oauth2_tokens(project, member.user) + serialized_project["coordinator_access_token"] = access_token.token + serialized_project["coordinator_refresh_token"] = refresh_token.token + + return Response(serialized_project, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ProjectUpdateAPIView(APIView): + """ + API Endpoint to update a project. + """ + + authentication_classes = (CustomOAuth2Authentication,) + permission_classes = (HasValidProjectToken,) + + def post(self, request): + """ + Take incoming json and update a project from it + """ + project = OAuth2DataRequestProject.objects.get(pk=self.request.auth.pk) + serializer = ProjectAPISerializer(project, data=request.data) + if serializer.is_valid(): + # serializer.save() returns the modified object, but it is not written + # to the database, hence the second save() + serializer.save().save() + return Response(serializer.validated_data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/private_sharing/migrations/0022_oauth2datarequestproject_diyexperiment.py b/private_sharing/migrations/0022_oauth2datarequestproject_diyexperiment.py new file mode 100644 index 000000000..2e3dea3b7 --- /dev/null +++ b/private_sharing/migrations/0022_oauth2datarequestproject_diyexperiment.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2 on 2019-04-23 20:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("private_sharing", "0021_auto_20190412_1908")] + + operations = [ + migrations.AddField( + model_name="oauth2datarequestproject", + name="diyexperiment", + field=models.BooleanField(default=False), + ) + ] diff --git a/private_sharing/models.py b/private_sharing/models.py index 11ba734a6..b861151cd 100644 --- a/private_sharing/models.py +++ b/private_sharing/models.py @@ -408,6 +408,8 @@ class Meta: # noqa: D101 verbose_name="Deauthorization Webhook URL", ) + diyexperiment = models.BooleanField(default=False) + def save(self, *args, **kwargs): if hasattr(self, "application"): application = self.application diff --git a/private_sharing/serializers.py b/private_sharing/serializers.py index 1348abbe6..1375bfbcc 100644 --- a/private_sharing/serializers.py +++ b/private_sharing/serializers.py @@ -3,10 +3,14 @@ from rest_framework import serializers from common.utils import full_url -from data_import.models import DataFile, DataType +from data_import.models import DataFile from data_import.serializers import DataFileSerializer -from .models import DataRequestProject, DataRequestProjectMember +from .models import ( + DataRequestProject, + DataRequestProjectMember, + OAuth2DataRequestProject, +) class ProjectDataSerializer(serializers.ModelSerializer): @@ -156,3 +160,49 @@ def to_representation(self, obj): rep.pop("username") return rep + + +class ProjectAPISerializer(serializers.Serializer): + """ + Fields that we should be getting through the API: + name + long_description + redirect_url + + Remainder of required fields; these are set at save() in the view. + is_study: set to False + leader: set to member.name from oauth2 token + coordinator: get from oauth2 token + is_academic_or_nonprofit: False + add_data: false + explore_share: false + short_description: first 139 chars of long_description plus an elipse + active: True + coordinator: from oauth2 token + """ + + id = serializers.IntegerField(required=False) + name = serializers.CharField(max_length=100) + long_description = serializers.CharField(max_length=1000) + redirect_url = serializers.URLField() + diyexperiment = serializers.BooleanField(required=False) + coordinator_join = serializers.BooleanField(default=False, required=False) + + def create(self, validated_data): + """ + Returns a new OAuth2DataRequestProject + """ + # Remove coordinator_join field as that doesn't actually exist in the model + validated_data.pop("coordinator_join") + return OAuth2DataRequestProject.objects.create(**validated_data) + + def update(self, instance, validated_data): + """ + Updates existing OAuth2DataRequestProject + """ + + for key, value in validated_data.items(): + if hasattr(instance, key): + setattr(instance, key, value) + + return instance diff --git a/private_sharing/tests.py b/private_sharing/tests.py index 029cbbb1e..29f9c492c 100644 --- a/private_sharing/tests.py +++ b/private_sharing/tests.py @@ -16,6 +16,8 @@ from data_import.models import DataType from open_humans.models import Member +from .api_authentication import make_oauth2_tokens + from .models import ( DataRequestProject, DataRequestProjectMember, @@ -693,3 +695,130 @@ def test_returned_data_description_activity(self): '{}//p[@class="activity-description"]'.format(prefix) ).text self.assertIn("def", description) + + +@override_settings(SSLIFY_DISABLE=True) +class DirectSharingOAuth2ProjectAPITests(TestCase): + """ + Tests the project creation, update, delete API + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + president = get_or_create_user("Zaphod Beeblebrox") + cls.president, _ = Member.objects.get_or_create(user=president) + cls.project_creation_project = OAuth2DataRequestProject(name="Project 42") + cls.project_creation_project.enrollment_url = "http://127.0.0.1" + cls.project_creation_project.terms_url = "http://127.0.0.1" + cls.project_creation_project.redirect_url = "http://127.0.0.1/complete/" + cls.project_creation_project.is_study = False + cls.project_creation_project.leader = "Zaphod Beeblebrox" + cls.project_creation_project.organization = "Galactic Government" + cls.project_creation_project.is_academic_or_nonprofit = False + cls.project_creation_project.add_data = False + cls.project_creation_project.explore_share = False + cls.project_creation_project.short_description = "Infinite Improbability Drive" + cls.project_creation_project.long_description = ( + "A project to power a spacecraft via Infinite Improbability Drive" + ) + cls.project_creation_project.request_username_access = False + cls.project_creation_project.approved = True + cls.project_creation_project.coordinator = cls.president + cls.project_creation_project.save() + + cls.project_update_project = OAuth2DataRequestProject(name="Milliways") + cls.project_update_project.long_description = ( + "The Hippest Place to watch all of Creation come to it's gasping end" + ) + cls.project_update_project.short_description = ( + "The Restaurant at the End of the Universe" + ) + cls.project_update_project.organization = "Milliways" + cls.project_update_project.leader = "Max Quordlepleen" + cls.project_update_project.enrollment_url = "http://127.0.0.1" + cls.project_update_project.terms_url = "http://127.0.0.1" + cls.project_update_project.redirect_url = "http://127.0.0.1/complete/" + cls.project_update_project.is_study = False + cls.project_update_project.is_academic_or_nonprofit = False + cls.project_update_project.add_data = False + cls.project_update_project.explore_share = False + cls.project_update_project.request_username_access = False + cls.project_update_project.approved = False + cls.project_update_project.coordinator = cls.president + cls.project_update_project.save() + + project_member = cls.project_creation_project.project_members.create( + member=cls.president + ) + project_member.joined = True + project_member.authorized = True + project_member.save() + + update_project_member = cls.project_update_project.project_members.create( + member=cls.president + ) + update_project_member.joined = True + update_project_member.authorized = True + update_project_member.save() + + def test_project_create_api(self): + access_token, refresh_token = make_oauth2_tokens( + self.project_creation_project, self.president.user + ) + url = "/api/direct-sharing/project/oauth2/create/?access_token={0}".format( + access_token.token + ) + + response = self.client.post( + url, + data={ + "name": "Stolen", + "long_description": "Stolen during the commissioning ceremony by the President of the Galazy. How wild is that? I guess it is the Improbability Drive, after all.", + "redirect_url": "http://localhost:7000/heart-of-gold/complete/", + "coordinator_join": True, + }, + ) + self.assertEqual(response.status_code, 201) + new_project = OAuth2DataRequestProject.objects.get(id=response.data["id"]) + self.assertEqual(response.data["name"], "Stolen") + self.assertEqual(new_project.name, "Stolen") + + # Check that the coordinator has, indeed, joined the project and been provided + # with a valid access_token + access_token = response.data["coordinator_access_token"] + url1 = "/api/direct-sharing/project/exchange-member/?access_token={0}".format( + access_token + ) + response1 = self.client.get(url1) + self.assertEqual(response1.status_code, 200) + + # Test for missing required args + response2 = self.client.post(url, data={"name": "Magrathea"}) + self.assertEqual(response2.status_code, 400) + + def test_project_update_api(self): + access_token, refresh_token = make_oauth2_tokens( + self.project_update_project, self.president.user + ) + + url = "/api/direct-sharing/project/oauth2/update/?access_token={0}".format( + access_token.token + ) + new_long_description = ( + "Only the hoopiest of hoopies come here to watch it all ... end" + ) + + response = self.client.post( + url, + data={ + "name": "Milliways", + "long_description": new_long_description, + "redirect_url": "http://localhost:7000/dinner-at-milliways/complete/", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["long_description"], new_long_description) + project = OAuth2DataRequestProject.objects.get(name="Milliways") + self.assertEqual(project.long_description, new_long_description)