Skip to content

Commit

Permalink
feat(api): add Token.perm_manage_tokens and surrounding functionality
Browse files Browse the repository at this point in the history
The migration sets `perm_manage_tokens = True` for all existing tokens,
and then sets the default to `False`.

Related: #347
  • Loading branch information
peterthomassen committed Nov 9, 2020
1 parent 8d7bf57 commit 40a0dfa
Show file tree
Hide file tree
Showing 10 changed files with 162 additions and 12 deletions.
23 changes: 23 additions & 0 deletions api/desecapi/migrations/0008_token_perm_manage_tokens.py
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions api/desecapi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions api/desecapi/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion api/desecapi/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
51 changes: 50 additions & 1 deletion api/desecapi/tests/test_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
6 changes: 3 additions & 3 deletions api/desecapi/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions docs/auth/tokens.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
]

Expand Down
52 changes: 52 additions & 0 deletions webapp/src/components/Field/SwitchBox.vue
Original file line number Diff line number Diff line change
@@ -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: 'Checkbox',
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 changes: 2 additions & 0 deletions webapp/src/views/CrudList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -316,6 +317,7 @@ export default {
components: {
RRSetType,
TimeAgo,
SwitchBox,
Code,
GenericText,
Record,
Expand Down
14 changes: 13 additions & 1 deletion webapp/src/views/TokenList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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}?`),
Expand Down Expand Up @@ -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',
Expand Down

0 comments on commit 40a0dfa

Please sign in to comment.