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

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

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

Урок 8 из 25 ~20 мин +20 XP

Refresh-токены: пара access/refresh

Схема refresh token в Go решает главную дилемму короткого access-токена: срок жизни в пятнадцать минут безопасен, но заставляет перелогиниваться каждые пятнадцать минут. Разбираем пару access/refresh в golang-jwt v5: два токена с разными TTL и назначением, ротация при каждом обновлении и отказ на повторное использование украденного refresh.

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

Попробуйте выбрать срок жизни одного токена для настоящего сервиса — и упрётесь в компромисс без выигрышной стороны. Access живёт пятнадцать минут: украденный токен быстро мёртв, но человек, который работает час, к четверти часа получает разлогин — продукт с такой схемой бросят. Access живёт неделю: удобно, но кража открывает неделю доступа, а отозвать stateless-токен без хранилища нельзя — сервер просто обязан верить подписи до exp. Обе крайности лечатся разделением полномочий, которое пришло из OAuth 2.0: работать с API ходит короткий access-токен, а обновляет его долгий refresh, годный только в одном месте — на эндпоинте обновления. Перелогин исчезает: клиент раз в пятнадцать минут незаметно для пользователя меняет access. Кража дешевеет: украденный access мёртв через минуты, а украденный refresh можно вычислить по повторному использованию. Схема пришла из OAuth 2.0 и давно переехала в обычные API — от SPA до мобильных клиентов и CLI, где cookie нет вовсе. Запросы refresh token go в поисковиках — про эту пару, и в этом уроке соберём её целиком: выпуск, различение по типу, ротацию и отказ на реюз.

Пара access/refresh: кто на что годен#

Два токена — два набора полномочий, и путать их нельзя. Access-токен короткий, от пяти до пятнадцати минут в распространённой практике: им подписывают каждый запрос к API, его видят все сервисы, и компрометация стоит минуты. Refresh-токен долгий, от суток до пары недель по тем же практикам: он появляется в трафике один раз за цикл обновления, ходит только на один эндпоинт и годен ровно на одно действие — получить новую пару. Реализация в golang-jwt v5 минимальна: подпись у обоих одна, HS256 с тем же секретом из урока про HMAC, а различает их поле в payload. Сроки из практики, а не из стандарта: RFC 6749 описывает механизм refresh-токена, но конкретные TTL оставляет сервису — их выбирают из модели риска, а не из документа. Удобнее всего добавить в свой claims-тип строковое поле TokenType и писать в него access или refresh; годятся и отдельные структуры claims под каждый токен — тогда правильный тип гарантируется самим типом Go, и перепутать сложнее. На стороне проверки разница одной строкой: refresh-эндпоинт отвергает токен, у которого TokenType равен access, а middleware API отвергает refresh. Без этой проверки пара вырождается: долгий refresh становится вечным пропуском в API, и весь смысл схемы исчезает.

Выпуск пары access/refresh

GO
const (
        accessTTL  = 15 * time.Minute
        refreshTTL = 7 * 24 * time.Hour
)

func issuePair(userID int64, role string) (string, string, error) {
        access, err := buildToken(userID, role, "access", accessTTL)
        if err != nil {
                return "", "", err
        }
        refresh, err := buildToken(userID, role, "refresh", refreshTTL)
        if err != nil {
                return "", "", err
        }
        return access, refresh, nil
}

func buildToken(userID int64, role, typ string, ttl time.Duration) (string, error) {
        now := time.Now()
        claims := AppClaims{
                UserID:    userID,
                Role:      role,
                TokenType: typ,
                RegisteredClaims: jwt.RegisteredClaims{
                        Subject:   fmt.Sprintf("%d", userID),
                        IssuedAt:  jwt.NewNumericDate(now),
                        ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
                        ID:        newJTI(),
                },
        }
        return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).
                SignedString([]byte(os.Getenv("JWT_SECRET")))
}

Один конструктор на оба токена: различие — TokenType и ttl, всё остальное общее. TTL задан константами — пятнадцать минут и неделя, диапазоны меняются в одном месте. Поле ID — jti, уникальный идентификатор токена: сейчас он кажется лишним, но на нём держится и ротация, и отзыв из текста ниже. Подпись — та же HS256 и тот же секрет, что в пятом уроке.

Ротация refresh-токенов#

Ротация — правило, на котором стоит вся пара: каждое обращение к refresh-эндпоинту выдаёт новую пару и убивает старый refresh. Схема выглядит так: клиент приносит refresh, сервер проверяет подпись и срок, убеждается по TokenType, что это действительно refresh, проверяет по jti, что токен не отозван, — и возвращает свежую пару, записывая старый jti в список мёртвых. Хранилище здесь неизбежно: чистый stateless не умеет забывать, а ротация без забывания — декорация. Подойдёт Redis с записью jti и TTL, равным остатку срока жизни токена: запись сама исчезнет, когда умрёт и её владелец, — механика разобрана в курсе по Redis. Временная шкала на живом примере: логин в десять часов дал access на пятнадцать минут и refresh на неделю; в 10:14 клиент обменял пару — получил новый access до 10:29 и свежий refresh на неделю вперёд, а старый jti лег в список мёртвых и сам исчезнет через остаток своего срока. У ротации есть бонус, ради которого её все и терпят: повторное использование старого refresh — почти точный сигнал кражи. Честный клиент держит одну актуальную пару; вторая попытка с уже отозванным jti значит, что где-то токен скопировали. По рекомендациям безопасности OAuth в этом случае отзываются все токены цепочки — пользовательский refresh и все access, выпущенные с ним: неудобно на минуту, зато атака обрывается целиком.

Эндпоинт обновления

GO
func refreshHandler(w http.ResponseWriter, r *http.Request) {
        refreshStr, err := readBodyToken(r)
        if err != nil {
                http.Error(w, "нет токена в запросе", http.StatusBadRequest)
                return
        }

        claims := &AppClaims{}
        _, err = jwt.ParseWithClaims(refreshStr, claims, verifyKey,
                jwt.WithValidMethods([]string{"HS256"}))
        if err != nil || claims.TokenType != "refresh" {
                http.Error(w, "невалидный refresh", http.StatusUnauthorized)
                return
        }
        if revoked(r.Context(), claims.ID) {
                revokeFamily(r.Context(), claims.Subject)
                http.Error(w, "refresh отозван", http.StatusUnauthorized)
                return
        }

        access, newRefresh, err := issuePair(claims.UserID, claims.Role)
        if err != nil {
                http.Error(w, "не удалось выпустить пару", http.StatusInternalServerError)
                return
        }
        markRevoked(r.Context(), claims.ID, refreshTTL)
        writeJSON(w, http.StatusOK, map[string]string{
                "access": access, "refresh": newRefresh,
        })
}

Три проверки по порядку: подпись и срок через ParseWithClaims, тип по TokenType, отзыв по jti — только потом выпуск новой пары. Повторное использование сработало бы на revoked: сервер отзывает всю семью и отказывает. Старый jti уходит в чёрный список с TTL, равным остатку жизни refresh, — запись почистит себя сама.

Где живёт refresh и как его хранят#

Refresh ценнее access: он дольше живёт, а значит и красть его выгоднее, поэтому хранение различается по силе. Для веба рабочий вариант — HttpOnly cookie: скрипт страницы её не читает, XSS не вытащит токен, а путь и SameSite режут поверхность атаки; тонкости SameSite и CSRF-вектора — отдельные уроки позже в курсе. Для SPA популярна связка access в памяти, refresh в HttpOnly cookie: перезагрузка страницы теряет access, но refresh молча его восстанавливает. localStorage против токенов — тема отдельного спора: доступ к нему имеет любой скрипт, и refresh в нём — мишень номер один. Мобильные клиенты хранят в защищённых хранилищах платформы. Что бы вы ни выбрали, два правила общие: refresh не кладут в URL и не пишут в логи — и то и другое оставляет копию в местах, откуда её не вычистить. Отзыв и полный жизненный цикл сессии — от выпуска до выхода — разобраны дальше по курсу, там же — denylist по jti целиком.

Access и refresh: сравнение по обязанностям#

СвойствоAccessRefresh
Срок жизни5-15 минут, обычная практикасутки и до пары недель
Где используетсякаждый запрос к APIтолько refresh-эндпоинт
Что умеетчитает и пишет данныеполучает новую пару
Частота в трафикекаждый вызоводин раз за цикл обновления
Хранениепамять клиента или cookieHttpOnly cookie или защищённое хранилище
Цена кражиминуты до expвысокая: отсюда ротация и отзыв

Проверка: срок access

Какой TTL чаще всего выбирают для access-токена в паре с refresh?

Проверка: зачем TokenType в claims

Зачем в claims пары лежит поле TokenType со значением access или refresh?

Повторное использование старого refresh

Прочитайте код и выберите, что он выведет:

