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 @@
+
+
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}