diff --git a/django_cryptolock/backends.py b/django_cryptolock/backends.py index 99637ba..cc022a4 100644 --- a/django_cryptolock/backends.py +++ b/django_cryptolock/backends.py @@ -21,12 +21,13 @@ class MoneroAddressBackend(ModelBackend): in the future it should be done locally to be more reliable and more performant. """ - if not all(address, challenge, signature): + if not all([address, challenge, signature]): return None - try: - stored_address = Address.objects.get(address=address).select_related("user") - except: + stored_address = ( + Address.objects.select_related("user").filter(address=address).first() + ) + if not stored_address: return None try: diff --git a/django_cryptolock/forms.py b/django_cryptolock/forms.py index 892326f..e655f8a 100644 --- a/django_cryptolock/forms.py +++ b/django_cryptolock/forms.py @@ -1,8 +1,95 @@ from django import forms +from django.contrib.auth import authenticate +from django.utils.translation import gettext, gettext_lazy as _ + +from .models import Address +from .validators import validate_monero_address +from .utils import generate_challenge -class SimpleSignUpForm(forms.Form): - username = forms.CharField() +class ChallengeMixin(forms.Form): + """ + Used on authentication forms to make sure an unique challenge is included. + + This mixin ensures that the challenge is always controlled by the server. + """ + challenge = forms.CharField() - address = forms.CharField() + + def include_challange(self): + new_challenge = generate_challenge() + if not self.data: + self.request.session["current_challenge"] = new_challenge + self.initial["challenge"] = new_challenge + + def clean_challenge(self): + challenge = self.cleaned_data.get("challenge") + if not challenge or challenge != self.request.session.get("current_challenge"): + raise forms.ValidationError(_("Invalid or outdated challenge")) + + return challenge + + +class SimpleLoginForm(ChallengeMixin, forms.Form): + """Basic login form, that can be used as reference for implementation.""" + + address = forms.CharField(validators=[validate_monero_address]) signature = forms.CharField() + + error_messages = { + "invalid_login": _("Please enter a correct Monero address or signature."), + "inactive": _("This account is inactive."), + } + + def __init__(self, request=None, *args, **kwargs): + """When rendering the form (no data provided) a new challenge + must be created.""" + super().__init__(*args, **kwargs) + self.request = request + self.user_cache = None + self.include_challange() + + def clean(self): + address = self.cleaned_data.get("address") + challenge = self.cleaned_data.get("challenge") + signature = self.cleaned_data.get("signature") + + if address and challenge and signature: + self.user_cache = authenticate( + self.request, address=address, challenge=challenge, signature=signature + ) + if self.user_cache is None: + raise self.get_invalid_login_error() + else: + self.confirm_login_allowed(self.user_cache) + + return self.cleaned_data + + def confirm_login_allowed(self, user): + if not user.is_active: + raise forms.ValidationError( + self.error_messages["inactive"], code="inactive" + ) + + def get_user(self): + return self.user_cache + + def get_invalid_login_error(self): + return forms.ValidationError( + self.error_messages["invalid_login"], code="invalid_login" + ) + + +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]) + signature = forms.CharField() + + def __init__(self, request=None, *args, **kwargs): + """When rendering the form (no data provided) a new challenge + must be created.""" + super().__init__(*args, **kwargs) + self.request = request + self.include_challange() diff --git a/django_cryptolock/utils.py b/django_cryptolock/utils.py index 77aa44d..7a6ee01 100644 --- a/django_cryptolock/utils.py +++ b/django_cryptolock/utils.py @@ -1,3 +1,5 @@ +from secrets import token_hex + from django.conf import settings from monerorpc.authproxy import AuthServiceProxy @@ -16,3 +18,8 @@ def verify_signature(address: str, challenge: str, signature: str) -> bool: ) return result.get("good", False) + + +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 fb271ca..a290b35 100644 --- a/django_cryptolock/views.py +++ b/django_cryptolock/views.py @@ -1,14 +1,47 @@ # -*- 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 +from django.urls import reverse -from .forms import SimpleSignUpForm +from monerorpc.authproxy import JSONRPCException + +from .forms import SimpleSignUpForm, SimpleLoginForm +from .utils import verify_signature class MoneroLoginView(LoginView): - pass + form_class = SimpleLoginForm class MoneroSignUpView(FormView): template_name = "registration/signup.html" form_class = SimpleSignUpForm + + def get_form(self, form_class=None): + 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) + except JSONRPCException: + form._errors["__all__"] = ErrorList([_("Error connecting to daemon")]) + return self.form_invalid(form) + + if valid_sig: + user = get_user_model().objects.create(username=username) + user.address_set.create(address=address) + return super().form_valid(form) + else: + form._errors["signature"] = ErrorList([_("Invalid signature")]) + return self.form_invalid(form) + + def get_success_url(self): + return settings.LOGIN_REDIRECT_URL diff --git a/example/example/settings.py b/example/example/settings.py index 8b95b8d..e12f31e 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -111,8 +111,16 @@ USE_TZ = True STATIC_URL = "/static/" +# Auth Settings +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/" + + # Monero Cryptolock Settings # Wallet RPC + +AUTHENTICATION_BACKENDS = ["django_cryptolock.backends.MoneroAddressBackend"] +DJCL_MONERO_NETWORK = "mainnet" MONERO_WALLET_RPC_PROTOCOL = os.environ.get("MONERO_WALLET_RPC_PROTOCOL", "http") MONERO_WALLET_RPC_HOST = os.environ.get("MONERO_WALLET_RPC_HOST", "localhost:6000") MONERO_WALLET_RPC_USER = os.environ.get("MONERO_WALLET_RPC_USER") diff --git a/example/templates/index.html b/example/templates/index.html index b632083..a5ea0b7 100644 --- a/example/templates/index.html +++ b/example/templates/index.html @@ -10,6 +10,7 @@
  • ...
  • + {% csrf_token %}
    {% else %} diff --git a/example/templates/registration/login.html b/example/templates/registration/login.html index 4cfce0b..6e4607c 100644 --- a/example/templates/registration/login.html +++ b/example/templates/registration/login.html @@ -1,7 +1,8 @@ {% extends 'django_cryptolock/base.html' %} {% block content %} -
    + + {% csrf_token %} {{form}}
    diff --git a/example/templates/registration/signup.html b/example/templates/registration/signup.html index fa87490..c82a0bc 100644 --- a/example/templates/registration/signup.html +++ b/example/templates/registration/signup.html @@ -1,7 +1,8 @@ {% extends 'django_cryptolock/base.html' %} {% block content %} -
    + + {% csrf_token %} {{form}}