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..594589fb7 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) @@ -41,7 +43,8 @@ def test_retrieve_my_token(self): response = self.client.get(url) self.assertStatus(response, status.HTTP_200_OK) - self.assertTrue(all(field in response.data for field in ['created', 'id', 'last_used', 'name'])) + self.assertTrue(all(field in response.data for field in ['created', 'id', 'last_used', 'name', + 'perm_manage_tokens'])) self.assertFalse(any(field in response.data for field in ['token', 'key', 'value'])) def test_retrieve_other_token(self): @@ -57,7 +60,15 @@ def test_update_my_token(self): for method in [self.client.patch, self.client.put]: response = method(url, data={'name': method.__name__}) self.assertStatus(response, status.HTTP_200_OK) - self.assertEqual(response.data['name'], method.__name__) + self.assertEqual(Token.objects.get(pk=self.token.id).name, method.__name__) + + # Revoke token management permission + response = self.client.patch(url, data={'perm_manage_tokens': False}) + self.assertStatus(response, status.HTTP_200_OK) + + # Verify that the change cannot be undone + response = self.client.patch(url, data={'perm_manage_tokens': True}) + self.assertStatus(response, status.HTTP_403_FORBIDDEN) def test_create_token(self): n = len(Token.objects.filter(user=self.owner).all()) @@ -71,3 +82,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..4493c1dfb 100644 --- a/docs/auth/tokens.rst +++ b/docs/auth/tokens.rst @@ -3,77 +3,190 @@ 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. +When accessing the token management endpoints using a token without sufficient +permission, the server will reply with ``403 Forbidden``. -Retrieving All Current Tokens -````````````````````````````` -To retrieve a list of currently valid tokens, issue a ``GET`` request:: +.. _`token object`: - curl -X GET https://desec.io/api/v1/auth/tokens/ \ - --header "Authorization: Token mu4W4MHuSc0HyrGD1h/dnKuZBond" +Token Field Reference* +`````````````````````` + +A JSON object representing a token has the following structure:: + + { + "created": "2018-09-06T09:08:43.762697Z", + "id": "3a6b94b5-d20e-40bd-a7cc-521f5c79fab3", + "last_used": null, + "name": "my new token", + "perm_manage_tokens": false, + "token": "4pnk7u-NHvrEkFzrhFDRTjGFyX_S" + } + +Field details: + +``created`` + :Access mode: read-only + :Type: timestamp + + Timestamp of token creation, in ISO 8601 format (e.g. + ``2018-09-06T09:08:43.762697Z``). -The server will respond with a list of token objects, each containing -timestamps of when the token was created and last used (or ``null``; note the -``Z`` indicating UTC timezone), and a UUID to identify that token. Furthermore, -each token can carry a name that has no operational meaning (it is meant for -user reference only). Certain API operations will automatically populate the -``name`` field with suitable values such as "login" or "dyndns". +``id`` + :Access mode: read-only + :Type: UUID -:: + Token ID, used for identification only (e.g. when deleting a token). This + is *not* the token value. - [ - { - "created": "2018-09-06T07:05:54.080564Z", - "id": "3159e485-5499-46c0-ae2b-aeb84d627a8e", - "last_used": "2019-04-29T18:01:09.894594Z", - "name": "login", - }, - { - "created": "2018-09-06T08:53:26.428396Z", - "id": "76d6e39d-65bc-4ab2-a1b7-6e94eee0a534", - "last_used": null, - "name": "" - } - ] +``last_used`` + :Access mode: read-only + :Type: timestamp (nullable) -You can also retrieve an individual token by appending ``{id}/`` to the URL, -for example in order to look up a token's name or creation timestamp. + Timestamp of when the token was last successfully authenticated, or + ``null`` if the token has never been used. + In most cases, this corresponds to the last time when an API operations + was performed with this token. However, if the operation was not + performed because it was found that the token did not have sufficient + permissions, this field will still be updated. -Create Additional Tokens -```````````````````````` +``name`` + :Access mode: read, write + :Type: string -To create another token using the token management interface, issue a -``POST`` request to the same endpoint:: + Token name. It is meant for user reference only and carries no + operational meaning. If omitted, the empty string will be assumed. + + Certain API operations will automatically populate the ``name`` field with + suitable values such as "login" or "dyndns". + +``perm_manage_tokens`` + :Access mode: read, write + :Type: boolean + + Permission to manage tokens (this one and also all others). A token which + does not have this flag set cannot access the ``auth/tokens/`` endpoints. + +``token`` + :Access mode: read-once + :Type: string (28 characters) + + Token value that is used to authenticate API requests. It is only + returned once, upon creation of the token. The value of an existing token + cannot be recovered (we store it in irreversibly hashed form). For + security details, see `Security Considerations`_. + + +Creating a Token +```````````````` + +To create a new token, issue a ``POST`` request to the tokens endpoint:: curl -X POST https://desec.io/api/v1/auth/tokens/ \ --header "Authorization: Token mu4W4MHuSc0HyrGD1h/dnKuZBond" \ --header "Content-Type: application/json" --data @- <<< \ '{"name": "my new token"}' -Note that the name is optional and will be empty if not specified. The server -will reply with ``201 Created`` and the created token in the response body:: +Note that the name and other fields are optional. The server will reply with +``201 Created`` and the created token in the response body:: { "created": "2018-09-06T09:08:43.762697Z", "id": "3a6b94b5-d20e-40bd-a7cc-521f5c79fab3", "last_used": null, - "token": "4pnk7u-NHvrEkFzrhFDRTjGFyX_S", - "name": "my new token" + "name": "my new token", + "perm_manage_tokens": false, + "token": "4pnk7u-NHvrEkFzrhFDRTjGFyX_S" } +The new token will, by default, possess fewer permissions than a login token. +In particular, the ``perm_manage_tokens`` flag will not be set, so that the +new token cannot be used to retrieve, modify, or delete any tokens (including +itself). + +With the default set of permissions, tokens qualify for carrying out all API +operations related to DNS management (i.e. managing both domains and DNS +records). Note that it is always possible to use the :ref:`log-out` endpoint +to delete a token. + +If you require tokens with extra permissions, you can provide them during +creation: + +- ``perm_manage_tokens``: If set to ``true``, the token can be used to + authorize token management operations (as described in this chapter). + + +Modifying a Token +````````````````` + +To modify a token, send a ``PATCH`` or ``PUT`` request to the +``auth/tokens/{id}/`` endpoint of the token you would like to modify:: + + curl -X POST https://desec.io/api/v1/auth/tokens/{id}/ \ + --header "Authorization: Token mu4W4MHuSc0HyrGD1h/dnKuZBond" \ + --header "Content-Type: application/json" --data @- <<< \ + '{"name": "my new token"}' + +The ID given in the URL is the ID of the token that will be modified. Upon +success, the server will reply with ``200 OK``. + +The token given in the ``Authorization`` header requires the +``perm_manage_tokens`` permissions. If permissions are insufficient, the +server will return an ``403 Forbidden``. + +``name`` and all other fields are optional. For a list of fields that can be +given, see `Creating a Token`_. If a field is provided but has invalid +content, ``400 Bad Request`` is returned, with error details in the body. + +**Note:** As long as the ``perm_manage_tokens`` permission is in effect, tt +is possible for a token to grant and revoke its own permissions. However, if +the ``perm_manage_tokens`` permission is removed, the operation can only be +reversed by means of another token that has this permission. + + +Listing Tokens +`````````````` + +To retrieve a list of currently valid tokens, issue a ``GET`` request:: + + curl -X GET https://desec.io/api/v1/auth/tokens/ \ + --header "Authorization: Token mu4W4MHuSc0HyrGD1h/dnKuZBond" + +The server will respond with a list of token objects. Up to 500 items are +returned at a time. If you have a larger number of tokens configured, the use +of :ref:`pagination` is required. + + +Retrieving a Specific Token +``````````````````````````` + +To retrieve a list of currently valid tokens, issue a ``GET`` request to the +token's endpoint: + + curl -X GET https://desec.io/api/v1/auth/tokens/{id}/ \ + --header "Authorization: Token mu4W4MHuSc0HyrGD1h/dnKuZBond" + +The response will contain a token object as described under `Token Field +Reference`_. You can use it to check a token's name, timestamps of creation +and last use, as well as permissions. + +**Note:** The response does *not* contain the token value itself! + .. _delete-tokens: -Delete Tokens -````````````` +Deleting a Token +```````````````` To delete an existing token by its ID via the token management endpoints, issue a ``DELETE`` request on the token's endpoint, replacing ``{id}`` with the 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',