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))
|
||||
return ret
|
||||
|
||||
@property
|
||||
def CHALLENGE_VALID_SECONDS(self): # pylint: disable=invalid-name
|
||||
return self._setting("CHALLENGE_VALID_SECONDS", 60)
|
||||
|
||||
|
||||
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):
|
||||
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)
|
||||
|
||||
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
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
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']
|
||||
|
||||
|
@ -9,3 +9,4 @@ django-ipware
|
||||
djangorestframework
|
||||
drf-nested-routers
|
||||
psycopg2-binary
|
||||
pynacl
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user