first working prototype of bitid

This commit is contained in:
Gonçalo Valério 2020-03-10 15:00:11 +00:00
parent a01e5ef393
commit ed4e085011
10 changed files with 102 additions and 40 deletions

View File

@ -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 ``DJCL_MONERO_WALLET_RPC_USER`` and ``DJCL_MONERO_WALLET_RPC_PASS`` to specify
which wallet RPC should be used. 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``. 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 Finaly create the templates files (``login.html`` and ``signup.html``) under a
``django_cryptolock`` subfolder. ``django_cryptolock`` subfolder.

View File

@ -1,5 +1,3 @@
import warnings
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@ -7,10 +5,9 @@ from django.utils.translation import gettext_lazy as _
from django.conf import settings from django.conf import settings
from monerorpc.authproxy import AuthServiceProxy, JSONRPCException from monerorpc.authproxy import AuthServiceProxy, JSONRPCException
from pybitid import bitid
from .models import Address from .models import Address
from .utils import verify_signature from .utils import verify_monero_signature, verify_bitcoin_signature
User = get_user_model() User = get_user_model()
@ -38,7 +35,9 @@ class MoneroAddressBackend(ModelBackend):
if not stored_address: if not stored_address:
return None return None
try: try:
is_valid = verify_signature(address, challenge, signature) is_valid = verify_monero_signature(
stored_address.address, challenge, signature
)
except JSONRPCException: except JSONRPCException:
raise PermissionDenied(_("Error while validating signature")) raise PermissionDenied(_("Error while validating signature"))
@ -52,21 +51,14 @@ class BitcoinAddressBackend(ModelBackend):
"""Custom Bitcoin-BitId authentication backend.""" """Custom Bitcoin-BitId authentication backend."""
def authenticate( 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. Validates the provided signature for the given Bitcoin address and challenge.
This method does not rely on any external components, everything is done locally. This method does not rely on any external components, everything is done locally.
""" """
network = getattr(settings, "DJCL_BITCOIN_NETWORK", None) if not all([address, challenge, signature]):
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]):
return None return None
stored_address = ( stored_address = (
@ -77,9 +69,8 @@ class BitcoinAddressBackend(ModelBackend):
if not stored_address: if not stored_address:
return None return None
callback_uri = request.build_absolute_uri() valid_signature = verify_bitcoin_signature(
valid_signature = bitid.challenge_valid( stored_address.address, challenge, signature, request
address, signature, bitid_uri, callback_uri, is_testnet
) )
if valid_signature: if valid_signature:

View File

@ -1,7 +1,10 @@
from django import forms from django import forms
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.contrib.auth import get_user_model
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from pybitid import bitid
from .models import Address from .models import Address
from .validators import validate_monero_address from .validators import validate_monero_address
from .utils import generate_challenge from .utils import generate_challenge
@ -17,7 +20,9 @@ class ChallengeMixin(forms.Form):
challenge = forms.CharField() challenge = forms.CharField()
def include_challange(self): def include_challange(self):
new_challenge = generate_challenge() new_challenge = bitid.build_uri(
self.request.build_absolute_uri(), generate_challenge()
)
if not self.data: if not self.data:
self.request.session["current_challenge"] = new_challenge self.request.session["current_challenge"] = new_challenge
self.initial["challenge"] = new_challenge self.initial["challenge"] = new_challenge
@ -33,7 +38,7 @@ class ChallengeMixin(forms.Form):
class SimpleLoginForm(ChallengeMixin, forms.Form): class SimpleLoginForm(ChallengeMixin, forms.Form):
"""Basic login form, that can be used as reference for implementation.""" """Basic login form, that can be used as reference for implementation."""
address = forms.CharField(validators=[validate_monero_address]) address = forms.CharField()
signature = forms.CharField() signature = forms.CharField()
error_messages = { error_messages = {
@ -84,7 +89,7 @@ class SimpleSignUpForm(ChallengeMixin, forms.Form):
"""Basic login form, that can be used as reference for implementation.""" """Basic login form, that can be used as reference for implementation."""
username = forms.CharField() username = forms.CharField()
address = forms.CharField(validators=[validate_monero_address]) address = forms.CharField()
signature = forms.CharField() signature = forms.CharField()
def __init__(self, request=None, *args, **kwargs): def __init__(self, request=None, *args, **kwargs):
@ -96,6 +101,13 @@ class SimpleSignUpForm(ChallengeMixin, forms.Form):
def clean_address(self): def clean_address(self):
value = self.cleaned_data["address"] value = self.cleaned_data["address"]
if Address.objects.filter(address=value).exists(): if Address.objects.filter(address=value).exists():
raise forms.ValidationError(_("This address already exists")) raise forms.ValidationError(_("This address already exists"))
return value 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

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.db import models from django.db import models
from django.http.request import HttpRequest
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -8,6 +9,7 @@ from django.core.exceptions import ValidationError
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from .validators import validate_monero_address, validate_bitcoin_address from .validators import validate_monero_address, validate_bitcoin_address
from .utils import verify_bitcoin_signature, verify_monero_signature
class Address(TimeStampedModel): class Address(TimeStampedModel):

View File

@ -2,11 +2,11 @@
from django.conf.urls import url from django.conf.urls import url
from django.views.generic import TemplateView from django.views.generic import TemplateView
from .views import MoneroLoginView, MoneroSignUpView from .views import CryptoLockLoginView, CryptoLockSignUpView
app_name = "django_cryptolock" app_name = "django_cryptolock"
urlpatterns = [ urlpatterns = [
url(r"login", MoneroLoginView.as_view(), name="login"), url(r"login", CryptoLockLoginView.as_view(), name="login"),
url(r"signup", MoneroSignUpView.as_view(), name="signup"), url(r"signup", CryptoLockSignUpView.as_view(), name="signup"),
] ]

View File

@ -1,11 +1,15 @@
import warnings
from secrets import token_hex from secrets import token_hex
from django.conf import settings 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 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.""" """Makes a request to wallet RPC to verify address and signature."""
protocol = settings.DJCL_MONERO_WALLET_RPC_PROTOCOL protocol = settings.DJCL_MONERO_WALLET_RPC_PROTOCOL
host = settings.DJCL_MONERO_WALLET_RPC_HOST 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) 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(): def generate_challenge():
"""Generates a new random challenge for the authentication.""" """Generates a new random challenge for the authentication."""
return token_hex(8) return token_hex(8)

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.views import LoginView from django.contrib.auth.views import LoginView
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -10,15 +11,16 @@ from django.urls import reverse
from monerorpc.authproxy import JSONRPCException from monerorpc.authproxy import JSONRPCException
from .forms import SimpleSignUpForm, SimpleLoginForm 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" template_name = "django_cryptolock/login.html"
form_class = SimpleLoginForm form_class = SimpleLoginForm
class MoneroSignUpView(FormView): class CryptoLockSignUpView(FormView):
template_name = "django_cryptolock/signup.html" template_name = "django_cryptolock/signup.html"
form_class = SimpleSignUpForm form_class = SimpleSignUpForm
@ -26,19 +28,18 @@ class MoneroSignUpView(FormView):
return self.form_class(request=self.request, **self.get_form_kwargs()) return self.form_class(request=self.request, **self.get_form_kwargs())
def form_valid(self, form): 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: try:
valid_sig = verify_signature(address, challenge, signature) valid_sig, network = self.verify_signature(form)
except JSONRPCException: except JSONRPCException:
form._errors["__all__"] = ErrorList([_("Error connecting to daemon")]) form._errors["__all__"] = ErrorList([_("Error connecting to daemon")])
return self.form_invalid(form) return self.form_invalid(form)
username = form.cleaned_data["username"]
address = form.cleaned_data["address"]
if valid_sig: if valid_sig:
user = get_user_model().objects.create(username=username) 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) return super().form_valid(form)
else: else:
form._errors["signature"] = ErrorList([_("Invalid signature")]) form._errors["signature"] = ErrorList([_("Invalid signature")])
@ -46,3 +47,24 @@ class MoneroSignUpView(FormView):
def get_success_url(self): def get_success_url(self):
return settings.LOGIN_REDIRECT_URL 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

View File

@ -116,10 +116,14 @@ LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/"
# Monero Cryptolock Settings # Django Cryptolock Settings
# Wallet RPC # 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_NETWORK = "mainnet"
DJCL_MONERO_WALLET_RPC_PROTOCOL = os.environ.get("MONERO_WALLET_RPC_PROTOCOL", "http") 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") DJCL_MONERO_WALLET_RPC_HOST = os.environ.get("MONERO_WALLET_RPC_HOST", "localhost:6000")

View File

@ -78,7 +78,7 @@ def test_monero_backend_invalid_signature(settings, existing_user):
set_monero_settings(settings) set_monero_settings(settings)
mommy.make(Address, address=VALID_MONERO_ADDRESS, user=existing_user) 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 verify_mock.return_value = False
user = authenticate( user = authenticate(
MagicMock(), MagicMock(),
@ -90,11 +90,11 @@ def test_monero_backend_invalid_signature(settings, existing_user):
assert user is None 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) set_monero_settings(settings)
mommy.make(Address, address=VALID_MONERO_ADDRESS, user=existing_user) 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 verify_mock.return_value = True
user = authenticate( user = authenticate(
MagicMock(), MagicMock(),
@ -167,7 +167,7 @@ def test_bitcoin_backend_valid_signature(settings, existing_user):
user = authenticate( user = authenticate(
mock, mock,
address=VALID_BITCOIN_ADDRESS, address=VALID_BITCOIN_ADDRESS,
bitid_uri=VALID_BITID_URI, challenge=VALID_BITID_URI,
signature=VALID_BITCOIN_SIG, signature=VALID_BITCOIN_SIG,
) )

View File

@ -19,9 +19,11 @@ def test_simpleloginform_generates_new_challenge():
initial = {} initial = {}
request.session.__setitem__.side_effect = initial.__setitem__ request.session.__setitem__.side_effect = initial.__setitem__
request.session.__getitem__.side_effect = initial.__getitem__ request.session.__getitem__.side_effect = initial.__getitem__
request.build_absolute_uri.return_value = "http://something/"
form = SimpleLoginForm(request=request) form = SimpleLoginForm(request=request)
assert form.initial.get("challenge") assert form.initial.get("challenge")
assert initial["current_challenge"] == 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(): def test_simpleloginform_generates_no_new_challenge():
@ -29,6 +31,7 @@ def test_simpleloginform_generates_no_new_challenge():
initial = {} initial = {}
request.session.__setitem__.side_effect = initial.__setitem__ request.session.__setitem__.side_effect = initial.__setitem__
request.session.__getitem__.side_effect = initial.__getitem__ request.session.__getitem__.side_effect = initial.__getitem__
request.build_absolute_uri.return_value = "http://something/"
form = SimpleLoginForm(request=request, data={"address": ""}) form = SimpleLoginForm(request=request, data={"address": ""})
assert not form.initial.get("challenge") assert not form.initial.get("challenge")
assert not initial.get("current_challenge") assert not initial.get("current_challenge")
@ -38,6 +41,7 @@ def test_simpleloginform_generates_no_new_challenge():
def test_simpleloginform_valid_data(settings): def test_simpleloginform_valid_data(settings):
settings.DJCL_MONERO_NETWORK = "mainnet" settings.DJCL_MONERO_NETWORK = "mainnet"
request = MagicMock() request = MagicMock()
request.build_absolute_uri.return_value = "http://something/"
form = SimpleLoginForm( form = SimpleLoginForm(
request=request, request=request,
data={ data={
@ -57,9 +61,11 @@ def test_simplesignupform_generaes_new_challenge():
initial = {} initial = {}
request.session.__setitem__.side_effect = initial.__setitem__ request.session.__setitem__.side_effect = initial.__setitem__
request.session.__getitem__.side_effect = initial.__getitem__ request.session.__getitem__.side_effect = initial.__getitem__
request.build_absolute_uri.return_value = "http://something/"
form = SimpleSignUpForm(request=request) form = SimpleSignUpForm(request=request)
assert form.initial.get("challenge") assert form.initial.get("challenge")
assert initial["current_challenge"] == 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(): def test_simplesignupform_generaes_no_new_challenge():
@ -67,6 +73,7 @@ def test_simplesignupform_generaes_no_new_challenge():
initial = {} initial = {}
request.session.__setitem__.side_effect = initial.__setitem__ request.session.__setitem__.side_effect = initial.__setitem__
request.session.__getitem__.side_effect = initial.__getitem__ request.session.__getitem__.side_effect = initial.__getitem__
request.build_absolute_uri.return_value = "http://something/"
form = SimpleSignUpForm(request=request, data={"address": ""}) form = SimpleSignUpForm(request=request, data={"address": ""})
assert not form.initial.get("challenge") assert not form.initial.get("challenge")
assert not initial.get("current_challenge") assert not initial.get("current_challenge")
@ -76,6 +83,7 @@ def test_validate_address_unique(settings):
settings.DJCL_MONERO_NETWORK = "mainnet" settings.DJCL_MONERO_NETWORK = "mainnet"
mommy.make(Address, address=VALID_ADDRESS) mommy.make(Address, address=VALID_ADDRESS)
request = MagicMock() request = MagicMock()
request.build_absolute_uri.return_value = "http://something/"
form = SimpleSignUpForm( form = SimpleSignUpForm(
request=request, request=request,
data={ data={