Содержание урока · 13 разделов
- echo jwt: токен вместо статической строки
- Анатомия JWT: три части и base64url
- Клеймы RegisteredClaims: поле за полем
- Клеймы: RegisteredClaims, MapClaims и float64
- TokenLookup: откуда echo-jwt достаёт токен
- Роли и гварды: requireRole поверх клейма
- Проверка подписи: KeyFunc, пин алгоритма и none-ловушка
- Refresh-токены: зачем и как
- ErrorHandler echo-jwt: конверт и публичные маршруты
- Что дальше: когда клиент — чужой сайт
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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: поле за полем#
| Клейм | Поле структуры | Смысл |
|---|---|---|
| iss | Issuer string | Кто выдал токен — имя вашего сервиса |
| sub | Subject string | О ком токен — у нас login пользователя |
| aud | Audience ClaimStrings | Для кого токен — получатель, один или список |
| exp | ExpiresAt *NumericDate | Срок смерти в секундах; просроченный токен — ErrTokenExpired |
| nbf | NotBefore *NumericDate | Не действует раньше; до момента — ErrTokenNotValidYet |
| iat | IssuedAt *NumericDate | Момент выпуска; в будущем — ErrTokenUsedBeforeIssued |
| jti | ID 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 с пином алгоритма, ключом и контекстом, затем чтение клеймов в обработчике.
Соберите жизненный цикл access-токена
Расставьте шаги: выдача до предъявления, подпись до отправки, пин алгоритма до next(c).
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Первая: ключ в литерале исходника — devSigningKey в каноне учебный; в проде он едет из окружения, и рестарт с новым ключом инвалидирует все выпущенные токены разом, как и в securecookie. Вторая: бессрочные access-токены — «выпущу на год и забуду» превращает каждую утечку в год компрометации: держите exp коротким, продлевайте refresh-ом. Третье: клеймы читает каждый — base64url не шифрование; персональные данные и секреты в payload не кладут, а если конфиденциальность нужна, шифруют весь токен (JWE) отдельным инструментом. Четвёртая: доступ-как-refresh — access может пройти проверку подписи на /token/refresh, если не проверять typ: клейм typ и отдельная проверка — обязательная пара. Пятая: забытый пин алгоритма при своём KeyFunc — скопировали Parse без гварда alg и открыли дверь атаки с подменой alg; echo-jwt пинует дефолтом, ручной ParseWithClaims — ваша ответственность.
Карточки: JWT в echo
Что дальше: когда клиент — чужой сайт#
Канон аутентифицировался по-взрослому. 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{}.
Личные заметкиПусто
Вопросы и ответы по уроку
Что 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 и не даст подменить алгоритм. Не пинуйте «любой из» — одно значение, иначе пин теряет смысл.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
GORM: ORM-библиотека для Go
Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.
- Подключать БД и настраивать пул соединений
- Описывать модели и связи: 1:1, 1:N, M:N
- Писать CRUD и сложные выборки chainable-стилем
- Контролировать N+1 через Preload