Compare commits

...

17 Commits

Author SHA1 Message Date
Gonçalo Valério 9c433636da
This package is no longer maintained 2023-01-23 11:31:05 +00:00
Gonçalo Valério 938416ac61
Remove travis-ci and add github actions workflows (#16) 2021-01-22 21:40:58 +00:00
Gonçalo Valério 49ccaeb5cb
Add code scanning
So basic security issues can be found early
2020-10-01 17:59:30 +01:00
Gonçalo Valério 3fe573e67d
Merge pull request #15 from dethos/add-rest-framework-support
First implementation of authentication views for DRF projects
2020-08-04 18:14:55 +01:00
Gonçalo Valério 1fa2d64c52 add DRF to the test dependencies 2020-07-14 18:28:33 +01:00
Gonçalo Valério 66930fbc46 update changelog 2020-07-14 18:25:16 +01:00
Gonçalo Valério c1fc972668 First implementation of authentication views for DRF projects 2020-07-14 18:17:55 +01:00
Gonçalo Valério fd6d243bfe
Merge pull request #11 from dethos/2-make-session-optional
Store challenges on the database
2020-05-13 11:39:38 +01:00
Gonçalo Valério 63bbc30df5 Add both backends to the example project again 2020-05-12 18:10:19 +01:00
Gonçalo Valério 5cba97f144 fix error related to mutating data while validating the challenge 2020-05-12 17:50:40 +01:00
Gonçalo Valério 62bb8d9e62 updated change log with issue #2 2020-05-12 17:04:40 +01:00
Gonçalo Valério c4daaba4f1 format migration file 2020-05-12 17:02:10 +01:00
Gonçalo Valério 045f79f867 current active challenges are now stored on the database 2020-05-12 16:53:23 +01:00
Gonçalo Valério 0ad15c6277 Challenge length is now configurable 2020-05-12 13:53:39 +01:00
Gonçalo Valério 7b024282e9 Black formatting is currently mandatory, so there isnt a need for editor config 2020-04-21 11:35:39 +01:00
Gonçalo Valério e9b5bc1e6a Fix some typos on the readme file. 2020-04-21 11:34:47 +01:00
Gonçalo Valério a5e084e728 fix rst error on history doc 2020-03-31 19:05:43 +01:00
33 changed files with 887 additions and 190 deletions

View File

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

68
.github/workflows/codeql-analysis.yml vendored Normal file
View File

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

25
.github/workflows/format-check.yml vendored Normal file
View File

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

27
.github/workflows/run-test-suite.yml vendored Normal file
View File

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

View File

@ -1,36 +0,0 @@
# Config file for automatic testing at travis-ci.org
stages:
- format
- test
language: python
jobs:
include:
- stage: format
name: "Black format check"
python: "3.7"
install: pip install black
script:
- black --version
- black . --check
after_success: ""
python:
- "3.6"
- "3.7"
- "3.8"
env:
- DJANGO_VERSION=django-22
- DJANGO_VERSION=django-30
# command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors
install: pip install -r requirements_test.txt
# command to run tests using coverage, e.g. python setup.py test
script: tox -e $(echo py$TRAVIS_PYTHON_VERSION-$DJANGO_VERSION | tr -d .)
after_success:
- coveralls

View File

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

View File

@ -3,8 +3,17 @@
History
-------
Development
+++++++++++
* 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
0.1.0 (2020-03-31)
++++++++++++++
++++++++++++++++++
* Add validation for existing addresses on the signup form
* Add rudimentary BitId support

View File

@ -2,25 +2,17 @@
Django-Cryptolock
=============================
.. image:: https://badge.fury.io/py/django-cryptolock.svg
:target: https://badge.fury.io/py/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://travis-ci.org/dethos/django-cryptolock.svg?branch=master
:target: https://travis-ci.org/dethos/django-cryptolock
Authentication using cryptocurrency wallets for Django projects.
.. image:: https://coveralls.io/repos/github/dethos/django-cryptolock/badge.svg
:target: https://coveralls.io/github/dethos/django-cryptolock
Authenticatio using cryptocurrency wallets for Django projects
This package provided a django app containing a set of utilities to make easier to
This package provides a django app containing a set of utilities to
implement the BitId and Monero Cryptolock authentication "protocols".
Future releases might include other cryptocurrencies but for the being
(until we reach some stability) the focus will continue BTC and XMR.
**DISCLAIMER:** This package is still in an early stage of development. It isn't meant to be
used on any production scenario yet (in other words, only test projects for now).
(until we reach some stability) all the focus will remain on BTC and XMR.
Documentation
-------------
@ -40,7 +32,7 @@ Quickstart
INSTALLED_APPS = (
...
'django_cryptolock.apps.DjangoCryptolockConfig',
"django_cryptolock.apps.DjangoCryptolockConfig",
...
)
@ -76,15 +68,4 @@ Quickstart
...
]
More detailed information can be found in the documentation.
Credits
-------
Tools used in rendering this package:
* Cookiecutter_
* `cookiecutter-djangopackage`_
.. _Cookiecutter: https://github.com/audreyr/cookiecutter
.. _`cookiecutter-djangopackage`: https://github.com/pydanny/cookiecutter-djangopackage
More detailed information can be found in the [documentation](#documentation).

View File

@ -0,0 +1,73 @@
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,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,23 @@ 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(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):
raise forms.ValidationError(_("Invalid or outdated challenge"))
return challenge
@ -44,7 +52,7 @@ class SimpleLoginForm(ChallengeMixin, forms.Form):
signature = forms.CharField()
error_messages = {
"invalid_login": _("Please enter a correct Monero address or signature."),
"invalid_login": _("Please enter a correct address or signature."),
"inactive": _("This account is inactive."),
}
@ -54,7 +62,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 +107,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):

