├── tests ├── __init__.py ├── templates │ └── django_cryptolock │ │ ├── login.html │ │ └── signup.html ├── test_utils.py ├── urls.py ├── helpers.py ├── settings.py ├── test_validators.py ├── test_managers.py ├── test_models.py ├── test_backends.py ├── test_api_views.py └── test_forms.py ├── example ├── example │ ├── __init__.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── testauth │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ ├── admin.py │ ├── apps.py │ ├── views.py │ └── urls.py ├── requirements.txt ├── manage.py ├── templates │ ├── django_cryptolock │ │ ├── login.html │ │ ├── signup.html │ │ └── base.html │ └── index.html └── README.md ├── django_cryptolock ├── migrations │ ├── __init__.py │ ├── 0002_auto_20200218_1312.py │ ├── 0003_challenge.py │ └── 0001_initial.py ├── static │ ├── img │ │ └── .gitignore │ ├── js │ │ └── django_cryptolock.js │ └── css │ │ └── django_cryptolock.css ├── __init__.py ├── apps.py ├── serializers.py ├── urls.py ├── templates │ └── django_cryptolock │ │ └── base.html ├── mixins.py ├── managers.py ├── validators.py ├── models.py ├── utils.py ├── views.py ├── backends.py ├── api_views.py └── forms.py ├── docs ├── authors.rst ├── history.rst ├── readme.rst ├── contributing.rst ├── modules.rst ├── installation.rst ├── features_roadmap.rst ├── index.rst ├── django_cryptolock.migrations.rst ├── django_cryptolock.rst ├── usage.rst ├── Makefile ├── make.bat └── conf.py ├── pytest.ini ├── requirements.txt ├── AUTHORS.rst ├── requirements_dev.txt ├── .coveragerc ├── MANIFEST.in ├── requirements_test.txt ├── manage.py ├── setup.cfg ├── .github ├── ISSUE_TEMPLATE.md ├── workflows │ ├── format-check.yml │ ├── run-test-suite.yml │ └── codeql-analysis.yml └── PULL_REQUEST_TEMPLATE.md ├── tox.ini ├── .gitignore ├── HISTORY.rst ├── LICENSE ├── Makefile ├── README.rst ├── setup.py └── CONTRIBUTING.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/testauth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_cryptolock/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_cryptolock/static/img/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/testauth/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_cryptolock/static/js/django_cryptolock.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /tests/templates/django_cryptolock/login.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/django_cryptolock/signup.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_cryptolock/static/css/django_cryptolock.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_cryptolock/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0" 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /example/testauth/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /example/testauth/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /example/testauth/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings 3 | python_files = tests.py test_*.py *_tests.py 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=2.2 2 | django-model-utils>=2.0 3 | monero>=0.6 4 | python-monerorpc>=0.5.5 5 | pybitid>=0.0.4 6 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | django_cryptolock 2 | ================= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | django_cryptolock 8 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | # Your app requirements. 2 | -r ../requirements_test.txt 3 | 4 | # Your app in editable mode. 5 | -e ../ 6 | -------------------------------------------------------------------------------- /example/testauth/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestauthConfig(AppConfig): 5 | name = "testauth" 6 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributors 3 | ============ 4 | 5 | * Gonçalo Valério 6 | * Guy Willett - https://github.com/guywillett 7 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | djangorestframework>=3.9.3 4 | bumpversion==0.5.3 5 | wheel==0.30.0 6 | twine==3.1.0 7 | Sphinx==2.2.1 8 | -------------------------------------------------------------------------------- /django_cryptolock/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from django.apps import AppConfig 3 | 4 | 5 | class DjangoCryptolockConfig(AppConfig): 6 | name = "django_cryptolock" 7 | -------------------------------------------------------------------------------- /example/testauth/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.views.generic import TemplateView 3 | 4 | 5 | class IndexView(TemplateView): 6 | template_name = "index.html" 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | 4 | [report] 5 | omit = 6 | *site-packages* 7 | *tests* 8 | *.tox* 9 | show_missing = True 10 | exclude_lines = 11 | raise NotImplementedError 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | include requirements.txt 7 | recursive-include django_cryptolock *.html *.png *.gif *js *.css *jpg *jpeg *svg *py 8 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | $ pip install django-cryptolock 8 | 9 | Or, if you have virtualenvwrapper installed:: 10 | 11 | $ mkvirtualenv django-cryptolock 12 | $ pip install django-cryptolock 13 | -------------------------------------------------------------------------------- /example/testauth/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib.auth.views import LogoutView 3 | 4 | from .views import IndexView 5 | 6 | urlpatterns = [ 7 | url(r"^logout$", LogoutView.as_view(), name="logout"), 8 | url(r"^$", IndexView.as_view(), name="index"), 9 | ] 10 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /example/templates/django_cryptolock/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'django_cryptolock/base.html' %} 2 | 3 | {% block content %} 4 |
5 | {% csrf_token %} 6 | {{form}} 7 | 8 |
9 | {% endblock content %} 10 | -------------------------------------------------------------------------------- /django_cryptolock/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Challenge 4 | from .forms import SimpleSignUpForm 5 | 6 | 7 | class ChallengeSerializer(serializers.ModelSerializer): 8 | class Meta: 9 | model = Challenge 10 | fields = ["challenge", "expires"] 11 | -------------------------------------------------------------------------------- /example/templates/django_cryptolock/signup.html: -------------------------------------------------------------------------------- 1 | {% extends 'django_cryptolock/base.html' %} 2 | 3 | {% block content %} 4 |
5 | {% csrf_token %} 6 | {{form}} 7 | 8 |
9 | {% endblock content %} 10 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | django-model-utils>=2.0 3 | monero>=0.6 4 | python-monerorpc>=0.5.5 5 | djangorestframework>=3.9.3 6 | 7 | # Test Dependencies 8 | coverage==4.4.1 9 | mock>=1.0.1 10 | flake8>=2.1.0 11 | tox>=1.7.0 12 | coveralls 13 | pytest 14 | pytest-django 15 | pytest-cov 16 | model_mommy 17 | black 18 | -------------------------------------------------------------------------------- /django_cryptolock/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf.urls import url 3 | 4 | from .views import CryptoLockLoginView, CryptoLockSignUpView 5 | 6 | 7 | app_name = "django_cryptolock" 8 | urlpatterns = [ 9 | url(r"login", CryptoLockLoginView.as_view(), name="login"), 10 | url(r"signup", CryptoLockSignUpView.as_view(), name="signup"), 11 | ] 12 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals, absolute_import 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /docs/features_roadmap.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | Features and Roadmap 3 | ==================== 4 | 5 | Features 6 | -------- 7 | 8 | Below are some of the features the package already supports: 9 | 10 | * Authentication using BitId 11 | * Authentication using Monero-Cryptolock 12 | * Supports custom user models 13 | 14 | Roadmap 15 | ------- 16 | 17 | * QR code generation 18 | * Multiple login addresses per-user 19 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.0 3 | commit = True 4 | tag = False 5 | 6 | [bumpversion:file:setup.py] 7 | 8 | [bumpversion:file:django_cryptolock/__init__.py] 9 | 10 | [wheel] 11 | universal = 1 12 | 13 | [flake8] 14 | ignore = D203 15 | exclude = 16 | django_cryptolock/migrations, 17 | .git, 18 | .tox, 19 | docs/conf.py, 20 | build, 21 | dist 22 | max-line-length = 119 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Django-Cryptolock version: 2 | * Django version: 3 | * Python version: 4 | * Operating System: 5 | 6 | ### Description 7 | 8 | Describe what you were trying to get done. 9 | Tell us what happened, what went wrong, and what you expected to happen. 10 | 11 | ### What I Did 12 | 13 | ``` 14 | Paste the command(s) you ran and the output. 15 | If there was a crash, please include the traceback here. 16 | ``` 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py3.6,py3.7,py3.8}-django{2.2,3.1} 4 | 5 | [testenv] 6 | setenv = 7 | PYTHONPATH = {toxinidir}:{toxinidir}/django_cryptolock 8 | commands = 9 | pytest --cov=django_cryptolock tests/ 10 | deps = 11 | django2.2: django>=2.2,<3.0 12 | django3.1: django>=3.1,<3.2 13 | -r {toxinidir}/requirements_test.txt 14 | basepython = 15 | py3.8: python3.8 16 | py3.7: python3.7 17 | py3.6: python3.6 18 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from model_mommy import mommy 3 | 4 | from django_cryptolock.utils import generate_challenge 5 | 6 | 7 | def test_challenge_has_default_byte_len(): 8 | challenge = generate_challenge() 9 | assert len(bytes.fromhex(challenge)) == 16 10 | 11 | 12 | @pytest.mark.parametrize("length", (8, 16, 32, 64)) 13 | def test_challenge_has_custom_byte_len(length, settings): 14 | settings.DJCL_CHALLENGE_BYTES = length 15 | challenge = generate_challenge() 16 | assert len(bytes.fromhex(challenge)) == length 17 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. complexity documentation master file, created by 2 | sphinx-quickstart on Tue Jul 9 22:26:36 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Django-Cryptolock's documentation! 7 | ================================================================= 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | readme 15 | installation 16 | usage 17 | features_roadmap 18 | contributing 19 | authors 20 | history 21 | -------------------------------------------------------------------------------- /.github/workflows/format-check.yml: -------------------------------------------------------------------------------- 1 | name: Format check 2 | 3 | on: push 4 | 5 | jobs: 6 | format-check: 7 | name: Check Codebase format 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v2 13 | 14 | - name: Set up Python 3.9 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.9 18 | 19 | - name: Install dependencies 20 | run: pip install -r requirements_test.txt 21 | 22 | - name: Run Black 23 | run: | 24 | black --version 25 | black . --check 26 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals, absolute_import 3 | 4 | from django.conf.urls import url, include 5 | from django.urls import path 6 | 7 | from django_cryptolock.api_views import ( 8 | CryptoLockAPITokenLoginView, 9 | CryptoLockAPISignUpView, 10 | ) 11 | 12 | 13 | urlpatterns = [ 14 | path( 15 | "api/token_login", CryptoLockAPITokenLoginView.as_view(), name="api_token_login" 16 | ), 17 | path("api/signup", CryptoLockAPISignUpView.as_view(), name="api_signup"), 18 | url(r"^", include("django_cryptolock.urls", namespace="django_cryptolock")), 19 | ] 20 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ##Example Project for django_cryptolock 2 | 3 | This example is provided as a convenience feature to allow potential users to try the app straight from the app repo without having to create a django project. 4 | 5 | It can also be used to develop the app in place. 6 | 7 | To run this example, follow these instructions: 8 | 9 | 1. Navigate to the `example` directory 10 | 2. Install the requirements for the package: 11 | 12 | pip install -r requirements.txt 13 | 14 | 3. Make and apply migrations 15 | 16 | python manage.py makemigrations 17 | 18 | python manage.py migrate 19 | 20 | 4. Run the server 21 | 22 | python manage.py runserver 23 | 24 | 5. Access from the browser at `http://127.0.0.1:8000` 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | htmlcov 30 | 31 | # Translations 32 | *.mo 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .pydevproject 38 | 39 | # Pycharm/Intellij 40 | .idea 41 | 42 | # Complexity 43 | output/*.html 44 | output/*/index.html 45 | 46 | # Sphinx 47 | docs/_build 48 | 49 | # Editors 50 | .vscode 51 | 52 | # Pytest 53 | .pytest_cache 54 | 55 | # Project specific 56 | example/db.sqlite3 57 | -------------------------------------------------------------------------------- /django_cryptolock/migrations/0002_auto_20200218_1312.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2020-02-18 19:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("django_cryptolock", "0001_initial")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="address", 13 | name="network", 14 | field=models.PositiveSmallIntegerField( 15 | choices=[(1, "Monero"), (2, "Bitcoin")], default=1 16 | ), 17 | ), 18 | migrations.AlterField( 19 | model_name="address", 20 | name="address", 21 | field=models.CharField(max_length=106, unique=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /.github/workflows/run-test-suite.yml: -------------------------------------------------------------------------------- 1 | name: Run Test Suite 2 | 3 | on: push 4 | 5 | jobs: 6 | run-test-suite: 7 | name: Run Test Suite 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python_version: [3.6, 3.7, 3.8] 12 | django_version: [2.2, 3.1] 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | 18 | - name: Set up Python ${{ matrix.python_version }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python_version }} 22 | 23 | - name: Install dependencies 24 | run: pip install -r requirements_test.txt 25 | 26 | - name: Run Tests 27 | run: tox -e py${{ matrix.python_version }}-django${{ matrix.django_version }} 28 | -------------------------------------------------------------------------------- /django_cryptolock/templates/django_cryptolock/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% comment %} 3 | As the developer of this package, don't place anything here if you can help it 4 | since this allows developers to have interoperability between your template 5 | structure and their own. 6 | 7 | Example: Developer melding the 2SoD pattern to fit inside with another pattern:: 8 | 9 | {% extends "base.html" %} 10 | {% load static %} 11 | 12 | 13 | {% block extra_js %} 14 | 15 | 16 | {% block javascript %} 17 | 18 | {% endblock javascript %} 19 | 20 | {% endblock extra_js %} 21 | {% endcomment %} 22 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Description:** Describe in a couple of sentences what this PR adds 2 | 3 | **Issue:** Link related issue 4 | 5 | **Dependencies:** dependencies on other outstanding PRs, issues, etc. 6 | 7 | **Installation instructions:** List any non-trivial installation 8 | instructions. 9 | 10 | **Testing instructions:** 11 | 12 | 1. Open page A 13 | 2. Do thing B 14 | 3. Expect C to happen 15 | 4. If D happened instead - check failed. 16 | 17 | **Merge checklist:** 18 | 19 | - [ ] All reviewers approved 20 | - [ ] CI build is green 21 | - [ ] Version bumped 22 | - [ ] Changelog record added 23 | - [ ] Documentation updated (not only docstrings) 24 | 25 | **Author concerns:** List any concerns about this PR - inelegant 26 | solutions, hacks, quick-and-dirty implementations, concerns about 27 | migrations, etc. 28 | -------------------------------------------------------------------------------- /example/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'django_cryptolock/base.html' %} 2 | 3 | {% block content %} 4 | {% if request.user.is_authenticated %} 5 |

Hello {{request.user.username}}

6 |

Here are your details:

7 | 12 |
13 | {% csrf_token %} 14 | 15 |
16 | {% else %} 17 |

Hey stranger

18 |

This website is an example of the "django_cryptolock" package.

19 |

20 | It lets you (and your users) use your monero wallet to login on your 21 | website. You just need to sign the challenge that is provided to you. 22 |

23 |

Go ahead and try it.

24 | {% endif %} 25 | {% endblock content %} 26 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | """example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.9/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url, include 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | url(r"^admin/", admin.site.urls), 21 | url(r"^auth/", include("django_cryptolock.urls", namespace="django_cryptolock")), 22 | url(r"^", include("testauth.urls")), 23 | ] 24 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ------- 5 | 6 | Development 7 | +++++++++++ 8 | 9 | * Challenges are now stored on the database. No longer expire when a new one is 10 | generated. 11 | * Added initial support for DRF, TokenAuthentication only. 12 | * Move CI from Travis-ci to Github Actions 13 | 14 | 15 | 0.1.0 (2020-03-31) 16 | ++++++++++++++++++ 17 | 18 | * Add validation for existing addresses on the signup form 19 | * Add rudimentary BitId support 20 | * Renamed the base auth views to generic names 21 | 22 | 0.0.2 (2020-01-08) 23 | ++++++++++++++++++ 24 | 25 | * A default ``urls.py`` is provided by the package so can work "out-of-the-box". 26 | * Default location for templates moved to ``django_cryptolock`` folder. 27 | * Update quickstart guide. 28 | * Update instructions to contribute to the project. 29 | * Add ``DJCL`` namespace to all related settings. 30 | * MoneroAddressBackend is now executed when more parameters are added to the 31 | ``authenticate`` function. 32 | 33 | 0.0.1 (2019-11-25) 34 | ++++++++++++++++++ 35 | 36 | * First release on PyPI. 37 | -------------------------------------------------------------------------------- /docs/django_cryptolock.migrations.rst: -------------------------------------------------------------------------------- 1 | django\_cryptolock.migrations package 2 | ===================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | django\_cryptolock.migrations.0001\_initial module 8 | -------------------------------------------------- 9 | 10 | .. automodule:: django_cryptolock.migrations.0001_initial 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | django\_cryptolock.migrations.0002\_auto\_20200218\_1312 module 16 | --------------------------------------------------------------- 17 | 18 | .. automodule:: django_cryptolock.migrations.0002_auto_20200218_1312 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | django\_cryptolock.migrations.0003\_challenge module 24 | ---------------------------------------------------- 25 | 26 | .. automodule:: django_cryptolock.migrations.0003_challenge 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | 32 | Module contents 33 | --------------- 34 | 35 | .. automodule:: django_cryptolock.migrations 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2019, Gonçalo Valério 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /django_cryptolock/mixins.py: -------------------------------------------------------------------------------- 1 | from django.db import transaction 2 | from django.contrib.auth import get_user_model 3 | 4 | from rest_framework.response import Response 5 | from rest_framework import status 6 | 7 | from pybitid import bitid 8 | 9 | from .models import Challenge 10 | from .serializers import ChallengeSerializer 11 | 12 | 13 | class CreateChallengeMixin: 14 | """Add create challenge on get functionality to API views.""" 15 | 16 | def get(self, request, format=None): 17 | """Returns a new challenge for the login.""" 18 | serializer = ChallengeSerializer(instance=Challenge.objects.generate()) 19 | serializer.data["challenge"] = bitid.build_uri( 20 | request.build_absolute_uri(), serializer.data["challenge"] 21 | ) 22 | return Response(serializer.data, status=status.HTTP_200_OK) 23 | 24 | 25 | class CreateUserMixin: 26 | @transaction.atomic 27 | def create_user(self, username, challenge, address, network): 28 | user = get_user_model().objects.create(username=username) 29 | user.address_set.create(address=address, network=network) 30 | Challenge.objects.invalidate(challenge) 31 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Set of functions and constants that help testing the existing functionality 3 | """ 4 | from pybitid import bitid 5 | 6 | from django_cryptolock.models import Challenge 7 | 8 | DUMMY_CREDS = {"username": "test", "password": "insecure"} 9 | VALID_MONERO_ADDRESS = "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq" 10 | VALID_BITCOIN_ADDRESS = "1N5attoW1FviYGnLmRu9xjaPMKTkWxtUCW" 11 | VALID_BITCOIN_SIG = "H5wI5uqhRCxBpyre2mYkjLxNKPi/TCj9IaHhmfnF8Wn1Pac6gsuYsd2GqTNpy/JFDv3HBSOD75pk2OsGDxE7U4o=" 12 | VALID_BITID_URI = "bitid://www.django-cryptolock.test/?x=44d91949c7b2eb20" 13 | EXAMPLE_LOGIN_URL = "https://www.django-cryptolock.test/" 14 | 15 | 16 | def set_monero_settings(settings): 17 | settings.AUTHENTICATION_BACKENDS = [ 18 | "django_cryptolock.backends.MoneroAddressBackend", 19 | "django.contrib.auth.backends.ModelBackend", 20 | ] 21 | 22 | 23 | def set_bitcoin_settings(settings): 24 | settings.AUTHENTICATION_BACKENDS = [ 25 | "django_cryptolock.backends.BitcoinAddressBackend", 26 | "django.contrib.auth.backends.ModelBackend", 27 | ] 28 | 29 | 30 | def gen_challenge(): 31 | return bitid.build_uri(EXAMPLE_LOGIN_URL, Challenge.objects.generate()) 32 | -------------------------------------------------------------------------------- /django_cryptolock/managers.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.db.models.manager import Manager 4 | from django.conf import settings 5 | from django.utils import timezone 6 | 7 | from .utils import generate_challenge 8 | 9 | 10 | class ChallengeManager(Manager): 11 | """Provides methods to easily create and verify challenges.""" 12 | 13 | def generate(self): 14 | token = generate_challenge() 15 | age = getattr(settings, "DJCL_CHALLENGE_EXPIRATION", 10) 16 | expiry_date = timezone.now() + timedelta(minutes=age) 17 | return self.create(challenge=token, expires=expiry_date) 18 | 19 | def is_active(self, challenge): 20 | """Returns True if the challenge can be used. Otherwise False.""" 21 | now = timezone.now() 22 | return self.filter(challenge=challenge, expires__gte=now).exists() 23 | 24 | def invalidate(self, challenge): 25 | """Removes the provided challenge if it exists.""" 26 | self.filter(challenge=challenge).delete() 27 | 28 | def clean_expired(self): 29 | """Delete all expired challenges. Returns nº of entries removed.""" 30 | now = timezone.now() 31 | del_summary = self.filter(expires__lt=now).delete() 32 | return del_summary[0] 33 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals, absolute_import 3 | 4 | import os 5 | 6 | import django 7 | 8 | DEBUG = True 9 | USE_TZ = True 10 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 11 | 12 | # SECURITY WARNING: keep the secret key used in production secret! 13 | SECRET_KEY = "!z^^097u*@)yq#w1n14m%uh-l67#h&uft9p+m%$$(0y(s%-q7o" 14 | 15 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 16 | 17 | ROOT_URLCONF = "tests.urls" 18 | 19 | INSTALLED_APPS = [ 20 | "django.contrib.auth", 21 | "django.contrib.contenttypes", 22 | "django.contrib.sites", 23 | "rest_framework.authtoken", 24 | "django_cryptolock", 25 | ] 26 | 27 | SITE_ID = 1 28 | 29 | if django.VERSION >= (1, 10): 30 | MIDDLEWARE = () 31 | else: 32 | MIDDLEWARE_CLASSES = () 33 | 34 | TEMPLATES = [ 35 | { 36 | "BACKEND": "django.template.backends.django.DjangoTemplates", 37 | "DIRS": [os.path.join(BASE_DIR, "tests/templates")], 38 | } 39 | ] 40 | 41 | AUTHENTICATION_BACKENDS = [ 42 | "django_cryptolock.backends.MoneroAddressBackend", 43 | "django_cryptolock.backends.BitcoinAddressBackend", 44 | "django.contrib.auth.backends.ModelBackend", 45 | ] 46 | 47 | # Test only default settings 48 | DJCL_MONERO_NETWORK = "stagenet" 49 | DJCL_BITCOIN_NETWORK = "mainnet" 50 | 51 | DJCL_MONERO_WALLET_RPC_HOST = "localhost:3030" 52 | DJCL_MONERO_WALLET_RPC_USER = "test" 53 | DJCL_MONERO_WALLET_RPC_PASS = "test" 54 | DJCL_MONERO_WALLET_RPC_PROTOCOL = "http" 55 | -------------------------------------------------------------------------------- /django_cryptolock/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.utils.translation import gettext_lazy as _ 3 | from django.conf import settings 4 | 5 | from monero.address import Address 6 | from pybitid.bitid import address_valid 7 | 8 | 9 | def validate_monero_address(value): 10 | try: 11 | address = Address(value) 12 | except ValueError as e: 13 | raise ValidationError( 14 | _("%(value)s is not a valid address"), params={"value": value} 15 | ) 16 | 17 | network = getattr(settings, "DJCL_MONERO_NETWORK", None) 18 | if not network: 19 | raise ValidationError( 20 | _("Please configure the monero network in the settings file") 21 | ) 22 | if network == "mainnet" and not address.is_mainnet(): 23 | raise ValidationError(_("Invalid address for mainnet")) 24 | elif network == "stagenet" and not address.is_stagenet(): 25 | raise ValidationError(_("Invalid address for stagenet")) 26 | elif network == "testnet" and not address.is_testnet(): 27 | raise ValidationError(_("Invalid address for testnet")) 28 | 29 | 30 | def validate_bitcoin_address(value): 31 | network = getattr(settings, "DJCL_BITCOIN_NETWORK", None) 32 | if not network: 33 | raise ValidationError( 34 | _("Please configure the monero network in the settings file") 35 | ) 36 | testnet = True if network == "testnet" else False 37 | if not address_valid(value, is_testnet=testnet): 38 | raise ValidationError(_(f"Invalid address for {network}")) 39 | -------------------------------------------------------------------------------- /django_cryptolock/migrations/0003_challenge.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2020-05-12 11:22 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | import model_utils.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("django_cryptolock", "0002_auto_20200218_1312")] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Challenge", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ( 26 | "created", 27 | model_utils.fields.AutoCreatedField( 28 | default=django.utils.timezone.now, 29 | editable=False, 30 | verbose_name="created", 31 | ), 32 | ), 33 | ( 34 | "modified", 35 | model_utils.fields.AutoLastModifiedField( 36 | default=django.utils.timezone.now, 37 | editable=False, 38 | verbose_name="modified", 39 | ), 40 | ), 41 | ("challenge", models.CharField(max_length=150)), 42 | ("expires", models.DateTimeField()), 43 | ], 44 | options={"verbose_name": "Challenge", "verbose_name_plural": "Challenges"}, 45 | ) 46 | ] 47 | -------------------------------------------------------------------------------- /tests/test_validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | 3 | import pytest 4 | 5 | from django_cryptolock.validators import validate_monero_address 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "network,address", 10 | [ 11 | ( 12 | "mainnet", 13 | "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq", 14 | ), 15 | ( 16 | "stagenet", 17 | "5A2uBqpNg7E4cYzA7bhXeuP9C2qSeUAtDhUScFgTBJWMUNJd9yZJxwDHg8sPVPBBx7JJFYqxDSbb7HFz2w8dttVJKW5Yipp", 18 | ), 19 | ( 20 | "testnet", 21 | "9waU7xxRYbC2HkKV2k4dzPjEYwkYDiHmfELLUkS8vegVLEYkk2dk3X5JJZtURNthsaDh8zL5SYAp8VXMzqvRYptgGTYNpEn", 22 | ), 23 | ], 24 | ) 25 | def test_valid_address(network, address, settings): 26 | settings.DJCL_MONERO_NETWORK = network 27 | assert validate_monero_address(address) is None 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "network,address", 32 | [ 33 | ( 34 | "mainnet", 35 | "9waU7xxRYbC2HkKV2k4dzPjEYwkYDiHmfELLUkS8vegVLEYkk2dk3X5JJZtURNthsaDh8zL5SYAp8VXMzqvRYptgGTYNpEn", 36 | ), 37 | ( 38 | "stagenet", 39 | "46fYuhPAdsxMbEeMg97LhSbFPamdiCw7C6b19VEcZSmV6xboWFZuZQ9MTbj1wLszhUExHi63CMtsWjDTrRDqegZiPVebgYq", 40 | ), 41 | ( 42 | "testnet", 43 | "5A2uBqpNg7E4cYzA7bhXeuP9C2qSeUAtDhUScFgTBJWMUNJd9yZJxwDHg8sPVPBBx7JJFYqxDSbb7HFz2w8dttVJKW5Yipp", 44 | ), 45 | ], 46 | ) 47 | def test_invalid_address(network, address, settings): 48 | settings.DJCL_MONERO_NETWORK = network 49 | 50 | with pytest.raises(ValidationError) as error: 51 | validate_monero_address(address) 52 | 53 | assert f"Invalid address for {network}" in str(error.value) 54 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | define BROWSER_PYSCRIPT 4 | import os, webbrowser, sys 5 | try: 6 | from urllib import pathname2url 7 | except: 8 | from urllib.request import pathname2url 9 | 10 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 11 | endef 12 | export BROWSER_PYSCRIPT 13 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 14 | 15 | help: 16 | @grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf "\033[36m %-25s\033[0m %s\n", $$1, $$2}' 17 | 18 | clean: clean-build clean-pyc 19 | 20 | clean-build: ## remove build artifacts 21 | rm -fr build/ 22 | rm -fr dist/ 23 | rm -fr *.egg-info 24 | 25 | clean-pyc: ## remove Python file artifacts 26 | find . -name '*.pyc' -exec rm -f {} + 27 | find . -name '*.pyo' -exec rm -f {} + 28 | find . -name '*~' -exec rm -f {} + 29 | 30 | lint: ## Apply black formatting into the code 31 | black . 32 | 33 | link-check: ## Check formatting with black 34 | black . --check 35 | 36 | test: ## run tests quickly with the default Python 37 | pytest --cov=django_cryptolock tests/ 38 | 39 | test-all: ## run tests on every Python version with tox 40 | tox 41 | 42 | coverage: ## check code coverage quickly with the default Python 43 | pytest --cov=django_cryptolock tests/ 44 | coverage report -m 45 | coverage html 46 | open htmlcov/index.html 47 | 48 | docs: ## generate Sphinx HTML documentation, including API docs 49 | rm -f docs/django_cryptolock.migrations.rst 50 | rm -f docs/django-cryptolock.rst 51 | rm -f docs/modules.rst 52 | sphinx-apidoc -o docs/ django_cryptolock 53 | $(MAKE) -C docs clean 54 | $(MAKE) -C docs html 55 | $(BROWSER) docs/_build/html/index.html 56 | 57 | release: clean ## package and upload a release 58 | python setup.py sdist 59 | python setup.py bdist_wheel 60 | twine upload dist/* 61 | 62 | sdist: clean ## package 63 | python setup.py sdist 64 | ls -l dist 65 | -------------------------------------------------------------------------------- /docs/django_cryptolock.rst: -------------------------------------------------------------------------------- 1 | django\_cryptolock package 2 | ========================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | django_cryptolock.migrations 10 | 11 | Submodules 12 | ---------- 13 | 14 | django\_cryptolock.apps module 15 | ------------------------------ 16 | 17 | .. automodule:: django_cryptolock.apps 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | django\_cryptolock.backends module 23 | ---------------------------------- 24 | 25 | .. automodule:: django_cryptolock.backends 26 | :members: 27 | :undoc-members: 28 | :show-inheritance: 29 | 30 | django\_cryptolock.forms module 31 | ------------------------------- 32 | 33 | .. automodule:: django_cryptolock.forms 34 | :members: 35 | :undoc-members: 36 | :show-inheritance: 37 | 38 | django\_cryptolock.models module 39 | -------------------------------- 40 | 41 | .. automodule:: django_cryptolock.models 42 | :members: 43 | :undoc-members: 44 | :show-inheritance: 45 | 46 | django\_cryptolock.urls module 47 | ------------------------------ 48 | 49 | .. automodule:: django_cryptolock.urls 50 | :members: 51 | :undoc-members: 52 | :show-inheritance: 53 | 54 | django\_cryptolock.utils module 55 | ------------------------------- 56 | 57 | .. automodule:: django_cryptolock.utils 58 | :members: 59 | :undoc-members: 60 | :show-inheritance: 61 | 62 | django\_cryptolock.validators module 63 | ------------------------------------ 64 | 65 | .. automodule:: django_cryptolock.validators 66 | :members: 67 | :undoc-members: 68 | :show-inheritance: 69 | 70 | django\_cryptolock.views module 71 | ------------------------------- 72 | 73 | .. automodule:: django_cryptolock.views 74 | :members: 75 | :undoc-members: 76 | :show-inheritance: 77 | 78 | 79 | Module contents 80 | --------------- 81 | 82 | .. automodule:: django_cryptolock 83 | :members: 84 | :undoc-members: 85 | :show-inheritance: 86 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Django-Cryptolock 3 | ============================= 4 | 5 | **DISCLAIMER: This project is no longer maintained. Feel free to fork. The 6 | PyPI package will remain available, but any user's should replace it as soon 7 | as possible.** 8 | 9 | Authentication using cryptocurrency wallets for Django projects. 10 | 11 | This package provides a django app containing a set of utilities to 12 | implement the BitId and Monero Cryptolock authentication "protocols". 13 | 14 | Future releases might include other cryptocurrencies but for the being 15 | (until we reach some stability) all the focus will remain on BTC and XMR. 16 | 17 | Documentation 18 | ------------- 19 | 20 | The full documentation is at https://django-cryptolock.readthedocs.io. 21 | 22 | Quickstart 23 | ---------- 24 | 25 | 1. Install Django-Cryptolock:: 26 | 27 | pip install django-cryptolock 28 | 29 | 2. Add it to your `INSTALLED_APPS`: 30 | 31 | .. code-block:: python 32 | 33 | INSTALLED_APPS = ( 34 | ... 35 | "django_cryptolock.apps.DjangoCryptolockConfig", 36 | ... 37 | ) 38 | 39 | 3. Migrate your database:: 40 | 41 | python manage.py migrate 42 | 43 | 44 | 4. Add the following settings to your project for the Monero Backend: 45 | 46 | .. code-block:: python 47 | 48 | AUTHENTICATION_BACKENDS = [ 49 | "django_cryptolock.backends.MoneroAddressBackend", 50 | ... 51 | ] 52 | DJCL_MONERO_NETWORK = "mainnet" 53 | DJCL_MONERO_WALLET_RPC_PROTOCOL = "" 54 | DJCL_MONERO_WALLET_RPC_HOST = ":" 55 | DJCL_MONERO_WALLET_RPC_USER = "" 56 | DJCL_MONERO_WALLET_RPC_PASS = "" 57 | 58 | 5. Add Django-Cryptolock's URL patterns: 59 | 60 | .. code-block:: python 61 | 62 | from django.conf.urls import url 63 | 64 | 65 | urlpatterns = [ 66 | ... 67 | url(r"^auth/", include("django_cryptolock.urls", namespace="django_cryptolock")), 68 | ... 69 | ] 70 | 71 | More detailed information can be found in the [documentation](#documentation). 72 | -------------------------------------------------------------------------------- /django_cryptolock/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import models 3 | from django.conf import settings 4 | from django.utils.translation import gettext_lazy as _ 5 | from django.core.exceptions import ValidationError 6 | 7 | from model_utils.models import TimeStampedModel 8 | 9 | from .validators import validate_monero_address, validate_bitcoin_address 10 | from .managers import ChallengeManager 11 | 12 | 13 | class Address(TimeStampedModel): 14 | """Addresses that belong to a given user account.""" 15 | 16 | NETWORK_MONERO = 1 17 | NETWORK_BITCOIN = 2 18 | 19 | NETWORKS = ((NETWORK_MONERO, "Monero"), (NETWORK_BITCOIN, "Bitcoin")) 20 | 21 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 22 | network = models.PositiveSmallIntegerField(choices=NETWORKS, default=NETWORK_MONERO) 23 | address = models.CharField(max_length=106, unique=True) 24 | 25 | class Meta: 26 | """Meta definition for Address.""" 27 | 28 | verbose_name = _("Address") 29 | verbose_name_plural = _("Addresses") 30 | 31 | def __str__(self): 32 | """Unicode representation of Address.""" 33 | return self.address 34 | 35 | def clean(self): 36 | try: 37 | if self.network == self.NETWORK_MONERO: 38 | validate_monero_address(self.address) 39 | else: 40 | validate_bitcoin_address(self.address) 41 | except ValidationError: 42 | raise ValidationError(_("Invalid address for the given network")) 43 | 44 | 45 | class Challenge(TimeStampedModel): 46 | """Challenges provided to users for authentication purposes.""" 47 | 48 | challenge = models.CharField(max_length=150) 49 | expires = models.DateTimeField(null=False) 50 | 51 | objects = ChallengeManager() 52 | 53 | class Meta: 54 | """Meta definition for Challenge.""" 55 | 56 | verbose_name = _("Challenge") 57 | verbose_name_plural = _("Challenges") 58 | 59 | def __str__(self): 60 | """Unicode representation of Challenge.""" 61 | return self.challenge 62 | -------------------------------------------------------------------------------- /tests/test_managers.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.utils import timezone 4 | 5 | import pytest 6 | from model_mommy import mommy 7 | 8 | from django_cryptolock.models import Challenge 9 | 10 | pytestmark = pytest.mark.django_db 11 | 12 | 13 | class TestChallengeManager: 14 | @pytest.mark.parametrize("conf", (None, 5, 10, 15, 20, 30, 60)) 15 | def test_generate_challenge_with_expiration(self, settings, conf): 16 | if conf: 17 | settings.DJCL_CHALLENGE_EXPIRATION = conf 18 | test = timezone.now() + timedelta(minutes=conf + 1) 19 | else: 20 | test = timezone.now() + timedelta(minutes=11) 21 | 22 | assert not Challenge.objects.all().exists() 23 | 24 | challenge = Challenge.objects.generate() 25 | assert challenge.challenge 26 | assert challenge.expires < test 27 | assert Challenge.objects.all().exists() 28 | 29 | def test_is_active_when_expired(self): 30 | challenge = mommy.make(Challenge, challenge="1234", expires=timezone.now()) 31 | assert not Challenge.objects.is_active(challenge=challenge.challenge) 32 | 33 | def test_is_active_when_inexistent(self): 34 | assert not Challenge.objects.is_active(challenge="1234") 35 | 36 | def test_is_active(self): 37 | challenge = Challenge.objects.generate() 38 | assert Challenge.objects.is_active(challenge=challenge.challenge) 39 | 40 | def test_invalidate_existing_challenge(self): 41 | challenge = Challenge.objects.generate() 42 | Challenge.objects.invalidate(challenge.challenge) 43 | assert not Challenge.objects.all().exists() 44 | 45 | def test_invalidate_inexistent_challenge(self): 46 | Challenge.objects.invalidate("1234") 47 | 48 | @pytest.mark.parametrize("num", (2, 5, 10, 15)) 49 | def test_clean_expired_challenges(self, num): 50 | mommy.make(Challenge, num, expires=timezone.now()) 51 | Challenge.objects.generate() 52 | deleted = Challenge.objects.clean_expired() 53 | assert deleted == num 54 | assert Challenge.objects.count() == 1 55 | -------------------------------------------------------------------------------- /django_cryptolock/utils.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from typing import Union 3 | from secrets import token_hex 4 | 5 | from django.conf import settings 6 | from django.http.request import HttpRequest 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | from monerorpc.authproxy import AuthServiceProxy 10 | from pybitid import bitid 11 | 12 | 13 | def verify_monero_signature(address: str, challenge: str, signature: str) -> bool: 14 | """Makes a request to wallet RPC to verify address and signature.""" 15 | protocol = settings.DJCL_MONERO_WALLET_RPC_PROTOCOL 16 | host = settings.DJCL_MONERO_WALLET_RPC_HOST 17 | user = settings.DJCL_MONERO_WALLET_RPC_USER 18 | pwd = settings.DJCL_MONERO_WALLET_RPC_PASS 19 | wallet_rpc = AuthServiceProxy(f"{protocol}://{user}:{pwd}@{host}/json_rpc") 20 | 21 | result = wallet_rpc.verify( 22 | {"data": challenge, "address": address, "signature": signature} 23 | ) 24 | 25 | return result.get("good", False) 26 | 27 | 28 | def verify_bitcoin_signature( 29 | address: str, challenge: str, signature: str, request: HttpRequest 30 | ) -> bool: 31 | """Verifies if the provided bitcoin signature is valid.""" 32 | network = getattr(settings, "DJCL_BITCOIN_NETWORK", None) 33 | if not network: 34 | warnings.warn(_("Please configure the bitcoin network in the settings file")) 35 | is_testnet = True if network == "testnet" else False 36 | callback_uri = request.build_absolute_uri() 37 | return bitid.challenge_valid( 38 | address, signature, challenge, callback_uri, is_testnet 39 | ) 40 | 41 | 42 | def verify_signature( 43 | network: str, address: str, challenge: str, signature: str, request: HttpRequest 44 | ): 45 | valid_sig = False 46 | 47 | if network == "Bitcoin": 48 | valid_sig = verify_bitcoin_signature( 49 | address, challenge, signature, request=request 50 | ) 51 | elif network == "Monero": 52 | valid_sig = verify_monero_signature(address, challenge, signature) 53 | 54 | return valid_sig 55 | 56 | 57 | def generate_challenge(): 58 | """Generates a new random challenge for the authentication.""" 59 | num_bytes = getattr(settings, "DJCL_CHALLENGE_BYTES", 16) 60 | return token_hex(num_bytes) 61 | -------------------------------------------------------------------------------- /django_cryptolock/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.utils.translation import gettext_lazy as _ 3 | from django.contrib.auth.views import LoginView 4 | from django.views.generic import FormView 5 | from django.forms.utils import ErrorList 6 | from django.conf import settings 7 | 8 | from monerorpc.authproxy import JSONRPCException 9 | 10 | from .forms import SimpleSignUpForm, SimpleLoginForm 11 | from .utils import verify_signature 12 | from .models import Address, Challenge 13 | from .mixins import CreateUserMixin 14 | 15 | 16 | class CryptoLockLoginView(LoginView): 17 | template_name = "django_cryptolock/login.html" 18 | form_class = SimpleLoginForm 19 | 20 | def form_valid(self, form): 21 | response = super().form_valid(form) 22 | challenge = form.cleaned_data["challenge"] 23 | Challenge.objects.invalidate(challenge) 24 | Challenge.objects.clean_expired() 25 | return response 26 | 27 | 28 | class CryptoLockSignUpView(CreateUserMixin, FormView): 29 | template_name = "django_cryptolock/signup.html" 30 | form_class = SimpleSignUpForm 31 | 32 | def get_form(self, form_class=None): 33 | return self.form_class(request=self.request, **self.get_form_kwargs()) 34 | 35 | def form_valid(self, form): 36 | username = form.cleaned_data["username"] 37 | address = form.cleaned_data["address"] 38 | challenge = form.cleaned_data["challenge"] 39 | signature = form.cleaned_data["signature"] 40 | network = [n[1] for n in Address.NETWORKS if n[0] == form.network][0] 41 | 42 | try: 43 | valid_sig = verify_signature( 44 | network, address, challenge, signature, self.request 45 | ) 46 | except JSONRPCException: 47 | form._errors["__all__"] = ErrorList( 48 | [_("Error connecting to Monero daemon")] 49 | ) 50 | return self.form_invalid(form) 51 | 52 | if valid_sig: 53 | self.create_user(username, challenge, address, form.network) 54 | return super().form_valid(form) 55 | else: 56 | form._errors["signature"] = ErrorList([_("Invalid signature")]) 57 | return self.form_invalid(form) 58 | 59 | def get_success_url(self): 60 | return settings.LOGIN_REDIRECT_URL 61 | -------------------------------------------------------------------------------- /django_cryptolock/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-10-15 17:25 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | import django_cryptolock.validators 8 | import model_utils.fields 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Address", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ( 31 | "created", 32 | model_utils.fields.AutoCreatedField( 33 | default=django.utils.timezone.now, 34 | editable=False, 35 | verbose_name="created", 36 | ), 37 | ), 38 | ( 39 | "modified", 40 | model_utils.fields.AutoLastModifiedField( 41 | default=django.utils.timezone.now, 42 | editable=False, 43 | verbose_name="modified", 44 | ), 45 | ), 46 | ( 47 | "address", 48 | models.CharField( 49 | max_length=106, 50 | unique=True, 51 | validators=[ 52 | django_cryptolock.validators.validate_monero_address 53 | ], 54 | ), 55 | ), 56 | ( 57 | "user", 58 | models.ForeignKey( 59 | on_delete=django.db.models.deletion.CASCADE, 60 | to=settings.AUTH_USER_MODEL, 61 | ), 62 | ), 63 | ], 64 | options={"verbose_name": "Address", "verbose_name_plural": "Addresses"}, 65 | ) 66 | ] 67 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use Django-Cryptolock in a project, add it to your `INSTALLED_APPS`: 6 | 7 | .. code-block:: python 8 | 9 | INSTALLED_APPS = ( 10 | ... 11 | "django_cryptolock.apps.DjangoCryptolockConfig", 12 | ... 13 | ) 14 | 15 | Now you should add the auth backend you wish to use on your project. You can use one or more: 16 | 17 | .. code-block:: python 18 | 19 | AUTHENTICATION_BACKENDS = [ 20 | "django_cryptolock.backends.BitcoinAddressBackend", 21 | "django_cryptolock.backends.MoneroAddressBackend", 22 | ] 23 | 24 | Required Configuration 25 | ---------------------- 26 | 27 | If you use Monero, currently the following extra settings are required: 28 | 29 | .. code-block:: python 30 | 31 | DJCL_MONERO_NETWORK = "mainnet" # mainnet, stagenet or testnet 32 | DJCL_MONERO_WALLET_RPC_PROTOCOL = "" 33 | DJCL_MONERO_WALLET_RPC_HOST = ":" 34 | DJCL_MONERO_WALLET_RPC_USER = "" 35 | DJCL_MONERO_WALLET_RPC_PASS = "" 36 | 37 | For Bitcoin, you only need to set the ``DJCL_BITCOIN_NETWORK``: 38 | 39 | .. code-block:: python 40 | 41 | DJCL_BITCOIN_NETWORK = "mainnet" # mainnet or testnet 42 | 43 | Optional Configuration 44 | ---------------------- 45 | 46 | ``DJCL_CHALLENGE_BYTES`` can be used to customize the challenge length. The 47 | default is ``16`` and you should avoid lower values unless you know what you 48 | are doing. 49 | 50 | ``DJCL_CHALLENGE_EXPIRATION`` can be used to control how long a challenge is 51 | valid. The default value is `10` minutes. 52 | 53 | Using the default forms and views 54 | --------------------------------- 55 | 56 | Add Django-Cryptolock's URL patterns: 57 | 58 | .. code-block:: python 59 | 60 | from django.conf.urls import url 61 | 62 | 63 | urlpatterns = [ 64 | ... 65 | url(r"^auth/", include("django_cryptolock.urls", namespace="django_cryptolock")), 66 | ... 67 | ] 68 | 69 | This will add 2 routes : 70 | 71 | * ``django_cryptolock:signup`` 72 | * ``django_cryptolock:login`` 73 | 74 | You can then customize the generated HTML by creating the template files 75 | (``login.html`` and ``signup.html``) under a ``django_cryptolock`` subfolder in 76 | your templates directory. 77 | 78 | Both of these templates will have access to a ``form`` containing the required 79 | fields for the authentication. 80 | -------------------------------------------------------------------------------- /example/templates/django_cryptolock/base.html: -------------------------------------------------------------------------------- 1 | {% load staticfiles i18n %} 2 | 3 | 4 | 5 | 6 | 7 | {% block title %}Django-Cryptolock{% endblock title %} 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | {% block css %} {% endblock %} 18 | 19 | 20 | 21 |
22 | 41 |
42 | 43 |
44 | {% if messages %} {% for message in messages %} 45 |
48 | {{ message }} 49 |
50 | {% endfor %} {% endif %} 51 | 52 | {% block content %} 53 |
54 |

Use this document as a way to quick start any new project.

55 |

56 | The current template is loaded from 57 | django-cryptolock/example/templates/base.html. 58 |

59 |

60 | Whenever you overwrite the contents of 61 | django-cryptolock/django_cryptolock/urls.py with your own 62 | content, you should see it here. 63 |

64 |
65 | {% endblock content %} 66 |
67 | 68 | {% block modal %}{% endblock modal %} 69 | 70 | 71 | 72 | {% block javascript %} {% endblock javascript %} 73 | 74 | 75 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import re 5 | import sys 6 | 7 | try: 8 | from setuptools import setup 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | 13 | def get_version(*file_paths): 14 | """Retrieves the version from django_cryptolock/__init__.py""" 15 | filename = os.path.join(os.path.dirname(__file__), *file_paths) 16 | version_file = open(filename).read() 17 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 18 | if version_match: 19 | return version_match.group(1) 20 | raise RuntimeError("Unable to find version string.") 21 | 22 | 23 | version = get_version("django_cryptolock", "__init__.py") 24 | 25 | 26 | if sys.argv[-1] == "publish": 27 | try: 28 | import wheel 29 | 30 | print("Wheel version: ", wheel.__version__) 31 | except ImportError: 32 | print('Wheel library missing. Please run "pip install wheel"') 33 | sys.exit() 34 | os.system("python setup.py sdist upload") 35 | os.system("python setup.py bdist_wheel upload") 36 | sys.exit() 37 | 38 | if sys.argv[-1] == "tag": 39 | print("Tagging the version on git:") 40 | os.system("git tag -a %s -m 'version %s'" % (version, version)) 41 | os.system("git push --tags") 42 | sys.exit() 43 | 44 | readme = open("README.rst").read() 45 | history = open("HISTORY.rst").read().replace(".. :changelog:", "") 46 | 47 | with open("requirements.txt", "r") as f: 48 | requirements = [req.strip() for req in f.readlines()] 49 | 50 | setup( 51 | name="django-cryptolock", 52 | version=version, 53 | description="""Django authentication using cryptocurrency wallets""", 54 | long_description=readme + "\n\n" + history, 55 | author="Gonçalo Valério", 56 | author_email="gon@ovalerio.net", 57 | url="https://github.com/dethos/django-cryptolock", 58 | packages=["django_cryptolock"], 59 | include_package_data=True, 60 | install_requires=requirements, 61 | extras_require={"drf": ["djangorestframework>=3.9.3"]}, 62 | license="MIT", 63 | zip_safe=False, 64 | keywords="django-cryptolock", 65 | classifiers=[ 66 | "Development Status :: 3 - Alpha", 67 | "Framework :: Django :: 2.2", 68 | "Framework :: Django :: 3.0", 69 | "Intended Audience :: Developers", 70 | "License :: OSI Approved :: MIT License", 71 | "Natural Language :: English", 72 | "Programming Language :: Python :: 3", 73 | "Programming Language :: Python :: 3.6", 74 | "Programming Language :: Python :: 3.7", 75 | "Programming Language :: Python :: 3.8", 76 | ], 77 | ) 78 | -------------------------------------------------------------------------------- /django_cryptolock/backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.backends import ModelBackend 2 | from django.core.exceptions import PermissionDenied 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from monerorpc.authproxy import JSONRPCException 6 | 7 | from .models import Address 8 | from .utils import verify_monero_signature, verify_bitcoin_signature 9 | 10 | 11 | class MoneroAddressBackend(ModelBackend): 12 | """Custom Monero-Cryptolock authentication backend.""" 13 | 14 | def authenticate( 15 | self, request, address=None, challenge=None, signature=None, **kwargs 16 | ): 17 | """Validates the provided signature for the given address and challenge. 18 | 19 | This method currently relies on Wallet RPC access to verify the signature, 20 | in the future it should be done locally to be more reliable and more 21 | performant. 22 | """ 23 | if not all([address, challenge, signature]): 24 | return None 25 | 26 | stored_address = ( 27 | Address.objects.select_related("user") 28 | .filter(address=address, network=Address.NETWORK_MONERO) 29 | .first() 30 | ) 31 | if not stored_address: 32 | return None 33 | try: 34 | is_valid = verify_monero_signature( 35 | stored_address.address, challenge, signature 36 | ) 37 | except JSONRPCException: 38 | raise PermissionDenied(_("Error while validating signature")) 39 | 40 | if is_valid: 41 | return stored_address.user 42 | 43 | return None 44 | 45 | 46 | class BitcoinAddressBackend(ModelBackend): 47 | """Custom Bitcoin-BitId authentication backend.""" 48 | 49 | def authenticate( 50 | self, request, address=None, challenge=None, signature=None, **kwargs 51 | ): 52 | """ 53 | Validates the provided signature for the given Bitcoin address and challenge. 54 | 55 | This method does not rely on any external components, everything is done locally. 56 | """ 57 | if not all([address, challenge, signature]): 58 | return None 59 | 60 | stored_address = ( 61 | Address.objects.select_related("user") 62 | .filter(address=address, network=Address.NETWORK_BITCOIN) 63 | .first() 64 | ) 65 | if not stored_address: 66 | return None 67 | 68 | valid_signature = verify_bitcoin_signature( 69 | stored_address.address, challenge, signature, request 70 | ) 71 | 72 | if valid_signature: 73 | return stored_address.user 74 | else: 75 | return None 76 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | schedule: 12 | - cron: "0 14 * * 6" 13 | 14 | jobs: 15 | analyze: 16 | name: Analyze 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | # Override automatic language detection by changing the below list 23 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 24 | language: ["python"] 25 | # Learn more... 26 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v2 31 | with: 32 | # We must fetch at least the immediate parents so that if this is 33 | # a pull request then we can checkout the head. 34 | fetch-depth: 2 35 | 36 | # If this run was triggered by a pull request event, then checkout 37 | # the head of the pull request instead of the merge commit. 38 | - run: git checkout HEAD^2 39 | if: ${{ github.event_name == 'pull_request' }} 40 | 41 | # Initializes the CodeQL tools for scanning. 42 | - name: Initialize CodeQL 43 | uses: github/codeql-action/init@v1 44 | with: 45 | languages: ${{ matrix.language }} 46 | # If you wish to specify custom queries, you can do so here or in a config file. 47 | # By default, queries listed here will override any specified in a config file. 48 | # Prefix the list here with "+" to use these queries and those in the config file. 49 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 50 | 51 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 52 | # If this step fails, then you should remove it and run the build manually (see below) 53 | - name: Autobuild 54 | uses: github/codeql-action/autobuild@v1 55 | 56 | # ℹ️ Command-line programs to run using the OS shell. 57 | # 📚 https://git.io/JvXDl 58 | 59 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 60 | # and modify them (or add more) to build your code if your project 61 | # uses a compiled language 62 | 63 | #- run: | 64 | # make bootstrap 65 | # make release 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@v1 69 | -------------------------------------------------------------------------------- /django_cryptolock/api_views.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from rest_framework.status import HTTP_200_OK 3 | 4 | from rest_framework.views import APIView 5 | from rest_framework.response import Response 6 | from rest_framework import status 7 | from rest_framework.authtoken.models import Token 8 | 9 | from monerorpc.authproxy import JSONRPCException 10 | 11 | from .models import Address, Challenge 12 | from .forms import SimpleSignUpForm, SimpleLoginForm 13 | from .utils import verify_signature 14 | from .mixins import CreateUserMixin, CreateChallengeMixin 15 | 16 | 17 | class CryptoLockAPITokenLoginView(CreateChallengeMixin, APIView): 18 | """Endpoint to login the user with cryptocurrency wallet address. 19 | 20 | Using the default token backend. 21 | """ 22 | 23 | http_method_names = ["get", "post"] 24 | 25 | def post(self, request, format=None): 26 | """Authenticates the user using the provided signature.""" 27 | form = SimpleLoginForm(request, request.data) 28 | if not form.is_valid(): 29 | return Response(form.errors, status=status.HTTP_400_BAD_REQUEST) 30 | 31 | challenge = form.cleaned_data["challenge"] 32 | Challenge.objects.invalidate(challenge) 33 | Challenge.objects.clean_expired() 34 | 35 | token = Token.objects.create(user=form.user_cache) 36 | return Response({"token": token.key}, status=HTTP_200_OK) 37 | 38 | 39 | class CryptoLockAPISignUpView(CreateUserMixin, CreateChallengeMixin, APIView): 40 | """Endpoint to create a new user using cryptocurrency wallet address.""" 41 | 42 | http_method_names = ["get", "post"] 43 | 44 | def post(self, request, format=None): 45 | """Verifies the signature and creates a new user account.""" 46 | form = SimpleSignUpForm(request, request.data) 47 | if not form.is_valid(): 48 | return Response(form.errors, status=status.HTTP_400_BAD_REQUEST) 49 | 50 | username = form.cleaned_data["username"] 51 | address = form.cleaned_data["address"] 52 | challenge = form.cleaned_data["challenge"] 53 | signature = form.cleaned_data["signature"] 54 | network = [n[1] for n in Address.NETWORKS if n[0] == form.network][0] 55 | 56 | try: 57 | valid_sig = verify_signature( 58 | network, address, challenge, signature, self.request 59 | ) 60 | except JSONRPCException: 61 | return Response( 62 | {"__all__": [_("Error connecting to Monero daemon")]}, 63 | status=status.HTTP_503_SERVICE_UNAVAILABLE, 64 | ) 65 | 66 | if valid_sig: 67 | self.create_user(username, challenge, address, form.network) 68 | return Response({}, status=status.HTTP_201_CREATED) 69 | else: 70 | return Response( 71 | {"signature": [_("Invalid signature")]}, 72 | status=status.HTTP_400_BAD_REQUEST, 73 | ) 74 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | test_django-cryptolock 6 | ------------ 7 | 8 | Tests for `django-cryptolock` models module. 9 | """ 10 | from django.core.exceptions import ValidationError 11 | from django.db import IntegrityError 12 | 13 | import pytest 14 | from model_mommy import mommy 15 | 16 | from django_cryptolock.models import Address 17 | 18 | VALID_MONERO_MAINNET_ADDR = "45D8b4XiUdz86FwztAJHVeLnQqGHQUqiHSwZe6rXFHSoXw522dPdixsdi7JAGsfJsfHRCP94UFhY5W3U8KCBhsRNUSLbYFk" 19 | VALID_MONERO_STAGENET_ADDR = "55LTR8KniP4LQGJSPtbYDacR7dz8RBFnsfAKMaMuwUNYX6aQbBcovzDPyrQF9KXF9tVU6Xk3K8no1BywnJX6GvZX8yJsXvt" 20 | VALID_MONERO_TESTNET_ADDR = "9vmn8Vyxh6JEVmPr4qTcj3ND3FywDpMXH2fVLLEARyKCJTc3jWjxeWcbRNcaa57Bj36cARBSfWnfS89oFVKBBvGTAegdRxG" 21 | 22 | VALID_BITCOIN_TESTNET_ADDR = "n47QBape2PcisN2mkHR2YnhqoBr56iPhJh" 23 | VALID_BITCOIN_MAINNET_ADDR = "1AUeWMGD9hDYtAhZGZLmDjEzKSrPow4zNt" 24 | 25 | pytestmark = pytest.mark.django_db 26 | 27 | 28 | def test_valid_monero_mainnet_address(settings): 29 | settings.DJCL_MONERO_NETWORK = "mainnet" 30 | 31 | addr = mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR) 32 | addr.full_clean() 33 | 34 | 35 | def test_valid_monero_stagenet_addr(settings): 36 | settings.DJCL_MONERO_NETWORK = "stagenet" 37 | 38 | addr = mommy.make(Address, address=VALID_MONERO_STAGENET_ADDR) 39 | addr.full_clean() 40 | 41 | 42 | def test_valid_monero_testnet_addr(settings): 43 | settings.DJCL_MONERO_NETWORK = "testnet" 44 | 45 | addr = mommy.make(Address, address=VALID_MONERO_TESTNET_ADDR) 46 | addr.full_clean() 47 | 48 | 49 | def test_valid_bitcoin_mainnet_address(settings): 50 | settings.DJCL_BITCOIN_NETWORK = "mainnet" 51 | 52 | addr = mommy.make( 53 | Address, address=VALID_BITCOIN_MAINNET_ADDR, network=Address.NETWORK_BITCOIN 54 | ) 55 | addr.full_clean() 56 | 57 | 58 | def test_valid_bitcoin_testnet_address(settings): 59 | settings.DJCL_BITCOIN_NETWORK = "testnet" 60 | 61 | addr = mommy.make( 62 | Address, address=VALID_BITCOIN_TESTNET_ADDR, network=Address.NETWORK_BITCOIN 63 | ) 64 | addr.full_clean() 65 | 66 | 67 | def test_invalid_address(): 68 | bad_addr = "Verywrongaddress" 69 | addr = mommy.make(Address, address=bad_addr) 70 | 71 | with pytest.raises(ValidationError) as error: 72 | addr.full_clean() 73 | 74 | assert ( 75 | "Invalid address for the given network" in error.value.message_dict["__all__"] 76 | ) 77 | 78 | 79 | def test_wrong_monero_network_address(settings): 80 | settings.DJCL_MONERO_NETWORK = "stagenet" 81 | addr = mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR) 82 | 83 | with pytest.raises(ValidationError) as error: 84 | addr.full_clean() 85 | 86 | assert ( 87 | "Invalid address for the given network" in error.value.message_dict["__all__"] 88 | ) 89 | 90 | 91 | def test_wrong_bitcoin_network_address(settings): 92 | settings.DJCL_BITCOIN_NETWORK = "testnet" 93 | addr = mommy.make( 94 | Address, address=VALID_BITCOIN_MAINNET_ADDR, network=Address.NETWORK_BITCOIN 95 | ) 96 | 97 | with pytest.raises(ValidationError) as error: 98 | addr.full_clean() 99 | 100 | assert ( 101 | "Invalid address for the given network" in error.value.message_dict["__all__"] 102 | ) 103 | 104 | 105 | def test_address_is_unique(): 106 | mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR) 107 | 108 | with pytest.raises(IntegrityError): 109 | mommy.make(Address, address=VALID_MONERO_MAINNET_ADDR) 110 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | You can contribute in many ways: 9 | 10 | Types of Contributions 11 | ---------------------- 12 | 13 | Report Bugs 14 | ~~~~~~~~~~~ 15 | 16 | Report bugs at https://github.com/dethos/django-cryptolock/issues. 17 | 18 | If you are reporting a bug, please include: 19 | 20 | * Your operating system name and version. 21 | * Any details about your local setup that might be helpful in troubleshooting. 22 | * Detailed steps to reproduce the bug. 23 | 24 | Fix Bugs 25 | ~~~~~~~~ 26 | 27 | Look through the GitHub issues for bugs. Anything tagged with "bug" 28 | is open to whoever wants to implement it. 29 | 30 | Implement Features 31 | ~~~~~~~~~~~~~~~~~~ 32 | 33 | Look through the GitHub issues for features. Anything tagged with "feature" 34 | is open to whoever wants to implement it. 35 | 36 | Write Documentation 37 | ~~~~~~~~~~~~~~~~~~~ 38 | 39 | Django-Cryptolock could always use more documentation, whether as part of the 40 | official Django-Cryptolock docs, in docstrings, or even on the web in blog posts, 41 | articles, and such. 42 | 43 | Submit Feedback 44 | ~~~~~~~~~~~~~~~ 45 | 46 | The best way to send feedback is to file an issue at https://github.com/dethos/django-cryptolock/issues. 47 | 48 | If you are proposing a feature: 49 | 50 | * Explain in detail how it would work. 51 | * Keep the scope as narrow as possible, to make it easier to implement. 52 | * Remember that this is a volunteer-driven project, and that contributions 53 | are welcome :) 54 | 55 | Get Started! 56 | ------------ 57 | 58 | Ready to contribute? Here's how to set up `django-cryptolock` for local development. 59 | 60 | 1. Fork the `django-cryptolock` repo on GitHub. 61 | 2. Clone your fork locally:: 62 | 63 | $ git clone git@github.com:your_name_here/django-cryptolock.git 64 | 65 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 66 | 67 | $ mkvirtualenv django-cryptolock 68 | $ cd django-cryptolock/ 69 | $ pip install -r requirements_dev.txt -r requirements_test.txt 70 | 71 | 4. Create a branch for local development:: 72 | 73 | $ git checkout -b name-of-your-bugfix-or-feature 74 | 75 | Now you can make your changes locally. 76 | 77 | 5. When you're done making changes, check that your changes pass ``black`` and the 78 | tests, including testing other Python versions with ``tox``: 79 | 80 | $ black --check django_cryptolock 81 | $ make test 82 | $ make test-all 83 | 84 | To get black and tox, just pip install them into your virtualenv. 85 | 86 | 6. If your changes are visible to the user, you can add a demo for them to the 87 | example project. 88 | 89 | 7. Commit your changes and push your branch to GitHub:: 90 | 91 | $ git add . 92 | $ git commit -m "Your detailed description of your changes." 93 | $ git push origin name-of-your-bugfix-or-feature 94 | 95 | 8. Submit a pull request through the GitHub website. 96 | 97 | 98 | Running tests for specific environments 99 | --------------------------------------- 100 | 101 | Do want to test only a specific python version / django version locally? 102 | 103 | You can use tox directly:: 104 | 105 | :: 106 | 107 | source /bin/activate 108 | (myenv) $ pip install tox 109 | (myenv) $ tox -e -django-<22_or_30> 110 | 111 | 112 | Pull Request Guidelines 113 | ----------------------- 114 | 115 | Before you submit a pull request, check that it meets these guidelines: 116 | 117 | 1. The pull request should include tests. 118 | 2. If the pull request adds functionality, the docs should be updated. Put 119 | your new functionality into a function with a docstring, and add the 120 | feature to the list in README.rst. 121 | 3. The pull request should work for Python 3.6, 3.7 and 3.8. Check 122 | https://travis-ci.org/dethos/django-cryptolock/pull_requests 123 | and make sure that the tests pass for all supported Python versions. 124 | 125 | Tips 126 | ---- 127 | 128 | To run a subset of tests:: 129 | 130 | $ pytest tests/test_models.py 131 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by Cookiecutter Django Package 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.9/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = "%6vggcu)vklb)$m!&1+epr*p2)z*$p@h@x@b1lp2)oye3#)@vl" 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | # Application definition 30 | 31 | INSTALLED_APPS = [ 32 | "django.contrib.admin", 33 | "django.contrib.auth", 34 | "django.contrib.contenttypes", 35 | "django.contrib.sessions", 36 | "django.contrib.messages", 37 | "django.contrib.staticfiles", 38 | "django_cryptolock", 39 | "testauth", 40 | # if your app has other dependencies that need to be added to the site 41 | # they should be added here 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "example.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [os.path.join(BASE_DIR, "templates")], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ] 68 | }, 69 | } 70 | ] 71 | 72 | WSGI_APPLICATION = "example.wsgi.application" 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.sqlite3", 80 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 81 | } 82 | } 83 | 84 | # Password validation 85 | # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators 86 | 87 | AUTH_PASSWORD_VALIDATORS = [ 88 | { 89 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 90 | }, 91 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 92 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 93 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 94 | ] 95 | 96 | # Internationalization 97 | # https://docs.djangoproject.com/en/1.9/topics/i18n/ 98 | 99 | LANGUAGE_CODE = "en-us" 100 | 101 | TIME_ZONE = "UTC" 102 | 103 | USE_I18N = True 104 | 105 | USE_L10N = True 106 | 107 | USE_TZ = True 108 | 109 | # Static files (CSS, JavaScript, Images) 110 | # https://docs.djangoproject.com/en/1.9/howto/static-files/ 111 | 112 | STATIC_URL = "/static/" 113 | 114 | # Auth Settings 115 | LOGIN_REDIRECT_URL = "/" 116 | LOGOUT_REDIRECT_URL = "/" 117 | 118 | 119 | # Django Cryptolock Settings 120 | # Wallet RPC 121 | 122 | AUTHENTICATION_BACKENDS = [ 123 | "django_cryptolock.backends.BitcoinAddressBackend", 124 | "django_cryptolock.backends.MoneroAddressBackend", 125 | ] 126 | DJCL_BITCOIN_NETWORK = "mainnet" 127 | DJCL_MONERO_NETWORK = "mainnet" 128 | DJCL_MONERO_WALLET_RPC_PROTOCOL = os.environ.get("MONERO_WALLET_RPC_PROTOCOL", "http") 129 | DJCL_MONERO_WALLET_RPC_HOST = os.environ.get("MONERO_WALLET_RPC_HOST", "localhost:6000") 130 | DJCL_MONERO_WALLET_RPC_USER = os.environ.get("MONERO_WALLET_RPC_USER") 131 | DJCL_MONERO_WALLET_RPC_PASS = os.environ.get("MONERO_WALLET_RPC_PASS") 132 | -------------------------------------------------------------------------------- /tests/test_backends.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from django.contrib.auth import authenticate 4 | from django.contrib.auth import get_user_model 5 | from django.core.exceptions import PermissionDenied 6 | 7 | import pytest 8 | from model_mommy import mommy 9 | 10 | from django_cryptolock.models import Address 11 | 12 | from .helpers import ( 13 | set_monero_settings, 14 | set_bitcoin_settings, 15 | DUMMY_CREDS, 16 | VALID_BITCOIN_ADDRESS, 17 | VALID_BITCOIN_SIG, 18 | VALID_BITID_URI, 19 | VALID_MONERO_ADDRESS, 20 | EXAMPLE_LOGIN_URL, 21 | ) 22 | 23 | User = get_user_model() 24 | pytestmark = pytest.mark.django_db 25 | 26 | 27 | @pytest.fixture 28 | def existing_user(): 29 | return User.objects.create_user(**DUMMY_CREDS) 30 | 31 | 32 | def test_monero_backend_receives_insuficient_data(settings, existing_user): 33 | set_monero_settings(settings) 34 | user = authenticate(MagicMock(), username="test") 35 | assert user is None 36 | 37 | 38 | def test_monero_backend_lets_the_next_backend_to_be_used(settings, existing_user): 39 | set_monero_settings(settings) 40 | user = authenticate(MagicMock(), **DUMMY_CREDS) 41 | assert user is not None 42 | 43 | 44 | def test_monero_backend_does_not_find_address(settings, existing_user): 45 | set_monero_settings(settings) 46 | user = authenticate( 47 | MagicMock(), address=VALID_MONERO_ADDRESS, challeng="1", signature="somesig" 48 | ) 49 | assert user is None 50 | 51 | 52 | def test_monero_backend_cannot_connect_to_RPC(settings, existing_user): 53 | set_monero_settings(settings) 54 | mommy.make(Address, address=VALID_MONERO_ADDRESS, user=existing_user) 55 | 56 | user = authenticate( 57 | MagicMock(), 58 | address=VALID_MONERO_ADDRESS, 59 | challenge="1", 60 | signature="invalid sig", 61 | **DUMMY_CREDS 62 | ) 63 | 64 | assert user is None 65 | 66 | 67 | def test_monero_backend_invalid_signature(settings, existing_user): 68 | set_monero_settings(settings) 69 | mommy.make(Address, address=VALID_MONERO_ADDRESS, user=existing_user) 70 | 71 | with patch("django_cryptolock.backends.verify_monero_signature") as verify_mock: 72 | verify_mock.return_value = False 73 | user = authenticate( 74 | MagicMock(), 75 | address=VALID_MONERO_ADDRESS, 76 | challenge="1", 77 | signature="invalid sig", 78 | ) 79 | 80 | assert user is None 81 | 82 | 83 | def test_monero_backend_valid_signature(settings, existing_user): 84 | set_monero_settings(settings) 85 | mommy.make(Address, address=VALID_MONERO_ADDRESS, user=existing_user) 86 | 87 | with patch("django_cryptolock.backends.verify_monero_signature") as verify_mock: 88 | verify_mock.return_value = True 89 | user = authenticate( 90 | MagicMock(), 91 | address=VALID_MONERO_ADDRESS, 92 | challenge="1", 93 | signature="valid sig", 94 | ) 95 | 96 | assert user == existing_user 97 | 98 | 99 | def test_bitcoin_backend_receives_insuficient_data(settings, existing_user): 100 | set_bitcoin_settings(settings) 101 | user = authenticate(MagicMock(), username="test") 102 | assert user is None 103 | 104 | 105 | def test_bitcoin_backend_lets_the_next_backend_to_be_used(settings, existing_user): 106 | set_bitcoin_settings(settings) 107 | user = authenticate(MagicMock(), **DUMMY_CREDS) 108 | assert user is not None 109 | 110 | 111 | def test_bitcoin_backend_does_not_find_address(settings, existing_user): 112 | set_bitcoin_settings(settings) 113 | user = authenticate( 114 | MagicMock(), 115 | address=VALID_BITCOIN_ADDRESS, 116 | bitid_uri="bitid://something", 117 | signature="somesig", 118 | ) 119 | assert user is None 120 | 121 | 122 | def test_bitcoin_backend_invalid_signature(settings, existing_user): 123 | set_bitcoin_settings(settings) 124 | mommy.make( 125 | Address, 126 | address=VALID_BITCOIN_ADDRESS, 127 | network=Address.NETWORK_BITCOIN, 128 | user=existing_user, 129 | ) 130 | 131 | mock = MagicMock() 132 | mock.build_absolute_uri.return_value = EXAMPLE_LOGIN_URL 133 | 134 | user = authenticate( 135 | mock, 136 | address=VALID_BITCOIN_ADDRESS, 137 | bitid_uri=VALID_BITID_URI, 138 | signature="invalid sig", 139 | ) 140 | 141 | assert user is None 142 | 143 | 144 | def test_bitcoin_backend_valid_signature(settings, existing_user): 145 | set_bitcoin_settings(settings) 146 | set_bitcoin_settings(settings) 147 | mommy.make( 148 | Address, 149 | address=VALID_BITCOIN_ADDRESS, 150 | network=Address.NETWORK_BITCOIN, 151 | user=existing_user, 152 | ) 153 | 154 | mock = MagicMock() 155 | mock.build_absolute_uri.return_value = EXAMPLE_LOGIN_URL 156 | 157 | user = authenticate( 158 | mock, 159 | address=VALID_BITCOIN_ADDRESS, 160 | challenge=VALID_BITID_URI, 161 | signature=VALID_BITCOIN_SIG, 162 | ) 163 | 164 | assert user == existing_user 165 | -------------------------------------------------------------------------------- /django_cryptolock/forms.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse, parse_qs 2 | 3 | from django import forms 4 | from django.contrib.auth import authenticate 5 | from django.contrib.auth import get_user_model 6 | from django.core.exceptions import ValidationError 7 | from django.utils.translation import gettext_lazy as _ 8 | from django.conf import settings 9 | 10 | from pybitid import bitid 11 | 12 | from .models import Address, Challenge 13 | from .validators import validate_monero_address, validate_bitcoin_address 14 | from .utils import generate_challenge 15 | 16 | 17 | class ChallengeMixin(forms.Form): 18 | """ 19 | Used on authentication forms to make sure an unique challenge is included. 20 | 21 | This mixin ensures that the challenge is always controlled by the server. 22 | """ 23 | 24 | challenge = forms.CharField() 25 | 26 | def include_challenge(self): 27 | """Created a new challenge only when no data is provided by user.""" 28 | if not self.data: 29 | new_challenge = bitid.build_uri( 30 | self.request.build_absolute_uri(), Challenge.objects.generate() 31 | ) 32 | self.initial["challenge"] = new_challenge 33 | 34 | def clean_challenge(self): 35 | challenge = self.cleaned_data.get("challenge") 36 | challenge_uri = urlparse(challenge) 37 | query = parse_qs(challenge_uri.query) 38 | if not query.get("x"): 39 | raise forms.ValidationError(_("Invalid or outdated challenge")) 40 | 41 | token = query["x"][0] 42 | if not token or not Challenge.objects.is_active(token): 43 | raise forms.ValidationError(_("Invalid or outdated challenge")) 44 | 45 | return challenge 46 | 47 | 48 | class SimpleLoginForm(ChallengeMixin, forms.Form): 49 | """Basic login form, that can be used as reference for implementation.""" 50 | 51 | address = forms.CharField() 52 | signature = forms.CharField() 53 | 54 | error_messages = { 55 | "invalid_login": _("Please enter a correct address or signature."), 56 | "inactive": _("This account is inactive."), 57 | } 58 | 59 | def __init__(self, request=None, *args, **kwargs): 60 | """When rendering the form (no data provided) a new challenge 61 | must be created.""" 62 | super().__init__(*args, **kwargs) 63 | self.request = request 64 | self.user_cache = None 65 | self.include_challenge() 66 | 67 | def clean(self): 68 | address = self.cleaned_data.get("address") 69 | challenge = self.cleaned_data.get("challenge") 70 | signature = self.cleaned_data.get("signature") 71 | 72 | if address and challenge and signature: 73 | self.user_cache = authenticate( 74 | self.request, address=address, challenge=challenge, signature=signature 75 | ) 76 | if self.user_cache is None: 77 | raise self.get_invalid_login_error() 78 | else: 79 | self.confirm_login_allowed(self.user_cache) 80 | 81 | return self.cleaned_data 82 | 83 | def confirm_login_allowed(self, user): 84 | if not user.is_active: 85 | raise forms.ValidationError( 86 | self.error_messages["inactive"], code="inactive" 87 | ) 88 | 89 | def get_user(self): 90 | return self.user_cache 91 | 92 | def get_invalid_login_error(self): 93 | return forms.ValidationError( 94 | self.error_messages["invalid_login"], code="invalid_login" 95 | ) 96 | 97 | 98 | class SimpleSignUpForm(ChallengeMixin, forms.Form): 99 | """Basic login form, that can be used as reference for implementation.""" 100 | 101 | username = forms.CharField() 102 | address = forms.CharField() 103 | signature = forms.CharField() 104 | 105 | def __init__(self, request=None, *args, **kwargs): 106 | """When rendering the form (no data provided) a new challenge 107 | must be created.""" 108 | super().__init__(*args, **kwargs) 109 | self.request = request 110 | self.include_challenge() 111 | self.network = None 112 | 113 | def clean_address(self): 114 | self.network = None 115 | value = self.cleaned_data["address"] 116 | bitcoin_backend = "django_cryptolock.backends.BitcoinAddressBackend" 117 | monero_backend = "django_cryptolock.backends.MoneroAddressBackend" 118 | 119 | if bitcoin_backend in settings.AUTHENTICATION_BACKENDS: 120 | try: 121 | validate_bitcoin_address(value) 122 | self.network = Address.NETWORK_BITCOIN 123 | except ValidationError: 124 | pass 125 | 126 | if monero_backend in settings.AUTHENTICATION_BACKENDS: 127 | try: 128 | validate_monero_address(value) 129 | self.network = Address.NETWORK_MONERO 130 | except ValidationError: 131 | pass 132 | 133 | if not self.network: 134 | raise forms.ValidationError(_("Invalid address")) 135 | 136 | if Address.objects.filter(address=value).exists(): 137 | raise forms.ValidationError(_("This address already exists")) 138 | 139 | return value 140 | 141 | def clean_username(self): 142 | value = self.cleaned_data["username"] 143 | if get_user_model().objects.filter(username=value).exists(): 144 | raise forms.ValidationError(_("This username is already taken")) 145 | return value 146 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/complexity.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/complexity.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/complexity" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/complexity" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /tests/test_api_views.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.urls import reverse_lazy 4 | from django.contrib.auth import get_user_model 5 | 6 | from rest_framework.test import APIClient 7 | from rest_framework.status import ( 8 | HTTP_200_OK, 9 | HTTP_201_CREATED, 10 | HTTP_405_METHOD_NOT_ALLOWED, 11 | HTTP_400_BAD_REQUEST, 12 | ) 13 | from model_mommy import mommy 14 | import pytest 15 | 16 | from django_cryptolock.models import Address, Challenge 17 | from .helpers import ( 18 | VALID_BITCOIN_ADDRESS, 19 | VALID_MONERO_ADDRESS, 20 | gen_challenge, 21 | set_bitcoin_settings, 22 | set_monero_settings, 23 | ) 24 | 25 | User = get_user_model() 26 | pytestmark = pytest.mark.django_db 27 | 28 | 29 | @pytest.fixture 30 | def api_client(): 31 | return APIClient() 32 | 33 | 34 | @pytest.mark.parametrize("method", ["put", "patch", "delete", "head", "options"]) 35 | def test_methods_not_allowed_for_token_login(api_client, method): 36 | func = getattr(api_client, method) 37 | response = func(reverse_lazy("api_token_login")) 38 | assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED 39 | 40 | 41 | def test_generate_new_token_login_challenge(api_client): 42 | response = api_client.get(reverse_lazy("api_token_login")) 43 | assert response.status_code == HTTP_200_OK 44 | assert "challenge" in response.json().keys() 45 | assert "expires" in response.json().keys() 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "addr,set_backend,network", 50 | [ 51 | (VALID_MONERO_ADDRESS, set_monero_settings, "monero"), 52 | (VALID_BITCOIN_ADDRESS, set_bitcoin_settings, "bitcoin"), 53 | ], 54 | ) 55 | def test_token_login_fails_invalid_data( 56 | api_client, settings, addr, set_backend, network 57 | ): 58 | settings.DJCL_MONERO_NETWORK = "mainnet" 59 | set_backend(settings) 60 | 61 | net = Address.NETWORK_BITCOIN if network == "bitcoin" else Address.NETWORK_MONERO 62 | user = mommy.make(User) 63 | mommy.make(Address, user=user, address=addr, network=net) 64 | challenge = gen_challenge() 65 | 66 | with patch(f"django_cryptolock.backends.verify_{network}_signature") as sig_mock: 67 | sig_mock.return_value = False 68 | response = api_client.post( 69 | reverse_lazy("api_token_login"), 70 | {"challenge": challenge, "address": addr, "signature": "something"}, 71 | ) 72 | 73 | assert response.status_code == HTTP_400_BAD_REQUEST 74 | errors = response.json() 75 | assert "Please enter a correct address or signature." in errors["__all__"] 76 | 77 | 78 | @pytest.mark.parametrize( 79 | "addr,set_backend,network", 80 | [ 81 | (VALID_MONERO_ADDRESS, set_monero_settings, "monero"), 82 | (VALID_BITCOIN_ADDRESS, set_bitcoin_settings, "bitcoin"), 83 | ], 84 | ) 85 | def test_token_login_succeeds(api_client, settings, addr, set_backend, network): 86 | settings.DJCL_MONERO_NETWORK = "mainnet" 87 | set_backend(settings) 88 | 89 | net = Address.NETWORK_BITCOIN if network == "bitcoin" else Address.NETWORK_MONERO 90 | user = mommy.make(User) 91 | mommy.make(Address, user=user, address=addr, network=net) 92 | challenge = gen_challenge() 93 | 94 | with patch(f"django_cryptolock.backends.verify_{network}_signature") as sig_mock: 95 | sig_mock.return_value = True 96 | response = api_client.post( 97 | reverse_lazy("api_token_login"), 98 | {"challenge": challenge, "address": addr, "signature": "something"}, 99 | ) 100 | 101 | assert response.status_code == HTTP_200_OK 102 | assert "token" in response.json().keys() 103 | 104 | 105 | @pytest.mark.parametrize("method", ["put", "patch", "delete", "head", "options"]) 106 | def test_methods_not_allowed_for_sign_up(api_client, method): 107 | func = getattr(api_client, method) 108 | response = func(reverse_lazy("api_signup")) 109 | assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED 110 | 111 | 112 | def test_generate_new_sign_up_challenge(api_client): 113 | response = api_client.get(reverse_lazy("api_signup")) 114 | assert response.status_code == HTTP_200_OK 115 | assert "challenge" in response.json().keys() 116 | assert "expires" in response.json().keys() 117 | 118 | 119 | def test_sign_up_fails_no_input(api_client): 120 | response = api_client.post(reverse_lazy("api_signup")) 121 | errors = response.json() 122 | assert response.status_code == HTTP_400_BAD_REQUEST 123 | assert "This field is required." in errors["challenge"] 124 | assert "This field is required." in errors["address"] 125 | assert "This field is required." in errors["signature"] 126 | assert "This field is required." in errors["username"] 127 | 128 | 129 | @pytest.mark.parametrize( 130 | "addr,set_backend", 131 | [ 132 | (VALID_MONERO_ADDRESS, set_monero_settings), 133 | (VALID_BITCOIN_ADDRESS, set_bitcoin_settings), 134 | ], 135 | ) 136 | def test_sign_up_fails_duplicate_address(api_client, settings, addr, set_backend): 137 | settings.DJCL_MONERO_NETWORK = "mainnet" 138 | set_backend(settings) 139 | challenge = gen_challenge() 140 | mommy.make(Address, address=addr) 141 | response = api_client.post( 142 | reverse_lazy("api_signup"), 143 | { 144 | "challenge": challenge, 145 | "address": addr, 146 | "signature": "something", 147 | "username": "user", 148 | }, 149 | ) 150 | 151 | assert response.status_code == HTTP_400_BAD_REQUEST 152 | errors = response.json() 153 | assert "This address already exists" in errors["address"] 154 | 155 | 156 | @pytest.mark.parametrize( 157 | "addr,set_backend", 158 | [ 159 | (VALID_MONERO_ADDRESS, set_monero_settings), 160 | (VALID_BITCOIN_ADDRESS, set_bitcoin_settings), 161 | ], 162 | ) 163 | def test_sign_up_fails_invalid_signature(api_client, settings, addr, set_backend): 164 | settings.DJCL_MONERO_NETWORK = "mainnet" 165 | set_backend(settings) 166 | challenge = gen_challenge() 167 | 168 | with patch("django_cryptolock.api_views.verify_signature") as sig_mock: 169 | sig_mock.return_value = False 170 | response = api_client.post( 171 | reverse_lazy("api_signup"), 172 | { 173 | "challenge": challenge, 174 | "address": addr, 175 | "signature": "something", 176 | "username": "user", 177 | }, 178 | ) 179 | 180 | assert response.status_code == HTTP_400_BAD_REQUEST 181 | errors = response.json() 182 | assert "Invalid signature" in errors["signature"] 183 | 184 | 185 | @pytest.mark.parametrize( 186 | "addr,set_backend", 187 | [ 188 | (VALID_MONERO_ADDRESS, set_monero_settings), 189 | (VALID_BITCOIN_ADDRESS, set_bitcoin_settings), 190 | ], 191 | ) 192 | def test_sign_up_succeeds(api_client, settings, addr, set_backend): 193 | settings.DJCL_MONERO_NETWORK = "mainnet" 194 | set_backend(settings) 195 | challenge = gen_challenge() 196 | 197 | with patch("django_cryptolock.api_views.verify_signature") as sig_mock: 198 | sig_mock.return_value = True 199 | response = api_client.post( 200 | reverse_lazy("api_signup"), 201 | { 202 | "challenge": challenge, 203 | "address": addr, 204 | "signature": "something", 205 | "username": "user", 206 | }, 207 | ) 208 | 209 | assert response.status_code == HTTP_201_CREATED 210 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\complexity.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\complexity.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | from datetime import timedelta 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.utils import timezone 6 | 7 | import pytest 8 | from model_mommy import mommy 9 | from pybitid import bitid 10 | 11 | from django_cryptolock.forms import SimpleLoginForm, SimpleSignUpForm 12 | from django_cryptolock.models import Address, Challenge 13 | 14 | from .helpers import ( 15 | set_monero_settings, 16 | set_bitcoin_settings, 17 | VALID_MONERO_ADDRESS, 18 | VALID_BITCOIN_ADDRESS, 19 | ) 20 | 21 | pytestmark = pytest.mark.django_db 22 | 23 | FUTURE_TIME = timezone.now() + timedelta(minutes=15) 24 | User = get_user_model() 25 | 26 | 27 | def gen_challenge(request, challenge): 28 | return bitid.build_uri(request.build_absolute_uri(), challenge) 29 | 30 | 31 | def test_simpleloginform_generates_new_challenge(): 32 | request = MagicMock() 33 | request.build_absolute_uri.return_value = "http://something/" 34 | assert not Challenge.objects.all().exists() 35 | 36 | form = SimpleLoginForm(request=request) 37 | challenge = Challenge.objects.first() 38 | assert form.initial.get("challenge") 39 | assert form.initial.get("challenge") == gen_challenge(request, challenge.challenge) 40 | assert form.initial.get("challenge").startswith("bitid://something") 41 | 42 | 43 | def test_simpleloginform_generates_no_new_challenge(): 44 | request = MagicMock() 45 | request.build_absolute_uri.return_value = "http://something/" 46 | assert not Challenge.objects.all().exists() 47 | 48 | form = SimpleLoginForm(request=request, data={"address": ""}) 49 | assert not Challenge.objects.all().exists() 50 | assert not form.initial.get("challenge") 51 | 52 | 53 | @pytest.mark.django_db 54 | def test_simpleloginform_valid_data(settings): 55 | settings.DJCL_MONERO_NETWORK = "mainnet" 56 | mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME) 57 | request = MagicMock() 58 | request.build_absolute_uri.return_value = "http://something/" 59 | 60 | form = SimpleLoginForm( 61 | request=request, 62 | data={ 63 | "address": VALID_MONERO_ADDRESS, 64 | "challenge": gen_challenge(request, "12345678"), 65 | "signature": "some valid signature", 66 | }, 67 | ) 68 | with patch("django_cryptolock.forms.authenticate") as auth_mock: 69 | auth_mock.return_value = mommy.make(User) 70 | assert form.is_valid() 71 | 72 | 73 | @pytest.mark.django_db 74 | def test_simpleloginform_invalid_challenge(settings): 75 | settings.DJCL_MONERO_NETWORK = "mainnet" 76 | mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME) 77 | request = MagicMock() 78 | request.build_absolute_uri.return_value = "http://something/" 79 | form = SimpleLoginForm( 80 | request=request, 81 | data={ 82 | "address": VALID_MONERO_ADDRESS, 83 | "challenge": gen_challenge(request, "1234567"), 84 | "signature": "some valid signature", 85 | }, 86 | ) 87 | with patch("django_cryptolock.forms.authenticate") as auth_mock: 88 | auth_mock.return_value = mommy.make(User) 89 | assert not form.is_valid() 90 | 91 | 92 | @pytest.mark.django_db 93 | def test_simpleloginform_expired_challenge(settings): 94 | settings.DJCL_MONERO_NETWORK = "mainnet" 95 | mommy.make(Challenge, challenge="12345678", expires=timezone.now()) 96 | request = MagicMock() 97 | request.build_absolute_uri.return_value = "http://something/" 98 | form = SimpleLoginForm( 99 | request=request, 100 | data={ 101 | "address": VALID_MONERO_ADDRESS, 102 | "challenge": gen_challenge(request, "12345678"), 103 | "signature": "some valid signature", 104 | }, 105 | ) 106 | with patch("django_cryptolock.forms.authenticate") as auth_mock: 107 | auth_mock.return_value = mommy.make(User) 108 | assert not form.is_valid() 109 | 110 | 111 | def test_simplesignupform_generates_new_challenge(): 112 | request = MagicMock() 113 | request.build_absolute_uri.return_value = "http://something/" 114 | assert not Challenge.objects.all().exists() 115 | 116 | form = SimpleSignUpForm(request=request) 117 | challenge = Challenge.objects.first() 118 | assert form.initial.get("challenge") 119 | assert form.initial.get("challenge") == gen_challenge(request, challenge.challenge) 120 | assert form.initial.get("challenge").startswith("bitid://something") 121 | 122 | 123 | def test_simplesignupform_generates_no_new_challenge(): 124 | request = MagicMock() 125 | request.build_absolute_uri.return_value = "http://something/" 126 | assert not Challenge.objects.all().exists() 127 | 128 | form = SimpleSignUpForm(request=request, data={"address": ""}) 129 | assert not Challenge.objects.all().exists() 130 | assert not form.initial.get("challenge") 131 | 132 | 133 | def test_validate_address_unique(settings): 134 | settings.DJCL_MONERO_NETWORK = "mainnet" 135 | mommy.make(Address, address=VALID_MONERO_ADDRESS) 136 | mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME) 137 | request = MagicMock() 138 | request.build_absolute_uri.return_value = "http://something/" 139 | form = SimpleSignUpForm( 140 | request=request, 141 | data={ 142 | "username": "foo", 143 | "address": VALID_MONERO_ADDRESS, 144 | "challenge": gen_challenge(request, "12345678"), 145 | "signature": "some valid signature", 146 | }, 147 | ) 148 | assert not form.is_valid() 149 | assert "This address already exists" in form.errors["address"] 150 | 151 | 152 | def test_simplesignupform_valid_bitcoin_addr(settings): 153 | set_bitcoin_settings(settings) 154 | mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME) 155 | 156 | request = MagicMock() 157 | request.build_absolute_uri.return_value = "http://something/" 158 | form = SimpleSignUpForm( 159 | request=request, 160 | data={ 161 | "username": "foo", 162 | "address": VALID_BITCOIN_ADDRESS, 163 | "challenge": gen_challenge(request, "12345678"), 164 | "signature": "some valid signature", 165 | }, 166 | ) 167 | assert form.is_valid() 168 | 169 | 170 | def test_simplesignupform_valid_monero_addr(settings): 171 | set_monero_settings(settings) 172 | mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME) 173 | 174 | settings.DJCL_MONERO_NETWORK = "mainnet" 175 | request = MagicMock() 176 | request.build_absolute_uri.return_value = "http://something/" 177 | form = SimpleSignUpForm( 178 | request=request, 179 | data={ 180 | "username": "foo", 181 | "address": VALID_MONERO_ADDRESS, 182 | "challenge": gen_challenge(request, "12345678"), 183 | "signature": "some valid signature", 184 | }, 185 | ) 186 | assert form.is_valid() 187 | 188 | 189 | def test_simplesignupform_invalid_addr(): 190 | mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME) 191 | request = MagicMock() 192 | request.build_absolute_uri.return_value = "http://something/" 193 | form = SimpleSignUpForm( 194 | request=request, 195 | data={ 196 | "username": "foo", 197 | "address": "bad addr", 198 | "challenge": gen_challenge(request, "12345678"), 199 | "signature": "some valid signature", 200 | }, 201 | ) 202 | assert not form.is_valid() 203 | assert "Invalid address" in form.errors["address"] 204 | 205 | 206 | def test_simplesignupform_invalid_challenge(settings): 207 | set_bitcoin_settings(settings) 208 | mommy.make(Challenge, challenge="12345678", expires=FUTURE_TIME) 209 | 210 | request = MagicMock() 211 | request.build_absolute_uri.return_value = "http://something/" 212 | form = SimpleSignUpForm( 213 | request=request, 214 | data={ 215 | "username": "foo", 216 | "address": VALID_BITCOIN_ADDRESS, 217 | "challenge": gen_challenge(request, "1234567"), 218 | "signature": "some valid signature", 219 | }, 220 | ) 221 | assert not form.is_valid() 222 | 223 | 224 | def test_simplesignupform_expired_challenge(settings): 225 | set_bitcoin_settings(settings) 226 | mommy.make(Challenge, challenge="12345678", expires=timezone.now()) 227 | 228 | request = MagicMock() 229 | request.build_absolute_uri.return_value = "http://something/" 230 | form = SimpleSignUpForm( 231 | request=request, 232 | data={ 233 | "username": "foo", 234 | "address": VALID_BITCOIN_ADDRESS, 235 | "challenge": gen_challenge(request, "12345678"), 236 | "signature": "some valid signature", 237 | }, 238 | ) 239 | assert not form.is_valid() 240 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # complexity documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # sys.path.insert(0, os.path.abspath('.')) 20 | 21 | cwd = os.getcwd() 22 | parent = os.path.dirname(cwd) 23 | sys.path.append(parent) 24 | 25 | from django.conf import settings 26 | 27 | settings.configure() 28 | 29 | import django_cryptolock 30 | 31 | # -- General configuration ----------------------------------------------------- 32 | 33 | # If your documentation needs a minimal Sphinx version, state it here. 34 | # needs_sphinx = '1.0' 35 | 36 | # Add any Sphinx extension module names here, as strings. They can be extensions 37 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 38 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ["_templates"] 42 | 43 | # The suffix of source filenames. 44 | source_suffix = ".rst" 45 | 46 | # The encoding of source files. 47 | # source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = "index" 51 | 52 | # General information about the project. 53 | project = u"Django-Cryptolock" 54 | copyright = u"2019, Gonçalo Valério" 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | version = django_cryptolock.__version__ 62 | # The full version, including alpha/beta/rc tags. 63 | release = django_cryptolock.__version__ 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | # language = None 68 | 69 | # There are two options for replacing |today|: either, you set today to some 70 | # non-false value, then it is used: 71 | # today = '' 72 | # Else, today_fmt is used as the format for a strftime call. 73 | # today_fmt = '%B %d, %Y' 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | exclude_patterns = ["_build"] 78 | 79 | # The reST default role (used for this markup: `text`) to use for all documents. 80 | # default_role = None 81 | 82 | # If true, '()' will be appended to :func: etc. cross-reference text. 83 | # add_function_parentheses = True 84 | 85 | # If true, the current module name will be prepended to all description 86 | # unit titles (such as .. function::). 87 | # add_module_names = True 88 | 89 | # If true, sectionauthor and moduleauthor directives will be shown in the 90 | # output. They are ignored by default. 91 | # show_authors = False 92 | 93 | # The name of the Pygments (syntax highlighting) style to use. 94 | pygments_style = "sphinx" 95 | 96 | # A list of ignored prefixes for module index sorting. 97 | # modindex_common_prefix = [] 98 | 99 | # If true, keep warnings as "system message" paragraphs in the built documents. 100 | # keep_warnings = False 101 | 102 | 103 | # -- Options for HTML output --------------------------------------------------- 104 | 105 | # The theme to use for HTML and HTML Help pages. See the documentation for 106 | # a list of builtin themes. 107 | html_theme = "default" 108 | 109 | # Theme options are theme-specific and customize the look and feel of a theme 110 | # further. For a list of options available for each theme, see the 111 | # documentation. 112 | # html_theme_options = {} 113 | 114 | # Add any paths that contain custom themes here, relative to this directory. 115 | # html_theme_path = [] 116 | 117 | # The name for this set of Sphinx documents. If None, it defaults to 118 | # " v documentation". 119 | # html_title = None 120 | 121 | # A shorter title for the navigation bar. Default is the same as html_title. 122 | # html_short_title = None 123 | 124 | # The name of an image file (relative to this directory) to place at the top 125 | # of the sidebar. 126 | # html_logo = None 127 | 128 | # The name of an image file (within the static path) to use as favicon of the 129 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 130 | # pixels large. 131 | # html_favicon = None 132 | 133 | # Add any paths that contain custom static files (such as style sheets) here, 134 | # relative to this directory. They are copied after the builtin static files, 135 | # so a file named "default.css" will overwrite the builtin "default.css". 136 | html_static_path = ["_static"] 137 | 138 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 139 | # using the given strftime format. 140 | # html_last_updated_fmt = '%b %d, %Y' 141 | 142 | # If true, SmartyPants will be used to convert quotes and dashes to 143 | # typographically correct entities. 144 | # html_use_smartypants = True 145 | 146 | # Custom sidebar templates, maps document names to template names. 147 | # html_sidebars = {} 148 | 149 | # Additional templates that should be rendered to pages, maps page names to 150 | # template names. 151 | # html_additional_pages = {} 152 | 153 | # If false, no module index is generated. 154 | # html_domain_indices = True 155 | 156 | # If false, no index is generated. 157 | # html_use_index = True 158 | 159 | # If true, the index is split into individual pages for each letter. 160 | # html_split_index = False 161 | 162 | # If true, links to the reST sources are added to the pages. 163 | # html_show_sourcelink = True 164 | 165 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 166 | # html_show_sphinx = True 167 | 168 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 169 | # html_show_copyright = True 170 | 171 | # If true, an OpenSearch description file will be output, and all pages will 172 | # contain a tag referring to it. The value of this option must be the 173 | # base URL from which the finished HTML is served. 174 | # html_use_opensearch = '' 175 | 176 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 177 | # html_file_suffix = None 178 | 179 | # Output file base name for HTML help builder. 180 | htmlhelp_basename = "django-cryptolockdoc" 181 | 182 | 183 | # -- Options for LaTeX output -------------------------------------------------- 184 | 185 | latex_elements = { 186 | # The paper size ('letterpaper' or 'a4paper'). 187 | #'papersize': 'letterpaper', 188 | # The font size ('10pt', '11pt' or '12pt'). 189 | #'pointsize': '10pt', 190 | # Additional stuff for the LaTeX preamble. 191 | #'preamble': '', 192 | } 193 | 194 | # Grouping the document tree into LaTeX files. List of tuples 195 | # (source start file, target name, title, author, documentclass [howto/manual]). 196 | latex_documents = [ 197 | ( 198 | "index", 199 | "django-cryptolock.tex", 200 | u"Django-Cryptolock Documentation", 201 | u"Gonçalo Valério", 202 | "manual", 203 | ) 204 | ] 205 | 206 | # The name of an image file (relative to this directory) to place at the top of 207 | # the title page. 208 | # latex_logo = None 209 | 210 | # For "manual" documents, if this is true, then toplevel headings are parts, 211 | # not chapters. 212 | # latex_use_parts = False 213 | 214 | # If true, show page references after internal links. 215 | # latex_show_pagerefs = False 216 | 217 | # If true, show URL addresses after external links. 218 | # latex_show_urls = False 219 | 220 | # Documents to append as an appendix to all manuals. 221 | # latex_appendices = [] 222 | 223 | # If false, no module index is generated. 224 | # latex_domain_indices = True 225 | 226 | 227 | # -- Options for manual page output -------------------------------------------- 228 | 229 | # One entry per manual page. List of tuples 230 | # (source start file, name, description, authors, manual section). 231 | man_pages = [ 232 | ( 233 | "index", 234 | "django-cryptolock", 235 | u"Django-Cryptolock Documentation", 236 | [u"Gonçalo Valério"], 237 | 1, 238 | ) 239 | ] 240 | 241 | # If true, show URL addresses after external links. 242 | # man_show_urls = False 243 | 244 | 245 | # -- Options for Texinfo output ------------------------------------------------ 246 | 247 | # Grouping the document tree into Texinfo files. List of tuples 248 | # (source start file, target name, title, author, 249 | # dir menu entry, description, category) 250 | texinfo_documents = [ 251 | ( 252 | "index", 253 | "django-cryptolock", 254 | u"Django-Cryptolock Documentation", 255 | u"Gonçalo Valério", 256 | "django-cryptolock", 257 | "One line description of project.", 258 | "Miscellaneous", 259 | ) 260 | ] 261 | 262 | # Documents to append as an appendix to all manuals. 263 | # texinfo_appendices = [] 264 | 265 | # If false, no module index is generated. 266 | # texinfo_domain_indices = True 267 | 268 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 269 | # texinfo_show_urls = 'footnote' 270 | 271 | # If true, do not generate a @detailmenu in the "Top" node's menu. 272 | # texinfo_no_detailmenu = False 273 | --------------------------------------------------------------------------------