From e85828f07bda47874e563af2851645e013723d40 Mon Sep 17 00:00:00 2001 From: frostyfan109 Date: Fri, 24 May 2024 21:40:38 -0400 Subject: [PATCH] Add identity access token system --- appstore/api/v1/views.py | 41 +++++++++++++++++++++++++++------------- appstore/core/apps.py | 3 +++ appstore/core/models.py | 36 ++++++++++++++++++++++++++++++++++- appstore/core/signals.py | 0 appstore/core/urls.py | 5 ++++- appstore/core/views.py | 23 ++++++++++++++++++++++ 6 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 appstore/core/signals.py diff --git a/appstore/api/v1/views.py b/appstore/api/v1/views.py index 8a3adcba3..66d46423c 100644 --- a/appstore/api/v1/views.py +++ b/appstore/api/v1/views.py @@ -19,7 +19,7 @@ from allauth import socialaccount from tycho.context import ContextFactory, Principal -from core.models import IrodAuthorizedUser +from core.models import IrodAuthorizedUser, UserIdentityToken from .models import Instance, InstanceSpec, App, LoginProvider, Resources, User from .serializers import ( @@ -230,15 +230,15 @@ def get_social_tokens(request): social_token_model_objects = ( ContentType.objects.get(model="socialtoken").model_class().objects.all() ) - access_token = request.COOKIES.get("sessionid") + access_token = None refresh_token = None - # for obj in social_token_model_objects: - # if obj.account.user.username == username: - # access_token = obj.token - # refresh_token = obj.token_secret if obj.token_secret else None - # break - # else: - # continue + for obj in social_token_model_objects: + if obj.account.user.username == username: + access_token = obj.token + refresh_token = obj.token_secret if obj.token_secret else None + break + else: + continue # with DRF and the user interaction in social auth we need username to be a string # when it is passed to `tycho.start` otherwise it will be a `User` object and there # will be a serialization failure from this line of code: @@ -249,6 +249,10 @@ def get_social_tokens(request): return str(username), access_token, refresh_token +def get_tokens(request): + username = request.user.get_username() + return username, None, None + class AppViewSet(viewsets.GenericViewSet): """ AppViewSet - ViewSet for managing Tycho apps. @@ -460,7 +464,7 @@ def get_principal(self, request): Retrieve principal information from Tycho based on the request user. """ - tokens = get_social_tokens(request) + tokens = get_tokens(request) principal = Principal(*tokens) return principal @@ -543,6 +547,8 @@ def create(self, request): Given an app id and resources pass the information to Tycho to start a instance of an app. """ + + username = request.user.get_username() serializer = self.get_serializer(data=request.data) logging.debug("checking if request is valid") @@ -552,13 +558,15 @@ def create(self, request): logging.debug(f"resource_request: {resource_request}") irods_enabled = os.environ.get("IROD_HOST",'').strip() # TODO update social query to fetch user. - tokens = get_social_tokens(request) + #Need to set an environment variable for the IRODS UID if irods_enabled != '': - nfs_id = get_nfs_uid(request.user) + nfs_id = get_nfs_uid(username) os.environ["NFSRODS_UID"] = str(nfs_id) - principal = Principal(*tokens) + # We will update this later once a system id for the app exists + identity_token = UserIdentityToken.objects.create(user=request.user) + principal = Principal(username, identity_token.token, None) app_id = serializer.data["app_id"] app_data = tycho.apps.get(app_id) @@ -596,6 +604,8 @@ def create(self, request): host = get_host(request) system = tycho.start(principal, app_id, resource_request.resources, host, env) + identity_token.consumer_id = identity_token.compute_app_consumer_id(app_id, system.identifier) + identity_token.save() s = InstanceSpec( principal.username, @@ -625,6 +635,7 @@ def create(self, request): # Failed to construct a tracked instance, attempt to remove # potentially created instance rather than leaving it hanging. tycho.delete({"name": system.services[0].identifier}) + identity_token.delete() return Response( {"message": "failed to submit app start."}, status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -678,6 +689,10 @@ def destroy(self, request, sid=None): logger.info("request username: " + str(request.user.username)) if status.services[0].username == request.user.username: response = tycho.delete({"name": serializer.validated_data["sid"]}) + # Delete all the tokens the user had associated with that app + consumer_id = UserIdentityToken.compute_app_consumer_id(serializer.validated_data["aid"], serializer.validated_data["sid"]) + tokens = UserIdentityToken.objects.filter(user=request.user, consumer_id=consumer_id) + tokens.delete() # TODO How can we avoid this sleep? Do we need an immediate response beyond # a successful submission? Can we do a follow up with Web Sockets or SSE # to the front end? diff --git a/appstore/core/apps.py b/appstore/core/apps.py index 21a8cc236..296eed9d8 100644 --- a/appstore/core/apps.py +++ b/appstore/core/apps.py @@ -3,3 +3,6 @@ class AppsCoreServicesConfig(AppConfig): name = 'core' + + def ready(self): + import core.signals \ No newline at end of file diff --git a/appstore/core/models.py b/appstore/core/models.py index df42b770f..44d56fdc9 100644 --- a/appstore/core/models.py +++ b/appstore/core/models.py @@ -1,7 +1,21 @@ +import secrets from django.db import models +from django.utils import timezone from django.contrib.auth import get_user_model +from django.contrib.sessions.models import Session as SessionModel from django.core.exceptions import ValidationError from django_saml2_auth.user import get_user +from datetime import timedelta +from string import ascii_letters, digits, punctuation + +UserModel = get_user_model() + +def generate_token(): + token = "".join(secrets.choice(ascii_letters + digits) for i in range(256)) + # Should realistically never occur, but it's possible. + while UserIdentityToken.objects.filter(token=token).exists(): + token = "".join(secrets.choice(ascii_letters + digits) for i in range(256)) + return token def update_user(user): # as of Django_saml2_auth v3.12.0 does not add email address by default @@ -30,4 +44,24 @@ class IrodAuthorizedUser(models.Model): uid = models.IntegerField() def __str__(self): - return f"{self.user}, {self.uid}" \ No newline at end of file + return f"{self.user}, {self.uid}" + +class UserIdentityToken(models.Model): + user = models.ForeignKey(UserModel, on_delete=models.CASCADE) + + token = models.CharField(max_length=256, unique=True, default=generate_token) + # Optionally, identify the consumer (probably an app) whom the token was generated for. + consumer_id = models.CharField(max_length=256, default=None, null=True) + expires = models.DateTimeField(default=timezone.now() + timedelta(days=31)) + + @property + def valid(self): + return timezone.now() <= self.expires + + @staticmethod + def compute_app_consumer_id(app_id, system_id): + return f"{ app_id }-{ system_id }" + + def __str__(self): + return f"{ self.user.get_username() }-token-{ self.pk }" + \ No newline at end of file diff --git a/appstore/core/signals.py b/appstore/core/signals.py new file mode 100644 index 000000000..e69de29bb diff --git a/appstore/core/urls.py b/appstore/core/urls.py index 0a1e18238..4562e075c 100644 --- a/appstore/core/urls.py +++ b/appstore/core/urls.py @@ -1,8 +1,11 @@ from django.urls import path, re_path -from .views import auth, index, HandlePrivateURL404s +from .views import auth, auth_identity, index, HandlePrivateURL404s urlpatterns = [ path('default/', index, name='index'), + # Auth based on sessionid cookie path("auth/", auth, name="auth"), + # Auth based on identity access token + path("auth/identity/", auth_identity, name="auth-identity"), re_path(r"^private/*", HandlePrivateURL404s, name="private"), ] diff --git a/appstore/core/views.py b/appstore/core/views.py index 91228290a..bf1d03b17 100644 --- a/appstore/core/views.py +++ b/appstore/core/views.py @@ -8,6 +8,8 @@ from django.http import HttpResponse, JsonResponse from django.shortcuts import render, redirect +from core.models import UserIdentityToken + from tycho.context import ContextFactory from urllib.parse import urljoin @@ -74,6 +76,27 @@ def get_access_token(request): return access_token +def auth_identity(request): + auth_header = request.headers.get("Authorization") + try: + bearer, raw_token = auth_header.split(" ") + if bearer != "Bearer": raise ValueError() + except: + return HttpResponse("Authorization header must be structured as 'Bearer {token}'", status=400) + + try: + token = UserIdentityToken.objects.get(token=raw_token) + if not token.valid: + return HttpResponse() + remote_user = token.user.get_username() + response = HttpResponse(remote_user, status=200) + response["REMOTE_USER"] = remote_user + response["ACCESS_TOKEN"] = token.token + return response + except UserIdentityToken.DoesNotExist: + return HttpResponse("The token does not exist", status=401) + + @login_required def auth(request): """Provide an endpoint for getting the user identity.