В уроке про middleware аутентификации сервис научился отвечать на вопрос «кто вы»: Auth разбирает Bearer-токен и кладёт claims в контекст запроса. Но «кто вы» и «что вам можно» — разные вопросы, и второй решается отдельным слоем. Авторизация ролей golang строится именно так: middleware RequireRole достаёт роль из уже проверенных claims и либо пропускает запрос дальше, либо обрывает его кодом 403. Важно, что это второй, а не первый слой: RequireRole не лезет в заголовки и не парсит токен заново — он доверяет факту аутентификации, как доверяет ему и хендлер. В этом уроке соберём фабрику RequireRole с параметрами, подключим её на вложенную группу chi-маршрутов, разведём 401 и 403, добавим вариант с иерархией ролей и разберём ошибку, которую делают почти все: пониженный в базе админ ходит по админке до конца срока своего токена.
Роли в claims: откуда они берутся и почему им можно верить#
Роль попадает в токен при выпуске: в login-хендлере из урока про bcrypt пароль сошёлся, и вместе с парой access/refresh сервер кладёт в claims поле Role — строку, прочитанную из колонки users.role. Дальше работает цепочка доверия: клиент не может дописать себе admin, потому что payload подписан, а подпись сверяется — ровно та защита от подделки, которую разбирали во втором уроке про устройство jwt. Отсюда следствие, экономящее недели отладки: любые права, которые проверяет RequireRole, достоверны ровно настолько, насколько надёжна подпись и насколько аккуратно сервер заполняет поле при выпуске. Две дисциплины по оформлению. Первая: маленький фиксированный набор строковых ролей — user, editor, admin — а не свободный текст из анкеты; опечатка Editor вместо editor при сравнении строк даст тихий отказ в правах. Вторая: не кладите в токен весь список разрешений — payload растёт с каждым новым правом и уезжает в каждый запрос; роль — сжатое представление, а разворачивать её в конкретные проверки будет код на сервере.
RequireRole: фабрика middleware
GOfunc RequireRole(roles ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "нет аутентификации", http.StatusUnauthorized)
return
}
for _, role := range roles {
if claims.Role == role {
next.ServeHTTP(w, r)
return
}
}
http.Error(w, "недостаточно прав", http.StatusForbidden)
})
}
}RequireRole — фабрика: вызов с аргументами возвращает middleware нужной сигнатуры, а список ролей запоминается замыканием. Роль достаётся через ClaimsFromContext — тем же context.Value под приватным ключом, которым десятый урок клал claims. Нет аутентификации — 401; аутентификация есть, роль не подошла — 403. Return после каждого http.Error обязателен: без него выполнение уйдёт в next с непроверенными правами.
Подключение RequireRole на группу маршрутов#
Подключается RequireRole не к каждому маршруту отдельно, а к группе — так, как chi группирует middleware в уроке про Route и Group: публичные маршруты снаружи, защищённые внутри группы с Auth, административные — во вложенной группе с RequireRole. Вложенность читается как матрёшка: запрос проходит Auth, потом RequireRole, только потом хендлер; каждый слой вносит свою гарантию и не делает чужую. Один вызов Use внутри группы защищает сразу все маршруты ветки — админ-эндпоинт, добавленный завтра, не потребует вспоминать про проверку: она уже висит на группе.
Вложенные группы: публичное, защищённое, админское
GOr := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Use(Auth) // слой из десятого урока: кладёт claims в контекст
r.Get("/me", meHandler)
r.Group(func(r chi.Router) {
r.Use(RequireRole("admin"))
r.Get("/admin/users", listUsersHandler)
r.Delete("/admin/users/{id}", deleteUserHandler)
r.Post("/admin/posts", createPostHandler)
})
})Вложенная группа наследует middleware родителя: Auth применяется ко всем маршрутам ветки, RequireRole — только к административной. Маршрут /me доступен любому аутентифицированному, /admin/users — только с ролью admin. Новые маршруты внутри админской группы получают проверку автоматически: точка, где можно забыть про права, просто не существует.
Токен — снимок роли на момент выпуска: уберите у пользователя admin в базе, а его access рассчитан ещё на пятнадцать минут — всё это время RequireRole будет пускать его в админку, потому что в claims всё ещё admin и подпись честная. Моё правило: access на пятнадцать минут, изменение прав подхватывается при refresh — тогда окно ошибки равно сроку access. Когда и пятнадцати минут много — бан или инцидент с утечкой — нужен принудительный отзыв, и он разбирается в уроке про denylist по jti.
401 против 403: два разных отказа#
Статусы отказов перепутаны в половине проектов, а у каждого своё значение. 401 Unauthorized значит «я не знаю, кто вы»: токена нет, он битый, истёкший или подписан чужим ключом — клиенту предлагается пройти аутентификацию заново, обычно через refresh или логин. 403 Forbidden значит «кто вы, я знаю, но прав нет»: токен валиден, роль user, а маршрут административный; повторная аутентификация не поможет — прав не появится. Из пары следуют два практических правила. Первое: RequireRole всегда стоит после Auth в цепочке — иначе он читает пустой контекст и отвечает 401 на запросы, которым положен 403. Второе: клиент на 403 не должен ретраить — поведение не изменится; на 401 — должен попробовать обновить токен и повторить запрос один раз.
Правила ролей в сервисе#
- Проверка прав — только на сервере: спрятанная в интерфейсе кнопка не защита, RequireRole обязана стоять на маршруте.
- Роль — сжатое представление прав: не кладите в токен список разрешений, который растёт вместе с фичами.
- Имена ролей — маленький фиксированный набор в нижнем регистре: сравнение строк не прощает опечаток.
- 401 — не аутентифицирован, 403 — аутентифицирован, но прав нет; клиенты реагируют на них по-разному.
- RequireRole ставится после Auth: пустой контекст означает отказ аутентификации, а не авторизации.
- Право на объект — мой документ, моя корзина — ролями не проверяется: только сравнением владельца в хендлере.
Лог отказов: что делать с волной 403#
RequireRole молчит на пропуске — и это правильно: шум от успешных проверок никому не нужен. Отказы же стоит логировать отдельно: строка с путём, ролью из claims и UserID отвечает на главный вопрос расследования — кто именно стучался в закрытый маршрут. Волна 403 — всегда сигнал, и расшифровывать её нужно по источнику. Пара отказов после релиза — фронтенд начал ходить на маршрут, который ему не положен: либо баг, либо забыли выдать роль. Ровные отказы от одного UserID по многим маршрутам — перебор прав, и это уже не баг, а инцидент: пользователь с валидным токеном систематически зондирует админку. Единичные отказы от обычных пользователей — чаще всего устаревшая закладка или кэшированная ссылка, и алертить их не нужно. Моё правило: отказ 403 попадает в лог с уровнем info и своим префиксом, а алерт вешается на скорость роста, а не на каждый факт — нормальный сервис с ролями живёт с фоновым потоком 403, и это нормально.
Проверка: отказ без прав
Аутентифицированный пользователь с ролью user открыл /admin/users. Что вернёт RequireRole("admin")?
Проверка: источник роли
Откуда RequireRole достаёт роль пользователя?
Auth забыли подключить
Прочитайте код и выберите, что он выведет:
r.Group(func(r chi.Router) {
r.Use(RequireRole("admin")) // Auth на группу не повесили
r.Get("/admin/users", listUsersHandler)
})
// администратор с валидным токеном открывает /admin/users
// что он получит?Роли — инструмент крупной гранулярности, и у него есть граница применимости, которую стоит видеть заранее. Правило «только админ удаляет пользователей» роли закрывают; правило «пользователь редактирует свои документы» — уже нет, потому что право зависит не от роли, а от объекта: документ номер семнадцать принадлежит Марии, и админ трогать его не обязан. Проверку владения ролью не решить — она живёт в хендлере рядом с загрузкой объекта: достали запись, сравнили OwnerID с claims.UserID, не совпало — 403. Между ролями и правами на объекты лежит промежуточная ступень — строки-разрешения вроде posts:delete, которые кладут в токен сервисы с большим числом операций; это гибко, но раздувает payload и усложняет ревью, поэтому для типового CRUD я остаюсь на простых ролях. Признак, что роли перестали справляться: в коде появились проверки вида role == admin и deptID == claims.Dept одновременно — значит, пора выносить права из токена в данные.
Иерархия ролей: когда user ниже editor, а editor ниже admin
GOvar roleRank = map[string]int{
"user": 1,
"editor": 2,
"admin": 3,
}
func RequireMinRole(min string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "нет аутентификации", http.StatusUnauthorized)
return
}
if roleRank[claims.Role] < roleRank[min] {
http.Error(w, "недостаточно прав", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}Ранги вместо списка допущений: RequireMinRole("editor") пропустит и editor, и admin, потому что ранг admin выше. Забытая в map новая роль получает ранг ноль — доступ к ней закрыт по умолчанию, и это правильная сторона ошибки: права падают, а не открываются. Иерархия удобна, пока роли действительно выстраиваются в линию; ветвящиеся права она не выражает.
Понадобятся локальный Go и chi: go mod init, go get github.com/go-chi/chi/v5, go run. Выпустите себе два access-токена с разными ролями через код выпуска из девятого урока и прогоните curl с Bearer по /me и /admin/users: первый маршрут ответит обоим, второй — только админу. Так разница 401 и 403 становится видимой за пять минут.
Восстановите RequireRole
Допишите middleware: чтение контекста, проверка флага и сравнение роли.
Соберите начало RequireRole
Расставьте строки middleware в правильном порядке: от обёртки хендлера до тихого отказа без claims.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: роли и права
Требования к проверке ролей не зависят от того, чем подписан токен: HMAC из пятого урока или асимметричная пара из следующего — RequireRole читает одни и те же claims. В уроке про RSA и ECDSA разберём раскладку, при которой выпуск и проверка живут в разных сервисах: она становится естественной ровно тогда, когда админку проверяет не тот же бинарник, что выдаёт токены. Если ваш стек — gin, а не chi, та же проверка в его терминах — c.Get, c.AbortWithStatusJSON, группы — собрана в уроке про JWT в gin. Карта всех тем — на странице курса jwt-go, а в итоговом проекте RequireRole ляжет на auth-сервис с refresh и ролями целиком.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Роли jwt проверяются вторым слоем: RequireRole читает role из claims в контексте, а не парсит токен заново.
- Фабрика RequireRole("admin") возвращает middleware; вложенная группа chi защищает все маршруты ветки одним Use.
- 401 — нет аутентификации, 403 — аутентификация есть, прав нет; RequireRole всегда ставится после Auth.
- Токен — снимок роли на момент выпуска: понижение в базе подхватывается только с новым токеном.
- Иерархия рангов через map закрывает запросы «не ниже editor»; забытая роль получает нулевой ранг и закрытый доступ.
- Права на объекты — владелец документа — ролями не проверяются: это сравнение в хендлере.
Домашнее задание
Добавьте в сервис из десятого урока RequireRole и RequireMinRole: закройте /admin/* ролью admin, а редакторские маршруты — минимальным рангом editor. Выпустите токены для user и admin и убедитесь, что 401 и 403 приходят в правильных случаях. Затем понизьте админа в базе и посмотрите, сколько минут он остаётся админом, — и посчитайте, что изменит сокращение exp до пяти минут.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли класть список ролей в токен вместо одной строки?
Можно: Role []string с проверкой вхождения циклом. Минус — рост payload и соблазн трактовать список как разрешения. Для типовых сервисов одной строки хватает, а если ролей становится много, это сигнал переходить к строкам-разрешениям или хранить права в базе.
Почему 401, а не 403, когда Auth не выполнялся?
401 сообщает клиенту, что проблема в самой аутентификации и её стоит повторить: refresh или логин. RequireRole без Auth видит пустой контекст и не может отличить отсутствие токена от отсутствия прав — по умолчанию он честно отвечает 401, а правильное лечение — поставить Auth раньше в цепочке.
Куда деть правила вида «админ только своего отдела»?
Это гибрид роли и объекта: роль admin проверяет RequireRole, а принадлежность отдела — сравнение поля из claims, например DeptID, с данными объекта в хендлере. В токен добавляется одно поле, но дерево прав или список отделов целиком туда класть не стоит.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния