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

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

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

Урок 15 из 25 ~24 мин +50 XP

JWT-аутентификация: echo-jwt, клеймы, роли и refresh-токены

echo jwt — тема, которую курс закладывал дважды. В седьмом уроке каталог встроенных слоёв честно признал: JWT в middleware нет, он вынесен в отдельный модуль labstack/echo-jwt. А в прошлом, [четырнадцатом уроке](/courses/echo/lessons/14-cookie-sessii/) мы закончили мостом: сессия держит состояние в куке и карте Values, а JWT несёт состояние в самом токене, подписанном SignedString. Сегодня подхватываем эстафету: канон на :1323 взрослеет — статический bearerAuth с токеном dev-token-42 из [восьмого урока](/courses/echo/lessons/08-kastomnye-middleware/) уступает место настоящей JWT-аутентификации. POST /login выдаёт пару токенов — короткоживущий access и долгоживущий refresh, — подписанные HMAC-SHA256 через golang-jwt v5; группу /api охраняет echojwt.WithConfig из echo-jwt v4.3.1, клеймы читаются в обработчиках, роли проверяет гвард requireRole, а ErrorHandler из [десятого урока](/courses/echo/lessons/10-httperror/) заворачивает отказы в привычный конверт {error, code}. Сверяемся с исходниками echo-jwt v4.3.1 и golang-jwt v5.2.2 — каждый факт ниже взят из файла, а не из памяти.

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

echo jwt: токен вместо статической строки#

Запрос «echo jwt» начинается с честного вопроса: чем плох статический токен, который мы носили весь курс? bearerAuth восьмого урока сравнивал заголовок Authorization со строкой dev-token-42 — и это работало, но носитель строки ничего о себе не рассказывал: сервер знал только «кто-то с верным паролем». Кто именно, какая у него роль, когда истекает доступ — на эти вопросы строка не отвечает. Хранилище сессии из прошлого урока отвечало, но требовало памяти и ключей с обеих сторон: сервер обязан помнить каждую сессию между запросами.

JWT — JSON Web Token — переворачивает хранение: вся идентичность едет в самом токене. Сервер ничего не помнит; он только проверяет подпись. Подписал issued-claims своим ключом — значит, содержимому можно верить; срок в клейме exp вышел — значит, доступ закрыт. Состояние аутентификации становится безгосударственным: любой экземпляр сервиса с тем же ключом проверит токен без похода в общее хранилище.

План урока такой: сначала анатомия токена и выдача через SignedString из golang-jwt v5, затем middleware echo-jwt — конфиг, источники извлечения, контекст, — после клеймы, роли и гвард поверх них, и в финале refresh-токены с честным разговором о том, что JWT не умеет отзывать. Всё собираем в каноне: /api/v1 остаётся под охраной, ErrorHandler форматит отказы, сессия из четырнадцатого урока продолжает жить для браузера — токены обслуживают API. Поисковый кейс «jwt echo» — это тот же маршрут с обратным порядком слов, и отвечаем мы на него тем же конвейером.

Анатомия JWT: три части и base64url#

Токен — строка из трёх частей, разделённых точками: header.payload.signature. Заголовок — маленький JSON с алгоритмом: {"alg":"HS256","typ":"JWT"}. Полезная нагрузка — JSON с клеймами, утверждениями о носителе: кто он, что может, до какого времени. Обе части кодируются base64url — вариантом base64 без плюс-слэш-равно, безопасным для URL и заголовков. Третья часть — подпись: HMAC от конкатенации первых двух, вычисленная серверным ключом.

Здесь первая неожиданность, которую надо проговорить вслух: base64url — это кодирование, а не шифрование. Любой, кто получил токен, прочитает заголовок и клеймы за секунду — в инспекторе браузера, в декодере, глазами. Никогда не кладите в клеймы пароли, номера карт, личные данные, которые не готовы показать самому носителю. Целостность защищает подпись: без ключа нельзя заменить «role":"user» на «role":"admin» — пересчитать HMAC нечем, и сервер отвергнет токен на проверке.

Сравнение с securecookie из прошлого урока напрашивается, и мы его сделаем одной строкой, не пережёвывая: там MAC строился по строке «name|date|value» и защищал значения куки, здесь подпись покрывает header.payload целиком — механика другая, принцип тот же: целостность без ключа не подделать. Разница существеннее в другом: подписанная кука читалась только тем сайтом, что её выдал, а JWT едет в заголовке Authorization любого HTTP-клиента — curl, мобильное приложение, другой сервис. Именно поэтому токены — валюта API, а куки — валюта браузера.

