From 204ba885cbd8acbe05160eb2d6a9544bc84b51e1 Mon Sep 17 00:00:00 2001
From: jefer94
Date: Mon, 10 Jul 2023 17:34:30 -0500
Subject: [PATCH 01/19] Add schema link and signature
---
breathecode/authenticate/actions.py | 70 +++++++-
breathecode/authenticate/models.py | 170 +++++++++++++++++++
breathecode/authenticate/tasks.py | 6 +
breathecode/authenticate/views.py | 21 +++
breathecode/utils/decorators/__init__.py | 1 +
breathecode/utils/decorators/scope.py | 201 +++++++++++++++++++++++
6 files changed, 466 insertions(+), 3 deletions(-)
create mode 100644 breathecode/utils/decorators/scope.py
diff --git a/breathecode/authenticate/actions.py b/breathecode/authenticate/actions.py
index 550db6a5b..58e353ddf 100644
--- a/breathecode/authenticate/actions.py
+++ b/breathecode/authenticate/actions.py
@@ -8,18 +8,21 @@
from random import randint
from django.core.handlers.wsgi import WSGIRequest
import breathecode.notify.actions as notify_actions
+from rest_framework.exceptions import AuthenticationFailed
+from functools import lru_cache
from django.contrib.auth.models import User
from django.utils import timezone
from django.db.models import Q
from breathecode.admissions.models import Academy, CohortUser
-from breathecode.notify.actions import send_email_message
from breathecode.utils import ValidationException
from breathecode.utils.i18n import translation
from breathecode.services.github import Github
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
-from .models import (CredentialsGithub, DeviceId, GitpodUser, ProfileAcademy, Role, Token, UserSetting,
- AcademyAuthSettings, GithubAcademyUser)
+from .models import (ASYMMETRIC_ALGORITHMS, CredentialsGithub, DeviceId, GitpodUser, ProfileAcademy, Role,
+ Token, UserSetting, AcademyAuthSettings, GithubAcademyUser)
logger = logging.getLogger(__name__)
@@ -619,3 +622,64 @@ def sync_organization_members(academy_id, only_status=[]):
# _member.log('Error inviting member to organization')
# _member.save()
# return False
+
+
+def generate_auth_keys(algorithm) -> tuple[bytes, bytes]:
+ public_key = None
+ key = Ed25519PrivateKey.generate()
+
+ private_key = key.private_bytes(encoding=Encoding.PEM,
+ format=PrivateFormat.PKCS8,
+ encryption_algorithm=NoEncryption()).hex()
+
+ if algorithm in ASYMMETRIC_ALGORITHMS:
+ public_key = key.public_key().public_bytes(encoding=Encoding.PEM,
+ format=PublicFormat.SubjectPublicKeyInfo).hex()
+
+ return public_key, private_key
+
+
+@lru_cache(maxsize=100)
+def get_app_keys(app_id):
+ from .models import App
+ app = App.objects.filter(id=app_id).first()
+
+ if app.algorithm == 'HMAC_SHA256':
+ alg = 'HS256'
+
+ elif app.algorithm == 'HMAC_SHA512':
+ alg = 'HS512'
+
+ elif app.algorithm == 'ed25519':
+ alg = 'EdDSA'
+
+ else:
+ raise AuthenticationFailed({'error': 'Algorithm not implemented', 'is_authenticated': False})
+
+ if app is None:
+ raise AuthenticationFailed({'error': 'Unauthorized', 'is_authenticated': False})
+
+ legacy_public_key = None
+ legacy_private_key = None
+ legacy_key = None
+ if hasattr(app, 'legacy_key'):
+ legacy_public_key = bytes.fromhex(app.legacy_key.public_key)
+ legacy_private_key = bytes.fromhex(app.legacy_key.private_key)
+ legacy_key = (
+ legacy_public_key,
+ legacy_private_key,
+ )
+
+ info = (
+ app.id,
+ alg,
+ app.strategy,
+ app.schema,
+ (x.slug for x in app.scopes.all()),
+ )
+ key = (
+ bytes.fromhex(app.public_key),
+ bytes.fromhex(app.private_key),
+ )
+
+ return info, key, legacy_key
diff --git a/breathecode/authenticate/models.py b/breathecode/authenticate/models.py
index add081f8f..579362aa9 100644
--- a/breathecode/authenticate/models.py
+++ b/breathecode/authenticate/models.py
@@ -11,6 +11,8 @@
from django.core.validators import RegexValidator
from django.contrib.contenttypes.models import ContentType
from django import forms
+from slugify import slugify
+from breathecode.authenticate import tasks
from breathecode.authenticate.exceptions import (BadArguments, InvalidTokenType, TokenNotFound,
TryToGetOrCreateAOneTimeToken)
@@ -110,6 +112,174 @@ def __str__(self):
return f'{self.name} ({self.slug})'
+class Scope(models.Model):
+ name = models.SlugField(max_length=25, unique=True)
+ slug = models.SlugField(unique=True)
+ description = models.CharField(max_length=255)
+ internal = models.BooleanField(default=False,
+ help_text='If true, this scope is only for internal use and '
+ 'it will not be shown to the user')
+
+ def clean(self) -> None:
+ if not self.slug:
+ self.slug = slugify(self.name)
+
+ if not self.description:
+ raise forms.ValidationError('Scope description is required')
+
+ return super().clean()
+
+ def save(self, *args, **kwargs):
+ self.full_clean()
+ super().save(*args, **kwargs)
+
+ def __str__(self):
+ return f'{self.name} ({self.slug})'
+
+
+HMAC_SHA256 = 'HMAC_SHA256'
+HMAC_SHA512 = 'HMAC_SHA512'
+ED25519 = 'ED25519'
+AUTH_ALGORITHM = (
+ (HMAC_SHA256, 'HMAC-SHA256'),
+ (HMAC_SHA512, 'HMAC_SHA512'),
+ (ED25519, 'ED25519'),
+)
+
+JWT = 'JWT'
+SIGNATURE = 'SIGNATURE'
+AUTH_STRATEGY = (
+ (JWT, 'Json Web Token'),
+ (SIGNATURE, 'Signature'),
+)
+
+LINK = 'LINK'
+AUTH_SCHEMA = ((LINK, 'Link'), )
+
+SYMMETRIC_ALGORITHMS = [HMAC_SHA256, HMAC_SHA512]
+ASYMMETRIC_ALGORITHMS = [ED25519]
+
+
+class App(models.Model):
+
+ def __init__(self, *args, **kwargs):
+ super(UserInvite, self).__init__(*args, **kwargs)
+
+ self._algorithm = self.algorithm
+ self._strategy = self.strategy
+ self._schema = self.schema
+
+ self._private_key = self.private_key
+ self._public_key = self.public_key
+
+ self._webhook_url = self.webhook_url
+ self._redirect_url = self.redirect_url
+
+ name = models.SlugField(max_length=25, unique=True)
+ slug = models.SlugField(unique=True)
+
+ algorithm = models.CharField(max_length=11, choices=AUTH_ALGORITHM)
+ strategy = models.CharField(max_length=9, choices=AUTH_STRATEGY)
+ schema = models.CharField(max_length=4, choices=AUTH_SCHEMA)
+
+ scopes = models.ManyToManyField(Scope, blank=True)
+
+ private_key = models.CharField(max_length=255, blank=True, null=False)
+ public_key = models.CharField(max_length=255, blank=True, null=True, default=None)
+
+ webhook_url = models.URLField()
+ redirect_url = models.URLField()
+
+ created_at = models.DateTimeField(auto_now_add=True, editable=False)
+ updated_at = models.DateTimeField(auto_now=True, editable=False)
+
+ def __str__(self):
+ return f'{self.name} ({self.slug})'
+
+ def clean(self) -> None:
+ from .actions import generate_auth_keys
+ if not self.slug:
+ self.slug = slugify(self.name)
+
+ if self.public_key and self.algorithm in SYMMETRIC_ALGORITHMS:
+ raise forms.ValidationError('Public key is not required for symmetric algorithms')
+
+ if not self.public_key and not self.private_key:
+ self.public_key, self.private_key = generate_auth_keys(self.algorithm)
+
+ return super().clean()
+
+ def save(self, *args, **kwargs):
+ self.full_clean()
+ super().save(*args, **kwargs)
+
+ if self.id and (self.private_key != self._private_key or self.public_key != self._public_key
+ or self.algorithm != self._algorithm):
+ key = LegacyKey()
+ key.app = self
+
+ key.algorithm = self._algorithm
+ key.strategy = self._strategy
+ key.schema = self._schema
+
+ key.private_key = self._private_key
+ key.public_key = self._public_key
+
+ key.webhook_url = self._webhook_url
+ key.redirect_url = self._redirect_url
+
+ key.save()
+
+ self._algorithm = self.algorithm
+ self._strategy = self.strategy
+ self._schema = self.schema
+
+ self._private_key = self.private_key
+ self._public_key = self.public_key
+
+ self._webhook_url = self.webhook_url
+ self._redirect_url = self.redirect_url
+
+
+LEGACY_KEY_LIFETIME = timezone.timedelta(minutes=2)
+
+
+class LegacyKey(models.Model):
+
+ app = models.OneToOneField(App, on_delete=models.CASCADE, related_name='legacy_key')
+
+ algorithm = models.CharField(max_length=11, choices=AUTH_ALGORITHM)
+ strategy = models.CharField(max_length=9, choices=AUTH_STRATEGY)
+ schema = models.CharField(max_length=4, choices=AUTH_SCHEMA)
+
+ private_key = models.CharField(max_length=255, blank=True, null=False)
+ public_key = models.CharField(max_length=255, blank=True, null=True, default=None)
+
+ webhook_url = models.URLField()
+ redirect_url = models.URLField()
+
+ created_at = models.DateTimeField(auto_now_add=True, editable=False)
+ updated_at = models.DateTimeField(auto_now=True, editable=False)
+
+ def __str__(self):
+ return f'{self.name} ({self.slug})'
+
+ def clean(self) -> None:
+ if self.public_key and self.algorithm in SYMMETRIC_ALGORITHMS:
+ raise forms.ValidationError('Public key is not required for symmetric algorithms')
+
+ if not self.public_key and not self.private_key:
+ raise forms.ValidationError('Public and private keys are required')
+
+ return super().clean()
+
+ def save(self, *args, **kwargs):
+ self.full_clean()
+ super().save(*args, **kwargs)
+
+ tasks.destroy_legacy_key.apply_async(args=(self.id), eta=LEGACY_KEY_LIFETIME)
+
+
PENDING = 'PENDING'
ACCEPTED = 'ACCEPTED'
REJECTED = 'REJECTED'
diff --git a/breathecode/authenticate/tasks.py b/breathecode/authenticate/tasks.py
index 456629270..f81747f51 100644
--- a/breathecode/authenticate/tasks.py
+++ b/breathecode/authenticate/tasks.py
@@ -73,3 +73,9 @@ def async_accept_user_from_waiting_list(user_invite_id: int) -> None:
'SUBJECT': 'Set your password at 4Geeks',
'LINK': os.getenv('API_URL', '') + f'/v1/auth/password/{invite.token}'
})
+
+
+@shared_task
+def destroy_legacy_key(legacy_key_id):
+ from .models import LegacyKey
+ LegacyKey.objects.filter(id=legacy_key_id).delete()
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index 25d01c166..48240d7f3 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -39,6 +39,7 @@
from breathecode.utils.api_view_extensions.api_view_extensions import \
APIViewExtensions
from breathecode.utils.decorators import has_permission
+from breathecode.utils.decorators.scope import scope
from breathecode.utils.find_by_full_name import query_like_by_full_name
from breathecode.utils.i18n import translation
from breathecode.utils.multi_status_response import MultiStatusResponse
@@ -2360,3 +2361,23 @@ def delete(self, request):
instance.delete()
return Response(None, status=status.HTTP_204_NO_CONTENT)
+
+
+# app/example
+class ExampleView(APIView):
+ extensions = APIViewExtensions(paginate=True)
+
+ @scope(['read:example'])
+ def get(self, request, app_id, token: dict):
+ pass
+
+ @scope(['create:example'])
+ def post(self, request, app_id, token: dict):
+ pass
+
+
+# app/webhook
+@api_view(['POST'])
+@scope(['webhook'])
+def app_webhook(request, app_id):
+ pass
diff --git a/breathecode/utils/decorators/__init__.py b/breathecode/utils/decorators/__init__.py
index 2e3f2f290..483b97aa9 100644
--- a/breathecode/utils/decorators/__init__.py
+++ b/breathecode/utils/decorators/__init__.py
@@ -1,4 +1,5 @@
from .capable_of import *
from .has_permission import *
+from .scope import *
from .validate_captcha import *
from .task import *
diff --git a/breathecode/utils/decorators/scope.py b/breathecode/utils/decorators/scope.py
new file mode 100644
index 000000000..a314403f8
--- /dev/null
+++ b/breathecode/utils/decorators/scope.py
@@ -0,0 +1,201 @@
+from datetime import timedelta
+import hashlib
+import hmac
+import logging
+
+from django.utils import timezone
+import jwt
+from rest_framework.views import APIView
+import urllib.parse
+
+from ..exceptions import ProgrammingError
+from ..validation_exception import ValidationException
+
+__all__ = ['scope']
+
+logger = logging.getLogger(__name__)
+
+
+def link_schema(request, required_scopes, authorization: str, use_signature: bool):
+ """
+ Authenticate the request and return a two-tuple of (user, token).
+ """
+ from breathecode.authenticate.models import App
+ from breathecode.authenticate.actions import get_app_keys
+
+ try:
+ authorization = dict([x.split('=') for x in authorization.split(',')])
+
+ except:
+ raise ValidationException('Unauthorized', code=401, slug='authorization-header-malformed')
+
+ if authorization.keys() != ['App', 'Token']:
+ raise ValidationException('Unauthorized', code=401, slug='authorization-header-bad-schema')
+
+ info, key, legacy_key = get_app_keys(authorization['App'])
+ app_id, alg, strategy, schema, scopes = info
+ public_key, private_key = key
+
+ for s in required_scopes:
+ if s not in scopes:
+ raise ValidationException('Unauthorized', code=401, slug='forbidden-scope')
+
+ if schema != 'LINK':
+ raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-schema')
+
+ if strategy != 'JWT':
+ raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-strategy')
+
+ try:
+ key = public_key if public_key else private_key
+ payload = jwt.decode(authorization['Token'], key, algorithms=[alg])
+
+ except:
+ if not legacy_key:
+ raise ValidationException('Unauthorized', code=401, slug='wrong-app-token')
+
+ try:
+ legacy_public_key, legacy_private_key = legacy_key
+
+ key = legacy_public_key if legacy_public_key else legacy_private_key
+ payload = jwt.decode(authorization['Token'], key, algorithms=['HS256'])
+
+ except:
+ raise ValidationException('Unauthorized', code=401, slug='wrong-legacy-app-token')
+
+ if 'exp' not in payload or payload['exp'] < timezone.now().timestamp():
+ raise ValidationException('Expired token', code=401, slug='expired')
+
+ return app_id, payload
+
+
+def get_payload(request):
+ headers = request.headers
+ headers.pop('Authorization', None)
+ payload = {
+ 'body': request.body,
+ 'headers': headers,
+ 'query_params': request.query_params,
+ }
+
+ return payload
+
+
+def hmac_signature(request, key, fn):
+ payload = get_payload(request)
+
+ paybytes = urllib.parse.urlencode(payload).encode('utf8')
+
+ return hmac.new(key, paybytes, fn).hexdigest()
+
+
+TOLERANCE = 2
+
+
+def signature_schema(request, required_scopes, authorization: str, use_signature: bool):
+ """
+ Authenticate the request and return a two-tuple of (user, token).
+ """
+ from breathecode.authenticate.models import App
+ from breathecode.authenticate.actions import get_app_keys
+
+ try:
+ authorization = dict([x.split('=') for x in authorization.split(',')])
+
+ except:
+ raise ValidationException('Unauthorized', code=401, slug='authorization-header-malformed')
+
+ if authorization.keys() != ['App', 'Token']:
+ raise ValidationException('Unauthorized', code=401, slug='authorization-header-bad-schema')
+
+ info, key, legacy_key = get_app_keys(authorization['App'])
+ app_id, alg, strategy, schema, scopes = info
+ public_key, private_key = key
+
+ for s in required_scopes:
+ if s not in scopes:
+ raise ValidationException('Unauthorized', code=401, slug='forbidden-scope')
+
+ if schema != 'LINK':
+ raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-schema')
+
+ if strategy != 'SIGNATURE' and not use_signature:
+ raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-strategy')
+
+ if alg not in ['HS256', 'HS512']:
+ raise ValidationException('Algorithm not implemented', code=401, slug='algorithm-not-implemented')
+
+ fn = hashlib.sha256 if alg == 'HS256' else hashlib.sha512
+
+ key = public_key if public_key else private_key
+ if hmac_signature(request, key, fn) != authorization['Token'] and not legacy_key:
+ if not legacy_key:
+ raise ValidationException('Unauthorized', code=401, slug='wrong-app-token')
+
+ legacy_public_key, legacy_private_key = legacy_key
+ key = legacy_public_key if legacy_public_key else legacy_private_key
+ if hmac_signature(request, key, fn) != authorization['Token']:
+ raise ValidationException('Unauthorized', code=401, slug='wrong-legacy-app-token')
+
+ try:
+ timestamp = float(request.headers.get('Timestamp', ''))
+ if ((timezone.now() - timedelta(minutes=TOLERANCE)).timestamp() < timestamp <
+ (timezone.now() + timedelta(minutes=TOLERANCE)).timestamp()):
+ raise Exception()
+
+ except:
+ raise ValidationException('Unauthorized', code=401, slug='bad-timestamp')
+
+ return app_id
+
+
+def scope(scopes: list = [], use_signature: bool = False) -> callable:
+ """This decorator check if the app has access to the scope provided"""
+
+ from breathecode.authenticate.models import App
+
+ def decorator(function: callable) -> callable:
+
+ def wrapper(*args, **kwargs):
+
+ if isinstance(scopes, list) == False:
+ raise ProgrammingError('Permission must be a list')
+
+ if len([x for x in scopes if not isinstance(x, str)]):
+ raise ProgrammingError('Permission must be a list of strings')
+
+ try:
+ if hasattr(args[0], '__class__') and isinstance(args[0], APIView):
+ request = args[1]
+
+ elif hasattr(args[0], 'user'):
+ request = args[0]
+
+ else:
+ raise IndexError()
+
+ except IndexError:
+ raise ProgrammingError('Missing request information, use this decorator with DRF View')
+
+ authorization = request.headers.get('Authorization', '')
+ if not authorization:
+ raise ValidationException('Unauthorized', code=401, slug='no-authorization-header')
+
+ if authorization.startswith('Link ') and not use_signature:
+ authorization = authorization.replace('Link ', '')
+ app_id, token = link_schema(request, scopes, authorization, use_signature)
+ function(*args, **kwargs, token=token, app_id=app_id)
+
+ elif authorization.startswith('Signature '):
+ authorization = authorization.replace('Signature ', '')
+ app_id = signature_schema(request, scopes, authorization, use_signature)
+ function(*args, **kwargs, app_id=app_id)
+
+ else:
+ raise ValidationException('Unknown auth schema or this schema is forbidden',
+ code=401,
+ slug='unknown-auth-schema')
+
+ return wrapper
+
+ return decorator
From 23098e87a4376cef3cf203c9587bf827d834258b Mon Sep 17 00:00:00 2001
From: jefer94
Date: Thu, 20 Jul 2023 22:18:34 -0500
Subject: [PATCH 02/19] add new view and a service wrapper
---
breathecode/authenticate/models.py | 2 +
breathecode/authenticate/serializers.py | 30 +++--
breathecode/authenticate/views.py | 35 ++++--
breathecode/utils/service.py | 143 ++++++++++++++++++++++++
4 files changed, 189 insertions(+), 21 deletions(-)
create mode 100644 breathecode/utils/service.py
diff --git a/breathecode/authenticate/models.py b/breathecode/authenticate/models.py
index 579362aa9..81a23e12c 100644
--- a/breathecode/authenticate/models.py
+++ b/breathecode/authenticate/models.py
@@ -186,6 +186,8 @@ def __init__(self, *args, **kwargs):
private_key = models.CharField(max_length=255, blank=True, null=False)
public_key = models.CharField(max_length=255, blank=True, null=True, default=None)
+ belongs_to_breathecode = models.BooleanField(default=False,
+ help_text='If true, this app belongs to breathecode')
webhook_url = models.URLField()
redirect_url = models.URLField()
diff --git a/breathecode/authenticate/serializers.py b/breathecode/authenticate/serializers.py
index 2d23061db..0c22897ac 100644
--- a/breathecode/authenticate/serializers.py
+++ b/breathecode/authenticate/serializers.py
@@ -349,25 +349,15 @@ class GetPermissionSmallSerializer(serpy.Serializer):
codename = serpy.Field()
-class UserSerializer(serpy.Serializer):
- """The serializer schema definition."""
+class AppUserSerializer(serpy.Serializer):
+
# Use a Field subclass like IntField if you need more validation.
id = serpy.Field()
email = serpy.Field()
first_name = serpy.Field()
last_name = serpy.Field()
github = serpy.MethodField()
- roles = serpy.MethodField()
profile = serpy.MethodField()
- permissions = serpy.MethodField()
-
- def get_permissions(self, obj):
- permissions = Permission.objects.none()
-
- for group in obj.groups.all():
- permissions |= group.permissions.all()
-
- return GetPermissionSmallSerializer(permissions.distinct().order_by('-id'), many=True).data
def get_profile(self, obj):
if not hasattr(obj, 'profile'):
@@ -381,6 +371,22 @@ def get_github(self, obj):
return None
return GithubSmallSerializer(github).data
+
+class UserSerializer(AppUserSerializer):
+ """The serializer schema definition."""
+ # Use a Field subclass like IntField if you need more validation.
+
+ roles = serpy.MethodField()
+ permissions = serpy.MethodField()
+
+ def get_permissions(self, obj):
+ permissions = Permission.objects.none()
+
+ for group in obj.groups.all():
+ permissions |= group.permissions.all()
+
+ return GetPermissionSmallSerializer(permissions.distinct().order_by('-id'), many=True).data
+
def get_roles(self, obj):
roles = ProfileAcademy.objects.filter(user=obj.id)
return ProfileAcademySmallSerializer(roles, many=True).data
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index 48240d7f3..e8d41b766 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -54,15 +54,14 @@
SyncGithubUsersForm)
from .models import (CredentialsFacebook, CredentialsGithub, CredentialsGoogle, CredentialsSlack, GitpodUser,
Profile, ProfileAcademy, Role, Token, UserInvite, GithubAcademyUser, AcademyAuthSettings)
-from .serializers import (AuthSerializer, GetGitpodUserSerializer, GetProfileAcademySerializer,
- GetProfileAcademySmallSerializer, GetProfileSerializer, GitpodUserSmallSerializer,
- MemberPOSTSerializer, MemberPUTSerializer, ProfileAcademySmallSerializer,
- ProfileSerializer, RoleBigSerializer, RoleSmallSerializer, StudentPOSTSerializer,
- TokenSmallSerializer, UserInviteSerializer, UserInviteShortSerializer,
- UserInviteSmallSerializer, UserInviteWaitingListSerializer, UserMeSerializer,
- UserSerializer, UserSmallSerializer, UserTinySerializer, GithubUserSerializer,
- PUTGithubUserSerializer, AuthSettingsBigSerializer, AcademyAuthSettingsSerializer,
- POSTGithubUserSerializer)
+from .serializers import (
+ AppUserSerializer, AuthSerializer, GetGitpodUserSerializer, GetProfileAcademySerializer,
+ GetProfileAcademySmallSerializer, GetProfileSerializer, GitpodUserSmallSerializer, MemberPOSTSerializer,
+ MemberPUTSerializer, ProfileAcademySmallSerializer, ProfileSerializer, RoleBigSerializer,
+ RoleSmallSerializer, StudentPOSTSerializer, TokenSmallSerializer, UserInviteSerializer,
+ UserInviteShortSerializer, UserInviteSmallSerializer, UserInviteWaitingListSerializer, UserMeSerializer,
+ UserSerializer, UserSmallSerializer, UserTinySerializer, GithubUserSerializer, PUTGithubUserSerializer,
+ AuthSettingsBigSerializer, AcademyAuthSettingsSerializer, POSTGithubUserSerializer)
logger = logging.getLogger(__name__)
APP_URL = os.getenv('APP_URL', '')
@@ -2376,6 +2375,24 @@ def post(self, request, app_id, token: dict):
pass
+# app/user/:id
+class AppUserView(APIView):
+ extensions = APIViewExtensions(paginate=True)
+
+ @scope(['read:user'])
+ def get(self, request, app_id, token: dict, user_id=None):
+ lang = get_user_language(request)
+ user = User.objects.filter(id=user_id).first()
+ if not user:
+ raise ValidationException(translation(lang, en='User not found', es='Usuario no encontrado'),
+ code=404,
+ slug='user-not-found',
+ silent=True)
+
+ serializer = AppUserSerializer(user, many=False)
+ return Response(serializer.data)
+
+
# app/webhook
@api_view(['POST'])
@scope(['webhook'])
diff --git a/breathecode/utils/service.py b/breathecode/utils/service.py
new file mode 100644
index 000000000..562481404
--- /dev/null
+++ b/breathecode/utils/service.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+from datetime import datetime, timedelta
+from functools import lru_cache
+import hashlib
+import hmac
+import os
+from typing import Optional
+import jwt
+import requests
+import urllib.parse
+from breathecode.authenticate.models import App
+from breathecode.tests.mixins import DatetimeMixin
+
+__all__ = ['get_app', 'Service']
+
+
+@lru_cache(maxsize=100)
+def get_app(pk: str | int) -> App:
+ kwargs = {}
+
+ if isinstance(pk, int):
+ kwargs['id'] = pk
+
+ elif isinstance(pk, str):
+ kwargs['slug'] = pk
+
+ else:
+ raise Exception('Invalid pk type')
+
+ if not (app := App.objects.filter(kwargs).first()):
+ raise Exception('App not found')
+
+ return app
+
+
+class Service:
+
+ def __init__(self, app_pk: str | int, user_pk: Optional[str | int] = None, use_signature: bool = False):
+ self.app = get_app(app_pk)
+ self.user_pk = user_pk
+ self.use_signature = use_signature
+
+ def _sign(self, method, params=None, data=None, json=None, **kwargs) -> requests.Request:
+ headers = kwargs.pop('headers', {})
+ headers.pop('Authorization', None)
+ payload = {
+ 'method': method,
+ 'params': params,
+ 'data': json if json else data,
+ 'headers': headers,
+ }
+
+ paybytes = urllib.parse.urlencode(payload).encode('utf8')
+
+ if self.app.algorithm == 'HMAC_SHA256':
+ sign = hmac.new(self.app.private_key, paybytes, hashlib.sha256).hexdigest()
+
+ elif self.app.algorithm == 'HMAC_SHA512':
+ sign = hmac.new(self.app.private_key, paybytes, hashlib.sha512).hexdigest()
+
+ else:
+ raise Exception('Algorithm not implemented')
+
+ headers['Authorization'] = (f'Signature App=breathecode,'
+ f'Nonce={sign},'
+ f'SignedHeaders={";".join(headers.keys())},'
+ f'Date={datetime.utcnow().isoformat()}')
+
+ return headers
+
+ def _jwt(self, method, **kwargs) -> requests.Request:
+ headers = kwargs.pop('headers', {})
+ # headers.pop('Authorization', None)
+ now = datetime.utcnow()
+
+ # https://datatracker.ietf.org/doc/html/rfc7519#section-4
+ payload = {
+ 'sub': self.user_pk,
+ 'iss': os.getenv('API_URL', 'http://localhost:8000'),
+ 'app': 'breathecode',
+ 'aud': self.app.slug,
+ 'exp': datetime.timestamp(now + timedelta(minutes=2)),
+ 'iat': datetime.timestamp(now),
+ 'typ': 'JWT',
+ }
+
+ if self.app.algorithm == 'HMAC_SHA256':
+ token = jwt.encode(payload, self.app.private_key, algorithm='HS256')
+
+ elif self.app.algorithm == 'HMAC_SHA512':
+ token = jwt.encode(payload, self.app.private_key, algorithm='HS512')
+
+ elif self.app.algorithm == 'ED25519':
+ token = jwt.encode(payload, self.app.private_key, algorithm='EdDSA')
+
+ else:
+ raise Exception('Algorithm not implemented')
+
+ headers['Authorization'] = (f'Link App=breathecode,'
+ f'Token={token}')
+
+ return headers
+
+ def _authenticate(self, method, params=None, data=None, json=None, **kwargs) -> requests.Request:
+ if self.app.strategy == 'SIGNATURE' or self.use_signature:
+ return self._sign(method, params=params, data=data, json=json, **kwargs)
+
+ elif self.app.strategy == 'JWT':
+ return self._jwt(self, method, **kwargs)
+
+ raise Exception('Strategy not implemented')
+
+ def get(self, url, params=None, **kwargs):
+ headers = self._authenticate('get', params=params, **kwargs)
+ return requests.get(url, params=params, **kwargs, headers=headers)
+
+ def options(self, url, **kwargs):
+ headers = self._authenticate('options', **kwargs)
+ return requests.options(url, **kwargs, headers=headers)
+
+ def head(self, url, **kwargs):
+ headers = self._authenticate('head', **kwargs)
+ return requests.head(url, **kwargs, headers=headers)
+
+ def post(self, url, data=None, json=None, **kwargs):
+ headers = self._authenticate('post', data=data, json=json, **kwargs)
+ return requests.post(url, data=data, json=json, **kwargs, headers=headers)
+
+ def put(self, url, data=None, **kwargs):
+ headers = self._authenticate('put', data=data, **kwargs)
+ return requests.put(url, data=data, **kwargs, headers=headers)
+
+ def patch(self, url, data=None, **kwargs):
+ headers = self._authenticate('patch', data=data, **kwargs)
+ return requests.patch(url, data=data, **kwargs, headers=headers)
+
+ def delete(self, url, **kwargs):
+ headers = self._authenticate('delete', **kwargs)
+ return requests.delete(url, **kwargs, headers=headers)
+
+ def request(self, method, url, **kwargs):
+ headers = self._authenticate(method, **kwargs)
+ return requests.request(method, url, **kwargs, headers=headers)
From d3bb045dc931c8b708865182d8eb561f4774d89f Mon Sep 17 00:00:00 2001
From: jefer94
Date: Mon, 24 Jul 2023 18:02:00 -0500
Subject: [PATCH 03/19] update models
---
breathecode/authenticate/models.py | 20 +++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)
diff --git a/breathecode/authenticate/models.py b/breathecode/authenticate/models.py
index 3fde6e8e0..d8d14b1c2 100644
--- a/breathecode/authenticate/models.py
+++ b/breathecode/authenticate/models.py
@@ -183,12 +183,15 @@ def __init__(self, *args, **kwargs):
strategy = models.CharField(max_length=9, choices=AUTH_STRATEGY)
schema = models.CharField(max_length=4, choices=AUTH_SCHEMA)
- scopes = models.ManyToManyField(Scope, blank=True)
+ available_scopes = models.ManyToManyField(Scope, blank=True)
+ optional_scopes = models.ManyToManyField(Scope, blank=True)
+ agreement_version = models.IntegerField(default=1,
+ help_text='Version of the agreement, based in the scopes')
private_key = models.CharField(max_length=255, blank=True, null=False)
public_key = models.CharField(max_length=255, blank=True, null=True, default=None)
- belongs_to_breathecode = models.BooleanField(default=False,
- help_text='If true, this app belongs to breathecode')
+ require_an_agreement = models.BooleanField(
+ default=False, help_text='If true, the user will be required to accept an agreement')
webhook_url = models.URLField()
redirect_url = models.URLField()
@@ -244,6 +247,17 @@ def save(self, *args, **kwargs):
self._redirect_url = self.redirect_url
+class OptionalScopesCache(models.Model):
+ optional_scopes = models.ManyToManyField(Scope, blank=True)
+
+
+class AppUserAgreement(models.Model):
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
+ app = models.ForeignKey(App, on_delete=models.CASCADE)
+ optional_scopes_cache = models.ForeignKey(OptionalScopesCache, on_delete=models.CASCADE)
+ agreement_version = models.IntegerField(default=1, help_text='Version of the agreement that was accepted')
+
+
LEGACY_KEY_LIFETIME = timezone.timedelta(minutes=2)
From 7f01039195b14ffce8a1cde81381aa572178625e Mon Sep 17 00:00:00 2001
From: jefer94
Date: Tue, 25 Jul 2023 14:22:38 -0500
Subject: [PATCH 04/19] add two commands to issue the signature
---
breathecode/authenticate/actions.py | 158 ++++++++++++++++--
breathecode/authenticate/admin.py | 23 ++-
.../management/commands/sign_jwt.py | 25 +++
.../management/commands/sign_request.py | 42 +++++
.../migrations/0041_auto_20230725_0619.py | 118 +++++++++++++
breathecode/authenticate/models.py | 60 +++++--
breathecode/authenticate/tasks.py | 3 +-
breathecode/authenticate/urls.py | 22 ++-
breathecode/authenticate/views.py | 10 +-
breathecode/utils/decorators/scope.py | 127 +++++++++-----
breathecode/utils/service.py | 54 ++----
11 files changed, 515 insertions(+), 127 deletions(-)
create mode 100644 breathecode/authenticate/management/commands/sign_jwt.py
create mode 100644 breathecode/authenticate/management/commands/sign_request.py
create mode 100644 breathecode/authenticate/migrations/0041_auto_20230725_0619.py
diff --git a/breathecode/authenticate/actions.py b/breathecode/authenticate/actions.py
index aa4b7d602..73e3b3501 100644
--- a/breathecode/authenticate/actions.py
+++ b/breathecode/authenticate/actions.py
@@ -1,12 +1,17 @@
import datetime
+import hashlib
+import hmac
import logging
import os
import random
import re
+import secrets
import string
+from typing import Any, Optional
import urllib.parse
from random import randint
from django.core.handlers.wsgi import WSGIRequest
+import jwt
import breathecode.notify.actions as notify_actions
from rest_framework.exceptions import AuthenticationFailed
from functools import lru_cache
@@ -21,8 +26,8 @@
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
-from .models import (ASYMMETRIC_ALGORITHMS, CredentialsGithub, DeviceId, GitpodUser, ProfileAcademy, Role,
- Token, UserSetting, AcademyAuthSettings, GithubAcademyUser)
+from .models import (ASYMMETRIC_ALGORITHMS, App, CredentialsGithub, DeviceId, GitpodUser, ProfileAcademy,
+ Role, Token, UserSetting, AcademyAuthSettings, GithubAcademyUser)
logger = logging.getLogger(__name__)
@@ -636,16 +641,93 @@ def sync_organization_members(academy_id, only_status=[]):
# _member.save()
# return False
+JWT_LIFETIME = 10
+
+
+def get_jwt(app: App, user_id: Optional[int] = None, reverse: bool = False):
+ from datetime import datetime, timedelta
+ now = datetime.utcnow()
+
+ # https://datatracker.ietf.org/doc/html/rfc7519#section-4
+ payload = {
+ 'sub': user_id,
+ 'iss': os.getenv('API_URL', 'http://localhost:8000'),
+ 'app': 'breathecode',
+ 'aud': app.slug,
+ 'exp': datetime.timestamp(now + timedelta(minutes=JWT_LIFETIME)),
+ 'iat': datetime.timestamp(now),
+ 'typ': 'JWT',
+ }
+
+ if reverse:
+ payload['app'] = app.slug
+ payload['aud'] = 'breathecode'
+
+ if app.algorithm == 'HMAC_SHA256':
+
+ token = jwt.encode(payload, bytes.fromhex(app.private_key), algorithm='HS256')
+
+ elif app.algorithm == 'HMAC_SHA512':
+ token = jwt.encode(payload, bytes.fromhex(app.private_key), algorithm='HS512')
+
+ elif app.algorithm == 'ED25519':
+ token = jwt.encode(payload, bytes.fromhex(app.private_key), algorithm='EdDSA')
+
+ else:
+ raise Exception('Algorithm not implemented')
+
+ return token
+
+
+def get_signature(app: App,
+ user_id: Optional[int] = None,
+ *,
+ method: str = 'get',
+ params: dict = {},
+ body: Optional[dict] = None,
+ headers: dict = {},
+ reverse: bool = False):
+ from datetime import datetime
+ now = datetime.utcnow().isoformat()
+
+ payload = {
+ 'timestamp': now,
+ 'app': 'breathecode',
+ 'method': method.upper(),
+ 'params': params,
+ 'body': body,
+ 'headers': headers,
+ }
+
+ if reverse:
+ payload['app'] = app.slug
+
+ paybytes = urllib.parse.urlencode(payload).encode('utf8')
+
+ if app.algorithm == 'HMAC_SHA256':
+ sign = hmac.new(bytes.fromhex(app.private_key), paybytes, hashlib.sha256).hexdigest()
+
+ elif app.algorithm == 'HMAC_SHA512':
+ sign = hmac.new(bytes.fromhex(app.private_key), paybytes, hashlib.sha512).hexdigest()
+
+ else:
+ raise Exception('Algorithm not implemented')
+
+ return sign, now
+
def generate_auth_keys(algorithm) -> tuple[bytes, bytes]:
public_key = None
key = Ed25519PrivateKey.generate()
- private_key = key.private_bytes(encoding=Encoding.PEM,
- format=PrivateFormat.PKCS8,
- encryption_algorithm=NoEncryption()).hex()
+ if algorithm == 'HMAC_SHA256' or algorithm == 'HMAC_SHA512':
+ private_key = secrets.token_hex(64)
+
+ elif algorithm == 'ED25519':
+ private_key = key.private_bytes(encoding=Encoding.PEM,
+ format=PrivateFormat.PKCS8,
+ encryption_algorithm=NoEncryption()).hex()
- if algorithm in ASYMMETRIC_ALGORITHMS:
public_key = key.public_key().public_bytes(encoding=Encoding.PEM,
format=PublicFormat.SubjectPublicKeyInfo).hex()
@@ -653,9 +735,48 @@ def generate_auth_keys(algorithm) -> tuple[bytes, bytes]:
@lru_cache(maxsize=100)
-def get_app_keys(app_id):
+def get_optional_scopes_set(scope_set_id):
+ from .models import OptionalScopeSet
+
+ scope_set = OptionalScopeSet.objects.filter(id=scope_set_id).first()
+ if scope_set is None:
+ raise Exception(f'Invalid scope set id: {scope_set_id}')
+
+ # use structure that use lower memory
+ return tuple(sorted(x for x in scope_set.optional_scopes.all()))
+
+
+def get_user_scopes(app_slug, user_id):
+ from .models import AppUserAgreement
+
+ info, _, _ = get_app_keys(app_slug)
+ _, _, _, _, require_an_agreement, required_scopes, optional_scopes = info
+
+ if require_an_agreement:
+ agreement = AppUserAgreement.objects.filter(app__id=app_id, user__id=user_id).first()
+ if not agreement:
+ raise ValidationException('User has not accepted the agreement',
+ slug='agreement-not-accepted',
+ silent=True,
+ data={
+ 'app_slug': app_slug,
+ 'user_id': user_id
+ })
+
+ optional_scopes = get_optional_scopes_set(agreement.optional_scope_set.id)
+
+ # use structure that use lower memory
+ return required_scopes, optional_scopes
+
+
+@lru_cache(maxsize=100)
+def get_app_keys(app_slug):
from .models import App
- app = App.objects.filter(id=app_id).first()
+
+ app = App.objects.filter(slug=app_slug).first()
+
+ if app is None:
+ raise AuthenticationFailed({'error': 'Unauthorized', 'is_authenticated': False})
if app.algorithm == 'HMAC_SHA256':
alg = 'HS256'
@@ -669,14 +790,11 @@ def get_app_keys(app_id):
else:
raise AuthenticationFailed({'error': 'Algorithm not implemented', 'is_authenticated': False})
- if app is None:
- raise AuthenticationFailed({'error': 'Unauthorized', 'is_authenticated': False})
-
legacy_public_key = None
legacy_private_key = None
legacy_key = None
if hasattr(app, 'legacy_key'):
- legacy_public_key = bytes.fromhex(app.legacy_key.public_key)
+ legacy_public_key = bytes.fromhex(app.legacy_key.public_key) if app.legacy_key.public_key else None
legacy_private_key = bytes.fromhex(app.legacy_key.private_key)
legacy_key = (
legacy_public_key,
@@ -688,11 +806,23 @@ def get_app_keys(app_id):
alg,
app.strategy,
app.schema,
- (x.slug for x in app.scopes.all()),
+ app.require_an_agreement,
+ tuple(sorted(x.slug for x in app.required_scopes.all())),
+ tuple(sorted(x.slug for x in app.optional_scopes.all())),
)
key = (
- bytes.fromhex(app.public_key),
+ bytes.fromhex(app.public_key) if app.public_key else None,
bytes.fromhex(app.private_key),
)
+ # use structure that use lower memory
return info, key, legacy_key
+
+
+def reset_app_cache():
+ get_app_keys.cache_clear()
+ get_optional_scopes_set.cache_clear()
+
+
+def reset_app_user_cache():
+ get_optional_scopes_set.cache_clear()
diff --git a/breathecode/authenticate/admin.py b/breathecode/authenticate/admin.py
index 4fc634d72..f9186c35c 100644
--- a/breathecode/authenticate/admin.py
+++ b/breathecode/authenticate/admin.py
@@ -7,9 +7,9 @@
from .actions import (delete_tokens, generate_academy_token, set_gitpod_user_expiration, reset_password,
sync_organization_members)
from django.utils.html import format_html
-from .models import (CredentialsGithub, DeviceId, Token, UserProxy, Profile, CredentialsSlack, ProfileAcademy,
- Role, CredentialsFacebook, Capability, UserInvite, CredentialsGoogle, AcademyProxy,
- GitpodUser, GithubAcademyUser, AcademyAuthSettings, GithubAcademyUserLog)
+from .models import (App, CredentialsGithub, DeviceId, LegacyKey, Token, UserProxy, Profile, CredentialsSlack,
+ ProfileAcademy, Role, CredentialsFacebook, Capability, UserInvite, CredentialsGoogle,
+ AcademyProxy, GitpodUser, GithubAcademyUser, AcademyAuthSettings, GithubAcademyUserLog)
from .tasks import async_set_gitpod_user_expiration
from breathecode.utils.admin import change_field
from django.contrib.admin import SimpleListFilter
@@ -443,3 +443,20 @@ def authenticate(self, obj):
return format_html(
f"connect owner"
)
+
+
+@admin.register(App)
+class AppAdmin(admin.ModelAdmin):
+ list_display = ('name', 'slug', 'algorithm', 'strategy', 'schema', 'agreement_version',
+ 'require_an_agreement')
+ search_fields = ['name', 'slug']
+ list_filter = ['algorithm', 'strategy', 'schema', 'require_an_agreement']
+ actions = []
+
+
+@admin.register(LegacyKey)
+class AppAdmin(admin.ModelAdmin):
+ list_display = ('app', 'algorithm', 'strategy', 'schema')
+ search_fields = ['app__name', 'app__slug']
+ list_filter = ['algorithm', 'strategy', 'schema']
+ actions = []
diff --git a/breathecode/authenticate/management/commands/sign_jwt.py b/breathecode/authenticate/management/commands/sign_jwt.py
new file mode 100644
index 000000000..bc3a14c5c
--- /dev/null
+++ b/breathecode/authenticate/management/commands/sign_jwt.py
@@ -0,0 +1,25 @@
+import os
+from django.core.management.base import BaseCommand
+
+HOST = os.environ.get('OLD_BREATHECODE_API')
+DATETIME_FORMAT = '%Y-%m-%d'
+
+
+class Command(BaseCommand):
+ help = 'Sync academies from old breathecode'
+
+ def add_arguments(self, parser):
+ parser.add_argument('app', nargs='?', type=int)
+ parser.add_argument('user', nargs='?', type=int)
+
+ def handle(self, *args, **options):
+ from ...models import App, User
+ from ...actions import get_jwt
+
+ if not options['app']:
+ raise Exception('Missing app id')
+
+ app = App.objects.get(id=options['app'])
+ token = get_jwt(app, user_id=options['user'], reverse=True)
+
+ print(f'Authorization: Link App={app.slug},Token={token}')
diff --git a/breathecode/authenticate/management/commands/sign_request.py b/breathecode/authenticate/management/commands/sign_request.py
new file mode 100644
index 000000000..76799595c
--- /dev/null
+++ b/breathecode/authenticate/management/commands/sign_request.py
@@ -0,0 +1,42 @@
+import os
+from django.core.management.base import BaseCommand
+
+HOST = os.environ.get('OLD_BREATHECODE_API')
+DATETIME_FORMAT = '%Y-%m-%d'
+
+
+class Command(BaseCommand):
+ help = 'Sync academies from old breathecode'
+
+ def add_arguments(self, parser):
+ parser.add_argument('app', nargs='?', type=int)
+ parser.add_argument('user', nargs='?', type=int)
+ parser.add_argument('method', nargs='?', type=str)
+ parser.add_argument('params', nargs='?', type=str)
+ parser.add_argument('body', nargs='?', type=str)
+ parser.add_argument('headers', nargs='?', type=str)
+
+ def handle(self, *args, **options):
+ from ...models import App, User
+ from ...actions import get_signature
+
+ if not options['app']:
+ raise Exception('Missing app id')
+
+ options['params'] = eval(options['params']) if options['params'] is not None else {}
+ options['body'] = eval(options['body']) if options['body'] is not None else None
+ options['headers'] = eval(options['headers']) if options['headers'] is not None else {}
+
+ app = App.objects.get(id=options['app'])
+ sign, now = get_signature(app,
+ options['user'],
+ method=options['method'],
+ params=options['params'],
+ body=options['body'],
+ headers=options['headers'],
+ reverse=True)
+
+ print(f'Authorization: Signature App={app.slug},'
+ f'Nonce={sign},'
+ f'SignedHeaders={";".join(options["headers"])},'
+ f'Date={now}')
diff --git a/breathecode/authenticate/migrations/0041_auto_20230725_0619.py b/breathecode/authenticate/migrations/0041_auto_20230725_0619.py
new file mode 100644
index 000000000..ecc680f65
--- /dev/null
+++ b/breathecode/authenticate/migrations/0041_auto_20230725_0619.py
@@ -0,0 +1,118 @@
+# Generated by Django 3.2.19 on 2023-07-25 06:19
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('authenticate', '0040_userinvite_is_email_validated'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='App',
+ fields=[
+ ('id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.SlugField(max_length=25, unique=True)),
+ ('slug', models.SlugField(unique=True)),
+ ('algorithm',
+ models.CharField(choices=[('HMAC_SHA256', 'HMAC-SHA256'), ('HMAC_SHA512', 'HMAC_SHA512'),
+ ('ED25519', 'ED25519')],
+ max_length=11)),
+ ('strategy',
+ models.CharField(choices=[('JWT', 'Json Web Token'), ('SIGNATURE', 'Signature')],
+ max_length=9)),
+ ('schema', models.CharField(choices=[('LINK', 'Link')], max_length=4)),
+ ('agreement_version',
+ models.IntegerField(default=1, help_text='Version of the agreement, based in the scopes')),
+ ('private_key', models.CharField(blank=True, max_length=255)),
+ ('public_key', models.CharField(blank=True, default=None, max_length=255, null=True)),
+ ('require_an_agreement',
+ models.BooleanField(default=True,
+ help_text='If true, the user will be required to accept an agreement')),
+ ('webhook_url', models.URLField()),
+ ('redirect_url', models.URLField()),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Scope',
+ fields=[
+ ('id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.SlugField(max_length=25, unique=True)),
+ ('slug', models.SlugField(unique=True)),
+ ('description', models.CharField(max_length=255)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='OptionalScopeSet',
+ fields=[
+ ('id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('optional_scopes', models.ManyToManyField(blank=True, to='authenticate.Scope')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='LegacyKey',
+ fields=[
+ ('id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('algorithm',
+ models.CharField(choices=[('HMAC_SHA256', 'HMAC-SHA256'), ('HMAC_SHA512', 'HMAC_SHA512'),
+ ('ED25519', 'ED25519')],
+ max_length=11)),
+ ('strategy',
+ models.CharField(choices=[('JWT', 'Json Web Token'), ('SIGNATURE', 'Signature')],
+ max_length=9)),
+ ('schema', models.CharField(choices=[('LINK', 'Link')], max_length=4)),
+ ('private_key', models.CharField(blank=True, max_length=255)),
+ ('public_key', models.CharField(blank=True, default=None, max_length=255, null=True)),
+ ('webhook_url', models.URLField()),
+ ('redirect_url', models.URLField()),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('app',
+ models.OneToOneField(on_delete=django.db.models.deletion.CASCADE,
+ related_name='legacy_key',
+ to='authenticate.app')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='AppUserAgreement',
+ fields=[
+ ('id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('agreement_version',
+ models.IntegerField(default=1, help_text='Version of the agreement that was accepted')),
+ ('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
+ to='authenticate.app')),
+ ('optional_scope_set',
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
+ related_name='app_user_agreement',
+ to='authenticate.optionalscopeset')),
+ ('user',
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ migrations.AddField(
+ model_name='app',
+ name='optional_scopes',
+ field=models.ManyToManyField(blank=True,
+ related_name='app_optional_scopes',
+ to='authenticate.Scope'),
+ ),
+ migrations.AddField(
+ model_name='app',
+ name='required_scopes',
+ field=models.ManyToManyField(blank=True,
+ related_name='app_required_scopes',
+ to='authenticate.Scope'),
+ ),
+ ]
diff --git a/breathecode/authenticate/models.py b/breathecode/authenticate/models.py
index d8d14b1c2..332e21a3a 100644
--- a/breathecode/authenticate/models.py
+++ b/breathecode/authenticate/models.py
@@ -12,7 +12,6 @@
from django.contrib.contenttypes.models import ContentType
from django import forms
from slugify import slugify
-from breathecode.authenticate import tasks
from breathecode.authenticate.exceptions import (BadArguments, InvalidTokenType, TokenNotFound,
TryToGetOrCreateAOneTimeToken)
@@ -117,9 +116,6 @@ class Scope(models.Model):
name = models.SlugField(max_length=25, unique=True)
slug = models.SlugField(unique=True)
description = models.CharField(max_length=255)
- internal = models.BooleanField(default=False,
- help_text='If true, this scope is only for internal use and '
- 'it will not be shown to the user')
def clean(self) -> None:
if not self.slug:
@@ -164,7 +160,7 @@ def __str__(self):
class App(models.Model):
def __init__(self, *args, **kwargs):
- super(UserInvite, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
self._algorithm = self.algorithm
self._strategy = self.strategy
@@ -183,15 +179,15 @@ def __init__(self, *args, **kwargs):
strategy = models.CharField(max_length=9, choices=AUTH_STRATEGY)
schema = models.CharField(max_length=4, choices=AUTH_SCHEMA)
- available_scopes = models.ManyToManyField(Scope, blank=True)
- optional_scopes = models.ManyToManyField(Scope, blank=True)
+ required_scopes = models.ManyToManyField(Scope, blank=True, related_name='app_required_scopes')
+ optional_scopes = models.ManyToManyField(Scope, blank=True, related_name='app_optional_scopes')
agreement_version = models.IntegerField(default=1,
help_text='Version of the agreement, based in the scopes')
private_key = models.CharField(max_length=255, blank=True, null=False)
public_key = models.CharField(max_length=255, blank=True, null=True, default=None)
require_an_agreement = models.BooleanField(
- default=False, help_text='If true, the user will be required to accept an agreement')
+ default=True, help_text='If true, the user will be required to accept an agreement')
webhook_url = models.URLField()
redirect_url = models.URLField()
@@ -204,6 +200,7 @@ def __str__(self):
def clean(self) -> None:
from .actions import generate_auth_keys
+
if not self.slug:
self.slug = slugify(self.name)
@@ -216,11 +213,15 @@ def clean(self) -> None:
return super().clean()
def save(self, *args, **kwargs):
+ from .actions import reset_app_cache
+
+ had_pk = self.pk
+
self.full_clean()
super().save(*args, **kwargs)
- if self.id and (self.private_key != self._private_key or self.public_key != self._public_key
- or self.algorithm != self._algorithm):
+ if had_pk and (self.private_key != self._private_key or self.public_key != self._public_key
+ or self.algorithm != self._algorithm):
key = LegacyKey()
key.app = self
@@ -236,6 +237,9 @@ def save(self, *args, **kwargs):
key.save()
+ if had_pk:
+ reset_app_cache()
+
self._algorithm = self.algorithm
self._strategy = self.strategy
self._schema = self.schema
@@ -247,16 +251,42 @@ def save(self, *args, **kwargs):
self._redirect_url = self.redirect_url
-class OptionalScopesCache(models.Model):
+class OptionalScopeSet(models.Model):
optional_scopes = models.ManyToManyField(Scope, blank=True)
+ def save(self, *args, **kwargs):
+ from .actions import reset_app_user_cache
+
+ had_pk = self.pk
+
+ self.full_clean()
+ super().save(*args, **kwargs)
+
+ self.__class__.objects.exclude(app_user_agreement__id__gte=1).delete()
+
+ if had_pk:
+ reset_app_user_cache()
+
class AppUserAgreement(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
app = models.ForeignKey(App, on_delete=models.CASCADE)
- optional_scopes_cache = models.ForeignKey(OptionalScopesCache, on_delete=models.CASCADE)
+ optional_scope_set = models.ForeignKey(OptionalScopeSet,
+ on_delete=models.CASCADE,
+ related_name='app_user_agreement')
agreement_version = models.IntegerField(default=1, help_text='Version of the agreement that was accepted')
+ def save(self, *args, **kwargs):
+ from .actions import reset_app_user_cache
+
+ had_pk = self.pk
+
+ self.full_clean()
+ super().save(*args, **kwargs)
+
+ if had_pk:
+ reset_app_user_cache()
+
LEGACY_KEY_LIFETIME = timezone.timedelta(minutes=2)
@@ -279,7 +309,7 @@ class LegacyKey(models.Model):
updated_at = models.DateTimeField(auto_now=True, editable=False)
def __str__(self):
- return f'{self.name} ({self.slug})'
+ return f'{self.app.name} ({self.app.slug})'
def clean(self) -> None:
if self.public_key and self.algorithm in SYMMETRIC_ALGORITHMS:
@@ -291,10 +321,12 @@ def clean(self) -> None:
return super().clean()
def save(self, *args, **kwargs):
+ from breathecode.authenticate import tasks
+
self.full_clean()
super().save(*args, **kwargs)
- tasks.destroy_legacy_key.apply_async(args=(self.id), eta=LEGACY_KEY_LIFETIME)
+ tasks.destroy_legacy_key.apply_async(args=(self.id, ), eta=timezone.now() + LEGACY_KEY_LIFETIME)
PENDING = 'PENDING'
diff --git a/breathecode/authenticate/tasks.py b/breathecode/authenticate/tasks.py
index f81747f51..a2cd8d2b7 100644
--- a/breathecode/authenticate/tasks.py
+++ b/breathecode/authenticate/tasks.py
@@ -1,6 +1,5 @@
import logging, os
from celery import shared_task, Task
-from .models import UserInvite, Token
from django.contrib.auth.models import User
from .actions import set_gitpod_user_expiration, add_to_organization, remove_from_organization
from breathecode.notify import actions as notify_actions
@@ -35,6 +34,8 @@ def async_remove_from_organization(cohort_id, user_id, force=False):
@shared_task
def async_accept_user_from_waiting_list(user_invite_id: int) -> None:
+ from .models import UserInvite
+
logger.debug(f'Process to accept UserInvite {user_invite_id}')
if not (invite := UserInvite.objects.filter(id=user_invite_id).first()):
diff --git a/breathecode/authenticate/urls.py b/breathecode/authenticate/urls.py
index d2dc68a39..0fdd4adeb 100644
--- a/breathecode/authenticate/urls.py
+++ b/breathecode/authenticate/urls.py
@@ -15,15 +15,15 @@
"""
from django.urls import path
-from .views import (AcademyInviteView, AcademyTokenView, ConfirmEmailView, GithubMeView, GitpodUserView,
- LoginView, LogoutView, MeInviteView, MemberView, PasswordResetView, ProfileInviteMeView,
- ProfileMePictureView, ProfileMeView, ResendInviteView, StudentView, TemporalTokenView,
- TokenTemporalView, UserMeView, WaitingListView, get_facebook_token, get_github_token,
- get_google_token, get_roles, get_slack_token, get_token_info, get_user_by_id_or_email,
- get_users, login_html_view, pick_password, render_academy_invite, render_invite,
- render_user_invite, reset_password_view, save_facebook_token, save_github_token,
- save_google_token, save_slack_token, sync_gitpod_users_view, GithubUserView,
- AcademyGithubSyncView, AcademyAuthSettingsView)
+from .views import (AcademyInviteView, AcademyTokenView, AppUserView, ConfirmEmailView, GithubMeView,
+ GitpodUserView, LoginView, LogoutView, MeInviteView, MemberView, PasswordResetView,
+ ProfileInviteMeView, ProfileMePictureView, ProfileMeView, ResendInviteView, StudentView,
+ TemporalTokenView, TokenTemporalView, UserMeView, WaitingListView, app_webhook,
+ get_facebook_token, get_github_token, get_google_token, get_roles, get_slack_token,
+ get_token_info, get_user_by_id_or_email, get_users, login_html_view, pick_password,
+ render_academy_invite, render_invite, render_user_invite, reset_password_view,
+ save_facebook_token, save_github_token, save_google_token, save_slack_token,
+ sync_gitpod_users_view, GithubUserView, AcademyGithubSyncView, AcademyAuthSettingsView)
app_name = 'authenticate'
urlpatterns = [
@@ -104,4 +104,8 @@
# sync with gitPOD
path('academy/gitpod/user', GitpodUserView.as_view(), name='gitpod_user'),
path('academy/gitpod/user/', GitpodUserView.as_view(), name='gitpod_user_id'),
+
+ # apps
+ path('app/user/', AppUserView.as_view(), name='app_user_id'),
+ path('app/webhook', app_webhook, name='app_webhook'),
]
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index 79189549b..593267c6e 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -2304,11 +2304,13 @@ def post(self, request, app_id, token: dict):
# app/user/:id
class AppUserView(APIView):
+ permission_classes = [AllowAny]
extensions = APIViewExtensions(paginate=True)
@scope(['read:user'])
def get(self, request, app_id, token: dict, user_id=None):
lang = get_user_language(request)
+
user = User.objects.filter(id=user_id).first()
if not user:
raise ValidationException(translation(lang, en='User not found', es='Usuario no encontrado'),
@@ -2322,6 +2324,10 @@ def get(self, request, app_id, token: dict, user_id=None):
# app/webhook
@api_view(['POST'])
-@scope(['webhook'])
+@permission_classes([AllowAny])
+@scope(['webhook'], use_signature=True)
def app_webhook(request, app_id):
- pass
+ return Response({'message': 'ok'})
+
+
+# app/webhook
diff --git a/breathecode/utils/decorators/scope.py b/breathecode/utils/decorators/scope.py
index a314403f8..c359f0085 100644
--- a/breathecode/utils/decorators/scope.py
+++ b/breathecode/utils/decorators/scope.py
@@ -1,4 +1,4 @@
-from datetime import timedelta
+from datetime import datetime, timedelta
import hashlib
import hmac
import logging
@@ -20,25 +20,25 @@ def link_schema(request, required_scopes, authorization: str, use_signature: boo
"""
Authenticate the request and return a two-tuple of (user, token).
"""
- from breathecode.authenticate.models import App
- from breathecode.authenticate.actions import get_app_keys
+ from breathecode.authenticate.actions import get_app_keys, get_user_scopes
+ print('here 0')
try:
authorization = dict([x.split('=') for x in authorization.split(',')])
except:
raise ValidationException('Unauthorized', code=401, slug='authorization-header-malformed')
- if authorization.keys() != ['App', 'Token']:
+ print('here 1', authorization.keys(), sorted(authorization.keys()) == ['App', 'Token'])
+ if sorted(authorization.keys()) != ['App', 'Token']:
raise ValidationException('Unauthorized', code=401, slug='authorization-header-bad-schema')
+ print('here 1')
info, key, legacy_key = get_app_keys(authorization['App'])
- app_id, alg, strategy, schema, scopes = info
+ app_id, alg, strategy, schema, require_an_agreement, required_app_scopes, optional_app_scopes = info
public_key, private_key = key
- for s in required_scopes:
- if s not in scopes:
- raise ValidationException('Unauthorized', code=401, slug='forbidden-scope')
+ print('here 2')
if schema != 'LINK':
raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-schema')
@@ -46,43 +46,65 @@ def link_schema(request, required_scopes, authorization: str, use_signature: boo
if strategy != 'JWT':
raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-strategy')
+ print('here 3')
try:
key = public_key if public_key else private_key
- payload = jwt.decode(authorization['Token'], key, algorithms=[alg])
+ payload = jwt.decode(authorization['Token'], key, algorithms=[alg], audience='breathecode')
- except:
+ except Exception as e:
+ print(1, e)
if not legacy_key:
raise ValidationException('Unauthorized', code=401, slug='wrong-app-token')
- try:
- legacy_public_key, legacy_private_key = legacy_key
+ print('here 4')
- key = legacy_public_key if legacy_public_key else legacy_private_key
- payload = jwt.decode(authorization['Token'], key, algorithms=['HS256'])
+ if not payload:
+ try:
+ legacy_public_key, legacy_private_key = legacy_key
- except:
- raise ValidationException('Unauthorized', code=401, slug='wrong-legacy-app-token')
+ key = legacy_public_key if legacy_public_key else legacy_private_key
+ payload = jwt.decode(authorization['Token'], key, algorithms=[alg])
+
+ except Exception as e:
+ print(2, e)
+ raise ValidationException('Unauthorized', code=401, slug='wrong-legacy-app-token')
+
+ print('here 5')
+ if require_an_agreement:
+ required_app_scopes, optional_app_scopes = get_user_scopes(authorization['App'], payload['sub'])
+ all_scopes = required_app_scopes + optional_app_scopes
+ for s in required_scopes:
+ if s not in all_scopes:
+ raise ValidationException('Unauthorized', code=401, slug='forbidden-scope')
+
+ print('here 6')
if 'exp' not in payload or payload['exp'] < timezone.now().timestamp():
raise ValidationException('Expired token', code=401, slug='expired')
+ print('here 7')
return app_id, payload
-def get_payload(request):
- headers = request.headers
+def get_payload(app, date, signed_headers, request):
+ headers = dict(request.headers)
headers.pop('Authorization', None)
payload = {
- 'body': request.body,
- 'headers': headers,
- 'query_params': request.query_params,
+ 'timestamp': date,
+ 'app': app,
+ 'method': request.method,
+ 'params': dict(request.GET),
+ 'body': request.data if request.data is not None else None,
+ 'headers': {k: v
+ for k, v in headers.items() if k in signed_headers},
}
+ print(222, payload)
return payload
-def hmac_signature(request, key, fn):
- payload = get_payload(request)
+def hmac_signature(app, date, signed_headers, request, key, fn):
+ payload = get_payload(app, date, signed_headers, request)
paybytes = urllib.parse.urlencode(payload).encode('utf8')
@@ -97,63 +119,81 @@ def signature_schema(request, required_scopes, authorization: str, use_signature
Authenticate the request and return a two-tuple of (user, token).
"""
from breathecode.authenticate.models import App
- from breathecode.authenticate.actions import get_app_keys
+ from breathecode.authenticate.actions import get_app_keys, get_user_scopes
+ print(1)
try:
authorization = dict([x.split('=') for x in authorization.split(',')])
except:
raise ValidationException('Unauthorized', code=401, slug='authorization-header-malformed')
- if authorization.keys() != ['App', 'Token']:
+ print(2)
+ if sorted(authorization.keys()) != ['App', 'Date', 'Nonce', 'SignedHeaders']:
raise ValidationException('Unauthorized', code=401, slug='authorization-header-bad-schema')
+ print(3)
info, key, legacy_key = get_app_keys(authorization['App'])
- app_id, alg, strategy, schema, scopes = info
+ app_id, alg, strategy, schema, require_an_agreement, required_app_scopes, optional_app_scopes = info
public_key, private_key = key
- for s in required_scopes:
- if s not in scopes:
- raise ValidationException('Unauthorized', code=401, slug='forbidden-scope')
+ if require_an_agreement:
+ required_app_scopes, optional_app_scopes = get_user_scopes(authorization['App'], payload['sub'])
+ all_scopes = required_app_scopes + optional_app_scopes
+
+ for s in required_scopes:
+ if s not in all_scopes:
+ raise ValidationException('Unauthorized', code=401, slug='forbidden-scope')
+ print(4)
if schema != 'LINK':
raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-schema')
+ print(5)
if strategy != 'SIGNATURE' and not use_signature:
raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-strategy')
+ print(6)
if alg not in ['HS256', 'HS512']:
raise ValidationException('Algorithm not implemented', code=401, slug='algorithm-not-implemented')
fn = hashlib.sha256 if alg == 'HS256' else hashlib.sha512
+ print(7)
key = public_key if public_key else private_key
- if hmac_signature(request, key, fn) != authorization['Token'] and not legacy_key:
+ if hmac_signature(authorization['App'], authorization['Date'], authorization['SignedHeaders'], request,
+ key, fn) != authorization['Nonce'] and not legacy_key:
if not legacy_key:
raise ValidationException('Unauthorized', code=401, slug='wrong-app-token')
- legacy_public_key, legacy_private_key = legacy_key
- key = legacy_public_key if legacy_public_key else legacy_private_key
- if hmac_signature(request, key, fn) != authorization['Token']:
- raise ValidationException('Unauthorized', code=401, slug='wrong-legacy-app-token')
+ print(8)
+ if legacy_key:
+ legacy_public_key, legacy_private_key = legacy_key
+ key = legacy_public_key if legacy_public_key else legacy_private_key
+ if hmac_signature(authorization['App'], authorization['Date'], authorization['SignedHeaders'],
+ request, key, fn) != authorization['Nonce']:
+ raise ValidationException('Unauthorized', code=401, slug='wrong-legacy-app-token')
+ print(9)
try:
- timestamp = float(request.headers.get('Timestamp', ''))
- if ((timezone.now() - timedelta(minutes=TOLERANCE)).timestamp() < timestamp <
- (timezone.now() + timedelta(minutes=TOLERANCE)).timestamp()):
+ date = datetime.fromisoformat(authorization['Date'])
+ date = date.replace(tzinfo=timezone.utc)
+ now = timezone.now()
+ print(date, now)
+ if (now - timedelta(minutes=TOLERANCE) > date) or (now + timedelta(minutes=TOLERANCE) < date):
raise Exception()
- except:
+ except Exception as e:
+ print(33333, e)
raise ValidationException('Unauthorized', code=401, slug='bad-timestamp')
+ print(10)
return app_id
def scope(scopes: list = [], use_signature: bool = False) -> callable:
"""This decorator check if the app has access to the scope provided"""
- from breathecode.authenticate.models import App
-
def decorator(function: callable) -> callable:
def wrapper(*args, **kwargs):
@@ -182,16 +222,19 @@ def wrapper(*args, **kwargs):
raise ValidationException('Unauthorized', code=401, slug='no-authorization-header')
if authorization.startswith('Link ') and not use_signature:
+ print('-1')
authorization = authorization.replace('Link ', '')
app_id, token = link_schema(request, scopes, authorization, use_signature)
- function(*args, **kwargs, token=token, app_id=app_id)
+ return function(*args, **kwargs, token=token, app_id=app_id)
elif authorization.startswith('Signature '):
+ print('-2')
authorization = authorization.replace('Signature ', '')
app_id = signature_schema(request, scopes, authorization, use_signature)
- function(*args, **kwargs, app_id=app_id)
+ return function(*args, **kwargs, app_id=app_id)
else:
+ print('-3')
raise ValidationException('Unknown auth schema or this schema is forbidden',
code=401,
slug='unknown-auth-schema')
diff --git a/breathecode/utils/service.py b/breathecode/utils/service.py
index 562481404..3a373350d 100644
--- a/breathecode/utils/service.py
+++ b/breathecode/utils/service.py
@@ -41,60 +41,30 @@ def __init__(self, app_pk: str | int, user_pk: Optional[str | int] = None, use_s
self.use_signature = use_signature
def _sign(self, method, params=None, data=None, json=None, **kwargs) -> requests.Request:
+ from breathecode.authenticate.actions import get_signature
+
headers = kwargs.pop('headers', {})
headers.pop('Authorization', None)
- payload = {
- 'method': method,
- 'params': params,
- 'data': json if json else data,
- 'headers': headers,
- }
-
- paybytes = urllib.parse.urlencode(payload).encode('utf8')
-
- if self.app.algorithm == 'HMAC_SHA256':
- sign = hmac.new(self.app.private_key, paybytes, hashlib.sha256).hexdigest()
- elif self.app.algorithm == 'HMAC_SHA512':
- sign = hmac.new(self.app.private_key, paybytes, hashlib.sha512).hexdigest()
-
- else:
- raise Exception('Algorithm not implemented')
+ sign, now = get_signature(self.app,
+ self.user_pk,
+ method=method,
+ params=params,
+ body=data or json,
+ headers=headers)
headers['Authorization'] = (f'Signature App=breathecode,'
f'Nonce={sign},'
f'SignedHeaders={";".join(headers.keys())},'
- f'Date={datetime.utcnow().isoformat()}')
+ f'Date={now}')
return headers
def _jwt(self, method, **kwargs) -> requests.Request:
+ from breathecode.authenticate.actions import get_jwt
headers = kwargs.pop('headers', {})
- # headers.pop('Authorization', None)
- now = datetime.utcnow()
-
- # https://datatracker.ietf.org/doc/html/rfc7519#section-4
- payload = {
- 'sub': self.user_pk,
- 'iss': os.getenv('API_URL', 'http://localhost:8000'),
- 'app': 'breathecode',
- 'aud': self.app.slug,
- 'exp': datetime.timestamp(now + timedelta(minutes=2)),
- 'iat': datetime.timestamp(now),
- 'typ': 'JWT',
- }
-
- if self.app.algorithm == 'HMAC_SHA256':
- token = jwt.encode(payload, self.app.private_key, algorithm='HS256')
-
- elif self.app.algorithm == 'HMAC_SHA512':
- token = jwt.encode(payload, self.app.private_key, algorithm='HS512')
-
- elif self.app.algorithm == 'ED25519':
- token = jwt.encode(payload, self.app.private_key, algorithm='EdDSA')
-
- else:
- raise Exception('Algorithm not implemented')
+
+ token = get_jwt(self.app, self.user_pk)
headers['Authorization'] = (f'Link App=breathecode,'
f'Token={token}')
From 03833cc8eefcc35c8ff38ca22e2b1fcbe7101e93 Mon Sep 17 00:00:00 2001
From: jefer94
Date: Tue, 25 Jul 2023 16:57:34 -0500
Subject: [PATCH 05/19] add fixes in scope decorator
---
Pipfile | 2 +
breathecode/authenticate/actions.py | 2 +
breathecode/authenticate/views.py | 48 ++++++++++++----
breathecode/utils/decorators/scope.py | 80 +++++++++++++--------------
4 files changed, 81 insertions(+), 51 deletions(-)
diff --git a/Pipfile b/Pipfile
index 9b6a91f5c..ab4d89ae6 100644
--- a/Pipfile
+++ b/Pipfile
@@ -31,6 +31,8 @@ docs="mkdocs serve --livereload"
generate_docs="mkdocs build"
doctor="python -m scripts.doctor"
docs_deploy="mkdocs gh-deploy -c"
+sign_jwt="python manage.py sign_jwt"
+sign_request="python manage.py sign_request"
[dev-packages]
pytest-cov = "*"
diff --git a/breathecode/authenticate/actions.py b/breathecode/authenticate/actions.py
index 73e3b3501..f3d9b2f82 100644
--- a/breathecode/authenticate/actions.py
+++ b/breathecode/authenticate/actions.py
@@ -809,6 +809,8 @@ def get_app_keys(app_slug):
app.require_an_agreement,
tuple(sorted(x.slug for x in app.required_scopes.all())),
tuple(sorted(x.slug for x in app.optional_scopes.all())),
+ app.webhook_url,
+ app.redirect_url,
)
key = (
bytes.fromhex(app.public_key) if app.public_key else None,
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index 593267c6e..8ae28d41f 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -2308,25 +2308,51 @@ class AppUserView(APIView):
extensions = APIViewExtensions(paginate=True)
@scope(['read:user'])
- def get(self, request, app_id, token: dict, user_id=None):
+ def get(self, request, app: dict, token: dict, user_id=None):
+ handler = self.extensions(request)
lang = get_user_language(request)
- user = User.objects.filter(id=user_id).first()
- if not user:
- raise ValidationException(translation(lang, en='User not found', es='Usuario no encontrado'),
- code=404,
- slug='user-not-found',
- silent=True)
+ extra = {}
+ if app.require_an_agreement:
+ extra['appuseragreement__app__id'] = app.id
- serializer = AppUserSerializer(user, many=False)
- return Response(serializer.data)
+ if token.sub:
+ extra['id'] = token.sub
+
+ if user_id:
+ if token.sub and token.sub != user_id:
+ raise ValidationException(translation(lang,
+ en='This user does not have access to this resource',
+ es='Este usuario no tiene acceso a este recurso'),
+ code=403,
+ slug='user-with-no-access',
+ silent=True)
+
+ if 'id' not in extra:
+ extra['id'] = user_id
+
+ user = User.objects.filter(**extra).first()
+ if not user:
+ raise ValidationException(translation(lang, en='User not found', es='Usuario no encontrado'),
+ code=404,
+ slug='user-not-found',
+ silent=True)
+
+ serializer = AppUserSerializer(user, many=False)
+ return Response(serializer.data)
+
+ items = User.objects.filter(**extra)
+ items = handler.queryset(items)
+ serializer = AppUserSerializer(items, many=True)
+
+ return handler.response(serializer.data)
# app/webhook
@api_view(['POST'])
@permission_classes([AllowAny])
-@scope(['webhook'], use_signature=True)
-def app_webhook(request, app_id):
+@scope(['webhook'], mode='signature')
+def app_webhook(request, app: dict):
return Response({'message': 'ok'})
diff --git a/breathecode/utils/decorators/scope.py b/breathecode/utils/decorators/scope.py
index c359f0085..c20767b1b 100644
--- a/breathecode/utils/decorators/scope.py
+++ b/breathecode/utils/decorators/scope.py
@@ -2,12 +2,15 @@
import hashlib
import hmac
import logging
+from typing import Optional
from django.utils import timezone
import jwt
from rest_framework.views import APIView
import urllib.parse
+from breathecode.utils.attr_dict import AttrDict
+
from ..exceptions import ProgrammingError
from ..validation_exception import ValidationException
@@ -22,42 +25,34 @@ def link_schema(request, required_scopes, authorization: str, use_signature: boo
"""
from breathecode.authenticate.actions import get_app_keys, get_user_scopes
- print('here 0')
try:
authorization = dict([x.split('=') for x in authorization.split(',')])
except:
raise ValidationException('Unauthorized', code=401, slug='authorization-header-malformed')
- print('here 1', authorization.keys(), sorted(authorization.keys()) == ['App', 'Token'])
if sorted(authorization.keys()) != ['App', 'Token']:
raise ValidationException('Unauthorized', code=401, slug='authorization-header-bad-schema')
- print('here 1')
info, key, legacy_key = get_app_keys(authorization['App'])
- app_id, alg, strategy, schema, require_an_agreement, required_app_scopes, optional_app_scopes = info
+ (app_id, alg, strategy, schema, require_an_agreement, required_app_scopes, optional_app_scopes,
+ webhook_url, redirect_url) = info
public_key, private_key = key
- print('here 2')
-
if schema != 'LINK':
raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-schema')
if strategy != 'JWT':
raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-strategy')
- print('here 3')
try:
key = public_key if public_key else private_key
payload = jwt.decode(authorization['Token'], key, algorithms=[alg], audience='breathecode')
except Exception as e:
- print(1, e)
if not legacy_key:
raise ValidationException('Unauthorized', code=401, slug='wrong-app-token')
- print('here 4')
-
if not payload:
try:
legacy_public_key, legacy_private_key = legacy_key
@@ -66,10 +61,8 @@ def link_schema(request, required_scopes, authorization: str, use_signature: boo
payload = jwt.decode(authorization['Token'], key, algorithms=[alg])
except Exception as e:
- print(2, e)
raise ValidationException('Unauthorized', code=401, slug='wrong-legacy-app-token')
- print('here 5')
if require_an_agreement:
required_app_scopes, optional_app_scopes = get_user_scopes(authorization['App'], payload['sub'])
all_scopes = required_app_scopes + optional_app_scopes
@@ -78,12 +71,22 @@ def link_schema(request, required_scopes, authorization: str, use_signature: boo
if s not in all_scopes:
raise ValidationException('Unauthorized', code=401, slug='forbidden-scope')
- print('here 6')
if 'exp' not in payload or payload['exp'] < timezone.now().timestamp():
raise ValidationException('Expired token', code=401, slug='expired')
- print('here 7')
- return app_id, payload
+ app = {
+ 'id': app_id,
+ 'private_key': private_key,
+ 'public_key': public_key,
+ 'algorithm': alg,
+ 'strategy': strategy,
+ 'schema': schema,
+ 'require_an_agreement': require_an_agreement,
+ 'webhook_url': webhook_url,
+ 'redirect_url': redirect_url,
+ }
+
+ return app, payload
def get_payload(app, date, signed_headers, request):
@@ -98,7 +101,6 @@ def get_payload(app, date, signed_headers, request):
'headers': {k: v
for k, v in headers.items() if k in signed_headers},
}
- print(222, payload)
return payload
@@ -121,20 +123,18 @@ def signature_schema(request, required_scopes, authorization: str, use_signature
from breathecode.authenticate.models import App
from breathecode.authenticate.actions import get_app_keys, get_user_scopes
- print(1)
try:
authorization = dict([x.split('=') for x in authorization.split(',')])
except:
raise ValidationException('Unauthorized', code=401, slug='authorization-header-malformed')
- print(2)
if sorted(authorization.keys()) != ['App', 'Date', 'Nonce', 'SignedHeaders']:
raise ValidationException('Unauthorized', code=401, slug='authorization-header-bad-schema')
- print(3)
info, key, legacy_key = get_app_keys(authorization['App'])
- app_id, alg, strategy, schema, require_an_agreement, required_app_scopes, optional_app_scopes = info
+ (app_id, alg, strategy, schema, require_an_agreement, required_app_scopes, optional_app_scopes,
+ webhook_url, redirect_url) = info
public_key, private_key = key
if require_an_agreement:
@@ -145,28 +145,23 @@ def signature_schema(request, required_scopes, authorization: str, use_signature
if s not in all_scopes:
raise ValidationException('Unauthorized', code=401, slug='forbidden-scope')
- print(4)
if schema != 'LINK':
raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-schema')
- print(5)
if strategy != 'SIGNATURE' and not use_signature:
raise ValidationException('Unauthorized', code=401, slug='authorization-header-forbidden-strategy')
- print(6)
if alg not in ['HS256', 'HS512']:
raise ValidationException('Algorithm not implemented', code=401, slug='algorithm-not-implemented')
fn = hashlib.sha256 if alg == 'HS256' else hashlib.sha512
- print(7)
key = public_key if public_key else private_key
if hmac_signature(authorization['App'], authorization['Date'], authorization['SignedHeaders'], request,
key, fn) != authorization['Nonce'] and not legacy_key:
if not legacy_key:
raise ValidationException('Unauthorized', code=401, slug='wrong-app-token')
- print(8)
if legacy_key:
legacy_public_key, legacy_private_key = legacy_key
key = legacy_public_key if legacy_public_key else legacy_private_key
@@ -174,24 +169,32 @@ def signature_schema(request, required_scopes, authorization: str, use_signature
request, key, fn) != authorization['Nonce']:
raise ValidationException('Unauthorized', code=401, slug='wrong-legacy-app-token')
- print(9)
try:
date = datetime.fromisoformat(authorization['Date'])
date = date.replace(tzinfo=timezone.utc)
now = timezone.now()
- print(date, now)
if (now - timedelta(minutes=TOLERANCE) > date) or (now + timedelta(minutes=TOLERANCE) < date):
raise Exception()
except Exception as e:
- print(33333, e)
raise ValidationException('Unauthorized', code=401, slug='bad-timestamp')
- print(10)
- return app_id
+ app = {
+ 'id': app_id,
+ 'private_key': private_key,
+ 'public_key': public_key,
+ 'algorithm': alg,
+ 'strategy': strategy,
+ 'schema': schema,
+ 'require_an_agreement': require_an_agreement,
+ 'webhook_url': webhook_url,
+ 'redirect_url': redirect_url,
+ }
+
+ return app
-def scope(scopes: list = [], use_signature: bool = False) -> callable:
+def scope(scopes: list = [], mode: Optional[str] = None) -> callable:
"""This decorator check if the app has access to the scope provided"""
def decorator(function: callable) -> callable:
@@ -221,20 +224,17 @@ def wrapper(*args, **kwargs):
if not authorization:
raise ValidationException('Unauthorized', code=401, slug='no-authorization-header')
- if authorization.startswith('Link ') and not use_signature:
- print('-1')
+ if authorization.startswith('Link ') and mode != 'signature':
authorization = authorization.replace('Link ', '')
- app_id, token = link_schema(request, scopes, authorization, use_signature)
- return function(*args, **kwargs, token=token, app_id=app_id)
+ app, token = link_schema(request, scopes, authorization, mode == 'signature')
+ return function(*args, **kwargs, token=AttrDict(**token), app=AttrDict(**app))
- elif authorization.startswith('Signature '):
- print('-2')
+ elif authorization.startswith('Signature ') and mode != 'jwt':
authorization = authorization.replace('Signature ', '')
- app_id = signature_schema(request, scopes, authorization, use_signature)
- return function(*args, **kwargs, app_id=app_id)
+ app = signature_schema(request, scopes, authorization, mode == 'signature')
+ return function(*args, **kwargs, app=AttrDict(**app))
else:
- print('-3')
raise ValidationException('Unknown auth schema or this schema is forbidden',
code=401,
slug='unknown-auth-schema')
From 08ec91c686c93e05116f5358f9178b0e01fd8507 Mon Sep 17 00:00:00 2001
From: jefer94
Date: Tue, 25 Jul 2023 17:39:01 -0500
Subject: [PATCH 06/19] add demo
---
breathecode/authenticate/views.py | 27 ++++++++++++++++++++++++++-
breathecode/utils/service.py | 8 ++++----
2 files changed, 30 insertions(+), 5 deletions(-)
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index 8ae28d41f..29b7a8819 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -43,6 +43,7 @@
from breathecode.utils.find_by_full_name import query_like_by_full_name
from breathecode.utils.i18n import translation
from breathecode.utils.multi_status_response import MultiStatusResponse
+from breathecode.utils.service import Service
from breathecode.utils.shorteners import C
from breathecode.utils.views import (private_view, render_message, set_query_parameter)
@@ -2356,4 +2357,28 @@ def app_webhook(request, app: dict):
return Response({'message': 'ok'})
-# app/webhook
+# demo
+class DemoView(APIView):
+ permission_classes = [AllowAny]
+ extensions = APIViewExtensions(paginate=True)
+
+ def get(self, request, user_id=None):
+ handler = self.extensions(request)
+ lang = get_user_language(request)
+
+ # default from app (jwt)
+ s = Service('rigobot', request.user.id)
+ request = s.get('https://rigobot.4geeks.com/user')
+ response = request.json()
+
+ # force jwt
+ s = Service('rigobot', request.user.id, mode='jwt')
+ request = s.get('https://rigobot.4geeks.com/user')
+ response = request.json()
+
+ # force signature
+ s = Service('rigobot', request.user.id, mode='signature')
+ request = s.get('https://rigobot.4geeks.com/webhook')
+ response = request.json()
+
+ return Response({'message': 'ok'})
diff --git a/breathecode/utils/service.py b/breathecode/utils/service.py
index 3a373350d..a389d5fc9 100644
--- a/breathecode/utils/service.py
+++ b/breathecode/utils/service.py
@@ -35,10 +35,10 @@ def get_app(pk: str | int) -> App:
class Service:
- def __init__(self, app_pk: str | int, user_pk: Optional[str | int] = None, use_signature: bool = False):
+ def __init__(self, app_pk: str | int, user_pk: Optional[str | int] = None, *, mode: Optional[str] = None):
self.app = get_app(app_pk)
self.user_pk = user_pk
- self.use_signature = use_signature
+ self.mode = mode
def _sign(self, method, params=None, data=None, json=None, **kwargs) -> requests.Request:
from breathecode.authenticate.actions import get_signature
@@ -72,10 +72,10 @@ def _jwt(self, method, **kwargs) -> requests.Request:
return headers
def _authenticate(self, method, params=None, data=None, json=None, **kwargs) -> requests.Request:
- if self.app.strategy == 'SIGNATURE' or self.use_signature:
+ if self.mode == 'signature' or self.app.strategy == 'SIGNATURE':
return self._sign(method, params=params, data=data, json=json, **kwargs)
- elif self.app.strategy == 'JWT':
+ elif self.mode == 'jwt' or self.app.strategy == 'JWT':
return self._jwt(self, method, **kwargs)
raise Exception('Strategy not implemented')
From 76f38d913cf6c053d6d5338900fc27284cf3b8a9 Mon Sep 17 00:00:00 2001
From: jefer94
Date: Tue, 25 Jul 2023 17:43:05 -0500
Subject: [PATCH 07/19] update webhook demo
---
breathecode/authenticate/views.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index 29b7a8819..7c99ce674 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -2354,6 +2354,15 @@ def get(self, request, app: dict, token: dict, user_id=None):
@permission_classes([AllowAny])
@scope(['webhook'], mode='signature')
def app_webhook(request, app: dict):
+ # {'type': 'user:updated', 'kind': 'user', 'data': {'id': 1, 'name': 'John'}}
+ # {'type': 'bug', 'kind': 'bug', 'url': 'https://xyz.io/bug/123'}
+
+ # save the webhook
+ ...
+
+ # send the webhook to celery
+ ...
+
return Response({'message': 'ok'})
From 84e744424c9680bd2853d6ca6919baa6a0be485b Mon Sep 17 00:00:00 2001
From: jefer94
Date: Tue, 25 Jul 2023 17:45:19 -0500
Subject: [PATCH 08/19] fix demo view
---
breathecode/authenticate/views.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index 7c99ce674..01b4c8521 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -2368,7 +2368,6 @@ def app_webhook(request, app: dict):
# demo
class DemoView(APIView):
- permission_classes = [AllowAny]
extensions = APIViewExtensions(paginate=True)
def get(self, request, user_id=None):
From 3171d84f0fe14ff7326b9d4366bdb91646a9b811 Mon Sep 17 00:00:00 2001
From: jefer94
Date: Thu, 27 Jul 2023 18:57:49 -0500
Subject: [PATCH 09/19] finish schema link
---
breathecode/authenticate/actions.py | 35 ++++++++++++----
breathecode/authenticate/admin.py | 32 +++++++++++++--
...o_20230725_0322_0041_auto_20230725_0619.py | 13 ++++++
breathecode/authenticate/views.py | 13 ------
breathecode/utils/__init__.py | 1 +
breathecode/utils/service.py | 41 ++++---------------
breathecode/utils/validation_exception.py | 7 +++-
7 files changed, 82 insertions(+), 60 deletions(-)
create mode 100644 breathecode/authenticate/migrations/0042_merge_0041_auto_20230725_0322_0041_auto_20230725_0619.py
diff --git a/breathecode/authenticate/actions.py b/breathecode/authenticate/actions.py
index f3d9b2f82..756f13c4a 100644
--- a/breathecode/authenticate/actions.py
+++ b/breathecode/authenticate/actions.py
@@ -652,16 +652,16 @@ def get_jwt(app: App, user_id: Optional[int] = None, reverse: bool = False):
payload = {
'sub': user_id,
'iss': os.getenv('API_URL', 'http://localhost:8000'),
- 'app': 'breathecode',
+ 'app': '4geeks',
'aud': app.slug,
'exp': datetime.timestamp(now + timedelta(minutes=JWT_LIFETIME)),
- 'iat': datetime.timestamp(now),
+ 'iat': datetime.timestamp(now) - 1,
'typ': 'JWT',
}
if reverse:
payload['app'] = app.slug
- payload['aud'] = 'breathecode'
+ payload['aud'] = '4geeks'
if app.algorithm == 'HMAC_SHA256':
@@ -692,11 +692,11 @@ def get_signature(app: App,
payload = {
'timestamp': now,
- 'app': 'breathecode',
+ 'app': '4geeks',
'method': method.upper(),
- 'params': params,
+ 'params': params or {},
'body': body,
- 'headers': headers,
+ 'headers': headers or {},
}
if reverse:
@@ -776,7 +776,7 @@ def get_app_keys(app_slug):
app = App.objects.filter(slug=app_slug).first()
if app is None:
- raise AuthenticationFailed({'error': 'Unauthorized', 'is_authenticated': False})
+ raise ValidationException('Unauthorized', code=401, slug='app-not-found')
if app.algorithm == 'HMAC_SHA256':
alg = 'HS256'
@@ -788,7 +788,7 @@ def get_app_keys(app_slug):
alg = 'EdDSA'
else:
- raise AuthenticationFailed({'error': 'Algorithm not implemented', 'is_authenticated': False})
+ raise ValidationException('Algorithm not implemented', code=401, slug='algorithm-not-implemented')
legacy_public_key = None
legacy_private_key = None
@@ -828,3 +828,22 @@ def reset_app_cache():
def reset_app_user_cache():
get_optional_scopes_set.cache_clear()
+
+
+@lru_cache(maxsize=100)
+def get_app(pk: str | int) -> App:
+ kwargs = {}
+
+ if isinstance(pk, int):
+ kwargs['id'] = pk
+
+ elif isinstance(pk, str):
+ kwargs['slug'] = pk
+
+ else:
+ raise Exception('Invalid pk type')
+
+ if not (app := App.objects.filter(**kwargs).first()):
+ raise Exception('App not found')
+
+ return app
diff --git a/breathecode/authenticate/admin.py b/breathecode/authenticate/admin.py
index f9186c35c..64c79c782 100644
--- a/breathecode/authenticate/admin.py
+++ b/breathecode/authenticate/admin.py
@@ -7,9 +7,10 @@
from .actions import (delete_tokens, generate_academy_token, set_gitpod_user_expiration, reset_password,
sync_organization_members)
from django.utils.html import format_html
-from .models import (App, CredentialsGithub, DeviceId, LegacyKey, Token, UserProxy, Profile, CredentialsSlack,
- ProfileAcademy, Role, CredentialsFacebook, Capability, UserInvite, CredentialsGoogle,
- AcademyProxy, GitpodUser, GithubAcademyUser, AcademyAuthSettings, GithubAcademyUserLog)
+from .models import (App, AppUserAgreement, CredentialsGithub, DeviceId, LegacyKey, OptionalScopeSet, Scope,
+ Token, UserProxy, Profile, CredentialsSlack, ProfileAcademy, Role, CredentialsFacebook,
+ Capability, UserInvite, CredentialsGoogle, AcademyProxy, GitpodUser, GithubAcademyUser,
+ AcademyAuthSettings, GithubAcademyUserLog)
from .tasks import async_set_gitpod_user_expiration
from breathecode.utils.admin import change_field
from django.contrib.admin import SimpleListFilter
@@ -445,13 +446,19 @@ def authenticate(self, obj):
)
+@admin.register(Scope)
+class ScopeAdmin(admin.ModelAdmin):
+ list_display = ('name', 'slug')
+ search_fields = ['name', 'slug']
+ actions = []
+
+
@admin.register(App)
class AppAdmin(admin.ModelAdmin):
list_display = ('name', 'slug', 'algorithm', 'strategy', 'schema', 'agreement_version',
'require_an_agreement')
search_fields = ['name', 'slug']
list_filter = ['algorithm', 'strategy', 'schema', 'require_an_agreement']
- actions = []
@admin.register(LegacyKey)
@@ -460,3 +467,20 @@ class AppAdmin(admin.ModelAdmin):
search_fields = ['app__name', 'app__slug']
list_filter = ['algorithm', 'strategy', 'schema']
actions = []
+
+
+@admin.register(OptionalScopeSet)
+class OptionalScopeSetAdmin(admin.ModelAdmin):
+ list_display = ('id', )
+ search_fields = ['optional_scopes__name', 'optional_scopes__slug']
+ actions = []
+
+
+@admin.register(AppUserAgreement)
+class AppUserAgreementAdmin(admin.ModelAdmin):
+ list_display = ('id', 'user', 'app', 'optional_scope_set', 'agreement_version')
+ search_fields = [
+ 'user__username', 'user__email', 'user__first_name', 'user__last_name', 'app__name', 'app__slug'
+ ]
+ list_filter = ['app']
+ actions = []
diff --git a/breathecode/authenticate/migrations/0042_merge_0041_auto_20230725_0322_0041_auto_20230725_0619.py b/breathecode/authenticate/migrations/0042_merge_0041_auto_20230725_0322_0041_auto_20230725_0619.py
new file mode 100644
index 000000000..5c1fc095b
--- /dev/null
+++ b/breathecode/authenticate/migrations/0042_merge_0041_auto_20230725_0322_0041_auto_20230725_0619.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.19 on 2023-07-27 21:45
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('authenticate', '0041_auto_20230725_0322'),
+ ('authenticate', '0041_auto_20230725_0619'),
+ ]
+
+ operations = []
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index 01b4c8521..34de119b4 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -2290,19 +2290,6 @@ def delete(self, request):
return Response(None, status=status.HTTP_204_NO_CONTENT)
-# app/example
-class ExampleView(APIView):
- extensions = APIViewExtensions(paginate=True)
-
- @scope(['read:example'])
- def get(self, request, app_id, token: dict):
- pass
-
- @scope(['create:example'])
- def post(self, request, app_id, token: dict):
- pass
-
-
# app/user/:id
class AppUserView(APIView):
permission_classes = [AllowAny]
diff --git a/breathecode/utils/__init__.py b/breathecode/utils/__init__.py
index 9dc5d727d..f4bd531f6 100644
--- a/breathecode/utils/__init__.py
+++ b/breathecode/utils/__init__.py
@@ -27,3 +27,4 @@
from .i18n import *
from .custom_serpy import *
from .shorteners import *
+from .service import *
diff --git a/breathecode/utils/service.py b/breathecode/utils/service.py
index a389d5fc9..405ac501c 100644
--- a/breathecode/utils/service.py
+++ b/breathecode/utils/service.py
@@ -1,41 +1,15 @@
from __future__ import annotations
-from datetime import datetime, timedelta
-from functools import lru_cache
-import hashlib
-import hmac
-import os
from typing import Optional
-import jwt
import requests
-import urllib.parse
-from breathecode.authenticate.models import App
-from breathecode.tests.mixins import DatetimeMixin
-__all__ = ['get_app', 'Service']
-
-
-@lru_cache(maxsize=100)
-def get_app(pk: str | int) -> App:
- kwargs = {}
-
- if isinstance(pk, int):
- kwargs['id'] = pk
-
- elif isinstance(pk, str):
- kwargs['slug'] = pk
-
- else:
- raise Exception('Invalid pk type')
-
- if not (app := App.objects.filter(kwargs).first()):
- raise Exception('App not found')
-
- return app
+__all__ = ['Service']
class Service:
def __init__(self, app_pk: str | int, user_pk: Optional[str | int] = None, *, mode: Optional[str] = None):
+ from breathecode.authenticate.actions import get_app
+
self.app = get_app(app_pk)
self.user_pk = user_pk
self.mode = mode
@@ -50,10 +24,10 @@ def _sign(self, method, params=None, data=None, json=None, **kwargs) -> requests
self.user_pk,
method=method,
params=params,
- body=data or json,
+ body=data if data is not None else json,
headers=headers)
- headers['Authorization'] = (f'Signature App=breathecode,'
+ headers['Authorization'] = (f'Signature App=4geeks,'
f'Nonce={sign},'
f'SignedHeaders={";".join(headers.keys())},'
f'Date={now}')
@@ -62,11 +36,12 @@ def _sign(self, method, params=None, data=None, json=None, **kwargs) -> requests
def _jwt(self, method, **kwargs) -> requests.Request:
from breathecode.authenticate.actions import get_jwt
+
headers = kwargs.pop('headers', {})
token = get_jwt(self.app, self.user_pk)
- headers['Authorization'] = (f'Link App=breathecode,'
+ headers['Authorization'] = (f'Link App=4geeks,'
f'Token={token}')
return headers
@@ -76,7 +51,7 @@ def _authenticate(self, method, params=None, data=None, json=None, **kwargs) ->
return self._sign(method, params=params, data=data, json=json, **kwargs)
elif self.mode == 'jwt' or self.app.strategy == 'JWT':
- return self._jwt(self, method, **kwargs)
+ return self._jwt(method, **kwargs)
raise Exception('Strategy not implemented')
diff --git a/breathecode/utils/validation_exception.py b/breathecode/utils/validation_exception.py
index b260c961b..cc3a56522 100644
--- a/breathecode/utils/validation_exception.py
+++ b/breathecode/utils/validation_exception.py
@@ -9,10 +9,13 @@
__all__ = ['ValidationException', 'APIException']
-IS_TEST_ENV = os.getenv('ENV') == 'test'
logger = logging.getLogger(__name__)
+def is_test_env():
+ return os.getenv('ENV') == 'test' or True
+
+
class ValidationException(APIException):
status_code: int
detail: str | list[C]
@@ -40,7 +43,7 @@ def __init__(self,
elif isinstance(details, list):
self.detail = self._get_details()
- elif IS_TEST_ENV and slug:
+ elif slug and is_test_env():
self.detail = slug
if isinstance(self.detail, str):
From eb87b23e052ec090116391f39bd7d9db965720bf Mon Sep 17 00:00:00 2001
From: jefer94
Date: Thu, 27 Jul 2023 19:05:57 -0500
Subject: [PATCH 10/19] changes in migrations
---
..._20230725_0619.py => 0042_auto_20230728_0004.py} | 4 ++--
...41_auto_20230725_0322_0041_auto_20230725_0619.py | 13 -------------
2 files changed, 2 insertions(+), 15 deletions(-)
rename breathecode/authenticate/migrations/{0041_auto_20230725_0619.py => 0042_auto_20230728_0004.py} (98%)
delete mode 100644 breathecode/authenticate/migrations/0042_merge_0041_auto_20230725_0322_0041_auto_20230725_0619.py
diff --git a/breathecode/authenticate/migrations/0041_auto_20230725_0619.py b/breathecode/authenticate/migrations/0042_auto_20230728_0004.py
similarity index 98%
rename from breathecode/authenticate/migrations/0041_auto_20230725_0619.py
rename to breathecode/authenticate/migrations/0042_auto_20230728_0004.py
index ecc680f65..040100af3 100644
--- a/breathecode/authenticate/migrations/0041_auto_20230725_0619.py
+++ b/breathecode/authenticate/migrations/0042_auto_20230728_0004.py
@@ -1,4 +1,4 @@
-# Generated by Django 3.2.19 on 2023-07-25 06:19
+# Generated by Django 3.2.19 on 2023-07-28 00:04
from django.conf import settings
from django.db import migrations, models
@@ -9,7 +9,7 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ('authenticate', '0040_userinvite_is_email_validated'),
+ ('authenticate', '0041_auto_20230725_0322'),
]
operations = [
diff --git a/breathecode/authenticate/migrations/0042_merge_0041_auto_20230725_0322_0041_auto_20230725_0619.py b/breathecode/authenticate/migrations/0042_merge_0041_auto_20230725_0322_0041_auto_20230725_0619.py
deleted file mode 100644
index 5c1fc095b..000000000
--- a/breathecode/authenticate/migrations/0042_merge_0041_auto_20230725_0322_0041_auto_20230725_0619.py
+++ /dev/null
@@ -1,13 +0,0 @@
-# Generated by Django 3.2.19 on 2023-07-27 21:45
-
-from django.db import migrations
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('authenticate', '0041_auto_20230725_0322'),
- ('authenticate', '0041_auto_20230725_0619'),
- ]
-
- operations = []
From e6e490b2822d970c8acf4207793341d5c890fe41 Mon Sep 17 00:00:00 2001
From: jefer94
Date: Tue, 1 Aug 2023 14:57:33 -0500
Subject: [PATCH 11/19] add changes
---
.../0059_alter_cohortuser_history_log.py | 22 +++++
breathecode/admissions/models.py | 2 +-
breathecode/assignments/tasks.py | 18 ++++
breathecode/assignments/urls.py | 14 +--
breathecode/assignments/views.py | 20 +++++
breathecode/authenticate/actions.py | 1 +
breathecode/authenticate/admin.py | 1 -
...728_0004.py => 0042_auto_20230801_0128.py} | 6 +-
.../migrations/0043_alter_scope_name.py | 18 ++++
.../migrations/0044_alter_scope_slug.py | 18 ++++
...0045_optionalscopeset_agreement_version.py | 18 ++++
...move_optionalscopeset_agreement_version.py | 17 ++++
breathecode/authenticate/models.py | 34 +++++--
breathecode/authenticate/receivers.py | 25 +++++-
.../authenticate/templates/authorize.html | 90 +++++++++++++++++++
.../authenticate/templatetags/__init__.py | 0
breathecode/authenticate/urls.py | 14 +--
breathecode/authenticate/views.py | 61 ++++++++++++-
breathecode/commons/templates/button.html | 7 ++
breathecode/commons/templates/scopes.html | 65 ++++++++++++++
breathecode/commons/templatetags/button.py | 15 ++++
breathecode/commons/templatetags/scopes.py | 14 +++
breathecode/utils/service.py | 14 +++
23 files changed, 466 insertions(+), 28 deletions(-)
create mode 100644 breathecode/admissions/migrations/0059_alter_cohortuser_history_log.py
rename breathecode/authenticate/migrations/{0042_auto_20230728_0004.py => 0042_auto_20230801_0128.py} (96%)
create mode 100644 breathecode/authenticate/migrations/0043_alter_scope_name.py
create mode 100644 breathecode/authenticate/migrations/0044_alter_scope_slug.py
create mode 100644 breathecode/authenticate/migrations/0045_optionalscopeset_agreement_version.py
create mode 100644 breathecode/authenticate/migrations/0046_remove_optionalscopeset_agreement_version.py
create mode 100644 breathecode/authenticate/templates/authorize.html
create mode 100644 breathecode/authenticate/templatetags/__init__.py
create mode 100644 breathecode/commons/templates/button.html
create mode 100644 breathecode/commons/templates/scopes.html
create mode 100644 breathecode/commons/templatetags/button.py
create mode 100644 breathecode/commons/templatetags/scopes.py
diff --git a/breathecode/admissions/migrations/0059_alter_cohortuser_history_log.py b/breathecode/admissions/migrations/0059_alter_cohortuser_history_log.py
new file mode 100644
index 000000000..e2031eb08
--- /dev/null
+++ b/breathecode/admissions/migrations/0059_alter_cohortuser_history_log.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.20 on 2023-08-01 03:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('admissions', '0058_alter_cohort_available_as_saas'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='cohortuser',
+ name='history_log',
+ field=models.JSONField(
+ blank=True,
+ default=dict,
+ help_text=
+ 'The cohort user log will save attendancy and information about progress on each class'),
+ ),
+ ]
diff --git a/breathecode/admissions/models.py b/breathecode/admissions/models.py
index b3a6b427f..cc5b8e400 100644
--- a/breathecode/admissions/models.py
+++ b/breathecode/admissions/models.py
@@ -476,7 +476,7 @@ def __init__(self, *args, **kwargs):
default=False, help_text='You can active students to the watch list and monitor them closely')
history_log = models.JSONField(
- default=dict(),
+ default=dict,
blank=True,
null=False,
help_text='The cohort user log will save attendancy and information about progress on each class')
diff --git a/breathecode/assignments/tasks.py b/breathecode/assignments/tasks.py
index ac38a4850..1a4f29414 100644
--- a/breathecode/assignments/tasks.py
+++ b/breathecode/assignments/tasks.py
@@ -7,6 +7,7 @@
from breathecode.assignments.actions import task_is_valid_for_notifications, NOTIFICATION_STRINGS
import breathecode.notify.actions as actions
+from breathecode.utils.service import Service
from .models import Task
# Get an instance of a logger
@@ -115,4 +116,21 @@ def serialize_task(task):
cohort_user.history_log = user_history_log
cohort_user.save()
+ s = None
+ if hasattr(task.user, 'credentialsgithub') and task.github_url:
+ s = Service('rigobot', task.user.id)
+
+ if s and task.task_status == 'DONE':
+ s.post('/v1/finetuning/me/repository/',
+ json={
+ 'url': task.github_url,
+ 'watchers': task.user.credentialsgithub.username,
+ })
+
+ elif s:
+ s.put('/v1/finetuning/me/repository/', json={
+ 'url': task.github_url,
+ 'activity_status': 'INACTIVE',
+ })
+
logger.info('History log saved')
diff --git a/breathecode/assignments/urls.py b/breathecode/assignments/urls.py
index 654517562..6d5130cc5 100644
--- a/breathecode/assignments/urls.py
+++ b/breathecode/assignments/urls.py
@@ -1,9 +1,7 @@
-from django.contrib import admin
-from django.urls import path, include
-from rest_framework import routers
-from .views import (TaskMeView, sync_cohort_tasks_view, TaskTeacherView, deliver_assignment_view,
- TaskMeDeliverView, FinalProjectMeView, CohortTaskView, SubtaskMeView,
- TaskMeAttachmentView, FinalProjectScreenshotView)
+from django.urls import path
+from .views import (MeCodeRevisionView, MeTaskCodeRevisionView, TaskMeView, sync_cohort_tasks_view,
+ TaskTeacherView, deliver_assignment_view, TaskMeDeliverView, FinalProjectMeView,
+ CohortTaskView, SubtaskMeView, TaskMeAttachmentView, FinalProjectScreenshotView)
app_name = 'assignments'
urlpatterns = [
@@ -16,6 +14,10 @@
path('user/me/final_project/', FinalProjectMeView.as_view(), name='user_me_project'),
path('user/me/task/', TaskMeView.as_view(), name='user_me_task_id'),
path('user/me/task//subtasks', SubtaskMeView.as_view(), name='user_me_task_id'),
+ path('me/coderevision', MeCodeRevisionView.as_view(), name='me_coderevision'),
+ path('me/task//coderevision',
+ MeTaskCodeRevisionView.as_view(),
+ name='me_task_id_coderevision'),
path('user//task', TaskMeView.as_view(), name='user_id_task'),
path('user//task/', TaskMeView.as_view(), name='user_id_task_id'),
path('academy/cohort//task', CohortTaskView.as_view()),
diff --git a/breathecode/assignments/views.py b/breathecode/assignments/views.py
index 0d4b50242..4540a9ad3 100644
--- a/breathecode/assignments/views.py
+++ b/breathecode/assignments/views.py
@@ -17,6 +17,7 @@
from rest_framework.response import Response
from rest_framework import status
from breathecode.utils import APIException
+from breathecode.utils.service import Service
from .models import Task, FinalProject, UserAttachment
from .actions import deliver_task
from .caches import TaskCache
@@ -818,3 +819,22 @@ def put(self, request, task_id):
item.save()
return Response(item.subtasks)
+
+
+class MeCodeRevisionView(APIView):
+
+ def get(self, request):
+ s = Service('rigobot', request.user.id)
+ response = s.get('/v1/finetuning/me/coderevision?read=false')
+ return response.json()
+
+
+class MeTaskCodeRevisionView(APIView):
+
+ def get(self, request, task_id):
+ if not (task := Task.objects.filter(id=task_id, user__id=request.user.id).exists()):
+ raise ValidationException('Task not found', code=404, slug='task-not-found')
+
+ s = Service('rigobot', request.user.id)
+ response = s.get(f'/v1/finetuning/coderevision?repo={task.github_url}')
+ return response.json()
diff --git a/breathecode/authenticate/actions.py b/breathecode/authenticate/actions.py
index 756f13c4a..c3065d4b2 100644
--- a/breathecode/authenticate/actions.py
+++ b/breathecode/authenticate/actions.py
@@ -822,6 +822,7 @@ def get_app_keys(app_slug):
def reset_app_cache():
+ get_app.cache_clear()
get_app_keys.cache_clear()
get_optional_scopes_set.cache_clear()
diff --git a/breathecode/authenticate/admin.py b/breathecode/authenticate/admin.py
index 64c79c782..4fa8b07a0 100644
--- a/breathecode/authenticate/admin.py
+++ b/breathecode/authenticate/admin.py
@@ -1,7 +1,6 @@
import base64, os, urllib.parse, logging, datetime
from django.contrib import admin
from django.utils import timezone
-from urllib.parse import urlparse
from django.contrib.auth.admin import UserAdmin
from django.contrib import messages
from .actions import (delete_tokens, generate_academy_token, set_gitpod_user_expiration, reset_password,
diff --git a/breathecode/authenticate/migrations/0042_auto_20230728_0004.py b/breathecode/authenticate/migrations/0042_auto_20230801_0128.py
similarity index 96%
rename from breathecode/authenticate/migrations/0042_auto_20230728_0004.py
rename to breathecode/authenticate/migrations/0042_auto_20230801_0128.py
index 040100af3..91543d27e 100644
--- a/breathecode/authenticate/migrations/0042_auto_20230728_0004.py
+++ b/breathecode/authenticate/migrations/0042_auto_20230801_0128.py
@@ -1,4 +1,4 @@
-# Generated by Django 3.2.19 on 2023-07-28 00:04
+# Generated by Django 3.2.20 on 2023-08-01 01:28
from django.conf import settings
from django.db import migrations, models
@@ -18,8 +18,9 @@ class Migration(migrations.Migration):
fields=[
('id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.SlugField(max_length=25, unique=True)),
+ ('name', models.CharField(max_length=25, unique=True)),
('slug', models.SlugField(unique=True)),
+ ('description', models.CharField(max_length=255)),
('algorithm',
models.CharField(choices=[('HMAC_SHA256', 'HMAC-SHA256'), ('HMAC_SHA512', 'HMAC_SHA512'),
('ED25519', 'ED25519')],
@@ -37,6 +38,7 @@ class Migration(migrations.Migration):
help_text='If true, the user will be required to accept an agreement')),
('webhook_url', models.URLField()),
('redirect_url', models.URLField()),
+ ('app_url', models.URLField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
diff --git a/breathecode/authenticate/migrations/0043_alter_scope_name.py b/breathecode/authenticate/migrations/0043_alter_scope_name.py
new file mode 100644
index 000000000..26f1f0a02
--- /dev/null
+++ b/breathecode/authenticate/migrations/0043_alter_scope_name.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.20 on 2023-08-01 01:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('authenticate', '0042_auto_20230801_0128'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='scope',
+ name='name',
+ field=models.CharField(max_length=25, unique=True),
+ ),
+ ]
diff --git a/breathecode/authenticate/migrations/0044_alter_scope_slug.py b/breathecode/authenticate/migrations/0044_alter_scope_slug.py
new file mode 100644
index 000000000..45f5940ab
--- /dev/null
+++ b/breathecode/authenticate/migrations/0044_alter_scope_slug.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.20 on 2023-08-01 02:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('authenticate', '0043_alter_scope_name'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='scope',
+ name='slug',
+ field=models.CharField(max_length=15, unique=True),
+ ),
+ ]
diff --git a/breathecode/authenticate/migrations/0045_optionalscopeset_agreement_version.py b/breathecode/authenticate/migrations/0045_optionalscopeset_agreement_version.py
new file mode 100644
index 000000000..44621462b
--- /dev/null
+++ b/breathecode/authenticate/migrations/0045_optionalscopeset_agreement_version.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.20 on 2023-08-01 03:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('authenticate', '0044_alter_scope_slug'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='optionalscopeset',
+ name='agreement_version',
+ field=models.IntegerField(default=1),
+ ),
+ ]
diff --git a/breathecode/authenticate/migrations/0046_remove_optionalscopeset_agreement_version.py b/breathecode/authenticate/migrations/0046_remove_optionalscopeset_agreement_version.py
new file mode 100644
index 000000000..719f0f9b3
--- /dev/null
+++ b/breathecode/authenticate/migrations/0046_remove_optionalscopeset_agreement_version.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.20 on 2023-08-01 03:41
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('authenticate', '0045_optionalscopeset_agreement_version'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='optionalscopeset',
+ name='agreement_version',
+ ),
+ ]
diff --git a/breathecode/authenticate/models.py b/breathecode/authenticate/models.py
index 05686e8b0..9a9835fb4 100644
--- a/breathecode/authenticate/models.py
+++ b/breathecode/authenticate/models.py
@@ -1,4 +1,5 @@
from datetime import datetime
+import re
from typing import Any
from django.contrib.auth.models import User, Group, Permission
from django.core.exceptions import MultipleObjectsReturned
@@ -113,9 +114,11 @@ def __str__(self):
class Scope(models.Model):
- name = models.SlugField(max_length=25, unique=True)
- slug = models.SlugField(unique=True)
- description = models.CharField(max_length=255)
+ name = models.CharField(max_length=25,
+ unique=True,
+ help_text='Descriptive and unique name that appears on the authorize UI')
+ slug = models.CharField(max_length=15, unique=True, help_text='{action}:{data} for example read:repo')
+ description = models.CharField(max_length=255, help_text='Description of the scope')
def clean(self) -> None:
if not self.slug:
@@ -124,6 +127,12 @@ def clean(self) -> None:
if not self.description:
raise forms.ValidationError('Scope description is required')
+ if not self.slug or not re.findall(
+ r'^[a-z_:]+$', self.slug) or self.slug.count(':') > 1 or self.slug.count('__') > 0:
+ raise forms.ValidationError(
+ 'Scope slug must be in the format "action_name:data_name" or "data_name" example '
+ '"read:repo" or "repo"')
+
return super().clean()
def save(self, *args, **kwargs):
@@ -172,12 +181,19 @@ def __init__(self, *args, **kwargs):
self._webhook_url = self.webhook_url
self._redirect_url = self.redirect_url
- name = models.SlugField(max_length=25, unique=True)
- slug = models.SlugField(unique=True)
+ name = models.CharField(max_length=25, unique=True, help_text='Descriptive and unique name of the app')
+ slug = models.SlugField(
+ unique=True,
+ help_text='Unique slug for the app, it must be url friendly and please avoid to change it')
+ description = models.CharField(max_length=255,
+ help_text='Description of the app, it will appear on the authorize UI')
algorithm = models.CharField(max_length=11, choices=AUTH_ALGORITHM)
strategy = models.CharField(max_length=9, choices=AUTH_STRATEGY)
- schema = models.CharField(max_length=4, choices=AUTH_SCHEMA)
+ schema = models.CharField(
+ max_length=4,
+ choices=AUTH_SCHEMA,
+ help_text='Schema to use for the auth process to represent how the apps will communicate')
required_scopes = models.ManyToManyField(Scope, blank=True, related_name='app_required_scopes')
optional_scopes = models.ManyToManyField(Scope, blank=True, related_name='app_optional_scopes')
@@ -191,6 +207,7 @@ def __init__(self, *args, **kwargs):
webhook_url = models.URLField()
redirect_url = models.URLField()
+ app_url = models.URLField()
created_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
@@ -210,6 +227,9 @@ def clean(self) -> None:
if not self.public_key and not self.private_key:
self.public_key, self.private_key = generate_auth_keys(self.algorithm)
+ if self.app_url.endswith('/'):
+ self.app_url = self.app_url[:-1]
+
return super().clean()
def save(self, *args, **kwargs):
@@ -262,7 +282,7 @@ def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
- self.__class__.objects.exclude(app_user_agreement__id__gte=1).delete()
+ self.__class__.objects.exclude(app_user_agreement__id__gte=1).exclude(id=self.id).delete()
if had_pk:
reset_app_user_cache()
diff --git a/breathecode/authenticate/receivers.py b/breathecode/authenticate/receivers.py
index 494043673..50df293d1 100644
--- a/breathecode/authenticate/receivers.py
+++ b/breathecode/authenticate/receivers.py
@@ -3,12 +3,12 @@
from django.contrib.auth.models import Group, User
from django.core.exceptions import ObjectDoesNotExist
-from django.db.models.signals import post_delete, post_save, pre_delete
-from breathecode.admissions.signals import student_edu_status_updated, cohort_stage_updated
+from django.db.models.signals import post_delete, post_save, pre_delete, m2m_changed
+from breathecode.admissions.signals import student_edu_status_updated
from breathecode.admissions.models import CohortUser
from django.dispatch import receiver
from .tasks import async_remove_from_organization, async_add_to_organization
-from breathecode.authenticate.models import ProfileAcademy
+from breathecode.authenticate.models import App, ProfileAcademy
from breathecode.mentorship.models import MentorProfile
from django.db.models import Q
from django.utils import timezone
@@ -107,3 +107,22 @@ def post_save_cohort_user(sender, instance, **kwargs):
async_add_to_organization(instance.cohort.id, instance.user.id)
else:
async_remove_from_organization(instance.cohort.id, instance.user.id)
+
+
+@receiver(m2m_changed, sender=App.optional_scopes.through)
+def increment_on_change_required_scope(sender: Type[App.required_scopes.through],
+ instance: App.required_scopes.through, action: str, **kwargs):
+
+ if action == 'post_add':
+
+ instance.agreement_version += 1
+ instance.save()
+
+
+@receiver(m2m_changed, sender=App.optional_scopes.through)
+def increment_on_change_optional_scope(sender: Type[App], instance: App, action: str, **kwargs):
+
+ if action == 'post_add':
+
+ instance.agreement_version += 1
+ instance.save()
diff --git a/breathecode/authenticate/templates/authorize.html b/breathecode/authenticate/templates/authorize.html
new file mode 100644
index 000000000..c558d2cb5
--- /dev/null
+++ b/breathecode/authenticate/templates/authorize.html
@@ -0,0 +1,90 @@
+{% load button %}
+{% load scopes %}
+
+
+
+
+
+
+ Academy Invite
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/breathecode/authenticate/templatetags/__init__.py b/breathecode/authenticate/templatetags/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/breathecode/authenticate/urls.py b/breathecode/authenticate/urls.py
index 0fdd4adeb..aecbe0662 100644
--- a/breathecode/authenticate/urls.py
+++ b/breathecode/authenticate/urls.py
@@ -19,11 +19,12 @@
GitpodUserView, LoginView, LogoutView, MeInviteView, MemberView, PasswordResetView,
ProfileInviteMeView, ProfileMePictureView, ProfileMeView, ResendInviteView, StudentView,
TemporalTokenView, TokenTemporalView, UserMeView, WaitingListView, app_webhook,
- get_facebook_token, get_github_token, get_google_token, get_roles, get_slack_token,
- get_token_info, get_user_by_id_or_email, get_users, login_html_view, pick_password,
- render_academy_invite, render_invite, render_user_invite, reset_password_view,
- save_facebook_token, save_github_token, save_google_token, save_slack_token,
- sync_gitpod_users_view, GithubUserView, AcademyGithubSyncView, AcademyAuthSettingsView)
+ authorize_view, get_facebook_token, get_github_token, get_google_token, get_roles,
+ get_slack_token, get_token_info, get_user_by_id_or_email, get_users, login_html_view,
+ pick_password, render_academy_invite, render_invite, render_user_invite,
+ reset_password_view, save_facebook_token, save_github_token, save_google_token,
+ save_slack_token, sync_gitpod_users_view, GithubUserView, AcademyGithubSyncView,
+ AcademyAuthSettingsView)
app_name = 'authenticate'
urlpatterns = [
@@ -105,6 +106,9 @@
path('academy/gitpod/user', GitpodUserView.as_view(), name='gitpod_user'),
path('academy/gitpod/user/', GitpodUserView.as_view(), name='gitpod_user_id'),
+ # authorize
+ path('authorize/', authorize_view, name='authorize'),
+
# apps
path('app/user/', AppUserView.as_view(), name='app_user_id'),
path('app/webhook', app_webhook, name='app_webhook'),
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index 34de119b4..b97eb2a4e 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -47,14 +47,15 @@
from breathecode.utils.shorteners import C
from breathecode.utils.views import (private_view, render_message, set_query_parameter)
-from .actions import (generate_academy_token, get_user_language, resend_invite, reset_password,
+from .actions import (generate_academy_token, get_app, get_user_language, resend_invite, reset_password,
set_gitpod_user_expiration, update_gitpod_users, sync_organization_members,
get_github_scopes)
from .authentication import ExpiringTokenAuthentication
from .forms import (InviteForm, LoginForm, PasswordChangeCustomForm, PickPasswordForm, ResetPasswordForm,
SyncGithubUsersForm)
-from .models import (CredentialsFacebook, CredentialsGithub, CredentialsGoogle, CredentialsSlack, GitpodUser,
- Profile, ProfileAcademy, Role, Token, UserInvite, GithubAcademyUser, AcademyAuthSettings)
+from .models import (AppUserAgreement, CredentialsFacebook, CredentialsGithub, CredentialsGoogle,
+ CredentialsSlack, GitpodUser, OptionalScopeSet, Profile, ProfileAcademy, Role, Scope,
+ Token, UserInvite, GithubAcademyUser, AcademyAuthSettings)
from .serializers import (
AppUserSerializer, AuthSerializer, GetGitpodUserSerializer, GetProfileAcademySerializer,
GetProfileAcademySmallSerializer, GetProfileSerializer, GitpodUserSmallSerializer, MemberPOSTSerializer,
@@ -2377,3 +2378,57 @@ def get(self, request, user_id=None):
response = request.json()
return Response({'message': 'ok'})
+
+
+@private_view()
+def authorize_view(request, token=None, app_slug=None):
+ try:
+ app = get_app(app_slug)
+
+ except:
+ return render_message(request, 'App not found', btn_label='Continue to 4Geeks', btn_url=APP_URL)
+
+ if not app.require_an_agreement:
+ return render_message(request, 'App not found', btn_label='Continue to 4Geeks', btn_url=APP_URL)
+
+ if request.method == 'GET':
+ return render(request, 'authorize.html', {
+ 'app': app,
+ 'reject_url': app.redirect_url + '?app=4geeks&status=rejected',
+ })
+
+ if request.method == 'POST':
+ items = set()
+ for key in request.POST:
+ if key == 'csrfmiddlewaretoken':
+ continue
+
+ items.add(key)
+
+ items = sorted(list(items))
+ query = Q()
+
+ for item in items:
+ query |= Q(optional_scopes__slug=item)
+
+ cache = OptionalScopeSet.objects.filter(query).first()
+ if cache is None or cache.optional_scopes.count() != len(items):
+ cache = OptionalScopeSet()
+ cache.save()
+
+ for s in items:
+ scope = Scope.objects.filter(slug=s).first()
+ cache.optional_scopes.add(scope)
+
+ if (agreement := AppUserAgreement.objects.filter(app=app, user=request.user).first()):
+ agreement.optional_scope_set = cache
+ agreement.agreement_version = app.agreement_version
+ agreement.save()
+
+ else:
+ agreement = AppUserAgreement.objects.create(app=app,
+ user=request.user,
+ agreement_version=app.agreement_version,
+ optional_scope_set=cache)
+
+ return redirect(app.redirect_url + '?app=4geeks&status=authorized')
diff --git a/breathecode/commons/templates/button.html b/breathecode/commons/templates/button.html
new file mode 100644
index 000000000..380c49c48
--- /dev/null
+++ b/breathecode/commons/templates/button.html
@@ -0,0 +1,7 @@
+{% if type == 'link' %}
+{{value}}
+{% else %}
+
+{% endif %}
diff --git a/breathecode/commons/templates/scopes.html b/breathecode/commons/templates/scopes.html
new file mode 100644
index 000000000..41d634828
--- /dev/null
+++ b/breathecode/commons/templates/scopes.html
@@ -0,0 +1,65 @@
+{% if scopes.count %}
+
+
+
+
+
+ {% for scope in scopes.all %}
+
+
+
+
+ {{scope.name}}
+
+
+
+
+
+
+ {% if disabled %}
+
+ {% else %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+
+{% endif %}
diff --git a/breathecode/commons/templatetags/button.py b/breathecode/commons/templatetags/button.py
new file mode 100644
index 000000000..04628059b
--- /dev/null
+++ b/breathecode/commons/templatetags/button.py
@@ -0,0 +1,15 @@
+# my_inclusion_tag.py
+from django import template
+
+register = template.Library()
+
+
+@register.inclusion_tag('button.html')
+def button(*, type='button', href='#', onclick='', className='', value):
+ return {
+ 'type': type,
+ 'href': href,
+ 'onclick': onclick,
+ 'className': className,
+ 'value': value,
+ }
diff --git a/breathecode/commons/templatetags/scopes.py b/breathecode/commons/templatetags/scopes.py
new file mode 100644
index 000000000..55e0170f0
--- /dev/null
+++ b/breathecode/commons/templatetags/scopes.py
@@ -0,0 +1,14 @@
+# my_inclusion_tag.py
+from django import template
+
+register = template.Library()
+
+
+@register.inclusion_tag('scopes.html')
+def scopes(*, scopes=[], id='unnamed', title='Unnamed', disabled=False):
+ return {
+ 'scopes': scopes,
+ 'id': id,
+ 'title': title,
+ 'disabled': disabled,
+ }
diff --git a/breathecode/utils/service.py b/breathecode/utils/service.py
index 405ac501c..c6cfb53b5 100644
--- a/breathecode/utils/service.py
+++ b/breathecode/utils/service.py
@@ -55,34 +55,48 @@ def _authenticate(self, method, params=None, data=None, json=None, **kwargs) ->
raise Exception('Strategy not implemented')
+ def _fix_url(self, url):
+ if url[0] != '/':
+ url = f'/{url}'
+
+ return url
+
def get(self, url, params=None, **kwargs):
+ url = self.app.app_url + self._fix_url(url)
headers = self._authenticate('get', params=params, **kwargs)
return requests.get(url, params=params, **kwargs, headers=headers)
def options(self, url, **kwargs):
+ url = self.app.app_url + self._fix_url(url)
headers = self._authenticate('options', **kwargs)
return requests.options(url, **kwargs, headers=headers)
def head(self, url, **kwargs):
+ url = self.app.app_url + self._fix_url(url)
headers = self._authenticate('head', **kwargs)
return requests.head(url, **kwargs, headers=headers)
def post(self, url, data=None, json=None, **kwargs):
+ url = self.app.app_url + self._fix_url(url)
headers = self._authenticate('post', data=data, json=json, **kwargs)
return requests.post(url, data=data, json=json, **kwargs, headers=headers)
def put(self, url, data=None, **kwargs):
+ url = self.app.app_url + self._fix_url(url)
headers = self._authenticate('put', data=data, **kwargs)
return requests.put(url, data=data, **kwargs, headers=headers)
def patch(self, url, data=None, **kwargs):
+ url = self.app.app_url + self._fix_url(url)
headers = self._authenticate('patch', data=data, **kwargs)
return requests.patch(url, data=data, **kwargs, headers=headers)
def delete(self, url, **kwargs):
+ url = self.app.app_url + self._fix_url(url)
headers = self._authenticate('delete', **kwargs)
return requests.delete(url, **kwargs, headers=headers)
def request(self, method, url, **kwargs):
+ url = self.app.app_url + self._fix_url(url)
headers = self._authenticate(method, **kwargs)
return requests.request(method, url, **kwargs, headers=headers)
From 3a0ebff7eb3426c5359203c86284f2ae818d84f9 Mon Sep 17 00:00:00 2001
From: jefer94
Date: Wed, 2 Aug 2023 00:08:03 -0500
Subject: [PATCH 12/19] add tests
---
breathecode/assignments/tasks.py | 34 +--
.../tests_set_cohort_user_assignments.py | 224 ++++++++++++++++++
.../tests_academy_task_id_coderevision.py | 93 ++++++++
.../tests/urls/tests_me_coderevision.py | 64 +++++
.../urls/tests_me_task_id_coderevision.py | 70 ++++++
breathecode/assignments/urls.py | 10 +-
breathecode/assignments/views.py | 82 ++++++-
breathecode/authenticate/actions.py | 4 +-
breathecode/authenticate/models.py | 9 +-
breathecode/authenticate/tasks.py | 5 +-
breathecode/authenticate/views.py | 26 --
11 files changed, 566 insertions(+), 55 deletions(-)
create mode 100644 breathecode/assignments/tests/urls/tests_academy_task_id_coderevision.py
create mode 100644 breathecode/assignments/tests/urls/tests_me_coderevision.py
create mode 100644 breathecode/assignments/tests/urls/tests_me_task_id_coderevision.py
diff --git a/breathecode/assignments/tasks.py b/breathecode/assignments/tasks.py
index 1a4f29414..5b77d8329 100644
--- a/breathecode/assignments/tasks.py
+++ b/breathecode/assignments/tasks.py
@@ -117,20 +117,24 @@ def serialize_task(task):
cohort_user.save()
s = None
- if hasattr(task.user, 'credentialsgithub') and task.github_url:
- s = Service('rigobot', task.user.id)
-
- if s and task.task_status == 'DONE':
- s.post('/v1/finetuning/me/repository/',
- json={
- 'url': task.github_url,
- 'watchers': task.user.credentialsgithub.username,
- })
-
- elif s:
- s.put('/v1/finetuning/me/repository/', json={
- 'url': task.github_url,
- 'activity_status': 'INACTIVE',
- })
+ try:
+ if hasattr(task.user, 'credentialsgithub') and task.github_url:
+ s = Service('rigobot', task.user.id)
+
+ if s and task.task_status == 'DONE':
+ s.post('/v1/finetuning/me/repository/',
+ json={
+ 'url': task.github_url,
+ 'watchers': task.user.credentialsgithub.username,
+ })
+
+ elif s:
+ s.put('/v1/finetuning/me/repository/',
+ json={
+ 'url': task.github_url,
+ 'activity_status': 'INACTIVE',
+ })
+ except:
+ logger.error('App Rigobot not found')
logger.info('History log saved')
diff --git a/breathecode/assignments/tests/tasks/tests_set_cohort_user_assignments.py b/breathecode/assignments/tests/tasks/tests_set_cohort_user_assignments.py
index 61dd9a552..06164c5e2 100644
--- a/breathecode/assignments/tests/tasks/tests_set_cohort_user_assignments.py
+++ b/breathecode/assignments/tests/tasks/tests_set_cohort_user_assignments.py
@@ -10,6 +10,7 @@
from ..mixins import AssignmentsTestCase
from ...tasks import set_cohort_user_assignments
+from breathecode.utils.service import Service
class MediaTestSuite(AssignmentsTestCase):
@@ -260,6 +261,229 @@ def test__with_one_task__task_is_pending__with_log__from_different_items(self):
])
self.assertEqual(Logger.error.call_args_list, [])
+ @patch('logging.Logger.info', MagicMock())
+ @patch('logging.Logger.error', MagicMock())
+ @patch('breathecode.assignments.signals.assignment_created.send', MagicMock())
+ @patch('breathecode.assignments.signals.assignment_status_updated.send', MagicMock())
+ @patch('breathecode.activity.tasks.get_attendancy_log.delay', MagicMock())
+ @patch('django.db.models.signals.pre_delete.send', MagicMock(return_value=None))
+ @patch('breathecode.admissions.signals.student_edu_status_updated.send', MagicMock(return_value=None))
+ def test__rigobot_not_found(self):
+ task_type = random.choice(['LESSON', 'QUIZ', 'PROJECT', 'EXERCISE'])
+ task = {
+ 'task_status': 'PENDING',
+ 'task_type': task_type,
+ 'github_url': self.bc.fake.url(),
+ }
+ cohort_user = {
+ 'history_log': {
+ 'delivered_assignments': [
+ {
+ 'id': 3,
+ 'type': task_type,
+ },
+ ],
+ 'pending_assignments': [
+ {
+ 'id': 2,
+ 'type': task_type,
+ },
+ ],
+ }
+ }
+ model = self.bc.database.create(task=task, cohort_user=cohort_user, credentials_github=1)
+
+ Logger.info.call_args_list = []
+
+ set_cohort_user_assignments.delay(1)
+
+ self.assertEqual(self.bc.database.list_of('assignments.Task'), [self.bc.format.to_dict(model.task)])
+ self.assertEqual(self.bc.database.list_of('admissions.CohortUser'), [
+ {
+ **self.bc.format.to_dict(model.cohort_user),
+ 'history_log': {
+ 'delivered_assignments': [
+ {
+ 'id': 3,
+ 'type': task_type,
+ },
+ ],
+ 'pending_assignments': [
+ {
+ 'id': 2,
+ 'type': task_type,
+ },
+ {
+ 'id': 1,
+ 'type': task_type,
+ },
+ ],
+ },
+ },
+ ])
+ self.assertEqual(Logger.info.call_args_list, [
+ call('Executing set_cohort_user_assignments'),
+ call('History log saved'),
+ ])
+ self.assertEqual(Logger.error.call_args_list, [call('App Rigobot not found')])
+
+ @patch('logging.Logger.info', MagicMock())
+ @patch('logging.Logger.error', MagicMock())
+ @patch('breathecode.assignments.signals.assignment_created.send', MagicMock())
+ @patch('breathecode.assignments.signals.assignment_status_updated.send', MagicMock())
+ @patch('breathecode.activity.tasks.get_attendancy_log.delay', MagicMock())
+ @patch('django.db.models.signals.pre_delete.send', MagicMock(return_value=None))
+ @patch('breathecode.admissions.signals.student_edu_status_updated.send', MagicMock(return_value=None))
+ @patch.multiple('breathecode.utils.service.Service',
+ __init__=MagicMock(return_value=None),
+ post=MagicMock(return_value=None),
+ put=MagicMock(return_value=None))
+ def test__rigobot_cancelled_revision(self):
+ task_type = random.choice(['LESSON', 'QUIZ', 'PROJECT', 'EXERCISE'])
+ task = {
+ 'task_status': 'PENDING',
+ 'task_type': task_type,
+ 'github_url': self.bc.fake.url(),
+ }
+ cohort_user = {
+ 'history_log': {
+ 'delivered_assignments': [
+ {
+ 'id': 3,
+ 'type': task_type,
+ },
+ ],
+ 'pending_assignments': [
+ {
+ 'id': 2,
+ 'type': task_type,
+ },
+ ],
+ }
+ }
+ model = self.bc.database.create(task=task, cohort_user=cohort_user, credentials_github=1)
+
+ Logger.info.call_args_list = []
+
+ set_cohort_user_assignments.delay(1)
+
+ self.assertEqual(self.bc.database.list_of('assignments.Task'), [self.bc.format.to_dict(model.task)])
+ self.assertEqual(self.bc.database.list_of('admissions.CohortUser'), [
+ {
+ **self.bc.format.to_dict(model.cohort_user),
+ 'history_log': {
+ 'delivered_assignments': [
+ {
+ 'id': 3,
+ 'type': task_type,
+ },
+ ],
+ 'pending_assignments': [
+ {
+ 'id': 2,
+ 'type': task_type,
+ },
+ {
+ 'id': 1,
+ 'type': task_type,
+ },
+ ],
+ },
+ },
+ ])
+ self.assertEqual(Logger.info.call_args_list, [
+ call('Executing set_cohort_user_assignments'),
+ call('History log saved'),
+ ])
+ self.assertEqual(Logger.error.call_args_list, [])
+ self.bc.check.calls(Service.__init__.call_args_list, [call('rigobot', 1)])
+ self.bc.check.calls(Service.post.call_args_list, [])
+ self.bc.check.calls(Service.put.call_args_list, [
+ call('/v1/finetuning/me/repository/',
+ json={
+ 'url': model.task.github_url,
+ 'activity_status': 'INACTIVE'
+ })
+ ])
+
+ @patch('logging.Logger.info', MagicMock())
+ @patch('logging.Logger.error', MagicMock())
+ @patch('breathecode.assignments.signals.assignment_created.send', MagicMock())
+ @patch('breathecode.assignments.signals.assignment_status_updated.send', MagicMock())
+ @patch('breathecode.activity.tasks.get_attendancy_log.delay', MagicMock())
+ @patch('django.db.models.signals.pre_delete.send', MagicMock(return_value=None))
+ @patch('breathecode.admissions.signals.student_edu_status_updated.send', MagicMock(return_value=None))
+ @patch.multiple('breathecode.utils.service.Service',
+ __init__=MagicMock(return_value=None),
+ post=MagicMock(return_value=None),
+ put=MagicMock(return_value=None))
+ def test__rigobot_schedule_revision(self):
+ task_type = random.choice(['LESSON', 'QUIZ', 'PROJECT', 'EXERCISE'])
+ task = {
+ 'task_status': 'DONE',
+ 'task_type': task_type,
+ 'github_url': self.bc.fake.url(),
+ }
+ cohort_user = {
+ 'history_log': {
+ 'delivered_assignments': [
+ {
+ 'id': 3,
+ 'type': task_type,
+ },
+ ],
+ 'pending_assignments': [
+ {
+ 'id': 2,
+ 'type': task_type,
+ },
+ ],
+ }
+ }
+ model = self.bc.database.create(task=task, cohort_user=cohort_user, credentials_github=1)
+
+ Logger.info.call_args_list = []
+
+ set_cohort_user_assignments.delay(1)
+
+ self.assertEqual(self.bc.database.list_of('assignments.Task'), [self.bc.format.to_dict(model.task)])
+ self.assertEqual(self.bc.database.list_of('admissions.CohortUser'), [
+ {
+ **self.bc.format.to_dict(model.cohort_user),
+ 'history_log': {
+ 'delivered_assignments': [
+ {
+ 'id': 3,
+ 'type': task_type,
+ },
+ {
+ 'id': 1,
+ 'type': task_type,
+ },
+ ],
+ 'pending_assignments': [
+ {
+ 'id': 2,
+ 'type': task_type,
+ },
+ ],
+ },
+ },
+ ])
+ self.assertEqual(Logger.info.call_args_list, [
+ call('Executing set_cohort_user_assignments'),
+ call('History log saved'),
+ ])
+ self.assertEqual(Logger.error.call_args_list, [])
+ self.bc.check.calls(Service.__init__.call_args_list, [call('rigobot', 1)])
+ self.bc.check.calls(
+ Service.post.call_args_list,
+ [call('/v1/finetuning/me/repository/', json={
+ 'url': model.task.github_url,
+ 'watchers': None
+ })])
+ self.bc.check.calls(Service.put.call_args_list, [])
+
@patch('logging.Logger.info', MagicMock())
@patch('logging.Logger.error', MagicMock())
@patch('breathecode.assignments.signals.assignment_created.send', MagicMock())
diff --git a/breathecode/assignments/tests/urls/tests_academy_task_id_coderevision.py b/breathecode/assignments/tests/urls/tests_academy_task_id_coderevision.py
new file mode 100644
index 000000000..281769e48
--- /dev/null
+++ b/breathecode/assignments/tests/urls/tests_academy_task_id_coderevision.py
@@ -0,0 +1,93 @@
+"""
+Test /answer
+"""
+import json
+import random
+from unittest.mock import MagicMock, call, patch
+
+from django.urls.base import reverse_lazy
+from rest_framework import status
+
+from breathecode.utils.service import Service
+
+from ..mixins import AssignmentsTestCase
+
+
+class MediaTestSuite(AssignmentsTestCase):
+
+ # When: no auth
+ # Then: response 401
+ def test_no_auth(self):
+ url = reverse_lazy('assignments:academy_task_id_coderevision', kwargs={'task_id': 1})
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = {'detail': 'Authentication credentials were not provided.', 'status_code': 401}
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+ self.assertEqual(self.bc.database.list_of('assignments.Task'), [])
+
+ # When: no capability
+ # Then: response 403
+ def test_no_capability(self):
+ model = self.bc.database.create(user=1)
+
+ self.bc.request.set_headers(academy=1)
+ self.bc.request.authenticate(model.user)
+
+ url = reverse_lazy('assignments:academy_task_id_coderevision', kwargs={'task_id': 1})
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = {
+ 'detail': 'You (user: 1) don\'t have this capability: read_assignment for academy 1',
+ 'status_code': 403,
+ }
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertEqual(self.bc.database.list_of('assignments.Task'), [])
+
+ # When: auth
+ # Then: response 200
+ def test_auth(self):
+ self.bc.request.set_headers(academy=1)
+
+ expected = {'data': {'getTask': {'id': random.randint(1, 100)}}}
+ query = {
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ }
+
+ mock = MagicMock()
+ mock.raw = iter([json.dumps(expected).encode()])
+ mock.headers = {'Content-Type': 'application/json'}
+ code = random.randint(200, 299)
+ mock.status_code = code
+ mock.reason = 'OK'
+
+ task = {'github_url': self.bc.fake.url()}
+ model = self.bc.database.create(profile_academy=1, task=task, role=1, capability='read_assignment')
+ self.bc.request.authenticate(model.user)
+
+ url = reverse_lazy('assignments:academy_task_id_coderevision',
+ kwargs={'task_id': 1}) + '?' + self.bc.format.querystring(query)
+
+ with patch.multiple('breathecode.utils.service.Service',
+ __init__=MagicMock(return_value=None),
+ get=MagicMock(return_value=mock)):
+ response = self.client.get(url)
+ self.bc.check.calls(Service.get.call_args_list, [
+ call('/v1/finetuning/coderevision',
+ params={
+ **query,
+ 'repo': model.task.github_url,
+ },
+ stream=True),
+ ])
+
+ self.assertEqual(response.getvalue().decode('utf-8'), json.dumps(expected))
+ self.assertEqual(response.status_code, code)
+ self.assertEqual(self.bc.database.list_of('assignments.Task'), [self.bc.format.to_dict(model.task)])
diff --git a/breathecode/assignments/tests/urls/tests_me_coderevision.py b/breathecode/assignments/tests/urls/tests_me_coderevision.py
new file mode 100644
index 000000000..a1796543b
--- /dev/null
+++ b/breathecode/assignments/tests/urls/tests_me_coderevision.py
@@ -0,0 +1,64 @@
+"""
+Test /answer
+"""
+import json
+import random
+from unittest.mock import MagicMock, call, patch
+
+from django.urls.base import reverse_lazy
+from rest_framework import status
+
+from breathecode.utils.service import Service
+
+from ..mixins import AssignmentsTestCase
+
+
+class MediaTestSuite(AssignmentsTestCase):
+
+ # When: no auth
+ # Then: response 401
+ def test_no_auth(self):
+ url = reverse_lazy('assignments:me_coderevision')
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = {'detail': 'Authentication credentials were not provided.', 'status_code': 401}
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+ self.assertEqual(self.bc.database.list_of('assignments.Task'), [])
+
+ # When: auth
+ # Then: response 200
+ def test_auth(self):
+ expected = {'data': {'getTask': {'id': random.randint(1, 100)}}}
+ query = {
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ }
+
+ mock = MagicMock()
+ mock.raw = iter([json.dumps(expected).encode()])
+ mock.headers = {'Content-Type': 'application/json'}
+ code = random.randint(200, 299)
+ mock.status_code = code
+ mock.reason = 'OK'
+
+ task = {'github_url': self.bc.fake.url()}
+ model = self.bc.database.create(profile_academy=1, task=task)
+ self.bc.request.authenticate(model.user)
+
+ url = reverse_lazy('assignments:me_coderevision') + '?' + self.bc.format.querystring(query)
+
+ with patch.multiple('breathecode.utils.service.Service',
+ __init__=MagicMock(return_value=None),
+ get=MagicMock(return_value=mock)):
+ response = self.client.get(url)
+ self.bc.check.calls(Service.get.call_args_list, [
+ call('/v1/finetuning/me/coderevision', params=query, stream=True),
+ ])
+
+ self.assertEqual(response.getvalue().decode('utf-8'), json.dumps(expected))
+ self.assertEqual(response.status_code, code)
+ self.assertEqual(self.bc.database.list_of('assignments.Task'), [self.bc.format.to_dict(model.task)])
diff --git a/breathecode/assignments/tests/urls/tests_me_task_id_coderevision.py b/breathecode/assignments/tests/urls/tests_me_task_id_coderevision.py
new file mode 100644
index 000000000..7188ea4e1
--- /dev/null
+++ b/breathecode/assignments/tests/urls/tests_me_task_id_coderevision.py
@@ -0,0 +1,70 @@
+"""
+Test /answer
+"""
+import json
+import random
+from unittest.mock import MagicMock, call, patch
+
+from django.urls.base import reverse_lazy
+from rest_framework import status
+
+from breathecode.utils.service import Service
+
+from ..mixins import AssignmentsTestCase
+
+
+class MediaTestSuite(AssignmentsTestCase):
+
+ # When: no auth
+ # Then: response 401
+ def test_no_auth(self):
+ url = reverse_lazy('assignments:me_task_id_coderevision', kwargs={'task_id': 1})
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = {'detail': 'Authentication credentials were not provided.', 'status_code': 401}
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+ self.assertEqual(self.bc.database.list_of('assignments.Task'), [])
+
+ # When: auth
+ # Then: response 200
+ def test_auth(self):
+ expected = {'data': {'getTask': {'id': random.randint(1, 100)}}}
+ query = {
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ }
+
+ mock = MagicMock()
+ mock.raw = iter([json.dumps(expected).encode()])
+ mock.headers = {'Content-Type': 'application/json'}
+ code = random.randint(200, 299)
+ mock.status_code = code
+ mock.reason = 'OK'
+
+ task = {'github_url': self.bc.fake.url()}
+ model = self.bc.database.create(profile_academy=1, task=task)
+ self.bc.request.authenticate(model.user)
+
+ url = reverse_lazy('assignments:me_task_id_coderevision',
+ kwargs={'task_id': 1}) + '?' + self.bc.format.querystring(query)
+
+ with patch.multiple('breathecode.utils.service.Service',
+ __init__=MagicMock(return_value=None),
+ get=MagicMock(return_value=mock)):
+ response = self.client.get(url)
+ self.bc.check.calls(Service.get.call_args_list, [
+ call('/v1/finetuning/coderevision',
+ params={
+ **query,
+ 'repo': model.task.github_url,
+ },
+ stream=True),
+ ])
+
+ self.assertEqual(response.getvalue().decode('utf-8'), json.dumps(expected))
+ self.assertEqual(response.status_code, code)
+ self.assertEqual(self.bc.database.list_of('assignments.Task'), [self.bc.format.to_dict(model.task)])
diff --git a/breathecode/assignments/urls.py b/breathecode/assignments/urls.py
index 6d5130cc5..cf3074183 100644
--- a/breathecode/assignments/urls.py
+++ b/breathecode/assignments/urls.py
@@ -1,7 +1,8 @@
from django.urls import path
-from .views import (MeCodeRevisionView, MeTaskCodeRevisionView, TaskMeView, sync_cohort_tasks_view,
- TaskTeacherView, deliver_assignment_view, TaskMeDeliverView, FinalProjectMeView,
- CohortTaskView, SubtaskMeView, TaskMeAttachmentView, FinalProjectScreenshotView)
+from .views import (AcademyTaskCodeRevisionView, MeCodeRevisionView, MeTaskCodeRevisionView, TaskMeView,
+ sync_cohort_tasks_view, TaskTeacherView, deliver_assignment_view, TaskMeDeliverView,
+ FinalProjectMeView, CohortTaskView, SubtaskMeView, TaskMeAttachmentView,
+ FinalProjectScreenshotView)
app_name = 'assignments'
urlpatterns = [
@@ -18,6 +19,9 @@
path('me/task//coderevision',
MeTaskCodeRevisionView.as_view(),
name='me_task_id_coderevision'),
+ path('academy/task//coderevision',
+ AcademyTaskCodeRevisionView.as_view(),
+ name='academy_task_id_coderevision'),
path('user//task', TaskMeView.as_view(), name='user_id_task'),
path('user//task/', TaskMeView.as_view(), name='user_id_task_id'),
path('academy/cohort//task', CohortTaskView.as_view()),
diff --git a/breathecode/assignments/views.py b/breathecode/assignments/views.py
index 4540a9ad3..cb7f03754 100644
--- a/breathecode/assignments/views.py
+++ b/breathecode/assignments/views.py
@@ -1,4 +1,4 @@
-from django.http import HttpResponseRedirect
+from django.http import HttpResponseRedirect, StreamingHttpResponse
from breathecode.authenticate.actions import get_user_language
from breathecode.authenticate.models import ProfileAcademy
import logging, hashlib, os
@@ -824,17 +824,87 @@ def put(self, request, task_id):
class MeCodeRevisionView(APIView):
def get(self, request):
+ params = {}
+ for key in request.GET.keys():
+ params[key] = request.GET.get(key)
+
s = Service('rigobot', request.user.id)
- response = s.get('/v1/finetuning/me/coderevision?read=false')
- return response.json()
+ response = s.get('/v1/finetuning/me/coderevision', params=params, stream=True)
+ resource = StreamingHttpResponse(
+ response.raw,
+ status=response.status_code,
+ reason=response.reason,
+ )
+
+ header_keys = [
+ x for x in response.headers.keys() if x != 'Transfer-Encoding' and x != 'Content-Encoding'
+ and x != 'Keep-Alive' and x != 'Connection'
+ ]
+
+ for header in header_keys:
+ resource[header] = response.headers[header]
+
+ return resource
class MeTaskCodeRevisionView(APIView):
def get(self, request, task_id):
- if not (task := Task.objects.filter(id=task_id, user__id=request.user.id).exists()):
+ if not (task := Task.objects.filter(id=task_id, user__id=request.user.id).first()):
raise ValidationException('Task not found', code=404, slug='task-not-found')
+ params = {}
+ for key in request.GET.keys():
+ params[key] = request.GET.get(key)
+
+ params['repo'] = task.github_url
+
s = Service('rigobot', request.user.id)
- response = s.get(f'/v1/finetuning/coderevision?repo={task.github_url}')
- return response.json()
+ response = s.get(f'/v1/finetuning/coderevision', params=params, stream=True)
+ resource = StreamingHttpResponse(
+ response.raw,
+ status=response.status_code,
+ reason=response.reason,
+ )
+
+ header_keys = [
+ x for x in response.headers.keys() if x != 'Transfer-Encoding' and x != 'Content-Encoding'
+ and x != 'Keep-Alive' and x != 'Connection'
+ ]
+
+ for header in header_keys:
+ resource[header] = response.headers[header]
+
+ return resource
+
+
+class AcademyTaskCodeRevisionView(APIView):
+
+ @capable_of('read_assignment')
+ def get(self, request, task_id, academy_id):
+ if not (task := Task.objects.filter(id=task_id, cohort__academy__id=academy_id).first()):
+ raise ValidationException('Task not found', code=404, slug='task-not-found')
+
+ params = {}
+ for key in request.GET.keys():
+ params[key] = request.GET.get(key)
+
+ params['repo'] = task.github_url
+
+ s = Service('rigobot')
+ response = s.get(f'/v1/finetuning/coderevision', params=params, stream=True)
+ resource = StreamingHttpResponse(
+ response.raw,
+ status=response.status_code,
+ reason=response.reason,
+ )
+
+ header_keys = [
+ x for x in response.headers.keys() if x != 'Transfer-Encoding' and x != 'Content-Encoding'
+ and x != 'Keep-Alive' and x != 'Connection'
+ ]
+
+ for header in header_keys:
+ resource[header] = response.headers[header]
+
+ return resource
diff --git a/breathecode/authenticate/actions.py b/breathecode/authenticate/actions.py
index c3065d4b2..cb67d858f 100644
--- a/breathecode/authenticate/actions.py
+++ b/breathecode/authenticate/actions.py
@@ -26,8 +26,8 @@
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
-from .models import (ASYMMETRIC_ALGORITHMS, App, CredentialsGithub, DeviceId, GitpodUser, ProfileAcademy,
- Role, Token, UserSetting, AcademyAuthSettings, GithubAcademyUser)
+from .models import (App, CredentialsGithub, DeviceId, GitpodUser, ProfileAcademy, Role, Token, UserSetting,
+ AcademyAuthSettings, GithubAcademyUser)
logger = logging.getLogger(__name__)
diff --git a/breathecode/authenticate/models.py b/breathecode/authenticate/models.py
index 9a9835fb4..9ec4a29a7 100644
--- a/breathecode/authenticate/models.py
+++ b/breathecode/authenticate/models.py
@@ -16,9 +16,8 @@
from breathecode.authenticate.exceptions import (BadArguments, InvalidTokenType, TokenNotFound,
TryToGetOrCreateAOneTimeToken)
-from breathecode.utils.validation_exception import ValidationException
from breathecode.utils.validators import validate_language_code
-from .signals import invite_status_updated, profile_academy_saved, academy_invite_accepted
+from .signals import invite_status_updated, academy_invite_accepted
from breathecode.admissions.models import Academy, Cohort
__all__ = [
@@ -348,6 +347,12 @@ def save(self, *args, **kwargs):
tasks.destroy_legacy_key.apply_async(args=(self.id, ), eta=timezone.now() + LEGACY_KEY_LIFETIME)
+ def delete(self, *args, **kwargs):
+ from . import actions
+ r = super().delete(*args, **kwargs)
+ actions.reset_app_cache()
+ return r
+
PENDING = 'PENDING'
ACCEPTED = 'ACCEPTED'
diff --git a/breathecode/authenticate/tasks.py b/breathecode/authenticate/tasks.py
index a2cd8d2b7..496c55f6b 100644
--- a/breathecode/authenticate/tasks.py
+++ b/breathecode/authenticate/tasks.py
@@ -1,6 +1,8 @@
import logging, os
from celery import shared_task, Task
from django.contrib.auth.models import User
+
+from breathecode.utils.decorators.task import task
from .actions import set_gitpod_user_expiration, add_to_organization, remove_from_organization
from breathecode.notify import actions as notify_actions
@@ -76,7 +78,8 @@ def async_accept_user_from_waiting_list(user_invite_id: int) -> None:
})
-@shared_task
+@task
def destroy_legacy_key(legacy_key_id):
from .models import LegacyKey
+
LegacyKey.objects.filter(id=legacy_key_id).delete()
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index b97eb2a4e..f9f3fdb91 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -2354,32 +2354,6 @@ def app_webhook(request, app: dict):
return Response({'message': 'ok'})
-# demo
-class DemoView(APIView):
- extensions = APIViewExtensions(paginate=True)
-
- def get(self, request, user_id=None):
- handler = self.extensions(request)
- lang = get_user_language(request)
-
- # default from app (jwt)
- s = Service('rigobot', request.user.id)
- request = s.get('https://rigobot.4geeks.com/user')
- response = request.json()
-
- # force jwt
- s = Service('rigobot', request.user.id, mode='jwt')
- request = s.get('https://rigobot.4geeks.com/user')
- response = request.json()
-
- # force signature
- s = Service('rigobot', request.user.id, mode='signature')
- request = s.get('https://rigobot.4geeks.com/webhook')
- response = request.json()
-
- return Response({'message': 'ok'})
-
-
@private_view()
def authorize_view(request, token=None, app_slug=None):
try:
From 4fe2386cb92640d38ba15e194b715cc7945f71c0 Mon Sep 17 00:00:00 2001
From: jefer94
Date: Wed, 2 Aug 2023 18:18:51 -0500
Subject: [PATCH 13/19] latest change
---
breathecode/authenticate/actions.py | 9 +-
breathecode/authenticate/models.py | 5 +-
.../tests/urls/tests_app_user_id.py | 127 ++++++++++++++++++
breathecode/authenticate/views.py | 1 +
.../tests/mixins/breathecode_mixin/request.py | 54 ++++++++
.../generate_models_mixin/auth_mixin.py | 7 +-
.../authenticate_models_mixin.py | 54 ++++++++
breathecode/utils/decorators/scope.py | 8 +-
8 files changed, 254 insertions(+), 11 deletions(-)
create mode 100644 breathecode/authenticate/tests/urls/tests_app_user_id.py
diff --git a/breathecode/authenticate/actions.py b/breathecode/authenticate/actions.py
index cb67d858f..15bc75c95 100644
--- a/breathecode/authenticate/actions.py
+++ b/breathecode/authenticate/actions.py
@@ -750,10 +750,10 @@ def get_user_scopes(app_slug, user_id):
from .models import AppUserAgreement
info, _, _ = get_app_keys(app_slug)
- _, _, _, _, require_an_agreement, required_scopes, optional_scopes = info
+ (_, _, _, _, require_an_agreement, required_scopes, optional_scopes, _, _, _) = info
if require_an_agreement:
- agreement = AppUserAgreement.objects.filter(app__id=app_id, user__id=user_id).first()
+ agreement = AppUserAgreement.objects.filter(app__slug=app_slug, user__id=user_id).first()
if not agreement:
raise ValidationException('User has not accepted the agreement',
slug='agreement-not-accepted',
@@ -778,13 +778,15 @@ def get_app_keys(app_slug):
if app is None:
raise ValidationException('Unauthorized', code=401, slug='app-not-found')
+ print(app.algorithm)
+
if app.algorithm == 'HMAC_SHA256':
alg = 'HS256'
elif app.algorithm == 'HMAC_SHA512':
alg = 'HS512'
- elif app.algorithm == 'ed25519':
+ elif app.algorithm == 'ED25519':
alg = 'EdDSA'
else:
@@ -811,6 +813,7 @@ def get_app_keys(app_slug):
tuple(sorted(x.slug for x in app.optional_scopes.all())),
app.webhook_url,
app.redirect_url,
+ app.app_url,
)
key = (
bytes.fromhex(app.public_key) if app.public_key else None,
diff --git a/breathecode/authenticate/models.py b/breathecode/authenticate/models.py
index 9ec4a29a7..619569cb5 100644
--- a/breathecode/authenticate/models.py
+++ b/breathecode/authenticate/models.py
@@ -187,11 +187,12 @@ def __init__(self, *args, **kwargs):
description = models.CharField(max_length=255,
help_text='Description of the app, it will appear on the authorize UI')
- algorithm = models.CharField(max_length=11, choices=AUTH_ALGORITHM)
- strategy = models.CharField(max_length=9, choices=AUTH_STRATEGY)
+ algorithm = models.CharField(max_length=11, choices=AUTH_ALGORITHM, default=HMAC_SHA512)
+ strategy = models.CharField(max_length=9, choices=AUTH_STRATEGY, default=JWT)
schema = models.CharField(
max_length=4,
choices=AUTH_SCHEMA,
+ default=LINK,
help_text='Schema to use for the auth process to represent how the apps will communicate')
required_scopes = models.ManyToManyField(Scope, blank=True, related_name='app_required_scopes')
diff --git a/breathecode/authenticate/tests/urls/tests_app_user_id.py b/breathecode/authenticate/tests/urls/tests_app_user_id.py
new file mode 100644
index 000000000..3f5d2e2f3
--- /dev/null
+++ b/breathecode/authenticate/tests/urls/tests_app_user_id.py
@@ -0,0 +1,127 @@
+"""
+Test cases for /user
+"""
+import pytz, datetime
+from django.urls.base import reverse_lazy
+from rest_framework import status
+from ..mixins.new_auth_test_case import AuthTestCase
+
+
+def credentials_github_serializer(credentials_github):
+ return {
+ 'avatar_url': credentials_github.avatar_url,
+ 'name': credentials_github.name,
+ 'username': credentials_github.username,
+ }
+
+
+def profile_serializer(credentials_github):
+ return {
+ 'avatar_url': credentials_github.avatar_url,
+ }
+
+
+def get_serializer(user, credentials_github=None, profile=None):
+ return {
+ 'email': user.email,
+ 'first_name': user.first_name,
+ 'github': credentials_github_serializer(credentials_github) if credentials_github else None,
+ 'id': user.id,
+ 'last_name': user.last_name,
+ 'profile': profile_serializer(profile) if profile else None,
+ }
+
+
+class AuthenticateTestSuite(AuthTestCase):
+
+ # When: no auth
+ # Then: return 401
+ def test_no_auth(self):
+ """Test /user/me without auth"""
+
+ # self.bc.request.sign_jwt_link(app, user_id: Optional[int] = None, reverse: bool = False)
+ url = reverse_lazy('authenticate:app_user_id', kwargs={'user_id': 1})
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = {
+ 'detail': 'no-authorization-header',
+ 'status_code': status.HTTP_401_UNAUTHORIZED,
+ }
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ # When: Sign with an user
+ # Then: return 200
+ def test_sign_with_user__get_own_info(self):
+ app = {'require_an_agreement': False}
+ credentials_githubs = [{'user_id': x + 1} for x in range(2)]
+ profiles = [{'user_id': x + 1} for x in range(2)]
+ model = self.bc.database.create(user=2,
+ app=app,
+ profile=profiles,
+ credentials_github=credentials_githubs)
+ self.bc.request.sign_jwt_link(model.app, 1)
+
+ url = reverse_lazy('authenticate:app_user_id', kwargs={'user_id': 1})
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = get_serializer(model.user[0], model.credentials_github[0], model.profile[0])
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('auth.User'), self.bc.format.to_dict(model.user))
+
+ # When: Sign with an user
+ # Then: return 200
+ def test_sign_with_user__get_info_from_another(self):
+ app = {'require_an_agreement': False}
+ credentials_githubs = [{'user_id': x + 1} for x in range(2)]
+ profiles = [{'user_id': x + 1} for x in range(2)]
+ model = self.bc.database.create(user=2,
+ app=app,
+ profile=profiles,
+ credentials_github=credentials_githubs)
+ self.bc.request.sign_jwt_link(model.app, 1)
+
+ url = reverse_lazy('authenticate:app_user_id', kwargs={'user_id': 2})
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = {
+ 'detail': 'user-with-no-access',
+ 'silent': True,
+ 'silent_code': 'user-with-no-access',
+ 'status_code': 403,
+ }
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertEqual(self.bc.database.list_of('auth.User'), self.bc.format.to_dict(model.user))
+
+ # When: Sign without an user
+ # Then: return 200
+ def test_sign_without_user(self):
+ """Test /user/me without auth"""
+
+ app = {'require_an_agreement': False}
+ credentials_githubs = [{'user_id': x + 1} for x in range(2)]
+ profiles = [{'user_id': x + 1} for x in range(2)]
+ model = self.bc.database.create(user=2,
+ app=app,
+ profile=profiles,
+ credentials_github=credentials_githubs)
+ self.bc.request.sign_jwt_link(model.app)
+
+ for user in model.user:
+ url = reverse_lazy('authenticate:app_user_id', kwargs={'user_id': user.id})
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = get_serializer(user, model.credentials_github[user.id - 1], model.profile[user.id - 1])
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('auth.User'), self.bc.format.to_dict(model.user))
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index f9f3fdb91..42dfa29cf 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -2330,6 +2330,7 @@ def get(self, request, app: dict, token: dict, user_id=None):
serializer = AppUserSerializer(user, many=False)
return Response(serializer.data)
+ # test this path
items = User.objects.filter(**extra)
items = handler.queryset(items)
serializer = AppUserSerializer(items, many=True)
diff --git a/breathecode/tests/mixins/breathecode_mixin/request.py b/breathecode/tests/mixins/breathecode_mixin/request.py
index e8c065e75..99d4dbc85 100644
--- a/breathecode/tests/mixins/breathecode_mixin/request.py
+++ b/breathecode/tests/mixins/breathecode_mixin/request.py
@@ -1,3 +1,6 @@
+import os
+from typing import Optional
+import jwt
from rest_framework.test import APITestCase
__all__ = ['Request']
@@ -75,3 +78,54 @@ def manual_authentication(self, user) -> None:
token = Token.objects.create(user=user)
self._parent.client.credentials(HTTP_AUTHORIZATION=f'Token {token.key}')
+
+ def sign_jwt_link(self, app, user_id: Optional[int] = None, reverse: bool = False):
+ """
+ Set Json Web Token in the request.
+
+ Usage:
+
+ ```py
+ # setup the database
+ model = self.bc.database.create(app=1, user=1)
+
+ # that setup the request to use the credential of user passed
+ self.bc.request.authenticate(model.app, model.user.id)
+ ```
+
+ Keywords arguments:
+
+ - user: a instance of user model `breathecode.authenticate.models.User`
+ """
+ from datetime import datetime, timedelta
+ now = datetime.utcnow()
+
+ # https://datatracker.ietf.org/doc/html/rfc7519#section-4
+ payload = {
+ 'sub': user_id,
+ 'iss': os.getenv('API_URL', 'http://localhost:8000'),
+ 'app': app.slug,
+ 'aud': '4geeks',
+ 'exp': datetime.timestamp(now + timedelta(minutes=2)),
+ 'iat': datetime.timestamp(now) - 1,
+ 'typ': 'JWT',
+ }
+
+ if reverse:
+ payload['aud'] = app.slug
+ payload['app'] = '4geeks'
+
+ if app.algorithm == 'HMAC_SHA256':
+
+ token = jwt.encode(payload, bytes.fromhex(app.private_key), algorithm='HS256')
+
+ elif app.algorithm == 'HMAC_SHA512':
+ token = jwt.encode(payload, bytes.fromhex(app.private_key), algorithm='HS512')
+
+ elif app.algorithm == 'ED25519':
+ token = jwt.encode(payload, bytes.fromhex(app.private_key), algorithm='EdDSA')
+
+ else:
+ raise Exception('Algorithm not implemented')
+
+ self._parent.client.credentials(HTTP_AUTHORIZATION=f'Link App={app.slug},Token={token}')
diff --git a/breathecode/tests/mixins/generate_models_mixin/auth_mixin.py b/breathecode/tests/mixins/generate_models_mixin/auth_mixin.py
index 4a4cbda32..fc9b2e799 100644
--- a/breathecode/tests/mixins/generate_models_mixin/auth_mixin.py
+++ b/breathecode/tests/mixins/generate_models_mixin/auth_mixin.py
@@ -29,6 +29,7 @@ def generate_credentials(self,
user_setting=False,
consumption_session=False,
provisioning_container=False,
+ app_user_agreement=False,
profile_academy='',
user_kwargs={},
group_kwargs={},
@@ -55,9 +56,9 @@ def generate_credentials(self,
if not 'user' in models and (is_valid(user) or is_valid(authenticate) or is_valid(profile_academy)
or is_valid(manual_authenticate) or is_valid(cohort_user)
or is_valid(task) or is_valid(slack_team) or is_valid(mentor_profile)
- or is_valid(consumable) or is_valid(invoice) or is_valid(subscription)
- or is_valid(bag) or is_valid(user_setting)
- or is_valid(consumption_session) or is_valid(provisioning_container)):
+ or is_valid(consumable) or is_valid(invoice) or is_valid(subscription) or
+ is_valid(bag) or is_valid(user_setting) or is_valid(consumption_session)
+ or is_valid(provisioning_container) or is_valid(app_user_agreement)):
kargs = {}
if 'group' in models:
diff --git a/breathecode/tests/mixins/generate_models_mixin/authenticate_models_mixin.py b/breathecode/tests/mixins/generate_models_mixin/authenticate_models_mixin.py
index b9dd8408b..28e2da925 100644
--- a/breathecode/tests/mixins/generate_models_mixin/authenticate_models_mixin.py
+++ b/breathecode/tests/mixins/generate_models_mixin/authenticate_models_mixin.py
@@ -30,6 +30,11 @@ def generate_authenticate_models(self,
user_setting=False,
github_academy_user_log=False,
pending_github_user=False,
+ scope=False,
+ app=False,
+ app_user_agreement=False,
+ optional_scope_set=False,
+ legacy_key=False,
profile_kwargs={},
device_id_kwargs={},
capability_kwargs={},
@@ -97,6 +102,55 @@ def generate_authenticate_models(self,
**role_kwargs
})
+ if not 'scope' in models and is_valid(scope):
+ kargs = {}
+
+ models['scope'] = create_models(scope, 'authenticate.Scope', **kargs)
+
+ if not 'app' in models and (is_valid(app) or is_valid(app_user_agreement) or is_valid(legacy_key)):
+ kargs = {
+ 'public_key': None,
+ 'private_key': '',
+ }
+
+ if 'scope' in models:
+ kargs['required_scopes'] = get_list(models['scope'])
+ kargs['optional_scopes'] = get_list(models['scope'])
+
+ models['app'] = create_models(app, 'authenticate.App', **kargs)
+
+ if not 'optional_scope_set' in models and is_valid(optional_scope_set):
+ kargs = {}
+
+ if 'scope' in models:
+ kargs['optional_scopes'] = get_list(models['scope'])
+
+ if 'scope' in models:
+ kargs['optional_scopes'] = get_list(models['scope'])
+
+ models['optional_scope_set'] = create_models(optional_scope_set, 'authenticate.OptionalScopeSet',
+ **kargs)
+
+ if not 'app_user_agreement' in models and is_valid(app_user_agreement):
+ kargs = {}
+
+ if 'user' in models:
+ kargs['user'] = get_list(models['user'])
+
+ if 'app' in models:
+ kargs['app'] = get_list(models['app'])
+
+ models['app_user_agreement'] = create_models(app_user_agreement, 'authenticate.AppUserAgreement',
+ **kargs)
+
+ if not 'legacy_key' in models and is_valid(legacy_key):
+ kargs = {}
+
+ if 'app' in models:
+ kargs['app'] = get_list(models['app'])
+
+ models['legacy_key'] = create_models(legacy_key, 'authenticate.LegacyKey', **kargs)
+
if not 'user_invite' in models and is_valid(user_invite):
kargs = {}
diff --git a/breathecode/utils/decorators/scope.py b/breathecode/utils/decorators/scope.py
index c20767b1b..0a00cecc9 100644
--- a/breathecode/utils/decorators/scope.py
+++ b/breathecode/utils/decorators/scope.py
@@ -36,7 +36,7 @@ def link_schema(request, required_scopes, authorization: str, use_signature: boo
info, key, legacy_key = get_app_keys(authorization['App'])
(app_id, alg, strategy, schema, require_an_agreement, required_app_scopes, optional_app_scopes,
- webhook_url, redirect_url) = info
+ webhook_url, redirect_url, app_url) = info
public_key, private_key = key
if schema != 'LINK':
@@ -47,7 +47,7 @@ def link_schema(request, required_scopes, authorization: str, use_signature: boo
try:
key = public_key if public_key else private_key
- payload = jwt.decode(authorization['Token'], key, algorithms=[alg], audience='breathecode')
+ payload = jwt.decode(authorization['Token'], key, algorithms=[alg], audience='4geeks')
except Exception as e:
if not legacy_key:
@@ -84,6 +84,7 @@ def link_schema(request, required_scopes, authorization: str, use_signature: boo
'require_an_agreement': require_an_agreement,
'webhook_url': webhook_url,
'redirect_url': redirect_url,
+ 'app_url': app_url,
}
return app, payload
@@ -134,7 +135,7 @@ def signature_schema(request, required_scopes, authorization: str, use_signature
info, key, legacy_key = get_app_keys(authorization['App'])
(app_id, alg, strategy, schema, require_an_agreement, required_app_scopes, optional_app_scopes,
- webhook_url, redirect_url) = info
+ webhook_url, redirect_url, app_url) = info
public_key, private_key = key
if require_an_agreement:
@@ -189,6 +190,7 @@ def signature_schema(request, required_scopes, authorization: str, use_signature
'require_an_agreement': require_an_agreement,
'webhook_url': webhook_url,
'redirect_url': redirect_url,
+ 'app_url': app_url,
}
return app
From 2f2687310798dedac9895824e5f259417ab6104d Mon Sep 17 00:00:00 2001
From: jefer94
Date: Fri, 4 Aug 2023 01:34:10 -0500
Subject: [PATCH 14/19] add latest tests
---
breathecode/authenticate/actions.py | 13 +-
.../management/commands/sign_jwt.py | 14 +-
.../management/commands/sign_request.py | 17 +-
breathecode/authenticate/models.py | 33 +-
breathecode/authenticate/serializers.py | 13 +
.../authenticate/templates/authorize.html | 4 +-
.../management/commands/tests_sign_jwt.py | 49 +++
.../management/commands/tests_sign_request.py | 62 +++
.../authenticate/tests/urls/tests_app_user.py | 166 ++++++++
.../tests/urls/tests_app_user_id.py | 56 ++-
.../tests/urls/tests_appuseragreement.py | 110 ++++++
.../tests/urls/tests_authorize_slug.py | 361 ++++++++++++++++++
breathecode/authenticate/urls.py | 24 +-
breathecode/authenticate/views.py | 76 +++-
breathecode/commons/templates/scopes.html | 27 +-
breathecode/commons/templatetags/scopes.py | 4 +-
.../authenticate_models_mixin.py | 45 ++-
breathecode/utils/decorators/scope.py | 2 +-
18 files changed, 1004 insertions(+), 72 deletions(-)
create mode 100644 breathecode/authenticate/tests/management/commands/tests_sign_jwt.py
create mode 100644 breathecode/authenticate/tests/management/commands/tests_sign_request.py
create mode 100644 breathecode/authenticate/tests/urls/tests_app_user.py
create mode 100644 breathecode/authenticate/tests/urls/tests_appuseragreement.py
create mode 100644 breathecode/authenticate/tests/urls/tests_authorize_slug.py
diff --git a/breathecode/authenticate/actions.py b/breathecode/authenticate/actions.py
index f0ef5cb10..f6aa57848 100644
--- a/breathecode/authenticate/actions.py
+++ b/breathecode/authenticate/actions.py
@@ -690,8 +690,7 @@ def get_signature(app: App,
body: Optional[dict] = None,
headers: dict = {},
reverse: bool = False):
- from datetime import datetime
- now = datetime.utcnow().isoformat()
+ now = timezone.now().isoformat()
payload = {
'timestamp': now,
@@ -755,7 +754,7 @@ def get_user_scopes(app_slug, user_id):
info, _, _ = get_app_keys(app_slug)
(_, _, _, _, require_an_agreement, required_scopes, optional_scopes, _, _, _) = info
- if require_an_agreement:
+ if user_id and require_an_agreement:
agreement = AppUserAgreement.objects.filter(app__slug=app_slug, user__id=user_id).first()
if not agreement:
raise ValidationException('User has not accepted the agreement',
@@ -774,15 +773,13 @@ def get_user_scopes(app_slug, user_id):
@lru_cache(maxsize=100)
def get_app_keys(app_slug):
- from .models import App
+ from .models import App, Scope
app = App.objects.filter(slug=app_slug).first()
if app is None:
raise ValidationException('Unauthorized', code=401, slug='app-not-found')
- print(app.algorithm)
-
if app.algorithm == 'HMAC_SHA256':
alg = 'HS256'
@@ -812,8 +809,8 @@ def get_app_keys(app_slug):
app.strategy,
app.schema,
app.require_an_agreement,
- tuple(sorted(x.slug for x in app.required_scopes.all())),
- tuple(sorted(x.slug for x in app.optional_scopes.all())),
+ tuple(sorted(x.slug for x in Scope.objects.filter(app_required_scopes__app=app))),
+ tuple(sorted(x.slug for x in Scope.objects.filter(app_optional_scopes__app=app))),
app.webhook_url,
app.redirect_url,
app.app_url,
diff --git a/breathecode/authenticate/management/commands/sign_jwt.py b/breathecode/authenticate/management/commands/sign_jwt.py
index bc3a14c5c..c6a7ff864 100644
--- a/breathecode/authenticate/management/commands/sign_jwt.py
+++ b/breathecode/authenticate/management/commands/sign_jwt.py
@@ -6,20 +6,26 @@
class Command(BaseCommand):
- help = 'Sync academies from old breathecode'
+ help = 'Sign a JWT token for a given app'
def add_arguments(self, parser):
parser.add_argument('app', nargs='?', type=int)
parser.add_argument('user', nargs='?', type=int)
def handle(self, *args, **options):
- from ...models import App, User
+ from ...models import App
from ...actions import get_jwt
if not options['app']:
raise Exception('Missing app id')
- app = App.objects.get(id=options['app'])
+ try:
+ app = App.objects.get(id=options['app'])
+
+ except App.DoesNotExist:
+ self.stderr.write(self.style.ERROR(f'App {options["app"]} not found'))
+ return
+
token = get_jwt(app, user_id=options['user'], reverse=True)
- print(f'Authorization: Link App={app.slug},Token={token}')
+ self.stdout.write(f'Authorization: Link App={app.slug},Token={token}')
diff --git a/breathecode/authenticate/management/commands/sign_request.py b/breathecode/authenticate/management/commands/sign_request.py
index 76799595c..74b2aba6b 100644
--- a/breathecode/authenticate/management/commands/sign_request.py
+++ b/breathecode/authenticate/management/commands/sign_request.py
@@ -23,11 +23,18 @@ def handle(self, *args, **options):
if not options['app']:
raise Exception('Missing app id')
+ options['method'] = options['method'] if options['method'] is not None else 'get'
options['params'] = eval(options['params']) if options['params'] is not None else {}
options['body'] = eval(options['body']) if options['body'] is not None else None
options['headers'] = eval(options['headers']) if options['headers'] is not None else {}
- app = App.objects.get(id=options['app'])
+ try:
+ app = App.objects.get(id=options['app'])
+
+ except App.DoesNotExist:
+ self.stderr.write(self.style.ERROR(f'App {options["app"]} not found'))
+ return
+
sign, now = get_signature(app,
options['user'],
method=options['method'],
@@ -36,7 +43,7 @@ def handle(self, *args, **options):
headers=options['headers'],
reverse=True)
- print(f'Authorization: Signature App={app.slug},'
- f'Nonce={sign},'
- f'SignedHeaders={";".join(options["headers"])},'
- f'Date={now}')
+ self.stdout.write(f'Authorization: Signature App={app.slug},'
+ f'Nonce={sign},'
+ f'SignedHeaders={";".join(options["headers"])},'
+ f'Date={now}')
diff --git a/breathecode/authenticate/models.py b/breathecode/authenticate/models.py
index 619569cb5..a9d3feb3c 100644
--- a/breathecode/authenticate/models.py
+++ b/breathecode/authenticate/models.py
@@ -127,7 +127,7 @@ def clean(self) -> None:
raise forms.ValidationError('Scope description is required')
if not self.slug or not re.findall(
- r'^[a-z_:]+$', self.slug) or self.slug.count(':') > 1 or self.slug.count('__') > 0:
+ r'^[a-z_:]+$', self.slug) or (0 < self.slug.count(':') > 1) or self.slug.count('__') > 0:
raise forms.ValidationError(
'Scope slug must be in the format "action_name:data_name" or "data_name" example '
'"read:repo" or "repo"')
@@ -195,8 +195,16 @@ def __init__(self, *args, **kwargs):
default=LINK,
help_text='Schema to use for the auth process to represent how the apps will communicate')
- required_scopes = models.ManyToManyField(Scope, blank=True, related_name='app_required_scopes')
- optional_scopes = models.ManyToManyField(Scope, blank=True, related_name='app_optional_scopes')
+ required_scopes = models.ManyToManyField(Scope,
+ blank=True,
+ through='AppRequiredScope',
+ through_fields=('App', 'Scope'),
+ related_name='app_required_scopes')
+ optional_scopes = models.ManyToManyField(Scope,
+ blank=True,
+ through='AppOptionalScope',
+ through_fields=('App', 'Scope'),
+ related_name='app_optional_scopes')
agreement_version = models.IntegerField(default=1,
help_text='Version of the agreement, based in the scopes')
@@ -271,6 +279,24 @@ def save(self, *args, **kwargs):
self._redirect_url = self.redirect_url
+class AppRequiredScope(models.Model):
+ app = models.ForeignKey(App, on_delete=models.CASCADE, related_name='app_required_scopes')
+ scope = models.ForeignKey(Scope, on_delete=models.CASCADE, related_name='app_required_scopes')
+ agreed_at = models.DateTimeField()
+
+ def __str__(self):
+ return f'{self.app.name} ({self.app.slug}) -> {self.scope.name} ({self.scope.slug})'
+
+
+class AppOptionalScope(models.Model):
+ app = models.ForeignKey(App, on_delete=models.CASCADE, related_name='app_optional_scopes')
+ scope = models.ForeignKey(Scope, on_delete=models.CASCADE, related_name='app_optional_scopes')
+ agreed_at = models.DateTimeField()
+
+ def __str__(self):
+ return f'{self.app.name} ({self.app.slug}) -> {self.scope.name} ({self.scope.slug})'
+
+
class OptionalScopeSet(models.Model):
optional_scopes = models.ManyToManyField(Scope, blank=True)
@@ -295,6 +321,7 @@ class AppUserAgreement(models.Model):
on_delete=models.CASCADE,
related_name='app_user_agreement')
agreement_version = models.IntegerField(default=1, help_text='Version of the agreement that was accepted')
+ agreed_at = models.DateTimeField()
def save(self, *args, **kwargs):
from .actions import reset_app_user_cache
diff --git a/breathecode/authenticate/serializers.py b/breathecode/authenticate/serializers.py
index 7f70326be..c10e1e88d 100644
--- a/breathecode/authenticate/serializers.py
+++ b/breathecode/authenticate/serializers.py
@@ -372,6 +372,19 @@ def get_github(self, obj):
return GithubSmallSerializer(github).data
+class SmallAppUserAgreementSerializer(serpy.Serializer):
+
+ # Use a Field subclass like IntField if you need more validation.
+ app = serpy.MethodField()
+ up_to_date = serpy.MethodField()
+
+ def get_app(self, obj):
+ return obj.app.slug
+
+ def get_up_to_date(self, obj):
+ return obj.agreement_version == obj.app.agreement_version
+
+
class UserSerializer(AppUserSerializer):
"""The serializer schema definition."""
# Use a Field subclass like IntField if you need more validation.
diff --git a/breathecode/authenticate/templates/authorize.html b/breathecode/authenticate/templates/authorize.html
index c558d2cb5..70a7371ab 100644
--- a/breathecode/authenticate/templates/authorize.html
+++ b/breathecode/authenticate/templates/authorize.html
@@ -67,8 +67,8 @@
{{app.description}}
- {% scopes scopes=app.required_scopes id='required' title='Required permissions' disabled=True %}
- {% scopes scopes=app.optional_scopes id='optional' title='Optional permissions' disabled=False %}
+ {% scopes scopes=required_scopes id='required' title='Required permissions' disabled=True new_scopes=new_scopes %}
+ {% scopes scopes=optional_scopes id='optional' title='Optional permissions' disabled=False new_scopes=new_scopes selected_scopes=selected_scopes %}
{% button type="link" className="btn-danger offset-2 col-4" href=reject_url value="Reject" %}
diff --git a/breathecode/authenticate/tests/management/commands/tests_sign_jwt.py b/breathecode/authenticate/tests/management/commands/tests_sign_jwt.py
new file mode 100644
index 000000000..b5247e07b
--- /dev/null
+++ b/breathecode/authenticate/tests/management/commands/tests_sign_jwt.py
@@ -0,0 +1,49 @@
+"""
+Test /academy/cohort
+"""
+import os
+import random
+import logging
+from unittest.mock import MagicMock, patch, call
+from ...mixins.new_auth_test_case import AuthTestCase
+from ....management.commands.sign_jwt import Command
+
+from django.core.management.base import OutputWrapper
+
+
+class AcademyCohortTestSuite(AuthTestCase):
+ """
+ 🔽🔽🔽 With zero Profile
+ """
+
+ # When: No app
+ # Then: Shouldn't do anything
+ @patch('django.core.management.base.OutputWrapper.write', MagicMock())
+ def test_no_app(self):
+ command = Command()
+ result = command.handle(app='1', user=None)
+
+ self.assertEqual(result, None)
+ self.assertEqual(self.bc.database.list_of('authenticate.UserInvite'), [])
+ self.assertEqual(OutputWrapper.write.call_args_list, [
+ call('App 1 not found'),
+ ])
+
+ # When: With app
+ # Then: Print the token
+ @patch('django.core.management.base.OutputWrapper.write', MagicMock())
+ def test_sign_jwt(self):
+ model = self.bc.database.create(app=1)
+
+ command = Command()
+
+ token = self.bc.fake.slug()
+ with patch('jwt.encode', MagicMock(return_value=token)):
+ result = command.handle(app='1', user=None)
+
+ self.assertEqual(result, None)
+ self.assertEqual(self.bc.database.list_of('authenticate.UserInvite'), [])
+ self.assertEqual(OutputWrapper.write.call_args_list, [
+ call(f'Authorization: Link App={model.app.slug},'
+ f'Token={token}'),
+ ])
diff --git a/breathecode/authenticate/tests/management/commands/tests_sign_request.py b/breathecode/authenticate/tests/management/commands/tests_sign_request.py
new file mode 100644
index 000000000..939afb004
--- /dev/null
+++ b/breathecode/authenticate/tests/management/commands/tests_sign_request.py
@@ -0,0 +1,62 @@
+"""
+Test /academy/cohort
+"""
+from datetime import datetime
+from django.utils import timezone
+from unittest.mock import MagicMock, patch, call
+from ...mixins.new_auth_test_case import AuthTestCase
+from ....management.commands.sign_request import Command
+
+from django.core.management.base import OutputWrapper
+
+
+class AcademyCohortTestSuite(AuthTestCase):
+ """
+ 🔽🔽🔽 With zero Profile
+ """
+
+ # When: No app
+ # Then: Shouldn't do anything
+ @patch('django.core.management.base.OutputWrapper.write', MagicMock())
+ def test_no_app(self):
+ command = Command()
+ result = command.handle(app='1', user=None, method=None, params=None, body=None, headers=None)
+
+ self.assertEqual(result, None)
+ self.assertEqual(self.bc.database.list_of('authenticate.UserInvite'), [])
+ self.assertEqual(OutputWrapper.write.call_args_list, [
+ call('App 1 not found'),
+ ])
+
+ # When: With app
+ # Then: Print the signature
+ @patch('django.core.management.base.OutputWrapper.write', MagicMock())
+ def test_sign_jwt(self):
+ headers = {
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ self.bc.fake.slug(): self.bc.fake.slug(),
+ }
+ model = self.bc.database.create(app=1)
+
+ command = Command()
+
+ token = self.bc.fake.slug()
+ d = datetime(2023, 8, 3, 4, 2, 58, 992939)
+ with patch('hmac.HMAC.hexdigest', MagicMock(return_value=token)):
+ with patch('django.utils.timezone.now', MagicMock(return_value=d)):
+ result = command.handle(app='1',
+ user=None,
+ method=f'{headers}',
+ params=f'{headers}',
+ body=f'{headers}',
+ headers=f'{headers}')
+
+ self.assertEqual(result, None)
+ self.assertEqual(self.bc.database.list_of('authenticate.UserInvite'), [])
+ self.assertEqual(OutputWrapper.write.call_args_list, [
+ call(f'Authorization: Signature App={model.app.slug},'
+ f'Nonce={token},'
+ f'SignedHeaders={";".join(headers.keys())},'
+ f'Date={d.isoformat()}'),
+ ])
diff --git a/breathecode/authenticate/tests/urls/tests_app_user.py b/breathecode/authenticate/tests/urls/tests_app_user.py
new file mode 100644
index 000000000..8bab46427
--- /dev/null
+++ b/breathecode/authenticate/tests/urls/tests_app_user.py
@@ -0,0 +1,166 @@
+"""
+Test cases for /user
+"""
+from django.urls.base import reverse_lazy
+from rest_framework import status
+from ..mixins.new_auth_test_case import AuthTestCase
+
+
+def credentials_github_serializer(credentials_github):
+ return {
+ 'avatar_url': credentials_github.avatar_url,
+ 'name': credentials_github.name,
+ 'username': credentials_github.username,
+ }
+
+
+def profile_serializer(credentials_github):
+ return {
+ 'avatar_url': credentials_github.avatar_url,
+ }
+
+
+def get_serializer(user, credentials_github=None, profile=None):
+ return {
+ 'email': user.email,
+ 'first_name': user.first_name,
+ 'github': credentials_github_serializer(credentials_github) if credentials_github else None,
+ 'id': user.id,
+ 'last_name': user.last_name,
+ 'profile': profile_serializer(profile) if profile else None,
+ }
+
+
+class AuthenticateTestSuite(AuthTestCase):
+
+ # When: no auth
+ # Then: return 401
+ def test_no_auth(self):
+ url = reverse_lazy('authenticate:app_user')
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = {
+ 'detail': 'no-authorization-header',
+ 'status_code': status.HTTP_401_UNAUTHORIZED,
+ }
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+ # When: Sign with an user
+ # Then: return 200
+ def test_sign_with_user__get_own_info(self):
+ app = {'require_an_agreement': False}
+ credentials_githubs = [{'user_id': x + 1} for x in range(2)]
+ profiles = [{'user_id': x + 1} for x in range(2)]
+ model = self.bc.database.create(user=2,
+ app=app,
+ profile=profiles,
+ credentials_github=credentials_githubs)
+ self.bc.request.sign_jwt_link(model.app, 1)
+
+ url = reverse_lazy('authenticate:app_user')
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = [get_serializer(model.user[0], model.credentials_github[0], model.profile[0])]
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('auth.User'), self.bc.format.to_dict(model.user))
+
+ # When: Sign with an user
+ # Then: return 200
+ def test_sign_with_user__get_info_from_another(self):
+ app = {'require_an_agreement': False}
+ credentials_githubs = [{'user_id': x + 1} for x in range(2)]
+ profiles = [{'user_id': x + 1} for x in range(2)]
+ model = self.bc.database.create(user=2,
+ app=app,
+ profile=profiles,
+ credentials_github=credentials_githubs)
+ self.bc.request.sign_jwt_link(model.app, 1)
+
+ url = reverse_lazy('authenticate:app_user_id', kwargs={'user_id': 2})
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = {
+ 'detail': 'user-with-no-access',
+ 'silent': True,
+ 'silent_code': 'user-with-no-access',
+ 'status_code': 403,
+ }
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertEqual(self.bc.database.list_of('auth.User'), self.bc.format.to_dict(model.user))
+
+ # When: Sign without an user
+ # Then: return 200
+ def test_sign_without_user(self):
+ app = {'require_an_agreement': False}
+ credentials_githubs = [{'user_id': x + 1} for x in range(2)]
+ profiles = [{'user_id': x + 1} for x in range(2)]
+ model = self.bc.database.create(user=2,
+ app=app,
+ profile=profiles,
+ credentials_github=credentials_githubs)
+ self.bc.request.sign_jwt_link(model.app)
+
+ for user in model.user:
+ url = reverse_lazy('authenticate:app_user_id', kwargs={'user_id': user.id})
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = get_serializer(user, model.credentials_github[user.id - 1], model.profile[user.id - 1])
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('auth.User'), self.bc.format.to_dict(model.user))
+
+ # When: Sign user with no agreement
+ # Then: return 200
+ def test_user_with_no_agreement(self):
+ app = {'require_an_agreement': False, 'require_an_agreement': True}
+ credentials_github = {'user_id': 1}
+ profile = {'user_id': 1}
+ model = self.bc.database.create(user=1,
+ app=app,
+ profile=profile,
+ credentials_github=credentials_github)
+ self.bc.request.sign_jwt_link(model.app)
+
+ url = reverse_lazy('authenticate:app_user')
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = []
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('auth.User'), [self.bc.format.to_dict(model.user)])
+
+ # When: Sign user with agreement
+ # Then: return 200
+ def test_user_with_agreement(self):
+ app = {'require_an_agreement': False, 'require_an_agreement': True}
+ credentials_github = {'user_id': 1}
+ profile = {'user_id': 1}
+ model = self.bc.database.create(user=1,
+ app=app,
+ profile=profile,
+ credentials_github=credentials_github,
+ app_user_agreement=1)
+ self.bc.request.sign_jwt_link(model.app)
+
+ url = reverse_lazy('authenticate:app_user')
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = [get_serializer(model.user, model.credentials_github, model.profile)]
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('auth.User'), [self.bc.format.to_dict(model.user)])
diff --git a/breathecode/authenticate/tests/urls/tests_app_user_id.py b/breathecode/authenticate/tests/urls/tests_app_user_id.py
index 3f5d2e2f3..911ef70f1 100644
--- a/breathecode/authenticate/tests/urls/tests_app_user_id.py
+++ b/breathecode/authenticate/tests/urls/tests_app_user_id.py
@@ -1,7 +1,6 @@
"""
Test cases for /user
"""
-import pytz, datetime
from django.urls.base import reverse_lazy
from rest_framework import status
from ..mixins.new_auth_test_case import AuthTestCase
@@ -37,9 +36,6 @@ class AuthenticateTestSuite(AuthTestCase):
# When: no auth
# Then: return 401
def test_no_auth(self):
- """Test /user/me without auth"""
-
- # self.bc.request.sign_jwt_link(app, user_id: Optional[int] = None, reverse: bool = False)
url = reverse_lazy('authenticate:app_user_id', kwargs={'user_id': 1})
response = self.client.get(url)
@@ -104,8 +100,6 @@ def test_sign_with_user__get_info_from_another(self):
# When: Sign without an user
# Then: return 200
def test_sign_without_user(self):
- """Test /user/me without auth"""
-
app = {'require_an_agreement': False}
credentials_githubs = [{'user_id': x + 1} for x in range(2)]
profiles = [{'user_id': x + 1} for x in range(2)]
@@ -125,3 +119,53 @@ def test_sign_without_user(self):
self.assertEqual(json, expected)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(self.bc.database.list_of('auth.User'), self.bc.format.to_dict(model.user))
+
+ # When: Sign user with no agreement
+ # Then: return 200
+ def test_user_with_no_agreement(self):
+ app = {'require_an_agreement': False, 'require_an_agreement': True}
+ credentials_github = {'user_id': 1}
+ profile = {'user_id': 1}
+ model = self.bc.database.create(user=1,
+ app=app,
+ profile=profile,
+ credentials_github=credentials_github)
+ self.bc.request.sign_jwt_link(model.app)
+
+ url = reverse_lazy('authenticate:app_user_id', kwargs={'user_id': 1})
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = {
+ 'detail': 'user-not-found',
+ 'silent': True,
+ 'silent_code': 'user-not-found',
+ 'status_code': 404,
+ }
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+ self.assertEqual(self.bc.database.list_of('auth.User'), [self.bc.format.to_dict(model.user)])
+
+ # When: Sign user with agreement
+ # Then: return 200
+ def test_user_with_agreement(self):
+ app = {'require_an_agreement': False, 'require_an_agreement': True}
+ credentials_github = {'user_id': 1}
+ profile = {'user_id': 1}
+ model = self.bc.database.create(user=1,
+ app=app,
+ profile=profile,
+ credentials_github=credentials_github,
+ app_user_agreement=1)
+ self.bc.request.sign_jwt_link(model.app)
+
+ url = reverse_lazy('authenticate:app_user_id', kwargs={'user_id': 1})
+ response = self.client.get(url)
+
+ json = response.json()
+ expected = get_serializer(model.user, model.credentials_github, model.profile)
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('auth.User'), [self.bc.format.to_dict(model.user)])
diff --git a/breathecode/authenticate/tests/urls/tests_appuseragreement.py b/breathecode/authenticate/tests/urls/tests_appuseragreement.py
new file mode 100644
index 000000000..ce7658a96
--- /dev/null
+++ b/breathecode/authenticate/tests/urls/tests_appuseragreement.py
@@ -0,0 +1,110 @@
+"""
+Test cases for /user
+"""
+from datetime import timedelta
+import random
+from unittest.mock import MagicMock, patch
+from django.urls.base import reverse_lazy
+from rest_framework import status
+from django.utils import timezone
+
+from breathecode.tests.mixins.breathecode_mixin.breathecode import fake
+from ..mixins.new_auth_test_case import AuthTestCase
+
+UTC_NOW = timezone.now()
+TOKEN = fake.name()
+
+
+def get_serializer(app, data={}):
+ return {
+ 'app': app.slug,
+ 'up_to_date': True,
+ **data,
+ }
+
+
+class AuthenticateTestSuite(AuthTestCase):
+ """Authentication test suite"""
+
+ # When: no auth
+ # Then: return 401
+ def test__auth__without_auth(self):
+ """Test /logout without auth"""
+ url = reverse_lazy('authenticate:appuseragreement')
+
+ response = self.client.get(url)
+ json = response.json()
+ expected = {'detail': 'Authentication credentials were not provided.', 'status_code': 401}
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+ self.assertEqual(self.bc.database.list_of('authenticate.AppUserAgreement'), [])
+
+ # When: no agreements
+ # Then: return empty list
+ def test__no_agreements(self):
+ url = reverse_lazy('authenticate:appuseragreement')
+
+ model = self.bc.database.create(user=1)
+ self.bc.request.authenticate(model.user)
+ response = self.client.get(url)
+ json = response.json()
+ expected = []
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('authenticate.AppUserAgreement'), [])
+
+ # teardown
+ self.bc.database.delete('authenticate.Token')
+
+ # When: have agreements, agreement_version match
+ # Then: return list of agreements
+ def test__have_agreements__version_match(self):
+ url = reverse_lazy('authenticate:appuseragreement')
+
+ version = random.randint(1, 100)
+ app = {'agreement_version': version}
+ app_user_agreements = [{'agreement_version': version, 'app_id': x + 1} for x in range(2)]
+ model = self.bc.database.create(user=1, app=(2, app), app_user_agreement=app_user_agreements)
+ self.bc.request.authenticate(model.user)
+ response = self.client.get(url)
+ json = response.json()
+ expected = [
+ get_serializer(model.app[0], {'up_to_date': True}),
+ get_serializer(model.app[1], {'up_to_date': True}),
+ ]
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(
+ self.bc.database.list_of('authenticate.AppUserAgreement'),
+ self.bc.format.to_dict(model.app_user_agreement),
+ )
+
+ # When: have agreements, agreement_version match
+ # Then: return list of agreements
+ def test__have_agreements__version_does_not_match(self):
+ url = reverse_lazy('authenticate:appuseragreement')
+
+ version1 = random.randint(1, 100)
+ version2 = random.randint(1, 100)
+ while version1 == version2:
+ version2 = random.randint(1, 100)
+ app = {'agreement_version': version1}
+ app_user_agreements = [{'agreement_version': version2, 'app_id': x + 1} for x in range(2)]
+ model = self.bc.database.create(user=1, app=(2, app), app_user_agreement=app_user_agreements)
+ self.bc.request.authenticate(model.user)
+ response = self.client.get(url)
+ json = response.json()
+ expected = [
+ get_serializer(model.app[0], {'up_to_date': False}),
+ get_serializer(model.app[1], {'up_to_date': False}),
+ ]
+
+ self.assertEqual(json, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(
+ self.bc.database.list_of('authenticate.AppUserAgreement'),
+ self.bc.format.to_dict(model.app_user_agreement),
+ )
diff --git a/breathecode/authenticate/tests/urls/tests_authorize_slug.py b/breathecode/authenticate/tests/urls/tests_authorize_slug.py
new file mode 100644
index 000000000..9b0b98662
--- /dev/null
+++ b/breathecode/authenticate/tests/urls/tests_authorize_slug.py
@@ -0,0 +1,361 @@
+"""
+Test cases for /academy/:id/member/:id
+"""
+from datetime import timedelta
+import os
+import random
+from unittest.mock import MagicMock, patch
+import urllib.parse
+from wsgiref.simple_server import WSGIRequestHandler
+from django.template import loader
+from django.urls.base import reverse_lazy
+from rest_framework import status
+from django.shortcuts import render
+from ..mixins.new_auth_test_case import AuthTestCase
+from django.core.handlers.wsgi import WSGIRequest
+from django.utils import timezone
+
+
+# IMPORTANT: the loader.render_to_string in a function is inside of function render
+def render_message(message):
+ request = None
+ context = {
+ 'MESSAGE': message,
+ 'BUTTON': None,
+ 'BUTTON_TARGET': '_blank',
+ 'LINK': None,
+ 'BUTTON': 'Continue to 4Geeks',
+ 'LINK': os.getenv('APP_URL', '')
+ }
+
+ return loader.render_to_string('message.html', context, request)
+
+
+def render_authorization(app, required_scopes=[], optional_scopes=[], selected_scopes=[], new_scopes=[]):
+ environ = {
+ 'HTTP_COOKIE': '',
+ 'PATH_INFO': f'/',
+ 'REMOTE_ADDR': '127.0.0.1',
+ 'REQUEST_METHOD': 'GET',
+ 'SCRIPT_NAME': '',
+ 'SERVER_NAME': 'testserver',
+ 'SERVER_PORT': '80',
+ 'SERVER_PROTOCOL': 'HTTP/1.1',
+ 'wsgi.version': (1, 0),
+ 'wsgi.url_scheme': 'http',
+ 'wsgi.input': None,
+ 'wsgi.errors': None,
+ 'wsgi.multiprocess': True,
+ 'wsgi.multithread': False,
+ 'wsgi.run_once': False,
+ 'QUERY_STRING': f'token=',
+ 'CONTENT_TYPE': 'application/octet-stream'
+ }
+
+ # if post:
+ # environ['REQUEST_METHOD'] = 'POST'
+ # environ['CONTENT_TYPE'] = 'multipart/form-data; boundary=BoUnDaRyStRiNg; charset=utf-8'
+
+ request = WSGIRequest(environ)
+
+ return loader.render_to_string(
+ 'authorize.html', {
+ 'app': app,
+ 'required_scopes': required_scopes,
+ 'optional_scopes': optional_scopes,
+ 'selected_scopes': selected_scopes,
+ 'new_scopes': new_scopes,
+ 'reject_url': app.redirect_url + '?app=4geeks&status=rejected',
+ }, request)
+
+
+class AuthenticateTestSuite(AuthTestCase):
+ # When: no auth
+ # Then: return 302
+ def test_no_auth(self):
+ url = reverse_lazy('authenticate:authorize_slug', kwargs={'app_slug': 'x'})
+ response = self.client.get(url)
+
+ hash = self.bc.format.to_base64('/v1/auth/authorize/x')
+ content = self.bc.format.from_bytes(response.content)
+ expected = ''
+
+ self.assertEqual(content, expected)
+ self.assertEqual(response.url, f'/v1/auth/view/login?attempt=1&url={hash}')
+ self.assertEqual(response.status_code, status.HTTP_302_FOUND)
+ self.assertEqual(self.bc.database.list_of('authenticate.App'), [])
+
+ # When: app not found
+ # Then: return 404
+ def test_app_not_found(self):
+ model = self.bc.database.create(user=1, token=1)
+
+ querystring = self.bc.format.to_querystring({'token': model.token.key})
+ url = reverse_lazy('authenticate:authorize_slug', kwargs={'app_slug': 'x'}) + f'?{querystring}'
+ response = self.client.get(url)
+
+ content = self.bc.format.from_bytes(response.content)
+ expected = render_message('App not found')
+
+ # dump error in external files
+ if content != expected:
+ with open('content.html', 'w') as f:
+ f.write(content)
+
+ with open('expected.html', 'w') as f:
+ f.write(expected)
+
+ self.assertEqual(content, expected)
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+ self.assertEqual(self.bc.database.list_of('authenticate.App'), [])
+
+ # When: app does not require an agreement
+ # Then: return 404
+ def test_app_does_not_require_an_agreement(self):
+ app = {'require_an_agreement': False}
+ model = self.bc.database.create(user=1, token=1, app=app)
+
+ querystring = self.bc.format.to_querystring({'token': model.token.key})
+ url = reverse_lazy('authenticate:authorize_slug', kwargs={'app_slug': model.app.slug
+ }) + f'?{querystring}'
+ response = self.client.get(url)
+
+ content = self.bc.format.from_bytes(response.content)
+ expected = render_message('App not found')
+
+ # dump error in external files
+ if content != expected:
+ with open('content.html', 'w') as f:
+ f.write(content)
+
+ with open('expected.html', 'w') as f:
+ f.write(expected)
+
+ self.assertEqual(content, expected)
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+ self.assertEqual(self.bc.database.list_of('authenticate.App'), [
+ self.bc.format.to_dict(model.app),
+ ])
+
+ # When: app require an agreement
+ # Then: return 200
+ @patch('django.template.context_processors.get_token', MagicMock(return_value='predicabletoken'))
+ def test_app_require_an_agreement(self):
+ app = {'require_an_agreement': True}
+ model = self.bc.database.create(user=1, token=1, app=app)
+
+ querystring = self.bc.format.to_querystring({'token': model.token.key})
+ url = reverse_lazy('authenticate:authorize_slug', kwargs={'app_slug': model.app.slug
+ }) + f'?{querystring}'
+ response = self.client.get(url)
+
+ content = self.bc.format.from_bytes(response.content)
+ expected = render_authorization(model.app)
+
+ # dump error in external files
+ if content != expected:
+ with open('content.html', 'w') as f:
+ f.write(content)
+
+ with open('expected.html', 'w') as f:
+ f.write(expected)
+
+ self.assertEqual(content, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('authenticate.App'), [
+ self.bc.format.to_dict(model.app),
+ ])
+
+ self.assertTrue('permissions' not in content)
+ self.assertTrue('required' not in content)
+ self.assertTrue('optional' not in content)
+ self.assertTrue(content.count('checked') == 0)
+ self.assertTrue(content.count('New') == 0)
+
+ # When: app require an agreement, with scopes
+ # Then: return 200
+ @patch('django.template.context_processors.get_token', MagicMock(return_value='predicabletoken'))
+ def test_app_require_an_agreement__with_scopes(self):
+ app = {'require_an_agreement': True}
+
+ slug1 = self.bc.fake.slug().replace('-', '_')[:7]
+ slug2 = self.bc.fake.slug().replace('-', '_')[:7]
+
+ if random.randint(0, 1):
+ slug1 += ':' + self.bc.fake.slug().replace('-', '_')[:7]
+
+ if random.randint(0, 1):
+ slug2 += ':' + self.bc.fake.slug().replace('-', '_')[:7]
+
+ scopes = [{'slug': slug1}, {'slug': slug2}]
+ now = timezone.now()
+ app_required_scopes = [{'app_id': 1, 'scope_id': n + 1, 'agreed_at': now} for n in range(2)]
+ app_optional_scopes = [{'app_id': 1, 'scope_id': n + 1, 'agreed_at': now} for n in range(2)]
+
+ model = self.bc.database.create(user=1,
+ token=1,
+ app=app,
+ scope=scopes,
+ app_required_scope=app_required_scopes,
+ app_optional_scope=app_optional_scopes)
+
+ querystring = self.bc.format.to_querystring({'token': model.token.key})
+ url = reverse_lazy('authenticate:authorize_slug', kwargs={'app_slug': model.app.slug
+ }) + f'?{querystring}'
+ response = self.client.get(url)
+
+ content = self.bc.format.from_bytes(response.content)
+ expected = render_authorization(model.app,
+ required_scopes=[model.scope[0], model.scope[1]],
+ optional_scopes=[model.scope[0], model.scope[1]],
+ new_scopes=[])
+
+ # dump error in external files
+ if content != expected or True:
+ with open('content.html', 'w') as f:
+ f.write(content)
+
+ with open('expected.html', 'w') as f:
+ f.write(expected)
+
+ self.assertEqual(content, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('authenticate.App'), [
+ self.bc.format.to_dict(model.app),
+ ])
+
+ self.assertTrue('permissions' in content)
+ self.assertTrue('required' in content)
+ self.assertTrue('optional' in content)
+ self.assertTrue(content.count('checked') == 4)
+ self.assertTrue(content.count('New') == 0)
+
+ # When: app require an agreement, with scopes, it requires update the agreement
+ # Then: return 200
+ @patch('django.template.context_processors.get_token', MagicMock(return_value='predicabletoken'))
+ def test_app_require_an_agreement__with_scopes__updating_agreement(self):
+ app = {'require_an_agreement': True}
+ optional_scope_set = {'optional_scopes': [1]}
+ # import timezone from django
+
+ now = timezone.now()
+ app_required_scopes = [{'app_id': 1, 'scope_id': n + 1, 'agreed_at': now} for n in range(2)]
+ app_optional_scopes = [{'app_id': 1, 'scope_id': n + 1, 'agreed_at': now} for n in range(2)]
+ app_user_agreement = {'agreed_at': now + timedelta(days=1)}
+
+ slug1 = self.bc.fake.slug().replace('-', '_')[:7]
+ slug2 = self.bc.fake.slug().replace('-', '_')[:7]
+
+ if random.randint(0, 1):
+ slug1 += ':' + self.bc.fake.slug().replace('-', '_')[:7]
+
+ if random.randint(0, 1):
+ slug2 += ':' + self.bc.fake.slug().replace('-', '_')[:7]
+
+ scopes = [{'slug': slug1}, {'slug': slug2}]
+
+ model = self.bc.database.create(user=1,
+ token=1,
+ app=app,
+ scope=scopes,
+ app_user_agreement=app_user_agreement,
+ optional_scope_set=optional_scope_set,
+ app_required_scope=app_required_scopes,
+ app_optional_scope=app_optional_scopes)
+
+ querystring = self.bc.format.to_querystring({'token': model.token.key})
+ url = reverse_lazy('authenticate:authorize_slug', kwargs={'app_slug': model.app.slug
+ }) + f'?{querystring}'
+ response = self.client.get(url)
+
+ content = self.bc.format.from_bytes(response.content)
+ expected = render_authorization(model.app,
+ required_scopes=[model.scope[0], model.scope[1]],
+ optional_scopes=[model.scope[0], model.scope[1]],
+ selected_scopes=[model.scope[0].slug],
+ new_scopes=[])
+
+ # dump error in external files
+ if content != expected:
+ with open('content.html', 'w') as f:
+ f.write(content)
+
+ with open('expected.html', 'w') as f:
+ f.write(expected)
+
+ self.assertEqual(content, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('authenticate.App'), [
+ self.bc.format.to_dict(model.app),
+ ])
+
+ self.assertTrue('permissions' in content)
+ self.assertTrue('required' in content)
+ self.assertTrue('optional' in content)
+ self.assertTrue(content.count('checked') == 3)
+ self.assertTrue(content.count('New') == 0)
+
+ # When: app require an agreement, with scopes, it requires update the agreement
+ # Then: return 200
+ @patch('django.template.context_processors.get_token', MagicMock(return_value='predicabletoken'))
+ def test_app_require_an_agreement__with_scopes__updating_agreement____(self):
+ app = {'require_an_agreement': True}
+ optional_scope_set = {'optional_scopes': []}
+ # import timezone from django
+
+ now = timezone.now()
+ app_required_scopes = [{'app_id': 1, 'scope_id': n + 1, 'agreed_at': now} for n in range(2)]
+ app_optional_scopes = [{'app_id': 1, 'scope_id': n + 1, 'agreed_at': now} for n in range(2)]
+ app_user_agreement = {'agreed_at': now - timedelta(days=1)}
+
+ slug1 = self.bc.fake.slug().replace('-', '_')[:7]
+ slug2 = self.bc.fake.slug().replace('-', '_')[:7]
+
+ if random.randint(0, 1):
+ slug1 += ':' + self.bc.fake.slug().replace('-', '_')[:7]
+
+ if random.randint(0, 1):
+ slug2 += ':' + self.bc.fake.slug().replace('-', '_')[:7]
+
+ scopes = [{'slug': slug1}, {'slug': slug2}]
+
+ model = self.bc.database.create(user=1,
+ token=1,
+ app=app,
+ scope=scopes,
+ app_user_agreement=app_user_agreement,
+ optional_scope_set=optional_scope_set,
+ app_required_scope=app_required_scopes,
+ app_optional_scope=app_optional_scopes)
+
+ querystring = self.bc.format.to_querystring({'token': model.token.key})
+ url = reverse_lazy('authenticate:authorize_slug', kwargs={'app_slug': model.app.slug
+ }) + f'?{querystring}'
+ response = self.client.get(url)
+
+ content = self.bc.format.from_bytes(response.content)
+ expected = render_authorization(model.app,
+ required_scopes=[model.scope[0], model.scope[1]],
+ optional_scopes=[model.scope[0], model.scope[1]],
+ selected_scopes=[],
+ new_scopes=[model.scope[0].slug, model.scope[1].slug])
+
+ # dump error in external files
+ if content != expected:
+ with open('content.html', 'w') as f:
+ f.write(content)
+
+ with open('expected.html', 'w') as f:
+ f.write(expected)
+
+ self.assertEqual(content, expected)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(self.bc.database.list_of('authenticate.App'), [
+ self.bc.format.to_dict(model.app),
+ ])
+
+ self.assertTrue('permissions' in content)
+ self.assertTrue('required' in content)
+ self.assertTrue('optional' in content)
+ self.assertTrue(content.count('checked') == 4)
+ self.assertTrue(content.count('New') == 4)
diff --git a/breathecode/authenticate/urls.py b/breathecode/authenticate/urls.py
index aecbe0662..959b6ca06 100644
--- a/breathecode/authenticate/urls.py
+++ b/breathecode/authenticate/urls.py
@@ -15,16 +15,16 @@
"""
from django.urls import path
-from .views import (AcademyInviteView, AcademyTokenView, AppUserView, ConfirmEmailView, GithubMeView,
- GitpodUserView, LoginView, LogoutView, MeInviteView, MemberView, PasswordResetView,
- ProfileInviteMeView, ProfileMePictureView, ProfileMeView, ResendInviteView, StudentView,
- TemporalTokenView, TokenTemporalView, UserMeView, WaitingListView, app_webhook,
- authorize_view, get_facebook_token, get_github_token, get_google_token, get_roles,
- get_slack_token, get_token_info, get_user_by_id_or_email, get_users, login_html_view,
- pick_password, render_academy_invite, render_invite, render_user_invite,
- reset_password_view, save_facebook_token, save_github_token, save_google_token,
- save_slack_token, sync_gitpod_users_view, GithubUserView, AcademyGithubSyncView,
- AcademyAuthSettingsView)
+from .views import (AcademyInviteView, AcademyTokenView, AppUserAgreementView, AppUserView, ConfirmEmailView,
+ GithubMeView, GitpodUserView, LoginView, LogoutView, MeInviteView, MemberView,
+ PasswordResetView, ProfileInviteMeView, ProfileMePictureView, ProfileMeView,
+ ResendInviteView, StudentView, TemporalTokenView, TokenTemporalView, UserMeView,
+ WaitingListView, app_webhook, authorize_view, get_facebook_token, get_github_token,
+ get_google_token, get_roles, get_slack_token, get_token_info, get_user_by_id_or_email,
+ get_users, login_html_view, pick_password, render_academy_invite, render_invite,
+ render_user_invite, reset_password_view, save_facebook_token, save_github_token,
+ save_google_token, save_slack_token, sync_gitpod_users_view, GithubUserView,
+ AcademyGithubSyncView, AcademyAuthSettingsView)
app_name = 'authenticate'
urlpatterns = [
@@ -107,9 +107,11 @@
path('academy/gitpod/user/
', GitpodUserView.as_view(), name='gitpod_user_id'),
# authorize
- path('authorize/', authorize_view, name='authorize'),
+ path('authorize/', authorize_view, name='authorize_slug'),
# apps
+ path('appuseragreement', AppUserAgreementView.as_view(), name='appuseragreement'),
+ path('app/user', AppUserView.as_view(), name='app_user'),
path('app/user/', AppUserView.as_view(), name='app_user_id'),
path('app/webhook', app_webhook, name='app_webhook'),
]
diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py
index 2167c18a7..e99aa719a 100644
--- a/breathecode/authenticate/views.py
+++ b/breathecode/authenticate/views.py
@@ -53,17 +53,20 @@
from .authentication import ExpiringTokenAuthentication
from .forms import (InviteForm, LoginForm, PasswordChangeCustomForm, PickPasswordForm, ResetPasswordForm,
SyncGithubUsersForm)
-from .models import (AppUserAgreement, CredentialsFacebook, CredentialsGithub, CredentialsGoogle,
- CredentialsSlack, GitpodUser, OptionalScopeSet, Profile, ProfileAcademy, Role, Scope,
- Token, UserInvite, GithubAcademyUser, AcademyAuthSettings)
-from .serializers import (
- AppUserSerializer, AuthSerializer, GetGitpodUserSerializer, GetProfileAcademySerializer,
- GetProfileAcademySmallSerializer, GetProfileSerializer, GitpodUserSmallSerializer, MemberPOSTSerializer,
- MemberPUTSerializer, ProfileAcademySmallSerializer, ProfileSerializer, RoleBigSerializer,
- RoleSmallSerializer, StudentPOSTSerializer, TokenSmallSerializer, UserInviteSerializer,
- UserInviteShortSerializer, UserInviteSmallSerializer, UserInviteWaitingListSerializer, UserMeSerializer,
- UserSerializer, UserSmallSerializer, UserTinySerializer, GithubUserSerializer, PUTGithubUserSerializer,
- AuthSettingsBigSerializer, AcademyAuthSettingsSerializer, POSTGithubUserSerializer)
+from .models import (App, AppOptionalScope, AppRequiredScope, AppUserAgreement, CredentialsFacebook,
+ CredentialsGithub, CredentialsGoogle, CredentialsSlack, GitpodUser, OptionalScopeSet,
+ Profile, ProfileAcademy, Role, Scope, Token, UserInvite, GithubAcademyUser,
+ AcademyAuthSettings)
+from .serializers import (AppUserSerializer, AuthSerializer, GetGitpodUserSerializer,
+ GetProfileAcademySerializer, GetProfileAcademySmallSerializer, GetProfileSerializer,
+ GitpodUserSmallSerializer, MemberPOSTSerializer, MemberPUTSerializer,
+ ProfileAcademySmallSerializer, ProfileSerializer, RoleBigSerializer,
+ RoleSmallSerializer, SmallAppUserAgreementSerializer, StudentPOSTSerializer,
+ TokenSmallSerializer, UserInviteSerializer, UserInviteShortSerializer,
+ UserInviteSmallSerializer, UserInviteWaitingListSerializer, UserMeSerializer,
+ UserSerializer, UserSmallSerializer, UserTinySerializer, GithubUserSerializer,
+ PUTGithubUserSerializer, AuthSettingsBigSerializer, AcademyAuthSettingsSerializer,
+ POSTGithubUserSerializer)
logger = logging.getLogger(__name__)
APP_URL = os.getenv('APP_URL', '')
@@ -2338,6 +2341,19 @@ def get(self, request, app: dict, token: dict, user_id=None):
return handler.response(serializer.data)
+class AppUserAgreementView(APIView):
+ extensions = APIViewExtensions(paginate=True)
+
+ def get(self, request):
+ handler = self.extensions(request)
+
+ items = AppUserAgreement.objects.filter(user=request.user, app__require_an_agreement=True)
+ items = handler.queryset(items)
+ serializer = SmallAppUserAgreementSerializer(items, many=True)
+
+ return handler.response(serializer.data)
+
+
# app/webhook
@api_view(['POST'])
@permission_classes([AllowAny])
@@ -2361,16 +2377,42 @@ def authorize_view(request, token=None, app_slug=None):
app = get_app(app_slug)
except:
- return render_message(request, 'App not found', btn_label='Continue to 4Geeks', btn_url=APP_URL)
+ return render_message(request,
+ 'App not found',
+ btn_label='Continue to 4Geeks',
+ btn_url=APP_URL,
+ status=404)
if not app.require_an_agreement:
- return render_message(request, 'App not found', btn_label='Continue to 4Geeks', btn_url=APP_URL)
+ return render_message(request,
+ 'App not found',
+ btn_label='Continue to 4Geeks',
+ btn_url=APP_URL,
+ status=404)
+
+ agreement = AppUserAgreement.objects.filter(app=app, user=request.user).first()
+ selected_scopes = [x.slug
+ for x in agreement.optional_scope_set.optional_scopes.all()] if agreement else []
+
+ required_scopes = Scope.objects.filter(app_required_scopes__app=app)
+ optional_scopes = Scope.objects.filter(app_optional_scopes__app=app)
+
+ new_scopes = [
+ x.slug for x in Scope.objects.filter(
+ Q(app_required_scopes__app=app, app_required_scopes__agreed_at__gt=agreement.agreed_at),
+ Q(app_optional_scopes__app=app, app_optional_scopes__agreed_at__gt=agreement.agreed_at))
+ ] if agreement else []
if request.method == 'GET':
- return render(request, 'authorize.html', {
- 'app': app,
- 'reject_url': app.redirect_url + '?app=4geeks&status=rejected',
- })
+ return render(
+ request, 'authorize.html', {
+ 'app': app,
+ 'required_scopes': required_scopes,
+ 'optional_scopes': optional_scopes,
+ 'selected_scopes': selected_scopes,
+ 'new_scopes': new_scopes,
+ 'reject_url': app.redirect_url + '?app=4geeks&status=rejected',
+ })
if request.method == 'POST':
items = set()
diff --git a/breathecode/commons/templates/scopes.html b/breathecode/commons/templates/scopes.html
index 41d634828..cc8cab72d 100644
--- a/breathecode/commons/templates/scopes.html
+++ b/breathecode/commons/templates/scopes.html
@@ -1,4 +1,4 @@
-{% if scopes.count %}
+{% if scopes|length %}