Предисловие: статья является продолжением цикла «Разбираем net/http на практике». Здесь мы учимся создавать веб-приложения на чистом Go без сторонних веб-фреймворков и библиотек, совместно реализуя сервис мёртвой почты DeadDrop. Для понимания материала предлагаю ознакомиться с предыдущими статьями:

Первая статья цикла

Предыдущая статья цикла

Введение

Мы проделали огромный путь. В «Разбираем net/http на практике. Часть 2.1» мы заложили твёрдый фундамент – научились принимать и обрабатывать POST-запросы, загружать файлы, защищаться от DoS-атак и реализовали наше первое in-memory хранилище для пользовательских секретов. В части 2.2 мы превратили хаотичный код в архитектурно целостное решение – мы внедрили чистую архитектуру, вынесли бизнес-логику в отдельный слой сервиса – use case и научились хэшированию с bcrypt.

Но всё же, у сервиса остался один очень весомый недостаток – пользователи всё ещё не могут просматривать содержимое ячеек. Мы не могли просто, перейдя по адресу /secret/{id}, получить содержимое секрета и банально сохранить файл. Взаимодействие между пользователями и сайтом до сих пор было неудобным и небезопасным.

В этой, заключительной подстатье мы сделаем из сервиса DeadDrop полноценое веб-приложение с полным функционалом, который запланировали реализовать в первой части цикла. Сегодня нас ждёт:

  • Динамическая маршрутизация

  • Аутентификация через cookie

  • Скачивание файлов с проверкой прав

  • Полноценный цикл работы с секретом

Сегодня DeadDrop станет не просто учебным проектом, а полноценным прототипом «мёртвой почты», готовым к дальнейшему совершенствованию

Весь код проекта доступен в GitHub репозитории. Буду рад ответить на любые вопросы в комментариях к статье!

Динамическая маршрутизация (/secret/{id})

Сейчас мы успешно создаём секреты, но они не могут быть просмотрены пользователями. Давайте дадим им возможность просматривать определенные ячейки нашего DeadDrop по уникальным ссылкам вида /secret/{id}. Что это такое?

- Этот новый для нас вид маршрутов называется маршрут с параметром или динамический маршрут. В современной веб-разработке это незаменимая практика.

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

habr.com

/ru/articles

/981356/

Статическая часть – адрес сайта

Тоже статическая часть, которая не изменяется. Она указывает на раздел «статья» в русскоязычном сегменте

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

В чём сложность и почему мы уделяем этому внимание? Дело в том, что стандартный функционал net/http не предусматривает динамических маршрутов. Адреса для обработчиков могут быть лишь статическими – такими, какие мы использовали ранее.

UPD: Начиная с Go 1.22 в net/http появилась возможность работы с динамическими маршрутами, делается это так: mux.HandleFunc(“GET /post/{id}, handler”), параметр в таком случае достается через r.PathValue(“id”). Это удобно и в продакшене строит пользоваться именно таким решением, но так как мы пытаемся по винтикам разобрать net/http в дальнейшем мы напишем свою функцию-обработчик таких маршрутов

Спроецируем схему адресов на DeadDrop-сервис:

localhost:8080

/secret/

abc123xyz

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

Адрес нашего сайта – доменное имя

Указываем, что обращаемся именно к секретной ячейке

Уникальный идентификатор ячейки, в данном случае лишь для примера abc123xyz

Значит, нам нужно «научить» наш обработчик распознавать конкретный параметр такого доменного имени.

