diff --git a/django_etesync/app_settings.py b/django_etesync/app_settings.py index a0a0d99..89b38f7 100644 --- a/django_etesync/app_settings.py +++ b/django_etesync/app_settings.py @@ -46,5 +46,9 @@ class AppSettings: ret.append(self.import_from_str(perm)) return ret + @property + def CHALLENGE_VALID_SECONDS(self): # pylint: disable=invalid-name + return self._setting("CHALLENGE_VALID_SECONDS", 60) + app_settings = AppSettings('ETESYNC_') diff --git a/django_etesync/migrations/0002_userinfo.py b/django_etesync/migrations/0002_userinfo.py new file mode 100644 index 0000000..ad7018a --- /dev/null +++ b/django_etesync/migrations/0002_userinfo.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.3 on 2020-05-14 09:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('myauth', '0001_initial'), + ('django_etesync', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='UserInfo', + fields=[ + ('owner', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('version', models.PositiveSmallIntegerField(default=1)), + ('pubkey', models.BinaryField(editable=True)), + ('salt', models.BinaryField(editable=True)), + ], + ), + ] diff --git a/django_etesync/models.py b/django_etesync/models.py index 05b81b3..0d0f3dc 100644 --- a/django_etesync/models.py +++ b/django_etesync/models.py @@ -137,3 +137,13 @@ class CollectionMember(models.Model): def __str__(self): return '{} {}'.format(self.collection.uid, self.user) + + +class UserInfo(models.Model): + owner = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, primary_key=True) + version = models.PositiveSmallIntegerField(default=1) + pubkey = models.BinaryField(editable=True, blank=False, null=False) + salt = models.BinaryField(editable=True, blank=False, null=False) + + def __str__(self): + return "UserInfo<{}>".format(self.owner) diff --git a/django_etesync/serializers.py b/django_etesync/serializers.py index eb50d76..3497d78 100644 --- a/django_etesync/serializers.py +++ b/django_etesync/serializers.py @@ -198,3 +198,67 @@ class CollectionSerializer(serializers.ModelSerializer): process_revisions_for_item(main_item, revision_data) return instance + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = (User.USERNAME_FIELD, User.EMAIL_FIELD) + + +class AuthenticationSignupSerializer(serializers.Serializer): + user = UserSerializer(many=False) + salt = BinaryBase64Field() + pubkey = BinaryBase64Field() + + def create(self, validated_data): + """Function that's called when this serializer creates an item""" + salt = validated_data.pop('salt') + pubkey = validated_data.pop('pubkey') + + with transaction.atomic(): + instance = UserSerializer.Meta.model.objects.create(**validated_data) + instance.set_unusable_password() + + models.UserInfo.objects.create(salt=salt, pubkey=pubkey, owner=instance) + + return instance + + def update(self, instance, validated_data): + raise NotImplementedError() + + +class AuthenticationLoginChallengeSerializer(serializers.Serializer): + username = serializers.CharField(required=False) + email = serializers.EmailField(required=False) + + def validate(self, data): + if not data.get('email') and not data.get('username'): + raise serializers.ValidationError('Either email or username must be set') + return data + + def create(self, validated_data): + raise NotImplementedError() + + def update(self, instance, validated_data): + raise NotImplementedError() + + +class AuthenticationLoginSerializer(AuthenticationLoginChallengeSerializer): + challenge = BinaryBase64Field() + host = serializers.CharField() + signature = BinaryBase64Field() + + def validate(self, data): + host = self.context.get('host', None) + if data['host'] != host: + raise serializers.ValidationError( + 'Found wrong host name. Got: "{}" expected: "{}"'.format(data['host'], host)) + + return super().validate(data) + + def create(self, validated_data): + raise NotImplementedError() + + def update(self, instance, validated_data): + raise NotImplementedError() diff --git a/django_etesync/views.py b/django_etesync/views.py index 87c8010..c628ab0 100644 --- a/django_etesync/views.py +++ b/django_etesync/views.py @@ -12,6 +12,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import json + from django.conf import settings from django.contrib.auth import get_user_model from django.db import transaction, IntegrityError @@ -24,10 +26,20 @@ from rest_framework import viewsets from rest_framework import parsers from rest_framework.decorators import action as action_decorator from rest_framework.response import Response +from rest_framework.authtoken.models import Token + +import nacl.encoding +import nacl.signing +import nacl.secret +import nacl.hash from . import app_settings from .models import Collection, CollectionItem, CollectionItemRevision from .serializers import ( + b64encode, + AuthenticationSignupSerializer, + AuthenticationLoginChallengeSerializer, + AuthenticationLoginSerializer, CollectionSerializer, CollectionItemSerializer, CollectionItemRevisionSerializer, @@ -290,6 +302,110 @@ class CollectionItemChunkViewSet(viewsets.ViewSet): return serve(request, basename, dirname) +class AuthenticationViewSet(viewsets.ViewSet): + allowed_methods = ['POST'] + + def get_encryption_key(self, salt): + key = nacl.hash.blake2b(settings.SECRET_KEY.encode(), encoder=nacl.encoding.RawEncoder) + return nacl.hash.blake2b(b'', key=key, salt=salt, person=b'etesync-auth', encoder=nacl.encoding.RawEncoder) + + def get_queryset(self): + return User.objects.all() + + def list(self, request): + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + + @action_decorator(detail=False, methods=['POST']) + def signup(self, request): + serializer = AuthenticationSignupSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + + return Response({}, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def get_login_user(self, serializer): + username = serializer.validated_data.get('username') + email = serializer.validated_data.get('email') + if username: + kwargs = {User.USERNAME_FIELD: username} + user = get_object_or_404(self.get_queryset(), **kwargs) + elif email: + kwargs = {User.EMAIL_FIELD: email} + user = get_object_or_404(self.get_queryset(), **kwargs) + + return user + + @action_decorator(detail=False, methods=['POST']) + def login_challenge(self, request): + from datetime import datetime + + serializer = AuthenticationLoginChallengeSerializer(data=request.data) + if serializer.is_valid(): + user = self.get_login_user(serializer) + + salt = user.userinfo.salt + enc_key = self.get_encryption_key(salt) + box = nacl.secret.SecretBox(enc_key) + + challenge_data = { + "timestamp": int(datetime.now().timestamp()), + "userId": user.id, + } + challenge = box.encrypt(json.dumps( + challenge_data, separators=(',', ':')).encode(), encoder=nacl.encoding.RawEncoder) + + ret = { + "salt": b64encode(salt), + "challenge": b64encode(challenge), + } + return Response(ret, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @action_decorator(detail=False, methods=['POST']) + def login(self, request): + from datetime import datetime + + serializer = AuthenticationLoginSerializer( + data=request.data, context={'host': request.get_host()}) + if serializer.is_valid(): + user = self.get_login_user(serializer) + challenge = serializer.validated_data['challenge'] + signature = serializer.validated_data['signature'] + + salt = user.userinfo.salt + enc_key = self.get_encryption_key(salt) + box = nacl.secret.SecretBox(enc_key) + + challenge_data = json.loads(box.decrypt(challenge).decode()) + now = int(datetime.now().timestamp()) + if now - challenge_data['timestamp'] > app_settings.CHALLENGE_VALID_SECONDS: + content = {'code': 'challenge_expired', 'detail': 'Login challange has expired'} + return Response(content, status=status.HTTP_400_BAD_REQUEST) + elif challenge_data['userId'] != user.id: + content = {'code': 'wrong_user', 'detail': 'This challenge is for the wrong user'} + return Response(content, status=status.HTTP_400_BAD_REQUEST) + + host_hash = nacl.hash.blake2b( + serializer.validated_data['host'].encode(), encoder=nacl.encoding.RawEncoder) + verify_key = nacl.signing.VerifyKey(user.userinfo.pubkey, encoder=nacl.encoding.RawEncoder) + verify_key.verify(challenge + host_hash, signature) + + data = { + 'token': Token.objects.get_or_create(user=user)[0].key, + } + return Response(data, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @action_decorator(detail=False, methods=['POST']) + def logout(self, request): + # FIXME: expire the token - we need better token handling - using knox? Something else? + return Response({}, status=status.HTTP_400_BAD_REQUEST) + + class ResetViewSet(BaseViewSet): allowed_methods = ['POST'] diff --git a/requirements.in/base.txt b/requirements.in/base.txt index a7d1734..e6d6379 100644 --- a/requirements.in/base.txt +++ b/requirements.in/base.txt @@ -9,3 +9,4 @@ django-ipware djangorestframework drf-nested-routers psycopg2-binary +pynacl diff --git a/requirements.txt b/requirements.txt index 34ca428..dcc37cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,25 +6,28 @@ # asgiref==3.2.3 # via django certifi==2019.11.28 # via requests +cffi==1.14.0 # via pynacl chardet==3.0.4 # via requests defusedxml==0.6.0 # via python3-openid -django-allauth==0.41.0 -django-anymail==7.0.0 -django-appconf==1.0.3 -django-cors-headers==3.2.1 -django-debug-toolbar==2.2 -django-fullurl==1.0 -django-ipware==2.1.0 -django==3.0.3 -djangorestframework==3.11.0 -drf-nested-routers==0.91 +django-allauth==0.41.0 # via -r requirements.in/base.txt +django-anymail==7.0.0 # via -r requirements.in/base.txt +django-appconf==1.0.3 # via -r requirements.in/base.txt +django-cors-headers==3.2.1 # via -r requirements.in/base.txt +django-debug-toolbar==2.2 # via -r requirements.in/base.txt +django-fullurl==1.0 # via -r requirements.in/base.txt +django-ipware==2.1.0 # via -r requirements.in/base.txt +django==3.0.3 # via -r requirements.in/base.txt, django-allauth, django-anymail, django-appconf, django-cors-headers, django-debug-toolbar, django-fullurl, djangorestframework, drf-nested-routers +djangorestframework==3.11.0 # via -r requirements.in/base.txt, drf-nested-routers +drf-nested-routers==0.91 # via -r requirements.in/base.txt idna==2.8 # via requests oauthlib==3.1.0 # via requests-oauthlib -psycopg2-binary==2.8.4 +psycopg2-binary==2.8.4 # via -r requirements.in/base.txt +pycparser==2.20 # via cffi +pynacl==1.3.0 # via -r requirements.in/base.txt python3-openid==3.1.0 # via django-allauth pytz==2019.3 # via django requests-oauthlib==1.3.0 # via django-allauth requests==2.22.0 # via django-allauth, django-anymail, requests-oauthlib -six==1.14.0 # via django-anymail, django-appconf +six==1.14.0 # via django-anymail, django-appconf, pynacl sqlparse==0.3.0 # via django, django-debug-toolbar urllib3==1.25.8 # via requests