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 + + + + + + + +
+
+ {% csrf_token %} +

+ {{app.name}} +

+ +

+ This app is requesting permission to access your account. +

+ +

+ {{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 %} + +
+ {% button type="link" className="btn-danger offset-2 col-4" href=reject_url value="Reject" %} + {% button type="submit" className="btn-primary col-4" value="Accept" %} +
+
+
+ + + + + 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 %}

@@ -19,18 +19,21 @@

data-bs-parent="#{{id}}" >
- {% for scope in scopes.all %} + {% for scope in scopes %}
- {{scope.name}} + {{scope.name}} {% if new_scopes and scope.slug in new_scopes %} + + New + {% endif %}
@@ -46,6 +49,22 @@

checked disabled /> + {% elif selected_scopes and scope.slug in selected_scopes %} + + {% elif selected_scopes %} + + {% else %} Date: Fri, 4 Aug 2023 13:34:05 -0500 Subject: [PATCH 15/19] finish tests --- breathecode/authenticate/receivers.py | 39 ++- .../tests/urls/tests_authorize_slug.py | 294 +++++++++++++++++- breathecode/authenticate/views.py | 7 + 3 files changed, 323 insertions(+), 17 deletions(-) diff --git a/breathecode/authenticate/receivers.py b/breathecode/authenticate/receivers.py index 978a3c4ed..1f9a93dfb 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, m2m_changed +from django.db.models.signals import post_delete, post_save, pre_delete 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 App, ProfileAcademy +from breathecode.authenticate.models import App, AppOptionalScope, AppRequiredScope, AppUserAgreement, ProfileAcademy from breathecode.mentorship.models import MentorProfile from django.db.models import Q from django.utils import timezone @@ -110,20 +110,33 @@ def post_save_cohort_user(sender, instance, **kwargs): 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): +@receiver(post_save, sender=AppRequiredScope) +def increment_on_update_required_scope(sender: Type[AppRequiredScope], instance: AppRequiredScope, **kwargs): + if AppUserAgreement.objects.filter(app=instance.app, + agreement_version=instance.app.agreement_version).exists(): + instance.app.agreement_version += 1 + instance.app.save() - if action == 'post_add': - instance.agreement_version += 1 - instance.save() +@receiver(post_save, sender=AppOptionalScope) +def increment_on_update_optional_scope(sender: Type[AppOptionalScope], instance: AppOptionalScope, **kwargs): + if AppUserAgreement.objects.filter(app=instance.app, + agreement_version=instance.app.agreement_version).exists(): + instance.app.agreement_version += 1 + instance.app.save() -@receiver(m2m_changed, sender=App.optional_scopes.through) -def increment_on_change_optional_scope(sender: Type[App], instance: App, action: str, **kwargs): +@receiver(pre_delete, sender=AppRequiredScope) +def increment_on_delete_required_scope(sender: Type[AppRequiredScope], instance: AppRequiredScope, **kwargs): + if AppUserAgreement.objects.filter(app=instance.app, + agreement_version=instance.app.agreement_version).exists(): + instance.app.agreement_version += 1 + instance.app.save() - if action == 'post_add': - instance.agreement_version += 1 - instance.save() +@receiver(pre_delete, sender=AppOptionalScope) +def increment_on_delete_optional_scope(sender: Type[AppOptionalScope], instance: AppOptionalScope, **kwargs): + if AppUserAgreement.objects.filter(app=instance.app, + agreement_version=instance.app.agreement_version).exists(): + instance.app.agreement_version += 1 + instance.app.save() diff --git a/breathecode/authenticate/tests/urls/tests_authorize_slug.py b/breathecode/authenticate/tests/urls/tests_authorize_slug.py index 9b0b98662..74d6d0f16 100644 --- a/breathecode/authenticate/tests/urls/tests_authorize_slug.py +++ b/breathecode/authenticate/tests/urls/tests_authorize_slug.py @@ -5,16 +5,27 @@ 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 +UTC_NOW = timezone.now() + + +def app_user_agreement_item(app, user, data={}): + return { + 'agreed_at': UTC_NOW, + 'agreement_version': app.agreement_version, + 'app_id': app.id, + 'id': 0, + 'optional_scope_set_id': 0, + 'user_id': user.id, + **data, + } + # IMPORTANT: the loader.render_to_string in a function is inside of function render def render_message(message): @@ -69,7 +80,7 @@ def render_authorization(app, required_scopes=[], optional_scopes=[], selected_s }, request) -class AuthenticateTestSuite(AuthTestCase): +class GetTestSuite(AuthTestCase): # When: no auth # Then: return 302 def test_no_auth(self): @@ -359,3 +370,278 @@ def test_app_require_an_agreement__with_scopes__updating_agreement____(self): self.assertTrue('optional' in content) self.assertTrue(content.count('checked') == 4) self.assertTrue(content.count('New') == 4) + + +class PostTestSuite(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.post(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.post(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, 'agreement_version': 1} + 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.post(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), + 'agreement_version': 1, + }, + ]) + + # When: user without agreement + # Then: return 200 + @patch('django.template.context_processors.get_token', MagicMock(return_value='predicabletoken')) + @patch('django.utils.timezone.now', MagicMock(return_value=UTC_NOW)) + def test_user_without_agreement(self): + app = {'require_an_agreement': True, 'agreement_version': 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}] + 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}' + + data = { + slug1: 'on', + slug2: 'on', + } + response = self.client.post(url, data) + + content = self.bc.format.from_bytes(response.content) + expected = '' + + # 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.url, model.app.redirect_url + '?app=4geeks&status=authorized') + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual(self.bc.database.list_of('authenticate.App'), [ + { + **self.bc.format.to_dict(model.app), + 'agreement_version': 1, + }, + ]) + + self.assertEqual(self.bc.database.list_of('authenticate.AppUserAgreement'), [ + app_user_agreement_item(model.app, model.user, data={ + 'id': 1, + 'optional_scope_set_id': 1, + }), + ]) + + # When: user with agreement, scopes not changed + # Then: return 200 + @patch('django.template.context_processors.get_token', MagicMock(return_value='predicabletoken')) + @patch('django.utils.timezone.now', MagicMock(return_value=UTC_NOW)) + def test_user_with_agreement__scopes_not_changed(self): + app = {'require_an_agreement': True, 'agreement_version': 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}] + 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 = {'agreement_version': 1} + + 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, + app_user_agreement=app_user_agreement) + + querystring = self.bc.format.to_querystring({'token': model.token.key}) + url = reverse_lazy('authenticate:authorize_slug', kwargs={'app_slug': model.app.slug + }) + f'?{querystring}' + + data = { + slug1: 'on', + slug2: 'on', + } + response = self.client.post(url, data) + + content = self.bc.format.from_bytes(response.content) + expected = '' + + # 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.url, model.app.redirect_url + '?app=4geeks&status=authorized') + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual(self.bc.database.list_of('authenticate.App'), [ + { + **self.bc.format.to_dict(model.app), + 'agreement_version': 1, + }, + ]) + + self.assertEqual(self.bc.database.list_of('authenticate.AppUserAgreement'), [ + self.bc.format.to_dict(model.app_user_agreement), + ]) + + # When: user with agreement, scopes changed + # Then: return 200 + @patch('django.template.context_processors.get_token', MagicMock(return_value='predicabletoken')) + @patch('django.utils.timezone.now', MagicMock(return_value=UTC_NOW)) + def test_user_with_agreement__scopes_changed(self): + app = {'require_an_agreement': True, 'agreement_version': 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}] + 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)] + optional_scope_set = {'optional_scopes': [1]} + app_user_agreement = {'agreement_version': 1} + + 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, + app_user_agreement=app_user_agreement, + optional_scope_set=optional_scope_set) + + querystring = self.bc.format.to_querystring({'token': model.token.key}) + url = reverse_lazy('authenticate:authorize_slug', kwargs={'app_slug': model.app.slug + }) + f'?{querystring}' + + data = { + slug1: 'on', + slug2: 'on', + } + response = self.client.post(url, data) + + content = self.bc.format.from_bytes(response.content) + expected = '' + + # 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.url, model.app.redirect_url + '?app=4geeks&status=authorized') + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual(self.bc.database.list_of('authenticate.App'), [ + { + **self.bc.format.to_dict(model.app), + 'agreement_version': 1, + }, + ]) + + self.assertEqual(self.bc.database.list_of('authenticate.AppUserAgreement'), [ + { + **self.bc.format.to_dict(model.app_user_agreement), + 'agreed_at': UTC_NOW, + 'optional_scope_set_id': 2, + 'agreement_version': 1, + }, + ]) diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py index e99aa719a..a0bd0b532 100644 --- a/breathecode/authenticate/views.py +++ b/breathecode/authenticate/views.py @@ -2428,16 +2428,22 @@ def authorize_view(request, token=None, app_slug=None): for item in items: query |= Q(optional_scopes__slug=item) + created = False cache = OptionalScopeSet.objects.filter(query).first() if cache is None or cache.optional_scopes.count() != len(items): cache = OptionalScopeSet() cache.save() + created = True + 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()): + if created: + agreement.agreed_at = timezone.now() + agreement.optional_scope_set = cache agreement.agreement_version = app.agreement_version agreement.save() @@ -2445,6 +2451,7 @@ def authorize_view(request, token=None, app_slug=None): else: agreement = AppUserAgreement.objects.create(app=app, user=request.user, + agreed_at=timezone.now(), agreement_version=app.agreement_version, optional_scope_set=cache) From a2a24517799b6cb87ac3fa6a5a9e99da9bd27265 Mon Sep 17 00:00:00 2001 From: jefer94 Date: Fri, 4 Aug 2023 17:31:32 -0500 Subject: [PATCH 16/19] update docs --- README.md | 140 ++++++++------------- docker-compose.yml | 19 +-- docs/images/codespaces.png | Bin 0 -> 37723 bytes docs/index.md | 95 ++++++-------- docs/security/authentication-class.md | 28 +++++ docs/security/capabilities.md | 5 + docs/security/introduction.md | 9 ++ docs/security/schema-link.md | 171 ++++++++++++++++++++++++++ mkdocs.yml | 5 +- 9 files changed, 305 insertions(+), 167 deletions(-) create mode 100644 docs/images/codespaces.png create mode 100644 docs/security/authentication-class.md create mode 100644 docs/security/introduction.md create mode 100644 docs/security/schema-link.md diff --git a/README.md b/README.md index a66af11d0..dbe112fa3 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@


- BreatheCode -
- BreatheCode + 4Geeks

-

BreatheCode's mission is to accelerate the way junior developers learn and evolve using technology.

+

4Geeks's mission is to accelerate the way junior developers learn and evolve using technology.

@@ -43,111 +41,95 @@ Check out the [Postman docs](https://documenter.getpostman.com/view/2432393/T1LP The documentation is divided into several sections: -- [No Installation (with gitpod)](#working-inside-gitpod-no-instalation) - - [How to work Gitpod](#how-to-work-gitpod) - - [Add the browser extension](#add-the-browser-extension) - - [How to use Gitpod browser extension](#how-to-use-gitpod-browser-extension) -- [Installation inside Docker (easier)](#working-inside-docker-easier) - - [Build BreatheCode Dev docker image](#build-breathecode-dev-docker-image) - - [Testing inside BreatheCode Dev](#testing-inside-breathecode-dev) - - [Run BreatheCode API as docker service](#run-breathecode-api-as-docker-service) -- [Installation in your local machine (a bit harder but more performant)](#working-in-your-local-machine-recomended) - - [Installation in your local machine](#installation-in-your-local-machine) - - [Testing in your local machine](#testing-in-your-local-machine) - - [Run BreatheCode API in your local machine](#run-breathecode-api-in-your-local-machine) - -## Working inside Gitpod (no installation) - -### `How to work Gitpod` -Creating a workspace is as easy as prefixing any GitHub URL with `gitpod.io/#`. +- [Run 4Geeks in Codespaces (no installation)](#run-4geeks-in-codespaces-no-instalation) +- [Install Docker](#install-docker) +- [Run 4Geeks API as docker service](#run-4geeks-api-as-docker-service) +- [Run 4Geeks in your local machine](#run-4geeks-api-in-your-local-machine) + - [Installation](#installation) + - [Run 4Geeks API](#run-4geeks-api) +- [Run tests](#run-tests) -### `Add the browser extension` +## Run 4Geeks in Codespaces (no installation) -Gitpod provide the extension for: +Click `Code` -> `Codespaces` -> `Create namespace on {BRANCH_NAME}`. -- [Chrome](https://chrome.google.com/webstore/detail/gitpod-online-ide/dodmmooeoklaejobgleioelladacbeki) - also works for Edge, Brave and other Chromium-based browsers. -- [Firefox](https://addons.mozilla.org/firefox/addon/gitpod/) +![Codespaces](docs/images/codespaces.png) -### `How to use Gitpod browser extension` +## Install Docker -For convenience, Gitpod developed a Gitpod browser extension. It adds a button to GitHub, GitLab or Bitbucket that does the prefixing for you - as simple as that. +Install [docker desktop](https://www.docker.com/products/docker-desktop) in your Windows, else find a guide to install Docker and Docker Compose in your linux distribution `uname -a`. -![How to use gitpod extension](https://www.gitpod.io/images/docs/browser-extension-lense.png) +## Running 4geeks -## Working inside Docker (easier) +### `Run 4Geeks API as docker service` -### `Build BreatheCode Dev docker image` +```bash +# open 4Geeks API as a service and export the port 8000 +docker-compose up -d -For mac and pc users install [docker desktop](https://www.docker.com/products/docker-desktop), else, for linux find a guide to install Docker and Docker Compose in your linux distribution `uname -a`. +# create super user +sudo docker compose run 4geeks python manage.py createsuperuser -```bash -# Check which dependencies you need install in you operating system -python -m scripts.doctor +# See the output of Django +docker-compose logs -f 4geeks -# Generate the BreatheCode Dev docker image -docker-compose build bc-dev +# open localhost:8000 to view the api +# open localhost:8000/admin to view the admin ``` -### `Testing inside BreatheCode Dev` +### `Run 4Geeks in your local machine` -```bash -# Open the BreatheCode Dev, this shell don't export the port 8000 -docker-compose run bc-dev fish +#### Installation -# Testing -pipenv run test ./breathecode/activity # path - -# Testing in parallel -pipenv run ptest ./breathecode/activity # path +```bash +# Check which dependencies you need install in your operating system +python -m scripts.doctor -# Coverage -pipenv run cov breathecode.activity # python module path +# Setting up the redis and postgres database, you also can install manually in your local machine this databases +docker-compose up -d redis postgres -# Coverage in parallel -pipenv run pcov breathecode.activity # python module path +# Install and setting up your development environment (this command replace your .env file) +python -m scripts.install ``` -### `Run BreatheCode API as docker service` +#### Run 4Geeks API + +You must up Redis and Postgres before open 4Geeks. ```bash -# open BreatheCode API as a service and export the port 8000 -docker-compose up -d bc-dev +# Collect statics +pipenv run python manage.py collectstatic --noinput -# open the BreatheCode Dev, this shell don't export the port 8000 -docker-compose run bc-dev fish +# Run migrations +pipenv run python manage.py migrate -# create super user -pipenv run python manage.py createsuperuser +# Load fixtures (populate the database) +pipenv run python manage.py loaddata breathecode/*/fixtures/dev_*.json -# Close the BreatheCode Dev -exit +# Create super user +pipenv run python manage.py createsuperuser -# See the output of Django -docker-compose logs -f bc-dev +# Run server +pipenv run start # open localhost:8000 to view the api # open localhost:8000/admin to view the admin ``` -## Working in your local machine (recommended) - -### `Installation in your local machine` +### `Testing in your local machine` -Install [docker desktop](https://www.docker.com/products/docker-desktop) in your Windows, else find a guide to install Docker and Docker Compose in your linux distribution `uname -a`. +#### Installation ```bash # Check which dependencies you need install in your operating system python -m scripts.doctor -# Setting up the redis and postgres database, you also can install manually in your local machine this databases -docker-compose up -d redis postgres - # Install and setting up your development environment (this command replace your .env file) python -m scripts.install ``` -### `Testing in your local machine` +#### Run tests ```bash # Testing @@ -162,25 +144,3 @@ pipenv run cov breathecode.activity # python module path # Coverage in parallel pipenv run pcov breathecode.activity # python module path ``` - -### `Run BreatheCode API in your local machine` - -```bash -# Collect statics -pipenv run python manage.py collectstatic --noinput - -# Run migrations -pipenv run python manage.py migrate - -# Load fixtures (populate the database) -pipenv run python manage.py loaddata breathecode/*/fixtures/dev_*.json - -# Create super user -pipenv run python manage.py createsuperuser - -# Run server -pipenv run start - -# open localhost:8000 to view the api -# open localhost:8000/admin to view the admin -``` diff --git a/docker-compose.yml b/docker-compose.yml index 7772bb45c..c1eb40fb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: "3.8" services: - breathecode: + 4geeks: image: geeksacademy/breathecode:development env_file: - ./.env @@ -16,23 +16,6 @@ services: - postgres - redis - bc-dev: - build: - context: . - dockerfile: .dev.Dockerfile - volumes: - - ./:/home/shell/apiv2 - environment: - - REDIS_URL=redis://redis:6379 - - DATABASE_URL=postgres://user:pass@postgres:5432/breathecode - - CELERY_DISABLE_SCHEDULER= - - ALLOW_UNSAFE_CYPRESS_APP=True - ports: - - "8000:8000" - depends_on: - - postgres - - redis - redis: image: redis:alpine ports: diff --git a/docs/images/codespaces.png b/docs/images/codespaces.png new file mode 100644 index 0000000000000000000000000000000000000000..23d9cc3b93631697d4a5c2b589bd451bfb5da6bd GIT binary patch literal 37723 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 @capable_of -> view +``` + Authenticated methods must be decorated with the `@capable_of` decorator in increase security validation. For example: ```python from breathecode.utils import capable_of + @capable_of('crud_member') def post(self, request, academy_id=None): serializer = StaffPOSTSerializer(data=request.data) diff --git a/docs/security/introduction.md b/docs/security/introduction.md new file mode 100644 index 000000000..625b0f1b1 --- /dev/null +++ b/docs/security/introduction.md @@ -0,0 +1,9 @@ +# Introduction + +## Authentication + +Authentication is the process of verifying the identity of a user, system, or client. When a user attempts to access a system or application, they must provide credentials to prove their identity. This can include entering a username and password, presenting a digital certificate, or even providing biometric data like a fingerprint. + +## Authorization + +Authorization is the process that comes after authentication and it determines what permissions an authenticated user has within a system. This means defining what actions a user is allowed to perform, what resources they are allowed to access, and what operations they are able to execute. diff --git a/docs/security/schema-link.md b/docs/security/schema-link.md new file mode 100644 index 000000000..b43f2cf8c --- /dev/null +++ b/docs/security/schema-link.md @@ -0,0 +1,171 @@ +# Schema link + +It's custom authorization schema that share a key between 2 servers, it's used when you can't block microservices by ip, both servers authorize what actions can perform its pair, if both apps does not belongs to the same company you must include additionally an agreement layer. + +## `When user must sign an agreement?` + +When both servers does not belong to the same company, it's mandatory. + +## `How is build that agreement?` + +It's builded as a collection of scopes that represent what's authorized server can do with your data. + +## `What's scope?` + +In the context of OAuth, OpenID Connect, and many authentication/authorization systems, "scope" refers to the permissions that are associated with a particular token. + +## `How to link both servers?` + +Each server must register the other server in its database, it must share the same algorithm, strategy, keys and schema, and the key can't be shared, because during the keys rotations many of them will breaks. + +# Objects + +## `Token` + +This object represent the json payload. + +- `sub`: The "subject" claim in a JWT. This usually represents the principal entity (typically a user) for which the JWT is intended. In this case, user_id would be the identifier of the user in the context of your system. +- `iss`: The "issuer" claim in a JWT. This represents the entity that generated and signed the JWT. In this case, it's being read from an environment variable API_URL, with a default value of 'http://localhost:8000'. +- `app`: This is a custom claim you've defined. It appears to specify the name of the application generating the token, in this case '4geeks'. +- `aud`: The "audience" claim. This represents the intended recipients of the JWT. In this case, it's app.slug, presumably the identifier for an application that should accept this token. +- `exp`: The "expiration time" claim. This is the time after which the JWT should no longer be accepted. It's calculated as the current time now plus a certain number of minutes determined by JWT_LIFETIME. +- `iat`: The "issued at" claim. This represents the time at which the JWT was issued. It's the current time now minus one second (to ensure the token is valid immediately on issuance). +- `typ`: The "type" claim. It's a hint about the type of token. In this case, it's 'JWT' to indicate that this is a JSON Web Token. + +## `App` + +This object represent the app on your database, this object must be cached. + +- `id`: The unique identifier for the application. This is often used as a reference to the application in the system. +- `private_key`: This is a cryptographic key that is kept secret and only known to the application. This can be used for things like signing tokens or encrypting data. +- `public_key`: This is the counterpart to the private key. It can be freely shared and is often used to verify tokens signed with the private key, or decrypt data encrypted with the private key. +- `algorithm`: This refers to the algorithm used for cryptographic operations. This could be a specific type of symmetric or asymmetric encryption, or a digital signature algorithm. +- `strategy`: This could refer to a particular strategy used for authentication or authorization in your system. +- `schema`: This could be the structure or format that the app's data follows. In the context of databases, a schema defines how data is organized and how relationships are enforced. +- `require_an_agreement`: This is likely a Boolean value (True/False) indicating whether the user needs to agree to certain terms before using the application. +- `webhook_url`: Webhooks provide a way for applications to get real-time data updates. This URL is likely where the application will send HTTP requests when certain events occur. +- `redirect_url`: In OAuth or similar authentication/authorization flows, this is the URL where users are redirected after they authorize the application. This URL often includes a code or token as a query parameter, which the application can exchange for an access token. +- `app_url`: This is likely the URL where the actual application can be accessed by users. + +# Using Json Web Token + +JWT stands for JSON Web Token. It is a standard (RFC 7519) for creating access tokens that assert some number of claims. For example, a server could generate a token that has the claim "logged in as admin" and provide that to a client. The client could then use that token to prove that it's logged in as admin. + +A JWT is composed of three parts: a header, a payload, and a signature. These parts are separated by dots (.) and are Base64Url encoded. + +- `Header`: The header typically consists of two parts: the type of the token, which is JWT, and the signing algorithm being used, such as HMAC SHA256 or RSA. + +- `Payload`: The second part of the token is the payload, which contains the claims. Claims are statements about an entity (typically, the user) and additional metadata. There are three types of claims: registered, public, and private claims. + +- `Signature`: To create the signature part you have to take the encoded header, the encoded payload, a secret, the algorithm specified in the header, and sign that. + +The resulting string is three Base64-URL strings concatenated with dots. The string is compact, URL-safe, and can be conveniently passed in HTML and HTTP environments. + +## `Params` + +- `Token`: JWT token. +- `App`: App's slug that sign this token. + +```http +GET /data HTTP/1.1 +Host: api.example.com +Authorization: Link App={App},Token={Token} +``` + +# Using Signature + +It's a mechanism that validates the authenticity and integrity of data. It provides a way to verify that the data came from a specific source and has not been altered in transit. + +## `Params` + +- App: it represents a unique identifier for the app making the request. This is included in the header so the server knows which application is making the request. +- Token: A nonce is a random or semi-random number that is generated for a specific use, typically to avoid replay attacks. In this case, it's a sign that refer to a signature generated using a cryptographic algorithm. +- SignedHeaders: This part of the header includes the list of HTTP headers that are included in the signature. These are joined into a string separated by semicolons. +- Date: This is the timestamp when the request is made. It's often used to ensure that a request is not replayed (that is, sent again by an attacker). + +```http +GET /api/resource HTTP/1.1 +Host: www.example.com +Authorization: Signature App={App},Nonce={Token},SignedHeaders={header1};{header2};{header3},Date={Date} +``` + +# Code + +## `Sender Code` + +Service is a requests wrapper that manage the authorization header, it use the authorization strategy specified in the app object. + +```py +# Make an action over multiple users +s = Service(app.id) +request = s.get('v1/auth/user') +data = request.json() +print(data) + +# Make an action over a specify user +s = Service(app.id, user.id) +request = s.get('v1/auth/user') +data = request.json() +print(data) + +# Force Json Web Token as authorization strategy +s = Service(app.id) +request = s.get('v1/auth/user', mode='JWT') +data = request.json() +print(data) + +# Force Json Web Token as authorization strategy +s = Service(app.id) +request = s.get('v1/auth/user', mode='SIGNATURE') +data = request.json() +print(data) +``` + +## Receiver + +Protect a endpoint to access to it having these scopes, like the sender, @scope get `mode` as argument. + +```py +@api_view(['POST']) +@permission_classes([AllowAny]) +@scope(['action_name1:data_name1', 'action_name2:data_name2', ...]) +def endpoint(request, app: dict, token: dict): + handler = self.extensions(request) + lang = get_user_language(request) + + extra = {} + if app.require_an_agreement: + extra['appuseragreement__app__id'] = app.id + + 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) + + # test this path + items = User.objects.filter(**extra) + items = handler.queryset(items) + serializer = AppUserSerializer(items, many=True) + + return handler.response(serializer.data) +``` diff --git a/mkdocs.yml b/mkdocs.yml index 4f67d540d..899cd86af 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,7 +10,7 @@ nav: - Installation: - 'installation/environment-variables.md' - 'installation/fixtures.md' - - Deployment: + - Deployments: - 'deployment/environment-variables.md' - 'deployment/configuring-the-github-secrets.md' - Apps: @@ -20,7 +20,10 @@ nav: - 'apps/activities.md' - 'apps/admissions.md' - Security: + - 'security/introduction.md' + - 'security/authentication-class.md' - 'security/capabilities.md' + - 'security/schema-link.md' - Services: - Google Cloud: - 'services/google_cloud/google-cloud-functions.md' From 6712b591f139b637a941894c9a97321fb21f9b1d Mon Sep 17 00:00:00 2001 From: jefer94 Date: Fri, 4 Aug 2023 22:37:42 -0500 Subject: [PATCH 17/19] add migrations --- .../0059_alter_cohortuser_history_log.py | 2 +- breathecode/authenticate/actions.py | 4 +- breathecode/authenticate/admin.py | 24 ++++-- ...801_0128.py => 0042_auto_20230805_0323.py} | 74 +++++++++++++++++-- .../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 | 14 ++-- breathecode/authenticate/views.py | 8 +- breathecode/events/models.py | 10 ++- 11 files changed, 105 insertions(+), 102 deletions(-) rename breathecode/authenticate/migrations/{0042_auto_20230801_0128.py => 0042_auto_20230805_0323.py} (61%) delete mode 100644 breathecode/authenticate/migrations/0043_alter_scope_name.py delete mode 100644 breathecode/authenticate/migrations/0044_alter_scope_slug.py delete mode 100644 breathecode/authenticate/migrations/0045_optionalscopeset_agreement_version.py delete mode 100644 breathecode/authenticate/migrations/0046_remove_optionalscopeset_agreement_version.py diff --git a/breathecode/admissions/migrations/0059_alter_cohortuser_history_log.py b/breathecode/admissions/migrations/0059_alter_cohortuser_history_log.py index e2031eb08..1ee76ae70 100644 --- a/breathecode/admissions/migrations/0059_alter_cohortuser_history_log.py +++ b/breathecode/admissions/migrations/0059_alter_cohortuser_history_log.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.20 on 2023-08-01 03:34 +# Generated by Django 3.2.20 on 2023-08-05 03:23 from django.db import migrations, models diff --git a/breathecode/authenticate/actions.py b/breathecode/authenticate/actions.py index f6aa57848..e3a172b2f 100644 --- a/breathecode/authenticate/actions.py +++ b/breathecode/authenticate/actions.py @@ -809,8 +809,8 @@ def get_app_keys(app_slug): app.strategy, app.schema, app.require_an_agreement, - 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))), + tuple(sorted(x.slug for x in Scope.objects.filter(m2m_required_scopes__app=app))), + tuple(sorted(x.slug for x in Scope.objects.filter(m2m_optional_scopes__app=app))), app.webhook_url, app.redirect_url, app.app_url, diff --git a/breathecode/authenticate/admin.py b/breathecode/authenticate/admin.py index 4f331ab81..2adb27edb 100644 --- a/breathecode/authenticate/admin.py +++ b/breathecode/authenticate/admin.py @@ -6,10 +6,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, AppUserAgreement, CredentialsGithub, DeviceId, LegacyKey, OptionalScopeSet, Scope, - Token, UserProxy, Profile, CredentialsSlack, ProfileAcademy, Role, CredentialsFacebook, - Capability, UserInvite, CredentialsGoogle, AcademyProxy, GitpodUser, GithubAcademyUser, - AcademyAuthSettings, GithubAcademyUserLog) +from .models import (App, AppOptionalScope, AppRequiredScope, 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 @@ -461,8 +461,22 @@ class AppAdmin(admin.ModelAdmin): list_filter = ['algorithm', 'strategy', 'schema', 'require_an_agreement'] +@admin.register(AppRequiredScope) +class AppRequiredScopeAdmin(admin.ModelAdmin): + list_display = ('app', 'scope', 'agreed_at') + search_fields = ['app__name', 'app__slug', 'scope__name', 'scope__slug'] + list_filter = ['app', 'scope'] + + +@admin.register(AppOptionalScope) +class AppOptionalScopeAdmin(admin.ModelAdmin): + list_display = ('app', 'scope', 'agreed_at') + search_fields = ['app__name', 'app__slug', 'scope__name', 'scope__slug'] + list_filter = ['app', 'scope'] + + @admin.register(LegacyKey) -class AppAdmin(admin.ModelAdmin): +class LegacyKeyAdmin(admin.ModelAdmin): list_display = ('app', 'algorithm', 'strategy', 'schema') search_fields = ['app__name', 'app__slug'] list_filter = ['algorithm', 'strategy', 'schema'] diff --git a/breathecode/authenticate/migrations/0042_auto_20230801_0128.py b/breathecode/authenticate/migrations/0042_auto_20230805_0323.py similarity index 61% rename from breathecode/authenticate/migrations/0042_auto_20230801_0128.py rename to breathecode/authenticate/migrations/0042_auto_20230805_0323.py index 91543d27e..649846645 100644 --- a/breathecode/authenticate/migrations/0042_auto_20230801_0128.py +++ b/breathecode/authenticate/migrations/0042_auto_20230805_0323.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.20 on 2023-08-01 01:28 +# Generated by Django 3.2.20 on 2023-08-05 03:23 from django.conf import settings from django.db import migrations, models @@ -18,17 +18,34 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=25, unique=True)), - ('slug', models.SlugField(unique=True)), - ('description', models.CharField(max_length=255)), + ('name', + models.CharField(help_text='Descriptive and unique name of the app', + max_length=25, + unique=True)), + ('slug', + models.SlugField( + help_text= + 'Unique slug for the app, it must be url friendly and please avoid to change it', + unique=True)), + ('description', + models.CharField(help_text='Description of the app, it will appear on the authorize UI', + max_length=255)), ('algorithm', models.CharField(choices=[('HMAC_SHA256', 'HMAC-SHA256'), ('HMAC_SHA512', 'HMAC_SHA512'), ('ED25519', 'ED25519')], + default='HMAC_SHA512', max_length=11)), ('strategy', models.CharField(choices=[('JWT', 'Json Web Token'), ('SIGNATURE', 'Signature')], + default='JWT', max_length=9)), - ('schema', models.CharField(choices=[('LINK', 'Link')], max_length=4)), + ('schema', + models.CharField( + choices=[('LINK', 'Link')], + default='LINK', + help_text= + 'Schema to use for the auth process to r2epresent how the apps will communicate', + 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)), @@ -48,9 +65,15 @@ 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)), - ('slug', models.SlugField(unique=True)), - ('description', models.CharField(max_length=255)), + ('name', + models.CharField(help_text='Descriptive and unique name that appears on the authorize UI', + max_length=25, + unique=True)), + ('slug', + models.CharField(help_text='{action}:{data} for example read:repo', + max_length=15, + unique=True)), + ('description', models.CharField(help_text='Description of the scope', max_length=255)), ], ), migrations.CreateModel( @@ -93,6 +116,7 @@ class Migration(migrations.Migration): 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')), + ('agreed_at', models.DateTimeField()), ('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='authenticate.app')), ('optional_scope_set', @@ -103,11 +127,44 @@ class Migration(migrations.Migration): models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), + migrations.CreateModel( + name='AppRequiredScope', + fields=[ + ('id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('agreed_at', models.DateTimeField()), + ('app', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='m2m_required_scopes', + to='authenticate.app')), + ('scope', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='m2m_required_scopes', + to='authenticate.scope')), + ], + ), + migrations.CreateModel( + name='AppOptionalScope', + fields=[ + ('id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('agreed_at', models.DateTimeField()), + ('app', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='m2m_optional_scopes', + to='authenticate.app')), + ('scope', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='m2m_optional_scopes', + to='authenticate.scope')), + ], + ), migrations.AddField( model_name='app', name='optional_scopes', field=models.ManyToManyField(blank=True, related_name='app_optional_scopes', + through='authenticate.AppOptionalScope', to='authenticate.Scope'), ), migrations.AddField( @@ -115,6 +172,7 @@ class Migration(migrations.Migration): name='required_scopes', field=models.ManyToManyField(blank=True, related_name='app_required_scopes', + through='authenticate.AppRequiredScope', to='authenticate.Scope'), ), ] diff --git a/breathecode/authenticate/migrations/0043_alter_scope_name.py b/breathecode/authenticate/migrations/0043_alter_scope_name.py deleted file mode 100644 index 26f1f0a02..000000000 --- a/breathecode/authenticate/migrations/0043_alter_scope_name.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index 45f5940ab..000000000 --- a/breathecode/authenticate/migrations/0044_alter_scope_slug.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index 44621462b..000000000 --- a/breathecode/authenticate/migrations/0045_optionalscopeset_agreement_version.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index 719f0f9b3..000000000 --- a/breathecode/authenticate/migrations/0046_remove_optionalscopeset_agreement_version.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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 a9d3feb3c..a5962f295 100644 --- a/breathecode/authenticate/models.py +++ b/breathecode/authenticate/models.py @@ -193,17 +193,17 @@ def __init__(self, *args, **kwargs): max_length=4, choices=AUTH_SCHEMA, default=LINK, - help_text='Schema to use for the auth process to represent how the apps will communicate') + help_text='Schema to use for the auth process to r2epresent how the apps will communicate') required_scopes = models.ManyToManyField(Scope, blank=True, through='AppRequiredScope', - through_fields=('App', 'Scope'), + through_fields=('app', 'scope'), related_name='app_required_scopes') optional_scopes = models.ManyToManyField(Scope, blank=True, through='AppOptionalScope', - through_fields=('App', 'Scope'), + 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') @@ -280,8 +280,8 @@ def save(self, *args, **kwargs): 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') + app = models.ForeignKey(App, on_delete=models.CASCADE, related_name='m2m_required_scopes') + scope = models.ForeignKey(Scope, on_delete=models.CASCADE, related_name='m2m_required_scopes') agreed_at = models.DateTimeField() def __str__(self): @@ -289,8 +289,8 @@ def __str__(self): 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') + app = models.ForeignKey(App, on_delete=models.CASCADE, related_name='m2m_optional_scopes') + scope = models.ForeignKey(Scope, on_delete=models.CASCADE, related_name='m2m_optional_scopes') agreed_at = models.DateTimeField() def __str__(self): diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py index a0bd0b532..6b8f610ee 100644 --- a/breathecode/authenticate/views.py +++ b/breathecode/authenticate/views.py @@ -2394,13 +2394,13 @@ def authorize_view(request, token=None, app_slug=None): 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) + required_scopes = Scope.objects.filter(m2m_required_scopes__app=app) + optional_scopes = Scope.objects.filter(m2m_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)) + Q(m2m_required_scopes__app=app, m2m_required_scopes__agreed_at__gt=agreement.agreed_at), + Q(m2m_optional_scopes__app=app, m2m_optional_scopes__agreed_at__gt=agreement.agreed_at)) ] if agreement else [] if request.method == 'GET': diff --git a/breathecode/events/models.py b/breathecode/events/models.py index 823f4857e..50d54325a 100644 --- a/breathecode/events/models.py +++ b/breathecode/events/models.py @@ -274,10 +274,12 @@ def save(self, *args, **kwargs): created = not self.id super().save(*args, **kwargs) - new_slug = f'{slugify(self.title).lower()}-{self.id}' - if self.slug != new_slug: - self.slug = new_slug - self.save() + if self.title: + print(333333, self.title, type(self.title)) + new_slug = f'{slugify(self.title).lower()}-{self.id}' + if self.slug != new_slug: + self.slug = new_slug + self.save() event_saved.send(instance=self, sender=self.__class__, created=created) From f3e89b444aaa2fc2eeded9c4a03120effefa7135 Mon Sep 17 00:00:00 2001 From: jefer94 Date: Fri, 4 Aug 2023 23:22:05 -0500 Subject: [PATCH 18/19] fix a line --- breathecode/authenticate/actions.py | 1 - breathecode/authenticate/tasks.py | 2 +- breathecode/authenticate/views.py | 4 ++-- breathecode/events/models.py | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/breathecode/authenticate/actions.py b/breathecode/authenticate/actions.py index e3a172b2f..d0d35b99b 100644 --- a/breathecode/authenticate/actions.py +++ b/breathecode/authenticate/actions.py @@ -13,7 +13,6 @@ 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 from django.contrib.auth.models import User diff --git a/breathecode/authenticate/tasks.py b/breathecode/authenticate/tasks.py index 496c55f6b..5f47bf221 100644 --- a/breathecode/authenticate/tasks.py +++ b/breathecode/authenticate/tasks.py @@ -78,7 +78,7 @@ def async_accept_user_from_waiting_list(user_invite_id: int) -> None: }) -@task +@task() def destroy_legacy_key(legacy_key_id): from .models import LegacyKey diff --git a/breathecode/authenticate/views.py b/breathecode/authenticate/views.py index 6b8f610ee..ea259ff45 100644 --- a/breathecode/authenticate/views.py +++ b/breathecode/authenticate/views.py @@ -4,7 +4,7 @@ import os import re import urllib.parse -from datetime import timedelta, timezone +from datetime import timedelta from urllib.parse import parse_qs, urlencode import requests @@ -17,7 +17,7 @@ from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import redirect, render from django.utils import timezone -from rest_framework import serializers, status +from rest_framework import status from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.decorators import api_view, permission_classes from rest_framework.exceptions import (APIException, PermissionDenied, ValidationError) diff --git a/breathecode/events/models.py b/breathecode/events/models.py index 50d54325a..7557e1aff 100644 --- a/breathecode/events/models.py +++ b/breathecode/events/models.py @@ -275,7 +275,6 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) if self.title: - print(333333, self.title, type(self.title)) new_slug = f'{slugify(self.title).lower()}-{self.id}' if self.slug != new_slug: self.slug = new_slug From 02f95739f332393d803d8d2a8392404f60d5caa7 Mon Sep 17 00:00:00 2001 From: jefer94 Date: Fri, 4 Aug 2023 23:51:20 -0500 Subject: [PATCH 19/19] format code --- breathecode/marketing/actions.py | 17 ++++++++--------- breathecode/provisioning/views.py | 19 ++++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/breathecode/marketing/actions.py b/breathecode/marketing/actions.py index d5a5e3d1b..694b46b43 100644 --- a/breathecode/marketing/actions.py +++ b/breathecode/marketing/actions.py @@ -137,15 +137,14 @@ def validate_email(email, lang): slug='invalid-email')) if 'score' in data and data['score'] < 50: - raise ValidationException( - translation( - lang, - en= - 'The email address seems to have poor quality. Are you able to provide a different email address?', - es= - 'El correo electrónico que haz especificado parece de mala calidad. ¿Podrías especificarnos otra dirección?', - slug='invalid-email'), - data=data) + raise ValidationException(translation( + lang, + en= + 'The email address seems to have poor quality. Are you able to provide a different email address?', + es= + 'El correo electrónico que haz especificado parece de mala calidad. ¿Podrías especificarnos otra dirección?', + slug='invalid-email'), + data=data) return data diff --git a/breathecode/provisioning/views.py b/breathecode/provisioning/views.py index c74e49fd3..b83a1420f 100644 --- a/breathecode/provisioning/views.py +++ b/breathecode/provisioning/views.py @@ -335,15 +335,16 @@ def render_html_all_bills(request, token): } if not academy_ids: - return render(request, - 'message.html', { - 'MESSAGE': - translation(lang, - en="You don't have the capabilities to read provisioning bills in this academy", - es='No tienes capacidads para leer provisioning bills en esta academia', - slug='no-access') - }, - status=403) + return render( + request, + 'message.html', { + 'MESSAGE': + translation(lang, + en="You don't have the capabilities to read provisioning bills in this academy", + es='No tienes capacidads para leer provisioning bills en esta academia', + slug='no-access') + }, + status=403) status_mapper = {} for key, value in BILL_STATUS: