소스 검색

feat(api): add Token.last_used, closes #351

Peter Thomassen 5 년 전
부모
커밋
baf4602983
6개의 변경된 파일78개의 추가작업 그리고 22개의 파일을 삭제
  1. 5 1
      api/desecapi/authentication.py
  2. 45 0
      api/desecapi/migrations/0013_token_last_used.py
  3. 2 1
      api/desecapi/models.py
  4. 2 2
      api/desecapi/serializers.py
  5. 14 10
      api/desecapi/tests/test_tokens.py
  6. 10 8
      docs/auth/tokens.rst

+ 5 - 1
api/desecapi/authentication.py

@@ -1,6 +1,7 @@
 import base64
 
 from django.contrib.auth.hashers import PBKDF2PasswordHasher
+from django.utils import timezone
 from rest_framework import exceptions, HTTP_HEADER_ENCODING
 from rest_framework.authentication import (
     BaseAuthentication,
@@ -17,7 +18,10 @@ class TokenAuthentication(RestFrameworkTokenAuthentication):
 
     def authenticate_credentials(self, key):
         key = Token.make_hash(key)
-        return super().authenticate_credentials(key)
+        user, token = super().authenticate_credentials(key)
+        token.last_used = timezone.now()
+        token.save()
+        return user, token
 
 
 class BasicTokenAuthentication(BaseAuthentication):

+ 45 - 0
api/desecapi/migrations/0013_token_last_used.py

@@ -0,0 +1,45 @@
+# Generated by Django 3.0.5 on 2020-04-29 17:41
+
+import desecapi.models
+import django.core.validators
+from django.db import migrations, models, transaction
+
+
+def migrate_data(apps, schema_editor):
+    Token = apps.get_model('desecapi', 'Token')
+    tokens = Token.objects.filter(last_used__isnull=True).all()
+    with transaction.atomic():
+        for token in tokens:
+            # Don't suggest that existing tokens have not been in use.
+            Token.objects.filter(pk=token.id).update(last_used=token.user.last_login)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0012_volatile_donations'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='token',
+            name='last_used',
+            field=models.DateTimeField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='token',
+            name='name',
+            field=models.CharField(blank=True, max_length=64, verbose_name='Name'),
+        ),
+        migrations.AlterField(
+            model_name='domain',
+            name='name',
+            field=models.CharField(max_length=191, unique=True, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_domain_name', message='Invalid value (not a DNS name).', regex='^([a-z0-9_-]{1,63}\\.)*[a-z]{1,63}$')]),
+        ),
+        migrations.AlterField(
+            model_name='rr',
+            name='content',
+            field=models.CharField(max_length=500),
+        ),
+        migrations.RunPython(migrate_data, migrations.RunPython.noop),
+    ]

+ 2 - 1
api/desecapi/models.py

@@ -178,7 +178,8 @@ class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models
         User, related_name='auth_tokens',
         on_delete=models.CASCADE, verbose_name="User"
     )
-    name = models.CharField("Name", max_length=64, default="")
+    name = models.CharField('Name', blank=True, max_length=64)
+    last_used = models.DateTimeField(null=True, blank=True)
     plain = None
 
     def generate_key(self):

+ 2 - 2
api/desecapi/serializers.py

@@ -50,8 +50,8 @@ class TokenSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = models.Token
-        fields = ('id', 'created', 'name', 'token',)
-        read_only_fields = ('created', 'token', 'id')
+        fields = ('id', 'created', 'last_used', 'name', 'token',)
+        read_only_fields = ('id', 'created', 'last_used', 'token')
 
     def __init__(self, *args, include_plain=False, **kwargs):
         self.include_plain = include_plain

+ 14 - 10
api/desecapi/tests/test_tokens.py

@@ -11,6 +11,11 @@ class TokenTestCase(DomainOwnerTestCase):
         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_200_OK)
@@ -36,6 +41,7 @@ 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.assertFalse(any(field in response.data for field in ['token', 'key', 'value']))
 
     def test_retrieve_other_token(self):
@@ -56,14 +62,12 @@ class TokenTestCase(DomainOwnerTestCase):
     def test_create_token(self):
         n = len(Token.objects.filter(user=self.owner).all())
 
-        response = self.client.post(self.reverse('v1:token-list'))
-        self.assertStatus(response, status.HTTP_201_CREATED)
-        self.assertTrue(all(field in response.data for field in ['id', 'created', 'token', 'name']))
-        self.assertEqual(response.data['name'], '')
-
-        response = self.client.post(self.reverse('v1:token-list'), data={'name': 'foobar'})
-        self.assertStatus(response, status.HTTP_201_CREATED)
-        self.assertTrue(all(field in response.data for field in ['id', 'created', 'token', 'name']))
-        self.assertEqual(response.data['name'], 'foobar')
+        datas = [{}, {'name': ''}, {'name': 'foobar'}]
+        for data in datas:
+            response = self.client.post(self.reverse('v1:token-list'), data=data)
+            self.assertStatus(response, status.HTTP_201_CREATED)
+            self.assertTrue(all(field in response.data for field in ['id', 'created', 'token', 'name']))
+            self.assertEqual(response.data['name'], data.get('name', ''))
+            self.assertIsNone(response.data['last_used'])
 
-        self.assertEqual(len(Token.objects.filter(user=self.owner).all()), n + 2)
+        self.assertEqual(len(Token.objects.filter(user=self.owner).all()), n + len(datas))

+ 10 - 8
docs/auth/tokens.rst

@@ -19,13 +19,12 @@ 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, each containing a
-timestamp when the token was created (note the ``Z`` indicating the UTC
-timezone) and a UUID to identify that token. Furthermore, each token can
-carry a name that is of no operational relevance to the API (it is meant
-for user reference only). Certain API operations (such as login) will
-automatically populate the ``name`` field with values such as "login" or
-"dyndns".
+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".
 
 ::
 
@@ -33,11 +32,13 @@ automatically populate the ``name`` field with values such as "login" or
         {
             "created": "2018-09-06T07:05:54.080564Z",
             "id": "3159e485-5499-46c0-ae2b-aeb84d627a8e",
-            "name": "login"
+            "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": ""
         }
     ]
@@ -63,6 +64,7 @@ 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"
     }