Варианты решения

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

  1. Использование сторонних роутеров – вокруг net/http есть достаточно популярных готовых обёрток, к примеру, упомянутые в первой статье цикла chi, gorilla/mux или fiber. Внутри них есть готовые парсеры динамических маршрутов.

  2. Реализовать собственный разборщик пути – мы можем зарегистрировать свой обработчик на адрес /secret/ (обратите внимание на важность / в конце адреса обработчика, в первой части цикла мы обсуждали, для чего он нужен https://habr.com/ru/articles/981356/), внутри такого обработчика мы можем доставать адрес статьи вручную

  3. Добавить параметры в querry-строку – например, /secret?=abc123xyz. Это работает, но не удовлетворяет принципам REST и не так красиво

Я выбираю второй вариант – вот почему. Помните, что мы делаем? – Мы реализуем на чистом net/http. Мы здесь для того, чтобы разобраться – «а как оно работает под капотом?». Разбор пути вручную – отличная практика, которая пригодится, если вы когда-нибудь решите сделать собственный микрофреймворк или работать с API-шлюзами.

Давайте сделаем всё по шагам.

Шаг 1: Регистрация обработчика на /secret/

Напомню принципиальную разницу между маршрутизацией «/secret» и «/secret/» - при наличии в конце пути / он будет обрабатывать все дочерние маршруты, начинающиеся с этого префикса. Такое поведение – то что нам нужно! Внутри обработчика на этот «обобщающий» маршрут мы будем вытягивать из адреса идущую после /secret/ часть нашим разборщиком.

Объявим его в нашем internal/delivery/http/router.go

mux.Handle("/secret/", secretHandler)

Дополнительно поясню: теперь все адреса по типу /secret/abc123 , /secret/zxc435 и даже /secret/ попадут сюда.

Наша задача – проверить корректность переданного id и в соответствии с этим передавать пользователю данные конкретного секрета.

Шаг 2: Создаём утилиту для проверки извлечения id из URL

Для того чтобы достать из ссылки id нам потребуется:

  1. Получить полный URL по которому перешел пользователь (сделать это можно через r.URL.path)

  2. Обрезать статическую часть «/secret/»

  3. Оставшуюся часть будем считать идентификатором

Напишите такую утилиту, для того чтобы обрезать префикс у ссылок с id. Попробуйте написать функцию со следующей сигнатурой в файле internal/lib/url/url.go:

func extractIDFromPath(path string) (string, bool) {}

Готовая реализация:

func extractIDFromPath(path string) (string, bool)
funcxtractIDFromPath(path string) (string, bool) {
    const prefix = "/secret/"

    if !strings.HasPrefix(path, prefix) {
        return "", false
    }

    id := strings.TrimPrefix(path, prefix)
    if id == "" || strings.Contains(id, "/") {
        return "", false
    }

    return id, true
}

Обратите внимание на проверку символа в конце функции. Наличие в id «/» либо пустых id – недопустимо. Это базовая проверка, но на текущем этапе разработки она необходима – не забывайте о безопасности.

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

Шаг 3: Реализуем обработчик SecretHandler

Мы уже набили шишки на нашем CreateHandler и знаем, основная логика будет реализована в соответствующем use case, и чтобы не переписывать наш обработчик в последствии, сразу организуем его с опорой на последующий DI (dependency injection – внедрение зависимости). Это значит, реализуем обработчик в формате структуры, которую пока что оставим пустой. Напомню, функционал обработчика в таком случае должен находится в методе структуры – ServeHTTP (вспомните раздел с рефакторингом CreateHandler).

Пока что – попробуйте сделать заглушку - пустую структуру SecretHandler (без полей). Создайте конструктор таких структур и реализуйте для неё метод ServeHTTP в котором новой утилитой извлекайте id и отдавайте пользователю либо ошибку, либо методом w.Write() текст:

 «Запрошен секрет с ID: <ID_запрошеного_секрета>».

Реализацию будем писать в слое доставки: delivery/http/handler/secret.go

Готовая реализация в нашем случае выглядит так:

type SecretHandler struct
type SecretHandler struct {    // заглушка для последующего DI
}
func NewSecretHandler() *SecretHandler {
return &SecretHandler{}
}
func (h *SecretHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
id, ok := url.ExtractIDFromPath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
// временное решение
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte("Запрошен секрет с ID: " + id))

}

Шаг 4: Тестируем текущий результат.

Теперь мы можем в router.go создать экземпляр структуры SecretHandler и передать его в обработчик. Задумайтесь: если бы вы его тестировали, какие бы маршруты для этого выбрали? Посмотрите:

Test Case

Ожидание

Результат

/secret/12345/

Page not found (страница не найдена)

Page not found

/secret/

Page not found (страница не найдена)

Page not found

/secret/axQ/233

Page not found (страница не найдена)

Page not found

/secret/ad31H234

Запрошен секрет с ID: ad31H234

Запрошен секрет с ID: ad31H234э

/secret/dsa123adxf

Запрошен секрет с ID: dsa123adxf

Запрошен секрет с ID: dsa123adxf

Обратите внимание на 3 первых случая: их можно назвать краевыми. В тестах следует обращать особое внимание на такие ситуации, так как в таких ситуациях шанс на непредвиденное разработчиком поведение сайта максимален. Благодаря тому, что мы предусмотрели соответствующую проверку в разборщике путей заранее – тесты прошли успешно. В последствии мы подробнее поговорим о тестировании.

Шаг 5: Создаём новый Use Case для обработчика

Пример Create use case дал нам хорошее понимание того, как правильно организовать use case’ы в Go. Нам нужно определить с тем, что мы будем передавать в сценарий использования, что будем из него получать. Дальнейшую логику будем строить от этих структур.

Давайте сформируем прототип, что должно быть перед пользователем при переходе по localhost:8080/secret/{id}:

Прототип взаимодействия пользователя с сайтом
Прототип взаимодействия пользователя с сайтом

После запроса страницы пользователя должно перебрасывать на страницу, которая будет требовать пароль. Верно введенный пароль будет сигналом к отображению содержимого ячейки. Неверно введённый пароль – сигналом к отображению уведомления о неверно введенном пароле.

Давайте сформируем структуру GetSecretRequest и GetSecretResponse по аналогии с соответствующими структурами use case’а создания ячейки.

Эта логика, также как и логика создания ячейки касается наших секретов и так как они функционально предназначены для одного и того же – давайте продолжим реализацию usecase в уже существующем internal/usecase/secret.go – в котором мы писали CreateSecret сценарий

// GetSecretRequest содержит данные для запроса секрета
type GetSecretRequest struct {
    ID       string
    Password string
}

// GetSecretResponse содержит данные секрета для отображения
type GetSecretResponse struct {
    ID        string
    Message   string
    FileName  string
    FileExt   string
    HasFile   bool
    ExpiresAt time.Time
}

Теперь реализуем саму бизнес-логику. При реализации помним – на входе имеем указатель на GetSecretRequest – на выходе возвращаем указатель на GetSecretResponse и, опционально, ошибку.

