Compare commits

..

3 Commits

46 changed files with 223 additions and 1369 deletions

23
.editorconfig Normal file
View File

@ -0,0 +1,23 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{py,rst,ini}]
indent_style = space
indent_size = 4
[*.{html,css,scss,json,yml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab

View File

@ -1,68 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
name: "CodeQL"
on:
push:
branches: [master]
schedule:
- cron: "0 14 * * 6"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Override automatic language detection by changing the below list
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
language: ["python"]
# Learn more...
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@ -1,25 +0,0 @@
name: Format check
on: push
jobs:
format-check:
name: Check Codebase format
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Python 3.9
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install dependencies
run: pip install -r requirements_test.txt
- name: Run Black
run: |
black --version
black . --check

View File

@ -1,27 +0,0 @@
name: Run Test Suite
on: push
jobs:
run-test-suite:
name: Run Test Suite
runs-on: ubuntu-latest
strategy:
matrix:
python_version: [3.6, 3.7, 3.8]
django_version: [2.2, 3.1]
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python_version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python_version }}
- name: Install dependencies
run: pip install -r requirements_test.txt
- name: Run Tests
run: tox -e py${{ matrix.python_version }}-django${{ matrix.django_version }}

25
.travis.yml Normal file
View File

@ -0,0 +1,25 @@
# Config file for automatic testing at travis-ci.org
language: python
python:
- "3.6"
- "3.7"
- "3.8"
env:
- DJANGO_VERSION=django-22
- DJANGO_VERSION=django-30
install: pip install -r requirements_test.txt
script: tox -e $(echo py$TRAVIS_PYTHON_VERSION-$DJANGO_VERSION | tr -d .)
jobs:
include:
- stage: format
python: 3.7
install: pip install black
script: black --check .
after_success:
- coveralls

View File

@ -1,6 +1,13 @@
============
Contributors
============
=======
Credits
=======
Development Lead
----------------
* Gonçalo Valério <gon@ovalerio.net>
* Guy Willett - https://github.com/guywillett
Contributors
------------
None yet. Why not be the first?

View File

@ -94,21 +94,6 @@ Ready to contribute? Here's how to set up `django-cryptolock` for local developm
8. Submit a pull request through the GitHub website.
Running tests for specific environments
---------------------------------------
Do want to test only a specific python version / django version locally?
You can use tox directly::
::
source <YOURVIRTUALENV>/bin/activate
(myenv) $ pip install tox
(myenv) $ tox -e <your-python-version>-django-<22_or_30>
Pull Request Guidelines
-----------------------

View File

@ -3,22 +3,12 @@
History
-------
Development
+++++++++++
(next version)
++++++++++++++
* Challenges are now stored on the database. No longer expire when a new one is
generated.
* Added initial support for DRF, TokenAuthentication only.
* Move CI from Travis-ci to Github Actions
* Added validation for existing addresses on the signup form
0.1.0 (2020-03-31)
++++++++++++++++++
* Add validation for existing addresses on the signup form
* Add rudimentary BitId support
* Renamed the base auth views to generic names
0.0.2 (2020-01-08)
++++++++++++++++++

View File

@ -2,17 +2,16 @@
Django-Cryptolock
=============================
**DISCLAIMER: This project is no longer maintained. Feel free to fork. The
PyPI package will remain available, but any user's should replace it as soon
as possible.**
.. image:: https://badge.fury.io/py/django-cryptolock.svg
:target: https://badge.fury.io/py/django-cryptolock
Authentication using cryptocurrency wallets for Django projects.
.. image:: https://travis-ci.org/dethos/django-cryptolock.svg?branch=master
:target: https://travis-ci.org/dethos/django-cryptolock
This package provides a django app containing a set of utilities to
implement the BitId and Monero Cryptolock authentication "protocols".
.. image:: https://coveralls.io/repos/github/dethos/django-cryptolock/badge.svg
:target: https://coveralls.io/github/dethos/django-cryptolock
Future releases might include other cryptocurrencies but for the being
(until we reach some stability) all the focus will remain on BTC and XMR.
Django authentication using cryptocurrency wallets
Documentation
-------------
@ -22,40 +21,23 @@ The full documentation is at https://django-cryptolock.readthedocs.io.
Quickstart
----------
1. Install Django-Cryptolock::
Install Django-Cryptolock::
pip install django-cryptolock
2. Add it to your `INSTALLED_APPS`:
Add it to your `INSTALLED_APPS`:
.. code-block:: python
INSTALLED_APPS = (
...
"django_cryptolock.apps.DjangoCryptolockConfig",
'django_cryptolock.apps.DjangoCryptolockConfig',
...
)
3. Migrate your database::
Migrate your database.
python manage.py migrate
4. Add the following settings to your project for the Monero Backend:
.. code-block:: python
AUTHENTICATION_BACKENDS = [
"django_cryptolock.backends.MoneroAddressBackend",
...
]
DJCL_MONERO_NETWORK = "mainnet"
DJCL_MONERO_WALLET_RPC_PROTOCOL = "<http_or_https>"
DJCL_MONERO_WALLET_RPC_HOST = "<wallet_rpc_host>:<port>"
DJCL_MONERO_WALLET_RPC_USER = "<user>"
DJCL_MONERO_WALLET_RPC_PASS = "<password>"
5. Add Django-Cryptolock's URL patterns:
Add Django-Cryptolock's URL patterns:
.. code-block:: python
@ -68,4 +50,43 @@ Quickstart
...
]
More detailed information can be found in the [documentation](#documentation).
Add the following settings to your project:
* ``django_cryptolock.backends.MoneroAddressBackend`` to your
``AUTHENTICATION_BACKENDS``
* Set ``DJCL_MONERO_NETWORK`` with the network in use: ``mainnet``,
``stagenet`` or ``testnet``
* Use ``DJCL_MONERO_WALLET_RPC_PROTOCOL``, ``DJCL_MONERO_WALLET_RPC_HOST``,
``DJCL_MONERO_WALLET_RPC_USER`` and ``DJCL_MONERO_WALLET_RPC_PASS`` to specify
which wallet RPC should be used.
Finaly create the templates files (``login.html`` and ``signup.html``) under a
``django_cryptolock`` subfolder.
Features
--------
* Adds authentication based on cryptocurrency wallets to a Django project.
Running Tests
-------------
Does the code actually work?
::
source <YOURVIRTUALENV>/bin/activate
(myenv) $ pip install tox
(myenv) $ tox -e <your-python-version>-django-22
Credits
-------
Tools used in rendering this package:
* Cookiecutter_
* `cookiecutter-djangopackage`_
.. _Cookiecutter: https://github.com/audreyr/cookiecutter
.. _`cookiecutter-djangopackage`: https://github.com/pydanny/cookiecutter-djangopackage

View File

@ -1 +1 @@
__version__ = "0.1.0"
__version__ = "0.0.2"

View File

@ -1,73 +0,0 @@
from django.utils.translation import gettext_lazy as _
from rest_framework.status import HTTP_200_OK
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.authtoken.models import Token
from monerorpc.authproxy import JSONRPCException
from .models import Address, Challenge
from .forms import SimpleSignUpForm, SimpleLoginForm
from .utils import verify_signature
from .mixins import CreateUserMixin, CreateChallengeMixin
class CryptoLockAPITokenLoginView(CreateChallengeMixin, APIView):
"""Endpoint to login the user with cryptocurrency wallet address.
Using the default token backend.
"""
http_method_names = ["get", "post"]
def post(self, request, format=None):
"""Authenticates the user using the provided signature."""
form = SimpleLoginForm(request, request.data)
if not form.is_valid():
return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
challenge = form.cleaned_data["challenge"]
Challenge.objects.invalidate(challenge)
Challenge.objects.clean_expired()
token = Token.objects.create(user=form.user_cache)
return Response({"token": token.key}, status=HTTP_200_OK)
class CryptoLockAPISignUpView(CreateUserMixin, CreateChallengeMixin, APIView):
"""Endpoint to create a new user using cryptocurrency wallet address."""
http_method_names = ["get", "post"]
def post(self, request, format=None):
"""Verifies the signature and creates a new user account."""
form = SimpleSignUpForm(request, request.data)
if not form.is_valid():
return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
username = form.cleaned_data["username"]
address = form.cleaned_data["address"]
challenge = form.cleaned_data["challenge"]
signature = form.cleaned_data["signature"]
network = [n[1] for n in Address.NETWORKS if n[0] == form.network][0]
try:
valid_sig = verify_signature(
network, address, challenge, signature, self.request
)
except JSONRPCException:
return Response(
{"__all__": [_("Error connecting to Monero daemon")]},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
if valid_sig:
self.create_user(username, challenge, address, form.network)
return Response({}, status=status.HTTP_201_CREATED)
else:
return Response(
{"signature": [_("Invalid signature")]},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -1,11 +1,14 @@
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _
from monerorpc.authproxy import JSONRPCException
from monerorpc.authproxy import AuthServiceProxy, JSONRPCException
from .models import Address
from .utils import verify_monero_signature, verify_bitcoin_signature
from .utils import verify_signature
User = get_user_model()
class MoneroAddressBackend(ModelBackend):
@ -24,16 +27,12 @@ class MoneroAddressBackend(ModelBackend):
return None
stored_address = (
Address.objects.select_related("user")
.filter(address=address, network=Address.NETWORK_MONERO)
.first()
Address.objects.select_related("user").filter(address=address).first()
)
if not stored_address:
return None
try:
is_valid = verify_monero_signature(
stored_address.address, challenge, signature
)
is_valid = verify_signature(address, challenge, signature)
except JSONRPCException:
raise PermissionDenied(_("Error while validating signature"))
@ -41,35 +40,3 @@ class MoneroAddressBackend(ModelBackend):
return stored_address.user
return None
class BitcoinAddressBackend(ModelBackend):
"""Custom Bitcoin-BitId authentication backend."""
def authenticate(
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.
"""
if not all([address, challenge, signature]):
return None
stored_address = (
Address.objects.select_related("user")
.filter(address=address, network=Address.NETWORK_BITCOIN)
.first()
)
if not stored_address:
return None
valid_signature = verify_bitcoin_signature(
stored_address.address, challenge, signature, request
)
if valid_signature:
return stored_address.user
else:
return None

View File

@ -1,16 +1,9 @@
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
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.utils.translation import gettext, gettext_lazy as _
from pybitid import bitid
from .models import Address, Challenge
from .validators import validate_monero_address, validate_bitcoin_address
from .models import Address
from .validators import validate_monero_address
from .utils import generate_challenge
@ -23,23 +16,15 @@ class ChallengeMixin(forms.Form):
challenge = forms.CharField()
def include_challenge(self):
"""Created a new challenge only when no data is provided by user."""
def include_challange(self):
new_challenge = generate_challenge()
if not self.data:
new_challenge = bitid.build_uri(
self.request.build_absolute_uri(), Challenge.objects.generate()
)
self.request.session["current_challenge"] = new_challenge
self.initial["challenge"] = new_challenge
def clean_challenge(self):
challenge = self.cleaned_data.get("challenge")
challenge_uri = urlparse(challenge)
query = parse_qs(challenge_uri.query)
if not query.get("x"):
raise forms.ValidationError(_("Invalid or outdated challenge"))
token = query["x"][0]
if not token or not Challenge.objects.is_active(token):
if not challenge or challenge != self.request.session.get("current_challenge"):
raise forms.ValidationError(_("Invalid or outdated challenge"))
return challenge
@ -48,11 +33,11 @@ class ChallengeMixin(forms.Form):
class SimpleLoginForm(ChallengeMixin, forms.Form):
"""Basic login form, that can be used as reference for implementation."""
address = forms.CharField()
address = forms.CharField(validators=[validate_monero_address])
signature = forms.CharField()
error_messages = {
"invalid_login": _("Please enter a correct address or signature."),
"invalid_login": _("Please enter a correct Monero address or signature."),
"inactive": _("This account is inactive."),
}
@ -62,7 +47,7 @@ class SimpleLoginForm(ChallengeMixin, forms.Form):
super().__init__(*args, **kwargs)
self.request = request
self.user_cache = None
self.include_challenge()
self.include_challange()
def clean(self):
address = self.cleaned_data.get("address")
@ -99,7 +84,7 @@ class SimpleSignUpForm(ChallengeMixin, forms.Form):
"""Basic login form, that can be used as reference for implementation."""
username = forms.CharField()
address = forms.CharField()
address = forms.CharField(validators=[validate_monero_address])
signature = forms.CharField()
def __init__(self, request=None, *args, **kwargs):
@ -107,39 +92,10 @@ class SimpleSignUpForm(ChallengeMixin, forms.Form):
must be created."""
super().__init__(*args, **kwargs)
self.request = request
self.include_challenge()
self.network = None
self.include_challange()
def clean_address(self):
self.network = None
value = self.cleaned_data["address"]
bitcoin_backend = "django_cryptolock.backends.BitcoinAddressBackend"
monero_backend = "django_cryptolock.backends.MoneroAddressBackend"
if bitcoin_backend in settings.AUTHENTICATION_BACKENDS:
try:
validate_bitcoin_address(value)
self.network = Address.NETWORK_BITCOIN
except ValidationError:
pass
if monero_backend in settings.AUTHENTICATION_BACKENDS:
try:
validate_monero_address(value)
self.network = Address.NETWORK_MONERO
except ValidationError:
pass
if not self.network:
raise forms.ValidationError(_("Invalid 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,32 +0,0 @@
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]

View File

@ -1,23 +0,0 @@
# Generated by Django 2.2.5 on 2020-02-18 19:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("django_cryptolock", "0001_initial")]
operations = [
migrations.AddField(
model_name="address",
name="network",
field=models.PositiveSmallIntegerField(
choices=[(1, "Monero"), (2, "Bitcoin")], default=1
),
),
migrations.AlterField(
model_name="address",
name="address",
field=models.CharField(max_length=106, unique=True),
),
]

View File

@ -1,46 +0,0 @@
# 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"},
)
]

View File

@ -1,30 +0,0 @@
from django.db import transaction
from django.contrib.auth import get_user_model
from rest_framework.response import Response
from rest_framework import status
from pybitid import bitid
from .models import Challenge
from .serializers import ChallengeSerializer
class CreateChallengeMixin:
"""Add create challenge on get functionality to API views."""
def get(self, request, format=None):
"""Returns a new challenge for the login."""
serializer = ChallengeSerializer(instance=Challenge.objects.generate())
serializer.data["challenge"] = bitid.build_uri(
request.build_absolute_uri(), serializer.data["challenge"]
)
return Response(serializer.data, status=status.HTTP_200_OK)
class CreateUserMixin:
@transaction.atomic
def create_user(self, username, challenge, address, network):
user = get_user_model().objects.create(username=username)
user.address_set.create(address=address, network=network)
Challenge.objects.invalidate(challenge)

View File

@ -1,26 +1,21 @@
# -*- coding: utf-8 -*-
from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _
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
from .validators import validate_monero_address
class Address(TimeStampedModel):
"""Addresses that belong to a given user account."""
NETWORK_MONERO = 1
NETWORK_BITCOIN = 2
NETWORKS = ((NETWORK_MONERO, "Monero"), (NETWORK_BITCOIN, "Bitcoin"))
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
network = models.PositiveSmallIntegerField(choices=NETWORKS, default=NETWORK_MONERO)
address = models.CharField(max_length=106, unique=True)
address = models.CharField(
max_length=106, validators=[validate_monero_address], unique=True
)
class Meta:
"""Meta definition for Address."""
@ -31,31 +26,3 @@ class Address(TimeStampedModel):
def __str__(self):
"""Unicode representation of Address."""
return self.address
def clean(self):
try:
if self.network == self.NETWORK_MONERO:
validate_monero_address(self.address)
else:
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

View File

@ -1,10 +0,0 @@
from rest_framework import serializers
from .models import Challenge
from .forms import SimpleSignUpForm
class ChallengeSerializer(serializers.ModelSerializer):
class Meta:
model = Challenge
fields = ["challenge", "expires"]

View File

@ -1,11 +1,12 @@
# -*- coding: utf-8 -*-
from django.conf.urls import url
from django.views.generic import TemplateView
from .views import CryptoLockLoginView, CryptoLockSignUpView
from .views import MoneroLoginView, MoneroSignUpView
app_name = "django_cryptolock"
urlpatterns = [
url(r"login", CryptoLockLoginView.as_view(), name="login"),
url(r"signup", CryptoLockSignUpView.as_view(), name="signup"),
url(r"login", MoneroLoginView.as_view(), name="login"),
url(r"signup", MoneroSignUpView.as_view(), name="signup"),
]

View File

@ -1,16 +1,11 @@
import warnings
from typing import Union
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_monero_signature(address: str, challenge: str, signature: str) -> bool:
def verify_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
@ -25,36 +20,6 @@ def verify_monero_signature(address: str, challenge: str, signature: str) -> boo
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 verify_signature(
network: str, address: str, challenge: str, signature: str, request: HttpRequest
):
valid_sig = False
if network == "Bitcoin":
valid_sig = verify_bitcoin_signature(
address, challenge, signature, request=request
)
elif network == "Monero":
valid_sig = verify_monero_signature(address, challenge, signature)
return valid_sig
def generate_challenge():
"""Generates a new random challenge for the authentication."""
num_bytes = getattr(settings, "DJCL_CHALLENGE_BYTES", 16)
return token_hex(num_bytes)
return token_hex(8)

View File

@ -3,7 +3,6 @@ from django.utils.translation import gettext_lazy as _
from django.conf import settings
from monero.address import Address
from pybitid.bitid import address_valid
def validate_monero_address(value):
@ -25,14 +24,3 @@ def validate_monero_address(value):
raise ValidationError(_("Invalid address for stagenet"))
elif network == "testnet" and not address.is_testnet():
raise ValidationError(_("Invalid address for testnet"))
def validate_bitcoin_address(value):
network = getattr(settings, "DJCL_BITCOIN_NETWORK", None)
if not network:
raise ValidationError(
_("Please configure the monero network in the settings file")
)
testnet = True if network == "testnet" else False
if not address_valid(value, is_testnet=testnet):
raise ValidationError(_(f"Invalid address for {network}"))

View File

@ -1,31 +1,24 @@
# -*- 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 monerorpc.authproxy import JSONRPCException
from .forms import SimpleSignUpForm, SimpleLoginForm
from .utils import verify_signature
from .models import Address, Challenge
from .mixins import CreateUserMixin
class CryptoLockLoginView(LoginView):
class MoneroLoginView(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(CreateUserMixin, FormView):
class MoneroSignUpView(FormView):
template_name = "django_cryptolock/signup.html"
form_class = SimpleSignUpForm
@ -37,20 +30,15 @@ class CryptoLockSignUpView(CreateUserMixin, FormView):
address = form.cleaned_data["address"]
challenge = form.cleaned_data["challenge"]
signature = form.cleaned_data["signature"]
network = [n[1] for n in Address.NETWORKS if n[0] == form.network][0]
try:
valid_sig = verify_signature(
network, address, challenge, signature, self.request
)
valid_sig = verify_signature(address, challenge, signature)
except JSONRPCException:
form._errors["__all__"] = ErrorList(
[_("Error connecting to Monero daemon")]
)
form._errors["__all__"] = ErrorList([_("Error connecting to daemon")])
return self.form_invalid(form)
if valid_sig:
self.create_user(username, challenge, address, form.network)
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")])

View File

@ -12,22 +12,6 @@ django\_cryptolock.migrations.0001\_initial module
:undoc-members:
:show-inheritance:
django\_cryptolock.migrations.0002\_auto\_20200218\_1312 module
---------------------------------------------------------------
.. automodule:: django_cryptolock.migrations.0002_auto_20200218_1312
:members:
:undoc-members:
:show-inheritance:
django\_cryptolock.migrations.0003\_challenge module
----------------------------------------------------
.. automodule:: django_cryptolock.migrations.0003_challenge
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------

View File

@ -1,18 +0,0 @@
====================
Features and Roadmap
====================
Features
--------
Below are some of the features the package already supports:
* Authentication using BitId
* Authentication using Monero-Cryptolock
* Supports custom user models
Roadmap
-------
* QR code generation
* Multiple login addresses per-user

View File

@ -14,7 +14,6 @@ Contents:
readme
installation
usage
features_roadmap
contributing
authors
history

View File

@ -4,7 +4,7 @@ Installation
At the command line::
$ pip install django-cryptolock
$ easy_install django-cryptolock
Or, if you have virtualenvwrapper installed::

View File

@ -8,72 +8,19 @@ To use Django-Cryptolock in a project, add it to your `INSTALLED_APPS`:
INSTALLED_APPS = (
...
"django_cryptolock.apps.DjangoCryptolockConfig",
'django_cryptolock.apps.DjangoCryptolockConfig',
...
)
Now you should add the auth backend you wish to use on your project. You can use one or more:
.. code-block:: python
AUTHENTICATION_BACKENDS = [
"django_cryptolock.backends.BitcoinAddressBackend",
"django_cryptolock.backends.MoneroAddressBackend",
]
Required Configuration
----------------------
If you use Monero, currently the following extra settings are required:
.. code-block:: python
DJCL_MONERO_NETWORK = "mainnet" # mainnet, stagenet or testnet
DJCL_MONERO_WALLET_RPC_PROTOCOL = "<http_or_https>"
DJCL_MONERO_WALLET_RPC_HOST = "<wallet_rpc_host>:<port>"
DJCL_MONERO_WALLET_RPC_USER = "<user>"
DJCL_MONERO_WALLET_RPC_PASS = "<password>"
For Bitcoin, you only need to set the ``DJCL_BITCOIN_NETWORK``:
.. code-block:: python
DJCL_BITCOIN_NETWORK = "mainnet" # mainnet or testnet
Optional Configuration
----------------------
``DJCL_CHALLENGE_BYTES`` can be used to customize the challenge length. The
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
---------------------------------
Add Django-Cryptolock's URL patterns:
.. code-block:: python
from django.conf.urls import url
from django_cryptolock import urls as django_cryptolock_urls
urlpatterns = [
...
url(r"^auth/", include("django_cryptolock.urls", namespace="django_cryptolock")),
url(r'^', include(django_cryptolock_urls)),
...
]
This will add 2 routes :
* ``django_cryptolock:signup``
* ``django_cryptolock:login``
You can then customize the generated HTML by creating the template files
(``login.html`` and ``signup.html``) under a ``django_cryptolock`` subfolder in
your templates directory.
Both of these templates will have access to a ``form`` containing the required
fields for the authentication.

View File

@ -116,14 +116,10 @@ LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
# Django Cryptolock Settings
# Monero Cryptolock Settings
# Wallet RPC
AUTHENTICATION_BACKENDS = [
"django_cryptolock.backends.BitcoinAddressBackend",
"django_cryptolock.backends.MoneroAddressBackend",
]
DJCL_BITCOIN_NETWORK = "mainnet"
AUTHENTICATION_BACKENDS = ["django_cryptolock.backends.MoneroAddressBackend"]
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

@ -2,4 +2,3 @@ django>=2.2
django-model-utils>=2.0
monero>=0.6
python-monerorpc>=0.5.5
pybitid>=0.0.4

View File

@ -1,6 +1,5 @@
-r requirements.txt
djangorestframework>=3.9.3
bumpversion==0.5.3
wheel==0.30.0
twine==3.1.0

View File

@ -2,7 +2,6 @@
django-model-utils>=2.0
monero>=0.6
python-monerorpc>=0.5.5
djangorestframework>=3.9.3
# Test Dependencies
coverage==4.4.1
@ -14,4 +13,3 @@ pytest
pytest-django
pytest-cov
model_mommy
black

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.1.0
current_version = 0.0.2
commit = True
tag = False

View File

@ -58,7 +58,6 @@ setup(
packages=["django_cryptolock"],
include_package_data=True,
install_requires=requirements,
extras_require={"drf": ["djangorestframework>=3.9.3"]},
license="MIT",
zip_safe=False,
keywords="django-cryptolock",

View File

@ -1,31 +0,0 @@
"""
Set of functions and constants that help testing the existing functionality
"""
from pybitid import bitid
from django_cryptolock.models import Challenge
DUMMY_CREDS = {"username": "test", "password": "insecure"}
VALID_MONERO_ADDRESS = "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq"
VALID_BITCOIN_ADDRESS = "1N5attoW1FviYGnLmRu9xjaPMKTkWxtUCW"
VALID_BITCOIN_SIG = "H5wI5uqhRCxBpyre2mYkjLxNKPi/TCj9IaHhmfnF8Wn1Pac6gsuYsd2GqTNpy/JFDv3HBSOD75pk2OsGDxE7U4o="
VALID_BITID_URI = "bitid://www.django-cryptolock.test/?x=44d91949c7b2eb20"
EXAMPLE_LOGIN_URL = "https://www.django-cryptolock.test/"
def set_monero_settings(settings):
settings.AUTHENTICATION_BACKENDS = [
"django_cryptolock.backends.MoneroAddressBackend",
"django.contrib.auth.backends.ModelBackend",
]
def set_bitcoin_settings(settings):
settings.AUTHENTICATION_BACKENDS = [
"django_cryptolock.backends.BitcoinAddressBackend",
"django.contrib.auth.backends.ModelBackend",
]
def gen_challenge():
return bitid.build_uri(EXAMPLE_LOGIN_URL, Challenge.objects.generate())

View File

@ -1,13 +1,10 @@
# -*- coding: utf-8
from __future__ import unicode_literals, absolute_import
import os
import django
DEBUG = True
USE_TZ = True
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "!z^^097u*@)yq#w1n14m%uh-l67#h&uft9p+m%$$(0y(s%-q7o"
@ -20,7 +17,6 @@ INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sites",
"rest_framework.authtoken",
"django_cryptolock",
]
@ -31,22 +27,13 @@ if django.VERSION >= (1, 10):
else:
MIDDLEWARE_CLASSES = ()
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "tests/templates")],
}
]
AUTHENTICATION_BACKENDS = [
"django_cryptolock.backends.MoneroAddressBackend",
"django_cryptolock.backends.BitcoinAddressBackend",
"django.contrib.auth.backends.ModelBackend",
]
# Test only default settings
DJCL_MONERO_NETWORK = "stagenet"
DJCL_BITCOIN_NETWORK = "mainnet"
DJCL_MONERO_WALLET_RPC_HOST = "localhost:3030"
DJCL_MONERO_WALLET_RPC_USER = "test"

View File

@ -1,209 +0,0 @@
from unittest.mock import patch
from django.urls import reverse_lazy
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework.status import (
HTTP_200_OK,
HTTP_201_CREATED,
HTTP_405_METHOD_NOT_ALLOWED,
HTTP_400_BAD_REQUEST,
)
from model_mommy import mommy
import pytest
from django_cryptolock.models import Address, Challenge
from .helpers import (
VALID_BITCOIN_ADDRESS,
VALID_MONERO_ADDRESS,
gen_challenge,
set_bitcoin_settings,
set_monero_settings,
)
User = get_user_model()
pytestmark = pytest.mark.django_db
@pytest.fixture
def api_client():
return APIClient()
@pytest.mark.parametrize("method", ["put", "patch", "delete", "head", "options"])
def test_methods_not_allowed_for_token_login(api_client, method):
func = getattr(api_client, method)
response = func(reverse_lazy("api_token_login"))
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
def test_generate_new_token_login_challenge(api_client):
response = api_client.get(reverse_lazy("api_token_login"))
assert response.status_code == HTTP_200_OK
assert "challenge" in response.json().keys()
assert "expires" in response.json().keys()
@pytest.mark.parametrize(
"addr,set_backend,network",
[
(VALID_MONERO_ADDRESS, set_monero_settings, "monero"),
(VALID_BITCOIN_ADDRESS, set_bitcoin_settings, "bitcoin"),
],
)
def test_token_login_fails_invalid_data(
api_client, settings, addr, set_backend, network
):
settings.DJCL_MONERO_NETWORK = "mainnet"
set_backend(settings)
net = Address.NETWORK_BITCOIN if network == "bitcoin" else Address.NETWORK_MONERO
user = mommy.make(User)
mommy.make(Address, user=user, address=addr, network=net)
challenge = gen_challenge()
with patch(f"django_cryptolock.backends.verify_{network}_signature") as sig_mock:
sig_mock.return_value = False
response = api_client.post(
reverse_lazy("api_token_login"),
{"challenge": challenge, "address": addr, "signature": "something"},
)
assert response.status_code == HTTP_400_BAD_REQUEST
errors = response.json()
assert "Please enter a correct address or signature." in errors["__all__"]
@pytest.mark.parametrize(
"addr,set_backend,network",
[
(VALID_MONERO_ADDRESS, set_monero_settings, "monero"),
(VALID_BITCOIN_ADDRESS, set_bitcoin_settings, "bitcoin"),
],
)
def test_token_login_succeeds(api_client, settings, addr, set_backend, network):
settings.DJCL_MONERO_NETWORK = "mainnet"
set_backend(settings)
net = Address.NETWORK_BITCOIN if network == "bitcoin" else Address.NETWORK_MONERO
user = mommy.make(User)
mommy.make(Address, user=user, address=addr, network=net)
challenge = gen_challenge()
with patch(f"django_cryptolock.backends.verify_{network}_signature") as sig_mock:
sig_mock.return_value = True
response = api_client.post(
reverse_lazy("api_token_login"),
{"challenge": challenge, "address": addr, "signature": "something"},
)
assert response.status_code == HTTP_200_OK
assert "token" in response.json().keys()
@pytest.mark.parametrize("method", ["put", "patch", "delete", "head", "options"])
def test_methods_not_allowed_for_sign_up(api_client, method):
func = getattr(api_client, method)
response = func(reverse_lazy("api_signup"))
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
def test_generate_new_sign_up_challenge(api_client):
response = api_client.get(reverse_lazy("api_signup"))
assert response.status_code == HTTP_200_OK
assert "challenge" in response.json().keys()
assert "expires" in response.json().keys()
def test_sign_up_fails_no_input(api_client):
response = api_client.post(reverse_lazy("api_signup"))
errors = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert "This field is required." in errors["challenge"]
assert "This field is required." in errors["address"]
assert "This field is required." in errors["signature"]
assert "This field is required." in errors["username"]
@pytest.mark.parametrize(
"addr,set_backend",
[
(VALID_MONERO_ADDRESS, set_monero_settings),
(VALID_BITCOIN_ADDRESS, set_bitcoin_settings),
],
)
def test_sign_up_fails_duplicate_address(api_client, settings, addr, set_backend):
settings.DJCL_MONERO_NETWORK = "mainnet"
set_backend(settings)
challenge = gen_challenge()
mommy.make(Address, address=addr)
response = api_client.post(
reverse_lazy("api_signup"),
{
"challenge": challenge,
"address": addr,
"signature": "something",
"username": "user",
},
)
assert response.status_code == HTTP_400_BAD_REQUEST
errors = response.json()
assert "This address already exists" in errors["address"]
@pytest.mark.parametrize(
"addr,set_backend",
[
(VALID_MONERO_ADDRESS, set_monero_settings),
(VALID_BITCOIN_ADDRESS, set_bitcoin_settings),
],
)
def test_sign_up_fails_invalid_signature(api_client, settings, addr, set_backend):
settings.DJCL_MONERO_NETWORK = "mainnet"
set_backend(settings)
challenge = gen_challenge()
with patch("django_cryptolock.api_views.verify_signature") as sig_mock:
sig_mock.return_value = False
response = api_client.post(
reverse_lazy("api_signup"),
{
"challenge": challenge,
"address": addr,
"signature": "something",
"username": "user",
},
)
assert response.status_code == HTTP_400_BAD_REQUEST
errors = response.json()
assert "Invalid signature" in errors["signature"]
@pytest.mark.parametrize(
"addr,set_backend",
[
(VALID_MONERO_ADDRESS, set_monero_settings),
(VALID_BITCOIN_ADDRESS, set_bitcoin_settings),
],
)
def test_sign_up_succeeds(api_client, settings, addr, set_backend):
settings.DJCL_MONERO_NETWORK = "mainnet"
set_backend(settings)
challenge = gen_challenge()
with patch("django_cryptolock.api_views.verify_signature") as sig_mock:
sig_mock.return_value = True
response = api_client.post(
reverse_lazy("api_signup"),
{
"challenge": challenge,
"address": addr,
"signature": "something",
"username": "user",
},
)
assert response.status_code == HTTP_201_CREATED

