From ca446530e388ba3d3c1ba5cd34b64c77a1d335cb Mon Sep 17 00:00:00 2001 From: Mara Karagianni Date: Mon, 2 Sep 2024 16:59:50 +0100 Subject: [PATCH] projects: added jwt for api --- adhocracy-plus/config/settings/base.py | 15 ++++++ adhocracy-plus/config/urls.py | 9 +++- apps/projects/serializers.py | 1 + changelog/8305.md | 3 ++ docs/authentication.md | 34 ++++++++++++++ mkdocs.yml | 1 + requirements/base.txt | 1 + tests/account/test_api.py | 33 +++++++++++++ tests/projects/test_app_project_api.py | 64 ++++++++++++++++++++++++++ 9 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 changelog/8305.md create mode 100644 docs/authentication.md diff --git a/adhocracy-plus/config/settings/base.py b/adhocracy-plus/config/settings/base.py index d978abe52..96c32778f 100644 --- a/adhocracy-plus/config/settings/base.py +++ b/adhocracy-plus/config/settings/base.py @@ -2,6 +2,7 @@ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) import os +from datetime import timedelta from django.conf import locale from django.utils.translation import gettext_lazy as _ @@ -33,6 +34,8 @@ "widget_tweaks", "rest_framework", "rest_framework.authtoken", + # JWT authentication + "rest_framework_simplejwt.token_blacklist", "django_filters", "allauth", "allauth.account", @@ -307,7 +310,19 @@ "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.TokenAuthentication", "rest_framework.authentication.SessionAuthentication", + "rest_framework_simplejwt.authentication.JWTAuthentication", ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=5), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "AUTH_HEADER_TYPES": ("Bearer",), } BLEACH_LIST = { diff --git a/adhocracy-plus/config/urls.py b/adhocracy-plus/config/urls.py index 1cb1cd9ab..649224815 100644 --- a/adhocracy-plus/config/urls.py +++ b/adhocracy-plus/config/urls.py @@ -12,6 +12,9 @@ from django_ckeditor_5 import views as ckeditor5_views from rest_framework import routers from rest_framework.authtoken.views import obtain_auth_token +from rest_framework_simplejwt.views import TokenObtainPairView +from rest_framework_simplejwt.views import TokenRefreshView +from rest_framework_simplejwt.views import TokenVerifyView from wagtail.contrib.sitemaps.views import sitemap as wagtail_sitemap from wagtail.documents import urls as wagtaildocs_urls @@ -107,8 +110,12 @@ path("api/", include(comment_router.urls)), path("api/", include(moderation_router.urls)), path("api/", include(router.urls)), - re_path(r"^api/login", obtain_auth_token, name="api-login"), re_path(r"^api/account/", AccountViewSet.as_view(), name="api-account"), + # API JWT authentication + re_path(r"^api/login", obtain_auth_token, name="api-login"), + path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_jwt"), + path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), path( "ckeditor5/image_upload/", user_is_project_admin(ckeditor5_views.upload_file), diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index aa03dae5c..385cb0e73 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -32,6 +32,7 @@ class Meta: model = Project fields = ( "pk", + "slug", "name", "description", "information", diff --git a/changelog/8305.md b/changelog/8305.md new file mode 100644 index 000000000..aa158a22c --- /dev/null +++ b/changelog/8305.md @@ -0,0 +1,3 @@ +### Added + +- django rest framework simplejwt for API authentication with jwt token diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 000000000..0f11010cb --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,34 @@ +# Authentication + +We use [Django Allauth](https://docs.allauth.org/) for handling user registration, email and social logins via the browser. + +For API authentication we setup Django Rest Framework with Simple JWT for authentication via JWT tokens. Existing users can obtain JWT tokens through the TokenObtainPairView. We haven't enabled JWT based registration yet, as this is an implementation for an external partner, and not as part of the software architecture exposing API registration with JWT. + +Login: Users can obtain JWT tokens via the `/api/token/` endpoint. +For testing via the terminal try: +``` +curl -X POST -H "Content-Type: application/json" -d '{"username": "admin","password": "password"}' http://adhocracy.plus/api/token/ + +``` +For testing in dev and stage servers replace the domain name with `http://aplus-dev.liqd.net/api/token/` and `http://aplus-stage.liqd.net/api/token/` respectively. +Make sure to use your corresponding user credentials (username, password). + +The response of the above command, will include an access and a refresh token. + +For accessing the projects list endpoint `/api/app-projects/`, user need to supply the obtained JWT token. +E.g testing via the terminal with: +``` +curl -H "Authorization: Bearer " https://adhocracy.plus/api/app-projects/ +``` + +For accessing a project details, we can lookup the `slug` key from the projects list, and call the endpoint `/api/app-projects/app-testing/`, where the slug is `app-testing`. +E.g for testing in the terminal, we can run: +``` +curl -H "Authorization: Bearer " https://aplus-dev.liqd.net/api/app-projects/app-testing/ +``` + +The expiration time for JWT access token is 5hrs, after which the user can refresh it by calling the `api/token/refresh/` with the `refresh` token obtained during login. +Refresh token expires after 1 day. They expiration times are set in the settings file `adhocracy-plus/config/settings/base.py` as `SIMPLE_JWT`. + +For more info about how to obtain and refresh a token, visit the [django restframework simple jwt](https://django-rest-framework-simplejwt.readthedocs.io/en/latest/getting_started.html#usage) docs. + diff --git a/mkdocs.yml b/mkdocs.yml index f4c65de71..e37dbc235 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,7 @@ nav: - API: - code reference: api.md - Explanations: + - authentication: authentication.md - celery: celery.md - ckeditor: ckeditor.md - images: img_saving_and_deletion.md diff --git a/requirements/base.txt b/requirements/base.txt index 102c8aed0..5b2882a44 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -6,6 +6,7 @@ brotli==1.1.0 django-cloudflare-push==0.2.2 django_csp==3.8 django-parler==2.3 +djangorestframework-simplejwt==5.3.1 sentry-sdk==2.3.1 wagtail==5.2.5 whitenoise==6.6.0 diff --git a/tests/account/test_api.py b/tests/account/test_api.py index 622a466fa..65f95b127 100644 --- a/tests/account/test_api.py +++ b/tests/account/test_api.py @@ -79,3 +79,36 @@ def test_user_can_change_account_settings(apiclient, user): assert user._avatar assert user._avatar.name.endswith(img_name) assert user.username == "changed name" + + +@pytest.mark.django_db +def test_jwt_login_success(apiclient, user): + login_data = { + "username": user.email, + "password": "password", + } + + # Perform the login request + response = apiclient.post(reverse("token_obtain_jwt"), login_data, format="json") + + # Assert that the request was successful + print(response.data) + assert response.status_code == 200 + assert "access" in response.data + assert "refresh" in response.data + + +@pytest.mark.django_db +def test_jwt_login_failure(apiclient, user): + login_data = { + "username": "wronguser", + "password": "wrongpassword", + } + + # Perform the login request + response = apiclient.post(reverse("token_obtain_jwt"), login_data, format="json") + + # Assert that the request failed + assert response.status_code == 401 + assert "access" not in response.data + assert "refresh" not in response.data diff --git a/tests/projects/test_app_project_api.py b/tests/projects/test_app_project_api.py index d6fb04b9a..c6e5456b4 100644 --- a/tests/projects/test_app_project_api.py +++ b/tests/projects/test_app_project_api.py @@ -175,3 +175,67 @@ def test_app_project_serializer( == "Participation ended. Read result." ) assert not response.data[0]["module_running_progress"] + + +@pytest.mark.django_db +def test_app_project_api_with_jwt_auth(user, project_factory, apiclient): + project_1 = project_factory(is_app_accessible=True) + project_2 = project_factory(is_app_accessible=True) + + url = reverse("app-projects-list") + response = apiclient.get(url, format="json") + assert response.status_code == 401 + + # Perform the login request + login_data = { + "username": user.email, + "password": "password", + } + + response = apiclient.post(reverse("token_obtain_jwt"), login_data, format="json") + + access_token = response.data["access"] + apiclient.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + response = apiclient.get(url, format="json") + assert response.status_code == 200 + + assert any( + [ + True + for dict in response.data + if ("pk" in dict and dict["pk"] == project_1.pk) + ] + ) + assert any( + [ + True + for dict in response.data + if ("pk" in dict and dict["pk"] == project_2.pk) + ] + ) + + +@pytest.mark.django_db +def test_retrieve_project_with_jwt_auth(apiclient, user, project_factory): + project = project_factory(is_app_accessible=True) + url = reverse("app-projects-detail", args=[project.slug]) + + # Perform the GET request + response = apiclient.get(url) + assert response.status_code == 401 + + # Perform the login request + login_data = { + "username": user.email, + "password": "password", + } + + response = apiclient.post(reverse("token_obtain_jwt"), login_data, format="json") + + access_token = response.data["access"] + apiclient.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + # Assert that the response is successful + response = apiclient.get(url) + assert response.status_code == 200