diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..585e2ab --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,rst,ini}] +indent_style = space +indent_size = 4 + +[*.{html,css,scss,json,yml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore index 3555fa8..9f6e64d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,159 @@ -*.pyc -*~ -*.tmp +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +__pycache__ + +# C extensions +*.so + +# If you are using PyCharm # +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/gradle.xml +.idea/**/libraries +*.iws /out/ + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +-# Mr Developer +-.mr.developer.cfg +-.project +-.pydevproject + +-# Sphinx +-docs/_build + +-# Complexity +-output/*.html +-output/*/index.html + +-example/db.sqlite3 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f4bce8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,43 @@ +MIT License + +Copyright (c) 2018 Alexander Tereshkin + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. + +MIT License + +Copyright (c) 2018 Bearle + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..3b00ea8 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include web3auth *.html *.png *.gif *js *.css *jpg *jpeg *svg *py diff --git a/README.md b/README.md index 3060c36..a469f47 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,78 @@ -# Django-Web3-Auth +# django-web3-auth -django-web3-auth is a pluggable Django app that enables login/signup via an Ethereum wallet (a la CryptoKitties). The user authenticates themselves by digitally signing the session key with their wallet's private key. +django-web3-auth is a pluggable Django app that enables login/signup via an Ethereum wallet (specifically MetaMask). +The user authenticates themselves by digitally signing the session key with their wallet's private key. + +Use with django >= 3.2.0, python >= 3.9 -## Installation - -django-web3-auth has no releases yet, you'll need to install it from repository: +## Quickstart +Install django-web3-auth with pip: ```bash -pip install https://github.com/atereshkin/django-web3-auth/archive/master.zip +pip install git+ssh://git@github.com/krilarite/django-web3-auth.git ``` - -You will also need [Web3.js](https://github.com/ethereum/web3.js) included in your pages. - -## Usage - -1. Add `'web3auth'` to the `INSTALLED_APPS` setting -2. Set `'web3auth.backend.Web3Backend'` as your authentication backend: +Add it to your INSTALLED_APPS: +```python +INSTALLED_APPS = [ + ... + 'web3auth', + ... +] +``` +Set 'web3auth.backend.Web3Backend' as your authentication backend: ```python -AUTHENTICATION_BACKENDS = ['web3auth.backend.Web3Backend'] +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'web3auth.backend.Web3Backend' +] ``` -3. Bind some URLs to `web3auth.views.login_view` and `web3auth.views.signup_view`. Both views take an optional `template_name` argument. +Set a field from the User model for storing the users' ETH address: ```python -from django.conf.urls import url - -from web3auth import views as web3auth_views +WEB3AUTH_USER_ADDRESS_FIELD = 'username' +``` +Add Django-Web3-Auth's URL patterns: +```python +from web3auth import urls as web3auth_urls urlpatterns = [ - url(r'^login/$', web3auth_views.login_view, {'template_name' : 'login.html'}, name='login'), - url(r'^signup/$', web3auth_views.signup_view, name='signup'), + ... + path('', include(web3auth_urls)), + ... ] +``` +Add some javascript to handle login: +```html + + +``` +Implement a login button: +```html + + +``` +MetaMask will prompt you to sign a message and you'll be logged in afterwards. + +## Contributing +Clone the project +```bash +git clone git@github.com:krilarite/django-web3-auth.git +``` +Set up a virtualenv +``` +mkvirtualenv -p /usr/bin/python3.9 -a `pwd` django-web3-auth +pip install -r requirements.txt +``` +Use the example project for testing +```bash +cd example +python manage.py migrate +python manage.py runserver ``` -4. Code your templates for login and signup pages. Example code can be found in [login.html](web3auth/templates/web3auth/login.html) and [signup.html](web3auth/templates/web3auth/signup.html) +Navigate to `localhost:8000/login` and you'll see a login page. diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..2fd8290 --- /dev/null +++ b/example/README.md @@ -0,0 +1,25 @@ +Example Project for web3auth + +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. + +It can also be used to develop the app in place. + +To run this example, follow these instructions: + +1. Clone the repository +2. Navigate to the `example` directory +3. Install the requirements for the package (probably in a virtualenv): + + pip install -r requirements.txt + +4. Make and apply migrations + + python manage.py makemigrations + + python manage.py migrate + +5. Run the server + + python manage.py runserver + +6. Access from the browser at `http://127.0.0.1:8000` diff --git a/web3auth/migrations/__init__.py b/example/example/__init__.py similarity index 100% rename from web3auth/migrations/__init__.py rename to example/example/__init__.py diff --git a/example/example/settings.py b/example/example/settings.py new file mode 100644 index 0000000..1551dfd --- /dev/null +++ b/example/example/settings.py @@ -0,0 +1,124 @@ +""" +Django settings for example project. + +Generated by Cookiecutter Django Package + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.9/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "11111111111111111111111111111111111111111111111111" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'web3auth', + + # if your app has other dependencies that need to be added to the site + # they should be added here +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'example.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates'), ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'example.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/1.9/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + +# Password validation +# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'web3auth.backend.Web3Backend' +] +# Internationalization +# https://docs.djangoproject.com/en/1.9/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.9/howto/static-files/ + +STATIC_URL = '/static/' +LOGIN_REDIRECT_URL = '/user/' diff --git a/example/example/urls.py b/example/example/urls.py new file mode 100644 index 0000000..c3988e1 --- /dev/null +++ b/example/example/urls.py @@ -0,0 +1,43 @@ +"""example URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.9/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url, include +from django.contrib import admin +from django.urls import path, include +from django.contrib.auth import logout + +from django.shortcuts import render, redirect + + +def login(request): + return render(request, 'web3auth/login.html') + + +def logout_view(request): + logout(request) + return redirect('login') + + +def user_view(request): + return render(request, 'web3auth/user.html') + + +urlpatterns = [ + path('admin/', admin.site.urls), + path('login/', login, name='login'), + path('user/', user_view, name='user'), + path('', include('web3auth.urls', namespace='web3auth')), + path('logout/', logout_view, name='logout'), +] diff --git a/example/example/wsgi.py b/example/example/wsgi.py new file mode 100644 index 0000000..fd6d782 --- /dev/null +++ b/example/example/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for example project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") + +application = get_wsgi_application() diff --git a/example/manage.py b/example/manage.py new file mode 100755 index 0000000..2605e37 --- /dev/null +++ b/example/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/example/requirements.txt b/example/requirements.txt new file mode 100644 index 0000000..1bea32d --- /dev/null +++ b/example/requirements.txt @@ -0,0 +1,7 @@ +# Your app requirements. +-r ../requirements_test.txt + +# Your app in editable mode. +-e ../ +Django==2.0.6 +packaging==16.8 diff --git a/example/templates/web3auth/base.html b/example/templates/web3auth/base.html new file mode 100644 index 0000000..f9b69f1 --- /dev/null +++ b/example/templates/web3auth/base.html @@ -0,0 +1,65 @@ +{% load static i18n %} + + + + + + {% block title %}Django-Web3-Auth{% endblock title %} + + + + + + + + {% block css %} + + + + {% endblock %} + + + + +
+ + +
+
+ {% block content %} + {% endblock content %} +
+ +{% block modal %}{% endblock modal %} + + + +{% block javascript %} + + + +{% endblock javascript %} + + diff --git a/example/templates/web3auth/login.html b/example/templates/web3auth/login.html new file mode 100644 index 0000000..f1c7c77 --- /dev/null +++ b/example/templates/web3auth/login.html @@ -0,0 +1,22 @@ +{% extends 'web3auth/base.html' %} +{% block content %} + {% if request.user.is_authenticated %} +

You're already logged in

+ {% else %} +
+ +
+ {% endif %} +{% endblock content %} +{% block javascript %} + + {{ block.super }} + +{% endblock javascript %} diff --git a/example/templates/web3auth/user.html b/example/templates/web3auth/user.html new file mode 100644 index 0000000..97eed69 --- /dev/null +++ b/example/templates/web3auth/user.html @@ -0,0 +1,8 @@ +{% extends 'web3auth/base.html' %} +{% block content %} + {% if request.user.is_authenticated %} +

Hi there, {{ request.user.username }}

+ {% else %} +

You're not logged in

+ {% endif %} +{% endblock content %} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..55516ef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ + +# Additional requirements go here +ethereum==2.3.* +eth_utils==1.10.* +Django>=3.2.* diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index f543703..ec88ced --- a/setup.py +++ b/setup.py @@ -1,13 +1,35 @@ -#!/usr/bin/env python - -from setuptools import setup - -setup(name='django-web3-auth', - version='0.1', - description='A pluggable Django app that enables login/signup via an Ethereum wallet', - url='https://github.com/atereshkin/django-web3-auth', - packages=['web3auth'], - package_data={'web3auth': ['templates/web3auth/*.html', - 'static/web3auth/js/*.js']}, - install_requires=['Django>=2.0', 'ethereum>=2.3.0'], - ) +from setuptools import setup, find_packages + + +with open('README.md', 'r') as fh: + LONG_DESCRIPTION = fh.read() + + +def main(): + pkg_setup = { + 'name': 'django-web3-auth', + 'version': '0.0.1', + 'description': 'Django authentication using an Ethereum wallet', + 'long_description': LONG_DESCRIPTION, + 'long_description_content_type': 'text/markdown', + 'author': 'Teodor Ivanov', + 'author_email': 'tdrivanov@gmail.com', + 'packages': find_packages(exclude=['*tests*']), + 'python_requires': '>=3.9', + 'install_requires': [ + 'ethereum==2.3.*', + 'eth_utils==1.10.*', + 'Django>=3.2.*', + ], + 'extras_require': {}, + 'include_package_data': True, + 'license': 'MIT', + 'project_urls': { + 'Source': 'https://github.com/krilarite/django-web3-auth', + }, + } + setup(**pkg_setup) + + +if __name__ == '__main__': + main() diff --git a/web3auth/__init__.py b/web3auth/__init__.py index e69de29..2fb2513 100644 --- a/web3auth/__init__.py +++ b/web3auth/__init__.py @@ -0,0 +1 @@ +__version__ = '0.1.6' diff --git a/web3auth/admin.py b/web3auth/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/web3auth/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/web3auth/backend.py b/web3auth/backend.py index 74d6bf7..a56fa7e 100644 --- a/web3auth/backend.py +++ b/web3auth/backend.py @@ -1,17 +1,93 @@ -from django.contrib.auth.models import User +from abc import abstractmethod, ABC +from typing import Optional + +from django.contrib.auth import get_user_model, backends +from django.conf import settings from web3auth.utils import recover_to_addr -class Web3Backend: - def authenticate(self, request, token=None, signature=None): - try: - addr = recover_to_addr(token, signature) - return User.objects.get(username=addr) - except User.DoesNotExist: - return None - - def get_user(self, user_id): - try: - return User.objects.get(pk=user_id) - except User.DoesNotExist: - return None +User = get_user_model() + +DEFAULT_ADDRESS_FIELD = 'username' +ADDRESS_FIELD = getattr( + settings, 'WEB3AUTH_USER_ADDRESS_FIELD', DEFAULT_ADDRESS_FIELD) +DEFAULT_ENS_FIELD = 'ens_name' +ENS_FIELD = getattr( + settings, 'WEB3AUTH_USER_ENS_FIELD', DEFAULT_ENS_FIELD) + + +class Web3Backend(backends.ModelBackend): + + def authenticate( + self, + request, + address, + token, + signature + ) -> Optional[User]: + # check if the address the user has provided matches the signature + if address != recover_to_addr(token, signature): + raise ValueError('Wallet address does not match signature') + else: + # get address field for the user model + kwargs = { + f"{ADDRESS_FIELD}__iexact": address + } + # try to get user with provided data + user = User.objects.filter(**kwargs).first() + if user is None: + # create the user if it does not exist + return self.create_user(address) + return user + + def create_user(self, address): + user = self._gen_user(address) + fields = [field.name for field in User._meta.fields] + if ( + ADDRESS_FIELD != DEFAULT_ADDRESS_FIELD + and 'username' in fields + ): + user.username = user.generate_username() + user.save() + return user + + def _gen_user(self, address: str) -> User: + return User(**{ADDRESS_FIELD: address}) + + +class ENSWeb3BaseBackend(Web3Backend, ABC): + """ + Abstract auth backend that supplies the ENS domain name in the User account + To make use of this backend you need to define a `fetch_ens` method in a + backend of your own, one that calls your own web3 client to fetch + the domain record from the user's wallet address. + """ + + def authenticate( + self, + request, + address, + token, + signature + ) -> Optional[User]: + user = super().authenticate(request, address, token, signature) + new_ens_name = self.fetch_ens(user.address) + if user.ens_name != new_ens_name: + user.ens_name = new_ens_name + user.save(update_fields=['ens_name']) + return user + + def _gen_user( + self, + address: str, + ) -> User: + return User( + **{ + ADDRESS_FIELD: address, + ENS_FIELD: self.fetch_ens(address) + } + ) + + @abstractmethod + def fetch_ens(self, address: str) -> str: + raise NotImplemented diff --git a/web3auth/fields.py b/web3auth/fields.py new file mode 100644 index 0000000..3c1e457 --- /dev/null +++ b/web3auth/fields.py @@ -0,0 +1,43 @@ +from django.db import models +from django import forms + +from web3auth.utils import validate_eth_address, validate_eth_transaction + + +class EthAddressField(models.CharField): + + def __init__(self, *args, **kwargs): + if 'max_length' not in kwargs: + kwargs['max_length'] = 42 + if 'db_index' not in kwargs: + kwargs['db_index'] = True + super().__init__(*args, **kwargs) + self.validators.append(validate_eth_address) + + +class EthAddressFormField(forms.CharField): + + def __init__(self, *args, **kwargs): + if 'max_length' not in kwargs: + kwargs['max_length'] = 42 + super().__init__(*args, **kwargs) + self.validators.append(validate_eth_address) + + +class EthTransactionField(models.CharField): + def __init__(self, *args, **kwargs): + if 'max_length' not in kwargs: + kwargs['max_length'] = 66 + if 'db_index' not in kwargs: + kwargs['db_index'] = True + super().__init__(*args, **kwargs) + self.validators.append(validate_eth_transaction) + + +class EthTransactionFormField(forms.CharField): + + def __init__(self, *args, **kwargs): + if 'max_length' not in kwargs: + kwargs['max_length'] = 66 + super().__init__(*args, **kwargs) + self.validators.append(validate_eth_transaction) diff --git a/web3auth/forms.py b/web3auth/forms.py index 2d365da..8adb8ad 100644 --- a/web3auth/forms.py +++ b/web3auth/forms.py @@ -1,27 +1,39 @@ import string from django import forms -from django.contrib.auth import authenticate -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model +try: + from django.utils.translation import ugettext_lazy as _ +except ImportError: + from django.utils.translation import gettext_lazy as _ -class LoginForm(forms.Form): - signature = forms.CharField(widget=forms.HiddenInput, max_length=132) +from .fields import EthAddressFormField +from .utils import validate_eth_address, recover_to_addr + + +class AuthForm(forms.Form): + signature = forms.CharField(max_length=132) + address = EthAddressFormField() def __init__(self, token, *args, **kwargs): self.token = token - super(LoginForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def clean_signature(self): sig = self.cleaned_data['signature'] - - if len(sig) != 132 or (sig[130:] != '1b' and sig[130:] != '1c') or not all(c in string.hexdigits for c in sig[2:]): - raise forms.ValidationError('Invalid signature') - - self.user = authenticate(token=self.token, signature=sig) + if any([ + len(sig) != 132, + sig[130:] != '1b' and sig[130:] != '1c', + not all(c in string.hexdigits for c in sig[2:]) + ]): + raise forms.ValidationError(_('Invalid signature')) return sig - -class SignupForm(forms.ModelForm): - class Meta: - model = User - fields = ('email',) + def clean(self): + cleaned_data = super().clean() + signature = cleaned_data.get('signature') + address = cleaned_data.get('address') + if address != recover_to_addr(self.token, signature): + raise forms.ValidationError( + _('Address used for signing does not match wallet address') + ) diff --git a/web3auth/models.py b/web3auth/models.py deleted file mode 100644 index 71a8362..0000000 --- a/web3auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/web3auth/static/web3auth/css/web3auth.css b/web3auth/static/web3auth/css/web3auth.css new file mode 100644 index 0000000..e69de29 diff --git a/web3auth/static/web3auth/img/.gitignore b/web3auth/static/web3auth/img/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/web3auth/static/web3auth/js/web3auth.js b/web3auth/static/web3auth/js/web3auth.js index 80a383f..aa59aa2 100644 --- a/web3auth/static/web3auth/js/web3auth.js +++ b/web3auth/static/web3auth/js/web3auth.js @@ -1,50 +1,121 @@ -web3auth = { - - init : function(loginToken) { - $(() => { - if (typeof web3 !== 'undefined') { - web3 = new Web3(web3.currentProvider); - web3.eth.getAccounts((err, accounts) => { // Check for wallet being locked - if (err) { - throw err; - } - if (accounts.length == 0) { - $('[data-web3auth-display').hide(); - $('[data-web3auth-display="wallet-locked"]').show(); - } else { - $('[data-web3auth-display').hide(); - $('[data-web3auth-display="wallet-available"]').show(); - } - - }); - } else { - $('[data-web3auth-display').hide(); - $('[data-web3auth-display="wallet-unavailable"]').show(); - } - - }); - let loginBtn = $('[data-web3auth="login-button"]'); - $(loginBtn).click(() => { - web3auth.login(loginToken, $('[data-web3auth="login-form"]')); - return false; - }); - - }, - - login : function(loginToken, form){ - if (typeof web3 == 'undefined') { - throw 'web3 missing'; - } - msg=web3.toHex(loginToken); - from = web3.eth.accounts[0]; - web3.personal.sign(msg, from, (err, result) => { - if (err){ - console.log(err, result); - } else { - $(form).find('input[name=signature]').val(result); - $(form).submit(); - } - }); +export function getCookie(name) { + var cookieValue = null; + if (document.cookie && document.cookie != '') { + var cookies = document.cookie.split(';'); + for (var i = 0; i < cookies.length; i++) { + var cookie = jQuery.trim(cookies[i]); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) == (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} + +function loginWithSignature(address, signature, authUrl, redirect) { + var request = new XMLHttpRequest(); + request.open('POST', authUrl, true); + request.onload = function () { + if (request.status >= 200 && request.status < 400) { + // Success! + var resp = JSON.parse(request.responseText); + if (resp.success) { + if (redirect) { + var redirectUrl = resp.redirect_url; + window.location.replace(redirectUrl); + } + } else { + console.log(resp) + } + } else { + // We reached our target server, but it returned an error + console.log(resp) + } + }; + + request.onerror = function () { + console.log("Autologin failed - there was an error"); + if (typeof onLoginRequestError == 'function') { + onLoginRequestError(request); + } + // There was a connection error of some sort + }; + request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); + request.setRequestHeader("X-CSRFToken", getCookie('csrftoken')); + var formData = 'address=' + address + '&signature=' + signature; + request.send(formData); +} + + +export async function getUserAccount(){ + const accounts = await window.ethereum.request( + { + method: 'eth_requestAccounts' + } + ); + return accounts[0]; +} +function asciiToHex (str) { + if(!str) + return "0x00"; + var hex = ""; + for(var i = 0; i < str.length; i++) { + var code = str.charCodeAt(i); + var n = code.toString(16); + hex += n.length < 2 ? '0' + n : n; } + return "0x" + hex; +}; + +export async function authWeb3(authUrl, redirect = true) { + // used in loginWithSignature + + // 1. Retrieve arbitrary login token from server + // 2. Sign it using web3 + // 3. Send signed message & your eth address to server + // 4. If server validates that you signature is valid + // 4.1 The user with an according eth address is found - you are logged in + // 4.2 The user with an according eth address is NOT found - you are redirected to signup page + + + var request = new XMLHttpRequest(); + request.open('GET', authUrl, true); + + request.onload = async function () { + if (request.status >= 200 && request.status < 400) { + // Success! + var resp = JSON.parse(request.responseText); + var token = resp.token; + var hex_token = asciiToHex(token); + var from = await getUserAccount(); + window.ethereum.request( + { + method: 'personal_sign', + params: [ + from, hex_token + ] + }) + .then((result) => { + loginWithSignature(from, result, authUrl, redirect); + }) + .catch((error) => { + console.log(error); + }); + } else { + // We reached our target server, but it returned an error + console.log("Autologin failed - request status " + request.status); + } + }; + request.onerror = function () { + // There was a connection error of some sort + console.log("Autologin failed - there was an error"); + }; + request.send(); } + +export async function connectWallet (redirect = true) { + await authWeb3(window.AUTH_ENDPOINT, redirect) +}; diff --git a/web3auth/templates/web3auth/base.html b/web3auth/templates/web3auth/base.html new file mode 100644 index 0000000..abb3708 --- /dev/null +++ b/web3auth/templates/web3auth/base.html @@ -0,0 +1,21 @@ +{% comment %} +As the developer of this package, don't place anything here if you can help it +since this allows developers to have interoperability between your template +structure and their own. + +Example: Developer melding the 2SoD pattern to fit inside with another pattern:: + + {% extends "base.html" %} + {% load static %} + + + {% block extra_js %} + + + {% block javascript %} + + {% endblock javascript %} + + {% endblock extra_js %} +{% endcomment %} + diff --git a/web3auth/templates/web3auth/login.html b/web3auth/templates/web3auth/login.html deleted file mode 100644 index 2ff437f..0000000 --- a/web3auth/templates/web3auth/login.html +++ /dev/null @@ -1,31 +0,0 @@ -{% load static %} - - - - - - - - - - - -
- Please install Metamask. -
-
- Your Metamask is locked. Please unlock it to continue. -
- -
-
- {% csrf_token %} - {{ form.as_p }} - -
-
- - - diff --git a/web3auth/tests.py b/web3auth/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/web3auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/web3auth/urls.py b/web3auth/urls.py index 247ce03..c47fc8b 100644 --- a/web3auth/urls.py +++ b/web3auth/urls.py @@ -1,10 +1,18 @@ -from django.conf.urls import url +from django.conf import settings +from django.urls import path from web3auth import views -urlpatterns = [ - url(r'^demo/$', views.demo), - url(r'^login/$', views.login_view), - url(r'^signup/$', views.signup_view), +app_name = 'web3auth' + +urlpatterns = [ + path('web3auth/', views.Web3AuthAPIView.as_view(), name='web3auth_api'), ] + +if getattr(settings, 'MOCK_LOGIN', False) and settings.DEBUG: + urlpatterns += [ + path( + 'mocklogin/',views.MockLoginView.as_view(), name='web3auth_mock' + ), + ] diff --git a/web3auth/utils.py b/web3auth/utils.py index f7602ea..3bcd394 100644 --- a/web3auth/utils.py +++ b/web3auth/utils.py @@ -1,12 +1,21 @@ import sha3 -import ethereum + +from ethereum.utils import ecrecover_to_pub +from eth_utils import is_hex_address, is_hex + +from django.core.exceptions import ValidationError +try: + from django.utils.translation import ugettext_lazy as _ +except ImportError: + from django.utils.translation import gettext_lazy as _ + def sig_to_vrs(sig): -# sig_bytes = bytes.fromhex(sig[2:]) + # sig_bytes = bytes.fromhex(sig[2:]) r = int(sig[2:66], 16) s = int(sig[66:130], 16) v = int(sig[130:], 16) - return v,r,s + return v, r, s def hash_personal_message(msg): @@ -17,5 +26,27 @@ def hash_personal_message(msg): def recover_to_addr(msg, sig): msghash = hash_personal_message(msg) vrs = sig_to_vrs(sig) - return '0x' + sha3.keccak_256(ethereum.utils.ecrecover_to_pub(msghash, *vrs)).hexdigest()[24:] + address = '0x' + sha3.keccak_256( + ecrecover_to_pub(msghash, *vrs)).hexdigest()[24:] + return address + + +def validate_eth_address(value): + if not is_hex_address(value): + raise ValidationError( + _('%(value)s is not a valid Ethereum address'), + params={'value': value}, + ) + +def validate_eth_transaction(value): + if not all( + [ + isinstance(value, str), + is_hex(value), + ] + ): + raise ValidationError( + _('%(value)s is not a valid Ethereum transaction id'), + params={'value': value}, + ) diff --git a/web3auth/views.py b/web3auth/views.py index 3696ee3..9312b3f 100644 --- a/web3auth/views.py +++ b/web3auth/views.py @@ -1,55 +1,116 @@ +import json import random import string -from django.shortcuts import render, redirect -from django.contrib.auth import login from django.conf import settings +from django.views import View +from django.contrib.auth import get_user_model, login, authenticate +from django.http import JsonResponse +from django.shortcuts import redirect, reverse +from django.urls.exceptions import NoReverseMatch +try: + from django.utils.translation import ugettext_lazy as _ +except ImportError: + from django.utils.translation import gettext_lazy as _ -from web3auth.forms import LoginForm, SignupForm -from web3auth.utils import recover_to_addr - -def demo(request): - return render(request, - 'web3auth/demo.html', - {}) - - -def login_view(request, template_name='web3auth/login.html'): - if request.method == 'POST': - token = request.session['login_token'] - form = LoginForm(token, request.POST) - if form.is_valid(): - if form.user is not None: +from web3auth.forms import AuthForm + +User = get_user_model() + +DEFAULT_AUTH_BACKEND = 'web3auth.backend.Web3Backend' + + +class Web3AuthAPIView(View): + http_method_names = ['get', 'post'] + MESSAGE = _( + 'Please sign this randomized token to verify your identity: ' + ) + + def get(self, request): + token = ''.join( + random.SystemRandom().choice( + string.ascii_uppercase + string.digits + ) for i in range(32) + ) + signable_message = self.MESSAGE + token + request.session['login_token'] = signable_message + return JsonResponse( + { + 'token': signable_message, + 'success': True, + } + ) + + def post(self, request): + token = request.session.get('login_token') + if not token: + return JsonResponse( + { + 'error': _( + 'No login token in session, please request token ' + ' again by sending GET request to this url' + ), + 'success': False + } + ) + else: + form = AuthForm(token, request.POST) + if form.is_valid(): + signature = form.cleaned_data.get("signature") + address = form.cleaned_data.get("address") del request.session['login_token'] - login(request, form.user) - return redirect(request.GET.get('next') or request.POST.get('next') or settings.LOGIN_REDIRECT_URL) + try: + user = authenticate( + request, token=token, + address=address, signature=signature + ) + except ValueError as exc: + return JsonResponse( + { + 'success': False, 'error': str(exc) + } + ) + auth_backend = getattr( + settings, + 'WEB3AUTH_BACKEND', + DEFAULT_AUTH_BACKEND + ) + login(request, user, auth_backend) + return JsonResponse( + { + 'success': True, + 'redirect_url': self.get_redirect_url(request) + } + ) else: - request.session['ethereum_address'] = recover_to_addr(token, form.cleaned_data['signature']) - return redirect(signup_view) - else: - token = ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(32)) - request.session['login_token'] = token - form = LoginForm(token) - return render(request, - template_name, - {'form' : form, - 'login_token' : token}) - - -def signup_view(request, template_name='web3auth/signup.html'): - ethereum_address = request.session['ethereum_address'] - if request.method == 'POST': - form = SignupForm(request.POST) - if form.is_valid(): - del request.session['ethereum_address'] - user = form.save(commit=False) - user.username = ethereum_address - user.save() - login(request, user) - return redirect(request.GET.get('next') or request.POST.get('next') or settings.LOGIN_REDIRECT_URL) - else: - form = SignupForm() - return render(request, - template_name, - {'form' : form}) - + return JsonResponse( + { + 'success': False, + 'error': json.loads(form.errors.as_json()) + } + ) + + def get_redirect_url(self, request): + if request.GET.get('next'): + return request.GET.get('next') + elif request.POST.get('next'): + return request.POST.get('next') + elif (referer := request.META.get('HTTP_REFERER')): + return referer + elif settings.LOGIN_REDIRECT_URL: + try: + url = reverse(settings.LOGIN_REDIRECT_URL) + except NoReverseMatch: + url = settings.LOGIN_REDIRECT_URL + return url + + +class MockLoginView(Web3AuthAPIView): + """ + A view that automatically logs in the first user, for test purposes + """ + + def get(self, request): + user = User.objects.first() + login(request, user, 'web3auth.backend.Web3Backend') + return redirect(self.get_redirect_url(request))