Сегодня мы напишем простой сниппет для аутентификации пользователей на сайте при помощи кошелька Metamask. Замечу, что данное решение максимально изолировано от фреймворка. Вы сможете легко адаптировать его не только к Django, но и к Flask, Sanic, Starlette, Aiohttp и т.п.

Сниппет будет состоять из двух частей - django template и django view.


Начнем с django template.

Допустим у нас есть шаблон для аутентификации templates/login.html

Создадим кнопку аутентификации при нажатии на которую будет вызван Metamask.

<button type="button" onclick="web3Login()">
    Log in with Metamask
</button>

Создадим обслуживающий скрипт.

<script src="https://cdn.ethers.io/lib/ethers-5.2.umd.min.js" type="application/javascript"></script>
<script>
function web3Login() {
    /* Подписываем сообщение */
    try {
        window.ethereum.enable().then(function () {
            provider = new ethers.providers.Web3Provider(window.ethereum);
            provider.getNetwork().then(function (result) {
                if (result['chainId'] != 1) {
                    alert('Switch to Mainnet!');
                } else { 
                    provider.listAccounts().then(function (result) {
                        accountAddress = result[0]; 
                        signer = provider.getSigner();
                        signer.signMessage("Sign to auth {{ csrf_token }}").then((signature) => {web3LoginBackend(accountAddress, signature)});
                    })
                }
            })
        })
    } catch {
        alert('Please install MetaMask for your browser.')
    }
}

function web3LoginBackend(accountAddress, signature) {
    /* Отправляем данные на django view */
    var form = document.createElement('form');
    form.action = '{% url "main:auth_web3" %}'; // TODO замените на свой адрес
    form.method = 'POST';
 
    var input = document.createElement('input');
    input.type = 'hidden';
    input.name = 'csrfmiddlewaretoken';
    input.value = '{{ csrf_token }}';
    form.appendChild(input);    

    var input = document.createElement('input');
    input.type = 'hidden';
    input.name = 'accountAddress';
    input.value = accountAddress;
    form.appendChild(input);

    var input = document.createElement('input');
    input.type = 'hidden';
    input.name = 'signature';
    input.value = signature;
    form.appendChild(input);

    document.body.appendChild(form);
    form.submit();
}
</script>

Данный скрипт состоит из двух функций web3Login и web3LoginBackend.

При нажатии на кнопку аутентификации вызывается функция web3Login, которая активирует окно «Запрос подписи» в Metamask. Подписывать мы будем сообщение вида Sign to auth {{ csrf_token }}.

После нажатия на кнопку «Подписать» вызывается функция web3LoginBackend, которая отправит данные (csrf token, адрес кошелька и подпись сообщения) в django view.


Перейдем к django view.

Установим необходимые зависимости.

pip install web3==5.30.0

pip install eth-account==0.5.9

pip install names==0.3.0

pip install shortuuid==1.0.9

Создадимdjango view.

import datetime
import secrets
import names
import string
import shortuuid
from django.contrib import messages
from django.contrib.auth import authenticate
from django.contrib.auth import login
from django.contrib.auth.models import User
from django.http import HttpResponse
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.shortcuts import reverse
from eth_account.messages import defunct_hash_message
from web3.auto import w3
from user_profile.models import UserProfile # TODO измените на свою модель


def auth_web3(request):

    public_address = request.POST["accountAddress"]
    signature = request.POST["signature"]
    csrf_token = request.POST["csrfmiddlewaretoken"]

    original_message = f"Sign to auth {csrf_token}"
    message_hash = defunct_hash_message(text=original_message)
    signer = w3.eth.account.recoverHash(message_hash, signature=signature)
    short_uuid = shortuuid.uuid()

    if signer == public_address:

        user_profile = UserProfile.objects.filter(eth_account_address=public_address).first()
        if user_profile:
            try:
                user = user_profile.user
            except:
                messages.add_message(request, messages.WARNING, _("Профайл не найден"))
                return HttpResponseRedirect(
                    reverse(
                        "main:user_login",  # TODO измените на свой адрес
                    )
                )
            user.backend = "django.contrib.auth.backends.ModelBackend"
            login(request, user)
            return HttpResponseRedirect(reverse("main:dashboard")) # TODO измените на свой адрес

        else:
            alphabet = string.ascii_letters + string.digits
            password = "".join(secrets.choice(alphabet) for i in range(20))
            
            first_name = names.get_first_name()
            last_name = names.get_last_name()
            email = f"{short_uuid}@{HOST}"
            
            user = User.objects.create_user(email=email, username=short_uuid, first_name=first_name, last_name=last_name, password=password)

            user_profile = UserProfile()
            user_profile.user = user
            user_profile.eth_account_address = public_address
            user_profile.save()

            user.backend = "django.contrib.auth.backends.ModelBackend"
            login(request, user)

            messages.success(request, _("Успешная регистрация."))
            return HttpResponseRedirect(reverse("main:dashboard")) # TODO измените на свой адрес

    else:
        messages.add_message(request, messages.WARNING, _("Обновите страницу и попробуйте еще раз"))
        return HttpResponseRedirect(
            reverse(
                "main:user_login",  # TODO измените на свой адрес
            )
        )

На строке 26 мы дублируем сообщение с django template

original_message = f"Sign to auth {csrf_token}"

И восстанавливаем адрес кошелька при помощи подписи signature полученной из django tempate после отправки данных в django view.

message_hash = defunct_hash_message(text=original_message)

signer = w3.eth.account.recoverHash(message_hash, signature=signature)

Если восстановленный адрес signer равен адресу public_address (адресу кошелька пользователя) отправленному из django template, то аутентификация прошла успешно и все что нам остается это сделать немного "магии".

P.S. Рабочий пример можно найти по адресу workhours.space. А еще это удобный и бесплатный трекер времени с удобным ботом в телеграм - пользуйтесь.

Комментарии (3)


  1. danilovmy
    27.08.2022 16:31
    +7

    Нет, Андрей @Sobolev5 , это не Django. Это махровый непричесаный питон с лютым JS-скриптом в шаблоне. Django это когда у тебя LoginView из django.contrib.auth.views, в котором переопределен класс формы. Хотя даже ее можно не трогать. для мессаджей можно прикрутить SuccessMessageMixin

    Непонятна причина создания формы на лету скриптом. что мешало отрендерить форму в момент генерации html.

    UserProfile уже с 1.5 Django получаем через get_user_model, для однотипности кода. Или у тебя боллее ранняя версия?

    Чем плох make_password из django.contrib.auth.hashers? зачем надо было придумывать собственный?

    Короче: так много вопросов, так мало ответов, и, непонятно, причем тут Django.


    1. baldr
      27.08.2022 16:56
      +2

      Да, тут антипаттернов в разы больше чем пользы. Уж если пишется статья, так оформляйте нормально - укажите какие библиотеки куда ставятся, какие варианты аутентификации у этого кошелька вообще есть... Не помешает ссылка на гитхаб с законченным минимальным примером.

      По-хорошему можно было написать свой authentication backend и использовать его как один из способов входа.

      Вообще class-based views, конечно, последние 10 лет - это предпочтительный способ, но что поделать если даже в официальной документации по-прежнему вьюшки через функции предлагают.


      1. danilovmy
        27.08.2022 18:32
        +1

        С authenticate бэкэндом согласен, для существующих. Но в статье ещё и новые создаются, потому без view-представления никак.