diff --git a/.github/workflows/docker-base.yml b/.github/workflows/docker-base.yml index 38f67b4a1..578f45a0a 100644 --- a/.github/workflows/docker-base.yml +++ b/.github/workflows/docker-base.yml @@ -17,10 +17,10 @@ jobs: uses: actions/checkout@v3 - name: Set up QEMU - uses: docker/setup-qemu-action@v2.0.0 + uses: docker/setup-qemu-action@v2.1.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.0.0 + uses: docker/setup-buildx-action@v2.1.0 - name: Login to DockerHub uses: docker/login-action@v2.0.0 @@ -29,7 +29,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build base image - uses: docker/build-push-action@v3.1.1 + uses: docker/build-push-action@v3.2.0 with: context: . push: true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bf605c010..82ff90afe 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -6,17 +6,18 @@ on: - master jobs: - path-context: + apache: + name: Build apache image runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Set up QEMU - uses: docker/setup-qemu-action@v2.0.0 + uses: docker/setup-qemu-action@v2.1.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.0.0 + uses: docker/setup-buildx-action@v2.1.0 - name: Login to DockerHub uses: docker/login-action@v2.0.0 @@ -24,20 +25,39 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build apache image - uses: docker/build-push-action@v3.1.1 + - name: Build image + uses: docker/build-push-action@v3.2.0 with: context: . push: true file: extras/docker/demo/Dockerfile platforms: linux/amd64,linux/arm64 - tags: wger/demo:latest,wger/demo:2.1-dev,wger/apache:latest,wger/apache:2.1-dev + tags: wger/demo:latest,wger/demo:2.2-dev,wger/apache:latest,wger/apache:2.2-dev - - name: Build dev image - uses: docker/build-push-action@v3.1.1 + prod: + name: Build production image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2.1.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2.1.0 + + - name: Login to DockerHub + uses: docker/login-action@v2.0.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build image + uses: docker/build-push-action@v3.2.0 with: context: . push: true file: extras/docker/development/Dockerfile platforms: linux/amd64,linux/arm64 - tags: wger/server:latest,wger/server:2.1-dev,wger/devel:latest,wger/devel:2.1-dev + tags: wger/server:latest,wger/server:2.2-dev,wger/devel:latest,wger/devel:2.2-dev diff --git a/extras/docker/development/settings.py b/extras/docker/development/settings.py index f63cae05f..b3c5a72b4 100644 --- a/extras/docker/development/settings.py +++ b/extras/docker/development/settings.py @@ -44,7 +44,7 @@ TIME_ZONE = env.str("TIME_ZONE", 'Europe/Berlin') # Make this unique, and don't share it with anybody. -SECRET_KEY = env.str("SECRET_KEY", 'wger-django-secret-key') +SECRET_KEY = env.str("SECRET_KEY", 'wger-docker-supersecret-key-1234567890!@#$%^&*(-_)') # Your reCaptcha keys @@ -122,3 +122,10 @@ AXES_FAILURE_LIMIT = 5 # configurable, default is 5 AXES_COOLOFF_TIME = 0.5 # configurable, default is 0.5 hours AXES_HANDLER = 'axes.handlers.cache.AxesCacheHandler' # Configurable, but default is the cache handler + +# +# Django Rest Framework SimpleJWT +# +SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'] = timedelta(minutes=env.int("ACCESS_TOKEN_LIFETIME", 15)) +SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'] = timedelta(hours=env.int("REFRESH_TOKEN_LIFETIME", 24)) +SIMPLE_JWT['SIGNING_KEY'] = env.str("SIGNING_KEY", SECRET_KEY) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f6bea32b8..810e6a997 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,13 +12,13 @@ django-activity-stream~=1.4 django-axes==5.39.0 django-crispy-forms~=1.14 django-simple-history~=3.1 -django-email-verification~=0.1.0 +django-email-verification~=0.3.1 django_compressor~=4.1 django_extensions~=3.2 django-storages~=1.13 django-environ==0.9.0 easy-thumbnails==2.8.3 -fontawesomefree~=6.1.1 +fontawesomefree~=6.2.0 icalendar==4.1.0 invoke==1.7.3 pillow==9.2.0 @@ -33,6 +33,7 @@ requests==2.28.1 django-cors-headers==3.13.0 django-filter==22.1 djangorestframework~=3.14 +djangorestframework-simplejwt[crypto]==5.2.1 # Not used anymore, but needed because some modules are imported in DB migration # files diff --git a/wger/core/api/views.py b/wger/core/api/views.py index d77d8044b..c947a1990 100644 --- a/wger/core/api/views.py +++ b/wger/core/api/views.py @@ -198,6 +198,7 @@ def get(request): class UserAPILoginView(viewsets.ViewSet): """ API endpoint for api user objects + .. warning:: This endpoint is deprecated """ permission_classes = (AllowAny, ) queryset = User.objects.all() @@ -205,7 +206,15 @@ class UserAPILoginView(viewsets.ViewSet): throttle_scope = 'login' def get(self, request): - return Response({'message': "You must send a 'username' and 'password' via POST"}) + return Response( + data={ + 'message': "You must send a 'username' and 'password' via POST", + 'warning': "This endpoint is deprecated." + }, + headers={ + "Deprecation": "Sat, 01 Oct 2022 23:59:59 GMT", + }, + ) def post(self, request): data = request.data @@ -223,7 +232,16 @@ def post(self, request): ) token = create_token(form.get_user()) - return Response({'token': token.key}, status=status.HTTP_200_OK) + return Response( + data={ + 'token': token.key, + 'message': "This endpoint is deprecated." + }, + status=status.HTTP_200_OK, + headers={ + "Deprecation": "Sat, 01 Oct 2022 23:59:59 GMT", + } + ) class UserAPIRegistrationViewSet(viewsets.ViewSet): diff --git a/wger/exercises/models/exercise.py b/wger/exercises/models/exercise.py index bf80d8631..0db2ff9f2 100644 --- a/wger/exercises/models/exercise.py +++ b/wger/exercises/models/exercise.py @@ -116,13 +116,12 @@ def get_absolute_url(self): """ Returns the canonical URL to view an exercise """ - return reverse( - 'exercise:exercise:view-base', - kwargs={ - 'pk': self.exercise_base_id, - 'slug': slugify(self.name) - } - ) + slug_name = slugify(self.name) + kwargs = {'pk': self.exercise_base_id} + if slug_name: + kwargs['slug'] = slug_name + + return reverse('exercise:exercise:view-base', kwargs=kwargs) def save(self, *args, **kwargs): """ diff --git a/wger/exercises/tests/test_exercise_model.py b/wger/exercises/tests/test_exercise_model.py new file mode 100644 index 000000000..947027633 --- /dev/null +++ b/wger/exercises/tests/test_exercise_model.py @@ -0,0 +1,38 @@ +# This file is part of wger Workout Manager. +# +# wger Workout Manager is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# wger Workout Manager is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License + +# wger +from wger.core.tests.base_testcase import WgerTestCase +from wger.exercises.models import Exercise + + +class ExerciseModelTestCase(WgerTestCase): + """ + Test the logic in the exercise model + """ + + def test_absolute_url_name(self): + """Test that the get_absolute_url returns the correct URL""" + exercise = Exercise(exercise_base_id=1, description='abc', name='foo') + self.assertEqual(exercise.get_absolute_url(), '/en/exercise/1/view-base/foo') + + def test_absolute_url_no_name(self): + """Test that the get_absolute_url returns the correct URL""" + exercise = Exercise(exercise_base_id=2, description='abc', name='') + self.assertEqual(exercise.get_absolute_url(), '/en/exercise/2/view-base') + + def test_absolute_url_no_name2(self): + """Test that the get_absolute_url returns the correct URL""" + exercise = Exercise(exercise_base_id=42, description='abc', name='@@@@@') + self.assertEqual(exercise.get_absolute_url(), '/en/exercise/42/view-base') diff --git a/wger/settings_global.py b/wger/settings_global.py index 8130583b0..c7afab45e 100644 --- a/wger/settings_global.py +++ b/wger/settings_global.py @@ -18,6 +18,7 @@ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) import os import re +from datetime import timedelta """ @@ -82,6 +83,7 @@ 'rest_framework', 'rest_framework.authtoken', 'django_filters', + 'rest_framework_simplejwt', # Breadcrumbs 'django_bootstrap_breadcrumbs', @@ -418,6 +420,7 @@ 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.TokenAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', ), 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', @@ -429,6 +432,17 @@ } } +# +# Django Rest Framework SimpleJWT +# +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': False, + 'BLACKLIST_AFTER_ROTATION': False, + 'UPDATE_LAST_LOGIN': False, +} + # # CORS headers: allow all hosts to access the API # @@ -489,8 +503,8 @@ def email_verified_callback(user): EMAIL_MAIL_SUBJECT = 'Confirm your email' EMAIL_MAIL_HTML = 'email_verification/email_body_html.tpl' EMAIL_MAIL_PLAIN = 'email_verification/email_body_txt.tpl' -EMAIL_TOKEN_LIFE = 60 * 60 -EMAIL_PAGE_TEMPLATE = 'email_verification/confirm_template.html' +EMAIL_MAIL_TOKEN_LIFE = 60 * 60 +EMAIL_MAIL_PAGE_TEMPLATE = 'email_verification/confirm_template.html' EMAIL_PAGE_DOMAIN = 'http://localhost:8000/' # diff --git a/wger/software/templates/api.html b/wger/software/templates/api.html index 911812779..1c7415351 100644 --- a/wger/software/templates/api.html +++ b/wger/software/templates/api.html @@ -16,11 +16,47 @@

Authentication

objects such as workouts, you need to generate an API KEY and pass it in the header, see the link on the sidebar for details.

-

You can also generate a token via the login endpoint. Send a -username and password and you will get the user's token or a new one will be -generated. At the moment it is not possible to register via the API.

+
JWT Authentication
+ +

+You can generate access token via /token/ endpoint. Send a username and password, and you will get the +access token which you can use to access the private endpoints. +

+curl \
+  -X POST \
+  -H "Content-Type: application/json" \
+  -d '{"username": "example_username", "password": "example_password "}' \
+  https://wger.de/api/v2/token/
+
+...
+{
+  "access":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNDU2LCJqdGkiOiJmZDJmOWQ1ZTFhN2M0MmU4OTQ5MzVlMzYyYmNhOGJjYSJ9.NHlztMGER7UADHZJlxNG0WSi22a2KaYSfd1S-AuT7lU",
+  "refresh":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImNvbGRfc3R1ZmYiOiLimIMiLCJleHAiOjIzNDU2NywianRpIjoiZGUxMmY0ZTY3MDY4NDI3ODg5ZjE1YWMyNzcwZGEwNTEifQ.aEoAYkSJjoWH1boshQAaTkf8G3yn0kapko6HFRt7Rh4"
+}
+
+ +

