From 40a0dfa49f963edec9d853c022099f2bcb4d8c54 Mon Sep 17 00:00:00 2001 From: Peter Thomassen Date: Sat, 7 Nov 2020 00:21:16 +0100 Subject: [PATCH] feat(api): add Token.perm_manage_tokens and surrounding functionality The migration sets `perm_manage_tokens = True` for all existing tokens, and then sets the default to `False`. Related: #347 --- .../0008_token_perm_manage_tokens.py | 23 ++++++++ api/desecapi/models.py | 2 + api/desecapi/permissions.py | 6 +++ api/desecapi/serializers.py | 2 +- api/desecapi/tests/test_tokens.py | 51 +++++++++++++++++- api/desecapi/views.py | 6 +-- docs/auth/tokens.rst | 16 +++--- webapp/src/components/Field/SwitchBox.vue | 52 +++++++++++++++++++ webapp/src/views/CrudList.vue | 2 + webapp/src/views/TokenList.vue | 14 ++++- 10 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 api/desecapi/migrations/0008_token_perm_manage_tokens.py create mode 100644 webapp/src/components/Field/SwitchBox.vue 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 @@ + + + diff --git a/webapp/src/views/CrudList.vue b/webapp/src/views/CrudList.vue index d2d53d252..628e3a176 100644 --- a/webapp/src/views/CrudList.vue +++ b/webapp/src/views/CrudList.vue @@ -306,6 +306,7 @@ import Code from '@/components/Field/Code'; import GenericText from '@/components/Field/GenericText'; import Record from '@/components/Field/Record'; import RRSet from '@/components/Field/RRSet'; +import SwitchBox from '@/components/Field/SwitchBox'; import TTL from '@/components/Field/TTL'; // safely access deeply nested objects @@ -316,6 +317,7 @@ export default { components: { RRSetType, TimeAgo, + SwitchBox, Code, GenericText, Record, diff --git a/webapp/src/views/TokenList.vue b/webapp/src/views/TokenList.vue index 970ef478d..51018399d 100644 --- a/webapp/src/views/TokenList.vue +++ b/webapp/src/views/TokenList.vue @@ -16,7 +16,7 @@ export default { destroy: 'Delete Token', }, texts: { - banner: () => ('Any API Token can be used to perform any DNS operation on any domain in this account. Token scoping is on our roadmap.'), + banner: () => ('New feature: You can now configure your tokens for finer access control. Check out the new settings below!'), create: () => ('

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}
It is only displayed once.`, destroy: d => (d.name ? `Delete token with name "${d.name}" and ID ${d.id}?` : `Delete unnamed token with ID ${d.id}?`), @@ -45,6 +45,18 @@ export default { datatype: 'GenericText', searchable: true, }, + perm_manage_tokens: { + name: 'item.perm_manage_tokens', + text: 'Can manage tokens', + textCreate: 'Can manage tokens?', + align: 'left', + sortable: true, + value: 'perm_manage_tokens', + readonly: false, + writeOnCreate: true, + datatype: 'SwitchBox', + searchable: false, + }, created: { name: 'item.created', text: 'Created',