Предисловие: статья является продолжением цикла «Разбираем 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}. Что это такое?
- Этот новый для нас вид маршрутов называется маршрут с параметром или динамический маршрут. В современной веб-разработке это незаменимая практика.
У маршрутов с параметрами есть постоянная часть и параметр, который может изменяться. Взгляните на таблицу, где подробно показаны элементы динамического маршрута:
/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 |
Значит, нам нужно «научить» наш обработчик распознавать конкретный параметр такого доменного имени.
Варианты решения
Важно сказать, что у это проблемы есть несколько решений и для вашего понимания сейчас я назову все:
Использование сторонних роутеров – вокруг net/http есть достаточно популярных готовых обёрток, к примеру, упомянутые в первой статье цикла chi, gorilla/mux или fiber. Внутри них есть готовые парсеры динамических маршрутов.
Реализовать собственный разборщик пути – мы можем зарегистрировать свой обработчик на адрес /secret/ (обратите внимание на важность / в конце адреса обработчика, в первой части цикла мы обсуждали, для чего он нужен https://habr.com/ru/articles/981356/), внутри такого обработчика мы можем доставать адрес статьи вручную
Добавить параметры в 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 нам потребуется:
Получить полный URL по которому перешел пользователь (сделать это можно через
r.URL.path)Обрезать статическую часть «/secret/»
Оставшуюся часть будем считать идентификатором
Напишите такую утилиту, для того чтобы обрезать префикс у ссылок с 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 и, опционально, ошибку.
Бизнес логика для данного сценария использования должна быть такой:
Получаем секрет из хранилища
Проверяем - не истек ли его срок действия (подсказка: воспользуйтесь методом time.After()) и в случае истечения – удаляем его из хранилища
Проверяем пароль (воспользуйтесь ранее реализованной утилитой CheckPasswordHash)
Возвращаем данные в структуре CreateSecretRequest
Не забывайте в случае ошибок возвращать их для последующего оборачивания в обработчике.
Помните – новый сценарий относится к тому же 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-запроса к секрету. Теперь нужно внедрить зависимость от него в наш обработчик. Сделайте это подобно тому, как мы делали при создании ячейки. Важное различие между ними, теперь у нас появилась некоторая вариативность: что нужно показать пользователя в зависимости от его действия:
Только зашел на страницу – отобразить шаблон layout и шаблон ввода пароля
Ввел правильный пароль – отобразить шаблон с данными секрета
Ввел неправильный пароль – отобразить уведомление о неверно введённом пароле
Теперь перед нами задача – сделать такой обработчик, который будет по-разному вести себя в зависимости от действий пользователя. Как же понять – отправил пользователь форму с паролем или просто перешел по ссылке? Попробуйте объяснить разницу в этих ситуациях обратившись к своим знаниям о методах запросов.
Всё просто. В случае простого перехода по ссылке /secret/<ID> на сервер уходит Get-запрос, а при отправке пароля приходит POST-запрос (так как ведь мы передаём данные - пароль) на тот же самый адрес.
Это классическая ситуация для веб-приложений и нам важно научиться обрабатывать такие случаи.
Но перед реализацией для понимания давайте реализуем все необходимые шаблоны:
Шаблон 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 }}Шаблон 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 новые глобальные переменные
Теперь мы готовы к реализации обработчика. Предлагаю при ней пользоваться блок-схемой, приведенной ниже:

Я уже говорил, блок-схемы удобны. Они позволяют быстро продумывать и проектировать взаимодействия между разными объектами, не привязываясь к конкретной реализации – не брезгуйте тратить на них время.
В своей реализации старайтесь разделить логику. Задача 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, который подтверждает факт аутентификации в данной ячейке.
При запросе на скачивание (/secret//download) проверяем наличие и валидность cookie
Если cookie есть – отдаём файл, если нет – спрашиваем пароль.
Проблема: Мы не можем просто положить в куки строчку: «authorized: true» - так любой злоумышленник сможет её подделать. Нам нужен механизм, который будет гарантировать, что куки создан именно нашим сервисом. Как это делать? Поговорим в следующем разделе.
Подписанные cookie
Как говорилось выше: мы не можем по соображениям безопасности использовать в куки простую строку вида «authorized: true». Нам нужно как-то «подписать» эти куки – дать гарантию того, что они созданы именно нашим сервисом. В коммерческой разработке для этого часто используют готовые пакеты и решения, например gorilla/securecookie. Но мы хотим понять, как оно работает «изнутри» - поэтому реализуем простую подпись на чистом net/http
Идея проста:
Берём ID секрета (например abc123)
Добавляем к нему секретный ключ, известный только серверу
Вычисляем подпись
Сохраняем куки как пару «значение:подпись»
Когда клиент приходит снова, мы отделяем значение от подписи, снова вычисляем подпись на сервере и сверяем подписи между собой. Если совпали – значит cookie не были подделаны.

Давайте реализуем алгоритм пошагово реализуем механизм выдачи подписанных cookie пользователям!
Шаг 1: Создаём утилиту для подписи cookie
Создаём файл internal/lib/auth/cookie.go
В нём нужно реализовать сразу ряд функций:
Функция создания подписи
Функция проверки подписи
Функция создания cookie для доступа к секрету
Функция проверки валидности cookie
Функция для удаления куки из браузера пользователя
Для вашего удобства – подробнее поговорим о них:
Функция 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. Фактически он должен:
Проверять, авторизован ли пользователь через cookie
Получать от хранилища секрет
Отдавать файл клиенту или возвращать ошибку
Шаг 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. Однако теперь внутри этого обработчика есть необходимость в обработке двух разных типов запросов:
Запросы к самому секрету: /secret/ (GET для формы пароля, POST для проверки пароля).
Запросы на скачивание файла к секрету: /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). Логика этого обработчика должна следовать нашему плану:
Проверять куки
Получать данные через use case
Отдавать файл
Ознакомьтесь с реализацией функции снизу, возможно, в ней есть ещё неизвестные для вас моменты, которые пояснены ниже:
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) }
Обсудим то, что мы реализовали в этой функции:
Проверка куки с помощью нашей утилиты. В случае их отсутствия – погружаем страницу ввода пароля
Просим слой use case найти файл. Use case сам проверяет его наличие, существование секрета, не истек ли его срок действия в принципе.
Обработка ошибок. Мы не просто возвращаем «404 – ничего нет», «500 – всё сломалось» или подобные варианты – мы грамотно их оборачиваем и даём понятную обратную связь пользователю
Новое для нас! Заголовок
Content-Disposition: attachment– он говорит сайту – не пытайся отобразить содержимое – просто сохрани на диск. А указание имени файла в кавычках даёт гарантию того, что оно будет корректным даже при наличии специальных символов, таких как пробел.Отдача файлов – самый простой шаг. Мы просто достаём из 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.
До встречи в новой статье!
