Skip to content

Commit

Permalink
projects: added jwt for api
Browse files Browse the repository at this point in the history
  • Loading branch information
m4ra committed Sep 3, 2024
1 parent 9f1f12d commit ca44653
Show file tree
Hide file tree
Showing 9 changed files with 160 additions and 1 deletion.
15 changes: 15 additions & 0 deletions adhocracy-plus/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand Down Expand Up @@ -33,6 +34,8 @@
"widget_tweaks",
"rest_framework",
"rest_framework.authtoken",
# JWT authentication
"rest_framework_simplejwt.token_blacklist",
"django_filters",
"allauth",
"allauth.account",
Expand Down Expand Up @@ -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 = {
Expand Down
9 changes: 8 additions & 1 deletion adhocracy-plus/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions apps/projects/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Meta:
model = Project
fields = (
"pk",
"slug",
"name",
"description",
"information",
Expand Down
3 changes: 3 additions & 0 deletions changelog/8305.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- django rest framework simplejwt for API authentication with jwt token
34 changes: 34 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
@@ -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 <access token from the response>" 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 <access token from the response>" 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.

1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions tests/account/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
64 changes: 64 additions & 0 deletions tests/projects/test_app_project_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit ca44653

Please sign in to comment.