Additionally, you can send an access token to /token/verify/ endpoint to verify that token.

+ +

When this short-lived access token expires, you can use the longer-lived refresh +token to obtain another access token. +

+curl \
+  -X POST \
+  -H "Content-Type: application/json" \
+  -d '{"refresh":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImNvbGRfc3R1ZmYiOiLimIMiLCJleHAiOjIzNDU2NywianRpIjoiZGUxMmY0ZTY3MDY4NDI3ODg5ZjE1YWMyNzcwZGEwNTEifQ.aEoAYkSJjoWH1boshQAaTkf8G3yn0kapko6HFRt7Rh4"}' \
+  https://wger.de/api/v2/token/refresh/
+
+...
+{"access":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3BrIjoxLCJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiY29sZF9zdHVmZiI6IuKYgyIsImV4cCI6MTIzNTY3LCJqdGkiOiJjNzE4ZTVkNjgzZWQ0NTQyYTU0NWJkM2VmMGI0ZGQ0ZSJ9.ekxRxgb9OKmHkfy-zs1Ro_xs1eMLXiR17dIDBVxeT-w"}
+
+

+

You should always use HTTPS if possible when communicating with the server.

+

At the moment it is not possible to register via the API.

+

Deprecated: You can also generate a token via the login endpoint. Send a +username and password, and you will get the user's token or a new one will be +generated.

diff --git a/wger/urls.py b/wger/urls.py index 8cbd71573..f5a391c60 100644 --- a/wger/urls.py +++ b/wger/urls.py @@ -30,6 +30,11 @@ # Third Party from django_email_verification import urls as email_urls from rest_framework import routers +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) # wger from wger.core.api import views as core_api_views @@ -44,7 +49,7 @@ from wger.weight.api import views as weight_api_views -#admin.autodiscover() +# admin.autodiscover() # # REST API @@ -204,7 +209,7 @@ # The actual URLs # urlpatterns = i18n_patterns( - #url(r'^admin/', admin.site.urls), + # url(r'^admin/', admin.site.urls), path('', include(('wger.core.urls', 'core'), namespace='core')), path('workout/', include(('wger.manager.urls', 'manager'), namespace='manager')), path('exercise/', include(('wger.exercises.urls', 'exercise'), namespace='exercise')), @@ -244,6 +249,9 @@ core_api_views.UserAPIRegistrationViewSet.as_view({'post': 'post'}), name='api_register' ), + path('api/v2/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/v2/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('api/v2/token/verify/', TokenVerifyView.as_view(), name='token_verify'), # Others path(