Авторизация go: итоговый проект в одном роутере#
Ситуация: весь курс позади, и в заметках накопился код — выпуск токена из третьего урока, проверка из шестого, пара access/refresh из восьмого, bcrypt из девятого, middleware из десятого, роли из одиннадцатого, denylist из пятнадцатого, куки из шестнадцатого, цикл сессии из девятнадцатого. По отдельности каждая деталь проверена, но проект — это не детали, а их раскладка по файлам и маршрутам. Итоговая сборка решает три задачи: показывает структуру, которая переживёт рост, сводит каждую правку к одному месту и даёт сценарий проверки, который гоняется руками до того, как код уедет в репозиторий. Проект сознательно минималистичный: chi, golang-jwt v5, bcrypt и хранилище в памяти — авторизация go в полном цикле без базы, чтобы стенд поднимался одной командой; где менять на Postgres и Redis, отмечено по ходу. Если порядок middleware в chi всплыл в памяти смутно — перечитайте урок про порядок chi, здесь он пригодится в первый же файл.
Auth сервис golang: план сборки#
Структура из пяти файлов — это ещё не «архитектура», но уже дисциплина: main.go с роутером и конфигурацией, handlers.go с четырьмя хендлерами, tokens.go с выпуском и проверкой JWT, middleware.go с аутентификацией и RequireRole, store.go с пользователями и denylist. Зависимости три: chi, golang-jwt/jwt/v5, x/crypto/bcrypt. Хранилище — карта под мьютексом: для учебного стенда честно, для продакшена меняется на Postgres одним интерфейсом Store, а denylist из пятнадцатого урока на Redis с TTL до exp. Секрет и стоимость bcrypt читаются из окружения с fail fast из двадцать четвёртого урока: сервис со слабым секретом не поднимается. Маршруты делятся на публичные — register, login, refresh, logout — и приватную группу с /me и /admin/stats, к которой применяются RequireAuth и RequireRole. Такой расклад означает, что порядок middleware фиксирован один раз в main.go, а не размазан по хендлерам.
main.go: роутер и порядок middleware
GOfunc main() {
if len(os.Getenv("JWT_SECRET")) < 32 {
log.Fatal("JWT_SECRET: минимум 32 байта")
}
st := NewMemoryStore()
app := &App{store: st, secret: []byte(os.Getenv("JWT_SECRET"))}
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(middleware.Logger)
r.Post("/register", app.handleRegister)
r.Post("/login", app.handleLogin)
r.Post("/refresh", app.handleRefresh)
r.Post("/logout", app.handleLogout)
r.Group(func(priv chi.Router) {
priv.Use(app.RequireAuth)
priv.Get("/me", app.handleMe)
priv.With(app.RequireRole("admin")).Get("/admin/stats", app.handleStats)
})
log.Fatal(http.ListenAndServe(":8080", r))
}Порядок фиксирован: RequestID первым, чтобы Recoverer и Logger писали в логи один и тот же идентификатор запроса. Recoverer раньше Logger — паника попадёт в лог, а не оборвёт соединение. Публичные маршруты объявлены до приватной группы: Group с Use применяет RequireAuth только к своим маршрутам, и регистрация с логином остаются свободными.
Регистрация: POST /register с bcrypt#
Регистрация — единственный эндпоинт, который создаёт учётные данные, и он держит три правила. Валидация до криптографии: email по простому признаку, пароль не короче восьми символов, лишние поля игнорируются — а не кладутся в структуру как есть. Хеширование bcrypt из девятого урока с cost из конфигурации: GenerateFromPassword вызывается на каждый registration, и cost 12 на учебном стенде даёт заметную сотню миллисекунд — терпимо, потому что регистрация редкая. Уникальность email проверяется до вставки, ответ на дубликат — 409: в отличие от логина, здесь честность безопасна, регистрирующий знает, что email его. Ответ 201 с идентификатором созданного пользователя, без токенов: выпуск сессии — работа логина, и смешивать их не стоит, иначе подпись /register разрастается до второго /login. Токен-версия пользователя ставится единицей при создании — поле, которое в девятнадцатом уроке помогало выгонять сессии при смене пароля.
Хендлер /register
GOfunc (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
var in RegisterIn
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "битый JSON", http.StatusBadRequest)
return
}
if len(in.Password) < 8 {
http.Error(w, "пароль от 8 символов", http.StatusBadRequest)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), a.bcryptCost)
if err != nil {
http.Error(w, "не удалось захешировать", http.StatusInternalServerError)
return
}
id, err := a.store.CreateUser(in.Email, string(hash))
if errors.Is(err, ErrEmailTaken) {
http.Error(w, "email занят", http.StatusConflict)
return
}
writeJSON(w, map[string]any{"id": id}, http.StatusCreated)
}Порядок проверок дешёвый к дорогому: JSON, длина пароля, bcrypt, вставка. GenerateFromPassword сам солит значение — в базе лежит строка вида bcrypt-cost-salt-hash, из которой CompareHashAndPassword достанет всё нужное. Ошибка уникальности приходит из store как ErrEmailTaken и переводится в 409; внутренние детали хранилища наружу не утекают.
Логин: выпуск пары access и refresh#
Логин собирает жизненный цикл из девятнадцатого урока. Сравнение пары проходит через CompareHashAndPassword, а ответ на неверный email и неверный пароль — один и тот же 401 с одинаковым текстом: перебирающему нечего различать. После успеха выпускаются два токена: access на пятнадцать минут с jti, subject и ролью — методы из третьего урока с RegisteredClaims из четвёртого; refresh на тридцать дней со своим jti. Транспорт по схеме из шестнадцатого урока: refresh уезжает в httpOnly cookie с Path /refresh и SameSite Lax, access возвращается телом — фронтенд держит его в памяти, как договорились в четырнадцатом. CSRF-слой из двадцать первого урока вписывается следующим шагом: кука csrf и проверка X-CSRF-Token на изменяющих маршрутах. И здесь же аудит-строка: кто, с какого адреса и когда вошёл.
Хендлеры /login и выпуска токенов
GOfunc (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
var in Credentials
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "битый JSON", http.StatusBadRequest)
return
}
u, err := a.store.UserByEmail(in.Email)
if err != nil || bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(in.Password)) != nil {
http.Error(w, "неверные данные входа", http.StatusUnauthorized)
return
}
access, _ := a.IssueAccess(u.ID, u.Role, 15*time.Minute)
refresh, _ := a.IssueRefresh(u.ID, 30*24*time.Hour)
http.SetCookie(w, a.refreshCookie(refresh))
writeJSON(w, map[string]string{"access": access}, http.StatusOK)
}IssueAccess и IssueRefresh — обёртки над jwt.NewWithClaims с SignedString из третьего урока, каждая ставит свой jti и exp. Роль читается из профиля, а не из запроса: клиент не выбирает свои права. Одинаковый ответ на обе ошибки логина не раскрывает существование email. Access в теле, refresh в куке — transports разделены по сроку и назначению.
Ротация refresh и идемпотентный logout#
Refresh и logout — короткие, потому что вся логика собиралась раньше, и здесь остаётся только расставить её по маршрутам. /refresh читает куку, проверяет токен, смотрит denylist: jti отозван — значит старый refresh пришёл повторно, и правильная реакция из девятнадцатого урока — отозвать всю семью сессий и вернуть 401. Иначе пара ротируется: новый access, новый refresh, старый jti в denylist с TTL до exp, новая кука. /logout отзывает оба jti, сбрасывает куку с MaxAge -1 и теми же Name и Path, отвечает 204 идемпотентно. Приватная группа держит /me: claims из контекста, middleware из десятого урока уже всё проверил, хендлеру остаётся вынуть subject и роль. /admin/stats прикрыт RequireRole из одиннадцатого — авторизация поверх аутентификации, два разных кода отказа: 401 на отсутствующую сессию, 403 на несовпадение роли. Отдельная деталь: узкий Path куки refresh в проекте — /refresh, а не /, поэтому на остальные маршруты cookie просто не доезжает, и middleware их не видит. Для /admin/stats роль дополнительно проверяется вторым слоем — никому не нужная привычка для одного маршрута, обязательная для десяти.
RequireAuth и RequireRole на chi
GOfunc (a *App) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("access")
token := ""
if err == nil {
token = c.Value
} else if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
token = strings.TrimPrefix(h, "Bearer ")
}
claims := AppClaims{}
_, perr := jwt.ParseWithClaims(token, &claims, a.keyfunc,
jwt.WithValidMethods([]string{"HS256"}))
if perr != nil || a.store.Revoked(claims.ID) {
http.Error(w, "сессия недействительна", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(WithClaims(r.Context(), claims)))
})
}Дуал кука плюс Bearer — практичный переходный вариант из шестнадцатого урока: браузер идёт кукой, мобильный клиент и curl — заголовком. ParseWithClaims с белым списком и проверкой denylist делают middleware единственным местом валидации access. Дальше хендлеры получают claims через контекст и не знают, каким транспортом токен приехал.
Три дыры, которые портят иначе приличные сборки. Первая — denylist в памяти без TTL: карта растёт вечно и через месяц сервис ест гигабайт; каждая запись обязана жить до exp и убираться сама. Вторая — тесты с bcrypt по умолчанию: сто логин-тестов с cost 12 занимают минуты, и тесты перестают запускать; в тестовой конфигурации ставится минимальный cost, как в двадцатом уроке. Третья — роли в токене без token_version: смена пароля и разжалование админа не действуют до конца exp access; поле версии в claims и проверка его в store делают отзыв мгновенным.
Эндпоинты итогового сервиса#
| Маршрут | Доступ | Действия | Ответ |
|---|---|---|---|
| POST /register | публичный | валидация, bcrypt, вставка пользователя | 201 с id; 409 на дубликат |
| POST /login | публичный | bcrypt-сравнение, выпуск пары, cookie для refresh | 200, access в теле |
| POST /refresh | кука refresh | проверка, denylist, ротация пары | 200, новый access |
| POST /logout | публичный | отзыв jti, сброс куки | 204 идемпотентно |
| GET /me | RequireAuth | чтение claims из контекста | 200, профиль |
| GET /admin/stats | RequireRole admin | проверка роли после аутентификации | 200 или 403 |
Понадобится локальный Go: go mod init, go get chi, golang-jwt и bcrypt, затем go run. Сценарий curl с файлом куки: register, login, /me с access, refresh, logout, повторный refresh — последний обязан вернуть 401. Браузерная песочница курса HTTP не умеет — она поддерживает только fmt, strings, strconv, math, sort, errors и time, — поэтому хендлеры гоняются локально, а в песочнице можно отрепетировать выпуск claims структурой и base64url-кодирование.
Проверка: порядок middleware
Почему RequestID стоит в цепочке раньше Logger?
Проверка: повтор старого refresh
Клиент присылает на /refresh токен, чей jti уже в denylist. Реакция сервиса?
Логин с несуществующим email
Прочитайте код и выберите, что он выведет:
// В store только u1@example.com
// Запрос: POST /login {"email":"ghost@example.com","password":"whatever123"}
u, err := a.store.UserByEmail("ghost@example.com")
// err != nil
if err != nil || bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(in.Password)) != nil {
http.Error(w, "неверные данные входа", http.StatusUnauthorized)
return
}Соберите хеш и выпуск токена
Допишите bcrypt-хеширование, сравнение и подпись access-токена.
Соберите полный цикл сессии
Расставьте шаги жизненного цикла одной сессии в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: итоговый проект
Курс закрыт: от анатомии токена до сервиса, который регистрирует, логинит, продлевает и отзывает сессии с ролями. Собранный проект — каркас, а не финальная точка: перечисленные ниже направления растут из него без переделки, потому что границы нарисованы заранее. Куда расти дальше: заменить MemoryStore на Postgres, перенести denylist в Redis и перевести тесты с miniredis — урок про тесты Redis-кода; добавить rate limiting на логин — rate limiter на Redis; вынести identity в отдельный сервис по схеме двадцать второго урока; покрыть таблицей кейсов из двадцатого урока и приёмами из курса go-advanced. Чек-лист безопасности — двадцать четвёртый урок — прогоните перед публикацией. Карта всех тем — на странице jwt-go, с которой курс начинался.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Структура из пяти файлов: main, handlers, tokens, middleware, store — каждая правка живёт в одном месте.
- Порядок middleware фиксируется в main.go: RequestID, Recoverer, Logger; приватная группа получает RequireAuth и RequireRole.
- Регистрация честная: bcrypt с конфигурируемым cost, 409 на дубликат, 201 без токенов — сессию выпускает логин.
- Логин одинаково отвечает на неверный email и пароль, выпускает пару с jti и кладёт refresh в узкую httpOnly куку.
- Refresh ротируется с denylist, повтор старого refresh отзывает семью; logout идемпотентен с MaxAge -1.
- Три забытые детали пет-проектов: TTL denylist, минимальный cost bcrypt в тестах, token_version для мгновенного отзыва.
Домашнее задание
Соберите сервис из пяти файлов и пройдите curl-сценарий: register, login, /me, refresh, logout, повторный refresh с сохранённой кукой — финальный запрос обязан вернуть 401. Затем проверьте роли: user на /admin/stats получает 403, admin — 200. Добавьте token_version в claims и убедитесь, что инкремент версии мгновенно гасит ранее выпущенный access. В конце замените карту denylist на Redis с EXPIRE и повторите сценарий — поведение не должно измениться.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему /register отвечает 409, а /login одинаков на обе ошибки?
У них разные модели угроз: при регистрации пользователь сам знает, что email его, и честный 409 экономит время; при логине разница ответов подсказывает перебирающему, какие email существуют, поэтому обе причины отказа выглядят одинаково. Это два осознанных компромисса, а не непоследовательность.
Где здесь место для Postgres и Redis?
За интерфейсом Store: пользователи уходят в Postgres, denylist — в Redis с EXPIRE до exp, сессии при желании — по схеме из курса go-redis. Границы интерфейса нарисованы так, чтобы замена не трогала хендлеры и middleware — это главная причина держать хранилище отдельным файлом с первого дня.
Достаточно ли этого кода для продакшена?
Как каркас — да, как релиз — нет: не хватает миграций, конфигурации из менеджера секретов, метрик и аудита в полноценном хранилище, rate limiting на логин и покрытого тестами denylist. Чек-лист двадцать четвёртого урока показывает, какие пункты остались открытыми, — это и есть план доводки.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния