
Предисловие
Приветствую тебя, дорогой Разработчик! Хочу поделиться своим опытом о том, как реализовать вход с помощью кошелька MetaMask (расширение для браузера) в твой проект. В этой статье я пропускаю весь код архитектуры приложения и покажу тебе только сервисный код (нижний уровень кода. Взгляни на DDD архитектуру, также известную как "Чистый код"). Я использую GO с распространенными библиотеками для веб-разработки, такими как Gin, jwt, sqlc и другие.
Для начала давай представим, как должен работать наш процесс входа. Как мы все знаем, стратегия "разделяй и властвуй" очень эффективна для чего угодно. Поэтому здесь мы можем разделить наш процесс на 2 логических шага. Назовем их "Начало" и "Завершение". Теперь давай посмотрим на схему нашего процесса, потому что мы, как инженеры, должны максимально упростить свою работу. Итак, схема - отличный способ достичь этого.

Начало
Теперь о генерации nonce. Что такое "nonce"? Nonce — это просто случайная строка, которая может включать в себя всё, что мы захотим (в будущем мы попросим пользователя подписать этот nonce приватным ключом). Вот моя вспомогательная функция для генерации nonce:
package utils
import (
_crypto "crypto/rand"
"errors"
"math/big"
_math "math/rand"
"strings"
"time"
)
var wordList = []string{
"apple", "banana", "cherry", "dog", "elephant",
"frog", "grape", "honey", "icecream", "jungle",
"kite", "lemon", "mango", "nap", "orange",
"parrot", "queen", "rabbit", "strawberry", "turtle",
"umbrella", "violet", "watermelon", "xylophone", "yak", "zebra",
}
func Contains(s []string, el string) bool {
for _, v := range s {
if v == el {
return true
}
}
return false
}
func GenerateSecretPhrase(numWords int) (string, error) {
var passphraseWords []string
_math.Seed(time.Now().UnixNano())
if numWords > len(wordList) {
return "", errors.New("too bit number")
}
for i := 0; i < numWords; i++ {
randomIndex, err := _crypto.Int(_crypto.Reader, big.NewInt(int64(len(wordList))))
if err != nil {
return "", err
}
if Contains(passphraseWords, wordList[randomIndex.Int64()]) {
continue
}
passphraseWords = append(passphraseWords, wordList[randomIndex.Int64()])
}
passphrase := strings.Join(passphraseWords, "-")
return passphrase, nil
}
В результате мы получаем случайную строку, основанную на всех этих словах. Например: zebra-violet-umbrella-apple-cherry-lemon-kite-rabbit-xylophone-watermelon
.
Чтобы сохранить эту часть процесса, нам нужно получить адрес кошелька MetaMask пользователя (здесь он выглядит как 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
). (Во второй части я расскажу, откуда мы его берем).
Также нам нужно выполнить стандартные проверки, например, существует ли уже пользователь (входил ли он в систему ранее) и т.д. Вот моя реализация этой функции (я не делюсь всей частью кода, поэтому вам нужно реализовать сервисный слой репозитория для работы с некоторыми данными пользователя):
func (a AuthUseCase) GetAuthenticatorByPublicAddress(ctx context.Context, publicAddress string) (*domain.Authenticator, error) {
applicantAuthenticator, err := a.repository.GetUserAuthenticatorByPublicAddress(ctx, publicAddress)
if err != nil && err != sql.ErrNoRows {
a.logger.Error(err)
return nil, err
}
nonce, err := utils.GenerateSecretPhrase(12)
if err != nil {
a.logger.Error(err)
return nil, err
}
// If Authenticator don't exists (sign in for the first time)
if applicantAuthenticator.ID == uuid.Nil {
dbAuthenticator := db.CreateUserAuthenticatorParams{
Nonce: nonce,
PublicAddress: publicAddress,
AuthType: string(domain.Metamask),
}
id, err := a.repository.CreateUserAuthenticator(ctx, dbAuthenticator)
if err != nil {
a.logger.Error(err)
return nil, err
}
// gets only the "Nonce" field in our controller layer
// and send it to the frontend
return &domain.Authenticator{
ID: id,
AuthType: domain.Metamask,
Nonce: nonce,
PublicAddress: publicAddress,
}, nil
}
// Already exists authenticator
err = a.repository.UpdateUserAuthenticatorNonceByPublicAddress(ctx, db.UpdateUserAuthenticatorNonceByPublicAddressParams{
Nonce: nonce,
PublicAddress: publicAddress,
})
if err != nil {
a.logger.Error(err)
return nil, err
}
// gets only the "Nonce" field in our controller layer
// and send it to the frontend
return &domain.Authenticator{
ID: applicantAuthenticator.ID,
AuthType: domain.AuthType(applicantAuthenticator.AuthType),
Nonce: nonce,
PublicAddress: applicantAuthenticator.PublicAddress,
}, nil
}
Завершение
После того, как пользователь подпишет наш nonce своим приватным ключом (смотрите вторую часть, которая скоро появится), нам нужно проверить подпись, создать профиль пользователя и выпустить JWT-токен. Для проверки подписи мы используем библиотеку Ethereum (пакет). Вам нужно установить её следующим образом:
go get github.com/ethereum/go-ethereum
От клиента (frontend) мы ожидаем следующий DTO (Data Transfer Object):
type VerifySignatureDto struct {
// same user's MetaMask wallet address as before
PublicAddress string
// signed nonce hash
Signature string
}
Метод верификации подписанного nonce
:
Тут так же нету реализации сервиса, который генерит и проверят JWT токен, но это не сложно сделать (Можете написать в комментах, если нужно, я сделаю статью на этот счет)
func (a AuthUseCase) VerifySignedNonce(ctx context.Context, dto domain.VerifySignatureDto) (string, error) {
// for consistent data in DB we need to transaction flow here
tx, err := a.connect.Begin()
if err != nil {
a.logger.Error(err)
return "", err
}
defer tx.Rollback()
qtx := a.repository.WithTx(tx)
applicantAuthenticator, err := qtx.GetUserAuthenticatorByPublicAddress(ctx, dto.PublicAddress)
if err != nil {
a.logger.Error(err)
return "", err
}
// checkout the etherium's library docs and issues
sig := hexutil.MustDecode(dto.Signature)
nonceAsByte := []byte(applicantAuthenticator.Nonce)
msg := accounts.TextHash(nonceAsByte)
sig[crypto.RecoveryIDOffset] -= 27 // Transform yellow paper V from 27/28 to 0/1
recovered, err := crypto.SigToPub(msg, sig)
if err != nil {
a.logger.Error(err)
return "", err
}
recoveredAddr := crypto.PubkeyToAddress(*recovered)
if recoveredAddr.Hex() != applicantAuthenticator.PublicAddress {
errMsg := errors.New("invalid signature")
a.logger.Error(errMsg)
return "", errMsg
}
// To security we need to update our nonce in DB
nonce, err := utils.GenerateSecretPhrase(12)
if err != nil {
a.logger.Error(err)
return "", err
}
err = qtx.UpdateUserAuthenticatorNonceByPublicAddress(ctx, db.UpdateUserAuthenticatorNonceByPublicAddressParams{
Nonce: nonce,
PublicAddress: dto.PublicAddress,
})
if err != nil {
a.logger.Error(err)
return "", err
}
userId := applicantAuthenticator.UserID.UUID
// Create user if not exists
if userId == uuid.Nil {
profileId, err := qtx.CreateUserProfile(ctx, int32(domain.Unknown))
if err != nil {
a.logger.Error(err)
return "", err
}
userId, err = qtx.CreateUser(ctx, db.CreateUserParams{
Status: string(domain.NotVerified),
ProfileID: profileId,
})
if err != nil {
a.logger.Error(err)
return "", err
}
err = qtx.SetUserIdToAuthenticator(ctx, db.SetUserIdToAuthenticatorParams{
UserID: uuid.NullUUID{Valid: true, UUID: userId},
PublicAddress: dto.PublicAddress,
})
if err != nil {
a.logger.Error(err)
return "", err
}
}
token, err := a.tokenMaker.CreateToken(go_toolkit.Payload{
UserId: userId,
}, time.Hour*168) // 7 days
if err != nil {
a.logger.Error(err)
return "", err
}
tx.Commit()
return token, nil
}
Все шаги выполнены, и я надеюсь, это не было скучно. Вы можете реализовать более простой способ хранения пользовательских данных, и добавить дополнительные проверки если хотите, но я просто делюсь частью кода своего проекта.
Увидимся во второй части (скоро)!