Содержание урока · 13 разделов
- echo cookie: форма узнала пользователя
- Set-Cookie и Cookie: два заголовка одной медали
- Поле за полем: *http.Cookie
- Expires против MaxAge и три флага: Secure, HttpOnly, SameSite
- От кук к сессиям echo: зачем нужен CookieStore
- gorilla/sessions: Session, Values и Store
- CookieStore изнутри: New, Save и ключевые пары
- Флеш-сообщения: одноразовые записки через сессию
- Подпись значений: securecookie и SignedString
- Что дальше: токен вместо куки
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
echo cookie: форма узнала пользователя#
Запрос «echo cookie» начинается там, где HTTP заканчивается. Протокол stateless: сервер обрабатывает каждый запрос как незнакомца — ни логина, ни истории, ни корзины. Кука — это договор о памяти: сервер в ответе выставляет заголовок Set-Cookie, браузер сохраняет его в своём хранилище и на каждом следующем запросе к этому сайту прикладывает заголовок Cookie с теми же парами имя=значение. Всё остальное — сессии, флеш-сообщения, remember-me — надстройки над этими двумя заголовками.
В echo работа с куками живёт в context.go, и контраст с формами из тринадцатого урока разительный: там был четвёртый метод и многоэтажный разбор multipart, здесь — три однострочника, после которых echo заканчивается и начинается net/http. Но тонкий слой обманчив: под ним сидят невалидные имена, которые молча не доходят до клиента, приоритеты истечения Expires против MaxAge и константы SameSite, из-за которых кука на localhost просто не вернётся. Мы пройдём весь стек честно: сначала сырые *http.Cookie и три метода контекста, затем echo-contrib/session — официальный мост к gorilla/sessions, чей интерфейс sessions.Store echo-contrib принимает как есть, не реализуя ничего своего. Сессия пользователя ляжет в Values, флеш-сообщения — под ключ _flash, подпись значений обеспечит securecookie с его HMAC. К концу урока POST /login на :1323 будет возвращать Set-Cookie с сессией, а GET /me — узнавать alice по имени.
Set-Cookie и Cookie: два заголовка одной медали#
Направление данных разное, и для него в net/http есть две разные структуры доступа. Исходящий путь: вы собираете &http.Cookie и зовёте http.SetCookie — она сериализует куку методом Cookie.String() и добавляет строку в заголовок ответа Set-Cookie. Входящий путь: браузер присылает заголовок Cookie в виде name1=value1; name2=value2 — и Request.Cookie(name) выцепляет одну куку по имени, а Request.Cookies() парсит их все. Док-комментарий у Cookies лаконичен: «Cookies parses and returns the HTTP cookies sent with the request» — парсинг живёт в readCookies, и echo сюда не добавляет ни строчки.
Одно направление — одна структура, и это важно понимать до кода. В ответе куку описывают атрибуты Path, Domain, Expires, MaxAge, Secure, HttpOnly, SameSite — они говорят браузеру, КОГДА и КУДА её отправлять; в запросе ни одного из этих атрибутов нет, клиент присылает только пары. Вручную запросы собирает Request.AddCookie — с оговоркой RFC 6265: «AddCookie does not attach more than one Cookie header field. That means all cookies, if any, are written into the same line, separated by semicolon» — все куки уезжают одной строкой через точку с запятой, второго заголовка Cookie не будет.
Echo не прячет ни одну из сторон: c.SetCookie добавляет Set-Cookie в ответ, c.Cookie и c.Cookies читают входящие. Каждый из трёх методов — чистое делегирование, и это не лень, а философия фреймворка: echo держится тонкой обёрткой, а вся политика — валидность имени, приоритет Max-Age над Expires, скоуп Path — остаётся в стандартной библиотеке, где её можно прочитать и проверить. Теперь к точным строкам context.go.
Поле за полем: *http.Cookie#
| Поле | Тип | Смысл и нюанс |
|---|---|---|
| Name | string | Токен: невалидное имя — кука молча не уйдёт в Set-Cookie |
| Value | string | Значение; при сериализации проходит sanitizeCookieValue |
| Path | string | Скоуп пути; "/" — кука на весь сайт |
| Domain | string | Хост; невалидный домен логируется и отбрасывается — кука станет host-only |
| Expires | time.Time | Дата смерти в формате RFC; валидность проверяет validCookieExpires |
| MaxAge | int | 0 — атрибута нет (кука до закрытия браузера), <0 — удалить сейчас, >0 — секунды |
| Secure | bool | Отправлять только по HTTPS |
| HttpOnly | bool | Не отдавать скрипту: document.cookie его не увидит |
| SameSite | SameSite | Константы http.SameSiteDefaultMode, Lax, Strict, None — iota + 1 |
context.go: трио кук — сырые строки v4.15.0
GO// context.go, v4.15.0 — интерфейс echo.Context
// Cookie returns the named cookie provided in the request.
Cookie(name string) (*http.Cookie, error)
// SetCookie adds a `Set-Cookie` header in HTTP response.
SetCookie(cookie *http.Cookie)
// Cookies returns the HTTP cookies sent with the request.
Cookies() []*http.Cookie
// context.go, v4.15.0 — реализации: три однострочника net/http
func (c *context) Cookie(name string) (*http.Cookie, error) {
return c.request.Cookie(name)
}
func (c *context) SetCookie(cookie *http.Cookie) {
http.SetCookie(c.Response(), cookie)
}
func (c *context) Cookies() []*http.Cookie {
return c.request.Cookies()
}
// net/http, cookie.go — что стоит за SetCookie
// SetCookie adds a Set-Cookie header to the provided [ResponseWriter]'s headers.
// The provided cookie must have a valid Name. Invalid cookies may be
// silently dropped.
func SetCookie(w ResponseWriter, cookie *Cookie) {
if v := cookie.String(); v != "" {
w.Header().Add("Set-Cookie", v)
}
}Контракт целиком: интерфейс — три строки, реализации — по одной. c.Cookie делегирует Request.Cookie и возвращает ошибку: промах — это http.ErrNoCookie с текстом «http: named cookie not present» (в старых выпусках Go переменная называлась ErrNoCookies, текст тот же); если кук с таким именем несколько, вернётся только одна — «If multiple cookies match the given name, only one cookie will be returned». c.Cookies читает весь входящий заголовок. c.SetCookie зовёт http.SetCookie(c.Response(), cookie): c.Response() — это *echo.Response, реализующий http.ResponseWriter, так что заголовок Set-Cookie добавляется в обычную карту заголовков. Самая важная строка — условие if v := cookie.String(); v != "": для невалидного имени Cookie.String() возвращает пустую строку («If c is nil or c.Name is invalid, the empty string is returned») — и заголовок просто не добавится, без ошибки и без паники. Ещё деталь сериализации: MaxAge<0 пишется в провод как «Max-Age=0» — «MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'».
Проверьте себя: невалидное имя куки
Обработчик вызывает c.SetCookie(&http.Cookie{Name: "bad name", Value: "x"}) — в имени пробел. Клиент получит ответ без ошибок. Что окажется в заголовках ответа?
Канон: кука темы на /api/v1/theme
GO// main.go — прямая работа с *http.Cookie в каноне :1323
func setTheme(c echo.Context) error {
theme := c.QueryParam("theme") // POST /api/v1/theme?theme=dark
if theme != "dark" && theme != "light" {
return echo.NewHTTPError(http.StatusBadRequest, "theme: только dark или light")
}
c.SetCookie(&http.Cookie{
Name: "theme",
Value: theme,
Path: "/", // кука на весь сайт
MaxAge: 86400 * 7, // неделя в секундах
Secure: false, // локально без HTTPS; в проде — true
HttpOnly: true, // скрипту страницы не видна
SameSite: http.SameSiteLaxMode,
})
return c.JSON(http.StatusOK, map[string]string{"theme": theme})
}
func getTheme(c echo.Context) error {
ck, err := c.Cookie("theme") // делегирует Request.Cookie
if errors.Is(err, http.ErrNoCookie) {
return c.JSON(http.StatusOK, map[string]string{"theme": "default"})
}
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"theme": ck.Value})
}
// регистрация в v1-группе рядом с задачами и вложениями:
// v1.GET("/theme", getTheme)
// v1.POST("/theme", setTheme)
//
// curl -si -X POST -d "theme=dark" -H "Authorization: Bearer dev-token-42" \
// "localhost:1323/api/v1/theme?theme=dark" | rg -i set-cookie
// Set-Cookie: theme=dark; Path=/; Max-Age=604800; HttpOnly; SameSite=Lax
// curl -s -b "theme=dark" -H "Authorization: Bearer dev-token-42" \
// localhost:1323/api/v1/theme → {"theme":"dark"}Канон продолжается: bearerAuth с токеном dev-token-42 охраняет /api/v1, кука темы живёт рядом с задачами и вложениями. setTheme собирает &http.Cookie литералом — все атрибуты на месте, поэтому провод предсказуем: «theme=dark; Path=/; Max-Age=604800; HttpOnly; SameSite=Lax» — ровно в том порядке, в котором Cookie.String() пишет атрибуты: Path, Domain, Expires, Max-Age, HttpOnly, Secure, SameSite. getTheme различает два случая через errors.Is(err, http.ErrNoCookie): промах — честный «default» в JSON, прочие ошибки — наверх в ErrorHandler, где они превратятся в JSON-конверт, потому что путь /api. Флаг -b в curl подкладывает заголовок Cookie руками — браузерный jar при этом не участвует, поэтому Secure-куки curl по http всё равно отправит; браузер — нет, и это различие ещё выстрелит в разделе про дефолты CookieStore.
Expires против MaxAge и три флага: Secure, HttpOnly, SameSite#
Времени у куки два механизма, и в Go они конкурируют. Expires — абсолютная дата; MaxAge — относительный счётчик в секундах, и его семантика закомментирована в структуре дословно: «MaxAge=0 means no 'Max-Age' attribute specified», «MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'», «MaxAge>0 means Max-Age attribute present and given in seconds». Ноль — не «удалить» и не «навсегда», а «атрибута нет»: кука станет сессионной и умрёт вместе с браузером. Если заполнить оба поля, Cookie.String() выпишет оба заголовка; gorilla не полагается на приоритеты браузеров — его NewCookie сам рассчитывает Expires из MaxAge «for Internet Explorer compatibility», но об этом после.
Три флага — про границы доверия. Secure запрещает браузеру отправлять куку по http; HttpOnly прячет её от document.cookie, снимая половину прибыли с кражи через XSS; SameSite решает, ехать ли куке в кросс-сайтовых запросах. В Go это константы типа http.SameSite — SameSiteDefaultMode SameSite = iota + 1, дальше Lax, Strict, None, — а их смысл сформулирован в доке стандартной библиотеки: «SameSite allows a server to define a cookie attribute making it impossible for the browser to send this cookie along with cross-site requests. The main goal is to mitigate the risk of cross-origin information leakage, and provide some protection against cross-site request forgery attacks».
Здесь мы останавливаемся сознательно: браузерная механика этих трёх флагов — почему None требует Secure, что именно Lax пропускает, а Strict нет, чем опасен document.cookie — уже разобрана в уроке fiber про cookie и сессии, и пересказывать её второй раз нечестно. Для echo важно другое: атрибуты вы ставите руками в каждом &http.Cookie, а в сессиях — один раз в Options хранилища, и от дефолтов этого Options вас будет отделять один сюрприз.
От кук к сессиям echo: зачем нужен CookieStore#
Кука с голым значением — это память без авторства. Три ограничения становятся заметны сразу. Первое: значение видит клиент — заголовок Set-Cookie читается глазами, а document.cookie и панель разработчика делают его общедоступным; любые «userId=7» — приглашение к подмене. Второе: объём — практический потолок куки около четырёх килобайт на пару имя-значение, и на большее не рассчитывайте. Третье: структура — в Value живёт строка, и каждую вложенную сущность надо сериализовать и защитить отдельно.
Сессия решает всё разом: данные лежат в карте Values — но на какой стороне, зависит от хранилища. В классической cookie-сессии данные не прячутся на сервер: они сериализуются, подписываются ключом (и опционально шифруются) и целиком уезжают в куку — сервер остаётся без состояния: подделать значение без ключа нельзя, а прочитать без шифрования можно. Запрос «сессии echo» выводит ровно на эту конструкцию: пакет github.com/labstack/echo-contrib/session — официальный мост echo к gorilla/sessions, стандарту де-факто с 2012 года. Совместимость с Gorilla здесь честная до типа: echo-contrib не реализовал своё хранилище — он принимает ЛЮБОЙ sessions.Store из gorilla и больше ничего не хранит. CookieStore, FilesystemStore, ваш собственный имплементатор трёх методов — всё это подходит без адаптеров.
Сравнение с курсом fiber из его двенадцатого урока уместно одной строкой: у fiber v3 собственная session-механика с адаптерами Storage, у echo — мост к зрелому gorilla; разные философии, одинаковый протокол Set-Cookie под ними. В gorilla/sessions Session — это struct с картой Values, а Store — интерфейс из трёх методов: Get, New, Save. Разберём обе стороны моста по исходникам.
session.go: мост echo-contrib — сырые строки v0.18.0
GO// session.go, echo-contrib v0.18.0 — последний релиз под echo/v4
// (go.mod: github.com/labstack/echo/v4 v4.15.0, gorilla/sessions v1.4.0)
// в master-ветке echo-contrib уже echo/v5
type (
// Config defines the config for Session middleware.
Config struct {
// Skipper defines a function to skip middleware.
Skipper middleware.Skipper
// Session store.
// Required.
Store sessions.Store
}
)
const (
key = "_session_store"
)
// Get returns a named session.
func Get(name string, c echo.Context) (*sessions.Session, error) {
s := c.Get(key)
if s == nil {
return nil, fmt.Errorf("%q session store not found", key)
}
store := s.(sessions.Store)
return store.Get(c.Request(), name)
}
// MiddlewareWithConfig returns a Sessions middleware with config.
func MiddlewareWithConfig(config Config) echo.MiddlewareFunc {
// Defaults
if config.Skipper == nil {
config.Skipper = DefaultConfig.Skipper
}
if config.Store == nil {
panic("echo: session middleware requires store")
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if config.Skipper(c) {
return next(c)
}
defer context.Clear(c.Request())
c.Set(key, config.Store)
return next(c)
}
}
}Мост оказался крошечным — и это лучшая гарантия совместимости. Middleware (обёртка над MiddlewareWithConfig) не трогает ни куки, ни данные: он один раз кладёт хранилище в контекст запроса — c.Set(key, config.Store) под ключом "_session_store" — и всё. session.Get(name, c) достаёт store обратно через c.Get и делает тип-ассершн s.(sessions.Store): если middleware не подключён, c.Get вернёт nil и вы получите ошибку «"_session_store" session store not found», а не панику. Дальше — store.Get(c.Request(), name): запрос передаётся gorilla как есть, echo-contrib заканчивается. Две детали честности: nil Store ловится паникой на этапе монтирования — «echo: session middleware requires store» — ошибка конфигурации проваливается рано; а строка defer context.Clear(c.Request()) — пережиток архивированного пакета gorilla/context, который подчищает его глобальную карту по завершении запроса; в v5-ветке этой строки уже нет. Кук в echo-contrib ровно ноль — все Set-Cookie выставляет gorilla.
Проверьте себя: Get без middleware
session.Get("user", c) вызван в обработчике, но e.Use(session.Middleware(store)) в приложении отсутствует. Что произойдёт?
gorilla/sessions: Session, Values и Store#
Пакет gorilla/sessions держит три сущности. Session — рабочая единица: struct из четырёх экспортируемых полей — ID string, Values map[interface{}]interface{}, Options *Options и IsNew bool. Док-комментарий предупреждает заранее: ID — «generated by stores. It should not be used for user data» — это внутренний идентификатор, а не место для логина; всё ваше живёт в Values. Карта нетипизированная — ключ и значение любого типа — потому что сериализатор по умолчанию encoding/gob, который умеет почти всё, но требует gob.Register для нестандартных типов; пакет сам в init регистрирует []interface{}{}. IsNew — главный диагностический флаг: true значит, что куки не было или она не расшифровалась.
Store — интерфейс из трёх методов: Get должен вернуть кэшированную сессию, New — создать (и «should never return a nil session, even in the case of an error»), Save — сохранить. Разница между Get и New описана в доке дословно: «calling New() twice will decode the session data twice, while Get() registers and reuses the same decoded session after the first call». Реестром занимается Registry: он кладётся в контекст запроса и хранит map имя→сессия, поэтому повторный session.Get с тем же именем в пределах одного запроса бесплатен и возвращает тот же указатель. Тот же Registry — источник ошибки на невалидном имени: «sessions: invalid character in cookie name: %s» — gorilla проверяет имя куки раньше, чем net/http успеет что-то сказать.
Осталась механика конвейера: где Values превращаются в Set-Cookie и обратно. Это CookieStore — и тут же дефолтные Options, из-за которых локальный http-сервер молча теряет сессию.
Канон: пользовательская сессия на :1323
GO// main.go — мост к gorilla включён: /login, /me, /logout рядом с /api/v1
var devHashKey = []byte("dev-cookie-hash-key-0123456789ab") // 32 байта; в проде — GenerateRandomKey
var devBlockKey = []byte("dev-cookie-blockkey-0123456789ab") // 32 байта — AES-256
func main() {
e := echo.New()
e.Use(middleware.RequestID(), middleware.Recover())
store := session.NewCookieStore(devHashKey, devBlockKey)
// дефолт CookieStore: Path "/", MaxAge 86400*30, SameSite None, Secure true,
// HttpOnly false — для http-локалки переопределяем три поля:
store.Options.SameSite = http.SameSiteLaxMode
store.Options.Secure = false
store.Options.HttpOnly = true
e.Use(session.Middleware(store)) // Store доступен всем через session.Get
e.POST("/login", login)
e.GET("/me", me)
e.POST("/logout", logout)
// /api/v1 продолжает жить под bearerAuth(AuthConfig{Token: "dev-token-42", Username: "alice"}):
// сессии обслуживают браузер, bearer-токен — API
}
func login(c echo.Context) error {
var body map[string]string
if err := c.Bind(&body); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "битое тело")
}
if body["username"] != "alice" || body["password"] != "qwerty-42" {
return echo.NewHTTPError(http.StatusUnauthorized, "неверные данные входа")
}
sess, err := session.Get("user", c) // Registry: кэш запроса → store.New
if err != nil {
return err // кука была, но не расшифровалась — Session всё равно вернётся
}
sess.Values["login"] = body["username"] // данные — обычная карта interface{}
sess.Values["visits"] = 1
sess.AddFlash("Вы вошли как alice") // одноразовое сообщение под _flash
return sess.Save(c.Request(), c.Response()) // EncodeMulti → Set-Cookie
}
func me(c echo.Context) error {
sess, err := session.Get("user", c)
if err != nil {
return err
}
login, ok := sess.Values["login"].(string) // тип-ассершн обязателен
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "нет входа")
}
flashes := sess.Flashes() // прочитать И стереть в карте
_ = sess.Save(c.Request(), c.Response()) // стирание и правки — сохранить
return c.JSON(http.StatusOK, map[string]any{
"login": login,
"is_new": sess.IsNew,
"flashes": flashes,
})
}
func logout(c echo.Context) error {
sess, err := session.Get("user", c)
if err != nil {
return err
}
delete(sess.Values, "login") // данные убрать
sess.Options.MaxAge = -1 // «MaxAge<0 means delete cookie immediately»
return sess.Save(c.Request(), c.Response()) // Expires = time.Unix(1, 0)
}Три хендлера — весь жизненный цикл. login: session.Get("user", c) возвращает сессию из Registry (первый вызов — store.New), Values заполняются парами любой природы, AddFlash кладёт сообщение, и sess.Save(c.Request(), c.Response()) сериализует карту через securecookie и выставляет Set-Cookie. Заметьте контракт Save из доки: «You should call Save before writing to the response or returning from the handler» — после c.JSON сохранять уже поздно, заголовок улетел. me: доставание из Values — это тип-ассершн, потому что карта нетипизированная; Flashes читает и стирает, поэтому после него обязателен повторный Save, иначе стирание не доехало бы до клиента. logout: удаляем ключ, ставим sess.Options.MaxAge = -1 — «Individual sessions can be deleted by setting Options.MaxAge = -1 for that session» — и Save: gorilla превратит минус в Expires = time.Unix(1, 0), дату в прошлом. Дефолтный срок сессии — 86400 * 30 секунд; глобально его правит store.MaxAge(age), который синхронно настраивает и Options, и каждый кодек. bearerAuth и сессии не конфликтуют: API под токеном, браузерные страницы — под кукой user.
Два Get в одном запросе: чей Values?
Прочитайте код и выберите, что он выведет:
// хендлер /me, один запрос:
sess1, _ := session.Get("user", c)
sess1.Values["visits"] = 7
sess2, _ := session.Get("user", c) // второй вызов с тем же именем
// что окажется в sess2.Values["visits"]?CookieStore изнутри: New, Save и ключевые пары#
CookieStore — хранилище, где вся сессия живёт в куке, а сервер только подписывает её. Конструктор описывает ключи так: «Keys are defined in pairs to allow key rotation, but the common case is to set a single authentication key and optionally an encryption key. The first key in a pair is used for authentication and the second for encryption. The encryption key can be set to nil or omitted in the last pair, but the authentication key is required in all pairs». Рекомендации по длинам тоже в доке: аутентификационному ключу — «32 or 64 bytes», ключу шифрования — «16, 24, or 32 bytes to select AES-128, AES-192, or AES-256». Из пар строится Codecs — срез securecookie.Codec через CodecsFromPairs: несколько пар — это ротация, старые куки декодируются старым ключом, новые кодируются первым.
Чтение: CookieStore.New достаёт куку из запроса и отдаёт её securecookie — if c, errCookie := r.Cookie(name); errCookie == nil { err = securecookie.DecodeMulti(name, c.Value, &session.Values, s.Codecs...) }; при успехе IsNew = false. Важный нюанс из доки Get: «It returns a new session and an error if the session exists but could not be decoded» — битая или подделанная кука НЕ превращается в панику и в пустоту: вы получаете новую сессию И ошибку, и решение «пустить ли гостя» остаётся вашим. Запись: Save кодирует карту — securecookie.EncodeMulti(session.Name(), session.Values, s.Codecs...) — и выставляет куку http.SetCookie(w, NewCookie(session.Name(), encoded, session.Options)).
Сроки: метод store.MaxAge(age) меняет Options.MaxAge и прогоняет то же число по всем кодекам, чтобы срок подписи совпадал со сроком куки; у отдельной сессии — свой Options.MaxAge, и -1 удаляет её немедленно: NewCookie при MaxAge<0 ставит «Expires = time.Unix(1, 0)» — «Set it to the past to expire now». Сам Options — подмножество полей куки, что заявлено в доке структуры: «Fields are a subset of http.Cookie fields» — те же Path, Domain, MaxAge, Secure, HttpOnly, SameSite плюс Partitioned из свежих стандартов. А вот дефолтный Options конструктора — ловушка, вынесенная в отдельный вопрос ниже. Серверная альтернатива — FilesystemStore: те же ключи и интерфейс, но сериализованная сессия лежит файлом на диске (каталог или os.TempDir), и в куке едет только идентификатор.
Проверьте себя: дефолтный CookieStore на localhost
store := session.NewCookieStore(hashKey) без правки Options; сервер на http://localhost:1323; браузер получил Set-Cookie. Что случится при следующем переходе?
Флеш-сообщения: одноразовые записки через сессию#
Флеш — это значение, которое должно показаться ровно один раз: «задача сохранена», «неверный пароль», «файл слишком большой». Механика gorilla помещается в два метода Session и одну константу: const flashesKey = "_flash" — дефолтный ключ, под которым флешы лежат в той же карте Values. AddFlash(value, vars...) дописывает значение в срез под ключом (свой ключ передаётся вторым аргументом: AddFlash("err", "notice")), а Flashes(vars...) возвращает срез И стирает его — комментарий в исходнике не оставляет вариантов: «Drop the flashes and return it», в коде — delete(s.Values, key).
Отсюда три правила, из которых растёт весь паттерн. Первое: прочёл — стёр; второй вызов Flashes в том же запросе вернёт пустой срез, потому что ключ уже удалён из Values. Второе: стирание живёт только в памяти запроса, пока не случился Save — именно поэтому в каноне после sess.Flashes() стоит sess.Save: иначе «прочитанный» флеш вернётся при следующем заходе и покажется второй раз. Третье: положил — в одном запросе, прочитал — в следующем: форма с ошибкой валидации из тринадцатого урока кладёт AddFlash("title обязателен") и редиректит обратно на страницу формы, а её обработчик при рендере вызывает Flashes и показывает сообщение один раз. Пакетная версия сохранения есть и на уровне пакета: sessions.Save(r, w) — в доке дословно «Save saves all sessions used during the current request» — прогоняет Registry.Save и собирает ошибки в MultiError — удобно, когда хендлер работал с несколькими именами сессий.
В fiber двенадцатого урока тот же паттерн собирался на родной сессии — считайте это вторым свидетелем: положил → редирект → прочитал → стёр — универсальный ритм одноразовых сообщений, различаются только API. Дальше — последняя деталь конвейера: чем именно подписана ваша Values, когда она превращается в строку куки.
Два Flashes подряд: сколько вернёт второй
Прочитайте код и выберите, что он выведет:
// в одном запросе, до Save:
sess.AddFlash("A")
sess.AddFlash("B")
first := sess.Flashes()
second := sess.Flashes()
// len(first) и len(second)?Подпись значений: securecookie и SignedString#
Загадка кодирования решается в соседнем пакете gorilla/securecookie — том самом, чей Codec торчит из CookieStore.Codecs. Интерфейс двухметодный: Encode(name string, value interface{}) (string, error) и Decode(name, value string, dst interface{}) error. Конструктор — securecookie.New(hashKey, blockKey): первый ключ обязателен и «used to authenticate values using HMAC», второй опционален и включает шифрование AES (длина 16, 24 или 32 байта — AES-128/192/256). Дефолты: хеш-функция sha256.New, срок подписи 86400 * 30 секунд, потолок длины 4096 — «which is the maximum value accepted by Internet Explorer», сериализатор encoding/gob (JSONEncoder включается SetSerializer).
Конвейер Encode описан в доке целиком: «It serializes, optionally encrypts, signs with a message authentication code, and finally encodes the value». В коде подпись строится по строке «name|date|value» — имя куки участвует в MAC, поэтому кука одного имени не переиспользуется под другим. Результат — base64 от «date|value|mac». Decode идёт наоборот и на каждом шаге может отказать: ошибка «the value is not valid» (ErrMacInvalid) — подпись не совпала, причём сравнение делает subtle.ConstantTimeCompare, защита от тайминг-атак; «expired timestamp» — MAC верен, но дата за пределами maxAge; «timestamp is too new» — при настроенном MinAge. Имя при декодировании должно совпасть с именем при кодировании — проверяется тем же MAC.
И обязательная оговорка о ключах: «keys created using GenerateRandomKey() are not automatically persisted. New keys will be created when the application is restarted, and previously issued cookies will not be able to be decoded» — ключ, сгенерированный на старте, убивает все сессии при каждом деплое. Секрет хранят в окружении или файле, как любой другой конфиденциальный параметр. И не путайте это с Token.SignedString из мира JWT: там подписывается целый токен с клеймами и отдельной логикой проверки — пятнадцатый урок курса echo, JWT-аутентификация, встретит вас именно этим методом; securecookie подписывает значения кук, и у них у этого механизма иная математика и другой срок годности.
securecookie напрямую: подписанная кука без сессии
GO// main.go — подпись значений руками: securecookie минус сессия
var sc = securecookie.New(devHashKey, nil) // только HMAC-SHA256, без шифрования
func setSigned(c echo.Context) error {
encoded, err := sc.Encode("pref", "dark") // serialize → MAC("pref|date|value") → base64
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "encode")
}
c.SetCookie(&http.Cookie{
Name: "pref",
Value: encoded,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return c.String(http.StatusOK, "ок")
}
func getSigned(c echo.Context) error {
ck, err := c.Cookie("pref")
if errors.Is(err, http.ErrNoCookie) {
return echo.NewHTTPError(http.StatusBadRequest, "нет куки pref")
}
var value string
if err := sc.Decode("pref", ck.Value, &value); err != nil {
// сюда попадают и подмена значения, и чужое имя, и просроченный timestamp:
// «the value is not valid» либо «expired timestamp»
return echo.NewHTTPError(http.StatusBadRequest, "кука не прошла проверку")
}
return c.JSON(http.StatusOK, map[string]string{"pref": value})
}
// curl -si "localhost:1323/pref?theme=dark" | rg -i set-cookie
// Set-Cookie: pref=MTcw...|value...; Path=/; HttpOnly; SameSite=Lax
// curl -s -b "pref=MTcw...подделанный хвост" localhost:1323/pref → 400Тот же механизм, что у CookieStore, но без карты Values: Encode пакует одиночное значение, Decode — раскладывает в &value, и вся проверка — внутри. С devBlockKey = nil шифрования нет: значение закодировано base64 и читается клиентом как открытый текст, но подменить его нельзя — MAC на ключе devHashKey не сойдётся, и Decode вернёт ошибку со стандартным текстом «the value is not valid». Это ровно тот компромисс, о котором стоит помнить: подпись защищает целостность, шифрование — конфиденциальность; хотите и то и другое — передайте второй ключ (AES, 16/24/32 байта). CookieStore делает всё то же самое: EncodeMulti/DecodeMulti — просто циклы по нескольким кодекам ради ротации ключей. Сигнатура имени в MAC — «name|date|value» — объясняет, почему имя при Encode и Decode обязано совпадать: разное имя — другая подпись.
Заполните пропуски: каркас сессии
Соберите каркас: легальные Options для http-локалки, монтирование моста, флеш и сохранение сессии до ответа.
Соберите жизненный цикл сессии
Расставьте строки в правильном порядке: хранилище до маршрутов, чтение до записи, флеш до Save, выход — последним.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Первая: невалидное имя куки — и Set-Cookie тихо не добавится: «Invalid cookies may be silently dropped», Cookie.String() вернёт пустую строку на пробеле в имени; ошибку не вернёт никто, диагностика — только взгляд на заголовки ответа. Вторая: забытый sess.Save — Values жили в памяти запроса и умерли вместе с ним: Set-Cookie не выставлен, второй запрос снова IsNew = true; доке безразлична причина — «You should call Save before writing to the response or returning from the handler». Третья: секрет в литерале или сгенерированный на старте — «previously issued cookies will not be able to be decoded» после каждого рестарта; выносите ключи в окружение и храните постоянно, а ротацию делайте через несколько пар в NewCookieStore. Четвёртая: дефолтный Options CookieStore — SameSite None, Secure true, HttpOnly false — продовая связка, на http-локалке дающая вечно новую сессию, а без HttpOnly — куку, видимую из document.cookie; три строки переопределения из канона решают обе проблемы.
Карточки: куки и сессии
Что дальше: токен вместо куки#
Канон научился помнить пользователя. Трио context.go — Cookie, SetCookie, Cookies — оказалось тремя однострочниками над net/http с одним честным сюрпризом: невалидное имя молча не доходит до клиента. Кука темы на /api/v1/theme показала литерал &http.Cookie с Max-Age в проводе; сессия на /login, /me, /logout — мост echo-contrib/session, за которым стоит gorilla/sessions без единой подмены: Config.Store — это sessions.Store, и никакого своего хранилища echo-contrib не содержит. Карта Values, Registry с кэшем на запрос, CookieStore с его ключевыми парами и дефолтными Options, флеш-сообщения под _flash со стиранием при чтении, подпись securecookie с HMAC по «name|date|value» — весь конвейер сверен с исходниками v4.15.0, v0.18.0, gorilla/sessions v1.4.0 и securecookie v1.1.2.
Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; соберите канон go run main.go и прогоните цепочку curl: POST /login с JSON alice/qwerty-42, взгляд на Set-Cookie, затем GET /me с -b "user=..." — и тот же запрос без куки, чтобы увидеть is_new: true. Отдельно сломайте подпись: измените один символ значения куки user и убедитесь, что /me отдаёт новую сессию, а не ошибку — и объясните, почему это ровно та логика, которая написана в CookieStore.New.
Следующий, пятнадцатый урок курса echo — JWT-аутентификация: echo-jwt middleware, клеймы, роли, refresh-токены. Сессия держит состояние в куке и в карте Values; JWT несёт состояние в самом токене, подписанном SignedString, — и первые вопросы там будут про то, где такой токен хранить и что он, в отличие от подписанной куки, прочитать умеет. Навигация по курсу — на странице курса Echo; форма, чьи ошибки заслуживают флеша, — в уроке про формы и файлы; параллельная реализация той же темы в fiber — в уроке fiber про cookie и сессии — там своя session-механика v3, пригодится для сравнения подходов, но не как источник фактов об echo.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- В echo куки — три однострочника context.go v4.15.0: c.Cookie(name) делегирует Request.Cookie (промах — ErrNoCookie «http: named cookie not present», при нескольких одноимённых вернётся одна), c.SetCookie — http.SetCookie(c.Response(), cookie), c.Cookies — Request.Cookies(). Всё остальное — политика net/http.
- http.SetCookie молча пропускает невалидную куку: «Invalid cookies may be silently dropped» — тело if v := cookie.String(); v != "", а String() даёт пустую строку при невалидном имени. MaxAge<0 пишется в провод как «Max-Age=0»; MaxAge=0 — атрибута нет, кука сессионная.
- echo-contrib/session v0.18.0 — последний релиз под echo/v4: middleware только кладёт хранилище в c.Set("_session_store"), session.Get достаёт его тип-ассершном и зовёт store.Get; без middleware — ошибка «"_session_store" session store not found», без Store — panic на монтировании. Gorilla-совместимость честная: Config.Store — интерфейс gorilla sessions.Store {Get, New, Save}.
- Session — это ID (не для данных), Values map[interface{}]interface{} (gob), Options и IsNew. Registry кэширует сессии на запрос: повторный session.Get возвращает тот же *Session — «Get() registers and reuses the same decoded session after the first call».
- CookieStore.New: кука есть → securecookie.DecodeMulti; битая подпись даёт новую сессию И ошибку. Save → EncodeMulti → http.SetCookie с NewCookie; sess.Save обязателен до записи тела ответа. Ключи — парами: HMAC 32/64 байта + AES 16/24/32; ротация через несколько пар. Дефолт Options — SameSite None и Secure true: на http-локалке браузер куку не вернёт, нужны Lax и Secure=false; HttpOnly в дефолте false.
- Флеш-сообщения: AddFlash пишет в Values["_flash"], Flashes возвращает и стирает («Drop the flashes and return it»); стирание доезжает до клиента только через Save; повторный Flashes в том же запросе пуст. Удаление сессии — Options.MaxAge = -1: NewCookie ставит Expires = time.Unix(1, 0).
- Подпись — gorilla/securecookie: New(hashKey, blockKey), Encode «serializes, optionally encrypts, signs with a message authentication code, and finally encodes», MAC по «name|date|value», провод — base64 от «date|value|mac», проверка subtle.ConstantTimeCompare, потолок 4096 байт, срок 30 дней. GenerateRandomKey не персистится — рестарт с новым ключом делает все выпущенные куки нерасшифровываемыми.
Домашнее задание
Соберите канон урока: кука темы на /api/v1/theme, сессия на /login, /me, /logout с CookieStore и переопределёнными Options. Первое: пройдите цикл входа curl-ом — POST /login с JSON alice/qwerty-42, сохраните куку в jar (-c), GET /me (-b), зафиксируйте is_new: false и флеш «Вы вошли как alice», затем повторите /me — флеш исчез; объясните, какая строка исходника гарантирует исчезновение. Второе: разлогиньтесь и убедитесь, что Set-Cookie ответа содержит Expires в прошлом и Max-Age=0; сверьте с кодом NewCookie для MaxAge<0. Третье: удалите из main три строки переопределения Options и попробуйте цикл входа браузером (не curl-ом) по http://localhost:1323 — объясните по дефолту NewCookieStore, почему сессия каждый раз новая; верните строки. Четвёртое: перепишите куку pref на securecookie с двумя ключами (hashKey и blockKey) и проверьте три сценария: честное значение, подменённый хвост (400, «the value is not valid») и имя Decode, не совпадающее с именем Encode. Пятое: добавьте в login AddFlash с собственным ключом "notice" и прочитайте его sess.Flashes("notice") на /me, не задев _flash.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем echo-contrib/session отличается от gorilla/sessions — что из чего импортировать?
gorilla/sessions — сама библиотека сессий: тип Session, карта Values, интерфейс Store и два хранилища (CookieStore, FilesystemStore). echo-contrib/session — тонкий мост для echo: middleware, который кладёт ваш gorilla-Store в контекст запроса, и хелпер session.Get(name, c), достающий его обратно. Импортируйте оба: session "github.com/labstack/echo-contrib/session" и gorilla "github.com/gorilla/sessions"; хранилище создаёт gorilla — session.NewCookieStore из echo-contrib — это алиас gorilla-конструктора. Совместимость честная: сигнатура принимает sessions.Store, поэтому любое gorilla-совместимое хранилище подходит без адаптеров.
Почему сессия не сохраняется между запросами, хотя sess.Save вызван?
Проверьте четыре места по порядку. Первое: Save должен случиться до записи тела — после c.JSON заголовок уже ушёл. Второе: дефолтные Options CookieStore — Secure true и SameSite None — по http браузер не отправит куку обратно; для локалки нужны SameSite Lax и Secure false (curl с -b при этом работает и маскирует проблему). Третье: ключ шифрования не должен генерироваться заново на каждый старт — иначе подпись старой куки не сойдётся и CookieStore.New вернёт новую сессию с ошибкой. Четвёртое: не меняйте имя сессии между вызовами — Registry кэширует по имени, и две сессии с разными именами — это две разные куки.
Когда хватит простой куки с securecookie, а когда нужна сессия с CookieStore?
Подписанная кука через securecookie.New — для одного-двух примитивных значений: тема, выбранный язык, «показать приветствие». Вы получаете подпись, срок и потолок 4096 байт без дополнительных зависимостей. Session с CookieStore нужна, когда значений много или они структурные: карта Values с gob, флеш-сообщения, опции на уровне сессии, ротация ключей и готовый механизм замены хранилища — FilesystemStore или свой Store, когда данные больше не должны уезжать в браузер. Помните: cookie-сессия не прячет данные без ключа шифрования — без blockKey значение читаемо клиентом, хотя и неподделываемо.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
GORM: ORM-библиотека для Go
Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.
- Подключать БД и настраивать пул соединений
- Описывать модели и связи: 1:1, 1:N, M:N
- Писать CRUD и сложные выборки chainable-стилем
- Контролировать N+1 через Preload