diff --git a/Pipfile b/Pipfile index 6a7009047..19f720a83 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/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/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..1ee76ae70 --- /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-05 03:23 + +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..5b77d8329 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,25 @@ def serialize_task(task): cohort_user.history_log = user_history_log cohort_user.save() + s = None + 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 654517562..cf3074183 100644 --- a/breathecode/assignments/urls.py +++ b/breathecode/assignments/urls.py @@ -1,9 +1,8 @@ -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 (AcademyTaskCodeRevisionView, MeCodeRevisionView, MeTaskCodeRevisionView, TaskMeView, + sync_cohort_tasks_view, TaskTeacherView, deliver_assignment_view, TaskMeDeliverView, + FinalProjectMeView, CohortTaskView, SubtaskMeView, TaskMeAttachmentView, + FinalProjectScreenshotView) app_name = 'assignments' urlpatterns = [ @@ -16,6 +15,13 @@ 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('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 cee7a063e..7ec91ebd0 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 @@ -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,92 @@ def put(self, request, task_id): item.save() return Response(item.subtasks) + + +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', 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).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', 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 86f583d76..d0d35b99b 100644 --- a/breathecode/authenticate/actions.py +++ b/breathecode/authenticate/actions.py @@ -1,24 +1,31 @@ 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 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, +from .models import (App, CredentialsGithub, DeviceId, GitpodUser, ProfileAcademy, Role, Token, UserSetting, AcademyAuthSettings, GithubAcademyUser) logger = logging.getLogger(__name__) @@ -635,3 +642,211 @@ def sync_organization_members(academy_id, only_status=[]): # _member.log('Error inviting member to organization') # _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': '4geeks', + 'aud': app.slug, + 'exp': datetime.timestamp(now + timedelta(minutes=JWT_LIFETIME)), + 'iat': datetime.timestamp(now) - 1, + 'typ': 'JWT', + } + + if reverse: + payload['app'] = app.slug + payload['aud'] = '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') + + 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): + now = timezone.now().isoformat() + + payload = { + 'timestamp': now, + 'app': '4geeks', + 'method': method.upper(), + 'params': params or {}, + 'body': body, + 'headers': headers or {}, + } + + 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() + + 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() + + 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_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 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', + 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, Scope + + app = App.objects.filter(slug=app_slug).first() + + if app is None: + raise ValidationException('Unauthorized', code=401, slug='app-not-found') + + if app.algorithm == 'HMAC_SHA256': + alg = 'HS256' + + elif app.algorithm == 'HMAC_SHA512': + alg = 'HS512' + + elif app.algorithm == 'ED25519': + alg = 'EdDSA' + + else: + raise ValidationException('Algorithm not implemented', code=401, slug='algorithm-not-implemented') + + 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) if app.legacy_key.public_key else None + 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, + app.require_an_agreement, + 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, + ) + 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.cache_clear() + get_app_keys.cache_clear() + get_optional_scopes_set.cache_clear() + + +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 d8c31ffe0..2adb27edb 100644 --- a/breathecode/authenticate/admin.py +++ b/breathecode/authenticate/admin.py @@ -1,15 +1,15 @@ 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, 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, 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 @@ -444,3 +444,57 @@ def authenticate(self, obj): return format_html( f"connect owner" ) + + +@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'] + + +@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 LegacyKeyAdmin(admin.ModelAdmin): + list_display = ('app', 'algorithm', 'strategy', 'schema') + 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/management/commands/sign_jwt.py b/breathecode/authenticate/management/commands/sign_jwt.py new file mode 100644 index 000000000..c6a7ff864 --- /dev/null +++ b/breathecode/authenticate/management/commands/sign_jwt.py @@ -0,0 +1,31 @@ +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 = '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 + from ...actions import get_jwt + + if not options['app']: + raise Exception('Missing app id') + + 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) + + 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 new file mode 100644 index 000000000..74b2aba6b --- /dev/null +++ b/breathecode/authenticate/management/commands/sign_request.py @@ -0,0 +1,49 @@ +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['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 {} + + 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'], + params=options['params'], + body=options['body'], + headers=options['headers'], + reverse=True) + + 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/migrations/0042_auto_20230805_0323.py b/breathecode/authenticate/migrations/0042_auto_20230805_0323.py new file mode 100644 index 000000000..649846645 --- /dev/null +++ b/breathecode/authenticate/migrations/0042_auto_20230805_0323.py @@ -0,0 +1,178 @@ +# Generated by Django 3.2.20 on 2023-08-05 03:23 + +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', '0041_auto_20230725_0322'), + ] + + operations = [ + migrations.CreateModel( + name='App', + fields=[ + ('id', + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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')], + 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)), + ('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()), + ('app_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.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( + 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')), + ('agreed_at', models.DateTimeField()), + ('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.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( + model_name='app', + name='required_scopes', + field=models.ManyToManyField(blank=True, + related_name='app_required_scopes', + through='authenticate.AppRequiredScope', + to='authenticate.Scope'), + ), + ] diff --git a/breathecode/authenticate/models.py b/breathecode/authenticate/models.py index 5460e70ed..a5962f295 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 @@ -11,12 +12,12 @@ from django.core.validators import RegexValidator from django.contrib.contenttypes.models import ContentType from django import forms +from slugify import slugify 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__ = [ @@ -111,6 +112,276 @@ def __str__(self): return f'{self.name} ({self.slug})' +class Scope(models.Model): + 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: + self.slug = slugify(self.name) + + 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 (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"') + + 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().__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.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, 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 r2epresent how the apps will communicate') + + 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') + + 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=True, 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, 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) + + if self.app_url.endswith('/'): + self.app_url = self.app_url[:-1] + + 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 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 + + 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() + + if had_pk: + reset_app_cache() + + 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 + + +class AppRequiredScope(models.Model): + 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): + 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='m2m_optional_scopes') + scope = models.ForeignKey(Scope, on_delete=models.CASCADE, related_name='m2m_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) + + 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).exclude(id=self.id).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_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') + agreed_at = models.DateTimeField() + + 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) + + +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.app.name} ({self.app.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): + from breathecode.authenticate import tasks + + self.full_clean() + super().save(*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' REJECTED = 'REJECTED' diff --git a/breathecode/authenticate/receivers.py b/breathecode/authenticate/receivers.py index 4962dc2ba..1f9a93dfb 100644 --- a/breathecode/authenticate/receivers.py +++ b/breathecode/authenticate/receivers.py @@ -4,11 +4,11 @@ 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 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, AppOptionalScope, AppRequiredScope, AppUserAgreement, ProfileAcademy from breathecode.mentorship.models import MentorProfile from django.db.models import Q from django.utils import timezone @@ -108,3 +108,35 @@ 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(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() + + +@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(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() + + +@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/serializers.py b/breathecode/authenticate/serializers.py index ffab4fae4..c10e1e88d 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,35 @@ def get_github(self, obj): return None 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. + + 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/tasks.py b/breathecode/authenticate/tasks.py index 456629270..5f47bf221 100644 --- a/breathecode/authenticate/tasks.py +++ b/breathecode/authenticate/tasks.py @@ -1,7 +1,8 @@ import logging, os from celery import shared_task, Task -from .models import UserInvite, Token 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 @@ -35,6 +36,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()): @@ -73,3 +76,10 @@ 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}' }) + + +@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/templates/authorize.html b/breathecode/authenticate/templates/authorize.html new file mode 100644 index 000000000..70a7371ab --- /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=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" %} + {% 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/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 new file mode 100644 index 000000000..911ef70f1 --- /dev/null +++ b/breathecode/authenticate/tests/urls/tests_app_user_id.py @@ -0,0 +1,171 @@ +""" +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_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): + 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_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..74d6d0f16 --- /dev/null +++ b/breathecode/authenticate/tests/urls/tests_authorize_slug.py @@ -0,0 +1,647 @@ +""" +Test cases for /academy/:id/member/:id +""" +from datetime import timedelta +import os +import random +from unittest.mock import MagicMock, patch +from django.template import loader +from django.urls.base import reverse_lazy +from rest_framework import status +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): + 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 GetTestSuite(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) + + +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/urls.py b/breathecode/authenticate/urls.py index d2dc68a39..959b6ca06 100644 --- a/breathecode/authenticate/urls.py +++ b/breathecode/authenticate/urls.py @@ -15,10 +15,11 @@ """ 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, +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, @@ -104,4 +105,13 @@ # sync with gitPOD 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_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 b621388f9..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) @@ -39,24 +39,29 @@ 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 +from breathecode.utils.service import Service 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 .serializers import (AuthSerializer, GetGitpodUserSerializer, GetProfileAcademySerializer, - GetProfileAcademySmallSerializer, GetProfileSerializer, GitpodUserSmallSerializer, - MemberPOSTSerializer, MemberPUTSerializer, ProfileAcademySmallSerializer, - ProfileSerializer, RoleBigSerializer, RoleSmallSerializer, StudentPOSTSerializer, +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, @@ -2287,3 +2292,167 @@ def delete(self, request): instance.delete() return Response(None, status=status.HTTP_204_NO_CONTENT) + + +# app/user/:id +class AppUserView(APIView): + permission_classes = [AllowAny] + extensions = APIViewExtensions(paginate=True) + + @scope(['read:user']) + def get(self, request, app: dict, token: dict, user_id=None): + 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) + + +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]) +@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'}) + + +@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, + status=404) + + if not app.require_an_agreement: + 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(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(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': + 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() + 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) + + 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() + + else: + agreement = AppUserAgreement.objects.create(app=app, + user=request.user, + agreed_at=timezone.now(), + 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..cc8cab72d --- /dev/null +++ b/breathecode/commons/templates/scopes.html @@ -0,0 +1,84 @@ +{% if scopes|length %} +
+
+

+ +

+
+
+ {% for scope in scopes %} +
+
+ +
+ {{scope.name}} {% if new_scopes and scope.slug in new_scopes %} + + New + {% endif %} + +
+
+
+ +
+ {% if disabled %} + + {% elif selected_scopes and scope.slug in selected_scopes %} + + {% elif selected_scopes %} + + + {% 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..36e0cea29 --- /dev/null +++ b/breathecode/commons/templatetags/scopes.py @@ -0,0 +1,16 @@ +# 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, selected_scopes=[], new_scopes=[]): + return { + 'scopes': scopes, + 'id': id, + 'title': title, + 'disabled': disabled, + 'selected_scopes': selected_scopes, + 'new_scopes': new_scopes, + } 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..9d1b8c58d 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,13 @@ 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, + app_required_scope=False, + app_optional_scope=False, profile_kwargs={}, device_id_kwargs={}, capability_kwargs={}, @@ -97,6 +104,78 @@ def generate_authenticate_models(self, **role_kwargs }) + if not 'scope' in models and (is_valid(scope) or is_valid(app_required_scope) + or is_valid(app_optional_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) + or is_valid(app_required_scope) or is_valid(app_optional_scope)): + kargs = { + 'public_key': None, + 'private_key': '', + } + + models['app'] = create_models(app, 'authenticate.App', **kargs) + + if not 'app_required_scope' in models and is_valid(app_required_scope): + kargs = {} + + if 'app' in models: + kargs['app'] = just_one(models['app']) + + if 'scope' in models: + kargs['scope'] = just_one(models['scope']) + + models['app_required_scope'] = create_models(app_required_scope, 'authenticate.AppRequiredScope', + **kargs) + + if not 'app_optional_scope' in models and is_valid(app_optional_scope): + kargs = {} + + if 'app' in models: + kargs['app'] = just_one(models['app']) + + if 'scope' in models: + kargs['scope'] = just_one(models['scope']) + + models['app_optional_scope'] = create_models(app_optional_scope, 'authenticate.AppOptionalScope', + **kargs) + + if not 'optional_scope_set' in models and (is_valid(optional_scope_set) + or is_valid(app_user_agreement)): + kargs = {} + + 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'] = just_one(models['user']) + + if 'app' in models: + kargs['app'] = just_one(models['app']) + + if 'optional_scope_set' in models: + kargs['optional_scope_set'] = just_one(models['optional_scope_set']) + + 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'] = just_one(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/__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/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..ff9662401 --- /dev/null +++ b/breathecode/utils/decorators/scope.py @@ -0,0 +1,246 @@ +from datetime import datetime, timedelta +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 + +__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.actions import get_app_keys, get_user_scopes + + try: + authorization = dict([x.split('=') for x in authorization.split(',')]) + + except: + raise ValidationException('Unauthorized', code=401, slug='authorization-header-malformed') + + if sorted(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, require_an_agreement, required_app_scopes, optional_app_scopes, + webhook_url, redirect_url, app_url) = info + public_key, private_key = key + + 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], audience='4geeks') + + except Exception as e: + if not legacy_key: + raise ValidationException('Unauthorized', code=401, slug='wrong-app-token') + + if not payload: + 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=[alg]) + + except Exception as e: + raise ValidationException('Unauthorized', code=401, slug='wrong-legacy-app-token') + + if payload['sub'] and 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') + + if 'exp' not in payload or payload['exp'] < timezone.now().timestamp(): + raise ValidationException('Expired token', code=401, slug='expired') + + 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, + 'app_url': app_url, + } + + return app, payload + + +def get_payload(app, date, signed_headers, request): + headers = dict(request.headers) + headers.pop('Authorization', None) + payload = { + '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}, + } + + return payload + + +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') + + 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, get_user_scopes + + try: + authorization = dict([x.split('=') for x in authorization.split(',')]) + + except: + raise ValidationException('Unauthorized', code=401, slug='authorization-header-malformed') + + if sorted(authorization.keys()) != ['App', 'Date', 'Nonce', 'SignedHeaders']: + raise ValidationException('Unauthorized', code=401, slug='authorization-header-bad-schema') + + 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, app_url) = info + public_key, private_key = key + + 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') + + 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(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') + + 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') + + try: + date = datetime.fromisoformat(authorization['Date']) + date = date.replace(tzinfo=timezone.utc) + now = timezone.now() + if (now - timedelta(minutes=TOLERANCE) > date) or (now + timedelta(minutes=TOLERANCE) < date): + raise Exception() + + except Exception as e: + raise ValidationException('Unauthorized', code=401, slug='bad-timestamp') + + 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, + 'app_url': app_url, + } + + return app + + +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: + + 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 mode != 'signature': + authorization = authorization.replace('Link ', '') + app, token = link_schema(request, scopes, authorization, mode == 'signature') + return function(*args, **kwargs, token=AttrDict(**token), app=AttrDict(**app)) + + elif authorization.startswith('Signature ') and mode != 'jwt': + authorization = authorization.replace('Signature ', '') + app = signature_schema(request, scopes, authorization, mode == 'signature') + return function(*args, **kwargs, app=AttrDict(**app)) + + else: + raise ValidationException('Unknown auth schema or this schema is forbidden', + code=401, + slug='unknown-auth-schema') + + return wrapper + + return decorator diff --git a/breathecode/utils/service.py b/breathecode/utils/service.py new file mode 100644 index 000000000..c6cfb53b5 --- /dev/null +++ b/breathecode/utils/service.py @@ -0,0 +1,102 @@ +from __future__ import annotations +from typing import Optional +import requests + +__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 + + 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) + + sign, now = get_signature(self.app, + self.user_pk, + method=method, + params=params, + body=data if data is not None else json, + headers=headers) + + headers['Authorization'] = (f'Signature App=4geeks,' + f'Nonce={sign},' + f'SignedHeaders={";".join(headers.keys())},' + f'Date={now}') + + return headers + + 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=4geeks,' + f'Token={token}') + + return headers + + def _authenticate(self, method, params=None, data=None, json=None, **kwargs) -> requests.Request: + if self.mode == 'signature' or self.app.strategy == 'SIGNATURE': + return self._sign(method, params=params, data=data, json=json, **kwargs) + + elif self.mode == 'jwt' or self.app.strategy == 'JWT': + return self._jwt(method, **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) 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): 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 000000000..23d9cc3b9 Binary files /dev/null and b/docs/images/codespaces.png differ diff --git a/docs/index.md b/docs/index.md index 0a640fefb..0251bb06e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,65 +1,30 @@ # Getting started -## Working inside Docker (slower) - -### `Build 4Geeks Dev docker image` +## Install Docker 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`. -```bash -# Check which dependencies you need install in your operating system -python -m scripts.doctor - -# Generate the 4Geeks Dev docker image -docker-compose build bc-dev -``` - -### `Testing inside 4Geeks Dev` - -```bash -# Open the 4Geeks Dev, this shell don't export the port 8000 -docker-compose run bc-dev fish - -# Testing -pipenv run test ./breathecode/activity # path - -# Testing in parallel -pipenv run ptest ./breathecode/activity # path - -# Coverage -pipenv run cov breathecode.activity # python module path - -# Coverage in parallel -pipenv run pcov breathecode.activity # python module path -``` +## Running 4geeks ### `Run 4Geeks API as docker service` ```bash # open 4Geeks API as a service and export the port 8000 -docker-compose up -d bc-dev - -# open the 4Geeks Dev, this shell don't export the port 8000 -docker-compose run bc-dev fish +docker-compose up -d # create super user -pipenv run python manage.py createsuperuser - -# Close the 4Geeks Dev -exit +sudo docker compose run 4geeks python manage.py createsuperuser # See the output of Django -docker-compose logs -f bc-dev +docker-compose logs -f 4geeks # open localhost:8000 to view the api # open localhost:8000/admin to view the admin ``` -## Working in your local machine (recomended) +### `Run 4Geeks in your local machine` -### `Installation 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 @@ -72,23 +37,9 @@ docker-compose up -d redis postgres python -m scripts.install ``` -### `Testing in your local machine` - -```bash -# Testing -pipenv run test ./breathecode/activity # path - -# Testing in parallel -pipenv run ptest ./breathecode/activity # path - -# Coverage -pipenv run cov breathecode.activity # python module path +#### Run 4Geeks API -# Coverage in parallel -pipenv run pcov breathecode.activity # python module path -``` - -### `Run 4Geeks API in your local machine` +You must up Redis and Postgres before open 4Geeks. ```bash # Collect statics @@ -109,3 +60,31 @@ pipenv run start # open localhost:8000 to view the api # open localhost:8000/admin to view the admin ``` + +### `Testing in your local machine` + +#### Installation + +```bash +# Check which dependencies you need install in your operating system +python -m scripts.doctor + +# Install and setting up your development environment (this command replace your .env file) +python -m scripts.install +``` + +#### Run tests + +```bash +# Testing +pipenv run test ./breathecode/activity # path + +# Testing in parallel +pipenv run ptest ./breathecode/activity # path + +# Coverage +pipenv run cov breathecode.activity # python module path + +# Coverage in parallel +pipenv run pcov breathecode.activity # python module path +``` diff --git a/docs/security/authentication-class.md b/docs/security/authentication-class.md new file mode 100644 index 000000000..ed6c7b939 --- /dev/null +++ b/docs/security/authentication-class.md @@ -0,0 +1,28 @@ +# Authentication classes + +## `ExpiringTokenAuthentication` + +it's the default authentication class implemented in 4Geeks. + +ExpiringTokenAuthentication is integral to the security of our Django web application. Its main purpose is to handle user authentication, i.e., verifying that a user is who they claim to be. + +```vbnet +GET /v1/resource/path HTTP/1.1 +Host: www.example.com +Authorization: Token your_token_here +``` + +The ExpiringTokenAuthentication class within it provides a specific type of token-based authentication. Unlike simple token-based authentication where tokens remain valid indefinitely, this class ensures that tokens expire after a certain period (24 hours in this case). This means that even if an attacker manages to get hold of a token, they can only use it for a limited time. + +Upon receiving a request, the system checks the provided token, confirms it's valid and hasn't expired, and identifies the user associated with the token. If any of these checks fail, an AuthenticationFailed exception is raised, denying access. + +```http +POST /v1/auth/login HTTP/1.1 +Host: www.example.com +Content-Type: application/json + +{ + "username": "john.doe", + "password": "supersecretpassword" +} +``` diff --git a/docs/security/capabilities.md b/docs/security/capabilities.md index a65285c94..84e59c90c 100644 --- a/docs/security/capabilities.md +++ b/docs/security/capabilities.md @@ -2,10 +2,15 @@ Authenticated users must belong to at least one academy with a specific role, each role has a series of capabilities that specify what any user with that role will be "capable" of doing. +```cypher +ExpiringTokenAuthentication -> @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'