golang-jwt v5: выдача токена SignedString

GO
// main.go — golang-jwt/jwt/v5 v5.2.2: конвейер выдачи
const devSigningKey = "dev-jwt-signing-key-0123456789ab" // 32 байта; в проде — из окружения

type tokenPair struct {
        AccessToken  string `json:"access_token"`
        RefreshToken string `json:"refresh_token"`
        TokenType    string `json:"token_type"`
        ExpiresIn    int    `json:"expires_in"`
}

func issuePair(sub, role string) (tokenPair, error) {
        now := time.Now()
        access, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
                "sub":  sub,          // о ком токен
                "role": role,         // наше кастомное утверждение
                "typ":  "access",     // различаем пары токенов
                "iat":  jwt.NewNumericDate(now),
                "exp":  jwt.NewNumericDate(now.Add(15 * time.Minute)),
                "jti":  newJTI(),     // уникальный id токена
        }).SignedString([]byte(devSigningKey))
        if err != nil {
                return tokenPair{}, err
        }
        refresh, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
                "sub": sub,
                "typ": "refresh",
                "iat": jwt.NewNumericDate(now),
                "exp": jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
                "jti": newJTI(),
        }).SignedString([]byte(devSigningKey))
        if err != nil {
                return tokenPair{}, err
        }
        return tokenPair{AccessToken: access, RefreshToken: refresh,
                TokenType: "Bearer", ExpiresIn: 900}, nil
}

Конвейер SignedString виден в исходнике token.go v5.2.2: метод собирает sstr через SigningString() — json.Marshal заголовка и клеймов, склеенные точкой, — затем sig, err := t.Method.Sign(sstr, key) и в конце return sstr + "." + t.EncodeSegment(sig), nil. Для HS256 метод Sign — hmac.New(m.Hash.New, keyBytes) по строке подписи, и он ждёт именно []byte: ветка if keyBytes, ok := key.([]byte); ok, иначе — ошибка «HMAC sign expects []byte» с ErrInvalidKeyType. Обратите внимание на jwt.NewNumericDate: клеймы времени в JWT — это числовые секунды, а не строки RFC3339, и v5 требует NumericDate, а не time.Time. Клейм typ мы придумали сами — он разделит access от refresh на проверке, а jti даст каждому токену индивидуальность, без которой ротация refresh не работает.

SignedString с обычной строкой: что вернёт

Прочитайте код и выберите, что он выведет:

token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "alice"})

s, err := token.SignedString("secret") // string, а не []byte

// что в s и err?

Клеймы RegisteredClaims: поле за полем#

КлеймПоле структурыСмысл
issIssuer stringКто выдал токен — имя вашего сервиса
subSubject stringО ком токен — у нас login пользователя
audAudience ClaimStringsДля кого токен — получатель, один или список
expExpiresAt *NumericDateСрок смерти в секундах; просроченный токен — ErrTokenExpired
nbfNotBefore *NumericDateНе действует раньше; до момента — ErrTokenNotValidYet
iatIssuedAt *NumericDateМомент выпуска; в будущем — ErrTokenUsedBeforeIssued
jtiID stringУникальный id токена — основа ротации и чёрных списков

Клеймы: RegisteredClaims, MapClaims и float64#

Клеймы — словарь, и golang-jwt v5 даёт два способа с ним работать. Первый — структура jwt.RegisteredClaims: семь зарегистрированных RFC 7519 полей с готовыми json-тегами — iss, sub, aud, exp, nbf, iat, jti — и методами GetExpirationTime, GetIssuer и прочими, через которые валидатор достаёт значения. Типовой приём — встроить её в свою: type accessClaims struct { Role string; jwt.RegisteredClaims } — и получить строгие поля плюс собственные клеймы рядом. Второй способ — jwt.MapClaims: обычная map[string]interface{}, в которую ложится любой JSON. Echo-jwt по умолчанию работает именно с ней: его NewClaimsFunc, если её не задать, возвращает jwt.MapClaims{}.

За универсальность MapClaims платят типом значений: json.Unmarshal в interface{} превращает любое число в float64. Это не каприз библиотеки, а свойство стандартного декодера, и оно бьёт по каждому, кто пишет claims["exp"].(int64): ассершн вернёт ноль и false — и если этим значением проверяется срок, вы молча решите, что токен вечный. Правильный ассершн — к float64, а с RegisteredClaims ещё честнее: даты уже распарсены в *NumericDate с методом Time().

Выбор между двумя мирами прост: выдаёте и проверяете токен в одном коде — берите структуры, компилятор поймает опечатку в имени клейма; токены едут извне, а читать нужно пару произвольных полей — берите MapClaims с аккуратными ассершнами. В каноне ниже выдача идёт MapClaims ради единообразия с middleware, но осознанно: вы всегда можете переключиться на структуру, и протокол не заметит подмены — на проводе тот же JSON.

echo-jwt на каноне: конфиг и чтение клеймов

GO
// main.go — middleware echojwt "github.com/labstack/echo-jwt/v4" v4.3.1
func main() {
        e := echo.New()
        e.Use(middleware.RequestID(), middleware.Recover())

        api := e.Group("/api", echojwt.WithConfig(echojwt.Config{
                SigningKey:    []byte(devSigningKey),          // ключ проверки HMAC
                SigningMethod: echojwt.AlgorithmHS256,          // пин алгоритма
                ContextKey:    "user",                          // дефолт — можно опустить
                ErrorHandler:  unauthorized,                    // свой отказ в конверте курса
        }))
        v1 := api.Group("/v1")
        v1.GET("/tasks", listTasks)
        v1.DELETE("/tasks/:id", deleteTask, requireRole("admin"))
        // ...
}

func userInfo(c echo.Context) (string, string, error) {
        token, ok := c.Get("user").(*jwt.Token) // middleware кладёт *jwt.Token
        if !ok {
                return "", "", echo.NewHTTPError(http.StatusUnauthorized, "нет токена в контексте")
        }
        claims, ok := token.Claims.(jwt.MapClaims) // дефолт NewClaimsFunc
        if !ok {
                return "", "", echo.NewHTTPError(http.StatusUnauthorized, "клеймы не MapClaims")
        }
        sub, _ := claims["sub"].(string)   // строки — обычные ассершны
        role, _ := claims["role"].(string)
        return sub, role, nil
}

func unauthorized(c echo.Context, err error) error {
        return echo.NewHTTPError(http.StatusUnauthorized, "invalid or expired jwt").SetInternal(err)
}

Конфиг читается по исходнику jwt.go v4.3.1. ToMiddleware заполняет пустоты: ContextKey == "" → "user", TokenLookup без настроек → "header:Authorization:Bearer ", SigningMethod == "" → AlgorithmHS256 — константа "HS256" из того же файла. Если не заданы ни SigningKey, ни SigningKeys, ни KeyFunc, ни ParseTokenFunc — return nil, errors.New("jwt middleware requires signing key"), и WithConfig паникует на ней при монтировании: конфигурационная ошибка проваливается рано, как и panic в сессиях. В цепочке middleware зовёт config.ParseTokenFunc(c, auth), при успехе кладёт c.Set(config.ContextKey, token) — заметьте, кладётся весь *jwt.Token, а не клеймы, — и только потом next(c). Ключ «user» совпадает с ключом bearerAuth восьмого урока намеренно: обработчики, читавшие пользователя через c.Get, продолжают работать.

Проверьте себя: запрос без заголовка Authorization

curl без Authorization шлёт GET /api/v1/tasks под echojwt с дефолтным ErrorHandler. Какой HTTP-статус вернёт сервер?

TokenLookup: откуда echo-jwt достаёт токен#

Дефолт «header:Authorization:Bearer » — лишь одна из настроек, и её стоит читать как DSL. Формат — «<source>:<name>», источников пять: header — заголовок запроса, query — параметр адресной строки, param — path-параметр маршрута, cookie — кука, form — поле формы. Хвостовая опция cut-prefix срезает статический префикс: в «header:Authorization:Bearer » пробел на конце — не опечатка, а точная длина отрезаемой строки «Bearer », без которой значение уехало бы с лишними шестью символами. Источники собираются через запятую: «header:Authorization:Bearer ,cookie:myowncookie» означает «сначала заголовок, если пусто — кука» — ровно так делают приложения, где один и тот же API кормит и мобильный клиент с заголовком, и веб-страницу, получившую токен кукой.

За строкой стоит CreateExtractors из extractors.go — он превращает описание в срез функций middleware.ValuesExtractor, и TokenLookupFuncs позволяет дописать свои. В теле middleware перебор честный и буквальный: для каждого extractor'а — попытка достать значение; на промахе lastExtractorErr запоминается и цикл идёт дальше; на найденном значении — ParseTokenFunc, и его провал уходит в lastTokenErr. Приоритет финальной ошибки прописан комментарием: токен найден и отвергнут — это серьёзнее, чем отсутствие, поэтому parsing-ошибка побеждает extraction-ошибку. Вся картина извлечения — пять строк цикла, и чтение их полезнее любых диаграмм.

Практическое следствие для безопасности: query и form удобны для WebSocket и старых клиентов, но токен в адресной строке остаётся в логах nginx, истории и реферерах. По возможности держите заголовок, а экзотику — для путей, где иначе никак.

Канон: POST /login выдаёт пару токенов

GO
// main.go — /login из четырнадцатого урока повзрослел: сессия осталась браузеру,
// 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, "неверные данные входа")
        }
        pair, err := issuePair(body["username"], "admin") // выдача из блока выше
        if err != nil {
                return echo.NewHTTPError(http.StatusInternalServerError, "signing failed").SetInternal(err)
        }
        return c.JSON(http.StatusOK, pair)
        // curl -s -X POST -d '{"username":"alice","password":"qwerty-42"}' \
        //   localhost:1323/login
        // → {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...",
        //    "refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...",
        //    "token_type":"Bearer","expires_in":900}
}

func newJTI() string {
        b := make([]byte, 8)
        _, _ = rand.Read(b) // crypto/rand
        return hex.EncodeToString(b)
}

Проверка пары alice/qwerty-42 — та же строка, что в сессиях прошлого урока: один /login обслуживает обоих клиентов. Ответ — JSON из tokenPair: access на 15 минут с role admin, refresh на 30 дней с typ refresh, token_type Bearer подсказывает, как строить заголовок: Authorization: Bearer <access_token>. Проверка curl-ом двухходовая: POST /login забирает пару, затем curl -H "Authorization: Bearer $TOKEN" localhost:1323/api/v1/tasks уходит под echo-jwt. Access короткий намеренно: если токен утёк, окно злоупотребления — четверть часа; длинный refresh живёт в более бережных условиях и обновляет пару. Это центральный компромисс всего урока, и к refresh-разделу он вернётся со всеми последствиями.

Роли и гварды: requireRole поверх клейма#

Аутентификация отвечает «кто ты», авторизация — «что тебе можно». В каноне роль уехала в клейм role при выдаче, и проверять её нужно на границе маршрута — тем же инструментом, каким весь курс строил слои: middleware. Гвард requireRole(role) — фабрика из восьмого урока в чистом виде: замыкание ловит роль, тело читает контекст, решение — вернуть next(c) или HTTPError. Повторять анатомию фабрик не будем — она разобрана дословно, — но два отличия от bearerAuth проговорим.

Первое: гвард читает то, что положил echojwt — c.Get("user") со звёздочкой *jwt.Token. Порядок важен: echojwt смонтирован на группе /api, requireRole — на конкретном маршруте, и слои группы всегда снаружи маршрутных, так что к моменту гварда токен уже в контексте. Если бы вы поставили requireRole выше по цепочке, c.Get вернул бы nil — и ассершн с ok спас бы от паники, вернув честный 401. Второе: отказ авторизации — 403, а не 401. Сервер различает «не знаю, кто ты» и «знаю, но нельзя»: echo-jwt отвечает первым, ваш гвард — вторым. Конверт ErrorHandler один и тот же — клиенту не важно, кто из слоёв отказал, — но в Internal для логов разница видна.

И про гранулярность: роль — грубая единица. В реальных системах клейм тащит permissions-список, а гвард проверяет требуемое право; механика та же дословно, меняется сравнение строк на пересечение множеств. Начните с роли — усложните, когда маршрутов станет много.

Гвард requireRole на маршруте DELETE

GO
// main.go — авторизация поверх аутентификации echo-jwt
func requireRole(role string) echo.MiddlewareFunc {
        return func(next echo.HandlerFunc) echo.HandlerFunc {
                return func(c echo.Context) error {
                        token, ok := c.Get("user").(*jwt.Token)
                        if !ok {
                                return echo.NewHTTPError(http.StatusUnauthorized, "нет токена")
                        }
                        claims, ok := token.Claims.(jwt.MapClaims)
                        if !ok {
                                return echo.NewHTTPError(http.StatusUnauthorized, "клеймы не MapClaims")
                        }
                        if r, _ := claims["role"].(string); r != role {
                                return echo.NewHTTPError(http.StatusForbidden, "нужна роль "+role)
                        }
                        return next(c)
                }
        }
}

// регистрация: удаление — только admin
//   v1.DELETE("/tasks/:id", deleteTask, requireRole("admin"))
//
// curl -X DELETE -H "Authorization: Bearer $TOKEN_USER" \
//   localhost:1323/api/v1/tasks/42 → {"error":"нужна роль admin","code":403}
// curl -X DELETE -H "Authorization: Bearer $TOKEN_ADMIN" \
//   localhost:1323/api/v1/tasks/42 → 204

Двойной ассершн — не паранойя, а контракт echo-jwt: между вашим импортом jwt и версией, которую тянет echo-jwt, должно быть совпадение, иначе ассершн jwt.Token молча вернёт false. README echo-jwt прямо предупреждает об этом разделом про integration-тесты: «type assertions like token := c.Get("user").(jwt.Token) may fail silently if the imported version of the JWT library differs» — держите один github.com/golang-jwt/jwt/v5 в go.mod. Заметьте и стиль ошибки: роль в тексте конверта — «нужна роль admin» — диагностируемо для клиента, а гвард не возвращает 401 там, где токен валиден: только 403. Связка «проверка до next(c), отказ — значением» ровно та, что курс закладывал в восьмом уроке; изменился только источник правды — подписанный токен вместо статической строки.

Проверьте себя: что лежит в c.Get("user")

echojwt смонтирован с дефолтным ContextKey. Что вернёт c.Get("user") в обработчике и как из этого получить роль?

Клейм exp: какой тип спрятан в MapClaims

Прочитайте код и выберите, что он выведет:

// echojwt проверил токен, обработчик читает MapClaims:
claims := token.Claims.(jwt.MapClaims)
exp, ok := claims["exp"].(int64)

// что в exp и ok?

Проверка подписи: KeyFunc, пин алгоритма и none-ловушка#

Вся криптография проверки сходится в KeyFunc — функции, которая получает токен и возвращает ключ для проверки подписи. У echo-jwt есть дефолтная, и её тело — лучшее объяснение того, почему конфигурация важнее интуиции. Первая строка: if token.Method.Alg() != config.SigningMethod { return nil, &TokenError{..., Err: fmt.Errorf("unexpected jwt signing method=%v", token.Header["alg"])} } — алгоритм пришит к конфигу, и любой чужой alg отбивается ДО проверки подписи. Это пин алгоритма, и он закрывает класс атак, вошедших в историю: когда библиотеки доверяли alg из заголовка, злоумышленник переписывал его на none — «без подписи» — и проходил проверку. golang-jwt v5 помнит урок: SigningMethodNone существует, но Verify требует, чтобы ключом был служебный тип unsafeNoneMagicConstant — «Only allow 'none' alg type if UnsafeAllowNoneSignatureType is specified as the key»; случайно его не передашь, а echo-jwt пинует чужой alg раньше любой ветки.

Вторая обязанность дефолтного KeyFunc — выбрать ключ. С одним SigningKey всё просто; SigningKeys map[string]interface{} включает ротацию по kid из заголовка: ключ ищется по идентификатору, промах — ошибка «unexpected jwt key id=%v». Тот же интерфейс обслуживает и асимметричные схемы: для RS256 SigningKey — это *rsa.PublicKey, и токены можно проверять, не владея приватным ключом подписи. Когда токены выпускает внешний провайдер, KeyFunc пишут свой — и тогда в него лягут проверка издателя и загрузка ключей по JWKS.

Выбор семейства алгоритмов — выбор модели доверия: HS256 с общим секретом удобен, пока выпускающий и проверяющий — один сервис; распределите роли по нескольким службам — RS256/ES256 с парой ключей спасёт от раздачи секрета всем подряд. Пин алгоритма в конфиге обязателен в обоих случаях.

Проверьте себя: чужой алгоритм против пина

echojwt настроен с SigningKey []byte и дефолтным SigningMethod HS256. Приходит токен, подписанный RS256, с валидной RSA-подписью. Что вернёт middleware?

Refresh-токены: зачем и как#

Компромисс урока уже виден: короткий access — это безопасность при утечке и неудобство для клиента, чья сессия живёт дольше пятнадцати минут. Перезапрашивать логин-пароль каждые четверть часа нельзя, продлевать access на неделю — хранить вечный ключ в заголовке каждого запроса. Стандартное решение — пара токенов: access живёт минуты и ездит в каждом запросе, refresh живёт дни и предъявляется один раз — на /token/refresh — в обмен на новую пару. Утечёт access — урон ограничен сроком; утечёт refresh — можно отреагировать: ротация и чёрные списки по jti дают для этого опору.

Механика обновления строгая: refresh — отдельный токен с отдельным клеймом typ и отдельным TTL; endpoint принимает его в теле, проверяет подпись, алгоритм, exp и typ, после чего выпускает новую пару. Ротация означает: каждый refresh работает один раз, следующий приходит с ответом. Повторное использование старого refresh — сигнал: возможно, токен украли, и честная система бьёт тревогу по jti, который мы завели в клеймах именно ради этого.

И два вопроса, которые задают всегда. Хранение на клиенте: у SPA токены держат в памяти и обновляют refresh-кукой с HttpOnly — перенос механики куки из прошлого урока на новый рельс; localStorage удобнее, но читается скриптами при XSS. Отзыв: у чистого JWT его нет — подпись верна, пока верна подпись; логаут до истечения exp требует состояния: список отозванных jti или короткий access, после которого отзыв сжимается до минут. Мы вернёмся к этому в callout-ловушках — это честная плата за безгосударственность.

Канон: POST /token/refresh обменивает refresh на пару

GO
// main.go — обновление пары: проверка подписи, alg и typ
func refreshToken(c echo.Context) error {
        var body map[string]string
        if err := c.Bind(&body); err != nil {
                return echo.NewHTTPError(http.StatusBadRequest, "битое тело")
        }
        parsed, err := jwt.Parse(body["refresh_token"], func(t *jwt.Token) (interface{}, error) {
                if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                        return nil, fmt.Errorf("unexpected jwt signing method=%v", t.Header["alg"])
                }
                return []byte(devSigningKey), nil
        })
        if err != nil || !parsed.Valid {
                return echo.NewHTTPError(http.StatusUnauthorized, "refresh недействителен")
        }
        claims, ok := parsed.Claims.(jwt.MapClaims)
        if !ok || claims["typ"] != "refresh" {
                return echo.NewHTTPError(http.StatusForbidden, "нужен refresh-токен")
        }
        sub, _ := claims["sub"].(string)
        pair, err := issuePair(sub, "admin") // в проде роль берут из хранилища, а не из refresh
        if err != nil {
                return echo.NewHTTPError(http.StatusInternalServerError, "signing failed").SetInternal(err)
        }
        return c.JSON(http.StatusOK, pair)
}

// регистрация: e.POST("/token/refresh", refreshToken) — ВНЕ группы /api,
// иначе middleware потребует access-токен, который мы как раз обновляем

Три проверки — по одной на каждый слой отказа. jwt.Parse зовёт keyfunc: гвард алгоритма через type assertion *jwt.SigningMethodHMAC — тот же пин, что ставит echo-jwt, но руками, потому что Parse здесь свой. Ошибка парса — ErrTokenExpired «token is expired» при просрочке, ErrTokenSignatureInvalid «token signature is invalid» при подделке — превращается в честный 401. Второй слой — typ: access-токен с валидной подписью, но typ access, отвергается 403-й: без этого клейма вор украденным access мог бы бесконечно продлевать пару. Третий — роль: в учебном каноне она константа, в проде её перечитывают из хранилища, чтобы уволенный сотрудник не продлевал доступ вечным refresh-ом. Endpoint вне /api — типичное размещение: маршруты обновления и входа публичны, всё остальное под middleware.

ErrorHandler echo-jwt: конверт и публичные маршруты#

Дефолтные отказы echo-jwt уже в духе курса — тексты «missing or malformed jwt» и «invalid or expired jwt» со статусами 400/401, — но конверт их сырой: без поля code, без request_id, без ветки для HTML. ErrorHandler из конфига — точка, где отказ JWT встраивается в ErrorHandler десятого урока: та же сигнатура func(c echo.Context, err error) error, тот же конверт {error, code}, причина — в Internal. Полезно знать типы ошибок: провал парса приходит как TokenParsingError, промах всех extractor'ов — как TokenExtractionError; в v4.1.0 их ввели ровно для того, чтобы ErrorHandler различал источник, а метод Is держит совместимость со старой логикой.

Вторая настройка этого узла — ErrorHandler вместе с ContinueOnIgnoredError. Документация конфига описывает сценарий честно: ErrorHandler может «проглотить» ошибку, вернув nil, и тогда middleware продолжит цепочку — фича для сайтов, где часть ресурсов публична, а авторизованным добавляется лишнее: ErrorHandler ставит дефолтное публичное значение в контекст, флаг ContinueOnIgnoredError разрешает идти дальше, и логика ниже решает, показывать ли премиум-контент. Это ровно та развилка, которую в восьмом уроке мы закрывали скипером: пропускать слой целиком или давать ему участвовать с мягким исходом.

