Implement a ZKPP login flow.

This commit is contained in:
Tom Hacohen 2020-05-14 13:43:49 +03:00
parent 6b0a40e9dd
commit 32a8b9c90d
7 changed files with 235 additions and 12 deletions

View File

@ -46,5 +46,9 @@ class AppSettings:
ret.append(self.import_from_str(perm)) ret.append(self.import_from_str(perm))
return ret return ret
@property
def CHALLENGE_VALID_SECONDS(self): # pylint: disable=invalid-name
return self._setting("CHALLENGE_VALID_SECONDS", 60)
app_settings = AppSettings('ETESYNC_') app_settings = AppSettings('ETESYNC_')

View File

@ -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)),
],
),
]

View File

@ -137,3 +137,13 @@ class CollectionMember(models.Model):
def __str__(self): def __str__(self):
return '{} {}'.format(self.collection.uid, self.user) 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)

View File

@ -198,3 +198,67 @@ class CollectionSerializer(serializers.ModelSerializer):
process_revisions_for_item(main_item, revision_data) process_revisions_for_item(main_item, revision_data)
return instance 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()

View File

@ -12,6 +12,8 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
@ -24,10 +26,20 @@ from rest_framework import viewsets
from rest_framework import parsers from rest_framework import parsers
from rest_framework.decorators import action as action_decorator from rest_framework.decorators import action as action_decorator
from rest_framework.response import Response 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 . import app_settings
from .models import Collection, CollectionItem, CollectionItemRevision from .models import Collection, CollectionItem, CollectionItemRevision
from .serializers import ( from .serializers import (
b64encode,
AuthenticationSignupSerializer,
AuthenticationLoginChallengeSerializer,
AuthenticationLoginSerializer,
CollectionSerializer, CollectionSerializer,
CollectionItemSerializer, CollectionItemSerializer,
CollectionItemRevisionSerializer, CollectionItemRevisionSerializer,
@ -290,6 +302,110 @@ class CollectionItemChunkViewSet(viewsets.ViewSet):
return serve(request, basename, dirname) 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): class ResetViewSet(BaseViewSet):
allowed_methods = ['POST'] allowed_methods = ['POST']

View File

@ -9,3 +9,4 @@ django-ipware
djangorestframework djangorestframework
drf-nested-routers drf-nested-routers
psycopg2-binary psycopg2-binary
pynacl

View File

@ -6,25 +6,28 @@
# #
asgiref==3.2.3 # via django asgiref==3.2.3 # via django
certifi==2019.11.28 # via requests certifi==2019.11.28 # via requests
cffi==1.14.0 # via pynacl
chardet==3.0.4 # via requests chardet==3.0.4 # via requests
defusedxml==0.6.0 # via python3-openid defusedxml==0.6.0 # via python3-openid
django-allauth==0.41.0 django-allauth==0.41.0 # via -r requirements.in/base.txt
django-anymail==7.0.0 django-anymail==7.0.0 # via -r requirements.in/base.txt
django-appconf==1.0.3 django-appconf==1.0.3 # via -r requirements.in/base.txt
django-cors-headers==3.2.1 django-cors-headers==3.2.1 # via -r requirements.in/base.txt
django-debug-toolbar==2.2 django-debug-toolbar==2.2 # via -r requirements.in/base.txt
django-fullurl==1.0 django-fullurl==1.0 # via -r requirements.in/base.txt
django-ipware==2.1.0 django-ipware==2.1.0 # via -r requirements.in/base.txt
django==3.0.3 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 djangorestframework==3.11.0 # via -r requirements.in/base.txt, drf-nested-routers
drf-nested-routers==0.91 drf-nested-routers==0.91 # via -r requirements.in/base.txt
idna==2.8 # via requests idna==2.8 # via requests
oauthlib==3.1.0 # via requests-oauthlib 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 python3-openid==3.1.0 # via django-allauth
pytz==2019.3 # via django pytz==2019.3 # via django
requests-oauthlib==1.3.0 # via django-allauth requests-oauthlib==1.3.0 # via django-allauth
requests==2.22.0 # via django-allauth, django-anymail, requests-oauthlib 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 sqlparse==0.3.0 # via django, django-debug-toolbar
urllib3==1.25.8 # via requests urllib3==1.25.8 # via requests