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
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.

View File

@ -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:

View File

@ -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

View File

@ -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):

View File

@ -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"),
]

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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,
)

View File

@ -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={