Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 21 из 25 ~18 мин +50 XP

Аутентификация: JWT и сессии как middleware

Аутентификация go-сервиса чаще всего размазана по хендлерам: в каждой ручке свой парсинг токена, и однажды в новой ручке проверка забудется. Здесь chi jwt-аутентификация собирается в одном middleware: токен проверяется один раз на входе, пользователь кладётся в контекст, дальше — структура JWT, golang-jwt/v5, Bearer-заголовок, сессии против JWT и Group для защищённых маршрутов.

Содержание урока · 10 разделов

Сервис заметок на 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: проверка токена и пользователь в контексте

GO
type 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

GO
import "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).Subject

RegisteredClaims — готовый набор полей стандарта: 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, в обе ветки отказа.

Сессии против 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: публичный корень и защищённая ветка

GO
r := 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.

Спор: 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.

token, err := jwt.(tokenString, &jwt.RegisteredClaims{}, keyfunc, jwt.WithValidMethods([]string{"HS256"})) if err != nil || !token. { w.Header().Set("WWW-Authenticate", "Bearer realm=api") http.Error(w, "нет доступа", http.) return }

Соберите скелет RequireAuth

Расставьте строки middleware в правильном порядке: от функции-обёртки до ответа 401 на не прошедших проверку.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: JWT и сессии

Термин

header.payload.signature

нажмите, чтобы перевернуть
Определение

Три части JWT в base64url: header с alg и typ, payload с claims (sub, exp, iat), подпись HMAC или RSA. Подпись гарантирует целостность, но payload не шифруется — его читает любой владелец токена.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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-ом: без токена, с токеном и с просроченным.

Личные заметкиПусто
Хранятся только в вашем браузере (Chi · Аутентификация: JWT и сессии как middleware)

Вопросы и ответы по уроку

JWT или сессии, если фронтенд один и живёт в браузере?

Обычно сессии: cookie HttpOnly с SameSite безопаснее localStorage, отзыв мгновенный, а масштабирование до сессий доезжает позже, чем кажется. JWT оправдан, когда клиенты разные — мобильные приложения, другие сервисы, междоменные вызовы, — либо когда лишний lookup в redis уже болит на нагрузке.

Где клиенту хранить JWT?

В памяти SPA плюс refresh-токен в HttpOnly cookie: access-токен XSS не заберёт, а сессия переживает перезагрузку страницы. localStorage удобен, но любой внедрённый скрипт читает его целиком — это типичная точка входа при утечке через сторонний скрипт.

Опасна ли подмена alg на none?

Для golang-jwt/v5 — нет, если передана WithValidMethods или алгоритм проверяется в keyfunc: парсер примет только заявленные методы. Уязвимость была в старых библиотеках, принимавших неподписанные токены. Правило одно: алгоритм из самого токена не используется без белого списка.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Redis25 уроков~12 чпрактика25 уроков уже доступны

Redis в Go: кэш, сессии и очереди на go-redis

Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.

  • Подключаться к Redis из Go и настраивать пул соединений
  • Кэшировать результаты запросов и придумывать TTL
  • Работать с хэшами, списками, множествами и ZSET
  • Строить сессии, счётчики, лидерборды и rate limiter