
Предисловие
Приветствую тебя, дорогой Разработчик! Хочу поделиться своим опытом о том, как реализовать вход с помощью кошелька 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 }
Все шаги выполнены, и я надеюсь, это не было скучно. Вы можете реализовать более простой способ хранения пользовательских данных, и добавить дополнительные проверки если хотите, но я просто делюсь частью кода своего проекта.
Увидимся во второй части (скоро)!
