current active challenges are now stored on the database
This commit is contained in:
parent
0ad15c6277
commit
045f79f867
|
@ -1,3 +1,5 @@
|
|||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth import get_user_model
|
||||
|
@ -7,7 +9,7 @@ from django.conf import settings
|
|||
|
||||
from pybitid import bitid
|
||||
|
||||
from .models import Address
|
||||
from .models import Address, Challenge
|
||||
from .validators import validate_monero_address, validate_bitcoin_address
|
||||
from .utils import generate_challenge
|
||||
|
||||
|
@ -21,17 +23,22 @@ class ChallengeMixin(forms.Form):
|
|||
|
||||
challenge = forms.CharField()
|
||||
|
||||
def include_challange(self):
|
||||
new_challenge = bitid.build_uri(
|
||||
self.request.build_absolute_uri(), generate_challenge()
|
||||
)
|
||||
def include_challenge(self):
|
||||
"""Created a new challenge only when no data is provided by user."""
|
||||
if not self.data:
|
||||
self.request.session["current_challenge"] = new_challenge
|
||||
new_challenge = bitid.build_uri(
|
||||
self.request.build_absolute_uri(), Challenge.objects.generate()
|
||||
)
|
||||
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"):
|
||||
challenge_uri = urlparse(self.cleaned_data.get("challenge"))
|
||||
query = parse_qs(challenge_uri.query)
|
||||
if not query.get("x"):
|
||||
raise forms.ValidationError(_("Invalid or outdated challenge"))
|
||||
|
||||
challenge = query["x"][0]
|
||||
if not challenge or not Challenge.objects.is_active(challenge):
|
||||
raise forms.ValidationError(_("Invalid or outdated challenge"))
|
||||
|
||||
return challenge
|
||||
|
@ -54,7 +61,7 @@ class SimpleLoginForm(ChallengeMixin, forms.Form):
|
|||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
self.user_cache = None
|
||||
self.include_challange()
|
||||
self.include_challenge()
|
||||
|
||||
def clean(self):
|
||||
address = self.cleaned_data.get("address")
|
||||
|
@ -99,7 +106,7 @@ class SimpleSignUpForm(ChallengeMixin, forms.Form):
|
|||
must be created."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.request = request
|
||||
self.include_challange()
|
||||
self.include_challenge()
|
||||
self.network = None
|
||||
|
||||
def clean_address(self):
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.db.models.manager import Manager
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from .utils import generate_challenge
|
||||
|
||||
|
||||
class ChallengeManager(Manager):
|
||||
"""Provides methods to easily create and verify challenges."""
|
||||
|
||||
def generate(self):
|
||||
token = generate_challenge()
|
||||
age = getattr(settings, "DJCL_CHALLENGE_EXPIRATION", 10)
|
||||
expiry_date = timezone.now() + timedelta(minutes=age)
|
||||
return self.create(challenge=token, expires=expiry_date)
|
||||
|
||||
def is_active(self, challenge):
|
||||
"""Returns True if the challenge can be used. Otherwise False."""
|
||||
now = timezone.now()
|
||||
return self.filter(challenge=challenge, expires__gte=now).exists()
|
||||
|
||||
def invalidate(self, challenge):
|
||||
"""Removes the provided challenge if it exists."""
|
||||
self.filter(challenge=challenge).delete()
|
||||
|
||||
def clean_expired(self):
|
||||
"""Delete all expired challenges. Returns nº of entries removed."""
|
||||
now = timezone.now()
|
||||
del_summary = self.filter(expires__lt=now).delete()
|
||||
return del_summary[0]
|
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 2.2.5 on 2020-05-12 11:22
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
import model_utils.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('django_cryptolock', '0002_auto_20200218_1312'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Challenge',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
|
||||
('challenge', models.CharField(max_length=150)),
|
||||
('expires', models.DateTimeField()),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Challenge',
|
||||
'verbose_name_plural': 'Challenges',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError
|
|||
from model_utils.models import TimeStampedModel
|
||||
|
||||
from .validators import validate_monero_address, validate_bitcoin_address
|
||||
from .managers import ChallengeManager
|
||||
|
||||
|
||||
class Address(TimeStampedModel):
|
||||
|
@ -39,3 +40,22 @@ class Address(TimeStampedModel):
|
|||
validate_bitcoin_address(self.address)
|
||||
except ValidationError:
|
||||
raise ValidationError(_("Invalid address for the given network"))
|
||||
|
||||
|
||||
class Challenge(TimeStampedModel):
|
||||
"""Challenges provided to users for authentication purposes."""
|
||||
|
||||
challenge = models.CharField(max_length=150)
|
||||
expires = models.DateTimeField(null=False)
|
||||
|
||||
objects = ChallengeManager()
|
||||
|
||||
class Meta:
|
||||
"""Meta definition for Challenge."""
|
||||
|
||||
verbose_name = _("Challenge")
|
||||
verbose_name_plural = _("Challenges")
|
||||
|
||||
def __str__(self):
|
||||
"""Unicode representation of Challenge."""
|
||||
return self.challenge
|
||||
|
|
|
@ -10,13 +10,20 @@ from monerorpc.authproxy import JSONRPCException
|
|||
|
||||
from .forms import SimpleSignUpForm, SimpleLoginForm
|
||||
from .utils import verify_monero_signature, verify_bitcoin_signature
|
||||
from .models import Address
|
||||
from .models import Address, Challenge
|
||||
|
||||
|
||||
class CryptoLockLoginView(LoginView):
|
||||
template_name = "django_cryptolock/login.html"
|
||||
form_class = SimpleLoginForm
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
challenge = form.cleaned_data["challenge"]
|
||||
Challenge.objects.invalidate(challenge)
|
||||
Challenge.objects.clean_expired()
|
||||
return response
|
||||
|
||||
|
||||
class CryptoLockSignUpView(FormView):
|
||||
template_name = "django_cryptolock/signup.html"
|
||||
|
@ -36,10 +43,12 @@ class CryptoLockSignUpView(FormView):
|
|||
|
||||
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)
|
||||
return super().form_valid(form)
|
||||
else:
|
||||
form._errors["signature"] = ErrorList([_("Invalid signature")])
|
||||
|
|
|
@ -20,6 +20,14 @@ django\_cryptolock.migrations.0002\_auto\_20200218\_1312 module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
django\_cryptolock.migrations.0003\_challenge module
|
||||
----------------------------------------------------
|
||||
|
||||
.. automodule:: django_cryptolock.migrations.0003_challenge
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
|
|
@ -47,6 +47,8 @@ Optional Configuration
|
|||
default is ``16`` and you should avoid lower values unless you know what you
|
||||
are doing.
|
||||
|
||||
``DJCL_CHALLENGE_EXPIRATION`` can be used to control how long a challenge is
|
||||
valid. The default value is `10` minutes.
|
||||
|
||||
Using the default forms and views
|
||||
---------------------------------
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from model_mommy import mommy
|
||||
from pybitid import bitid
|
||||
|
||||
from django_cryptolock.forms import SimpleLoginForm, SimpleSignUpForm
|
||||
from django_cryptolock.models import Address
|
||||
from django_cryptolock.models import Address, Challenge
|
||||
|
||||
from .helpers import set_monero_settings, set_bitcoin_settings
|
||||
|
||||
|
@ -14,77 +17,120 @@ pytestmark = pytest.mark.django_db
|
|||
|
||||
VALID_MONERO_ADDRESS = "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq"
|
||||
VALID_BITCOIN_ADDRESS = "1N5attoW1FviYGnLmRu9xjaPMKTkWxtUCW"
|
||||
FUTURE_TIME = timezone.now() + timedelta(minutes=15)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def gen_challenge(request, challenge):
|
||||
return bitid.build_uri(request.build_absolute_uri(), challenge)
|
||||
|
||||
|
||||
def test_simpleloginform_generates_new_challenge():
|
||||
request = MagicMock()
|
||||
initial = {}
|
||||
request.session.__setitem__.side_effect = initial.__setitem__
|
||||
request.session.__getitem__.side_effect = initial.__getitem__
|
||||
request.build_absolute_uri.return_value = "http://something/"
|
||||
assert not Challenge.objects.all().exists()
|
||||
|
||||
form = SimpleLoginForm(request=request)
|
||||
challenge = Challenge.objects.first()
|
||||
assert form.initial.get("challenge")
|
||||
assert initial["current_challenge"] == form.initial.get("challenge")
|
||||
assert form.initial.get("challenge") == gen_challenge(request, challenge.challenge)
|
||||
assert form.initial.get("challenge").startswith("bitid://something")
|
||||
|
||||
|
||||
def test_simpleloginform_generates_no_new_challenge():
|
||||
request = MagicMock()
|
||||
initial = {}
|
||||
request.session.__setitem__.side_effect = initial.__setitem__
|
||||
request.session.__getitem__.side_effect = initial.__getitem__
|
||||
request.build_absolute_uri.return_value = "http://something/"
|
||||
assert not Challenge.objects.all().exists()
|
||||
|
||||
form = SimpleLoginForm(request=request, data={"address": ""})
|
||||
assert not Challenge.objects.all().exists()
|
||||
assert not form.initial.get("challenge")
|
||||
assert not initial.get("current_challenge")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_simpleloginform_valid_data(settings):
|
||||
settings.DJCL_MONERO_NETWORK = "mainnet"
|
||||
mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME)
|
||||
request = MagicMock()
|
||||
request.build_absolute_uri.return_value = "http://something/"
|
||||
|
||||
form = SimpleLoginForm(
|
||||
request=request,
|
||||
data={
|
||||
"address": VALID_MONERO_ADDRESS,
|
||||
"challenge": gen_challenge(request, "12345678"),
|
||||
"signature": "some valid signature",
|
||||
},
|
||||
)
|
||||
with patch("django_cryptolock.forms.authenticate") as auth_mock:
|
||||
auth_mock.return_value = mommy.make(User)
|
||||
assert form.is_valid()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_simpleloginform_invalid_challenge(settings):
|
||||
settings.DJCL_MONERO_NETWORK = "mainnet"
|
||||
mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME)
|
||||
request = MagicMock()
|
||||
request.build_absolute_uri.return_value = "http://something/"
|
||||
form = SimpleLoginForm(
|
||||
request=request,
|
||||
data={
|
||||
"address": VALID_MONERO_ADDRESS,
|
||||
"challenge": "12345678",
|
||||
"challenge": gen_challenge(request, "1234567"),
|
||||
"signature": "some valid signature",
|
||||
},
|
||||
)
|
||||
with patch("django_cryptolock.forms.authenticate") as auth_mock:
|
||||
auth_mock.return_value = mommy.make(User)
|
||||
request.session.get.return_value = "12345678"
|
||||
assert form.is_valid()
|
||||
assert not form.is_valid()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_simpleloginform_expired_challenge(settings):
|
||||
settings.DJCL_MONERO_NETWORK = "mainnet"
|
||||
mommy.make(Challenge, challenge="12345678", expires=timezone.now())
|
||||
request = MagicMock()
|
||||
request.build_absolute_uri.return_value = "http://something/"
|
||||
form = SimpleLoginForm(
|
||||
request=request,
|
||||
data={
|
||||
"address": VALID_MONERO_ADDRESS,
|
||||
"challenge": gen_challenge(request, "12345678"),
|
||||
"signature": "some valid signature",
|
||||
},
|
||||
)
|
||||
with patch("django_cryptolock.forms.authenticate") as auth_mock:
|
||||
auth_mock.return_value = mommy.make(User)
|
||||
assert not form.is_valid()
|
||||
|
||||
|
||||
def test_simplesignupform_generates_new_challenge():
|
||||
request = MagicMock()
|
||||
initial = {}
|
||||
request.session.__setitem__.side_effect = initial.__setitem__
|
||||
request.session.__getitem__.side_effect = initial.__getitem__
|
||||
request.build_absolute_uri.return_value = "http://something/"
|
||||
assert not Challenge.objects.all().exists()
|
||||
|
||||
form = SimpleSignUpForm(request=request)
|
||||
challenge = Challenge.objects.first()
|
||||
assert form.initial.get("challenge")
|
||||
assert initial["current_challenge"] == form.initial.get("challenge")
|
||||
assert form.initial.get("challenge") == gen_challenge(request, challenge.challenge)
|
||||
assert form.initial.get("challenge").startswith("bitid://something")
|
||||
|
||||
|
||||
def test_simplesignupform_generates_no_new_challenge():
|
||||
request = MagicMock()
|
||||
initial = {}
|
||||
request.session.__setitem__.side_effect = initial.__setitem__
|
||||
request.session.__getitem__.side_effect = initial.__getitem__
|
||||
request.build_absolute_uri.return_value = "http://something/"
|
||||
assert not Challenge.objects.all().exists()
|
||||
|
||||
form = SimpleSignUpForm(request=request, data={"address": ""})
|
||||
assert not Challenge.objects.all().exists()
|
||||
assert not form.initial.get("challenge")
|
||||
assert not initial.get("current_challenge")
|
||||
|
||||
|
||||
def test_validate_address_unique(settings):
|
||||
settings.DJCL_MONERO_NETWORK = "mainnet"
|
||||
mommy.make(Address, address=VALID_MONERO_ADDRESS)
|
||||
mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME)
|
||||
request = MagicMock()
|
||||
request.build_absolute_uri.return_value = "http://something/"
|
||||
form = SimpleSignUpForm(
|
||||
|
@ -92,7 +138,7 @@ def test_validate_address_unique(settings):
|
|||
data={
|
||||
"username": "foo",
|
||||
"address": VALID_MONERO_ADDRESS,
|
||||
"challenge": "12345678",
|
||||
"challenge": gen_challenge(request, "12345678"),
|
||||
"signature": "some valid signature",
|
||||
},
|
||||
)
|
||||
|
@ -100,17 +146,18 @@ def test_validate_address_unique(settings):
|
|||
assert "This address already exists" in form.errors["address"]
|
||||
|
||||
|
||||
def test_simplesignupform_validate_bitcoin_addr(settings):
|
||||
def test_simplesignupform_valid_bitcoin_addr(settings):
|
||||
set_bitcoin_settings(settings)
|
||||
mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME)
|
||||
|
||||
request = MagicMock()
|
||||
request.build_absolute_uri.return_value = "http://something/"
|
||||
request.session.get.return_value = "12345678"
|
||||
form = SimpleSignUpForm(
|
||||
request=request,
|
||||
data={
|
||||
"username": "foo",
|
||||
"address": VALID_BITCOIN_ADDRESS,
|
||||
"challenge": "12345678",
|
||||
"challenge": gen_challenge(request, "12345678"),
|
||||
"signature": "some valid signature",
|
||||
},
|
||||
)
|
||||
|
@ -119,23 +166,25 @@ def test_simplesignupform_validate_bitcoin_addr(settings):
|
|||
|
||||
def test_simplesignupform_valid_monero_addr(settings):
|
||||
set_monero_settings(settings)
|
||||
mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME)
|
||||
|
||||
settings.DJCL_MONERO_NETWORK = "mainnet"
|
||||
request = MagicMock()
|
||||
request.build_absolute_uri.return_value = "http://something/"
|
||||
request.session.get.return_value = "12345678"
|
||||
form = SimpleSignUpForm(
|
||||
request=request,
|
||||
data={
|
||||
"username": "foo",
|
||||
"address": VALID_MONERO_ADDRESS,
|
||||
"challenge": "12345678",
|
||||
"challenge": gen_challenge(request, "12345678"),
|
||||
"signature": "some valid signature",
|
||||
},
|
||||
)
|
||||
assert form.is_valid()
|
||||
|
||||
|
||||
def test_simplesignupform_validate_invalid_addr():
|
||||
def test_simplesignupform_invalid_addr():
|
||||
mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME)
|
||||
request = MagicMock()
|
||||
request.build_absolute_uri.return_value = "http://something/"
|
||||
form = SimpleSignUpForm(
|
||||
|
@ -143,9 +192,17 @@ def test_simplesignupform_validate_invalid_addr():
|
|||
data={
|
||||
"username": "foo",
|
||||
"address": "bad addr",
|
||||
"challenge": "12345678",
|
||||
"challenge": gen_challenge(request, "12345678"),
|
||||
"signature": "some valid signature",
|
||||
},
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert "Invalid address" in form.errors["address"]
|
||||
|
||||
|
||||
# def test_simplesignupform_invalid_challenge():
|
||||
# pass
|
||||
|
||||
|
||||
# def test_simple_signupform_expired_challenge():
|
||||
# pass
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from model_mommy import mommy
|
||||
|
||||
from django_cryptolock.models import Challenge
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class TestChallengeManager:
|
||||
@pytest.mark.parametrize("conf", (None, 5, 10, 15, 20, 30, 60))
|
||||
def test_generate_challenge_with_expiration(self, settings, conf):
|
||||
if conf:
|
||||
settings.DJCL_CHALLENGE_EXPIRATION = conf
|
||||
test = timezone.now() + timedelta(minutes=conf + 1)
|
||||
else:
|
||||
test = timezone.now() + timedelta(minutes=11)
|
||||
|
||||
assert not Challenge.objects.all().exists()
|
||||
|
||||
challenge = Challenge.objects.generate()
|
||||
assert challenge.challenge
|
||||
assert challenge.expires < test
|
||||
assert Challenge.objects.all().exists()
|
||||
|
||||
def test_is_active_when_expired(self):
|
||||
challenge = mommy.make(Challenge, challenge="1234", expires=timezone.now())
|
||||
assert not Challenge.objects.is_active(challenge=challenge.challenge)
|
||||
|
||||
def test_is_active_when_inexistent(self):
|
||||
assert not Challenge.objects.is_active(challenge="1234")
|
||||
|
||||
def test_is_active(self):
|
||||
challenge = Challenge.objects.generate()
|
||||
assert Challenge.objects.is_active(challenge=challenge.challenge)
|
||||
|
||||
def test_invalidate_existing_challenge(self):
|
||||
challenge = Challenge.objects.generate()
|
||||
Challenge.objects.invalidate(challenge.challenge)
|
||||
assert not Challenge.objects.all().exists()
|
||||
|
||||
def test_invalidate_inexistent_challenge(self):
|
||||
Challenge.objects.invalidate("1234")
|
||||
|
||||
@pytest.mark.parametrize("num", (2, 5, 10, 15))
|
||||
def test_clean_expired_challenges(self, num):
|
||||
mommy.make(Challenge, num, expires=timezone.now())
|
||||
Challenge.objects.generate()
|
||||
deleted = Challenge.objects.clean_expired()
|
||||
assert deleted == num
|
||||
assert Challenge.objects.count() == 1
|
Loading…
Reference in New Issue