Browse Source

feat(auth): multiple auth tokens per user

To increase authentication flexibility, this change allows users to
create and name additional authentication tokens. They can be used
independently of the current login session.

This change needs some database migration that IS NOT AUTOMATED.
Automatic migration requires either more priviliges or a bunch of code.
Not worth for the number of deployments (one).

Migrate with e.g.

INSERT INTO desecapi_token(`created`, `key`, `user_id`, `name`)
  SELECT `created`, `key`, `user_id`, 'default' FROM authtoken_token;
TRUNCATE TABLE authtoken_token;

Failure to apply this will result in all login tokens invalid.

This commit does not break any available API, but only extends it.
Nils Wisiol 7 years ago
parent
commit
55a8ae2389

+ 7 - 2
api/desecapi/authentication.py

@@ -1,9 +1,13 @@
 from __future__ import unicode_literals
 import base64, os
 from rest_framework import exceptions, HTTP_HEADER_ENCODING
-from rest_framework.authtoken.models import Token
 from rest_framework.authentication import BaseAuthentication, get_authorization_header, authenticate
-from desecapi.models import Domain
+from desecapi.models import Domain, Token
+from rest_framework.authentication import TokenAuthentication as RestFrameworkTokenAuthentication
+
+
+class TokenAuthentication(RestFrameworkTokenAuthentication):
+    model = Token
 
 
 class BasicTokenAuthentication(BaseAuthentication):
@@ -96,6 +100,7 @@ class URLParamAuthentication(BaseAuthentication):
 
 
 class IPAuthentication(BaseAuthentication):
+
     """
     Authentication against remote IP address for dedyn.io management by nslord
     """

+ 37 - 0
api/desecapi/migrations/0021_tokens.py

@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.15 on 2018-08-24 15:08
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0020_user_locked'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Token',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
+                ('key', models.CharField(db_index=True, max_length=40, unique=True, verbose_name='Key')),
+                ('name', models.CharField(max_length=64, verbose_name='Name')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User')),
+                ('user_specific_id', models.BigIntegerField()),
+            ],
+            options={
+                'verbose_name': 'Token',
+                'verbose_name_plural': 'Tokens',
+                'abstract': False,
+            },
+        ),
+        migrations.AlterUniqueTogether(
+            name='token',
+            unique_together=set([('user', 'user_specific_id')]),
+        ),
+    ]

+ 22 - 1
api/desecapi/models.py

@@ -6,8 +6,9 @@ from django.core.exceptions import SuspiciousOperation, ValidationError
 from desecapi import pdns, mixins
 import datetime, uuid
 from django.core.validators import MinValueValidator
-from rest_framework.authtoken.models import Token
 from collections import OrderedDict
+import rest_framework.authtoken.models
+from time import time
 
 
 class MyUserManager(BaseUserManager):
@@ -43,6 +44,26 @@ class MyUserManager(BaseUserManager):
         return user
 
 