И SuccessHandler — третья точка: func(c echo.Context), зовущаяся после валидного токена до next(c). Туда просятся метрики входов, refresh-куки для SPA, подготовка клеймов. Пустой в дефолте — но теперь вы знаете, куда его ставить.

Заполните пропуски: каркас JWT-аутентификации

Соберите каркас: конфиг echo-jwt с пином алгоритма, ключом и контекстом, затем чтение клеймов в обработчике.

jwtMiddleware := echojwt.WithConfig(echojwt.Config{ SigningKey: []byte(devSigningKey), // ключ HMAC SigningMethod: echojwt., // пин алгоритма — константа «HS256» ContextKey: "", // под этим ключом лежит *jwt.Token TokenLookup: ":Authorization:Bearer ", // источник и отрезаемый префикс ErrorHandler: unauthorized, // свой отказ в конверте курса }) func whoAmI(c echo.Context) error { token, ok := c.Get("user").(*jwt.) // весь токен, не клеймы if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "нет токена") } claims := token.Claims.(jwt.MapClaims) sub := claims["sub"].() // числа — float64, строки — string return c.String(http.StatusOK, "привет, "+sub) }

Соберите жизненный цикл access-токена

Расставьте шаги: выдача до предъявления, подпись до отправки, пин алгоритма до next(c).

Строки

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

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

Карточки: JWT в echo

Термин

Анатомия JWT и что она защищает

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

header.payload.signature; заголовок {"alg":"HS256","typ":"JWT"}; payload — клеймы; обе части — base64url (кодирование, не шифрование — читают все). Подпись — HMAC от header.payload: без ключа не подделать. Менять клеймы при сохранении подписи нельзя: сервер пересчитает HMAC и отвергнет.

1 / 6

Что дальше: когда клиент — чужой сайт#

Канон аутентифицировался по-взрослому. POST /login выдаёт пару токенов через SignedString — access с role и typ на пятнадцать минут, refresh на тридцать дней с jti; группа /api закрыта echojwt.Config с пином HS256 и ErrorHandler в конверте курса; гвард requireRole различает 401 и 403; /token/refresh проверяет три слоя — подпись, алгоритм, typ. Каждый факт сверен с исходниками echo-jwt v4.3.1 и golang-jwt v5.2.2: от «HMAC sign expects []byte» до «unexpected jwt signing method=%v».

Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; соберите канон go run main.go и пройдите цикл curl: вход, забор пары, запрос /api/v1/tasks с access, затем тот же запрос с refresh — и убедитесь, что 403 «нужен refresh-токен» приходит на access, предъявленный не по адресу. Сломайте пин: подпишите токен другим алгоритмом и прочитайте Internal в логе ErrorHandler.

Следующий, шестнадцатый урок курса echo — CORS, CSRF и безопасность: middleware.CORS, csrf-токены, безопасные заголовки. Сегодняшний токен живёт в заголовке Authorization — а это ровно тот случай, когда браузер захочет спросить разрешения preflight-ом: чужому сайту с API на другом домене без CORS не обойтись. Навигация по курсу — на странице курса Echo; статическая строка, которую мы сегодня заменили, и анатомия фабрик — в уроке про кастомные middleware; конверт ошибок, в который легли отказы 401/403, — в уроке про HTTPError; сессии, которые продолжают обслуживать браузер, — в уроке про cookie и сессии.

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

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • JWT — три части через точку: header.payload.signature; header и payload — base64url (кодирование, не шифрование: клеймы читает любой носитель), подпись — HMAC от конкатенации. Состояние аутентификации едет в токене: сервер без хранилища проверяет подпись и сроки.
  • Выдача в golang-jwt v5.2.2: jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(key)) — внутри SigningString() (json.Marshal заголовка и клеймов) + Method.Sign + EncodeSegment. HMAC ждёт []byte: string даёт ошибку «HMAC sign expects []byte» (ErrInvalidKeyType). Даты — jwt.NewNumericDate.
  • echo-jwt v4.3.1: ToMiddleware ставит дефолты ContextKey "user", TokenLookup "header:Authorization:Bearer ", SigningMethod AlgorithmHS256, NewClaimsFunc → MapClaims; без ключей — errors.New("jwt middleware requires signing key") и паника WithConfig. В контекст кладётся *jwt.Token целиком: c.Set(config.ContextKey, token); клеймы достаются token.Claims.(jwt.MapClaims), числа в карте — float64.
  • Статусы отказов: нет токена — 400 «missing or malformed jwt» (ErrJWTMissing 401 — наследие), битый/просроченный — 401 «invalid or expired jwt» с ErrTokenExpired внутри. TokenParsingError против TokenExtractionError различают источник в ErrorHandler; ErrorHandler + ContinueOnIgnoredError умеют делать маршрут публичным с дефолтным гостем.
  • Проверка подписи: defaultKeyFunc пинует алгоритм — token.Method.Alg() != SigningMethod → «unexpected jwt signing method=%v»; SigningKeys — ротация по kid («unexpected jwt key id=%v»); none-атака закрыта требованием unsafeNoneMagicConstant. Роли — клейм role и гвард requireRole поверх c.Get("user"): 403 при несовпадении, 401 при отсутствии токена.
  • Refresh-пара: access на минуты ездит в каждом запросе, refresh на дни приходит на POST /token/refresh вне /api; проверки — подпись, пин *jwt.SigningMethodHMAC, exp, typ == "refresh". Ротация: refresh работает один раз, повтор jti — сигнал кражи; отзыв у чистого JWT отсутствует — логаут до exp требует чёрного списка или короткого access.

Домашнее задание

Соберите канон урока на :1323: /login с парой токенов, /api/v1 под echojwt, requireRole на DELETE, /token/refresh вне /api. Первое: пройдите полный цикл curl-ом — вход, запрос списка с access, отказ на refresh вместо access (403 «нужен refresh-токен»), обновление пары, повторный запрос с новым access; зафиксируйте, что старый access после выпуска новой пары работает до собственного exp — и объясните, почему это следствие безгосударственности. Второе: просрочите access вручную (exp за минуту) и поймайте 401; загляните в Internal ErrorHandler — там должен быть «token is expired» (ErrTokenExpired). Третье: запросите /api/v1/tasks вообще без заголовка и убедитесь, что статус 400, а не 401; найдите ветку в исходнике v4.3.1, где это написано. Четвёртое: замените SigningKey в конфиге и подпишите токен RS256 — проверьте текст «unexpected jwt signing method=RS256» в Internal. Пятое: добавьте клейм permissions ([]string) на выдаче и перепишите requireRole на проверку вхождения требуемого права в список; объясните, почему для []string из MapClaims понадобится ассершн к []interface{}.

Личные заметкиПусто
Хранятся только в вашем браузере (Echo · JWT-аутентификация: echo-jwt, клеймы, роли и refresh-токены)

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

Что import-ить для JWT в echo: сам echo-jwt или golang-jwt, и почему версии не совпадают?

Оба, но роли разные. echo-jwt (github.com/labstack/echo-jwt/v4, пакет echojwt) — только middleware: извлекает токен, зовёт парсер, кладёт результат в контекст. Всю криптографию делает golang-jwt/jwt/v5 — тот же пакет стоит в go.mod echo-jwt v4.3.1. Импортируйте echojwt для конфига и jwt для выдачи/чтения клеймов — и держите одну major-версию golang-jwt в проекте: ассершн *jwt.Token работает только между одинаковыми типами, что README echo-jwt фиксирует отдельным разделом про integration-тесты.

Почему echo-jwt отвечает 400 на отсутствие токена, когда все ждут 401?

Разделение намеренное: 400 — «запрос сформирован неправильно» (заголовка нет, extractor ничего не достал), 401 — «аутентификация не пройдена» (токен был, но парс или подпись отвергли). Тело middleware v4.3.1: if lastTokenErr == nil → NewHTTPError(http.StatusBadRequest, "missing or malformed jwt"); иначе 401 «invalid or expired jwt». Хотите единый 401 — переопределите ErrorHandler и верните NewHTTPError(401, ...) на обе ветки; контракт текстов при этом ваш.

HS256 или RS256 для echo-jwt: как выбрать и что поменяется в конфиге?

HS256 — симметричный HMAC: выпускающий и проверяющий владеют одним секретом []byte; просто и быстро, пока сервис один. RS256 — асимметричный RSA: подписывает приватный ключ, проверяют публичным — распределённые системы и внешние издатели без раздачи секрета. В конфиге меняется SigningKey ([]byte → *rsa.PublicKey) и пин SigningMethod: "HS256" → "RS256"; дефолтный KeyFunc в обоих случаях пинует alg и не даст подменить алгоритм. Не пинуйте «любой из» — одно значение, иначе пин теряет смысл.

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

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

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

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

GORM: ORM-библиотека для Go

Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.

  • Подключать БД и настраивать пул соединений
  • Описывать модели и связи: 1:1, 1:N, M:N
  • Писать CRUD и сложные выборки chainable-стилем
  • Контролировать N+1 через Preload