View File

@ -9,53 +9,42 @@ from model_mommy import mommy
from django_cryptolock.models import Address
from .helpers import (
set_monero_settings,
set_bitcoin_settings,
DUMMY_CREDS,
VALID_BITCOIN_ADDRESS,
VALID_BITCOIN_SIG,
VALID_BITID_URI,
VALID_MONERO_ADDRESS,
EXAMPLE_LOGIN_URL,
)
VALID_ADDRESS = "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq"
User = get_user_model()
pytestmark = pytest.mark.django_db
DUMMY_CREDS = {"username": "test", "password": "insecure"}
@pytest.fixture
def existing_user():
return User.objects.create_user(**DUMMY_CREDS)
def test_monero_backend_receives_insuficient_data(settings, existing_user):
set_monero_settings(settings)
def test_monero_backend_receives_insuficient_data(existing_user):
user = authenticate(MagicMock(), username="test")
assert user is None
def test_monero_backend_lets_the_next_backend_to_be_used(settings, existing_user):
set_monero_settings(settings)
def test_monero_backend_lets_the_next_backend_to_be_used(existing_user):
user = authenticate(MagicMock(), **DUMMY_CREDS)
assert user is not None
def test_monero_backend_does_not_find_address(settings, existing_user):
set_monero_settings(settings)
def test_monero_backend_does_not_find_address(existing_user):
user = authenticate(
MagicMock(), address=VALID_MONERO_ADDRESS, challeng="1", signature="somesig"
MagicMock(), address=VALID_ADDRESS, challeng="1", signature="somesig"
)
assert user is None
def test_monero_backend_cannot_connect_to_RPC(settings, existing_user):
set_monero_settings(settings)
mommy.make(Address, address=VALID_MONERO_ADDRESS, user=existing_user)
def test_monero_backend_cannot_connect_to_RPC(existing_user):
mommy.make(Address, address=VALID_ADDRESS, user=existing_user)
user = authenticate(
MagicMock(),
address=VALID_MONERO_ADDRESS,
address=VALID_ADDRESS,
challenge="1",
signature="invalid sig",
**DUMMY_CREDS
@ -64,101 +53,25 @@ def test_monero_backend_cannot_connect_to_RPC(settings, existing_user):
assert user is None
def test_monero_backend_invalid_signature(settings, existing_user):
set_monero_settings(settings)
mommy.make(Address, address=VALID_MONERO_ADDRESS, user=existing_user)
def test_monero_backend_invalid_signature(existing_user):
mommy.make(Address, address=VALID_ADDRESS, user=existing_user)
with patch("django_cryptolock.backends.verify_monero_signature") as verify_mock:
with patch("django_cryptolock.backends.verify_signature") as verify_mock:
verify_mock.return_value = False
user = authenticate(
MagicMock(),
address=VALID_MONERO_ADDRESS,
challenge="1",
signature="invalid sig",
MagicMock(), address=VALID_ADDRESS, challenge="1", signature="invalid sig"
)
assert user is None
def test_monero_backend_valid_signature(settings, existing_user):
set_monero_settings(settings)
mommy.make(Address, address=VALID_MONERO_ADDRESS, user=existing_user)
def test_monero_backed_valid_signature(existing_user):
mommy.make(Address, address=VALID_ADDRESS, user=existing_user)
with patch("django_cryptolock.backends.verify_monero_signature") as verify_mock:
with patch("django_cryptolock.backends.verify_signature") as verify_mock:
verify_mock.return_value = True
user = authenticate(
MagicMock(),
address=VALID_MONERO_ADDRESS,
challenge="1",
signature="valid sig",
MagicMock(), address=VALID_ADDRESS, challenge="1", signature="valid sig"
)
assert user == existing_user
def test_bitcoin_backend_receives_insuficient_data(settings, existing_user):
set_bitcoin_settings(settings)
user = authenticate(MagicMock(), username="test")
assert user is None
def test_bitcoin_backend_lets_the_next_backend_to_be_used(settings, existing_user):
set_bitcoin_settings(settings)
user = authenticate(MagicMock(), **DUMMY_CREDS)
assert user is not None
def test_bitcoin_backend_does_not_find_address(settings, existing_user):
set_bitcoin_settings(settings)
user = authenticate(
MagicMock(),
address=VALID_BITCOIN_ADDRESS,
bitid_uri="bitid://something",
signature="somesig",
)
assert user is None
def test_bitcoin_backend_invalid_signature(settings, existing_user):
set_bitcoin_settings(settings)
mommy.make(
Address,
address=VALID_BITCOIN_ADDRESS,
network=Address.NETWORK_BITCOIN,
user=existing_user,
)
mock = MagicMock()
mock.build_absolute_uri.return_value = EXAMPLE_LOGIN_URL
user = authenticate(
mock,
address=VALID_BITCOIN_ADDRESS,
bitid_uri=VALID_BITID_URI,
signature="invalid sig",
)
assert user is None
def test_bitcoin_backend_valid_signature(settings, existing_user):
set_bitcoin_settings(settings)
set_bitcoin_settings(settings)
mommy.make(
Address,
address=VALID_BITCOIN_ADDRESS,
network=Address.NETWORK_BITCOIN,
user=existing_user,
)
mock = MagicMock()
mock.build_absolute_uri.return_value = EXAMPLE_LOGIN_URL
user = authenticate(
mock,
address=VALID_BITCOIN_ADDRESS,
challenge=VALID_BITID_URI,
signature=VALID_BITCOIN_SIG,
)
assert user == existing_user

View File

@ -1,239 +1,89 @@
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, Challenge
from .helpers import (
set_monero_settings,
set_bitcoin_settings,
VALID_MONERO_ADDRESS,
VALID_BITCOIN_ADDRESS,
)
from django_cryptolock.models import Address
pytestmark = pytest.mark.django_db
FUTURE_TIME = timezone.now() + timedelta(minutes=15)
VALID_ADDRESS = "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq"
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()
request.build_absolute_uri.return_value = "http://something/"
assert not Challenge.objects.all().exists()
initial = {}
request.session.__setitem__.side_effect = initial.__setitem__
request.session.__getitem__.side_effect = initial.__getitem__
form = SimpleLoginForm(request=request)
challenge = Challenge.objects.first()
assert form.initial.get("challenge")
assert form.initial.get("challenge") == gen_challenge(request, challenge.challenge)
assert form.initial.get("challenge").startswith("bitid://something")
assert initial["current_challenge"] == form.initial.get("challenge")
def test_simpleloginform_generates_no_new_challenge():
request = MagicMock()
request.build_absolute_uri.return_value = "http://something/"
assert not Challenge.objects.all().exists()
initial = {}
request.session.__setitem__.side_effect = initial.__setitem__
request.session.__getitem__.side_effect = initial.__getitem__
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"),
"address": VALID_ADDRESS,
"challenge": "12345678",
"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()
@pytest.mark.django_db
def test_simpleloginform_invalid_challenge(settings):
settings.DJCL_MONERO_NETWORK = "mainnet"
mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME)
def test_simplesignupform_generaes_new_challenge():
request = MagicMock()
request.build_absolute_uri.return_value = "http://something/"
form = SimpleLoginForm(
request=request,
data={
"address": VALID_MONERO_ADDRESS,
"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)
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()
request.build_absolute_uri.return_value = "http://something/"
assert not Challenge.objects.all().exists()
initial = {}
request.session.__setitem__.side_effect = initial.__setitem__
request.session.__getitem__.side_effect = initial.__getitem__
form = SimpleSignUpForm(request=request)
challenge = Challenge.objects.first()
assert form.initial.get("challenge")
assert form.initial.get("challenge") == gen_challenge(request, challenge.challenge)
assert form.initial.get("challenge").startswith("bitid://something")
assert initial["current_challenge"] == form.initial.get("challenge")
def test_simplesignupform_generates_no_new_challenge():
def test_simplesignupform_generaes_no_new_challenge():
request = MagicMock()
request.build_absolute_uri.return_value = "http://something/"
assert not Challenge.objects.all().exists()
initial = {}
request.session.__setitem__.side_effect = initial.__setitem__
request.session.__getitem__.side_effect = initial.__getitem__
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)
mommy.make(Address, address=VALID_ADDRESS)
request = MagicMock()
request.build_absolute_uri.return_value = "http://something/"
form = SimpleSignUpForm(
request=request,
data={
"username": "foo",
"address": VALID_MONERO_ADDRESS,
"challenge": gen_challenge(request, "12345678"),
"address": VALID_ADDRESS,
"challenge": "12345678",
"signature": "some valid signature",
},
)
assert not form.is_valid()
assert "This address already exists" in form.errors["address"]
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/"
form = SimpleSignUpForm(
request=request,
data={
"username": "foo",
"address": VALID_BITCOIN_ADDRESS,
"challenge": gen_challenge(request, "12345678"),
"signature": "some valid signature",
},
)
assert form.is_valid()
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/"
form = SimpleSignUpForm(
request=request,
data={
"username": "foo",
"address": VALID_MONERO_ADDRESS,
"challenge": gen_challenge(request, "12345678"),
"signature": "some valid signature",
},
)
assert form.is_valid()
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(
request=request,
data={
"username": "foo",
"address": "bad addr",
"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(settings):
set_bitcoin_settings(settings)
mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME)
request = MagicMock()
request.build_absolute_uri.return_value = "http://something/"
form = SimpleSignUpForm(
request=request,
data={
"username": "foo",
"address": VALID_BITCOIN_ADDRESS,
"challenge": gen_challenge(request, "1234567"),
"signature": "some valid signature",
},
)
assert not form.is_valid()
def test_simplesignupform_expired_challenge(settings):
set_bitcoin_settings(settings)
mommy.make(Challenge, challenge="12345678", expires=timezone.now())
request = MagicMock()
request.build_absolute_uri.return_value = "http://something/"
form = SimpleSignUpForm(
request=request,
data={
"username": "foo",
"address": VALID_BITCOIN_ADDRESS,
"challenge": gen_challenge(request, "12345678"),
"signature": "some valid signature",
},
)
assert not form.is_valid()

View File

@ -1,54 +0,0 @@
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

View File

@ -19,51 +19,30 @@ VALID_MONERO_MAINNET_ADDR = "45D8b4XiUdz86FwztAJHVeLnQqGHQUqiHSwZe6rXFHSoXw522dP
VALID_MONERO_STAGENET_ADDR = "55LTR8KniP4LQGJSPtbYDacR7dz8RBFnsfAKMaMuwUNYX6aQbBcovzDPyrQF9KXF9tVU6Xk3K8no1BywnJX6GvZX8yJsXvt"
VALID_MONERO_TESTNET_ADDR = "9vmn8Vyxh6JEVmPr4qTcj3ND3FywDpMXH2fVLLEARyKCJTc3jWjxeWcbRNcaa57Bj36cARBSfWnfS89oFVKBBvGTAegdRxG"
VALID_BITCOIN_TESTNET_ADDR = "n47QBape2PcisN2mkHR2YnhqoBr56iPhJh"
VALID_BITCOIN_MAINNET_ADDR = "1AUeWMGD9hDYtAhZGZLmDjEzKSrPow4zNt"
pytestmark = pytest.mark.django_db
def test_valid_monero_mainnet_address(settings):
def test_valid_mainnet_address(settings):
settings.DJCL_MONERO_NETWORK = "mainnet"
addr = mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR)
addr.full_clean()
def test_valid_monero_stagenet_addr(settings):
def test_valid_stagenet_addr(settings):
settings.DJCL_MONERO_NETWORK = "stagenet"
addr = mommy.make(Address, address=VALID_MONERO_STAGENET_ADDR)
addr.full_clean()
def test_valid_monero_testnet_addr(settings):
def test_valid_testnet_addr(settings):
settings.DJCL_MONERO_NETWORK = "testnet"
addr = mommy.make(Address, address=VALID_MONERO_TESTNET_ADDR)
addr.full_clean()
def test_valid_bitcoin_mainnet_address(settings):
settings.DJCL_BITCOIN_NETWORK = "mainnet"
addr = mommy.make(
Address, address=VALID_BITCOIN_MAINNET_ADDR, network=Address.NETWORK_BITCOIN
)
addr.full_clean()
def test_valid_bitcoin_testnet_address(settings):
settings.DJCL_BITCOIN_NETWORK = "testnet"
addr = mommy.make(
Address, address=VALID_BITCOIN_TESTNET_ADDR, network=Address.NETWORK_BITCOIN
)
addr.full_clean()
def test_invalid_address():
bad_addr = "Verywrongaddress"
addr = mommy.make(Address, address=bad_addr)
@ -72,38 +51,23 @@ def test_invalid_address():
addr.full_clean()
assert (
"Invalid address for the given network" in error.value.message_dict["__all__"]
"{} is not a valid address".format(bad_addr)
in error.value.message_dict["address"]
)
def test_wrong_monero_network_address(settings):
def test_wrong_network_address(settings):
settings.DJCL_MONERO_NETWORK = "stagenet"
addr = mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR)
with pytest.raises(ValidationError) as error:
addr.full_clean()
assert (
"Invalid address for the given network" in error.value.message_dict["__all__"]
)
def test_wrong_bitcoin_network_address(settings):
settings.DJCL_BITCOIN_NETWORK = "testnet"
addr = mommy.make(
Address, address=VALID_BITCOIN_MAINNET_ADDR, network=Address.NETWORK_BITCOIN
)
with pytest.raises(ValidationError) as error:
addr.full_clean()
assert (
"Invalid address for the given network" in error.value.message_dict["__all__"]
)
assert "Invalid address for stagenet" in error.value.message_dict["address"]
def test_address_is_unique():
mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR)
addr = mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR)
with pytest.raises(IntegrityError):
mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR)

View File

@ -4,13 +4,6 @@ from model_mommy import mommy
from django_cryptolock.utils import generate_challenge
def test_challenge_has_default_byte_len():
def test_challenge_has_8_bytes():
challenge = generate_challenge()
assert len(bytes.fromhex(challenge)) == 16
@pytest.mark.parametrize("length", (8, 16, 32, 64))
def test_challenge_has_custom_byte_len(length, settings):
settings.DJCL_CHALLENGE_BYTES = length
challenge = generate_challenge()
assert len(bytes.fromhex(challenge)) == length
assert len(bytes.fromhex(challenge)) == 8

View File

@ -2,18 +2,8 @@
from __future__ import unicode_literals, absolute_import
from django.conf.urls import url, include
from django.urls import path
from django_cryptolock.api_views import (
CryptoLockAPITokenLoginView,
CryptoLockAPISignUpView,
)
urlpatterns = [
path(
"api/token_login", CryptoLockAPITokenLoginView.as_view(), name="api_token_login"
),
path("api/signup", CryptoLockAPISignUpView.as_view(), name="api_signup"),
url(r"^", include("django_cryptolock.urls", namespace="django_cryptolock")),
url(r"^", include("django_cryptolock.urls", namespace="django_cryptolock"))
]

15
tox.ini
View File

@ -1,17 +1,16 @@
[tox]
envlist =
{py3.6,py3.7,py3.8}-django{2.2,3.1}
{py36,py37,py38}-django-{22,30}
[testenv]
setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/django_cryptolock
commands =
pytest --cov=django_cryptolock tests/
commands = pytest --cov=django_cryptolock tests/
deps =
django2.2: django>=2.2,<3.0
django3.1: django>=3.1,<3.2
django-22: django>=2.2,<3.0
django-30: django>=3.0,<3.1
-r {toxinidir}/requirements_test.txt
basepython =
py3.8: python3.8
py3.7: python3.7
py3.6: python3.6
py38: python3.8
py37: python3.7
py36: python3.6