// Ротация работает, denylist на Redis заведён.
// Клиент дважды прислал один и тот же refresh:
// первый запрос — успех, через секунду второй:
_, err = jwt.ParseWithClaims(refreshStr, claims, verifyKey)
// err == nil, TokenType == "refresh",
// revoked(ctx, claims.ID) вернул true

Access пришёл на refresh-эндпоинт

Прочитайте код и выберите, что он выведет:

// Клиент перепутал и прислал access вместо refresh
claims := &AppClaims{}
_, err := jwt.ParseWithClaims(accessStr, claims, verifyKey)
// err == nil, claims.TokenType == "access"

Схема собрана, и честная оговорка про запуск ставит точку: паре нужен работающий HTTP и Redis, поэтому в браузерной песочнице сайта она не поднимется — нужен локальный Go, установленный пакет и запущенный Redis из курса по нему. Без запуска материал остаётся рабочим: quizzes построены на реальном поведении v5, а код эндпоинта читается построчно. Теперь у аутентификации не хватает одного звена — входа по паролю, который и выпускает пару: логин проверяет пароль против хеша, и в следующем уроке мы разберём bcrypt вместо MD5. Из него затем вырастет цельный middleware десятого урока, который будет требовать access на каждом защищённом маршруте.

Допишите проверки refresh-эндпоинта

Восстановите вызов разбора и сравнение типа токена.

claims := &AppClaims{} _, err := jwt.(refreshStr, claims, verifyKey) if err != nil || claims. != "refresh" { http.Error(w, "невалидный refresh", http.StatusUnauthorized) return }

Соберите скелет refresh-эндпоинта

Расставьте строки обработки refresh по порядку: от разбора до тихого выхода.

Строки

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

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

Карточки: refresh-токены

Термин

Access-токен

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

Короткий токен пары, 5-15 минут по практике: подписывает каждый запрос к API, компрометация стоит минуты. Требуется на каждом защищённом маршруте.

1 / 5

Дальше по курсу пара встраивается в полный цикл: в уроке про bcrypt вместо MD5 появится проверка пароля, которая выдаёт пару на логине, в уроке про middleware аутентификации access станет обязательным на защищённых маршрутах, а в уроке про отзыв токенов denylist по jti разберут целиком. Для хранилища ротации пригодится курс по Redis — записи с TTL там в первых уроках. Карта всех уроков и смежные темы — на странице курса: там же хранение токена на клиенте и HttpOnly cookie с их компромиссами.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Пара access/refresh решает дилемму TTL: короткий access ходит в каждом запросе, долгий refresh только обновляет пару.
  • Тип токена — поле в claims: refresh-эндпоинт отвергает access, middleware отвергает refresh.
  • Подпись у пары общая — HS256 и секрет из урока про HMAC; различие в TokenType и TTL.
  • Ротация выдаёт новую пару на каждом обновлении и отзывает старый refresh по jti — хранилище с TTL обязательно.
  • Повторное использование старого refresh — сигнал кражи: по рекомендациям OAuth отзывается вся семья токенов.
  • Бесконечный refresh без общей границы сессии превращает выход в фикцию; хранят refresh в HttpOnly cookie или защищённом хранилище.

Домашнее задание

Соберите выпуск пары и refresh-эндпоинт локально: Redis для отозванных jti, два TTL-константы, проверка TokenType. Прогоните цикл: логин, запрос с access, обновление пары, второй запрос с тем же refresh — он обязан упасть с отзывом семьи. Затем добавьте в claims общую границу сессии и убедитесь, что через неё refresh-цепочка умирает.

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

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

Можно ли обойтись одним access-токеном без refresh?

Можно, если сервису не нужен долгий вход: токен на пятнадцать минут и честный перелогин. Пару строят ради удобства без потери контроля: пользователь не замечает обновлений, а сервер сохраняет короткое окно компрометации. Для API без человека — машинных интеграций — refresh чаще не нужен вовсе.

Нужен ли refresh отдельный секрет подписи?

Общий секрет с access — распространённая практика, различие делает поле TokenType. Отдельный секрет добавляет расхождений в конфигурации ради проверки, которую уже делает тип в claims. Разделять секреты имеет смысл между сервисами, а не между токенами одной пары.

Что делать, если хранилища для ротации нет?

Ротация без хранилища — имитация: старый refresh остаётся годным до exp. Рабочие минимумы: совсем без хранилища держите короткий refresh и короткую общую границу сессии; либо берите лёгкую таблицу в существующей базе — jti и срок, это одна таблица и два запроса.

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

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

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

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

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

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

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