---
title: "Итоговый проект: auth-сервис с refresh"
description: "auth сервис golang как итоговый проект: chi с /register, /login, /refresh и ролями; авторизация go итоговый проект — bcrypt, ротация, denylist. С кодом."
course: "JWT-аутентификация в Go: golang-jwt и bcrypt"
url: "https://golangmaster.ru/courses/jwt-go/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-10-02"
minutes: 24
---

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

*Курс «JWT-аутентификация в Go: golang-jwt и bcrypt», урок 25 из 25. ~24 минут. Обновлено 02.10.2026. Источник: https://golangmaster.ru/courses/jwt-go/lessons/25-itogovyj-proekt/*

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

### Авторизация go: итоговый проект в одном роутере

Ситуация: весь курс позади, и в заметках накопился код — выпуск токена из третьего урока, проверка из шестого, пара access/refresh из восьмого, bcrypt из девятого, middleware из десятого, роли из одиннадцатого, denylist из пятнадцатого, куки из шестнадцатого, цикл сессии из девятнадцатого. По отдельности каждая деталь проверена, но проект — это не детали, а их раскладка по файлам и маршрутам. Итоговая сборка решает три задачи: показывает структуру, которая переживёт рост, сводит каждую правку к одному месту и даёт сценарий проверки, который гоняется руками до того, как код уедет в репозиторий. Проект сознательно минималистичный: chi, golang-jwt v5, bcrypt и хранилище в памяти — авторизация go в полном цикле без базы, чтобы стенд поднимался одной командой; где менять на Postgres и Redis, отмечено по ходу. Если порядок middleware в chi всплыл в памяти смутно — перечитайте [урок про порядок chi](/courses/chi/lessons/18-middleware-poryadok/), здесь он пригодится в первый же файл.

### 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 из [пятнадцатого урока](/courses/jwt-go/lessons/15-otzyv-tokenov/) на Redis с TTL до exp. Секрет и стоимость bcrypt читаются из окружения с fail fast из [двадцать четвёртого урока](/courses/jwt-go/lessons/24-luchshie-praktiki/): сервис со слабым секретом не поднимается. Маршруты делятся на публичные — 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 из [девятого урока](/courses/jwt-go/lessons/09-bcrypt-paroli/) с 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

Логин собирает жизненный цикл из [девятнадцатого урока](/courses/jwt-go/lessons/19-zhiznennyj-cikl/). Сравнение пары проходит через CompareHashAndPassword, а ответ на неверный email и неверный пароль — один и тот же 401 с одинаковым текстом: перебирающему нечего различать. После успеха выпускаются два токена: access на пятнадцать минут с jti, subject и ролью — методы из [третьего урока](/courses/jwt-go/lessons/03-ustanovka-golang-jwt/) с RegisteredClaims из [четвёртого](/courses/jwt-go/lessons/04-claims/); refresh на тридцать дней со своим jti. Транспорт по схеме из [шестнадцатого урока](/courses/jwt-go/lessons/16-httponly-cookie/): refresh уезжает в httpOnly cookie с Path /refresh и SameSite Lax, access возвращается телом — фронтенд держит его в памяти, как договорились в [четырнадцатом](/courses/jwt-go/lessons/14-hranenie-tokena/). CSRF-слой из [двадцать первого урока](/courses/jwt-go/lessons/21-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 из [десятого урока](/courses/jwt-go/lessons/10-middleware-auth/) уже всё проверил, хендлеру остаётся вынуть subject и роль. /admin/stats прикрыт RequireRole из [одиннадцатого](/courses/jwt-go/lessons/11-roli-i-prava/) — авторизация поверх аутентификации, два разных кода отказа: 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 через контекст и не знают, каким транспортом токен приехал.

> **Живое место: что забывают в пет-проектах.** Три дыры, которые портят иначе приличные сборки. Первая — 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?

- Чтобы Logger успел записать панику
- ✅ Чтобы каждая запись лога несла идентификатор запроса и строки сходились в трассировку
- Чтобы RequestID мог отменить запрос по таймауту
- Порядок не влияет ни на что

Middleware в chi оборачивает всё, что ниже: RequestID кладёт идентификатор в контекст, и все последующие записи — Logger, хендлеры, Recoverer — его видят. Если поменять местами, логи останутся, но связать строки одного запроса станет нельзя. Полный разбор — в уроке про порядок middleware chi.

### Проверка: повтор старого refresh

**Вопрос:** Клиент присылает на /refresh токен, чей jti уже в denylist. Реакция сервиса?

- Выдать новый access, refresh не ротировать
- ✅ Вернуть 401 и отозвать всю семью сессий пользователя
- Молча вернуть новую пару
- Подождать истечения exp refresh

Ротированный refresh не должен появляться дважды: второй приход означает копию токена вне вашего контроля. Отзыв семьи жертвует удобством одного пользователя ради остановки кражи. Тихая выдача новой пары приучает атакующего повторять запрос — это худший из вариантов.

### Логин с несуществующим email

```go
// В 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
}
```

**Что выведет программа?**

- 200: пользователь создастся автоматически
- ✅ 401 «неверные данные входа» — тот же ответ, что и на неверный пароль
- 404 «пользователь не найден»
- 500: паника на нулевом u.Hash

Короткое вычисление or не даёт CompareHashAndPassword выполниться при err != nil, поэтому паники нет — нулевой Hash не разыменуется. Ответ одинаковый для обеих причин отказа: перебирающий не узнает, какие email существуют. 404 выдал бы существование учётных записей.

### Соберите хеш и выпуск токена

Допишите bcrypt-хеширование, сравнение и подпись access-токена.

```go
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)
```

Подсказка: Создание хеша — GenerateFromPassword, проверка — CompareHashAndPassword, подпись токена — SignedString.

Правильные ответы в пропусках: `GenerateFromPassword`, `CompareHashAndPassword`, `SignedString`

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

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

Правильный порядок строк:

1. `POST /register: bcrypt-хеш, пользователь в store`
2. `POST /login: пара токенов, refresh в httpOnly куке`
3. `GET /me с access: RequireAuth кладёт claims в контекст`
4. `access истёк: POST /refresh с кукой — ротация пары`
5. `старый jti уехал в denylist с TTL до exp`
6. `POST /logout: отзыв jti, сброс куки, 204`

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

- **Структура проекта** — main.go с роутером и порядком middleware, handlers.go, tokens.go, middleware.go, store.go. Зависимости: chi, golang-jwt v5, bcrypt.
- **Дуал транспорта** — Access читается из куки или из Authorization: Bearer: браузер идёт кукой, мобильный клиент и curl — заголовком. Валидация одна и та же.
- **409 на дубликат** — Регистрация честно отвечает 409 на занятый email — в отличие от логина, где ошибки одинаковы, чтобы не подсказывать перебору существующие учётки.
- **Токен-версия** — Поле в claims, проверяемое по store: смена пароля или разжалование инкрементирует версию и мгновенно гасит все прежние access-токены.
- **Fail fast конфига** — Секрет короче 32 байт или не задан — сервис падает на старте до подъёма роутера, а не подписывает токены слабым значением.
- **Denylist с TTL** — Каждая запись живёт ровно до exp отозванного токена и убирается сама: карта без TTL — утечка памяти, Redis с EXPIRE — правильное хранилище.

Курс закрыт: от анатомии токена до сервиса, который регистрирует, логинит, продлевает и отзывает сессии с ролями. Собранный проект — каркас, а не финальная точка: перечисленные ниже направления растут из него без переделки, потому что границы нарисованы заранее. Куда расти дальше: заменить MemoryStore на Postgres, перенести denylist в Redis и перевести тесты с miniredis — [урок про тесты Redis-кода](/courses/go-redis/lessons/24-testy-miniredis/); добавить rate limiting на логин — [rate limiter на Redis](/courses/go-redis/lessons/22-rate-limiter/); вынести identity в отдельный сервис по схеме [двадцать второго урока](/courses/jwt-go/lessons/22-jwt-mikroservisy/); покрыть таблицей кейсов из [двадцатого урока](/courses/jwt-go/lessons/20-testy-auth/) и приёмами из [курса go-advanced](/courses/go-advanced/lessons/16-testirovanie/). Чек-лист безопасности — [двадцать четвёртый урок](/courses/jwt-go/lessons/24-luchshie-praktiki/) — прогоните перед публикацией. Карта всех тем — [на странице jwt-go](/courses/jwt-go/), с которой курс начинался.

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

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