View File

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

View File

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

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

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

View File

@ -0,0 +1,10 @@
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,4 +1,5 @@
import warnings
from typing import Union
from secrets import token_hex
from django.conf import settings
@ -33,12 +34,27 @@ def verify_bitcoin_signature(
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."""
return token_hex(8)
num_bytes = getattr(settings, "DJCL_CHALLENGE_BYTES", 16)
return token_hex(num_bytes)

View File

@ -1,7 +1,6 @@
# -*- 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
@ -9,16 +8,24 @@ from django.conf import settings
from monerorpc.authproxy import JSONRPCException
from .forms import SimpleSignUpForm, SimpleLoginForm
from .utils import verify_monero_signature, verify_bitcoin_signature
from .models import Address
from .utils import verify_signature
from .models import Address, Challenge
from .mixins import CreateUserMixin
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):
class CryptoLockSignUpView(CreateUserMixin, FormView):
template_name = "django_cryptolock/signup.html"
form_class = SimpleSignUpForm
@ -26,20 +33,24 @@ class CryptoLockSignUpView(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"]
network = [n[1] for n in Address.NETWORKS if n[0] == form.network][0]
try:
valid_sig = self.verify_signature(form)
valid_sig = verify_signature(
network, address, challenge, signature, self.request
)
except JSONRPCException:
form._errors["__all__"] = ErrorList(
[_("Error connecting to Monero 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, network=form.network)
self.create_user(username, challenge, address, form.network)
return super().form_valid(form)
else:
form._errors["signature"] = ErrorList([_("Invalid signature")])
@ -47,20 +58,3 @@ class CryptoLockSignUpView(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 = form.network == Address.NETWORK_BITCOIN
monero = form.network == Address.NETWORK_MONERO
valid_sig = False
if bitcoin:
valid_sig = verify_bitcoin_signature(
address, challenge, signature, request=self.request
)
elif monero:
valid_sig = verify_monero_signature(address, challenge, signature)
return valid_sig

View File

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

View File

@ -8,7 +8,7 @@ To use Django-Cryptolock in a project, add it to your `INSTALLED_APPS`:
INSTALLED_APPS = (
...
'django_cryptolock.apps.DjangoCryptolockConfig',
"django_cryptolock.apps.DjangoCryptolockConfig",
...
)
@ -21,6 +21,9 @@ Now you should add the auth backend you wish to use on your project. You can use
"django_cryptolock.backends.MoneroAddressBackend",
]
Required Configuration
----------------------
If you use Monero, currently the following extra settings are required:
.. code-block:: python
@ -37,6 +40,19 @@ For Bitcoin, you only need to set the ``DJCL_BITCOIN_NETWORK``:
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
@ -55,9 +71,9 @@ This will add 2 routes :
* ``django_cryptolock:signup``
* ``django_cryptolock:login``
For usega within you templates. For specific auth pages you can create the
template files (``login.html`` and ``signup.html``) under a
``django_cryptolock`` subfolder.
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
Both of these templates will have access to a ``form`` containing the required
fields for the authentication.

View File

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

View File

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

View File

@ -58,6 +58,7 @@ 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,6 +1,16 @@
"""
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):
@ -15,3 +25,7 @@ def set_bitcoin_settings(settings):
"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,10 +1,13 @@
# -*- 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"
@ -17,6 +20,7 @@ INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sites",
"rest_framework.authtoken",
"django_cryptolock",
]
@ -27,6 +31,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",

209
tests/test_api_views.py Normal file
View File

@ -0,0 +1,209 @@
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,18 +9,20 @@ from model_mommy import mommy
from django_cryptolock.models import Address
from .helpers import set_monero_settings, set_bitcoin_settings
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,
)
User = get_user_model()
pytestmark = pytest.mark.django_db
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/"
@pytest.fixture
def existing_user():

View File

@ -1,90 +1,139 @@
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
from .helpers import (
set_monero_settings,
set_bitcoin_settings,
VALID_MONERO_ADDRESS,
VALID_BITCOIN_ADDRESS,
)
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 +141,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 +149,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 +169,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 +195,45 @@ 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(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()

54
tests/test_managers.py Normal file
View File

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

View File

@ -4,6 +4,13 @@ from model_mommy import mommy
from django_cryptolock.utils import generate_challenge
def test_challenge_has_8_bytes():
def test_challenge_has_default_byte_len():
challenge = generate_challenge()
assert len(bytes.fromhex(challenge)) == 8
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

View File

@ -2,8 +2,18 @@
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 = [
url(r"^", include("django_cryptolock.urls", namespace="django_cryptolock"))
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")),
]

15
tox.ini
View File

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