diff --git a/api/desecapi/migrations/0008_token_perm_manage_tokens.py b/api/desecapi/migrations/0008_token_perm_manage_tokens.py
new file mode 100644
index 000000000..999f0b736
--- /dev/null
+++ b/api/desecapi/migrations/0008_token_perm_manage_tokens.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.1.2 on 2020-11-06 23:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('desecapi', '0007_email_citext'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='token',
+ name='perm_manage_tokens',
+ field=models.BooleanField(default=True),
+ ),
+ migrations.AlterField(
+ model_name='token',
+ name='perm_manage_tokens',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/api/desecapi/models.py b/api/desecapi/models.py
index 1e3877cb0..2fb8406b2 100644
--- a/api/desecapi/models.py
+++ b/api/desecapi/models.py
@@ -197,6 +197,8 @@ class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models
)
name = models.CharField('Name', blank=True, max_length=64)
last_used = models.DateTimeField(null=True, blank=True)
+ perm_manage_tokens = models.BooleanField(default=False)
+
plain = None
def generate_key(self):
diff --git a/api/desecapi/permissions.py b/api/desecapi/permissions.py
index ee05c069b..c2782aad4 100644
--- a/api/desecapi/permissions.py
+++ b/api/desecapi/permissions.py
@@ -32,6 +32,12 @@ def has_permission(self, request, view):
return ip in IPv4Network('10.8.0.0/24')
+class ManageTokensPermission(permissions.BasePermission):
+
+ def has_permission(self, request, view):
+ return request.auth.perm_manage_tokens
+
+
class WithinDomainLimitOnPOST(permissions.BasePermission):
"""
Permission that requires that the user still has domain limit quota available, if the request is using POST.
diff --git a/api/desecapi/serializers.py b/api/desecapi/serializers.py
index 446fded27..08f5cd717 100644
--- a/api/desecapi/serializers.py
+++ b/api/desecapi/serializers.py
@@ -52,7 +52,7 @@ class TokenSerializer(serializers.ModelSerializer):
class Meta:
model = models.Token
- fields = ('id', 'created', 'last_used', 'name', 'token',)
+ fields = ('id', 'created', 'last_used', 'name', 'perm_manage_tokens', 'token',)
read_only_fields = ('id', 'created', 'last_used', 'token')
def __init__(self, *args, include_plain=False, **kwargs):
diff --git a/api/desecapi/tests/test_tokens.py b/api/desecapi/tests/test_tokens.py
index 4b5590cc0..c38d02ca4 100644
--- a/api/desecapi/tests/test_tokens.py
+++ b/api/desecapi/tests/test_tokens.py
@@ -4,10 +4,12 @@
from desecapi.tests.base import DomainOwnerTestCase
-class TokenTestCase(DomainOwnerTestCase):
+class TokenPermittedTestCase(DomainOwnerTestCase):
def setUp(self):
super().setUp()
+ self.token.perm_manage_tokens = True
+ self.token.save()
self.token2 = self.create_token(self.owner, name='testtoken')
self.other_token = self.create_token(self.user)
@@ -71,3 +73,50 @@ def test_create_token(self):
self.assertIsNone(response.data['last_used'])
self.assertEqual(len(Token.objects.filter(user=self.owner).all()), n + len(datas))
+
+
+class TokenForbiddenTestCase(DomainOwnerTestCase):
+
+ def setUp(self):
+ super().setUp()
+ self.token2 = self.create_token(self.owner, name='testtoken')
+ self.other_token = self.create_token(self.user)
+
+ def test_token_last_used(self):
+ self.assertIsNone(Token.objects.get(pk=self.token.id).last_used)
+ self.client.get(self.reverse('v1:root'))
+ self.assertIsNotNone(Token.objects.get(pk=self.token.id).last_used)
+
+ def test_list_tokens(self):
+ response = self.client.get(self.reverse('v1:token-list'))
+ self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+ def test_delete_my_token(self):
+ for token_id in [Token.objects.get(user=self.owner, name='testtoken').id, self.token.id]:
+ url = self.reverse('v1:token-detail', pk=token_id)
+ response = self.client.delete(url)
+ self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+ def test_retrieve_my_token(self):
+ for token_id in [Token.objects.get(user=self.owner, name='testtoken').id, self.token.id]:
+ url = self.reverse('v1:token-detail', pk=token_id)
+ response = self.client.get(url)
+ self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+ def test_retrieve_other_token(self):
+ token_id = Token.objects.get(user=self.user).id
+ url = self.reverse('v1:token-detail', pk=token_id)
+ response = self.client.get(url)
+ self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+ def test_update_my_token(self):
+ url = self.reverse('v1:token-detail', pk=self.token.id)
+ for method in [self.client.patch, self.client.put]:
+ response = method(url, data={'name': method.__name__})
+ self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+
+ def test_create_token(self):
+ datas = [{}, {'name': ''}, {'name': 'foobar'}]
+ for data in datas:
+ response = self.client.post(self.reverse('v1:token-list'), data=data)
+ self.assertStatus(response, status.HTTP_403_FORBIDDEN)
diff --git a/api/desecapi/views.py b/api/desecapi/views.py
index bde135a70..5a52fb171 100644
--- a/api/desecapi/views.py
+++ b/api/desecapi/views.py
@@ -24,7 +24,7 @@
from desecapi.exceptions import ConcurrencyException
from desecapi.pdns import get_serials
from desecapi.pdns_change_tracker import PDNSChangeTracker
-from desecapi.permissions import IsDomainOwner, IsOwner, IsVPNClient, WithinDomainLimitOnPOST
+from desecapi.permissions import ManageTokensPermission, IsDomainOwner, IsOwner, IsVPNClient, WithinDomainLimitOnPOST
from desecapi.renderers import PlainTextRenderer
@@ -78,7 +78,7 @@ def initial(self, request, *args, **kwargs):
class TokenViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
serializer_class = serializers.TokenSerializer
- permission_classes = (IsAuthenticated,)
+ permission_classes = (IsAuthenticated, ManageTokensPermission,)
throttle_scope = 'account_management_passive'
def get_queryset(self):
@@ -510,7 +510,7 @@ class AccountLoginView(generics.GenericAPIView):
def post(self, request, *args, **kwargs):
user = self.request.user
- token = models.Token.objects.create(user=user, name="login")
+ token = models.Token.objects.create(user=user, name="login", perm_manage_tokens=True)
user_logged_in.send(sender=user.__class__, request=self.request, user=user)
data = serializers.TokenSerializer(token, include_plain=True).data
diff --git a/docs/auth/tokens.rst b/docs/auth/tokens.rst
index bb89343da..7123791d3 100644
--- a/docs/auth/tokens.rst
+++ b/docs/auth/tokens.rst
@@ -3,12 +3,14 @@
Manage Tokens
~~~~~~~~~~~~~
-To make authentication more flexible, the API can provide you with multiple
+To make authentication more flexible, you can create and configure multiple
authentication tokens. To that end, we provide a set of token management
-endpoints that are separate from the above-mentioned log in and log out
-endpoints. The most notable difference is that the log in endpoint needs
-authentication with email address and password, whereas the token management
-endpoint is authenticated using already issued tokens.
+endpoints that are separate from the login and logout endpoints. The most
+notable differences are a) that the login endpoint needs authentication with
+email address and password, whereas the token management endpoint is
+authenticated using an already issued token, and b) that the login endpoint
+always returns a token with a wide range of permissions, whereas the token
+management API allows to exert more fine-grained control.
Retrieving All Current Tokens
@@ -34,12 +36,14 @@ user reference only). Certain API operations will automatically populate the
"id": "3159e485-5499-46c0-ae2b-aeb84d627a8e",
"last_used": "2019-04-29T18:01:09.894594Z",
"name": "login",
+ "perm_manage_tokens": true
},
{
"created": "2018-09-06T08:53:26.428396Z",
"id": "76d6e39d-65bc-4ab2-a1b7-6e94eee0a534",
"last_used": null,
- "name": ""
+ "name": "",
+ "perm_manage_tokens": false
}
]
diff --git a/webapp/src/components/Field/SwitchBox.vue b/webapp/src/components/Field/SwitchBox.vue
new file mode 100644
index 000000000..8c88b8c67
--- /dev/null
+++ b/webapp/src/components/Field/SwitchBox.vue
@@ -0,0 +1,52 @@
+
+
You can create a new API token here. The token is displayed after submitting this form.
Warning: Be sure to protect your tokens appropriately! Knowledge of an API token allows performing actions on your behalf.
'), createSuccess: (item) => `Your new token is:${item.token}