Как реализовать базовый CRUD в Golang, защищенный Auth0


О

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

Если вы уже понимаете эти механизмы и просто ищете быстрое решение, не стесняйтесь форкнуть этот проект.

Предварительные условия

Этот учебник предполагает, что:

📌 Вы умеете писать базовые CRUD-приложения на Go. Здесь мы будем использовать фреймворк Echo для создания API и MongoDB для хранения данных. Однако, достаточно иметь представление о том, как создать подобную систему, используя эти технологии.

📌 Вы знаете, что такое JWT. Если нет, пожалуйста, посмотрите это замечательное видео.

Авторизация 👮

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

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

Аутентификация 🔐

Для аутентификации пользователя мы будем использовать Auth0, поскольку он позволяет нам сосредоточиться на возможностях нашего приложения, а не на защите уязвимых данных пользователя, таких как учетные данные. Он также предоставляет несколько удобных функций, таких как аутентификация с помощью аккаунта Google и бесплатный уровень с количеством пользователей до 7k, что более чем достаточно для вашего MVP 😎.

Давайте выполним базовую настройку и обсудим концепцию аутентификации.

Создание приложения Auth0

Прежде всего, вам нужен аккаунт на платформе Auth0, поэтому перейдите на их страницу и создайте его.

После этого перейдите в dashboard, найдите страницу Applications и нажмите на Create Application:

Назовите его так, как вам нравится, выберите Regular Web Application и нажмите Create. Вы должны быть перенаправлены на страницу только что созданного приложения.

Здесь вы можете найти Domain, Client ID и Client Secret. Запомните, где вы можете найти эти значения, так как они понадобятся нам в будущем.

Теперь нам нужно настроить его на использование алгоритма подписи HS256, чтобы мы могли проверять входящие JWT-токены от пользователей, используя наш секрет приложения. Таким образом, наш API-сервер сможет аутентифицировать пользователя, делающего запрос, и убедиться, что данные, предоставленные в токене, действительны.

Перейдите в Settings, прокрутите вниз до раздела Application URIs и добавьте http://localhost:9000/callback URL в текстовое поле под заголовком Allowed Callback URLs.

После этого продолжите прокрутку вниз до кнопки Advanced settings и нажмите на нее. Далее перейдите на страницу OAuth и в разделе JSON Web Token (JWT) Signature Algorithm выберите HS256.

Наконец, сохраните изменения, нажав кнопку Save Changes в самом низу страницы.

Концепция

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

Чтобы получить такой токен, пользователь должен попасть на страницу входа в систему нашего приложения, управляемую Auth0. Затем он проходит аутентификацию любым удобным для него способом и в случае успеха получает специальный code. Далее наш API-сервер должен использовать это значение вместе с secret и другими данными для создания JWT-токена, принадлежащего нашему пользователю. Чтобы это произошло, страница входа Auth0 перенаправляет клиента пользователя на URL обратного вызова, который мы предоставили с code значением, сохраненным в параметрах запроса URL под, ну, именем code. После этого сервер API считывает code, отправляет POST запрос на Auth0, получает токен и предоставляет JWT клиенту пользователя.

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

Создание базы данных MongoDB

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

Как обычно, создайте учетную запись на mongodb.com и войдите в нее. Затем перейдите на страницу Projects и создайте новый проект, нажав кнопку New Project:

Укажите любое название:

Нажмите кнопку Create Project:

Далее перейдите на страницу Database и нажмите кнопку Build a Database.

Чтобы создать бесплатную базу данных, выберите план «Shared», нажав кнопку Create под его ярлыком.

На следующей странице вы можете настроить свой бесплатный кластер по своему усмотрению, но для целей данного руководства достаточно конфигурации по умолчанию. Давайте изменим название и нажмем Create Cluster.

Должна открыться страница Security Quickstart. Здесь нам нужно настроить доступ к только что созданному кластеру.

Во-первых, давайте создадим учетные данные, которые будет использовать API-сервер для обмена данными с базой данных. Вы можете ввести любое имя пользователя и пароль. Для примера я собираюсь ввести admin в оба поля. Пожалуйста, запомните эти значения, так как мы будем использовать их позже. Нажмите кнопку Создать пользователя.

В разделе ниже мы должны указать IP-адрес, с которого можно получить доступ к базе данных. Скорее всего, вы собираетесь запустить сервер API с той же машины, на которой вы регистрируете кластер из браузера. Нажмите Add My Current IP Address, если это так. В противном случае… вы поняли идею 😉.

На открывшейся странице Database найдите созданный кластер и нажмите кнопку Connect. Далее нажмите Подключить ваше приложение.

Выберите Go в качестве драйвера, выберите свою версию и запомните URI mongo, который был сгенерирован для вас. Сервер API будет использовать его для установления соединения с базой данных.

Вот и все! Теперь мы настроены и готовы приступить к кодированию 👨💻.

Создание API-сервера

Ладно, хватит теории, перейдем к практике 😉

Инициализация

Создайте каталог для нашего проекта, запустите go mod init для его инициализации и создайте файл main.go со следующими импортами:

package main

import (
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)
Вход в полноэкранный режим Выход из полноэкранного режима

Определите функцию main() и создайте базовое приложение echo. Давайте сделаем его более аккуратным и добавим промежуточное ПО logger и recover.

func main() {
    e := echo.New()

    if err := godotenv.Load(); err != nil {
        panic(fmt.Errorf("failed to load .env file: %v", err))
    }

    e.Use(middleware.Recover()).Find
    e.Use(middleware.Logger())

    e.Logger.Fatal(e.Start(":9000"))
}
Вход в полноэкранный режим Выход из полноэкранного режима

Далее создайте файл .env и заполните его данными из предыдущих шагов этого руководства. Не забудьте заменить значение <password> в mongo URI.

DATABASE_NAME='example-database'
MONGO_URI='mongodb+srv://admin:admin@example-cluster.f1wmqog.mongodb.net/?retryWrites=true&w=majority'

AUTH0_DOMAIN='dev-tslb5vli.us.auth0.com'
AUTH0_CALLBACK_URL='http://localhost:9000/callback'
AUTH0_CALLBACK_ENDPOINT='/callback'
AUTH0_CLIENT_ID='PpstQbfS9tDdQvSTjDVbzRe8QWmGplrA'
AUTH0_CLIENT_SECRET='UKRAAKSJDFAfds4v1FXGGGDx66j343asd24FSASAMLhAEecJdasdkKJK3k3_V7CkdlksdtINE'
Вход в полноэкранный режим Выйдите из полноэкранного режима

Подключитесь к базе данных

Создайте каталог configs с файлом database.go внутри. Укажите имя пакета и добавьте следующие импорты:

package configs

import (
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)
Войти в полноэкранный режим Выход из полноэкранного режима

Далее нам нужно определить функцию, используемую для установления соединения с базой данных.

...

func connectDB() (*mongo.Client, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    mongoURI := os.Getenv("MONGO_URI")
    client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI))
    if err != nil {
        return nil, fmt.Errorf("failed to create a new client: %v", err)
    }

    err = client.Ping(ctx, nil)
    if err != nil {
        return nil, fmt.Errorf("failed to ping the database: %v", err)
    }

    return client, nil
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем добавьте в этот конфиг несколько глобальных переменных для клиента базы данных и коллекций, которые будут описаны позже в этом руководстве:

var DB *mongo.Client
var Collections struct {
    Accounts *mongo.Collection
    Notes *mongo.Collection
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Наконец, нам нужно инициализировать их. Создайте функцию init:

...

func init() {
    log.Print("Initializing database connection")

    var err error
    DB, err = connectDB()
    if err != nil {
        return log.Fatalf("failed to connect to database: %v", err)
    }

    databaseName := os.Getenv(envnames.DatabaseName)
    Collections.Accounts = DB.Database(databaseName).Collection("accounts")
    Collections.Notes = DB.Database(databaseName).Collection("notes")
}
Войти в полноэкранный режим Выход из полноэкранного режима

Импортируйте этот пакет в main.go для вызова инициализации:

package main

import (
    _ "<your-module-name>/configs"

    ...
Войти в полноэкранный режим Выйти из полноэкранного режима

Больше информации об этом виде импорта можно найти на официальной странице спецификации. BTW, я считаю страницу спецификации лучшим источником информации о Golang. Серьезно, потратьте немного времени и прочитайте ее 📖.

Создание конечной точки обратного вызова

Теперь давайте добавим хороший контроллер обратного вызова с некоторой обработкой ошибок. Для этого создайте каталог controllers с файлом callback.go внутри него и определите функцию для получения JWT из сервиса Auth0. Назовем ее FetchJWT. Она должна считывать URL-параметр code, который пользователь-клиент передаст после того, как сервер Auth0 перенаправит его на нашу конечную точку.

func FetchJWT(c echo.Context) error {
    code := c.QueryParam("code")
    if code == "" {
        return c.String(http.StatusBadRequest, "code parameter was not provided")
    }

    ...
Вход в полноэкранный режим Выход из полноэкранного режима

Далее нам нужно отправить запрос POST с этим кодом на Auth0 и проверить ответ:

    ...

    tokenFetchURL = fmt.Sprintf("https://%s/oauth/token", os.Getenv("AUTH0_DOMAIN"))

    data := url.Values{
        "grant_type":    {"authorization_code"},
        "client_id":     {os.Getenv("AUTH0_CLIENT_ID")},
        "client_secret": {os.Getenv("AUTH0_CLIENT_SECRET")},
        "redirect_uri":  {os.Getenv("AUTH0_CALLBACK_URL")},
        "code":{[]string{code}},
    }

    response, err := http.PostForm(tokenFetchURL, data)
    if err != nil {
        return fmt.Errorf("failed to retrieve JWT token from Auth0 server: %v", err)
    }

    body, err := io.ReadAll(response.Body)
    if err != nil {
        return fmt.Errorf("failed to read response form Auth0 server: %v", err)
    }

    if response.StatusCode != http.StatusOK {
        if response.StatusCode == http.StatusForbidden {
            return responses.Message(c, http.StatusForbidden, "got response from auth0: unauthorized")
        } else {
            return fmt.Errorf("got bad response from auth0: %v", err)
        }
    }

    ...
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы хотим убедиться, что каждый проверенный JWT на наш API-сервер будет содержать email пользователя. Мы будем использовать это значение в качестве идентификатора внутренней учетной записи пользователя в нашем приложении (подробнее об этом позже). Пока что давайте создадим проверку, чтобы убедиться, что каждый токен, который мы возвращаем нашим пользователям, содержит область «email», что в основном подразумевает, что одно из утверждений JWT содержит email пользователя.

    ...

    fieldsToCheck := struct {
        Scope string `json:"scope"`
    }{}

    if err := json.Unmarshal(body, &fieldsToCheck); err != nil {
        return fmt.Errorf("failed to unmarshal body for field check: %v", err)
    }

    if !strings.Contains(fieldsToCheck.Scope, "email") {
        return responses.Message(c, http.StatusBadRequest, `"email" scope is required`)
    }

    return c.String(http.StatusOK, string(body))
}
Вход в полноэкранный режим Выход из полноэкранного режима

Наконец, давайте зарегистрируем конечную точку обратного вызова на нашем сервере. Как всегда, мы стараемся придерживаться лучших практик, чтобы все было красиво и читабельно. Создайте каталог routes с файлом callback.go внутри. Вставьте приведенный ниже код (вероятно, вам придется изменить импорты):

package routes

import (
    "mrsydar/apiserver/controllers"

    "github.com/labstack/echo/v4"
)

func ApplyCallback(e *echo.Echo) {
    e.GET(os.Getenv("AUTH0_CALLBACK_ENDPOINT"), controllers.FetchJWT)
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь примените эту конечную точку в функции main:

    ...

    e.Use(middleware.Logger())

    routes.ApplyCallback(e)

    e.Logger.Fatal(e.Start(":9000"))

    ...
Войти в полноэкранный режим Выйти из полноэкранного режима

Давайте протестируем его! Запустите сервер с помощью go run .. Заполните необходимые значения и откройте следующий URL:

https://<AUTH0_DOMAIN>/authorize?response_type=code&client_id=<AUTH0_CLIENT_ID>&redirect_uri=http://localhost:9000/callback&scope=openid%20email&state=STATE
Вход в полноэкранный режим Выйти из полноэкранного режима

В моем случае это выглядит следующим образом:

https://dev-tslb5vli.us.auth0.com/authorize?response_type=code&client_id=PpstQbfS9tDdQvSTjDVbzRe8QWmGplrA&redirect_uri=http://localhost:9000/callback&scope=openid%20email&state=STATE
Войти в полноэкранный режим Выйти из полноэкранного режима

Вы должны увидеть страницу входа, которую Auth0 сделал для нас:

Авторизуйтесь, нажав Accept. Вы будете автоматически перенаправлены на нашу конечную точку обратного вызова http://localhost:9000/callback. Наш бэкенд прочитает code, выполнит JWT-запрос и вернет вам результат. Обычно этим должен заниматься фронтенд нашего проекта, но пока это работает, этого достаточно 👀.

Вуаля! Мы закончили с обратным вызовом. Круто 😎

Создание внутренних учетных записей

Давайте посмотрим на наш JWT-токен. Для этого скопируйте значение из поля id_token ответа обратного вызова и вставьте его на страницу jwt.io. Одно из утверждений токена содержит значение email. Мы можем использовать это значение для защиты наших данных в БД.

Например, давайте вернемся к нашему примеру с заметками. Каждая запись note в нашей базе данных должна содержать 3 поля:

Теперь вы поняли?

Когда пользователь отправит запрос на список своих заметок, мы проверим его токен, а затем вернем все заметки, у которых owner_email установлен в значение email из JWT. Гениально и просто!

Но есть гораздо больше преимуществ в создании и хранении внутренних учетных записей пользователей в вашей базе данных и использовании их идентификаторов вместо того, чтобы просто использовать поле email. Так что давайте сделаем это.

Прежде всего, определим модель для наших учетных записей. Создайте каталог models и account.go внутри него. Определите модель счета там:

package models

import "go.mongodb.org/mongo-driver/bson/primitive"

type Account struct {
    ID primitive.ObjectID `bson:"_id,omitempty"`

    Email string `bson:"email,omitempty" validate:"required"`
}
Вход в полноэкранный режим Выход из полноэкранного режима

Нам нужно написать собственное промежуточное ПО, которое будет создавать и ассоциировать внутреннего пользователя с входящим запросом. Создайте каталог middlewares с accounts.go внутри. Определите промежуточную функцию AssociateAccountWithRequest.

func AssociateAccountWithRequest(next echo.HandlerFunc) echo.HandlerFunc {
    func (next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            return next(c)
        }
    }
}
Вход в полноэкранный режим Выйдите из полноэкранного режима

Далее прочитайте электронное письмо, хранящееся внутри JWT. Для этого вставьте следующий фрагмент кода внутрь анонимной функции, которую мы определили внутри AssociateAccountWithRequest прямо перед вызовом next(c):

        ...

        user := c.Get("user").(*jwt.Token)
        claims := user.Claims.(jwt.MapClaims)
        email := claims["email"].(string)
        if email == "" {
            return errors.New("email value is empty in the received JWT token")
        }

        ...

Вход в полноэкранный режим Выход из полноэкранного режима

Теперь возьмите аккаунт из нашей БД по значению email или создайте новый:

        ...

        account := models.Account{}
        if err := database.Collections.Accounts.FindOne(context.Background(), bson.M{"email": email}).Decode(&account); err != nil {
            if err == mongo.ErrNoDocuments {
                account.Email = email
                result, err := database.Collections.Accounts.InsertOne(context.Background(), account)
                if err != nil {
                    return fmt.Errorf("failed to insert account resource: %v", err)
                }
                account.ID = result.InsertedID.(primitive.ObjectID)
            } else {
                return fmt.Errorf("failed to get account: %v", err)
            }
        }

        ...
Войти в полноэкранный режим Выйти из полноэкранного режима

Наконец, передадим ID аккаунта в контекст запроса, чтобы контроллер мог возвращать только данные, доступные пользователю, отправившему запрос.

    ...
    c.Set(contextnames.AccountID, account.ID)

    return next(c)
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Подведем итоги. Мы только что создали промежуточное ПО, которое мы можем использовать на любом маршруте для нашего API-сервера. Оно будет проверять пользователя, отправляющего запрос, создавать или получать уже существующего внутреннего пользователя и передавать его идентификатор на следующие шаги конвейера запросов. Потрясающе 🦄

Создание контроллера заметок

Теперь, когда промежуточное ПО готово, идем дальше и создаем две конечные точки для отправки и списка данных, которые необходимо защитить. В нашем случае это объект note.

Прежде всего, определите модель note note.go в каталоге models. Добавьте поле ID, поле Owher для привязки заметки к какому-либо владельцу (т.е. внутреннему пользователю) и поле text для самих данных.

package models

type Note struct {
    ID    primitive.ObjectID `bson:"_id" json:"_id"`
    Owner primitive.ObjectID `bson:"owner,omitempty" json:"owner"`

    Text string `bson:"text" json:"text"`
}
Вход в полноэкранный режим Выход из полноэкранного режима

Модель готова, теперь нам нужны контроллеры, чтобы привести ее в действие. Создайте файл note.go в директории controllers.

Определите функцию, которая позволит пользователям сохранять свои заметки. Она должна разбирать тело запроса и вставлять полученные данные в соответствующую коллекцию документов:

func PostNote(c echo.Context) error {
    var note models.Note

    body, err := io.ReadAll(c.Request().Body)
    if err != nil {
        msg := "failed to post a note"
        log.Logger.Errorf("%v: %v", msg, err)
        return responses.Message(c, http.StatusBadRequest, msg)
    }

    if len(body) == 0 {
        msg := "request body is empty"
        return responses.Message(c, http.StatusBadRequest, msg)
    }

    if err := json.Unmarshal(body, &note); err != nil {
        msg := "failed to post a note"
        log.Logger.Errorf("%v: %v", msg, err)
        return responses.Message(c, http.StatusInternalServerError, msg)
    }

    note.ID = primitive.NewObjectID()
    note.Owner = c.Get("accountID").(primitive.ObjectID)

    result, err := database.Collections.Notes.InsertOne(context.Background(), note)
    if err != nil {
        msg := "failed to post a note"
        log.Logger.Errorf("%v: %v", msg, err)
        return responses.Message(c, http.StatusInternalServerError, msg)
    }

    note.ID = result.InsertedID.(primitive.ObjectID)

    return c.JSON(http.StatusCreated, note)
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь определите функцию для получения заметок по владельцу:

func GetNotes(c echo.Context) error {
    notes := []models.Note{}

    accountID := c.Get("accountID").(primitive.ObjectID)
    cur, err := database.Collections.Notes.Find(context.Background(), bson.M{"owner": accountID})
    if err != nil {
        msg := "failed to list notes"
        log.Logger.Errorf("%v: %v", msg, err)
        return responses.Message(c, http.StatusInternalServerError, msg)
    }

    if err := cur.All(context.Background(), &notes); err != nil {
        msg := "failed to list notes"
        log.Logger.Errorf("%v: %v", msg, err)
        return responses.Message(c, http.StatusInternalServerError, msg)
    }

    return c.JSON(http.StatusOK, notes)
}
Войти в полноэкранный режим Выход из полноэкранного режима

Наконец, привяжите эти функции к соответствующим конечным точкам в файле note.go в каталоге routes. Не забудьте использовать созданное нами промежуточное ПО. В противном случае контекст запроса не будет содержать необходимого идентификатора владельца.

func ApplyNotes(e *echo.Echo) {
    e.GET("/notes", controllers.GetNotes,
        middleware.JWTWithConfig(middleware.JWTConfig{SigningKey: []byte(os.Getenv("AUTH0_CLIENT_SECRET"))}),
        d2middlewares.AssociateAccountWithRequest,
    )

    e.POST("/notes", controllers.PostNote,
        middleware.JWTWithConfig(middleware.JWTConfig{SigningKey: []byte(os.Getenv("AUTH0_CLIENT_SECRET"))}),
        d2middlewares.AssociateAccountWithRequest,
    )
}
Вход в полноэкранный режим Выйдите из полноэкранного режима

Примените эти маршруты в функции main:

    ...

    routes.ApplyCallback(e)
    routes.ApplyNotes(e)

    e.Logger.Fatal(e.Start(":9000"))
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Вот и все, сервер готов к тестированию 🌟

Протестируйте его 🧪

Запустите наш сервер командой go run .. Он должен подключиться к базе данных и начать обслуживать наши конечные точки.

Теперь отправьте запрос POST на адрес localhost:9000/notes с телом, содержащим любой текст (например, {"text": "vulnerable data"}). Ответ должен содержать нашу заметку, заполненную данными _id и owner. Не забудьте добавить заголовок Authorization и поместить JWT, который мы извлекли ранее Bearer <id_token value>. Что-то вроде: Authorization: Bearer eyJhbGciOiJIUzI1NiIsR5cCI6IkpXVCJ9.... Обратите внимание, что у этого токена есть срок действия. Если он истек, вам нужно повторить процесс аутентификации, который мы делали ранее, и скопировать новый id_token (также есть возможность обновить токены, подробнее об этом вы можете прочитать в документации Auth0).

Давайте отправим 2 заметки на сервер.

Затем перечислим их с помощью запроса GET к той же конечной точке. Опять же, не забудьте установить JWT, как и ранее.

Та-да 🎉

Если что-то не работает, не волнуйтесь. Просто загляните в готовый проект.

Домашнее задание

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

Также просмотрите коллекции базы данных и убедитесь, что вы понимаете каждое поле в ней:

Послесловие

Вы сделали это! Поздравляем 🥳

Оцените статью
devanswers.ru
Добавить комментарий