Authorization: Token {{ api_token }} {% trans 'or' %}Authorization: Bearer {{ api_token }} {% trans 'or' %}curl -X GET http://your.domain.com/api/recipes/ -H 'Authorization:
- Token {{ api_token }}'
+ Bearer {{ api_token }}'
diff --git a/cookbook/tests/api/test_api_access_token.py b/cookbook/tests/api/test_api_access_token.py
new file mode 100644
index 000000000..884017c45
--- /dev/null
+++ b/cookbook/tests/api/test_api_access_token.py
@@ -0,0 +1,115 @@
+import json
+
+import pytest
+from django.contrib import auth
+from django.urls import reverse
+from django.utils import timezone
+from django_scopes import scopes_disabled
+from oauth2_provider.models import AccessToken
+
+from cookbook.models import ViewLog
+
+LIST_URL = 'api:accesstoken-list'
+DETAIL_URL = 'api:accesstoken-detail'
+
+
+@pytest.fixture()
+def obj_1(u1_s1):
+ return AccessToken.objects.create(user=auth.get_user(u1_s1), scope='test', expires=timezone.now() + timezone.timedelta(days=365 * 5), token='test1')
+
+
+@pytest.fixture()
+def obj_2(u1_s1):
+ return AccessToken.objects.create(user=auth.get_user(u1_s1), scope='test', expires=timezone.now() + timezone.timedelta(days=365 * 5), token='test2')
+
+
+@pytest.mark.parametrize("arg", [
+ ['a_u', 403],
+ ['g1_s1', 200],
+ ['u1_s1', 200],
+ ['a1_s1', 200],
+])
+def test_list_permission(arg, request):
+ c = request.getfixturevalue(arg[0])
+ assert c.get(reverse(LIST_URL)).status_code == arg[1]
+
+
+def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
+ assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
+ assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
+
+ obj_1.user = auth.get_user(u1_s2)
+ obj_1.save()
+
+ assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
+ assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 1
+
+
+def test_token_visibility(u1_s1, obj_1):
+ # tokens should only be returned on the first API request (first 15 seconds)
+ at = json.loads(u1_s1.get(reverse(DETAIL_URL, args=[obj_1.id])).content)
+ assert at['token'] == obj_1.token
+ with scopes_disabled():
+ obj_1.created = timezone.now() - timezone.timedelta(seconds=16)
+ obj_1.save()
+ at = json.loads(u1_s1.get(reverse(DETAIL_URL, args=[obj_1.id])).content)
+ assert at['token'] != obj_1.token
+
+
+@pytest.mark.parametrize("arg", [
+ ['a_u', 403],
+ ['g1_s1', 404],
+ ['u1_s1', 200],
+ ['a1_s1', 404],
+ ['g1_s2', 404],
+ ['u1_s2', 404],
+ ['a1_s2', 404],
+])
+def test_update(arg, request, obj_1):
+ c = request.getfixturevalue(arg[0])
+ r = c.patch(
+ reverse(
+ DETAIL_URL,
+ args={obj_1.id}
+ ),
+ {'scope': 'lorem ipsum'},
+ content_type='application/json'
+ )
+ assert r.status_code == arg[1]
+
+
+@pytest.mark.parametrize("arg", [
+ ['a_u', 403],
+ ['g1_s1', 201],
+ ['u1_s1', 201],
+ ['a1_s1', 201],
+])
+def test_add(arg, request, u1_s2, u2_s1, recipe_1_s1):
+ c = request.getfixturevalue(arg[0])
+ r = c.post(
+ reverse(LIST_URL),
+ {'scope': 'test', 'expires': timezone.now() + timezone.timedelta(days=365 * 5)},
+ content_type='application/json'
+ )
+ response = json.loads(r.content)
+ assert r.status_code == arg[1]
+ if r.status_code == 201:
+ assert response['scope'] == 'test'
+
+
+def test_delete(u1_s1, u1_s2, obj_1):
+ r = u1_s2.delete(
+ reverse(
+ DETAIL_URL,
+ args={obj_1.id}
+ )
+ )
+ assert r.status_code == 404
+
+ r = u1_s1.delete(
+ reverse(
+ DETAIL_URL,
+ args={obj_1.id}
+ )
+ )
+ assert r.status_code == 204
diff --git a/cookbook/urls.py b/cookbook/urls.py
index 50b204d4a..80e96b648 100644
--- a/cookbook/urls.py
+++ b/cookbook/urls.py
@@ -51,6 +51,7 @@ router.register(r'user', api.UserViewSet)
router.register(r'user-preference', api.UserPreferenceViewSet)
router.register(r'user-space', api.UserSpaceViewSet)
router.register(r'view-log', api.ViewLogViewSet)
+router.register(r'access-token', api.AccessTokenViewSet)
urlpatterns = [
path('', views.index, name='index'),
diff --git a/cookbook/views/api.py b/cookbook/views/api.py
index 3d5252435..cfebfcc0b 100644
--- a/cookbook/views/api.py
+++ b/cookbook/views/api.py
@@ -29,6 +29,7 @@ from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from icalendar import Calendar, Event
from PIL import UnidentifiedImageError
+from oauth2_provider.models import AccessToken
from recipe_scrapers import scrape_html, scrape_me
from recipe_scrapers._exceptions import NoSchemaFoundInWildMode
from requests.exceptions import MissingSchema
@@ -86,7 +87,7 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportListSeri
SupermarketCategorySerializer, SupermarketSerializer,
SyncLogSerializer, SyncSerializer, UnitSerializer,
UserFileSerializer, UserSerializer, UserPreferenceSerializer,
- UserSpaceSerializer, ViewLogSerializer)
+ UserSpaceSerializer, ViewLogSerializer, AccessTokenSerializer)
from cookbook.views.import_export import get_integration
from recipes import settings
@@ -1090,6 +1091,15 @@ class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin):
return super().get_queryset()
+class AccessTokenViewSet(viewsets.ModelViewSet):
+ queryset = AccessToken.objects
+ serializer_class = AccessTokenSerializer
+ permission_classes = [CustomIsOwner]
+
+ def get_queryset(self):
+ return self.queryset.filter(user=self.request.user)
+
+
# -------------- DRF custom views --------------------
class AuthTokenThrottle(AnonRateThrottle):
diff --git a/cookbook/views/views.py b/cookbook/views/views.py
index 514e9d24b..8fb2962f3 100644
--- a/cookbook/views/views.py
+++ b/cookbook/views/views.py
@@ -1,5 +1,6 @@
import os
import re
+import uuid
from datetime import datetime
from uuid import UUID
@@ -18,6 +19,7 @@ from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
+from oauth2_provider.models import AccessToken
from rest_framework.authtoken.models import Token
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingPreferenceForm,
@@ -338,8 +340,8 @@ def user_settings(request):
elif not search_error:
search_form = SearchPreferenceForm()
- if (api_token := Token.objects.filter(user=request.user).first()) is None:
- api_token = Token.objects.create(user=request.user)
+ if (api_token := AccessToken.objects.filter(user=request.user).first()) is None:
+ api_token = AccessToken.objects.create(user=request.user, token=f'tda_{str(uuid.uuid4()).replace("-","_")}', expires=(timezone.now() + timezone.timedelta(days=365*5)), scope='read write').token
# these fields require postgresql - just disable them if postgresql isn't available
if not settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
diff --git a/recipes/settings.py b/recipes/settings.py
index c30de945c..43bd7a0ec 100644
--- a/recipes/settings.py
+++ b/recipes/settings.py
@@ -99,6 +99,7 @@ INSTALLED_APPS = [
'django.contrib.sites',
'django.contrib.staticfiles',
'django.contrib.postgres',
+ 'oauth2_provider',
'django_prometheus',
'django_tables2',
'corsheaders',
@@ -235,10 +236,15 @@ AUTH_PASSWORD_VALIDATORS = [
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
+OAUTH2_PROVIDER = {
+ 'SCOPES': {'read': 'Read scope', 'write': 'Write scope', 'bookmarklet': 'only access to bookmarklet'}
+}
+
+
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
- 'rest_framework.authentication.TokenAuthentication',
+ 'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
'rest_framework.authentication.BasicAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': [
diff --git a/requirements.txt b/requirements.txt
index 657b14afe..d4f597f09 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,6 +7,7 @@ django-crispy-forms==1.14.0
django-tables2==2.4.1
djangorestframework==3.13.1
drf-writable-nested==0.6.4
+django-oauth-toolkit==2.1.0
bleach==5.0.1
bleach-allowlist==1.0.3
gunicorn==20.1.0
diff --git a/vue/src/components/Settings/APISettingsComponent.vue b/vue/src/components/Settings/APISettingsComponent.vue
index d96cc8882..da5af7f15 100644
--- a/vue/src/components/Settings/APISettingsComponent.vue
+++ b/vue/src/components/Settings/APISettingsComponent.vue
@@ -1,54 +1,77 @@
- Authorization: Bearer TOKEN orcurl -X GET http://your.domain.com/api/recipes/ -H 'Authorization:
+ Bearer TOKEN'
+