From c1fc972668c3d4f50af00e6f798c2409e98f564f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Val=C3=A9rio?= Date: Tue, 14 Jul 2020 18:17:55 +0100 Subject: [PATCH] First implementation of authentication views for DRF projects --- README.rst | 2 +- django_cryptolock/api_views.py | 73 ++++++ django_cryptolock/mixins.py | 30 +++ django_cryptolock/serializers.py | 10 + django_cryptolock/utils.py | 16 ++ django_cryptolock/views.py | 41 ++-- requirements_dev.txt | 1 + setup.py | 1 + tests/helpers.py | 14 ++ tests/settings.py | 11 + tests/templates/django_cryptolock/login.html | 0 tests/templates/django_cryptolock/signup.html | 0 tests/test_api_views.py | 209 ++++++++++++++++++ tests/test_backends.py | 18 +- tests/test_forms.py | 9 +- tests/urls.py | 12 +- 16 files changed, 406 insertions(+), 41 deletions(-) create mode 100644 django_cryptolock/api_views.py create mode 100644 django_cryptolock/mixins.py create mode 100644 django_cryptolock/serializers.py create mode 100644 tests/templates/django_cryptolock/login.html create mode 100644 tests/templates/django_cryptolock/signup.html create mode 100644 tests/test_api_views.py diff --git a/README.rst b/README.rst index a1eae77..10337fc 100644 --- a/README.rst +++ b/README.rst @@ -76,4 +76,4 @@ Quickstart ... ] -More detailed information can be found in the documentation. +More detailed information can be found in the [documentation](#documentation). diff --git a/django_cryptolock/api_views.py b/django_cryptolock/api_views.py new file mode 100644 index 0000000..4cecfff --- /dev/null +++ b/django_cryptolock/api_views.py @@ -0,0 +1,73 @@ +from django.utils.translation import gettext_lazy as _ +from rest_framework.status import HTTP_200_OK + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.authtoken.models import Token + +from monerorpc.authproxy import JSONRPCException + +from .models import Address, Challenge +from .forms import SimpleSignUpForm, SimpleLoginForm +from .utils import verify_signature +from .mixins import CreateUserMixin, CreateChallengeMixin + + +class CryptoLockAPITokenLoginView(CreateChallengeMixin, APIView): + """Endpoint to login the user with cryptocurrency wallet address. + + Using the default token backend. + """ + + http_method_names = ["get", "post"] + + def post(self, request, format=None): + """Authenticates the user using the provided signature.""" + form = SimpleLoginForm(request, request.data) + if not form.is_valid(): + return Response(form.errors, status=status.HTTP_400_BAD_REQUEST) + + challenge = form.cleaned_data["challenge"] + Challenge.objects.invalidate(challenge) + Challenge.objects.clean_expired() + + token = Token.objects.create(user=form.user_cache) + return Response({"token": token.key}, status=HTTP_200_OK) + + +class CryptoLockAPISignUpView(CreateUserMixin, CreateChallengeMixin, APIView): + """Endpoint to create a new user using cryptocurrency wallet address.""" + + http_method_names = ["get", "post"] + + def post(self, request, format=None): + """Verifies the signature and creates a new user account.""" + form = SimpleSignUpForm(request, request.data) + if not form.is_valid(): + return Response(form.errors, status=status.HTTP_400_BAD_REQUEST) + + username = form.cleaned_data["username"] + address = form.cleaned_data["address"] + challenge = form.cleaned_data["challenge"] + signature = form.cleaned_data["signature"] + network = [n[1] for n in Address.NETWORKS if n[0] == form.network][0] + + try: + valid_sig = verify_signature( + network, address, challenge, signature, self.request + ) + except JSONRPCException: + return Response( + {"__all__": [_("Error connecting to Monero daemon")]}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + if valid_sig: + self.create_user(username, challenge, address, form.network) + return Response({}, status=status.HTTP_201_CREATED) + else: + return Response( + {"signature": [_("Invalid signature")]}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/django_cryptolock/mixins.py b/django_cryptolock/mixins.py new file mode 100644 index 0000000..8777788 --- /dev/null +++ b/django_cryptolock/mixins.py @@ -0,0 +1,30 @@ +from django.db import transaction +from django.contrib.auth import get_user_model + +from rest_framework.response import Response +from rest_framework import status + +from pybitid import bitid + +from .models import Challenge +from .serializers import ChallengeSerializer + + +class CreateChallengeMixin: + """Add create challenge on get functionality to API views.""" + + def get(self, request, format=None): + """Returns a new challenge for the login.""" + serializer = ChallengeSerializer(instance=Challenge.objects.generate()) + serializer.data["challenge"] = bitid.build_uri( + request.build_absolute_uri(), serializer.data["challenge"] + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class CreateUserMixin: + @transaction.atomic + def create_user(self, username, challenge, address, network): + user = get_user_model().objects.create(username=username) + user.address_set.create(address=address, network=network) + Challenge.objects.invalidate(challenge) diff --git a/django_cryptolock/serializers.py b/django_cryptolock/serializers.py new file mode 100644 index 0000000..04e46eb --- /dev/null +++ b/django_cryptolock/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from .models import Challenge +from .forms import SimpleSignUpForm + + +class ChallengeSerializer(serializers.ModelSerializer): + class Meta: + model = Challenge + fields = ["challenge", "expires"] diff --git a/django_cryptolock/utils.py b/django_cryptolock/utils.py index 5c3ba36..e7aa6ee 100644 --- a/django_cryptolock/utils.py +++ b/django_cryptolock/utils.py @@ -1,4 +1,5 @@ import warnings +from typing import Union from secrets import token_hex from django.conf import settings @@ -38,6 +39,21 @@ def verify_bitcoin_signature( ) +def verify_signature( + network: str, address: str, challenge: str, signature: str, request: HttpRequest +): + valid_sig = False + + if network == "Bitcoin": + valid_sig = verify_bitcoin_signature( + address, challenge, signature, request=request + ) + elif network == "Monero": + valid_sig = verify_monero_signature(address, challenge, signature) + + return valid_sig + + def generate_challenge(): """Generates a new random challenge for the authentication.""" num_bytes = getattr(settings, "DJCL_CHALLENGE_BYTES", 16) diff --git a/django_cryptolock/views.py b/django_cryptolock/views.py index d157f6c..7600734 100644 --- a/django_cryptolock/views.py +++ b/django_cryptolock/views.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- from django.utils.translation import gettext_lazy as _ from django.contrib.auth.views import LoginView -from django.contrib.auth import get_user_model from django.views.generic import FormView from django.forms.utils import ErrorList from django.conf import settings @@ -9,8 +8,9 @@ from django.conf import settings from monerorpc.authproxy import JSONRPCException from .forms import SimpleSignUpForm, SimpleLoginForm -from .utils import verify_monero_signature, verify_bitcoin_signature +from .utils import verify_signature from .models import Address, Challenge +from .mixins import CreateUserMixin class CryptoLockLoginView(LoginView): @@ -25,7 +25,7 @@ class CryptoLockLoginView(LoginView): return response -class CryptoLockSignUpView(FormView): +class CryptoLockSignUpView(CreateUserMixin, FormView): template_name = "django_cryptolock/signup.html" form_class = SimpleSignUpForm @@ -33,22 +33,24 @@ class CryptoLockSignUpView(FormView): return self.form_class(request=self.request, **self.get_form_kwargs()) def form_valid(self, form): + username = form.cleaned_data["username"] + address = form.cleaned_data["address"] + challenge = form.cleaned_data["challenge"] + signature = form.cleaned_data["signature"] + network = [n[1] for n in Address.NETWORKS if n[0] == form.network][0] + try: - valid_sig = self.verify_signature(form) + valid_sig = verify_signature( + network, address, challenge, signature, self.request + ) except JSONRPCException: form._errors["__all__"] = ErrorList( [_("Error connecting to Monero daemon")] ) return self.form_invalid(form) - username = form.cleaned_data["username"] - address = form.cleaned_data["address"] - challenge = form.cleaned_data["challenge"] - if valid_sig: - user = get_user_model().objects.create(username=username) - user.address_set.create(address=address, network=form.network) - Challenge.objects.invalidate(challenge) + self.create_user(username, challenge, address, form.network) return super().form_valid(form) else: form._errors["signature"] = ErrorList([_("Invalid signature")]) @@ -56,20 +58,3 @@ class CryptoLockSignUpView(FormView): def get_success_url(self): return settings.LOGIN_REDIRECT_URL - - def verify_signature(self, form): - address = form.cleaned_data["address"] - challenge = form.cleaned_data["challenge"] - signature = form.cleaned_data["signature"] - bitcoin = form.network == Address.NETWORK_BITCOIN - monero = form.network == Address.NETWORK_MONERO - valid_sig = False - - if bitcoin: - valid_sig = verify_bitcoin_signature( - address, challenge, signature, request=self.request - ) - elif monero: - valid_sig = verify_monero_signature(address, challenge, signature) - - return valid_sig diff --git a/requirements_dev.txt b/requirements_dev.txt index 72e9843..1d8a8ff 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,5 +1,6 @@ -r requirements.txt +djangorestframework>=3.9.3 bumpversion==0.5.3 wheel==0.30.0 twine==3.1.0 diff --git a/setup.py b/setup.py index 4d6d841..58c9a98 100755 --- a/setup.py +++ b/setup.py @@ -58,6 +58,7 @@ setup( packages=["django_cryptolock"], include_package_data=True, install_requires=requirements, + extras_require={"drf": ["djangorestframework>=3.9.3"]}, license="MIT", zip_safe=False, keywords="django-cryptolock", diff --git a/tests/helpers.py b/tests/helpers.py index 091fd39..110c8cd 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,6 +1,16 @@ """ Set of functions and constants that help testing the existing functionality """ +from pybitid import bitid + +from django_cryptolock.models import Challenge + +DUMMY_CREDS = {"username": "test", "password": "insecure"} +VALID_MONERO_ADDRESS = "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq" +VALID_BITCOIN_ADDRESS = "1N5attoW1FviYGnLmRu9xjaPMKTkWxtUCW" +VALID_BITCOIN_SIG = "H5wI5uqhRCxBpyre2mYkjLxNKPi/TCj9IaHhmfnF8Wn1Pac6gsuYsd2GqTNpy/JFDv3HBSOD75pk2OsGDxE7U4o=" +VALID_BITID_URI = "bitid://www.django-cryptolock.test/?x=44d91949c7b2eb20" +EXAMPLE_LOGIN_URL = "https://www.django-cryptolock.test/" def set_monero_settings(settings): @@ -15,3 +25,7 @@ def set_bitcoin_settings(settings): "django_cryptolock.backends.BitcoinAddressBackend", "django.contrib.auth.backends.ModelBackend", ] + + +def gen_challenge(): + return bitid.build_uri(EXAMPLE_LOGIN_URL, Challenge.objects.generate()) diff --git a/tests/settings.py b/tests/settings.py index aa8de06..fcb91ad 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 from __future__ import unicode_literals, absolute_import +import os + import django DEBUG = True USE_TZ = True +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "!z^^097u*@)yq#w1n14m%uh-l67#h&uft9p+m%$$(0y(s%-q7o" @@ -17,6 +20,7 @@ INSTALLED_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sites", + "rest_framework.authtoken", "django_cryptolock", ] @@ -27,6 +31,13 @@ if django.VERSION >= (1, 10): else: MIDDLEWARE_CLASSES = () +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "tests/templates")], + } +] + AUTHENTICATION_BACKENDS = [ "django_cryptolock.backends.MoneroAddressBackend", "django_cryptolock.backends.BitcoinAddressBackend", diff --git a/tests/templates/django_cryptolock/login.html b/tests/templates/django_cryptolock/login.html new file mode 100644 index 0000000..e69de29 diff --git a/tests/templates/django_cryptolock/signup.html b/tests/templates/django_cryptolock/signup.html new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api_views.py b/tests/test_api_views.py new file mode 100644 index 0000000..b5d09ff --- /dev/null +++ b/tests/test_api_views.py @@ -0,0 +1,209 @@ +from unittest.mock import patch + +from django.urls import reverse_lazy +from django.contrib.auth import get_user_model + +from rest_framework.test import APIClient +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_405_METHOD_NOT_ALLOWED, + HTTP_400_BAD_REQUEST, +) +from model_mommy import mommy +import pytest + +from django_cryptolock.models import Address, Challenge +from .helpers import ( + VALID_BITCOIN_ADDRESS, + VALID_MONERO_ADDRESS, + gen_challenge, + set_bitcoin_settings, + set_monero_settings, +) + +User = get_user_model() +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.mark.parametrize("method", ["put", "patch", "delete", "head", "options"]) +def test_methods_not_allowed_for_token_login(api_client, method): + func = getattr(api_client, method) + response = func(reverse_lazy("api_token_login")) + assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED + + +def test_generate_new_token_login_challenge(api_client): + response = api_client.get(reverse_lazy("api_token_login")) + assert response.status_code == HTTP_200_OK + assert "challenge" in response.json().keys() + assert "expires" in response.json().keys() + + +@pytest.mark.parametrize( + "addr,set_backend,network", + [ + (VALID_MONERO_ADDRESS, set_monero_settings, "monero"), + (VALID_BITCOIN_ADDRESS, set_bitcoin_settings, "bitcoin"), + ], +) +def test_token_login_fails_invalid_data( + api_client, settings, addr, set_backend, network +): + settings.DJCL_MONERO_NETWORK = "mainnet" + set_backend(settings) + + net = Address.NETWORK_BITCOIN if network == "bitcoin" else Address.NETWORK_MONERO + user = mommy.make(User) + mommy.make(Address, user=user, address=addr, network=net) + challenge = gen_challenge() + + with patch(f"django_cryptolock.backends.verify_{network}_signature") as sig_mock: + sig_mock.return_value = False + response = api_client.post( + reverse_lazy("api_token_login"), + {"challenge": challenge, "address": addr, "signature": "something"}, + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + errors = response.json() + assert "Please enter a correct address or signature." in errors["__all__"] + + +@pytest.mark.parametrize( + "addr,set_backend,network", + [ + (VALID_MONERO_ADDRESS, set_monero_settings, "monero"), + (VALID_BITCOIN_ADDRESS, set_bitcoin_settings, "bitcoin"), + ], +) +def test_token_login_succeeds(api_client, settings, addr, set_backend, network): + settings.DJCL_MONERO_NETWORK = "mainnet" + set_backend(settings) + + net = Address.NETWORK_BITCOIN if network == "bitcoin" else Address.NETWORK_MONERO + user = mommy.make(User) + mommy.make(Address, user=user, address=addr, network=net) + challenge = gen_challenge() + + with patch(f"django_cryptolock.backends.verify_{network}_signature") as sig_mock: + sig_mock.return_value = True + response = api_client.post( + reverse_lazy("api_token_login"), + {"challenge": challenge, "address": addr, "signature": "something"}, + ) + + assert response.status_code == HTTP_200_OK + assert "token" in response.json().keys() + + +@pytest.mark.parametrize("method", ["put", "patch", "delete", "head", "options"]) +def test_methods_not_allowed_for_sign_up(api_client, method): + func = getattr(api_client, method) + response = func(reverse_lazy("api_signup")) + assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED + + +def test_generate_new_sign_up_challenge(api_client): + response = api_client.get(reverse_lazy("api_signup")) + assert response.status_code == HTTP_200_OK + assert "challenge" in response.json().keys() + assert "expires" in response.json().keys() + + +def test_sign_up_fails_no_input(api_client): + response = api_client.post(reverse_lazy("api_signup")) + errors = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert "This field is required." in errors["challenge"] + assert "This field is required." in errors["address"] + assert "This field is required." in errors["signature"] + assert "This field is required." in errors["username"] + + +@pytest.mark.parametrize( + "addr,set_backend", + [ + (VALID_MONERO_ADDRESS, set_monero_settings), + (VALID_BITCOIN_ADDRESS, set_bitcoin_settings), + ], +) +def test_sign_up_fails_duplicate_address(api_client, settings, addr, set_backend): + settings.DJCL_MONERO_NETWORK = "mainnet" + set_backend(settings) + challenge = gen_challenge() + mommy.make(Address, address=addr) + response = api_client.post( + reverse_lazy("api_signup"), + { + "challenge": challenge, + "address": addr, + "signature": "something", + "username": "user", + }, + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + errors = response.json() + assert "This address already exists" in errors["address"] + + +@pytest.mark.parametrize( + "addr,set_backend", + [ + (VALID_MONERO_ADDRESS, set_monero_settings), + (VALID_BITCOIN_ADDRESS, set_bitcoin_settings), + ], +) +def test_sign_up_fails_invalid_signature(api_client, settings, addr, set_backend): + settings.DJCL_MONERO_NETWORK = "mainnet" + set_backend(settings) + challenge = gen_challenge() + + with patch("django_cryptolock.api_views.verify_signature") as sig_mock: + sig_mock.return_value = False + response = api_client.post( + reverse_lazy("api_signup"), + { + "challenge": challenge, + "address": addr, + "signature": "something", + "username": "user", + }, + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + errors = response.json() + assert "Invalid signature" in errors["signature"] + + +@pytest.mark.parametrize( + "addr,set_backend", + [ + (VALID_MONERO_ADDRESS, set_monero_settings), + (VALID_BITCOIN_ADDRESS, set_bitcoin_settings), + ], +) +def test_sign_up_succeeds(api_client, settings, addr, set_backend): + settings.DJCL_MONERO_NETWORK = "mainnet" + set_backend(settings) + challenge = gen_challenge() + + with patch("django_cryptolock.api_views.verify_signature") as sig_mock: + sig_mock.return_value = True + response = api_client.post( + reverse_lazy("api_signup"), + { + "challenge": challenge, + "address": addr, + "signature": "something", + "username": "user", + }, + ) + + assert response.status_code == HTTP_201_CREATED diff --git a/tests/test_backends.py b/tests/test_backends.py index d36e4c0..0e530f4 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -9,18 +9,20 @@ from model_mommy import mommy from django_cryptolock.models import Address -from .helpers import set_monero_settings, set_bitcoin_settings +from .helpers import ( + set_monero_settings, + set_bitcoin_settings, + DUMMY_CREDS, + VALID_BITCOIN_ADDRESS, + VALID_BITCOIN_SIG, + VALID_BITID_URI, + VALID_MONERO_ADDRESS, + EXAMPLE_LOGIN_URL, +) User = get_user_model() pytestmark = pytest.mark.django_db -DUMMY_CREDS = {"username": "test", "password": "insecure"} -VALID_MONERO_ADDRESS = "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq" -VALID_BITCOIN_ADDRESS = "1N5attoW1FviYGnLmRu9xjaPMKTkWxtUCW" -VALID_BITCOIN_SIG = "H5wI5uqhRCxBpyre2mYkjLxNKPi/TCj9IaHhmfnF8Wn1Pac6gsuYsd2GqTNpy/JFDv3HBSOD75pk2OsGDxE7U4o=" -VALID_BITID_URI = "bitid://www.django-cryptolock.test/?x=44d91949c7b2eb20" -EXAMPLE_LOGIN_URL = "https://www.django-cryptolock.test/" - @pytest.fixture def existing_user(): diff --git a/tests/test_forms.py b/tests/test_forms.py index 9494b9b..11b3afa 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -11,12 +11,15 @@ from pybitid import bitid from django_cryptolock.forms import SimpleLoginForm, SimpleSignUpForm from django_cryptolock.models import Address, Challenge -from .helpers import set_monero_settings, set_bitcoin_settings +from .helpers import ( + set_monero_settings, + set_bitcoin_settings, + VALID_MONERO_ADDRESS, + VALID_BITCOIN_ADDRESS, +) pytestmark = pytest.mark.django_db -VALID_MONERO_ADDRESS = "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq" -VALID_BITCOIN_ADDRESS = "1N5attoW1FviYGnLmRu9xjaPMKTkWxtUCW" FUTURE_TIME = timezone.now() + timedelta(minutes=15) User = get_user_model() diff --git a/tests/urls.py b/tests/urls.py index 99d679c..7bf7bf4 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -2,8 +2,18 @@ from __future__ import unicode_literals, absolute_import from django.conf.urls import url, include +from django.urls import path + +from django_cryptolock.api_views import ( + CryptoLockAPITokenLoginView, + CryptoLockAPISignUpView, +) urlpatterns = [ - url(r"^", include("django_cryptolock.urls", namespace="django_cryptolock")) + path( + "api/token_login", CryptoLockAPITokenLoginView.as_view(), name="api_token_login" + ), + path("api/signup", CryptoLockAPISignUpView.as_view(), name="api_signup"), + url(r"^", include("django_cryptolock.urls", namespace="django_cryptolock")), ]