Compare commits
34 Commits
Author | SHA1 | Date |
---|---|---|
Gonçalo Valério | 9c433636da | |
Gonçalo Valério | 938416ac61 | |
Gonçalo Valério | 49ccaeb5cb | |
Gonçalo Valério | 3fe573e67d | |
Gonçalo Valério | 1fa2d64c52 | |
Gonçalo Valério | 66930fbc46 | |
Gonçalo Valério | c1fc972668 | |
Gonçalo Valério | fd6d243bfe | |
Gonçalo Valério | 63bbc30df5 | |
Gonçalo Valério | 5cba97f144 | |
Gonçalo Valério | 62bb8d9e62 | |
Gonçalo Valério | c4daaba4f1 | |
Gonçalo Valério | 045f79f867 | |
Gonçalo Valério | 0ad15c6277 | |
Gonçalo Valério | 7b024282e9 | |
Gonçalo Valério | e9b5bc1e6a | |
Gonçalo Valério | a5e084e728 | |
Gonçalo Valério | 6a2201ca95 | |
Gonçalo Valério | a7452c1e14 | |
Gonçalo Valério | 197fb789cf | |
Gonçalo Valério | 68d2b8f273 | |
Gonçalo Valério | dd2e1a769a | |
Gonçalo Valério | c2fa8309fd | |
Gonçalo Valério | ed4e085011 | |
Gonçalo Valério | a01e5ef393 | |
Gonçalo Valério | a26d07fa0d | |
Gonçalo Valério | fb86a99a58 | |
Gonçalo Valério | 1eac8809e8 | |
Gonçalo Valério | 0071013d71 | |
Gonçalo Valério | c297ec2601 | |
guy | f01d9b10f9 | |
guy | a1a0a299df | |
guy | fec4cbb897 | |
guy | 6cd6364857 |
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 }}
|
24
.travis.yml
24
.travis.yml
|
@ -1,24 +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
|
||||
- DJANGO_VERSION=django-30
|
||||
|
||||
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
|
15
AUTHORS.rst
15
AUTHORS.rst
|
@ -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
|
||||
|
|
|
@ -94,6 +94,21 @@ Ready to contribute? Here's how to set up `django-cryptolock` for local developm
|
|||
|
||||
8. Submit a pull request through the GitHub website.
|
||||
|
||||
|
||||
Running tests for specific environments
|
||||
---------------------------------------
|
||||
|
||||
Do want to test only a specific python version / django version locally?
|
||||
|
||||
You can use tox directly::
|
||||
|
||||
::
|
||||
|
||||
source <YOURVIRTUALENV>/bin/activate
|
||||
(myenv) $ pip install tox
|
||||
(myenv) $ tox -e <your-python-version>-django-<22_or_30>
|
||||
|
||||
|
||||
Pull Request Guidelines
|
||||
-----------------------
|
||||
|
||||
|
|
16
HISTORY.rst
16
HISTORY.rst
|
@ -3,6 +3,22 @@
|
|||
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
|
||||
* Renamed the base auth views to generic names
|
||||
|
||||
0.0.2 (2020-01-08)
|
||||
++++++++++++++++++
|
||||
|
||||
|
|
83
README.rst
83
README.rst
|
@ -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,23 +22,40 @@ 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",
|
||||
...
|
||||
)
|
||||
|
||||
Migrate your database.
|
||||
3. Migrate your database::
|
||||
|
||||
Add Django-Cryptolock's URL patterns:
|
||||
python manage.py migrate
|
||||
|
||||
|
||||
4. Add the following settings to your project for the Monero Backend:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django_cryptolock.backends.MoneroAddressBackend",
|
||||
...
|
||||
]
|
||||
DJCL_MONERO_NETWORK = "mainnet"
|
||||
DJCL_MONERO_WALLET_RPC_PROTOCOL = "<http_or_https>"
|
||||
DJCL_MONERO_WALLET_RPC_HOST = "<wallet_rpc_host>:<port>"
|
||||
DJCL_MONERO_WALLET_RPC_USER = "<user>"
|
||||
DJCL_MONERO_WALLET_RPC_PASS = "<password>"
|
||||
|
||||
5. Add Django-Cryptolock's URL patterns:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
|
@ -50,43 +68,4 @@ Add Django-Cryptolock's URL patterns:
|
|||
...
|
||||
]
|
||||
|
||||
|
||||
Add the following settings to your project:
|
||||
|
||||
* ``django_cryptolock.backends.MoneroAddressBackend`` to your
|
||||
``AUTHENTICATION_BACKENDS``
|
||||
* Set ``DJCL_MONERO_NETWORK`` with the network in use: ``mainnet``,
|
||||
``stagenet`` or ``testnet``
|
||||
* Use ``DJCL_MONERO_WALLET_RPC_PROTOCOL``, ``DJCL_MONERO_WALLET_RPC_HOST``,
|
||||
``DJCL_MONERO_WALLET_RPC_USER`` and ``DJCL_MONERO_WALLET_RPC_PASS`` to specify
|
||||
which wallet RPC should be used.
|
||||
|
||||
Finaly create the templates files (``login.html`` and ``signup.html``) under a
|
||||
``django_cryptolock`` subfolder.
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
* Adds authentication based on cryptocurrency wallets to a Django project.
|
||||
|
||||
Running Tests
|
||||
-------------
|
||||
|
||||
Does the code actually work?
|
||||
|
||||
::
|
||||
|
||||
source <YOURVIRTUALENV>/bin/activate
|
||||
(myenv) $ pip install tox
|
||||
(myenv) $ tox -e <your-python-version>-django-22
|
||||
|
||||
Credits
|
||||
-------
|
||||
|
||||
Tools used in rendering this package:
|
||||
|
||||
* Cookiecutter_
|
||||
* `cookiecutter-djangopackage`_
|
||||
|
||||
.. _Cookiecutter: https://github.com/audreyr/cookiecutter
|
||||
.. _`cookiecutter-djangopackage`: https://github.com/pydanny/cookiecutter-djangopackage
|
||||
More detailed information can be found in the [documentation](#documentation).
|
||||
|
|
|
@ -1 +1 @@
|
|||
__version__ = "0.0.2"
|
||||
__version__ = "0.1.0"
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -1,14 +1,11 @@
|
|||
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):
|
||||
|
@ -27,12 +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"))
|
||||
|
||||
|
@ -40,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
|
||||
|
|
|
@ -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,10 +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
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from django.db.models.manager import Manager
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from .utils import generate_challenge
|
||||
|
||||
|
||||
class ChallengeManager(Manager):
|
||||
"""Provides methods to easily create and verify challenges."""
|
||||
|
||||
def generate(self):
|
||||
token = generate_challenge()
|
||||
age = getattr(settings, "DJCL_CHALLENGE_EXPIRATION", 10)
|
||||
expiry_date = timezone.now() + timedelta(minutes=age)
|
||||
return self.create(challenge=token, expires=expiry_date)
|
||||
|
||||
def is_active(self, challenge):
|
||||
"""Returns True if the challenge can be used. Otherwise False."""
|
||||
now = timezone.now()
|
||||
return self.filter(challenge=challenge, expires__gte=now).exists()
|
||||
|
||||
def invalidate(self, challenge):
|
||||
"""Removes the provided challenge if it exists."""
|
||||
self.filter(challenge=challenge).delete()
|
||||
|
||||
def clean_expired(self):
|
||||
"""Delete all expired challenges. Returns nº of entries removed."""
|
||||
now = timezone.now()
|
||||
del_summary = self.filter(expires__lt=now).delete()
|
||||
return del_summary[0]
|
|
@ -0,0 +1,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),
|
||||
),
|
||||
]
|
|
@ -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"},
|
||||
)
|
||||
]
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
|
@ -1,12 +1,11 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from django.conf.urls import url
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from .views import MoneroLoginView, MoneroSignUpView
|
||||
from .views import CryptoLockLoginView, CryptoLockSignUpView
|
||||
|
||||
|
||||
app_name = "django_cryptolock"
|
||||
urlpatterns = [
|
||||
url(r"login", MoneroLoginView.as_view(), name="login"),
|
||||
url(r"signup", MoneroSignUpView.as_view(), name="signup"),
|
||||
url(r"login", CryptoLockLoginView.as_view(), name="login"),
|
||||
url(r"signup", CryptoLockSignUpView.as_view(), name="signup"),
|
||||
]
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
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.DJCL_MONERO_WALLET_RPC_PROTOCOL
|
||||
host = settings.DJCL_MONERO_WALLET_RPC_HOST
|
||||
|
@ -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)
|
||||
|
|
|
@ -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}"))
|
||||
|
|
|
@ -1,24 +1,31 @@
|
|||
# -*- 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):
|
||||
|
||||
class CryptoLockSignUpView(CreateUserMixin, FormView):
|
||||
template_name = "django_cryptolock/signup.html"
|
||||
form_class = SimpleSignUpForm
|
||||
|
||||
|
@ -30,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")])
|
||||
|
|
|
@ -12,6 +12,22 @@ django\_cryptolock.migrations.0001\_initial module
|
|||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
django\_cryptolock.migrations.0002\_auto\_20200218\_1312 module
|
||||
---------------------------------------------------------------
|
||||
|
||||
.. automodule:: django_cryptolock.migrations.0002_auto_20200218_1312
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
django\_cryptolock.migrations.0003\_challenge module
|
||||
----------------------------------------------------
|
||||
|
||||
.. automodule:: django_cryptolock.migrations.0003_challenge
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
|
|
@ -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
|
|
@ -14,6 +14,7 @@ Contents:
|
|||
readme
|
||||
installation
|
||||
usage
|
||||
features_roadmap
|
||||
contributing
|
||||
authors
|
||||
history
|
||||
|
|
|
@ -4,7 +4,7 @@ Installation
|
|||
|
||||
At the command line::
|
||||
|
||||
$ easy_install django-cryptolock
|
||||
$ pip install django-cryptolock
|
||||
|
||||
Or, if you have virtualenvwrapper installed::
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -116,10 +116,14 @@ 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"
|
||||
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")
|
||||
|
|
|
@ -2,3 +2,4 @@ django>=2.2
|
|||
django-model-utils>=2.0
|
||||
monero>=0.6
|
||||
python-monerorpc>=0.5.5
|
||||
pybitid>=0.0.4
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
-r requirements.txt
|
||||
|
||||
djangorestframework>=3.9.3
|
||||
bumpversion==0.5.3
|
||||
wheel==0.30.0
|
||||
twine==3.1.0
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
[bumpversion]
|
||||
current_version = 0.0.2
|
||||
current_version = 0.1.0
|
||||
commit = True
|
||||
tag = False
|
||||
|
||||
|
|
1
setup.py
1
setup.py
|
@ -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",
|
||||
|
|
|
@ -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())
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -9,42 +9,53 @@ from model_mommy import mommy
|
|||
|
||||
from django_cryptolock.models import Address
|
||||
|
||||
VALID_ADDRESS = "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq"
|
||||
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"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def existing_user():
|
||||
return User.objects.create_user(**DUMMY_CREDS)
|
||||
|
||||
|
||||
def test_monero_backend_receives_insuficient_data(existing_user):
|
||||
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(existing_user):
|
||||
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(existing_user):
|
||||
def test_monero_backend_does_not_find_address(settings, existing_user):
|
||||
set_monero_settings(settings)
|
||||
user = authenticate(
|
||||
MagicMock(), address=VALID_ADDRESS, challeng="1", signature="somesig"
|
||||
MagicMock(), address=VALID_MONERO_ADDRESS, challeng="1", signature="somesig"
|
||||
)
|
||||
assert user is None
|
||||
|
||||
|
||||
def test_monero_backend_cannot_connect_to_RPC(existing_user):
|
||||
mommy.make(Address, address=VALID_ADDRESS, user=existing_user)
|
||||
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_ADDRESS,
|
||||
address=VALID_MONERO_ADDRESS,
|
||||
challenge="1",
|
||||
signature="invalid sig",
|
||||
**DUMMY_CREDS
|
||||
|
@ -53,25 +64,101 @@ def test_monero_backend_cannot_connect_to_RPC(existing_user):
|
|||
assert user is None
|
||||
|
||||
|
||||
def test_monero_backend_invalid_signature(existing_user):
|
||||
mommy.make(Address, address=VALID_ADDRESS, user=existing_user)
|
||||
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_signature") as verify_mock:
|
||||
with patch("django_cryptolock.backends.verify_monero_signature") as verify_mock:
|
||||
verify_mock.return_value = False
|
||||
user = authenticate(
|
||||
MagicMock(), address=VALID_ADDRESS, challenge="1", signature="invalid sig"
|
||||
MagicMock(),
|
||||
address=VALID_MONERO_ADDRESS,
|
||||
challenge="1",
|
||||
signature="invalid sig",
|
||||
)
|
||||
|
||||
assert user is None
|
||||
|
||||
|
||||
def test_monero_backed_valid_signature(existing_user):
|
||||
mommy.make(Address, address=VALID_ADDRESS, user=existing_user)
|
||||
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_signature") as verify_mock:
|
||||
with patch("django_cryptolock.backends.verify_monero_signature") as verify_mock:
|
||||
verify_mock.return_value = True
|
||||
user = authenticate(
|
||||
MagicMock(), address=VALID_ADDRESS, challenge="1", signature="valid sig"
|
||||
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
|
||||
|
|
|
@ -1,89 +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
|
||||
from django_cryptolock.models import Address, Challenge
|
||||
|
||||
from .helpers import (
|
||||
set_monero_settings,
|
||||
set_bitcoin_settings,
|
||||
VALID_MONERO_ADDRESS,
|
||||
VALID_BITCOIN_ADDRESS,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
VALID_ADDRESS = "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq"
|
||||
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_ADDRESS)
|
||||
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_ADDRESS,
|
||||
"challenge": "12345678",
|
||||
"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()
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
15
tox.ini
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue