Login with MetaMask 1/2 (GO lang)

от автора

Hero

Hero

Предисловие

Приветствую тебя, дорогой Разработчик! Хочу поделиться своим опытом о том, как реализовать вход с помощью кошелька MetaMask (расширение для браузера) в твой проект. В этой статье я пропускаю весь код архитектуры приложения и покажу тебе только сервисный код (нижний уровень кода. Взгляни на DDD архитектуру, также известную как «Чистый код»). Я использую GO с распространенными библиотеками для веб-разработки, такими как Gin, jwt, sqlc и другие.

Для начала давай представим, как должен работать наш процесс входа. Как мы все знаем, стратегия «разделяй и властвуй» очень эффективна для чего угодно. Поэтому здесь мы можем разделить наш процесс на 2 логических шага. Назовем их «Начало» и «Завершение». Теперь давай посмотрим на схему нашего процесса, потому что мы, как инженеры, должны максимально упростить свою работу. Итак, схема — отличный способ достичь этого.

Схема auth flow

Схема auth flow

Начало

Теперь о генерации 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 }

Все шаги выполнены, и я надеюсь, это не было скучно. Вы можете реализовать более простой способ хранения пользовательских данных, и добавить дополнительные проверки если хотите, но я просто делюсь частью кода своего проекта.

Увидимся во второй части (скоро)!


ссылка на оригинал статьи https://habr.com/ru/articles/907274/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *