From ed4e08501165e2ccb4abdc1437002af9bc41943b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Val=C3=A9rio?= Date: Tue, 10 Mar 2020 15:00:11 +0000 Subject: [PATCH] first working prototype of bitid --- README.rst | 6 +++++- django_cryptolock/backends.py | 25 +++++++--------------- django_cryptolock/forms.py | 18 +++++++++++++--- django_cryptolock/models.py | 2 ++ django_cryptolock/urls.py | 6 +++--- django_cryptolock/utils.py | 21 +++++++++++++++++- django_cryptolock/views.py | 40 +++++++++++++++++++++++++++-------- example/example/settings.py | 8 +++++-- tests/test_backends.py | 8 +++---- tests/test_forms.py | 8 +++++++ 10 files changed, 102 insertions(+), 40 deletions(-) diff --git a/README.rst b/README.rst index b107c16..6f11caa 100644 --- a/README.rst +++ b/README.rst @@ -64,8 +64,12 @@ Add the following settings to your project for the Monero Backend: ``DJCL_MONERO_WALLET_RPC_USER`` and ``DJCL_MONERO_WALLET_RPC_PASS`` to specify which wallet RPC should be used. -In case only Bitcoin Backend is used, you just need ``DJCL_MONERO_NETWORK`` with +In case only Bitcoin Backend is used, you just need: + +* ``DJCL_BITCOIN_NETWORK`` with one of two possible values: ``mainnet`` or ``testnet``. +* Add ``django_cryptolock.backends.BitcoinAddressBackend`` to your + ``AUTHENTICATION_BACKENDS`` Finaly create the templates files (``login.html`` and ``signup.html``) under a ``django_cryptolock`` subfolder. diff --git a/django_cryptolock/backends.py b/django_cryptolock/backends.py index f199f05..55a6594 100644 --- a/django_cryptolock/backends.py +++ b/django_cryptolock/backends.py @@ -1,5 +1,3 @@ -import warnings - from django.contrib.auth.backends import ModelBackend from django.contrib.auth import get_user_model from django.core.exceptions import PermissionDenied @@ -7,10 +5,9 @@ from django.utils.translation import gettext_lazy as _ from django.conf import settings from monerorpc.authproxy import AuthServiceProxy, JSONRPCException -from pybitid import bitid from .models import Address -from .utils import verify_signature +from .utils import verify_monero_signature, verify_bitcoin_signature User = get_user_model() @@ -38,7 +35,9 @@ class MoneroAddressBackend(ModelBackend): if not stored_address: return None try: - is_valid = verify_signature(address, challenge, signature) + is_valid = verify_monero_signature( + stored_address.address, challenge, signature + ) except JSONRPCException: raise PermissionDenied(_("Error while validating signature")) @@ -52,21 +51,14 @@ class BitcoinAddressBackend(ModelBackend): """Custom Bitcoin-BitId authentication backend.""" def authenticate( - self, request, address=None, bitid_uri=None, signature=None, **kwargs + self, request, address=None, challenge=None, signature=None, **kwargs ): """ Validates the provided signature for the given Bitcoin address and challenge. This method does not rely on any external components, everything is done locally. """ - network = getattr(settings, "DJCL_BITCOIN_NETWORK", None) - if not network: - warnings.warn( - _("Please configure the bitcoin network in the settings file") - ) - is_testnet = True if network == "testnet" else False - - if not all([address, bitid_uri, signature]): + if not all([address, challenge, signature]): return None stored_address = ( @@ -77,9 +69,8 @@ class BitcoinAddressBackend(ModelBackend): if not stored_address: return None - callback_uri = request.build_absolute_uri() - valid_signature = bitid.challenge_valid( - address, signature, bitid_uri, callback_uri, is_testnet + valid_signature = verify_bitcoin_signature( + stored_address.address, challenge, signature, request ) if valid_signature: diff --git a/django_cryptolock/forms.py b/django_cryptolock/forms.py index eec3ca8..cc9239b 100644 --- a/django_cryptolock/forms.py +++ b/django_cryptolock/forms.py @@ -1,7 +1,10 @@ from django import forms from django.contrib.auth import authenticate +from django.contrib.auth import get_user_model from django.utils.translation import gettext, gettext_lazy as _ +from pybitid import bitid + from .models import Address from .validators import validate_monero_address from .utils import generate_challenge @@ -17,7 +20,9 @@ class ChallengeMixin(forms.Form): challenge = forms.CharField() def include_challange(self): - new_challenge = generate_challenge() + new_challenge = bitid.build_uri( + self.request.build_absolute_uri(), generate_challenge() + ) if not self.data: self.request.session["current_challenge"] = new_challenge self.initial["challenge"] = new_challenge @@ -33,7 +38,7 @@ class ChallengeMixin(forms.Form): class SimpleLoginForm(ChallengeMixin, forms.Form): """Basic login form, that can be used as reference for implementation.""" - address = forms.CharField(validators=[validate_monero_address]) + address = forms.CharField() signature = forms.CharField() error_messages = { @@ -84,7 +89,7 @@ class SimpleSignUpForm(ChallengeMixin, forms.Form): """Basic login form, that can be used as reference for implementation.""" username = forms.CharField() - address = forms.CharField(validators=[validate_monero_address]) + address = forms.CharField() signature = forms.CharField() def __init__(self, request=None, *args, **kwargs): @@ -96,6 +101,13 @@ class SimpleSignUpForm(ChallengeMixin, forms.Form): def clean_address(self): value = self.cleaned_data["address"] + if Address.objects.filter(address=value).exists(): raise forms.ValidationError(_("This address already exists")) return value + + def clean_username(self): + value = self.cleaned_data["username"] + if get_user_model().objects.filter(username=value).exists(): + raise forms.ValidationError(_("This username is already taken")) + return value diff --git a/django_cryptolock/models.py b/django_cryptolock/models.py index ecffe9c..a5f4cf3 100644 --- a/django_cryptolock/models.py +++ b/django_cryptolock/models.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from django.db import models +from django.http.request import HttpRequest from django.conf import settings from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError @@ -8,6 +9,7 @@ from django.core.exceptions import ValidationError from model_utils.models import TimeStampedModel from .validators import validate_monero_address, validate_bitcoin_address +from .utils import verify_bitcoin_signature, verify_monero_signature class Address(TimeStampedModel): diff --git a/django_cryptolock/urls.py b/django_cryptolock/urls.py index 5589f62..8e4016e 100644 --- a/django_cryptolock/urls.py +++ b/django_cryptolock/urls.py @@ -2,11 +2,11 @@ from django.conf.urls import url from django.views.generic import TemplateView -from .views import MoneroLoginView, MoneroSignUpView +from .views import CryptoLockLoginView, CryptoLockSignUpView app_name = "django_cryptolock" urlpatterns = [ - url(r"login", MoneroLoginView.as_view(), name="login"), - url(r"signup", MoneroSignUpView.as_view(), name="signup"), + url(r"login", CryptoLockLoginView.as_view(), name="login"), + url(r"signup", CryptoLockSignUpView.as_view(), name="signup"), ] diff --git a/django_cryptolock/utils.py b/django_cryptolock/utils.py index 47eb11b..1e5baef 100644 --- a/django_cryptolock/utils.py +++ b/django_cryptolock/utils.py @@ -1,11 +1,15 @@ +import warnings from secrets import token_hex from django.conf import settings +from django.http.request import HttpRequest +from django.utils.translation import gettext_lazy as _ from monerorpc.authproxy import AuthServiceProxy +from pybitid import bitid -def verify_signature(address: str, challenge: str, signature: str) -> bool: +def verify_monero_signature(address: str, challenge: str, signature: str) -> bool: """Makes a request to wallet RPC to verify address and signature.""" protocol = settings.DJCL_MONERO_WALLET_RPC_PROTOCOL host = settings.DJCL_MONERO_WALLET_RPC_HOST @@ -20,6 +24,21 @@ def verify_signature(address: str, challenge: str, signature: str) -> bool: return result.get("good", False) +def verify_bitcoin_signature( + address: str, challenge: str, signature: str, request: HttpRequest +) -> bool: + """Verifies if the provided bitcoin signature is valid.""" + network = getattr(settings, "DJCL_BITCOIN_NETWORK", None) + if not network: + warnings.warn(_("Please configure the bitcoin network in the settings file")) + is_testnet = True if network == "testnet" else False + callback_uri = request.build_absolute_uri() + + return bitid.challenge_valid( + address, signature, challenge, callback_uri, is_testnet + ) + + def generate_challenge(): """Generates a new random challenge for the authentication.""" return token_hex(8) diff --git a/django_cryptolock/views.py b/django_cryptolock/views.py index 406ccb4..8b57f58 100644 --- a/django_cryptolock/views.py +++ b/django_cryptolock/views.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from django.contrib.auth.views import LoginView from django.contrib.auth import get_user_model @@ -10,15 +11,16 @@ from django.urls import reverse from monerorpc.authproxy import JSONRPCException from .forms import SimpleSignUpForm, SimpleLoginForm -from .utils import verify_signature +from .utils import verify_monero_signature, verify_bitcoin_signature +from .models import Address -class MoneroLoginView(LoginView): +class CryptoLockLoginView(LoginView): template_name = "django_cryptolock/login.html" form_class = SimpleLoginForm -class MoneroSignUpView(FormView): +class CryptoLockSignUpView(FormView): template_name = "django_cryptolock/signup.html" form_class = SimpleSignUpForm @@ -26,19 +28,18 @@ class MoneroSignUpView(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"] try: - valid_sig = verify_signature(address, challenge, signature) + valid_sig, network = self.verify_signature(form) except JSONRPCException: form._errors["__all__"] = ErrorList([_("Error connecting to daemon")]) return self.form_invalid(form) + username = form.cleaned_data["username"] + address = form.cleaned_data["address"] + if valid_sig: user = get_user_model().objects.create(username=username) - user.address_set.create(address=address) + user.address_set.create(address=address, network=network) return super().form_valid(form) else: form._errors["signature"] = ErrorList([_("Invalid signature")]) @@ -46,3 +47,24 @@ class MoneroSignUpView(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_backend = "django_cryptolock.backends.BitcoinAddressBackend" + monero_backend = "django_cryptolock.backends.MoneroAddressBackend" + valid_sig = False + network = None + + if bitcoin_backend in settings.AUTHENTICATION_BACKENDS: + valid_sig = verify_bitcoin_signature( + address, challenge, signature, request=self.request + ) + network = Address.NETWORK_BITCOIN + + if monero_backend in settings.AUTHENTICATION_BACKENDS and not valid_sig: + valid_sig = verify_monero_signature(address, challenge, signature) + network = Address.NETWORK_MONERO + + return valid_sig, network diff --git a/example/example/settings.py b/example/example/settings.py index 837c2be..b1d638c 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -116,10 +116,14 @@ LOGIN_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/" -# Monero Cryptolock Settings +# Django Cryptolock Settings # Wallet RPC -AUTHENTICATION_BACKENDS = ["django_cryptolock.backends.MoneroAddressBackend"] +AUTHENTICATION_BACKENDS = [ + "django_cryptolock.backends.BitcoinAddressBackend", + "django_cryptolock.backends.MoneroAddressBackend", +] +DJCL_BITCOIN_NETWORK = "mainnet" DJCL_MONERO_NETWORK = "mainnet" DJCL_MONERO_WALLET_RPC_PROTOCOL = os.environ.get("MONERO_WALLET_RPC_PROTOCOL", "http") DJCL_MONERO_WALLET_RPC_HOST = os.environ.get("MONERO_WALLET_RPC_HOST", "localhost:6000") diff --git a/tests/test_backends.py b/tests/test_backends.py index 4106c0d..e8bd9c7 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -78,7 +78,7 @@ def test_monero_backend_invalid_signature(settings, existing_user): set_monero_settings(settings) mommy.make(Address, address=VALID_MONERO_ADDRESS, user=existing_user) - with patch("django_cryptolock.backends.verify_signature") as verify_mock: + with patch("django_cryptolock.backends.verify_monero_signature") as verify_mock: verify_mock.return_value = False user = authenticate( MagicMock(), @@ -90,11 +90,11 @@ def test_monero_backend_invalid_signature(settings, existing_user): assert user is None -def test_monero_backed_valid_signature(settings, existing_user): +def test_monero_backend_valid_signature(settings, existing_user): set_monero_settings(settings) mommy.make(Address, address=VALID_MONERO_ADDRESS, user=existing_user) - with patch("django_cryptolock.backends.verify_signature") as verify_mock: + with patch("django_cryptolock.backends.verify_monero_signature") as verify_mock: verify_mock.return_value = True user = authenticate( MagicMock(), @@ -167,7 +167,7 @@ def test_bitcoin_backend_valid_signature(settings, existing_user): user = authenticate( mock, address=VALID_BITCOIN_ADDRESS, - bitid_uri=VALID_BITID_URI, + challenge=VALID_BITID_URI, signature=VALID_BITCOIN_SIG, ) diff --git a/tests/test_forms.py b/tests/test_forms.py index b6fba6f..356b9b6 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -19,9 +19,11 @@ def test_simpleloginform_generates_new_challenge(): initial = {} request.session.__setitem__.side_effect = initial.__setitem__ request.session.__getitem__.side_effect = initial.__getitem__ + request.build_absolute_uri.return_value = "http://something/" form = SimpleLoginForm(request=request) assert form.initial.get("challenge") assert initial["current_challenge"] == form.initial.get("challenge") + assert form.initial.get("challenge").startswith("bitid://something") def test_simpleloginform_generates_no_new_challenge(): @@ -29,6 +31,7 @@ def test_simpleloginform_generates_no_new_challenge(): initial = {} request.session.__setitem__.side_effect = initial.__setitem__ request.session.__getitem__.side_effect = initial.__getitem__ + request.build_absolute_uri.return_value = "http://something/" form = SimpleLoginForm(request=request, data={"address": ""}) assert not form.initial.get("challenge") assert not initial.get("current_challenge") @@ -38,6 +41,7 @@ def test_simpleloginform_generates_no_new_challenge(): def test_simpleloginform_valid_data(settings): settings.DJCL_MONERO_NETWORK = "mainnet" request = MagicMock() + request.build_absolute_uri.return_value = "http://something/" form = SimpleLoginForm( request=request, data={ @@ -57,9 +61,11 @@ def test_simplesignupform_generaes_new_challenge(): initial = {} request.session.__setitem__.side_effect = initial.__setitem__ request.session.__getitem__.side_effect = initial.__getitem__ + request.build_absolute_uri.return_value = "http://something/" form = SimpleSignUpForm(request=request) assert form.initial.get("challenge") assert initial["current_challenge"] == form.initial.get("challenge") + assert form.initial.get("challenge").startswith("bitid://something") def test_simplesignupform_generaes_no_new_challenge(): @@ -67,6 +73,7 @@ def test_simplesignupform_generaes_no_new_challenge(): initial = {} request.session.__setitem__.side_effect = initial.__setitem__ request.session.__getitem__.side_effect = initial.__getitem__ + request.build_absolute_uri.return_value = "http://something/" form = SimpleSignUpForm(request=request, data={"address": ""}) assert not form.initial.get("challenge") assert not initial.get("current_challenge") @@ -76,6 +83,7 @@ def test_validate_address_unique(settings): settings.DJCL_MONERO_NETWORK = "mainnet" mommy.make(Address, address=VALID_ADDRESS) request = MagicMock() + request.build_absolute_uri.return_value = "http://something/" form = SimpleSignUpForm( request=request, data={