Implement a ZKPP login flow.
This commit is contained in:
parent
6b0a40e9dd
commit
32a8b9c90d
@ -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_')
|
||||||
|
25
django_etesync/migrations/0002_userinfo.py
Normal file
25
django_etesync/migrations/0002_userinfo.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
@ -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)
|
||||||
|
@ -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()
|
||||||
|
@ -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']
|
||||||
|
|
||||||
|
@ -9,3 +9,4 @@ django-ipware
|
|||||||
djangorestframework
|
djangorestframework
|
||||||
drf-nested-routers
|
drf-nested-routers
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
|
pynacl
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user