Бизнес логика для данного сценария использования должна быть такой:

  1. Получаем секрет из хранилища

  2. Проверяем - не истек ли его срок действия (подсказка: воспользуйтесь методом time.After()) и в случае истечения – удаляем его из хранилища

  3. Проверяем пароль  (воспользуйтесь ранее реализованной утилитой CheckPasswordHash)

  4. Возвращаем данные в структуре CreateSecretRequest

  5. Не забывайте в случае ошибок возвращать их для последующего оборачивания в обработчике.

Помните – новый сценарий относится к тому же SecretUseCase – он содержит внутри себя хранилище, с которым работают как Get сценарий использования, так и Create. Это значит, что его сигнатура должна быть такой:

func (uc SecretUseCase) GetSecret(req GetSecretRequest) (*GetSecretResponse, error){}

Реализация:

func (uc *SecretUseCase) GetSecret(req *GetSecretRequest) (*GetSecretResponse, error)
func (uc *SecretUseCase) GetSecret(req *GetSecretRequest) (*GetSecretResponse, error) {
    secret, err := uc.storage.Get(req.ID)
    if err != nil {
        return nil, fmt.Errorf("failed to get secret: %w", err)
    }
    if secret == nil {
        return nil, errors.New("secret not found")
    }


    if time.Now().After(secret.ExpiresAt) {
        uc.storage.Delete(req.ID)
        return nil, errors.New("secret has expired")
    }

    if !hash.CheckPasswordHash(req.Password, secret.Password) {
        return nil, errors.New("invalid password")
    }

    return &GetSecretResponse{
        ID:        secret.ID,
        Message:   secret.Message,
        FileName:  secret.FileName,
        FileExt:   secret.FileExt,
        HasFile:   len(secret.FileData) > 0,
        ExpiresAt: secret.ExpiresAt,
    }, nil
}

Шаг 6: Реализуем SecretHandler полностью

Мы уже реализовали сценарий использования для Get-запроса к секрету. Теперь нужно внедрить зависимость от него в наш обработчик. Сделайте это подобно тому, как мы делали при создании ячейки. Важное различие между ними, теперь у нас появилась некоторая вариативность: что нужно показать пользователя в зависимости от его действия:

  1. Только зашел на страницу – отобразить шаблон layout и шаблон ввода пароля

  2. Ввел правильный пароль – отобразить шаблон с данными секрета

  3. Ввел неправильный пароль – отобразить уведомление о неверно введённом пароле

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

Всё просто. В случае простого перехода по ссылке /secret/<ID> на сервер уходит Get-запрос, а при отправке пароля приходит POST-запрос (так как ведь мы передаём данные - пароль) на тот же самый адрес.

Это классическая ситуация для веб-приложений и нам важно научиться обрабатывать такие случаи.

Но перед реализацией для понимания давайте реализуем все необходимые шаблоны:

  1. Шаблон secret_password.html – страница с формой для ввода пароля.

    Реализация:

    template define "content"
    {{ define "content" }}
    <div class="form-container">
        <h1>Введите пароль</h1>
        <p>Для доступа к ячейке <strong>{{ .ID }}</strong> введите пароль:</p>
        <form action="/secret/{{ .ID }}" method="POST">
            <div class="form-group">
                <label for="password">Пароль:</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit">Получить секрет</button>
        </form>
        <p style="margin-top: 20px;">
            <a href="/">← На главную</a>
        </p>
    </div>
    {{ end }}
    
  2. Шаблон secret_content.html – страница с содержимым ячейки (сообщением и файлом)

    Реализация

    template define "content"
    {{ define "content" }}
    <div class="form-container">
        <h1>Содержимое ячейки</h1>
        <p><strong>ID:</strong> {{ .ID }}</p>
        <p><strong>Сообщение:</strong></p>
        <div class="message-box">
            {{ .Message }}
        </div>
        {{ if .HasFile }}
        <p><strong>Файл:</strong> {{ .FileName }}</p>
        <p><a href="/secret/{{ .ID }}/download" class="button">Скачать файл</a></p>
        {{ end }}
        <p><small>* Ячейка исчезнет {{ .ExpiresAt }}</small></p>
        <p style="margin-top: 20px;">
            <a href="/">← На главную</a>
        </p>
    </div>
    {{ end }}
    

Ничего нового в создании шаблонов для нас не появилось – можете взять готовую реализацию выше или сделать сами. Не забудьте объявить их в templates.go – также как мы делали с result.html

Не помните как объявить? - Обратитесь к сворачиваемому блоку ниже

templates.go - глобальные переменные
var SecretPassTemplate = template.Must(    template.ParseFS(templateFiles, "templates/layout.html", "templates/secret_password.html"),
)
var SecretContentTemplate = template.Must(
template.ParseFS(templateFiles, "templates/layout.html", "templates/secret_content.html"),
)

Добавьте глобальные переменные в общий список в templates.go новые глобальные переменные

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

Блок-схема SecretHandler
Блок-схема SecretHandler

Я уже говорил, блок-схемы удобны. Они позволяют быстро продумывать и проектировать взаимодействия между разными объектами, не привязываясь к конкретной реализации – не брезгуйте тратить на них время.

В своей реализации старайтесь разделить логику. Задача ServeHTTP должна быть фактически диспетчерской – он определяет, как к нему обратились и в соответствии с этим вызывает ту или иную дочернюю функцию обработчик. Сейчас, ваших знаний достаточно для внедрения зависимости от GetSecretUseCase. Реализуйте её самостоятельно и обратитесь к моей реализации – сверьтесь, всё ли вами было предусмотрено?

Результат внедрения зависимости:

SecretHandler - обработчик HTTP-запросов для доступа к секретам
type SecretHandler struct {
    secretUseCase *usecase.SecretUseCase
}

func NewSecretHandler(uc *usecase.SecretUseCase) *SecretHandler {
    return &SecretHandler{
        secretUseCase: uc,
    }
}

func (h *SecretHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    id, ok := url.ExtractIDFromPath(r.URL.Path)
    if !ok {
        http.NotFound(w, r)
        return
    }

    switch r.Method {
    case http.MethodGet:
        h.showPasswordForm(w, r, id)
    case http.MethodPost:
        h.handleSecretAccess(w, r, id)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

func (h *SecretHandler) showPasswordForm(w http.ResponseWriter, r *http.Request, id string) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")

    data := struct {
        ID    string
        Title string
    }{
        ID:    id,
        Title: "Введите пароль",
    }

    if err := assets.SecretPassTemplate.ExecuteTemplate(w, "layout", data); err != nil {
        log.Printf("[ERROR] Failed to render password template: %v", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
    }
}

func (h *SecretHandler) handleSecretAccess(w http.ResponseWriter, r *http.Request, id string) {
    password := r.FormValue("password")
    if password == "" {
        http.Error(w, "Password is required", http.StatusBadRequest)
        return
    }

    req := &usecase.GetSecretRequest{
        ID:       id,
        Password: password,
    }

    resp, err := h.secretUseCase.GetSecret(req)
    if err != nil {
        log.Printf("[ERROR] Failed to get secret %s: %v", id, err)

        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        data := struct {
            ID    string
            Title string
            Error string
        }{
            ID:    id,
            Title: "Содержимое ячейки",
            Error: err.Error(),
        }
        assets.SecretPassTemplate.ExecuteTemplate(w, "layout", data)
        return
    }

    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    contentData := struct {
        ID        string
        Message   string
        FileName  string
        HasFile   bool
        ExpiresAt string
        Title     string
    }{
        ID:        resp.ID,
        Message:   resp.Message,
        FileName:  resp.FileName,
        HasFile:   resp.HasFile,
        ExpiresAt: resp.ExpiresAt.Format(time.RFC1123),
        Title:     "DeadDrop",
    }

    if err := assets.SecretContentTemplate.ExecuteTemplate(w, "layout", contentData); err != nil {
        log.Printf("[ERROR] Failed to render content template: %v", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
    }
}

По желанию, обновляем styles.css в директории assets – подгоняем к единому стилю сервиса наши шаблоны (либо достаём готовый styles.css из GitHub-страницы проекта: https://github.com/Meedoeed/DeadDrop). На выходе имеем:

Схема взаимодействия поользователя с текущей версией сайта
Схема взаимодействия поользователя с текущей версией сайта

Отличная работа! Мы создали полноценный механизм просмотра секретов с проверкой пароля. Но, как вы заметили, осталась важная деталь — скачивание файлов. Сейчас при нажатии на кнопку "Скачать файл" пользователь попадает на несуществующий маршрут /secret/{id}/download. Давайте это исправим!

Скачивание файлов и управление доступом

Проблема: как сохранять права доступа после ввода пароля?

Представьте – пользователь ввёл пароль – увидел, что к его ячейке прикреплен файл. Он нажимает кнопку «Скачать файл», и Get-запросом попадает на адрес «/secret/<ID>/download». Сервер не знает, что пользователь только что успешно прошёл проверку и что теперь делать? Снова спрашивать пароль? – Это было бы очень неудобно.

К тому же, если никакая логика и вовсе не реализована – человек может просто перейти по адресу любой ячейки добавив к адресу /download и получить файл любого секрета – даже если он и не знает от него пароль.

Сейчас нам нужен механизм, который «запоминает», что пользователь уже аутентифицировался к конкретной ячейке. В веб-разработке в таких целях традиционно используют куки (cookies)

Что такое cookie?

Cookie – небольшие фрагменты данных, которые сервер отправляет браузеру, браузер сохраняет cookie до тех пор, пока их срок не истечёт (тот же time to live – которым мы пользуемся в наших секретах). При каждом последующем запросе к серверу браузер будет дополнительно сообщать ему о наличии в нём заложенных cookie.

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

Как должны работать cookie в нашем сервисе
Как должны работать cookie в нашем сервисе

В нашем случае, поступим следующим образом:

  1. Пользователь успешно вводит пароль

  2. Сервер создаёт специальный cookie, который подтверждает факт аутентификации в данной ячейке.

  3. При запросе на скачивание (/secret//download) проверяем наличие и валидность cookie

  4. Если cookie есть – отдаём файл, если нет – спрашиваем пароль.

Проблема: Мы не можем просто положить в куки строчку: «authorized: true» - так любой злоумышленник сможет её подделать. Нам нужен механизм, который будет гарантировать, что куки создан именно нашим сервисом. Как это делать? Поговорим в следующем разделе.

Подписанные cookie

Как говорилось выше: мы не можем по соображениям безопасности использовать в куки простую строку вида «authorized: true». Нам нужно как-то «подписать» эти куки – дать гарантию того, что они созданы именно нашим сервисом. В коммерческой разработке для этого часто используют готовые пакеты и решения, например gorilla/securecookie. Но мы хотим понять, как оно работает «изнутри» - поэтому реализуем простую подпись на чистом net/http

Идея проста:

  1. Берём ID секрета (например abc123)

  2. Добавляем к нему секретный ключ, известный только серверу

  3. Вычисляем подпись

  4. Сохраняем куки как пару «значение:подпись»

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

Как сервер проверяет подписанные куки
Как сервер проверяет подписанные куки

Давайте реализуем алгоритм пошагово реализуем механизм выдачи подписанных cookie пользователям!

Шаг 1: Создаём утилиту для подписи cookie

Создаём файл internal/lib/auth/cookie.go

В нём нужно реализовать сразу ряд функций:

  1. Функция создания подписи

  2. Функция проверки подписи

  3. Функция создания cookie для доступа к секрету

  4. Функция проверки валидности cookie

  5. Функция для удаления куки из браузера пользователя

Для вашего удобства – подробнее поговорим о них:

Функция createSignature(data string) string

Это сердце нашего механизма создания подписи. Функция принимает строку и возаращает криптографическую подпись. Как она работает внутри?

Мы используем HMAC (Hash-based Message Authentication Code) – стандартный алгоритм, который комбинирует данные с секретным ключом и пропускает через хэш-функцию, описанный пошагово выше.

Почему именно HMAC, а не просто хэш по данным? Это повышает надёжность. Если бы мы, например, использовали хеш SHA-256 – злоумышленник мог бы изменить данные – пересчитать хэш и подставить его – мы не заметили бы подмены. А в HMAC злоумышленник не знает секретный ключ – хранимый на стороне сервиса. Поэтому он никак не может воссоздать правильную подпись.

В Go для HMAC есть стандартный пакет crypto/hmac. Мы вызываем метод hmac.New(<хеш-функция>, <секретный ключ>), передавая туда алгоритм хеширования и наш секретный ключ. На выходе имеем объект hmac.Hash – воспринимать это стоит как коробку внутри которой находится механизм для создания подписей. Прогнать данные через наш механизм можно вызвав метод этого объекта: Write(<данные>). Когда мы его вызываем мы как бы забрасываем внутрь коробки наши данные (например ID), а они уже внутри перемешиваются с ключом и хешируются.

Обратиться к итоговому хэшу можно с помощью метода объекта hmac.Hash – Sum(nil) он принимает на входе срез байт и добавляет к нему то, что лежит на данный момент в хэшере.

Не забываем применить RawUrlEncoding пакета base64, чтобы не было проблем с URL-ссылками и вернуть хэш.

И наконец, возвращаем из функции строку – сгенерированную подпись.

Итоговая реализация метода createSignature довольно небольшая:

var (
	secretKey   = []byte("A8zCb7GtY$!") // Хранить так нельзя! Мы исправим это в будущем!
)

func createSignature(data string) string {

	h := hmac.New(sha256.New, secretKey)
	h.Write([]byte(data))
	
	return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}

Функция validateSignature (data, signature string) bool

Функция проверяет – соответствует ли подпись данным. Алгоритм прост: берем данные – заново вычисляем по ним подпись и сравниваем с той подписью, которую прислал клиент. Если они совпадают – данные подлинны.

Для проверки используйте метод пакета crypto/hmac – hmac.Equal – это важно с точки зрения безопасности.

Реализация validateSignature

func validateSignature(data, signature string) bool {
	expectedSignature := createSignature(data)
	return hmac.Equal([]byte(signature), []byte(expectedSignature))
}

Функция SetAuthCookie (w http.ResponseWriter, secretID string)

Здесь мы создаём объект Cookie – подписываем её и отправляем клиенту.

Пошагово разберём прооцесс:

Шаг 1: Подготовка данных для подписи

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

signature := createSignature(secretID)

Шаг 2: Создаём значение cookie

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

value := secretID + ":" + signature

Шаг 3: Создаём объект http.Cookie

Пакет net/http предоставляет очень удобную структуру для работы с куками. Взгляните:

cookie := &http.Cookie{
    Name:     "secret_auth_" + secretID,  
    Value:    value,
    Path:     "/secret/",       
    HttpOnly: true, 
    SameSite: http.SameSiteStrictMode,   
    MaxAge:   3600,                   
}

Для понимания предлагаю рассмотреть таблицу:

Поле

Значение

Зачем нужно

Что будет если убрать?

Name

"secret_auth_" + ID

Идентифицирует куку (для какого секрета)

Куки разных секретов будут мешать друг другу

Value

ID:подпись

Хранит данные + защиту от подделки

Злоумышленник сможет подменить ID

Path

/secret/

Кука работает только на страницах секретов

Будет отправляться на ВСЕ запросы (даже на главную)

HttpOnly

true

Запрещает JavaScript читать куку

XSS-атака украдет куку через JavaScript

SameSite

StrictMode

Запрещает чужим сайтам использовать куку

CSRF-атака: другой сайт сможет скачать файлы от имени пользователя

MaxAge

3600 (1 час)

Время жизни куки

Без MaxAge кука живет вечно или до закрытия браузера

Шаг 4: Отправка клиенту

Отправлять куки также очень удобно – достаточно воспользоваться методом net/http http.SetCookie(w, cookie), где w – ResponseWriter нашего обработчика, а cookie – созданный на шаге 3 экземпляр http.Cookie

Полная реализация метода SetAuthCookie выглядит так:

Функция CheckAuthCookie(r http.Request, secretID string) bool

Здесь мы проверяем, есть ли у пользователя валидная кука для данной ячейки DeadDrop

Реализация:

func CheckAuthCookie(r *http.Request, secretID string) bool {
    // Получаем куку по имени
    cookie, err := r.Cookie("secret_auth_" + secretID)
    if err != nil {
        return false // Куки нет или она повреждена
    }
    
    parts := strings.Split(cookie.Value, ":")
    if len(parts) != 2 {
        return false // Неправильный формат
    }
    
    cookieID := parts[0]
    signature := parts[1]
    
    if cookieID != secretID {
        return false
    }
    
    // Проверяем подпись
    return validateSignature(secretID, signature)
}

Функция ClearAuthCookie(w http.ResponseWriter, secretID string)

Когда секрет удалился или пользователь выходит – нужно удалить куку. Делается это просто – создаётся экземпляр http.Cookie с тем же названием, что существующая кука без значения и с отрицательным ttl. Такая кука отправляется клиенту через http.SetCookie – как мы делали это раньше

Реализация ClearAuthCookie:

func ClearAuthCookie(w http.ResponseWriter, secretID string) {
    cookie := &http.Cookie{
        Name:     "secret_auth_" + secretID,
        Value:    "",
        Path:     "/secret/",
        HttpOnly: true,
        MaxAge:   -1, 
    }
    
    http.SetCookie(w, cookie)
}

ВАЖНО! В текущей реализации мы оставили секретный ключ сервера в глобальной переменной прямо в утилите. Так делать категорически нельзя в реальных проектах. Ключ для безопасности хранят в:
1)     Переменных окружениях
2)     В защищённых конфигурационных файлах
3)     В специальных секретных хранилищах
В последующих статьях мы ещё обязательно исправим этот недостаток и научимся правильно работать с конфигурацией нашего проекта!

Шаг 2: Интеграция с SecretHandler

Теперь добавляем в SecretHandler логику работы с куками. Когда пользователь успешно вводит пароль – будем генерируем и устанавливаем ему cookie. Когда запрашивает скачивание – проверяем её. Попробуйте модернизировать обработчик самостоятельно, воспользовавшись методами новой утилиты.

Обновленный метод handleSecretAccess:

SecretHandler.handleSecretAccess - обработка POST-запроса с паролем
func (h *SecretHandler) handleSecretAccess(w http.ResponseWriter, r *http.Request, id string) {
    password := r.FormValue("password")
    if password == "" {
        http.Error(w, "Password is required", http.StatusBadRequest)
        return
    }

    req := &usecase.GetSecretRequest{
        ID:       id,
        Password: password,
    }

    resp, err := h.secretUseCase.GetSecret(req)
    if err != nil {
        log.Printf("[ERROR] Failed to get secret %s: %v", id, err)

        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        data := struct {
            ID    string
            Title string
            Error string
        }{
            ID:    id,
            Title: "Введите пароль",
            Error: err.Error(),
        }
        assets.SecretPassTemplate.ExecuteTemplate(w, "layout", data)
        return
    }

    auth.SetAuthCookie(w, id)

    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    contentData := struct {
        ID        string
        Message   string
        FileName  string
        HasFile   bool
        ExpiresAt string
        Title     string
    }{
        ID:        resp.ID,
        Message:   resp.Message,
        FileName:  resp.FileName,
        HasFile:   resp.HasFile,
        ExpiresAt: resp.ExpiresAt.Format(time.RFC1123),
        Title:     "DeadDrop",
    }

    if err := assets.SecretContentTemplate.ExecuteTemplate(w, "layout", contentData); err != nil {
        log.Printf("[ERROR] Failed to render content template: %v", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
    }
}

Промежуточный итог

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

Обработчик скачивания файлов

Мы уже подготовили почву: научились создавать подписанные куки, проверять их подлинность, при необходимости – сбрасывать их. Осталось самое главное – создать обработчик для скачивания файлов: /secret/<id>/download. Фактически он должен:

  1. Проверять, авторизован ли пользователь через cookie

  2. Получать от хранилища секрет

  3. Отдавать файл клиенту или возвращать ошибку

Шаг 1: Создаём новый Use Case для получения файла

Давайте в наш Secret use case добавим новую функцию – GetFile. Этот метод должен возвращать данные файла по запросу пользователя. Обращаю внимание на особенности нашей архитектуры. В соответствии с ней – присваивать куки и проверять правомерность пользователя на доступ к содержимому ячейки мы должны на слое доставки - в обработчике.

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

type GetFileRequest struct {
    ID       string
    // Мы не передаём пароль, потому что проверка уже выполнена через куки на уровне хендлера
}

type GetFileResponse struct {
    FileData []byte
    FileName string
    FileExt  string
    Message  string // может пригодиться для логирования

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

type GetFileRequest struct {
    ID       string
}

type GetFileResponse struct {
    FileData []byte
    FileName string
    FileExt  string
    Message  string 
}

func (uc *SecretUseCase) GetFile(req *GetFileRequest) (*GetFileResponse, error) {
    secret, err := uc.storage.Get(req.ID)
    if err != nil {
        return nil, fmt.Errorf("failed to get secret: %w", err)
    }
    if secret == nil {
        return nil, errors.New("secret not found")
    }

    // Проверяем, не истёк ли срок
    if time.Now().After(secret.ExpiresAt) {
        uc.storage.Delete(req.ID)
        return nil, errors.New("secret has expired")
    }

    // Проверяем, есть ли файл
    if len(secret.FileData) == 0 {
        return nil, errors.New("no file attached to this secret")
    }

    return &GetFileResponse{
        FileData: secret.FileData,
        FileName: secret.FileName,
        FileExt:  secret.FileExt,
        Message:  secret.Message,
    }, nil
}

Шаг 2: Решение о маршрутизации

Как вы помните, при разработке мы зарегистрировали общий маршрут на путь /secret/. Это означает что все запросы, которые начинаются с этого префикса, попадают в наш SecretHandler. Однако теперь внутри этого обработчика есть необходимость в обработке двух разных типов запросов:

  1. Запросы к самому секрету: /secret/ (GET для формы пароля, POST для проверки пароля).

  2. Запросы на скачивание файла к секрету: /secret/{id}/download (GET для получения файла).

К тому же – текущая версия утилиты ExtractIdFromPath вовсе запрещает нам иметь слеши в ссылке – он теперь вовсе не сработает. Нам нужна логика, которая будет обрабатывать такие маршруты.

Я предлагаю создать простую, но эффективную реализацию прямо в самом SecretHandler. Идея проста: мы уже умеем извлекать ID. Если после извлечения ID в оставшейся части пути ничего нет — значит, это запрос к странице секрета. Если же после ID следует /download — значит, это запрос на скачивание.

Самым элегантным решением будет добавить в утилиту internal/lib/url/url.go новую функцию:

func ParseSecretPath(path string) (id, action string, ok bool) {
    const prefix = "/secret/"
    
    if !strings.HasPrefix(path, prefix) {
        return "", "", false
    }
    
    remaining := strings.TrimPrefix(path, prefix)
    parts := strings.Split(remaining, "/")
    
    if len(parts) == 0 || parts[0] == "" {
        return "", "", false 
    }
    
    id = parts[0]
    

    if len(parts) == 1 {
        return id, "view", true 
    }
    
    if len(parts) == 2 && parts[1] == "download" {
        return id, "download", true 
    }
    
    return "", "", false
}

Эта функция как будто бы диспетчер. Она не только извлекает ID, но и говорит нам – чего хочет пользователь: просто посмотреть страницу секрета или скачать файл ячейки. Старую функцию ExtractIdFromPath можно просто-напросто убрать.

Шаг 3: Обновим логику маршрутизации в ServeHTTP secret.go

Вооружим наш обработчик новой утилитой. Теперь вместо простого ID из него мы достаём и действие. Это сделает код чище и понятнее:

Обновление ServeHTTP хэндлера secret.go

func (h *SecretHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    id, action, ok := url.ParseSecretPath(r.URL.Path)
    if !ok {
        http.NotFound(w, r)
        return
    }

    switch action {
    case "view":
        switch r.Method {
        case http.MethodGet:
            h.showPasswordForm(w, r, id)
        case http.MethodPost:
            h.handleSecretAccess(w, r, id)
        default:
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        }
    case "download":
        if r.Method != http.MethodGet {
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
            return
        }
        h.handleFileDownload(w, r, id)
    default:
        http.NotFound(w, r)
    }
}

Как видите – всё, что мы придумали очень аккуратно легло на хэндрлер. Он теперь как вокзал с двумя платформами – клиенты к просмотру страниц уходят на одну платформу, клиенты к скачиванию – на другую.

Шаг 4: Реализуем функцию – обработчик скачивания

В коде ServeHTTP вы должно быть заметили – появилась новая функция, которую мы ранее не реализовывали - h.handleFileDownload(w, r, id). Логика этого обработчика должна следовать нашему плану:

  1. Проверять куки

  2. Получать данные через use case

  3. Отдавать файл

Ознакомьтесь с реализацией функции снизу, возможно, в ней есть ещё неизвестные для вас моменты, которые пояснены ниже:

SecretHandler.handleFileDownload - скачивание файла с проверкой авторизации
func (h *SecretHandler) handleFileDownload(w http.ResponseWriter, r *http.Request, id string) {
    // 1: Проверяем авторизацию через подписанную куку
    if !auth.CheckAuthCookie(r, id) {
        log.Printf("[WARN] Unauthorized download attempt for secret %s from %s", id, r.RemoteAddr)
        http.Redirect(w, r, "/secret/"+id, http.StatusSeeOther)
        return
    }

    // 2: Создаём запрос для Use Case
    req := &usecase.GetFileRequest{
        ID: id,
    }

    // 3: Вызываем Use Case для получения данных файла
    resp, err := h.secretUseCase.GetFile(req)
    if err != nil {
        log.Printf("[ERROR] Failed to get file for secret %s: %v", id, err)
        
        switch {
        case err.Error() == "secret not found" || err.Error() == "secret has expired":
            http.NotFound(w, r)
        case err.Error() == "no file attached to this secret":
            w.Header().Set("Content-Type", "text/html; charset=utf-8")
            data := struct {
                ID    string
                Title string
                Error string
            }{
                ID:    id,
                Title: "Содержимое ячейки",
                Error: "К этой ячейке не прикреплён файл",
            }
            assets.SecretContentTemplate.ExecuteTemplate(w, "layout", data)
        default:
            http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        }
        return
    }

    fileName := resp.FileName
    if fileName == "" {
        fileName = "secret_" + id + resp.FileExt
    }

    // Content-Disposition: attachment заставляет браузер скачать файл, а не открывать его
    w.Header().Set("Content-Disposition", "attachment; filename=\""+fileName+"\"")
    
    // Определяем Content-Type по расширению или данным файла
    mimeType := http.DetectContentType(resp.FileData)
    w.Header().Set("Content-Type", mimeType)
    
    // 5: Отдаём файл
    w.WriteHeader(http.StatusOK)
    w.Write(resp.FileData)
    
    log.Printf("[INFO] File %s downloaded for secret %s", fileName, id)
}

Обсудим то, что мы реализовали в этой функции:

  1. Проверка куки с помощью нашей утилиты. В случае их отсутствия – погружаем страницу ввода пароля

  2. Просим слой use case найти файл. Use case сам проверяет его наличие, существование секрета, не истек ли его срок действия в принципе.

  3. Обработка ошибок. Мы не просто возвращаем «404 – ничего нет», «500 – всё сломалось» или подобные варианты – мы грамотно их оборачиваем и даём понятную обратную связь пользователю

  4. Новое для нас! Заголовок Content-Disposition: attachment – он говорит сайту – не пытайся отобразить содержимое – просто сохрани на диск. А указание имени файла в кавычках даёт гарантию того, что оно будет корректным даже при наличии специальных символов, таких как пробел.

  5. Отдача файлов – самый простой шаг. Мы просто достаём из use case слайс байт файла и отправляем их пишем их в http.ResponseWriter. Байты пойдут прямиком в браузер пользователя и начнётся скачивание.

Шаг 5: Последний штрих

Если в вашем шаблоне для отображения контента до сих пор не было кнопки для перехода на /secret/<id>/download – самое время её добавить:

<a href="/secret/{{ .ID }}/download" class="button">Скачать файл</a>

Тестируем!

Результат внендрения обработчика скачивания файлов
Результат внендрения обработчика скачивания файлов

Теперь запускаем наш сервис – создаём ячейку и крепим к ней файл. Теперь рассмотрим 4 сценария:

Сценарий

Ожидание

Результат

Переход сразу на secret/<id>/download без предварительного ввода пароля

 

Форма для ввода пароля и файл не скачивается

Форма для ввода пароля и файл не скачивается

Переход на /secret/<id> впервые

Форма для ввода пароля

Форма для ввода пароля

Переход на /secret/<id> после успешно введенного пароля в последующие разы

Содержимое ячейки без формы для ввода пароля

Содержимое ячейки без формы для ввода пароля

Переход сразу на secret/<id>/download по нажатию на кнопку скачать файл

 

Файл скачивается

Файл скачивается

Заход при наличии куков прямо через адресную строку на  secret/<id>/download

Файл скачивается

Файл скачивается

Поздравляю. Цели нашей работы в этой статье достигнуты! Перейдём к итогам

Итоги "Разбираем net/http на практике. Часть 2"

К концу второй статьи цикла «Изучаем net/http» мы превратили наш DeadDrop из статической витрины в полноценное, функциональное веб-приложение. Теперь он умеет не только показывать красивую главную, но и создавать секретные ячейки, сохранять их и предоставлять к ним доступ по уникальной ссылке с проверкой пароля.

Вместе с вами мы на практике разобрали следующие ключевые темы:

  • Научились принимать POST-запросы, парсить multipart/form-data, извлекать текстовые поля и файлы. Организовали многоуровневую защиту от DoS-атак, ограничивая размер запроса, формы и отдельных файлов.

  • Валидировали ��агружаемые файлы по реальному MIME-типу (а не по расширению), проводили санитаризацию имен файлов и внедрили хэширование паролей с помощью bcrypt, чтобы никогда не хранить их в открытом виде.

  • Четко разделили ответственность между слоями. Вынесли бизнес-логику (генерация ID/пароля, проверка файлов) в слой usecase, а работу с данными абстрагировали за интерфейс storage.Storage. Это позволило сделать код тестируемым, гибким и готовым к смене хранилища.

  • Реализовали свое первое хранилище на map с использованием sync.RWMutex для безопасного доступа из множества горутин.

  • Научились обрабатывать пути с параметрами (/secret/{id}), реализовав собственный парсер URL без использования сторонних библиотек.

  • Вручную реализовали механизм аутентификации с помощью подписанных cookies (HMAC), чтобы пользователь мог скачать файл из ячейки без повторного ввода пароля, а злоумышленник не мог его подделать.

Что изменилось в проекте DeadDrop к концу этой части:

  • Главная страница теперь содержит работающую форму для создания ячейки с сообщением, файлом и временем жизни.

  • После отправки формы пользователь видит страницу с уникальным ID, сгенерированным паролем и сроком действия.

  • По адресу /secret/{id} работает динамическая страница, которая спрашивает пароль и отображает содержимое ячейки (текст и кнопку для скачивания файла) после его успешной проверки.

  • Реализован защищенный эндпоинт /secret/{id}/download, который отдает файл только при наличии валидной подписанной cookie, выданной после ввода правильного пароля.

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

  • Защита от CSRF-атак

  • Настройка security-заголовков

  • Как внедрить валидацию данных и ограничение скорости запросов (rate limiting)?

  • И, конечно же, продолжим улучшать DeadDrop, делая его по-настоящему безопасным

Как всегда, лучше всего учиться на практике — пишите код, экспериментируйте и задавайте вопросы в комментариях! Следите за обновлениями в GitHub репозитории, где для этой статьи доступна версия v2.

До встречи в новой статье!