浏览代码

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
Peter Thomassen 4 年之前
父节点
当前提交
d720ed5610

+ 23 - 0
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),
+        ),
+    ]

+ 2 - 0
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):

+ 6 - 0
api/desecapi/permissions.py

@@ -32,6 +32,12 @@ class IsVPNClient(permissions.BasePermission):
         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.

+ 1 - 1
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):

+ 61 - 3
api/desecapi/tests/test_tokens.py

@@ -4,10 +4,12 @@ from desecapi.models import Token
 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 @@ class TokenTestCase(DomainOwnerTestCase):
 
         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 @@ class TokenTestCase(DomainOwnerTestCase):
         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 @@ class TokenTestCase(DomainOwnerTestCase):
             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)

+ 3 - 3
api/desecapi/views.py

@@ -24,7 +24,7 @@ from desecapi import metrics, models, serializers
 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 @@ class DomainViewMixin:
 
 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

+ 164 - 49
docs/auth/tokens.rst

@@ -3,77 +3,195 @@
 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 that the login endpoint needs authentication with
+the user credentials (email address and password) and its purpose is to return
+a token with a broad range of permissions, whereas the token management
+endpoints are authenticated using an already issued token, for the purpose of
+configuring more fine-grained token permissions.
 
+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``).
+
+``id``
+    :Access mode: read-only
+    :Type: UUID
 
-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".
+    Token ID, used for identification only (e.g. when deleting a token). This
+    is *not* the token value.
 
-::
+``last_used``
+    :Access mode: read-only
+    :Type: timestamp (nullable)
 
-    [
-        {
-            "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": ""
-        }
-    ]
+    Timestamp of when the token was last successfully authenticated, or
+    ``null`` if the token has never been used.
 
-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.
+    In most cases, this corresponds to the last time when an API operation
+    was performed using this token.  However, if the operation was not
+    executed because it was found that the token did not have sufficient
+    permission, this field will still be updated.
 
+``name``
+    :Access mode: read, write
+    :Type: string
 
-Create Additional Tokens
-````````````````````````
+    Token name.  It is meant for user reference only and carries no
+    operational meaning.  If omitted, the empty string is assumed.
 
-To create another token using the token management interface, issue a
-``POST`` request to the same endpoint::
+    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
+
+    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 the desired
+configuration during creation:
+
+- ``perm_manage_tokens``:  If set to ``true``, the token can be used to
+  authorize token management operations (as described in this chapter).
+
+If a field is provided but has invalid content, ``400 Bad Request`` is
+returned, with error details in the body.
+
+
+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`` permission.  If permissions are insufficient, the
+server will return ``403 Forbidden``.
+
+``name`` and all other fields are optional.  The list of fields that can be
+given is the same as when `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, it
+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 as
+follows::
+
+    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 properties, such as name,
+timestamps of creation and last use, or 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
@@ -87,15 +205,12 @@ The server will reply with ``204 No Content``, even if the token was not found.
 If you do not have the token UUID, but you do have the token value itself, you
 can use the :ref:`log-out` endpoint to delete it.
 
-Note that, for now, all tokens have equal power -- every token can authorize
-any action. We are planning to implement scoped tokens in the future.
-
 
 Security Considerations
 ```````````````````````
 
-This section is for information only. Token length and encoding may change in
-the future.
+This section is for purely informational. Token length and encoding may change
+in the future.
 
 Any token is generated from 168 bits of randomness at the server and stored in
 hashed format (PBKDF2-HMAC-SHA256). Guessing the token correctly or reversing
@@ -106,5 +221,5 @@ base64 encoding. It comprises only the characters ``A-Z``, ``a-z``, ``0-9``, ``-
 and ``_``. (Base64 padding is not needed as the string length is a multiple of 4.)
 
 Old versions of the API encoded 20-byte tokens in 40 characters with hexadecimal
-representation. Such tokens will not be issued anymore, but remain valid until
+representation. Such tokens are not issued anymore, but remain valid until
 invalidated by the user.

+ 52 - 0
webapp/src/components/Field/SwitchBox.vue

@@ -0,0 +1,52 @@
+<template>
+  <v-switch
+    :label="label"
+    :disabled="disabled || readonly"
+    :error-messages="errorMessages"
+    :input-value="value"
+    :required="required"
+    :rules="[v => !required || !!v || 'Required.']"
+    @change="input($event)"
+    @keyup="keyup($event)"
+  />
+</template>
+
+<script>
+export default {
+  name: 'SwitchBox',
+  props: {
+    disabled: {
+      type: Boolean,
+      required: false,
+    },
+    errorMessages: {
+      type: [String, Array],
+      default: () => [],
+    },
+    label: {
+      type: String,
+      required: false,
+    },
+    readonly: {
+      type: Boolean,
+      required: false,
+    },
+    required: {
+      type: Boolean,
+      default: false,
+    },
+    value: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  methods: {
+    input(event) {
+      this.$emit('input', event);
+    },
+    keyup(event) {
+      this.$emit('keyup', event);
+    },
+  },
+};
+</script>

+ 2 - 0
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,

+ 13 - 1
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: () => ('<strong>New feature:</strong> You can now configure your tokens for finer access control. Check out the new settings below!'),
           create: () => ('<p>You can create a new API token here. The token is displayed after submitting this form.</p><p><strong>Warning:</strong> Be sure to protect your tokens appropriately! Knowledge of an API token allows performing actions on your behalf.</p>'),
           createSuccess: (item) => `Your new token is: <code>${item.token}</code><br />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',