+class Token(rest_framework.authtoken.models.Token):
+    key = models.CharField("Key", max_length=40, db_index=True, unique=True)
+    # relation to user is a ForeignKey, so each user can have more than one token
+    user = models.ForeignKey(
+        settings.AUTH_USER_MODEL, related_name='auth_tokens',
+        on_delete=models.CASCADE, verbose_name="User"
+    )
+    name = models.CharField("Name", max_length=64, default="")
+    user_specific_id = models.BigIntegerField("User-Specific ID")
+
+    def save(self, *args, **kwargs):
+        if not self.user_specific_id:
+            self.user_specific_id = int(time() * 100000)
+        super().save(*args, **kwargs) # Call the "real" save() method.
+
+    class Meta:
+        abstract = False
+        unique_together = (('user', 'user_specific_id'),)
+
+
 class User(AbstractBaseUser):
     email = models.EmailField(
         verbose_name='email address',

+ 12 - 1
api/desecapi/serializers.py

@@ -1,6 +1,6 @@
 from rest_framework import serializers
 from rest_framework.exceptions import ValidationError
-from desecapi.models import Domain, Donation, User, RR, RRset
+from desecapi.models import Domain, Donation, User, RR, RRset, Token
 from djoser import serializers as djoserSerializers
 from django.db import models, transaction
 import django.core.exceptions
@@ -9,6 +9,17 @@ import re
 from rest_framework.fields import empty
 
 
+class TokenSerializer(serializers.ModelSerializer):
+    value = serializers.ReadOnlyField(source='key')
+    # note this overrides the original "id" field, which is the db primary key
+    id = serializers.ReadOnlyField(source='user_specific_id')
+
+    class Meta:
+        model = Token
+        fields = ('id', 'created', 'name', 'value',)
+        read_only_fields = ('created', 'value', 'id')
+
+
 class RRSerializer(serializers.ModelSerializer):
     class Meta:
         model = RR

+ 2 - 2
api/desecapi/settings.py

@@ -43,7 +43,6 @@ INSTALLED_APPS = (
     'django.contrib.messages',
     'django.contrib.staticfiles',
     'rest_framework',
-    'rest_framework.authtoken',
     'djoser',
     'desecapi',
 )
@@ -104,7 +103,7 @@ STATIC_URL = '/api/static/'
 
 REST_FRAMEWORK = {
     'DEFAULT_AUTHENTICATION_CLASSES': (
-        'rest_framework.authentication.TokenAuthentication',
+        'desecapi.authentication.TokenAuthentication',
     ),
 }
 
@@ -120,6 +119,7 @@ DJOSER = {
         'user': 'desecapi.serializers.UserSerializer',
         'user_create': 'desecapi.serializers.UserCreateSerializer',
     },
+    'TOKEN_MODEL': 'desecapi.models.Token',
 }
 
 TEMPLATES = [

+ 1 - 2
api/desecapi/tests/utils.py

@@ -1,8 +1,7 @@
 import random
 import string
 
-from rest_framework.authtoken.models import Token
-from desecapi.models import Domain, User
+from desecapi.models import Domain, User, Token
 
 
 class utils(object):

+ 7 - 0
api/desecapi/urls.py

@@ -2,7 +2,11 @@ from django.conf.urls import include, url
 from desecapi.views import *
 from rest_framework.urlpatterns import format_suffix_patterns
 from desecapi import views
+from rest_framework.routers import SimpleRouter
 
+router = SimpleRouter()
+router.register(r'', TokenViewSet, base_name='token')
+token_urls = router.urls
 
 apiurls = [
     url(r'^$', Root.as_view(), name='root'),
@@ -11,6 +15,7 @@ apiurls = [
     url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/$', DomainDetailByName.as_view(), name='domain-detail/byName'),
     url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/$', RRsetList.as_view(), name='rrsets'),
     url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/(?P<subname>(\*)?[a-zA-Z\.\-_0-9=]*)\.\.\./(?P<type>[A-Z][A-Z0-9]*)/$', RRsetDetail.as_view(), name='rrset'),
+    url(r'^tokens/', include(token_urls)),
     url(r'^dns$', DnsQuery.as_view(), name='dns-query'),
     url(r'^dyndns/update$', DynDNS12Update.as_view(), name='dyndns12update'),
     url(r'^donation/', DonationList.as_view(), name='donation'),
@@ -22,6 +27,8 @@ apiurls = format_suffix_patterns(apiurls)
 
 urlpatterns = [
     url(r'^api/v1/auth/users/create/$', UserCreateView.as_view(), name='register'),
+    url(r'^api/v1/auth/token/create/$', TokenCreateView.as_view(), name='login'),
+    url(r'^api/v1/auth/token/destroy/$', TokenDestroyView.as_view(), name='logout'),
     url(r'^api/v1/auth/', include('djoser.urls')),
     url(r'^api/v1/auth/', include('djoser.urls.authtoken')),
     url(r'^api/v1/', include(apiurls)),

+ 58 - 6
api/desecapi/views.py

@@ -1,8 +1,8 @@
 from __future__ import unicode_literals
 from django.core.mail import EmailMessage
-from desecapi.models import Domain, User, RRset, RR
+from desecapi.models import Domain, User, RRset, RR, Token
 from desecapi.serializers import (
-    DomainSerializer, RRsetSerializer, DonationSerializer)
+    DomainSerializer, RRsetSerializer, DonationSerializer, TokenSerializer)
 from rest_framework import generics
 from desecapi.permissions import IsOwner, IsDomainOwner
 from rest_framework import permissions
@@ -10,8 +10,7 @@ from django.http import Http404, HttpResponseRedirect
 from rest_framework.views import APIView
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
-from rest_framework.authentication import (
-    TokenAuthentication, get_authorization_header)
+from rest_framework.authentication import get_authorization_header
 from rest_framework.renderers import StaticHTMLRenderer
 from dns import resolver
 from django.template.loader import get_template
@@ -32,6 +31,12 @@ from desecapi.emails import send_account_lock_email, send_token_email
 import re
 import ipaddress, os
 from rest_framework_bulk import ListBulkCreateUpdateAPIView
+from django.contrib.auth import user_logged_in, user_logged_out
+import djoser.views
+from djoser.serializers import TokenSerializer as DjoserTokenSerializer
+from rest_framework.viewsets import GenericViewSet
+from rest_framework import mixins
+
 
 patternDyn = re.compile(r'^[A-Za-z-][A-Za-z0-9_-]*\.dedyn\.io$')
 patternNonDyn = re.compile(r'^([A-Za-z0-9-][A-Za-z0-9_-]*\.)+[A-Za-z]+$')
@@ -41,6 +46,53 @@ def get_client_ip(request):
     return request.META.get('REMOTE_ADDR')
 
 
+class TokenCreateView(djoser.views.TokenCreateView):
+
+    def _action(self, serializer):
+        user = serializer.user
+        token = Token(user=user, name="login")
+        token.save()
+        user_logged_in.send(sender=user.__class__, request=self.request, user=user)
+        token_serializer_class = DjoserTokenSerializer
+        return Response(
+            data=token_serializer_class(token).data,
+            status=status.HTTP_201_CREATED,
+        )
+
+
+class TokenDestroyView(djoser.views.TokenDestroyView):
+
+    def post(self, request):
+        _, token = auth.TokenAuthentication().authenticate(request)
+        token.delete()
+        user_logged_out.send(
+            sender=request.user.__class__, request=request, user=request.user
+        )
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class TokenViewSet(mixins.CreateModelMixin,
+                   mixins.DestroyModelMixin,
+                   mixins.ListModelMixin,
+                   GenericViewSet):
+    serializer_class = TokenSerializer
+    permission_classes = (permissions.IsAuthenticated, )
+    lookup_field = 'user_specific_id'
+
+    def get_queryset(self):
+        return self.request.user.auth_tokens.all()
+
+    def destroy(self, request, *args, **kwargs):
+        try:
+            super().destroy(self, request, *args, **kwargs)
+        except Http404:
+            pass
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+    def perform_create(self, serializer):
+        serializer.save(user=self.request.user)
+
+
 class DomainList(generics.ListCreateAPIView):
     serializer_class = DomainSerializer
     permission_classes = (permissions.IsAuthenticated, IsOwner,)
@@ -175,7 +227,7 @@ class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
 
 
 class RRsetList(ListBulkCreateUpdateAPIView):
-    authentication_classes = (TokenAuthentication, auth.IPAuthentication,)
+    authentication_classes = (auth.TokenAuthentication, auth.IPAuthentication,)
     serializer_class = RRsetSerializer
     permission_classes = (permissions.IsAuthenticated, IsDomainOwner,)
 
@@ -290,7 +342,7 @@ class DnsQuery(APIView):
 
 
 class DynDNS12Update(APIView):
-    authentication_classes = (TokenAuthentication, auth.BasicTokenAuthentication, auth.URLParamAuthentication,)
+    authentication_classes = (auth.TokenAuthentication, auth.BasicTokenAuthentication, auth.URLParamAuthentication,)
     renderer_classes = [StaticHTMLRenderer]
 
     def findDomain(self, request):

+ 15 - 0
test/e2e/schemas.js

@@ -45,3 +45,18 @@ exports.rrsets = {
     type: "array",
     items: exports.rrset
 };
+
+exports.token = {
+    properties: {
+        value: { type: "string" },
+        name: { type: "string" },
+        created: { type: "string" },
+        id: { type: "integer" },
+    },
+    required: ["value", "name", "created", "id"]
+};
+
+exports.tokens = {
+    type: "array",
+    items: exports.token
+};

+ 134 - 0
test/e2e/spec/api_spec.js

@@ -5,6 +5,7 @@ var itShowsUpInPdnsAs = require("./../setup.js").itShowsUpInPdnsAs;
 var schemas = require("./../schemas.js");
 
 describe("API", function () {
+    this.timeout(3000);
 
     before(function () {
         chakram.setRequestDefaults({
@@ -68,6 +69,81 @@ describe("API", function () {
             });
         });
 
+        describe("token management (djoser)", function () {
+
+            var token1, token2;
+
+            function createTwoTokens() {
+                return chakram.waitFor([
+                    chakram.post('/auth/token/create/', {
+                        "email": email,
+                        "password": password,
+                    }).then(function (loginResponse) {
+                        expect(loginResponse).to.have.status(201);
+                        expect(loginResponse.body.auth_token).to.match(/^[a-z0-9]{40}$/);
+                        token1 = loginResponse.body.auth_token;
+                        expect(token1).to.not.equal(token2);
+                    }),
+                    chakram.post('/auth/token/create/', {
+                        "email": email,
+                        "password": password,
+                    }).then(function (loginResponse) {
+                        expect(loginResponse).to.have.status(201);
+                        expect(loginResponse.body.auth_token).to.match(/^[a-z0-9]{40}$/);
+                        token2 = loginResponse.body.auth_token;
+                        expect(token2).to.not.equal(token1);
+                    })
+                ]);
+            }
+
+            function deleteToken(token) {
+                var response = chakram.post('/auth/token/destroy/', null, {
+                    headers: {'Authorization': 'Token ' + token}
+                });
+
+                return expect(response).to.have.status(204);
+            }
+
+            it("can create additional tokens", createTwoTokens);
+
+            describe("additional tokens", function () {
+
+                before(createTwoTokens);
+
+                it("can be used for login (1)", function () {
+                    return expect(chakram.get('/domains/', {
+                        headers: {'Authorization': 'Token ' + token1 }
+                    })).to.have.status(200);
+                });
+
+                it("can be used for login (2)", function () {
+                    return expect(chakram.get('/domains/', {
+                        headers: {'Authorization': 'Token ' + token2 }
+                    })).to.have.status(200);
+                });
+
+                describe("and one deleted", function () {
+
+                    before(function () {
+                        var response = chakram.post('/auth/token/destroy/', undefined,
+                            { headers: {'Authorization': 'Token ' + token1 } }
+                        );
+
+                        return expect(response).to.have.status(204);
+                    });
+
+                    it("leaves the other untouched", function () {
+                        return expect(chakram.get('/domains/', {
+                            headers: {'Authorization': 'Token ' + token2 }
+                        })).to.have.status(200);
+                    });
+
+                });
+
+            });
+
+        });
+
     });
 
     var email = require("uuid").v4() + '@e2etest.local';
@@ -881,6 +957,64 @@ describe("API", function () {
 
             });
 
+            describe("tokens/ endpoint", function () {
+
+                var tokenId;
+                var tokenValue;
+
+                function createTokenWithName () {
+                    var tokenname = "e2e-token-" + require("uuid").v4();
+                    return chakram.post('/tokens/', { name: tokenname }).then(function (response) {
+                        expect(response).to.have.status(201);
+                        expect(response).to.have.json('name', tokenname);
+                        tokenId = response.body['id'];
+                    });
+                }
+
+                function createToken () {
+                    return chakram.post('/tokens/').then(function (response) {
+                        expect(response).to.have.status(201);
+                        tokenId = response.body['id'];
+                        tokenValue = response.body['value'];
+                    });
+                }
+
+                it("can create tokens", createToken);
+
+                it("can create tokens with name", createTokenWithName)
+
+                describe("with tokens", function () {
+                    before(createToken)
+
+                    it("a list of tokens can be retrieved", function () {
+                        var response = chakram.get('/tokens/');
+                        return expect(response).to.have.schema(schemas.tokens);
+                    });
+
+                    describe("can delete token", function () {
+
+                        before( function () {
+                            var response = chakram.delete('/tokens/' + tokenId + '/');
+                            return expect(response).to.have.status(204);
+                        });
+
+                        it("deactivates the token", function () {
+                            return expect(chakram.get('/tokens/', {
+                                headers: {'Authorization': 'Token ' + tokenValue }
+                            })).to.have.status(401);
+                        });
+
+                    });
+
+                    it("deleting nonexistent tokens yields 204", function () {
+                        var response = chakram.delete('/tokens/wedonthavethisid/');
+                        return expect(response).to.have.status(204);
+                    });
+
+                });
+
+            })
+
         });
 
     });