Содержание урока · 10 разделов
- Аутентификация go: токен проверяется один раз в middleware
- JWT изнутри: header.payload.signature и подпись
- Сессии против JWT: как работают обе схемы
- JWT против сессий
- Публичные и защищённые маршруты: chi jwt middleware
- Спор: stateless JWT против server-side сессий
- Чек-лист: JWT в chi без дыр
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сервис заметок на chi вырос до пятнадцати хендлеров, и проверка токена в нём жила в каждом: четыре строки — достань заголовок Authorization, распарси JWT, свер exp, положи userID в локальную переменную. Копипаст шелестел от ручки к ручке, и однажды в новой ручке редактирования профиля проверка не приехала: ручка работала, тест на неё писался без токена, а ревьюер заметил дыру через неделю после релиза. Четыре строки в пятнадцати местах — это пятнадцать шансов забыть одну. Чинится переносом проверки на слой выше: middleware в chi стоит перед каждым хендлером ветки, токен разбирается один раз, а пользователь кладётся в контекст запроса. В этом уроке соберём аутентификацию именно так — на golang-jwt/v5, Bearer-заголовке и Group из урока про Route и Group.
Аутентификация go: токен проверяется один раз в middleware#
Middleware в chi — функция вида func(http.Handler) http.Handler, как в уроке про собственные middleware: она получает next, оборачивает его и возвращает новый хендлер. Аутентификация go-сервиса — идеальная работа именно для неё: алгоритм одинаков для всех ручек, а ветвится редко. Middleware делает три вещи: достаёт токен из запроса, проверяет его и кладёт результат в контекст — request-scoped хранилище, о котором подробно говорили в уроке про context. Дальше хендлеры достают пользователя оттуда и не знают ни про заголовки, ни про подписи. Здесь важно не перепутать две вещи: аутентификация отвечает на вопрос «кто ты» — она в middleware; авторизация отвечает «что тебе можно» — «этот пользователь админ» — и это отдельная проверка на конкретной ручке или ветке. Смешивать их в одном слое рано: сначала сервис узнаёт, кто пришёл, потом решает, что можно.
RequireAuth: проверка токена и пользователь в контексте
GOtype userKey struct{}
func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) {
w.Header().Set("WWW-Authenticate", "Bearer realm=\"api\"")
http.Error(w, "требуется токен", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(header, prefix)
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("неожиданный алгоритм: %v", t.Header["alg"])
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
http.Error(w, "токен не прошёл проверку", http.StatusUnauthorized)
return
}
claims := token.Claims.(jwt.MapClaims)
ctx := context.WithValue(r.Context(), userKey{}, claims["sub"])
next.ServeHTTP(w, r.WithContext(ctx))
})
}Проверка живёт в одном месте: нет заголовка или подпись не сошлась — 401 и выход, до хендлера дело не доходит. userKey — пустая структура-тип, как RouteCtxKey в chi: чужой пакет не прочтёт и не перезапишет значение случайно. Хендлеры достают пользователя через r.Context().Value(userKey{}) и не знают про JWT. Проверка алгоритма внутри keyfunc — защита от подмены alg, о ней ниже.
Проверка: зачем middleware
Пятнадцать хендлеров и один RequireAuth на ветке. Что меняется для хендлеров?
JWT изнутри: header.payload.signature и подпись#
JWT — три части в base64url, склеенные точками. Header рассказывает, чем подписано: alg HS256 для HMAC или RS256 для RSA, плюс typ JWT. Payload — claims: sub с идентификатором пользователя, exp с моментом истечения, iat с временем выдачи; туда можно класть свои поля — роль, email. Signature — HMAC-SHA256 от первых двух частей секретным ключом. Подпись гарантирует одно: после выдачи ни байт в header и payload не менялся — изменился символ, сверка рухнула. А вот шифрования в JWT нет: base64url — кодирование, а не защита; любой, у кого есть токен, читает payload как открытый текст — это удобно видеть на jwt.io. Отсюда правило: секретов и личных данных в claims быть не должно. Классическая атака на токены — подмена alg: клиент шлёт header с alg none и пустой подписью в надежде, что библиотека примет. В golang-jwt/v5 это закрыто двумя способами — проверкой типа в keyfunc, как в блоке выше, и опцией WithValidMethods, о которой ниже.
golang-jwt/v5: выдача токена и ParseWithClaims
GOimport "github.com/golang-jwt/jwt/v5"
// выдача: подписываем RegisteredClaims с истечением через 15 минут
claims := jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).
SignedString([]byte(secret))
// проверка: ParseWithClaims + keyfunc + белый список алгоритмов
parsed, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{},
func(t *jwt.Token) (any, error) { return []byte(secret), nil },
jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
// errors.Is(err, jwt.ErrTokenExpired) и другие причины
http.Error(w, "токен недействителен", http.StatusUnauthorized)
return
}
sub := parsed.Claims.(*jwt.RegisteredClaims).SubjectRegisteredClaims — готовый набор полей стандарта: iss, sub, aud, exp, nbf, iat. exp задаётся NumericDate, и v5 сверяет его сам: просроченный токен упадёт с ErrTokenExpired без единой строки проверки. WithValidMethods закрывает подмену alg: парсер примет только HS256. ParseWithClaims вместо Parse берут, чтобы достать claims типом, а не словарём MapClaims. Проверьте оба вызова локальным Go: соберите токен и распарсите его обратно.
Проверка: просроченный токен
exp в прошлом. Что вернёт jwt.ParseWithClaims для такого токена?
Токен приезжает в заголовке Authorization по схеме Bearer: «Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...» — слово Bearer, пробел, сам токен. Middleware срезает префикс через strings.HasPrefix и strings.TrimPrefix и работает с чистой строкой; нет префикса — сразу 401. RFC 6750 требует, чтобы ответ 401 нёс заголовок WWW-Authenticate с объяснением: Bearer realm=«api» — заголовка не было вовсе, Bearer error=«invalid_token» — токен есть, но проверку не прошёл. На этот заголовок смотрят машины: HTTP-библиотеки мобильных клиентов решают по нему, молча разлогинивать или запускать refresh. Тело ответа — для человека, его менять безопасно; пропустить WWW-Authenticate — легко, потому что в браузере отсутствие не заметно. Мы положим заголовок прямо в RequireAuth, в обе ветки отказа.
const secret = «super-secret-2024» в main.go — и через год репозиторий показывают фрилансеру, открывают для задачи или копируют в форк. Ключ подписи теперь у всех: любой желающий выдаёт валидный токен с любым sub и ходит чужим пользователем, при этом криптография ниже безупречна — HS256 честно подписан. Лечение скучное: секрет из переменной окружения или секрет-менеджера, сканер секретов вроде gitleaks в CI и запланированная ротация — два ключа параллельно, старый доживает до конца exp.
Сессии против JWT: как работают обе схемы#
Сессии — схема постарше и проще. На логине сервер создаёт запись: в redis или БД лежит, кто это и что ему можно, а клиенту в HttpOnly cookie отдаётся session id. Каждый запрос сервер берёт id из куки и делает lookup: жива ли запись. JWT переворачивает хранение: состояние едет в самом токене, сервер держит только секрет подписи и проверяет целостность — ни redis, ни lookup. Отсюда все плюсы и минусы, которые сводим в таблицу ниже. Отдельный вопрос — где клиент хранит токен: cookie HttpOnly недоступна JavaScript, а токен в localStorage читается любым XSS. Для браузера cookie с HttpOnly и SameSite по-прежнему безопаснее; заголовок Authorization удобнее мобильным приложениям и междоменным API. Это не конкуренция на смерть, а выбор точки хранения состояния: в redis или в подписанной строке у клиента.
JWT против сессий#
| Критерий | JWT | Сессии |
|---|---|---|
| Отзыв и logout | живёт до exp; честно — только denylist, и это снова состояние | удаление записи в redis — мгновенно и на всех инстансах |
| Хранение на сервере | нет: stateless, только секрет подписи | redis или БД, lookup на каждый запрос |
| Вес запроса | токен в заголовке — сотни байт, растут с claims | маленький session id |
| Мобильные и междоменные клиенты | заголовок Authorization — без CORS-игр с куками | куки требуют SameSite и доменной логики |
| Криптография | проверка подписи на каждый запрос | нет: доверяем своему redis |
| Главный риск | утёкший секрет или подмена alg | угон cookie, XSS |
Что ответит сервер на подделку
Прочитайте код и выберите, что он выведет:
// клиент взял валидный токен и поменял один байт в payload:
// sub с "user-1" на "admin". Подпись пересчитать не умеет.
token, err := jwt.ParseWithClaims(forged, &jwt.RegisteredClaims{}, keyfunc,
jwt.WithValidMethods([]string{"HS256"}))
// RequireAuth: err != nil → ?Публичные и защищённые маршруты: chi jwt middleware#
Защита нужна не всему сервису: /healthz должен пинговаться без токена, /login и /register — принимать анонимов. В chi jwt-аутентификация вешается точечно через Group из урока про Route и Group: Group возвращает inline-роутер на том же дереве без префикса, и r.Use внутри действует только на ветку. Публичные маршруты остаются на корне, защищённые собираются в Group с RequireAuth — chi jwt middleware здесь собирается один раз, а не копипастится в хендлеры. Для ролей поверх RequireAuth добавляется RequireAdmin из урока про собственные middleware: аутентификация и авторизация снова в разных слоях. Ошибка новичка — повесить RequireAuth через r.Use на корень: тогда 401 начнёт получать и /healthz, по которому мониторинг решит, что сервис умер.
Group: публичный корень и защищённая ветка
GOr := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
// публичные: без токена
r.Get("/healthz", healthz)
r.Post("/login", login(secret))
r.Post("/register", register(store))
// защищённые: RequireAuth до хендлеров ветки
r.Group(func(r chi.Router) {
r.Use(RequireAuth)
r.Get("/notes", listNotes(store))
r.Post("/notes", createNote(store))
r.Get("/notes/{id}", getNote(store))
})Публичные ручки зарегистрированы до и вне Group — RequireAuth их не накрывает. Внутри группы порядок тот же, что в корне: сначала Use, потом маршруты. Хендлеры достают пользователя из контекста, который положил RequireAuth. Проверьте локальным Go: запустите сервис, снимите токен с /login и сравните curl без заголовка и с ним — 401 с WWW-Authenticate против 200.
Отдали 401 с телом «нет токена» и без WWW-Authenticate — в браузере всё работало, а мобильный клиент лёг: его HTTP-библиотека решала по RFC 6750, запускать ли refresh, читая заголовок, а заголовка не было; интерсептор падал и разлогинивал пользователя на каждом истёкшем токене. Чинилось одной строкой — WWW-Authenticate: Bearer error=«invalid_token» в RequireAuth. Машина-клиент читает заголовки, а не тело ответа: как клиенты вообще разбирают ответы HTTP — тема урока про HTTP-клиент.
Спор: stateless JWT против server-side сессий#
Партия JWT: масштабирование без липких сессий — любой инстанс проверит подпись одинаково, redis не в критическом пути, мобильные клиенты довольны. Минус вы уже знаете: отзыв. Токен жив до exp, «выйти» на сервере негде; честный logout требует denylist отозванных токенов — а это redis, lookup и конец stateless-рая. Обходной путь — короткий exp в 5–15 минут плюс refresh-токен: ущерб от украденного ограничен, но refresh уже хранит состояние на сервере, так что полностью stateless не остаётся нигде. Партия сессий: отзыв мгновенный, cookie HttpOnly, клиенту нечем распорядиться; минус — lookup на каждый запрос и redis в критическом пути. Моя граница такая: SPA плюс мобильные плюс междоменное API — JWT с коротким exp и refresh; админка, деньги и всё, где logout обязан срабатывать мгновенно, — сессии. И честная треть: для типового сервиса в браузере сессии проще, а разницу в масштабировании вы не увидите до очень серьёзного трафика.
Чек-лист: JWT в chi без дыр#
- Секрет подписи — из окружения или секрет-менеджера, не из кода; ротация запланирована заранее.
- Алгоритм проверяется всегда: WithValidMethods у парсера или явный type assertion в keyfunc.
- Короткий exp — 5–15 минут — и refresh-токен; RegisteredClaims с ExpiresAt, а не ручные сверки.
- Каждый 401 несёт WWW-Authenticate: Bearer realm=... или error=invalid_token — по нему клиенты решают судьбу сессии.
- Пользователь кладётся в контекст через приватный тип-ключ, а не строку — как RouteCtxKey в chi.
- Мгновенный отзыв нужен — это denylist или сессии: stateless JWT отзывается с болью.
Соберите проверку токена
Восстановите три вызова проверки: разбор с готовыми claims, поле валидности и константу 401.
Соберите скелет RequireAuth
Расставьте строки middleware в правильном порядке: от функции-обёртки до ответа 401 на не прошедших проверку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: JWT и сессии
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Аутентификация go — это middleware: токен парсится один раз на входе, пользователь кладётся в контекст, хендлеры не знают про заголовки.
- JWT = header.payload.signature: подпись гарантирует целостность claims, но payload читается всеми — секретов в нём нет.
- golang-jwt/v5: ParseWithClaims с keyfunc и WithValidMethods; exp из RegisteredClaims проверяется автоматически, просрочка — ErrTokenExpired.
- Bearer-токен живёт в Authorization; 401 обязан нести WWW-Authenticate — по нему клиенты решают, запускать ли refresh.
- Сессии: состояние на сервере, отзыв мгновенный; JWT: stateless, но отзывается через denylist и refresh — то есть снова с состоянием.
- Публичные маршруты — на корне, защищённые — в r.Group с r.Use(RequireAuth), как в уроке 07.
- Секрет подписи — из окружения, не из кода: утёкший ключ делает любой sub вашим пользователем.
Домашнее задание
Соберите chi-сервис из двух зон: публичные /healthz, /login и /register на корне, защищённая Group с RequireAuth для /notes. Выдавайте HS256-токен с exp на 15 минут через RegisteredClaims, проверяйте ParseWithClaims с WithValidMethods, кладите sub в контекст и добавьте WWW-Authenticate к каждому 401. Проверьте curl-ом: без токена, с токеном и с просроченным.
Личные заметкиПусто
Вопросы и ответы по уроку
JWT или сессии, если фронтенд один и живёт в браузере?
Обычно сессии: cookie HttpOnly с SameSite безопаснее localStorage, отзыв мгновенный, а масштабирование до сессий доезжает позже, чем кажется. JWT оправдан, когда клиенты разные — мобильные приложения, другие сервисы, междоменные вызовы, — либо когда лишний lookup в redis уже болит на нагрузке.
Где клиенту хранить JWT?
В памяти SPA плюс refresh-токен в HttpOnly cookie: access-токен XSS не заберёт, а сессия переживает перезагрузку страницы. localStorage удобен, но любой внедрённый скрипт читает его целиком — это типичная точка входа при утечке через сторонний скрипт.
Опасна ли подмена alg на none?
Для golang-jwt/v5 — нет, если передана WithValidMethods или алгоритм проверяется в keyfunc: парсер примет только заявленные методы. Уязвимость была в старых библиотеках, принимавших неподписанные токены. Правило одно: алгоритм из самого токена не используется без белого списка.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Производительность chi: что под капотом
- Деплой: chi за reverse-прокси
- Chi против Gin, Echo и стандартного mux
- Итоговый проект: REST API на chi
- Тестирование хендлеров: httptest и chi
- Subrouters и структура большого сервиса
- Порядок middleware: почему он важен
- Валидация параметров и тел запросов
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter