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

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

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

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

Итоговый проект: auth-сервис с refresh

Auth сервис golang — финальный проект курса: четыре эндпоинта, роли и полный цикл сессии собираются в один chi-роутер из деталей, которые мы разобрали в первых двадцати четырёх уроках. Регистрация с bcrypt, логин с выпуском пары access/refresh, ротация refresh с denylist, /me с ролями — и всё это проверяется curl-сценарием за пять минут. Здесь собран код целиком, со связками на уроки, где каждое решение обосновано.

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

Авторизация 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

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

GO
func (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 и выпуска токенов

GO
func (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

GO
func (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 через контекст и не знают, каким транспортом токен приехал.

Эндпоинты итогового сервиса#

МаршрутДоступДействияОтвет
POST /registerпубличныйвалидация, bcrypt, вставка пользователя201 с id; 409 на дубликат
POST /loginпубличныйbcrypt-сравнение, выпуск пары, cookie для refresh200, access в теле
POST /refreshкука refreshпроверка, denylist, ротация пары200, новый access
POST /logoutпубличныйотзыв jti, сброс куки204 идемпотентно
GET /meRequireAuthчтение claims из контекста200, профиль
GET /admin/statsRequireRole adminпроверка роли после аутентификации200 или 403

Проверка: порядок 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-токена.

hash, err := bcrypt.([]byte(in.Password), a.bcryptCost) if err != nil { http.Error(w, "не удалось захешировать", http.StatusInternalServerError) return } if bcrypt.([]byte(u.Hash), []byte(in.Password)) != nil { http.Error(w, "неверные данные входа", http.StatusUnauthorized) return } tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).(a.secret)

Соберите полный цикл сессии

Расставьте шаги жизненного цикла одной сессии в правильном порядке.

Строки

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

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

Карточки: итоговый проект

Термин

Структура проекта

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

main.go с роутером и порядком middleware, handlers.go, tokens.go, middleware.go, store.go. Зависимости: chi, golang-jwt v5, bcrypt.

1 / 6

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

Личные заметкиПусто
Хранятся только в вашем браузере (JWT · Итоговый проект: auth-сервис с refresh)

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

Почему /register отвечает 409, а /login одинаков на обе ошибки?

У них разные модели угроз: при регистрации пользователь сам знает, что email его, и честный 409 экономит время; при логине разница ответов подсказывает перебирающему, какие email существуют, поэтому обе причины отказа выглядят одинаково. Это два осознанных компромисса, а не непоследовательность.

Где здесь место для Postgres и Redis?

За интерфейсом Store: пользователи уходят в Postgres, denylist — в Redis с EXPIRE до exp, сессии при желании — по схеме из курса go-redis. Границы интерфейса нарисованы так, чтобы замена не трогала хендлеры и middleware — это главная причина держать хранилище отдельным файлом с первого дня.

Достаточно ли этого кода для продакшена?

Как каркас — да, как релиз — нет: не хватает миграций, конфигурации из менеджера секретов, метрик и аудита в полноценном хранилище, rate limiting на логин и покрытого тестами denylist. Чек-лист двадцать четвёртого урока показывает, какие пункты остались открытыми, — это и есть план доводки.

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

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

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

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

Telegram-боты на Go: от эха до полноценного сервиса

Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.

  • Создавать бота через BotFather и хранить токен безопасно
  • Получать обновления через long polling и webhook
  • Отвечать сообщениями, командами и клавиатурами
  • Строить многошаговые диалоги с сохранением состояния