Compare commits

...

47 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
Gonçalo Valério 6a2201ca95 bumb version 2020-03-31 18:42:47 +01:00
Gonçalo Valério a7452c1e14 update documentation 2020-03-31 18:37:34 +01:00
Gonçalo Valério 197fb789cf
Merge pull request #10 from dethos/bitid
Basic BitId support
2020-03-31 15:44:42 +01:00
Gonçalo Valério 68d2b8f273 fix code format issues 2020-03-31 14:06:23 +01:00
Gonçalo Valério dd2e1a769a validate the address first on signup 2020-03-31 13:59:07 +01:00
Gonçalo Valério c2fa8309fd Update changelog to reference the new naming of default views 2020-03-10 15:03:02 +00:00
Gonçalo Valério ed4e085011 first working prototype of bitid 2020-03-10 15:00:11 +00:00
Gonçalo Valério a01e5ef393 removed unused dependency: python-bitcoinaddress 2020-02-19 18:14:25 +00:00
Gonçalo Valério a26d07fa0d format the generated migration 2020-02-19 18:09:35 +00:00
Gonçalo Valério fb86a99a58 add tests for the new bitcoin authentication backend 2020-02-19 17:56:22 +00:00
Gonçalo Valério 1eac8809e8 add bitcoin to address model 2020-02-19 15:04:19 +00:00
Gonçalo Valério 0071013d71 update changelog and contributors list 2020-01-28 16:46:52 +00:00
Gonçalo Valério c297ec2601
Merge pull request #9 from dethos/black-check-ci-job
Black check ci job
2020-01-28 16:33:08 +00:00
guy f01d9b10f9 Remove allow failures and black exclusions. 2020-01-28 17:24:59 +01:00
guy a1a0a299df Tweak after_success. 2020-01-28 16:53:25 +01:00
guy fec4cbb897 Remove matrix in place of Jobs. 2020-01-28 16:44:40 +01:00
guy 6cd6364857 Add Black check to CI. 2020-01-28 16:16:48 +01:00
Gonçalo Valério f53dd9b89d
Merge pull request #7 from dethos/fix-address_unique-bug
Add clean method for address in SignUp form.
2020-01-28 13:29:23 +00:00
guy a5511b8f1d Add clean method for address in SignUp form. 2020-01-28 13:32:39 +01:00
Gonçalo Valério 9b72049576 bump version 2020-01-08 18:44:37 +00:00
Gonçalo Valério 05bb7edd1a update release date on history file 2020-01-08 18:34:03 +00:00
Gonçalo Valério d94a18e692 update requirements to avoid double dependency problem on the CI 2020-01-06 18:40:32 +00:00
Gonçalo Valério 9fd12ae4fc add django 3.0 to the ci 2020-01-06 18:13:47 +00:00
Gonçalo Valério 1de5510680 MoneroAddressBackend now handles extra keyword arguments that might be provided. Also added more tests 2020-01-06 17:24:27 +00:00
Gonçalo Valério 57efe853e0 added djcl namespace to all settings related to this project 2020-01-06 12:23:41 +00:00
Gonçalo Valério 3de2feca1d Update contribution instructions 2020-01-06 12:06:15 +00:00
Gonçalo Valério 9642883190 configure django settings when building docs 2019-12-01 00:30:18 +00:00
Gonçalo Valério cb8ebbdfb7 remove django setup from docs conf 2019-12-01 00:16:58 +00:00
Gonçalo Valério e12a7a8a44 update quickstart guide 2019-12-01 00:07:03 +00:00
Gonçalo Valério 9b7eadfb31 Add urls.py and move default template location to cryptolock folder 2019-11-30 21:46:54 +00:00
56 changed files with 1638 additions and 224 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,23 +0,0 @@
# Config file for automatic testing at travis-ci.org
language: python
python:
- "3.6"
- "3.7"
- "3.8"
env:
- DJANGO_VERSION=django-22
matrix:
fast_finish: true
# 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
------------
None yet. Why not be the first?
* Guy Willett - https://github.com/guywillett

View File

