Alex Edwards Let's Go часть 2

edwardsLetsGo учебный материал по написанию приложения на Go с авторизацией пользователя. Краткий конспект с основными мыслями (главы с 9 по 12)

Stateful HTTP

Отслеживание состояний в HTTP

Будем выводить для начала сообщение по результатам добавления новой формы

Выбор менеджера сессий


Сравнение и анализ менеджеров сессий

1. Сравнение gorilla/sessions и alexedwards/scs

Характеристика gorilla/sessions alexedwards/scs
Тип хранилища Cookie, файлы, Redis, Memcached, другие (через сторонние адаптеры) Cookie, Redis, PostgreSQL, MySQL, SQLite, in-memory
Безопасность Поддержка подписанных кук, но требует ручной настройки Встроенная защита от подделки (secure cookies), автоматическое обновление токенов
Производительность Зависит от бэкенда (Redis быстрее, чем куки) Оптимизирован для работы с разными хранилищами, меньше накладных расходов
Гибкость Гибкий API, можно настраивать под разные сценарии Более структурированный API, но менее гибкий
Поддержка контекста Нет встроенной поддержки context.Context Полная интеграция с context.Context
Автоматическое продление Нет (нужно реализовывать вручную) Есть (автоматически обновляет сессии)
Зависимости Часть Gorilla Toolkit (можно использовать отдельно) Независимая библиотека
Активность разработки Заморожен (Gorilla Toolkit больше не поддерживается) Активно развивается
Использование в продакшене Широко использовался, но сейчас менее предпочтителен Рекомендуется для новых проектов

Вывод:

  • Gorilla/sessions — устаревающий, но проверенный вариант. Подходит, если уже используется Gorilla Toolkit.
  • SCS (alexedwards/scs) — современный, безопасный и более удобный для новых проектов.

2. Другие популярные менеджеры сессий в Go

a) Gin Sessions (для фреймворка Gin)

  • Особенности: Интеграция с Gin, поддержка Redis, Cookie, Memcached.
  • Плюсы: Простота использования в Gin-приложениях.
  • Минусы: Зависит от Gin, не подходит для других фреймворков.
  • Ссылка: github.com/gin-contrib/sessions

b) Beego Session

  • Особенности: Встроен в фреймворк Beego, поддерживает файлы, Redis, Memcached, MySQL, PostgreSQL.
  • Плюсы: Готовое решение для Beego.
  • Минусы: Привязан к Beego.
  • Ссылка: beego.me

c) Go-chi/session (от создателей Chi)

  • Особенности: Минималистичный, работает с context.Context, поддерживает Redis и куки.
  • Плюсы: Легковесный, хорош для микрофреймворков.
  • Минусы: Меньше возможностей, чем у SCS.
  • Ссылка: github.com/go-chi/session

d) Iriris Sessions

  • Особенности: Интеграция с фреймворком Iris, поддержка Redis, BoltDB, Badger, файлы.
  • Плюсы: Высокая производительность, удобство в Iris.
  • Минусы: Привязан к Iris.
  • Ссылка: github.com/kataras/iris/sessions

3. Какой выбрать?

  1. Для новых проектовalexedwards/scs (безопасность, контекст, активная разработка).
  2. Для Gingin-contrib/sessions.
  3. Для микрофреймворков (Chi, Echo)go-chi/session или scs.
  4. Legacy-проекты на Gorillagorilla/sessions (но лучше мигрировать на scs).

Установка alexedwards/scs

go get github.com/alexedwards/scs/v2@v2
go get github.com/alexedwards/scs/mysqlstore

Создать таблицу в базе данных для хранения сессий

Поддерживает много различных баз данных о чем говорит в документации: https://github.com/alexedwards/scs

USE snippetbox;

CREATE TABLE sessions (
    token CHAR(43) PRIMARY KEY,
    data BLOB NOT NULL,
    expiry TIMESTAMP(6) NOT NULL
);

CREATE INDEX sessions_expiry_idx ON sessions (expiry);

этот пример для нашей базы snippetbox

  • Поле токена (tocken) будет содержать уникальный, случайно сгенерированный идентификатор для каждого сеанса.
  • Поле данных (data) будет содержать фактические данные сеанса, которые вы хотите использовать совместно с HTTP-запросами. Они хранятся в виде двоичных данных типа BLOB (двоичный большой объект).
  • Поле истечения срока действия (expiry) будет содержать время истечения срока действия сеанса.

Пакет scs автоматически удалит истекшие сеансы из таблицы сеансов, чтобы она не стала слишком большой.

Изменения в main.go

  1. Добавим импорты
    "github.com/alexedwards/scs/mysqlstore" // New import
    "github.com/alexedwards/scs/v2"         // New import
  1. Добавим поле сессии в структуру приложения
type application struct {
    errorLog       *log.Logger
    infoLog        *log.Logger
    snippets       *models.SnippetModel
    templateCache  map[string]*template.Template
    formDecoder    *form.Decoder
    sessionManager *scs.SessionManager
}
  1. Настройка менеджера сессий
    sessionManager := scs.New() // Используем scs.New() для инициализации нового менеджера сессий. 
    sessionManager.Store = mysqlstore.New(db) //настраиваем его на использование нашей базы данных MySQL в качестве хранилища сессий 
    sessionManager.Lifetime = 12 * time.Hour //устанавливаем время жизни 12 часов 
  1. Добавить менеджер сессий в структуру приложения
    app := &application{
        errorLog:       errorLog,
        infoLog:        infoLog,
        snippets:       &models.SnippetModel{DB: db},
        templateCache:  templateCache,
        formDecoder:    formDecoder,
        sessionManager: sessionManager,
    }

Обернуть обработчики запросов

Чтобы сеансы работали, нам также необходимо обернуть маршруты наших приложений в промежуточное ПО, предоставляемое методом SessionManager.LoadAndSave(). Это промежуточное ПО автоматически загружает и сохраняет данные сеанса при каждом HTTP-запросе и ответе.

/static/*filepath - оборачивать не будем

Создадим ноувую цепочку для динамических данных сайта

Корректируем файл: routes.go

  1. Создаем переменную для учета сессий
    dynamic := alice.New(app.sessionManager.LoadAndSave)
  1. Обертываем обработчики
    router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
    router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
    router.Handler(http.MethodGet, "/snippet/create", dynamic.ThenFunc(app.snippetCreate))
    router.Handler(http.MethodPost, "/snippet/create", dynamic.ThenFunc(app.snippetCreatePost))
  1. Остальное оставляем без изменений
    standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
    return standard.Then(router)

Работа с данными сессии

Добавление сообщения об успешном добавлении сниппета

  1. Внесем изменения в файл handlers.go

Используйте метод Put() для добавления строкового значения (“Сниппет успешно создан!”) и соответствующего ключа (“flash”) к данным сессии.

func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) {
    var form snippetCreateForm
	...
    app.sessionManager.Put(r.Context(), "flash", "Snippet successfully created!")
  • Первый параметр, который мы передаем в app.sessionManager.Put(), — это текущий контекст запроса (это место, где менеджер сеансов временно хранит информацию, пока ваши обработчики работают с запросом.)
  • Второй параметр (в нашем случае строка “flash”) является ключом для конкретного сообщения, которое мы добавляем в данные сессии. Впоследствии мы получим сообщение из данных сеанса также с помощью этого ключа.

Если для текущего пользователя нет существующего сеанса (или срок его сеанса истек), то новый пустой сеанс для него будет автоматически создан промежуточным программным обеспечением сеанса.

  1. Далее мы хотим, чтобы наш обработчик snippetView извлек флэш-сообщение (если оно существует в сессии для текущего пользователя) и передал его в HTML-шаблон для последующего отображения.

Поскольку мы хотим отобразить флэш-сообщение только один раз, на самом деле мы хотим извлечь и удалить сообщение из данных сеанса. Мы можем выполнить обе эти операции одновременно с помощью метода PopString().

func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
    params := httprouter.ParamsFromContext(r.Context())
...
    flash := app.sessionManager.PopString(r.Context(), "flash")

    data := app.newTemplateData(r)
    data.Snippet = snippet

    // Pass the flash message to the template.
    data.Flash = flash 
    app.render(w, http.StatusOK, "view.tmpl", data)
}
  1. Добавить поле Flash в структуру данных templateData (в файле templates.go)
type templateData struct {
    CurrentYear int
    Snippet     *models.Snippet
    Snippets    []*models.Snippet
    Form        any
    Flash       string // Add a Flash field to the templateData struct.
}
  1. Внесем изменения в шаблон base.tmpl
{{define "base"}}
<!doctype html>
<html lang='en'>
    <head>
        <meta charset='utf-8'>
        <title>{{template "title" .}} - Snippetbox</title>
        <link rel='stylesheet' href='/static/css/main.css'>
        <link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
        <link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700'>
    </head>
    <body>
        <header>
            <h1><a href='/'>Snippetbox</a></h1>
        </header>
        {{template "nav" .}}
        <main>
            <!-- Display the flash message if one exists -->
            {{with .Flash}}
                <div class='flash'>{{.}}</div>
            {{end}}
            {{template "main" .}}
        </main>
        <footer>
            Powered by <a href='https://golang.org/'>Go</a> in {{.CurrentYear}}
        </footer>
        <script src="/static/js/main.js" type="text/javascript"></script>
    </body>
</html>
{{end}}
  1. Модернизируем функцию newTemplateData в файле helpers.go для вывода других сообщений при совершении действий на сайте.
func (app *application) newTemplateData(r *http.Request) *templateData {
    return &templateData{
        CurrentYear: time.Now().Year(),
        // Add the flash message to the template data, if one exists.
        Flash:       app.sessionManager.PopString(r.Context(), "flash"),
    }
}

поэтому теперь можно убрать строки из файла handlers.go

    flash := app.sessionManager.PopString(r.Context(), "flash") //убрать
...
    data.Flash = flash //убрать

Безопасность данных

Создание самозаверяющего сертификата TLS

HTTPS — это, по сути, HTTP, отправляемый через соединение TLS (Transport Layer Security).

Поскольку данные отправляются через соединение TLS, они шифруются и подписываются, что помогает обеспечить их конфиденциальность и целостность во время передачи.

Прежде чем наш сервер сможет начать использовать HTTPS, нам необходимо сгенерировать сертификат TLS.

Удобно отметить, что пакет crypto/tls в стандартной библиотеке Go включает в себя инструмент generate_cert.go, который мы можем использовать для простого создания собственного самоподписанного сертификата.

Создание своего самозаписывающего сертификата

cd $HOME/code/snippetbox
mkdir tls
cd tls

Чтобы запустить инструмент generate_cert.go, вам нужно знать место на вашем компьютере, где установлен исходный код стандартной библиотеки Go.

Если вы используете Linux, macOS или FreeBSD и следуете официальным инструкциям по установке, то файл generate_cert.go должен находиться в папке /usr/local/go/src/crypto/tls.

Запуск генерации сертификата

$ go run /usr/local/go/src/crypto/tls/generate_cert.go --rsa-bits=2048 --host=localhost

на manjaro оказался на /usr/lib/go/src/crypto/tls/generate_cert.go

для поиска можно воспользоваться командой:

find /usr -name 'generate_cert.go' 

За кулисами инструмент generate_cert.go работает в два этапа:

  1. сначала он генерирует 2048-битную пару ключей RSA, которая является криптографически защищенным открытым ключом и закрытым ключом.
  2. Затем он сохраняет закрытый ключ в файле key.pem и генерирует самоподписанный сертификат TLS для хоста localhost, содержащий открытый ключ, который он хранит в файле cert.pem.

Как закрытый ключ, так и сертификат закодированы в PEM, что является стандартным форматом, используемым в большинстве реализаций TLS.

Запуск HTTPS сервера

  1. Заменить в файле main.go srv.ListenAndServe() на srv.ListenAndServeTLS()
    err = srv.ListenAndServeTLS("./tls/cert.pem", "./tls/key.pem")
    errorLog.Fatal(err)
  1. Запустить приложение
  2. Открыть сайт https://localhost:4000/ по протоколу https

Занесите каталог tls в .gitignore

нежелательно пушить сертификаты в репозиторий

Настройка параметров HTTPS

Go имеет хорошие настройки по умолчанию для своего HTTPS-сервера, но можно оптимизировать и настроить поведение сервера.

tls.CurveP256 and tls.X25519 - рекомендуется использовать эти протоколы

Добавим настройки в файл main.go

  1. Добавим импорт
import (
    "crypto/tls" // New import
  1. Добавим tlsConfig
    tlsConfig := &tls.Config{
        CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
    }
  1. Добавим tls настройки в структуру srv
    srv := &http.Server{
        Addr:      *addr,
        ErrorLog:  errorLog,
        Handler:   app.routes(),
        TLSConfig: tlsConfig,
    }

Настройка версий TLS

tlsConfig := &tls.Config{
    MinVersion: tls.VersionTLS12,
    MaxVersion: tls.VersionTLS12,
}

Ограничение наборов шифров Полный набор наборов шифров, поддерживаемых Go,

tlsConfig := &tls.Config{
    CipherSuites: []uint16{
        tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
        tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
        tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
        tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
        tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
        tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
    },
}

Тайм-ауты подключения

Давайте уделим немного времени, чтобы повысить отказоустойчивость нашего сервера, добавив некоторые настройки тайм-аута, например:

    srv := &http.Server{
        Addr:      *addr,
        ErrorLog:  errorLog,
        Handler:   app.routes(),
        TLSConfig: tlsConfig,
        // Add Idle, Read and Write timeouts to the server.
        IdleTimeout:  time.Minute,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
    }

добавляем в структуру srv необходимые поля для настройки таймаута

Все эти три тайм-аута — IdleTimeout, ReadTimeout и WriteTimeout — являются общесерверными настройками, которые действуют на базовое соединение и применяются ко всем запросам независимо от их обработчика или URL-адреса.

Параметр IdleTimeout

По умолчанию Go включает проверку активности для всех принятых подключений. Это помогает уменьшить задержку (особенно для подключений HTTPS), поскольку клиент может повторно использовать одно и то же соединение для нескольких запросов без необходимости повторять рукопожатие.

В нашем случае мы установили IdleTimeout на 1 минуту, что означает, что все keep-alive соединения будут автоматически закрыты после 1 минуты бездействия.

Параметр ReadTimeout

В нашем коде мы также установили параметр ReadTimeout равным 5 секундам. Это означает, что если заголовки или тело запроса все еще считываются через 5 секунд после первого принятия запроса, то Go закроет базовое соединение. Поскольку это “жесткое” замыкание соединения, пользователь не получит никакого ответа HTTP(S). Установка короткого периода ReadTimeout помогает снизить риск атак на медленных клиентов, таких как Slowloris, которые в противном случае могли бы держать соединение открытым на неопределенный срок, отправляя частичные, неполные HTTP(S) запросы.

Параметр WriteTimeout

Параметр WriteTimeout закроет базовое соединение, если наш сервер попытается записать данные в соединение через определенный период времени (в нашем коде это 10 секунд).

Но это ведет себя немного по-разному в зависимости от используемого протокола.

Для HTTP-соединений, если некоторые данные записываются в соединение более чем через 10 секунд после завершения чтения заголовка запроса, Go закроет базовое соединение, а не запишет данные.

Для соединений HTTPS, если некоторые данные записываются в соединение более чем через 10 секунд после первого принятия запроса, Go закроет базовое соединение вместо записи данных.

Это означает, что если вы используете HTTPS (как мы), имеет смысл установить значение WriteTimeout больше, чем ReadTimeout.

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

Настройка ReadHeaderTimeout

Функция http.Server также предоставляет параметр ReadHeaderTimeout, который мы не использовали в нашем приложении. Это работает аналогично ReadTimeout, за исключением того, что применяется только к чтению заголовков HTTP(S).

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

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

Настройка MaxHeaderBytes

http.Server также предоставляет поле MaxHeaderBytes, которое можно использовать для управления максимальным количеством байтов, которое сервер будет считывать при анализе заголовков запросов. По умолчанию Go разрешает максимальную длину заголовка 1 МБ.

Например, если вы хотите ограничить максимальную длину заголовка до 0,5 МБ, вы должны написать:

srv := &http.Server{
    Addr:           *addr,
    MaxHeaderBytes: 524288,
    ...
}

Аутентификация пользователей

  1. Пользователь должен зарегистрироваться, посетив форму по адресу /user/signup и введя свое имя, адрес электронной почты и пароль. Мы сохраним эту информацию в новой таблице базы данных пользователей.
  2. Пользователь войдет в систему, посетив форму по адресу /user/login и введя свой адрес электронной почты и пароль.
  3. Затем мы проверим базу данных, чтобы увидеть, совпадают ли введенные ими адрес электронной почты и пароль с одним из пользователей в таблице пользователей. Если совпадение найдено, пользователь успешно прошел проверку подлинности, и мы добавляем соответствующее значение id для пользователя в данные его сеанса с помощью ключа “authenticatedUserID”.
  4. Когда мы получаем какие-либо последующие запросы, мы можем проверить данные сеанса пользователя на наличие значения “authenticatedUserID”. Если она существует, мы знаем, что пользователь уже успешно вошел в систему. Мы можем продолжать проверять это до тех пор, пока сессия не истечет, когда пользователю потребуется снова войти в систему. Если в сеансе отсутствует “authenticatedUserID”, мы знаем, что пользователь не вошел в систему.

Подготовительные мероприятия для создания инфраструктуры данных

Добавим новые маршруты для регистрации пользователей

HTTP Method Route Handler Description
GET /user/signup userSignup Display a HTML form for signing up a new user
POST /user/signup userSignupPost Create a new user
GET /user/login userLogin Display a HTML form for logging in a user
POST /user/login userLoginPost Authenticate and login the user
POST /user/logout userLogoutPost Logout the user

Пропишем в файле routes.go данные маршруты:

    router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup))
    router.Handler(http.MethodPost, "/user/signup", dynamic.ThenFunc(app.userSignupPost))
    router.Handler(http.MethodGet, "/user/login", dynamic.ThenFunc(app.userLogin))
    router.Handler(http.MethodPost, "/user/login", dynamic.ThenFunc(app.userLoginPost))
    router.Handler(http.MethodPost, "/user/logout", dynamic.ThenFunc(app.userLogoutPost))

Создание обработчиков для маршрутов handlers.go

func (app *application) userSignup(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Display a HTML form for signing up a new user...")
}

func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Create a new user...")
}

func (app *application) userLogin(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Display a HTML form for logging in a user...")
}

func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Authenticate and login the user...")
}

func (app *application) userLogoutPost(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Logout the user...")
}

Создать ссылки на экране для вызова функций nav.tmpl

{{define "nav"}}
<nav>
    <div>
        <a href='/'>Home</a>
        <a href='/snippet/create'>Create snippet</a>
    </div>
    <div>
        <a href='/user/signup'>Signup</a>
        <a href='/user/login'>Login</a>
        <form action='/user/logout' method='POST'>
            <button>Logout</button>
        </form>
    </div>
</nav>
{{end}}

Создать таблицу для пользователей

USE snippetbox;

CREATE TABLE users (
    id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    hashed_password CHAR(60) NOT NULL,
    created DATETIME NOT NULL
);

ALTER TABLE users ADD CONSTRAINT users_uc_email UNIQUE (email);

Мы также добавили ограничение UNIQUE в столбец электронной почты и назвали его users_uc_email. Это ограничение гарантирует, что у нас не будет двух пользователей с одинаковым адресом электронной почты. Если мы попытаемся вставить запись в эту таблицу с дубликатом электронной почты, MySQL выдаст ошибку 1062: ER_DUP_ENTRY ошибку.

Создать аналогичные методы и структуры для работы с данными пользователей

Добавить необходимые типы ошибок для обработки пользователей в файл errors.go

package models

import (
    "errors"
)

var (
    ErrNoRecord = errors.New("models: no matching record found")

    // Add a new ErrInvalidCredentials error. We'll use this later if a user
    // tries to login with an incorrect email address or password.
    ErrInvalidCredentials = errors.New("models: invalid credentials")

    // Add a new ErrDuplicateEmail error. We'll use this later if a user
    // tries to signup with an email address that's already in use.
    ErrDuplicateEmail = errors.New("models: duplicate email")
)

Создаем отдельный файл для обработки пользователей users.go

cd $HOME/code/snippetbox
touch internal/models/users.go
package models

import (
    "database/sql"
    "time"
)

// Define a new User type. Notice how the field names and types align
// with the columns in the database "users" table?
type User struct {
    ID             int
    Name           string
    Email          string
    HashedPassword []byte
    Created        time.Time
}

// Define a new UserModel type which wraps a database connection pool.
type UserModel struct {
    DB *sql.DB
}

// We'll use the Insert method to add a new record to the "users" table.
func (m *UserModel) Insert(name, email, password string) error {
    return nil
}

// We'll use the Authenticate method to verify whether a user exists with
// the provided email address and password. This will return the relevant
// user ID if they do.
func (m *UserModel) Authenticate(email, password string) (int, error) {
    return 0, nil
}

// We'll use the Exists method to check if a user exists with a specific ID.
func (m *UserModel) Exists(id int) (bool, error) {
    return false, nil
}

Добавить новое поле в структуру приложения main.go

добавим поле users

type application struct {
    errorLog       *log.Logger
    infoLog        *log.Logger
    snippets       *models.SnippetModel
    users          *models.UserModel
    templateCache  map[string]*template.Template
    formDecoder    *form.Decoder
    sessionManager *scs.SessionManager
}

func main() {
    
    ...

    // Initialize a models.UserModel instance and add it to the application
    // dependencies.
    app := &application{
        errorLog:       errorLog,
        infoLog:        infoLog,
        snippets:       &models.SnippetModel{DB: db},
        users:          &models.UserModel{DB: db},
        templateCache:  templateCache,
        formDecoder:    formDecoder,
        sessionManager: sessionManager,
    }
...

Регистрация пользователей

Создадим форму регистрации ui/html/pages/signup.tmpl

$ cd $HOME/code/snippetbox
$ touch ui/html/pages/signup.tmpl
{{define "title"}}Signup{{end}}

{{define "main"}}
<form action='/user/signup' method='POST' novalidate>
    <div>
        <label>Name:</label>
        {{with .Form.FieldErrors.name}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='text' name='name' value='{{.Form.Name}}'>
    </div>
    <div>
        <label>Email:</label>
        {{with .Form.FieldErrors.email}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='email' name='email' value='{{.Form.Email}}'>
    </div>
    <div>
        <label>Password:</label>
        {{with .Form.FieldErrors.password}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='password' name='password'>
    </div>
    <div>
        <input type='submit' value='Signup'>
    </div>
</form>
{{end}}

Создадим новую структуру данных для регистрации пользователя

В файле handlers.go:

package main

...

// Create a new userSignupForm struct.
type userSignupForm struct {
    Name                string `form:"name"`
    Email               string `form:"email"`
    Password            string `form:"password"`
    validator.Validator `form:"-"`
}

// Update the handler so it displays the signup page.
func (app *application) userSignup(w http.ResponseWriter, r *http.Request) {
    data := app.newTemplateData(r)
    data.Form = userSignupForm{}
    app.render(w, http.StatusOK, "signup.tmpl", data)
}

...

Валидация формы регистрации

Добавим новые методы валидации в файле: internal/validator/validator.go

package validator

import (
    "regexp" // New import
    "strings"
    "unicode/utf8"
)

// Use the regexp.MustCompile() function to parse a regular expression pattern
// for sanity checking the format of an email address. This returns a pointer to 
// a 'compiled' regexp.Regexp type, or panics in the event of an error. Parsing 
// this pattern once at startup and storing the compiled *regexp.Regexp in a 
// variable is more performant than re-parsing the pattern each time we need it.
var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")

...

// MinChars() returns true if a value contains at least n characters.
func MinChars(value string, n int) bool {
    return utf8.RuneCountInString(value) >= n
}

// Matches() returns true if a value matches a provided compiled regular 
// expression pattern.
func Matches(value string, rx *regexp.Regexp) bool {
    return rx.MatchString(value)
}

Добавим обработчик формы в handlers.go

func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
    // Declare an zero-valued instance of our userSignupForm struct.
    var form userSignupForm

    // Parse the form data into the userSignupForm struct.
    err := app.decodePostForm(r, &form)
    if err != nil {
        app.clientError(w, http.StatusBadRequest)
        return
    }

    // Validate the form contents using our helper functions.
    form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank")
    form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
    form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
    form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
    form.CheckField(validator.MinChars(form.Password, 8), "password", "This field must be at least 8 characters long")

    // If there are any errors, redisplay the signup form along with a 422
    // status code.
    if !form.Valid() {
        data := app.newTemplateData(r)
        data.Form = form
        app.render(w, http.StatusUnprocessableEntity, "signup.tmpl", data)
        return
    }

    // Otherwise send the placeholder response (for now!).
    fmt.Fprintln(w, "Create a new user...")
}

Краткое введение в bcrypt

  1. Хорошей практикой — является хранение одностороннего хеша пароля, полученного с помощью ресурсоемкой функции извлечения ключа, такой как Argon2, scrypt или bcrypt. Go имеет реализации всех 3 алгоритмов в пакете golang.org/x/crypto.
  2. Тем не менее, плюсом реализации bcrypt является то, что она включает в себя вспомогательные функции, специально разработанные для хеширования и проверки паролей, и именно их мы будем использовать здесь.
  3. Установка bcrypt
go get golang.org/x/crypto/bcrypt@latest
  1. Создать хэш пароля
hash, err := bcrypt.GenerateFromPassword([]byte("my plain text password"), 12)
  1. Эта функция вернет хеш длиной 60 символов, который выглядит примерно так:
$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG

Второй параметр мы передаем в bcrypt.GenerateFromPassword() указывает стоимость, которая представлена целым числом от 4 до 31. 12 означает, что для генерации хэша пароля будет использовано 4096 (2^12) итераций bcrypt.

  1. Проверка пароля: функция bcrypt.CompareHashAndPassword()
hash := []byte("$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6GzGJSWG") 
err := bcrypt.CompareHashAndPassword(hash, []byte("my plain text password"))

Хранение информации о пользователе

Следующим этапом нашей сборки является обновление метода UserModel.Insert() таким образом, чтобы он создал новую запись в нашей таблице пользователей, содержащую проверенное имя, адрес электронной почты и хэшированный пароль.

  1. Для обработки ошибок в базе данных и сохранения хэша понадобятся библиотеки
import (
    "database/sql"
    "errors"  // New import
    "strings" // New import
    "time"

    "github.com/go-sql-driver/mysql" // New import
    "golang.org/x/crypto/bcrypt"     // New import
)
  1. Файл internal/models/users.go для обработки запросов к базе данных
type UserModel struct {
    DB *sql.DB
}

func (m *UserModel) Insert(name, email, password string) error {
    // Create a bcrypt hash of the plain-text password.
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
    if err != nil {
        return err
    }

    stmt := `INSERT INTO users (name, email, hashed_password, created)
    VALUES(?, ?, ?, UTC_TIMESTAMP())`

    // Use the Exec() method to insert the user details and hashed password
    // into the users table.
    _, err = m.DB.Exec(stmt, name, email, string(hashedPassword))
    if err != nil {
        // If this returns an error, we use the errors.As() function to check
        // whether the error has the type *mysql.MySQLError. If it does, the
        // error will be assigned to the mySQLError variable. We can then check
        // whether or not the error relates to our users_uc_email key by
        // checking if the error code equals 1062 and the contents of the error 
        // message string. If it does, we return an ErrDuplicateEmail error.
        var mySQLError *mysql.MySQLError
        if errors.As(err, &mySQLError) {
            if mySQLError.Number == 1062 && strings.Contains(mySQLError.Message, "users_uc_email") {
                return ErrDuplicateEmail
            }
        }
        return err
    }

    return nil
}
  1. В основном приложении handlers.go отрабатывает запросы по маршрутам
func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
    var form userSignupForm

...

    err = app.users.Insert(form.Name, form.Email, form.Password)
    if err != nil {
        if errors.Is(err, models.ErrDuplicateEmail) {
            form.AddFieldError("email", "Email address is already in use")

            data := app.newTemplateData(r)
            data.Form = form
            app.render(w, http.StatusUnprocessableEntity, "signup.tmpl", data)
        } else {
            app.serverError(w, err)
        }

        return
    }
...

User login

Для начала обновим internal/validator/validator.go

package validator

...

// Add a new NonFieldErrors []string field to the struct, which we will use to 
// hold any validation errors which are not related to a specific form field.
type Validator struct {
    NonFieldErrors []string
    FieldErrors    map[string]string
}

// Update the Valid() method to also check that the NonFieldErrors slice is
// empty.
func (v *Validator) Valid() bool {
    return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0
}

// Create an AddNonFieldError() helper for adding error messages to the new
// NonFieldErrors slice.
func (v *Validator) AddNonFieldError(message string) {
    v.NonFieldErrors = append(v.NonFieldErrors, message)
}

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

Создадим шаблон для login (ui/html/pages/login.tmpl)

{{define "title"}}Login{{end}}

{{define "main"}}
<form action='/user/login' method='POST' novalidate>
    <!-- Notice that here we are looping over the NonFieldErrors and displaying
    them, if any exist -->
    {{range .Form.NonFieldErrors}}
        <div class='error'>{{.}}</div>
    {{end}}
    <div>
        <label>Email:</label>
        {{with .Form.FieldErrors.email}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='email' name='email' value='{{.Form.Email}}'>
    </div>
    <div>
        <label>Password:</label>
        {{with .Form.FieldErrors.password}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='password' name='password'>
    </div>
    <div>
        <input type='submit' value='Login'>
    </div>
</form>
{{end}}

Создадим структуру данных для login (handlers.go)

// Create a new userLoginForm struct.
type userLoginForm struct {
    Email               string `form:"email"`
    Password            string `form:"password"`
    validator.Validator `form:"-"`
}

// Update the handler so it displays the login page.
func (app *application) userLogin(w http.ResponseWriter, r *http.Request) {
    data := app.newTemplateData(r)
    data.Form = userLoginForm{}
    app.render(w, http.StatusOK, "login.tmpl", data)
}

Проверка пользователя

  1. Проверить есть такой email — если нет, то вернуть ошибку
  2. Сравнить хэш пароля — если не совпадает, вернуть ошибку
  3. Если все ОК — вернем ID пользователя

Внесем изменения в internal/models/users.go

func (m *UserModel) Authenticate(email, password string) (int, error) {
    // Retrieve the id and hashed password associated with the given email. If
    // no matching email exists we return the ErrInvalidCredentials error.
    var id int
    var hashedPassword []byte

    stmt := "SELECT id, hashed_password FROM users WHERE email = ?"

    err := m.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return 0, ErrInvalidCredentials
        } else {
            return 0, err
        }
    }

    // Check whether the hashed password and plain-text password provided match.
    // If they don't, we return the ErrInvalidCredentials error.
    err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password))
    if err != nil {
        if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
            return 0, ErrInvalidCredentials
        } else {
            return 0, err
        }
    }

    // Otherwise, the password is correct. Return the user ID.
    return id, nil
}
  1. После подтверждения пользователя создадим текущую сессию с пользователем. Внесем изменения в handlers.go
func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) {
    // Decode the form data into the userLoginForm struct.
    var form userLoginForm

    err := app.decodePostForm(r, &form)
    if err != nil {
        app.clientError(w, http.StatusBadRequest)
        return
    }

    form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
    form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
    form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")

    if !form.Valid() {
        data := app.newTemplateData(r)
        data.Form = form
        app.render(w, http.StatusUnprocessableEntity, "login.tmpl", data)
        return
    }

    // Check whether the credentials are valid. If they're not, add a generic
    // non-field error message and re-display the login page.
    id, err := app.users.Authenticate(form.Email, form.Password)
    if err != nil {
        if errors.Is(err, models.ErrInvalidCredentials) {
            form.AddNonFieldError("Email or password is incorrect")

            data := app.newTemplateData(r)
            data.Form = form
            app.render(w, http.StatusUnprocessableEntity, "login.tmpl", data)
        } else {
            app.serverError(w, err)
        }
        return
    }

    //после каждого изменения состояния пользователя login/logout изменяем хэш текущей сессии
    err = app.sessionManager.RenewToken(r.Context())
    if err != nil {
        app.serverError(w, err)
        return
    }

    // Add the ID of the current user to the session, so that they are now
    // 'logged in'.
    app.sessionManager.Put(r.Context(), "authenticatedUserID", id)

    // Redirect the user to the create snippet page.
    http.Redirect(w, r, "/snippet/create", http.StatusSeeOther)
}

Примечание: Метод SessionManager.RenewToken(), который мы используем в приведенном выше коде, изменит идентификатор текущей сессии пользователя, но сохранит все данные, связанные с сессией. Рекомендуется делать это перед входом в систему, чтобы снизить риск атаки фиксации сеанса. Для получения дополнительной информации и информации по этому вопросу, пожалуйста, ознакомьтесь со шпаргалкой по управлению сеансами OWASP.

User logout

  1. Нужно удалить authenticatedUserID из сессии и все.

Обновим файл handlers.go

func (app *application) userLogoutPost(w http.ResponseWriter, r *http.Request) {
    // обновим токен сессии
    err := app.sessionManager.RenewToken(r.Context())
    if err != nil {
        app.serverError(w, err)
        return
    }

    // удалим authenticatedUserID из сессии'logged out'.
    app.sessionManager.Remove(r.Context(), "authenticatedUserID")

    // Add a flash message to the session to confirm to the user that they've been
    // logged out.
    app.sessionManager.Put(r.Context(), "flash", "You've been logged out successfully!")

    // Redirect the user to the application home page.
    http.Redirect(w, r, "/", http.StatusSeeOther)
}

User authorization

В этом разделе включаются те полезности, которые доступны только аутентифицированным пользователям

  1. они могут создавать сниппеты
  2. в меню появляются новые пункты и убираются ненужные

Содержимое панели навигации меняется в зависимости от того, аутентифицирован ли пользователь (вошел в систему) или нет. В частности:

  • аутентифицированные пользователи должны видеть ссылки на «Главную», «Создать сниппет» и «Выйти».
  • Неаутентифицированные пользователи должны видеть ссылки на «Главную», «Регистрация» и «Вход».

Добавим вспомогательную функцию isAuthenticated() для возврата статуса аутентификации (helpers.go):

func (app *application) isAuthenticated(r *http.Request) bool {
    return app.sessionManager.Exists(r.Context(), "authenticatedUserID")
}

Добавим новое поле IsAuthenticated в нашу структуру templateData (templates.go):

type templateData struct {
    CurrentYear     int
    Snippet         *models.Snippet
    Snippets        []*models.Snippet
    Form            any
    Flash           string
    IsAuthenticated bool // Add an IsAuthenticated field to the templateData struct.
}

Обновить newTemplateData() в helpers.go, чтобы эта информация автоматически добавлялась в структуру templateData каждый раз, когда мы отрисовываем шаблон.

func (app *application) newTemplateData(r *http.Request) *templateData {
    return &templateData{
        CurrentYear:     time.Now().Year(),
        Flash:           app.sessionManager.PopString(r.Context(), "flash"),
        // Add the authentication status to the template data.
        IsAuthenticated: app.isAuthenticated(r),
    }
}

Проверяем поле {{if .IsAuthenticated}} в шаблонах nav.tmpl

{{define "nav"}}
<nav>
    <div>
        <a href='/'>Home</a>
        <!-- Toggle the link based on authentication status -->
        {{if .IsAuthenticated}}
            <a href='/snippet/create'>Create snippet</a>
        {{end}}
    </div>
    <div>
        <!-- Toggle the links based on authentication status -->
        {{if .IsAuthenticated}}
            <form action='/user/logout' method='POST'>
                <button>Logout</button>
            </form>
        {{else}}
            <a href='/user/signup'>Signup</a>
            <a href='/user/login'>Login</a>
        {{end}}
    </div>
</nav>
{{end}}

Ограничение доступа к страницам

Ограничение доступа

В настоящее время мы скрываем навигационную ссылку «Создать сниппет» для всех пользователей, которые не вошли в систему. Но пользователь, не прошедший аутентификацию, все равно может создать новый сниппет, посетив страницу https://localhost:4000/snippet/create напрямую.

Давайте исправим это, так что если неаутентифицированный пользователь попытается посетить какие-либо маршруты с URL-адресом /snippet/create, он будет перенаправлен на /user/login.

Будем использовать middleware

Внесем изменения в middleware.go

func (app *application) requireAuthentication(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Если пользователь не аутентифицирован, перенаправьте его на страницу входа и 
		// вернитесь из цепочки промежуточного ПО, чтобы последующие обработчики в 
		// цепочке не выполнялись.
        if !app.isAuthenticated(r) {
            http.Redirect(w, r, "/user/login", http.StatusSeeOther)
            return
        }

        //В противном случае установите заголовок "Cache-Control: no-store" так, чтобы страницы, 
		// требующие аутентификации, не сохранялись в кэше браузера пользователя (или 
		// другом промежуточном кэше).
        w.Header().Add("Cache-Control", "no-store")

        // And call the next handler in the chain.
        next.ServeHTTP(w, r)
    })
}

В нашем случае мы захотим защитить маршруты GET /snippet/create и POST /snippet/create.

И нет особого смысла выходить из системы пользователя, если он не вошел в систему, поэтому имеет смысл использовать его также и в маршруте POST /user/logout.

Чтобы помочь в этом, давайте разделим маршруты наших приложений на две «группы».

  • Первая группа будет содержать наши «незащищенные» маршруты и использовать нашу существующую динамическую цепочку промежуточного программного обеспечения.
  • Вторая группа будет содержать наши «защищенные» маршруты и будет использовать новую защищенную цепочку промежуточного ПО, состоящую из динамической цепочки промежуточного ПО и нашего нового промежуточного ПО requireAuthentication().

Корректируем файл routes.go

func (app *application) routes() http.Handler {
    router := httprouter.New()

    router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        app.notFound(w)
    })

    fileServer := http.FileServer(http.Dir("./ui/static/"))
    router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))

    // Unprotected application routes using the "dynamic" middleware chain.
    dynamic := alice.New(app.sessionManager.LoadAndSave)

    router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
    router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
    router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup))
    router.Handler(http.MethodPost, "/user/signup", dynamic.ThenFunc(app.userSignupPost))
    router.Handler(http.MethodGet, "/user/login", dynamic.ThenFunc(app.userLogin))
    router.Handler(http.MethodPost, "/user/login", dynamic.ThenFunc(app.userLoginPost))

    // Protected (authenticated-only) application routes, using a new "protected"
    // middleware chain which includes the requireAuthentication middleware.
    protected := dynamic.Append(app.requireAuthentication)

    router.Handler(http.MethodGet, "/snippet/create", protected.ThenFunc(app.snippetCreate))
    router.Handler(http.MethodPost, "/snippet/create", protected.ThenFunc(app.snippetCreatePost))
    router.Handler(http.MethodPost, "/user/logout", protected.ThenFunc(app.userLogoutPost))

    standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
    return standard.Then(router)
}

Без использования alice

Если вы не используете пакет justinas/alice — вы можете вручную обернуть обработчики следующим образом:

router.Handler(http.MethodPost, "/snippet/create", app.sessionManager.LoadAndSave(app.requireAuthentication(http.HandlerFunc(app.snippetCreate))))

CSRF protection

Что такое CSRF Protection

Что такое CSRF Protection?

CSRF (Cross-Site Request Forgery) — это тип атаки, при которой злоумышленник заставляет пользователя выполнить нежелательное действие на веб-сайте, на котором он аутентифицирован. Это может привести к несанкционированным действиям, таким как изменение паролей, отправка сообщений или выполнение финансовых транзакций.

Зачем нужна защита от CSRF?

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

Автор рекомендует использовать пакет justinas/nosurf для борьбы с CSRF

go get github.com/justinas/nosurf@v1

Добавление функции в middleware.go

import (
    "fmt"
    "net/http"

    "github.com/justinas/nosurf" // New import
)

...

// Create a NoSurf middleware function which uses a customized CSRF cookie with
// the Secure, Path and HttpOnly attributes set.
func noSurf(next http.Handler) http.Handler {
    csrfHandler := nosurf.New(next)
    csrfHandler.SetBaseCookie(http.Cookie{
        HttpOnly: true,
        Path:     "/",
        Secure:   true,
    })

    return csrfHandler
}

Защита формы logout

Одной из форм, которую нам необходимо защитить от CSRF-атак, является форма выхода из системы, которая включена в наш partial nav.tmpl и потенциально может появиться на любой странице нашего приложения. Таким образом, из-за этого нам необходимо использовать промежуточное ПО noSurf() на всех маршрутах нашего приложения (кроме /static/*filepath).

Исправим routes.go

dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf)

остальные маршруты получают настройку автоматически

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

Исправим templates.go

добавим поле с токеном в структуру templateData

package main

import (
    "html/template"
    "path/filepath"
    "time"

    "snippetbox.alexedwards.net/internal/models"
)

type templateData struct {
    CurrentYear     int
    Snippet         *models.Snippet
    Snippets        []*models.Snippet
    Form            any
    Flash           string
    IsAuthenticated bool
    CSRFToken       string // Add a CSRFToken field.
}
Исправим helpers.go

внесем изменения в помощник для всех форм

func (app *application) newTemplateData(r *http.Request) *templateData {
    return &templateData{
        CurrentYear:     time.Now().Year(),
        Flash:           app.sessionManager.PopString(r.Context(), "flash"),
        IsAuthenticated: app.isAuthenticated(r),
        CSRFToken:       nosurf.Token(r), // Add the CSRF token.
    }
}

Исправим все шаблоны и внесем в них скрытые поля

<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>

Using request context

Мы могли бы сделать эту проверку более надежной, запросив таблицу базы данных наших пользователей, чтобы убедиться, что значение “authenticatedUserID” является реальным, действительным значением (т.е. мы не удаляли учетную запись пользователя с момента его последнего входа в систему).

Поэтому сейчас будем изучать контекст запроса.

Как работает контекст запроса

Синтаксис контекста запроса

Основной код для добавления информации в контекст запроса выглядит следующим образом:

// Где r — это *http.Request...
ctx := r.Context()
ctx = context.WithValue(ctx, "isAuthenticated", true)
r = r.WithContext(ctx)

Давайте разберем это построчно.

  1. Сначала мы используем метод r.Context(), чтобы получить существующий контекст из запроса и присваиваем его переменной ctx.
  2. Затем мы используем метод context.WithValue(), чтобы создать новую копию существующего контекста, содержащую ключ “isAuthenticated” и значение true.
  3. Наконец, мы используем метод r.WithContext(), чтобы создать копию запроса с нашим новым контекстом.

Также стоит отметить, что для ясности я сделал этот фрагмент кода немного более многословным, чем это необходимо. Обычно его пишут так:

ctx = context.WithValue(r.Context(), "isAuthenticated", true)
r = r.WithContext(ctx)

Итак, вот как вы добавляете данные в контекст запроса. Но как их снова извлечь?

Важно объяснить, что за кулисами значения контекста запроса хранятся с типом any. Это означает, что после извлечения их из контекста вам нужно будет привести их к исходному типу перед использованием.

Чтобы извлечь значение, нам нужно использовать метод r.Context().Value(), вот так:

isAuthenticated, ok := r.Context().Value("isAuthenticated").(bool)
if !ok {
    return errors.New("не удалось преобразовать значение в bool")
}

В этом коде мы пытаемся получить значение, связанное с ключом “isAuthenticated”, и одновременно проверяем, удалось ли нам привести его к типу bool. Если преобразование не удалось, мы возвращаем ошибку.

Избежание коллизий ключей

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

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

// Объявляем пользовательский тип "contextKey" для ваших ключей контекста.
type contextKey string

// Создаем константу с типом contextKey, которую мы можем использовать.
const isAuthenticatedContextKey = contextKey("isAuthenticated")

...

// Устанавливаем значение в контексте запроса, используя нашу константу isAuthenticatedContextKey в качестве ключа.
ctx := r.Context()
ctx = context.WithValue(ctx, isAuthenticatedContextKey, true)
r = r.WithContext(ctx)

...

// Извлекаем значение из контекста запроса, используя нашу константу в качестве ключа.
isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool)
if !ok {
    return errors.New("не удалось преобразовать значение в bool")
}

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

Request context для authentication/authorization

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

Мы начнем с того, что вернемся к файлу internal/models/users.go и обновим метод UserModel.Exists(), чтобы он возвращал true, если пользователь с определенным ID существует в нашей таблице пользователей, и false в противном случае. Вот так:

Файл: internal/models/users.go

package models

...

func (m *UserModel) Exists(id int) (bool, error) {
    var exists bool

    stmt := "SELECT EXISTS(SELECT true FROM users WHERE id = ?)"

    err := m.DB.QueryRow(stmt, id).Scan(&exists)
    return exists, err
}

Затем давайте создадим новый файл cmd/web/context.go. В этом файле мы определим пользовательский тип contextKey и переменную isAuthenticatedContextKey, чтобы у нас был уникальный ключ, который мы можем использовать для хранения и извлечения статуса аутентификации из контекста запроса (без риска коллизий имен).

package main

type contextKey string

const isAuthenticatedContextKey = contextKey("isAuthenticated")

Давайте создадим новый метод промежуточного ПО authenticate(), который:

  1. Извлекает ID пользователя из данных сессии.
  2. Проверяет базу данных, чтобы узнать, соответствует ли ID действительному пользователю, используя метод UserModel.Exists().
  3. Обновляет контекст запроса, добавляя ключ isAuthenticatedContextKey со значением true.

Вот код:

Файл: cmd/web/middleware.go

package main

import (
    "context" // Новый импорт
    "fmt"
    "net/http"

    "github.com/justinas/nosurf"
)

...

func (app *application) authenticate(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Извлекаем значение authenticatedUserID из сессии, используя метод
        // GetInt(). Это вернет нулевое значение для int (0), если в сессии
        // нет значения "authenticatedUserID" — в этом случае мы
        // вызываем следующий обработчик в цепочке как обычно и прерываем обработку.
        id := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")
        if id == 0 {
            next.ServeHTTP(w, r)
            return
        }

        // В противном случае мы проверяем, существует ли пользователь с этим ID
        // в нашей базе данных.
        exists, err := app.users.Exists(id)
        if err != nil {
            app.serverError(w, err)
            return
        }

        // Если найден соответствующий пользователь, мы знаем, что запрос
        // поступает от аутентифицированного пользователя, который существует
        // в нашей базе данных. Мы создаем новую копию запроса (с
        // значением isAuthenticatedContextKey равным true в контексте запроса)
        // и присваиваем ее переменной r.
        if exists {
            ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true)
            r = r.WithContext(ctx)
        }

        // Вызываем следующий обработчик в цепочке.
        next.ServeHTTP(w, r)
    })
}

Важно подчеркнуть следующее различие:

  • Когда у нас нет действительного аутентифицированного пользователя, мы передаем оригинальный и неизмененный *http.Request следующему обработчику в цепочке.
  • Когда у нас есть действительный аутентифицированный пользователь, мы создаем копию запроса с ключом isAuthenticatedContextKey и значением true, хранящимся в контексте запроса. Затем мы передаем эту копию *http.Request следующему обработчику в цепочке.

Хорошо, давайте обновим файл cmd/web/routes.go, чтобы включить промежуточное ПО authenticate() в нашу динамическую цепочку промежуточного ПО.

package main

...

func (app *application) routes() http.Handler {
    router := httprouter.New()

    router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        app.notFound(w)
    })

    fileServer := http.FileServer(http.Dir("./ui/static/"))
    router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))
    
    // Add the authenticate() middleware to the chain.
    dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)

    router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
    router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
    router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup))
    router.Handler(http.MethodPost, "/user/signup", dynamic.ThenFunc(app.userSignupPost))
    router.Handler(http.MethodGet, "/user/login", dynamic.ThenFunc(app.userLogin))
    router.Handler(http.MethodPost, "/user/login", dynamic.ThenFunc(app.userLoginPost))

    protected := dynamic.Append(app.requireAuthentication)

    router.Handler(http.MethodGet, "/snippet/create", protected.ThenFunc(app.snippetCreate))
    router.Handler(http.MethodPost, "/snippet/create", protected.ThenFunc(app.snippetCreatePost))
    router.Handler(http.MethodPost, "/user/logout", protected.ThenFunc(app.userLogoutPost))

    standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
    return standard.Then(router)
}

Последнее, что нам нужно сделать, — это обновить наш вспомогательный метод isAuthenticated(), чтобы вместо проверки данных сессии он теперь проверял контекст запроса, чтобы определить, аутентифицирован ли пользователь или нет.

Мы можем сделать это следующим образом:

Файл: cmd/web/helpers.go

package main

...

func (app *application) isAuthenticated(r *http.Request) bool {
    isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool)
    if !ok {
        return false
    }

    return isAuthenticated
}

Важно отметить, что если в контексте запроса нет значения с ключом isAuthenticatedContextKey или если подлежащие значение не является типом bool, то это приведение типов завершится неудачей. В этом случае мы принимаем «безопасный» запасной вариант и возвращаем false (т.е. предполагаем, что пользователь не аутентифицирован).

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

Дополнительная информация

Неправильное использование контекста запроса

Важно подчеркнуть, что контекст запроса следует использовать только для хранения информации, относящейся к времени жизни конкретного запроса. Документация Go для context.Context предупреждает:

Используйте значения контекста только для данных, ограниченных запросом, которые передаются между процессами и API.

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

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