@ -3,7 +3,7 @@ Contributing
============
Contributions are welcome, and they are greatly appreciated! Every
little bit helps, and credit will always be given.
little bit helps, and credit will always be given.
You can contribute in many ways:
@ -36,7 +36,7 @@ is open to whoever wants to implement it.
Write Documentation
~~~~~~~~~~~~~~~~~~~
Django-Cryptolock could always use more documentation, whether as part of the
Django-Cryptolock could always use more documentation, whether as part of the
official Django-Cryptolock docs, in docstrings, or even on the web in blog posts,
articles, and such.
@ -66,7 +66,7 @@ Ready to contribute? Here's how to set up `django-cryptolock` for local developm
$ mkvirtualenv django-cryptolock
$ cd django-cryptolock/
$ python setup.py develop
$ pip install -r requirements_dev.txt -r requirements_test.txt
4. Create a branch for local development::
@ -74,22 +74,40 @@ Ready to contribute? Here's how to set up `django-cryptolock` for local developm
Now you can make your changes locally.
5. When you're done making changes, check that your changes pass flake8 and the
tests, including testing other Python versions with tox::
5. When you're done making changes, check that your changes pass ``black`` and the
tests, including testing other Python versions with ``tox``:
$ flake8 django_cryptolock tests
$ python setup.py test
$ tox
$ black --check django_cryptolock
$ make test
$ make test-all
To get flake8 and tox, just pip install them into your virtualenv.
To get black and tox, just pip install them into your virtualenv.
6. Commit your changes and push your branch to GitHub::
6. If your changes are visible to the user, you can add a demo for them to the
example project.
7. Commit your changes and push your branch to GitHub::
$ git add .
$ git commit -m "Your detailed description of your changes."
$ git push origin name-of-your-bugfix-or-feature
7. Submit a pull request through the GitHub website.
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
-----------------------
@ -100,7 +118,7 @@ Before you submit a pull request, check that it meets these guidelines:
2. If the pull request adds functionality, the docs should be updated. Put
your new functionality into a function with a docstring, and add the
feature to the list in README.rst.
3. The pull request should work for Python 2.6, 2.7, and 3.3, and for PyPy. Check
3. The pull request should work for Python 3.6, 3.7 and 3.8. Check
https://travis-ci.org/dethos/django-cryptolock/pull_requests
and make sure that the tests pass for all supported Python versions.
@ -109,4 +127,4 @@ Tips
To run a subset of tests::
$ python -m unittest tests.test_django_cryptolock
$ pytest tests/test_models.py

View File

@ -3,7 +3,34 @@
History
-------
0.0.1 (2019-09-24)
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
* Renamed the base auth views to generic names
0.0.2 (2020-01-08)
++++++++++++++++++
* A default ``urls.py`` is provided by the package so can work "out-of-the-box".
* Default location for templates moved to ``django_cryptolock`` folder.
* Update quickstart guide.
* Update instructions to contribute to the project.
* Add ``DJCL`` namespace to all related settings.
* MoneroAddressBackend is now executed when more parameters are added to the
``authenticate`` function.
0.0.1 (2019-11-25)
++++++++++++++++++
* First release on PyPI.

View File

@ -46,6 +46,7 @@ coverage: ## check code coverage quickly with the default Python
open htmlcov/index.html
docs: ## generate Sphinx HTML documentation, including API docs
rm -f docs/django_cryptolock.migrations.rst
rm -f docs/django-cryptolock.rst
rm -f docs/modules.rst
sphinx-apidoc -o docs/ django_cryptolock

View File

@ -2,16 +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
This package provides a django app containing a set of utilities to
implement the BitId and Monero Cryptolock authentication "protocols".
Django authentication using cryptocurrency wallets
Future releases might include other cryptocurrencies but for the being
(until we reach some stability) all the focus will remain on BTC and XMR.
Documentation
-------------
@ -21,56 +22,50 @@ The full documentation is at https://django-cryptolock.readthedocs.io.
Quickstart
----------
Install Django-Cryptolock::
1. Install Django-Cryptolock::
pip install django-cryptolock
Add it to your `INSTALLED_APPS`:
2. Add it to your `INSTALLED_APPS`:
.. code-block:: python
INSTALLED_APPS = (
...
'django_cryptolock.apps.DjangoCryptolockConfig',
"django_cryptolock.apps.DjangoCryptolockConfig",
...
)
Add Django-Cryptolock's URL patterns:
3. Migrate your database::
python manage.py migrate
4. Add the following settings to your project for the Monero Backend:
.. code-block:: python
from django_cryptolock import urls as django_cryptolock_urls
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:
.. code-block:: python
from django.conf.urls import url
urlpatterns = [
...
url(r'^', include(django_cryptolock_urls)),
url(r"^auth/", include("django_cryptolock.urls", namespace="django_cryptolock")),
...
]
Features
--------
* TODO
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
More detailed information can be found in the [documentation](#documentation).

View File

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

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,20 +1,19 @@
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 AuthServiceProxy, JSONRPCException
from monerorpc.authproxy import JSONRPCException
from .models import Address
from .utils import verify_signature
User = get_user_model()
from .utils import verify_monero_signature, verify_bitcoin_signature
class MoneroAddressBackend(ModelBackend):
"""Custom Monero-Cryptolock authentication backend."""
def authenticate(self, request, address=None, challenge=None, signature=None):
def authenticate(
self, request, address=None, challenge=None, signature=None, **kwargs
):
"""Validates the provided signature for the given address and challenge.
This method currently relies on Wallet RPC access to verify the signature,
@ -25,13 +24,16 @@ class MoneroAddressBackend(ModelBackend):
return None
stored_address = (
Address.objects.select_related("user").filter(address=address).first()
Address.objects.select_related("user")
.filter(address=address, network=Address.NETWORK_MONERO)
.first()
)
if not stored_address:
return None
try:
is_valid = verify_signature(address, challenge, signature)
is_valid = verify_monero_signature(
stored_address.address, challenge, signature
)
except JSONRPCException:
raise PermissionDenied(_("Error while validating signature"))
@ -39,3 +41,35 @@ 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,9 +1,16 @@
from urllib.parse import urlparse, parse_qs
from django import forms
from django.contrib.auth import authenticate
from django.utils.translation import gettext, gettext_lazy as _
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 .models import Address
from .validators import validate_monero_address
from pybitid import bitid
from .models import Address, Challenge
from .validators import validate_monero_address, validate_bitcoin_address
from .utils import generate_challenge
@ -16,15 +23,23 @@ class ChallengeMixin(forms.Form):
challenge = forms.CharField()
def include_challange(self):
new_challenge = 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
@ -33,11 +48,11 @@ class ChallengeMixin(forms.Form):
class SimpleLoginForm(ChallengeMixin, forms.Form):
"""Basic login form, that can be used as reference for implementation."""
address = forms.CharField(validators=[validate_monero_address])
address = forms.CharField()
signature = forms.CharField()
error_messages = {
"invalid_login": _("Please enter a correct Monero address or signature."),
"invalid_login": _("Please enter a correct address or signature."),
"inactive": _("This account is inactive."),
}
@ -47,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")
@ -84,7 +99,7 @@ class SimpleSignUpForm(ChallengeMixin, forms.Form):
"""Basic login form, that can be used as reference for implementation."""
username = forms.CharField()
address = forms.CharField(validators=[validate_monero_address])
address = forms.CharField()
signature = forms.CharField()
def __init__(self, request=None, *args, **kwargs):
@ -92,4 +107,39 @@ 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):
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

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

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

@ -1,21 +1,26 @@
# -*- 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
from .validators import validate_monero_address, validate_bitcoin_address
from .managers import ChallengeManager
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)
address = models.CharField(
max_length=106, validators=[validate_monero_address], unique=True
)
network = models.PositiveSmallIntegerField(choices=NETWORKS, default=NETWORK_MONERO)
address = models.CharField(max_length=106, unique=True)
class Meta:
"""Meta definition for Address."""
@ -26,3 +31,31 @@ 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

@ -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,9 +1,11 @@
# -*- coding: utf-8 -*-
from django.conf.urls import url
from django.views.generic import TemplateView
from . import views
from .views import CryptoLockLoginView, CryptoLockSignUpView
app_name = "django_cryptolock"
urlpatterns = []
urlpatterns = [
url(r"login", CryptoLockLoginView.as_view(), name="login"),
url(r"signup", CryptoLockSignUpView.as_view(), name="signup"),
]

View File

@ -1,16 +1,21 @@
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_signature(address: str, challenge: str, signature: str) -> bool:
def verify_monero_signature(address: str, challenge: str, signature: str) -> bool:
"""Makes a request to wallet RPC to verify address and signature."""
protocol = settings.MONERO_WALLET_RPC_PROTOCOL
host = settings.MONERO_WALLET_RPC_HOST
user = settings.MONERO_WALLET_RPC_USER
pwd = settings.MONERO_WALLET_RPC_PASS
protocol = settings.DJCL_MONERO_WALLET_RPC_PROTOCOL
host = settings.DJCL_MONERO_WALLET_RPC_HOST
user = settings.DJCL_MONERO_WALLET_RPC_USER
pwd = settings.DJCL_MONERO_WALLET_RPC_PASS
wallet_rpc = AuthServiceProxy(f"{protocol}://{user}:{pwd}@{host}/json_rpc")
result = wallet_rpc.verify(
@ -20,6 +25,36 @@ def verify_signature(address: str, challenge: str, signature: str) -> bool:
return result.get("good", False)
def verify_bitcoin_signature(
address: str, challenge: str, signature: str, request: HttpRequest
) -> bool:
"""Verifies if the provided bitcoin signature is valid."""
network = getattr(settings, "DJCL_BITCOIN_NETWORK", None)
if not network:
warnings.warn(_("Please configure the bitcoin network in the settings file"))
is_testnet = True if network == "testnet" else False
callback_uri = request.build_absolute_uri()
return bitid.challenge_valid(
address, signature, challenge, callback_uri, is_testnet
)
def 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

@ -3,6 +3,7 @@ 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):
@ -24,3 +25,14 @@ 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,24 +1,32 @@
# -*- 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 MoneroLoginView(LoginView):
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 MoneroSignUpView(FormView):
template_name = "registration/signup.html"
class CryptoLockSignUpView(CreateUserMixin, FormView):
template_name = "django_cryptolock/signup.html"
form_class = SimpleSignUpForm
def get_form(self, form_class=None):
@ -29,15 +37,20 @@ class MoneroSignUpView(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(address, challenge, signature)
valid_sig = verify_signature(
network, address, challenge, signature, self.request
)
except JSONRPCException:
form._errors["__all__"] = ErrorList([_("Error connecting to daemon")])
form._errors["__all__"] = ErrorList(
[_("Error connecting to Monero daemon")]
)
return self.form_invalid(form)
if valid_sig:
user = get_user_model().objects.create(username=username)
user.address_set.create(address=address)
self.create_user(username, challenge, address, form.network)
return super().form_valid(form)
else:
form._errors["signature"] = ErrorList([_("Invalid signature")])

View File

@ -22,6 +22,10 @@ cwd = os.getcwd()
parent = os.path.dirname(cwd)
sys.path.append(parent)
from django.conf import settings
settings.configure()
import django_cryptolock
# -- General configuration -----------------------------------------------------

View File

@ -0,0 +1,38 @@
django\_cryptolock.migrations package
=====================================
Submodules
----------
django\_cryptolock.migrations.0001\_initial module
--------------------------------------------------
.. automodule:: django_cryptolock.migrations.0001_initial
:members:
: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
---------------
.. automodule:: django_cryptolock.migrations
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,85 @@
django\_cryptolock package
==========================
Subpackages
-----------
.. toctree::
django_cryptolock.migrations
Submodules
----------
django\_cryptolock.apps module
------------------------------
.. automodule:: django_cryptolock.apps
:members:
:undoc-members:
:show-inheritance:
django\_cryptolock.backends module
----------------------------------
.. automodule:: django_cryptolock.backends
:members:
:undoc-members:
:show-inheritance:
django\_cryptolock.forms module
-------------------------------
.. automodule:: django_cryptolock.forms
:members:
:undoc-members:
:show-inheritance:
django\_cryptolock.models module
--------------------------------
.. automodule:: django_cryptolock.models
:members:
:undoc-members:
:show-inheritance:
django\_cryptolock.urls module
------------------------------
.. automodule:: django_cryptolock.urls
:members:
:undoc-members:
:show-inheritance:
django\_cryptolock.utils module
-------------------------------
.. automodule:: django_cryptolock.utils
:members:
:undoc-members:
:show-inheritance:
django\_cryptolock.validators module
------------------------------------
.. automodule:: django_cryptolock.validators
:members:
:undoc-members:
:show-inheritance:
django\_cryptolock.views module
-------------------------------
.. automodule:: django_cryptolock.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: django_cryptolock
:members:
:undoc-members:
:show-inheritance:

18
docs/features_roadmap.rst Normal file
View File

@ -0,0 +1,18 @@
====================
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,6 +14,7 @@ Contents:
readme
installation
usage
features_roadmap
contributing
authors
history

View File

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

7
docs/modules.rst Normal file
View File

@ -0,0 +1,7 @@
django_cryptolock
=================
.. toctree::
:maxdepth: 4
django_cryptolock

View File

@ -8,19 +8,72 @@ 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_cryptolock import urls as django_cryptolock_urls
from django.conf.urls import url
urlpatterns = [
...
url(r'^', include(django_cryptolock_urls)),
url(r"^auth/", include("django_cryptolock.urls", namespace="django_cryptolock")),
...
]
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,12 +116,16 @@ LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
# Monero Cryptolock Settings
# Django Cryptolock Settings
# Wallet RPC
AUTHENTICATION_BACKENDS = ["django_cryptolock.backends.MoneroAddressBackend"]
AUTHENTICATION_BACKENDS = [
"django_cryptolock.backends.BitcoinAddressBackend",
"django_cryptolock.backends.MoneroAddressBackend",
]
DJCL_BITCOIN_NETWORK = "mainnet"
DJCL_MONERO_NETWORK = "mainnet"
MONERO_WALLET_RPC_PROTOCOL = os.environ.get("MONERO_WALLET_RPC_PROTOCOL", "http")
MONERO_WALLET_RPC_HOST = os.environ.get("MONERO_WALLET_RPC_HOST", "localhost:6000")
MONERO_WALLET_RPC_USER = os.environ.get("MONERO_WALLET_RPC_USER")
MONERO_WALLET_RPC_PASS = os.environ.get("MONERO_WALLET_RPC_PASS")
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")
DJCL_MONERO_WALLET_RPC_USER = os.environ.get("MONERO_WALLET_RPC_USER")
DJCL_MONERO_WALLET_RPC_PASS = os.environ.get("MONERO_WALLET_RPC_PASS")

View File

@ -16,12 +16,8 @@ Including another URLconf
from django.conf.urls import url, include
from django.contrib import admin
from testauth.views import IndexView
urlpatterns = [
url(r"^admin/", admin.site.urls),
url(r"", include("django_cryptolock.urls", namespace="django_cryptolock")),
url(r"^auth/", include("testauth.urls")),
url(r"^$", IndexView.as_view(), name="index"),
url(r"^auth/", include("django_cryptolock.urls", namespace="django_cryptolock")),
url(r"^", include("testauth.urls")),
]

View File

@ -28,10 +28,10 @@
</li>
{% if not request.user.is_authenticated %}
<li>
<a href="{% url 'test_signup' %}">Sign up</a>
<a href="{% url 'django_cryptolock:signup' %}">Sign up</a>
</li>
<li>
<a href="{% url 'test_login' %}">Login</a>
<a href="{% url 'django_cryptolock:login' %}">Login</a>
</li>
{% endif %}
</ul>

View File

@ -1,7 +1,7 @@
{% extends 'django_cryptolock/base.html' %}
{% block content %}
<form method="post" action="{% url 'test_login' %}">
<form method="post" action="{% url 'django_cryptolock:login' %}">
{% csrf_token %}
{{form}}
<input type="submit" value="Login" />

View File

@ -1,7 +1,7 @@
{% extends 'django_cryptolock/base.html' %}
{% block content %}
<form method="post" action="{% url 'test_signup' %}" >
<form method="post" action="{% url 'django_cryptolock:signup' %}" >
{% csrf_token %}
{{form}}
<input type="submit" value="Sign Up" />

View File

@ -5,11 +5,11 @@
<h1>Hello {{request.user.username}}</h1>
<p>Here are your details:</p>
<ul>
<li>...</li>
<li>...</li>
<li>...</li>
<li>Address = {{user.address_set.first.address}}</li>
<li>Date Joined = {{user.date_joined}}</li>
<li>Last Login = {{user.last_login}}</li>
</ul>
<form method="post" action="{% url 'test_logout' %}">
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<input type="submit" value="Logout" />
</form>

View File

@ -1,10 +1,9 @@
from django.conf.urls import url
from django.contrib.auth.views import LogoutView
from django_cryptolock.views import MoneroLoginView, MoneroSignUpView
from .views import IndexView
urlpatterns = [
url(r"login", MoneroLoginView.as_view(), name="test_login"),
url(r"signup", MoneroSignUpView.as_view(), name="test_signup"),
url(r"logout", LogoutView.as_view(), name="test_logout"),
url(r"^logout$", LogoutView.as_view(), name="logout"),
url(r"^$", IndexView.as_view(), name="index"),
]

View File

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

View File

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

View File

@ -1,5 +1,10 @@
-r requirements.txt
# Dependencies
django-model-utils>=2.0
monero>=0.6
python-monerorpc>=0.5.5
djangorestframework>=3.9.3
# Test Dependencies
coverage==4.4.1
mock>=1.0.1
flake8>=2.1.0
@ -9,3 +14,4 @@ pytest
pytest-django
pytest-cov
model_mommy
black

View File

@ -1,7 +1,7 @@
[bumpversion]
current_version = 0.0.1
current_version = 0.1.0
commit = True
tag = True
tag = False
[bumpversion:file:setup.py]

View File

@ -58,12 +58,14 @@ 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",
classifiers=[
"Development Status :: 3 - Alpha",
"Framework :: Django :: 2.2",
"Framework :: Django :: 3.0",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",

31
tests/helpers.py Normal file
View File

@ -0,0 +1,31 @@
"""
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,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,13 +31,22 @@ 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"

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

164
tests/test_backends.py Normal file
View File

@ -0,0 +1,164 @@
from unittest.mock import MagicMock, patch
from django.contrib.auth import authenticate
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
import pytest
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,
)
User = get_user_model()
pytestmark = pytest.mark.django_db
@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)
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)
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)
user = authenticate(
MagicMock(), address=VALID_MONERO_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)
user = authenticate(
MagicMock(),
address=VALID_MONERO_ADDRESS,
challenge="1",
signature="invalid sig",
**DUMMY_CREDS
)
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)
with patch("django_cryptolock.backends.verify_monero_signature") as verify_mock:
verify_mock.return_value = False
user = authenticate(
MagicMock(),
address=VALID_MONERO_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)
with patch("django_cryptolock.backends.verify_monero_signature") as verify_mock:
verify_mock.return_value = True
user = authenticate(
MagicMock(),
address=VALID_MONERO_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,69 +1,239 @@
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
VALID_ADDRESS = "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq"
from .helpers import (
set_monero_settings,
set_bitcoin_settings,
VALID_MONERO_ADDRESS,
VALID_BITCOIN_ADDRESS,
)
pytestmark = pytest.mark.django_db
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_ADDRESS,
"challenge": "12345678",
"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)
request.session.get.return_value = "12345678"
assert form.is_valid()
def test_simplesignupform_generaes_new_challenge():
@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()
initial = {}
request.session.__setitem__.side_effect = initial.__setitem__
request.session.__getitem__.side_effect = initial.__getitem__
request.build_absolute_uri.return_value = "http://something/"
form = SimpleLoginForm(
request=request,
data={
"address": 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()
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_generaes_no_new_challenge():
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(
request=request,
data={
"username": "foo",
"address": VALID_MONERO_ADDRESS,
"challenge": gen_challenge(request, "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()

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

@ -19,30 +19,51 @@ 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_mainnet_address(settings):
def test_valid_monero_mainnet_address(settings):
settings.DJCL_MONERO_NETWORK = "mainnet"
addr = mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR)
addr.full_clean()
def test_valid_stagenet_addr(settings):
def test_valid_monero_stagenet_addr(settings):
settings.DJCL_MONERO_NETWORK = "stagenet"
addr = mommy.make(Address, address=VALID_MONERO_STAGENET_ADDR)
addr.full_clean()
def test_valid_testnet_addr(settings):
def test_valid_monero_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)
@ -51,23 +72,38 @@ def test_invalid_address():
addr.full_clean()
assert (
"{} is not a valid address".format(bad_addr)
in error.value.message_dict["address"]
"Invalid address for the given network" in error.value.message_dict["__all__"]
)
def test_wrong_network_address(settings):
def test_wrong_monero_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 stagenet" in error.value.message_dict["address"]
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__"]
)
def test_address_is_unique():
addr = mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR)
mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR)
with pytest.raises(IntegrityError):
mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR)

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

13
tox.ini
View File

@ -1,14 +1,17 @@
[tox]
envlist =
{py36,py37,py38}-django-22
{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 =
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