Украли access-token. Что дальше: три слоя защиты в своём OpenID-провайдере

от автора

redb.Identity

redb.Identity

Bearer-токен работает у любого, кто его держит. Разбор трёх слоёв в redb.Identity: BFF без токена в браузере, привязка к ключу через DPoP, быстрый отзыв.

Access-token по умолчанию предъявительский. Это значит ровно то, как звучит: кто его держит, тот и вы. Сервер не может отличить настоящего владельца от того, кто вытащил токен из чужого браузера. Пока токен не истёк, он работает с любой машины, из любой страны, и в логах это выглядит как обычные легитимные запросы.

Дальше начинается самое неприятное. Средний срок жизни access-token в реальных конфигурациях от пятнадцати минут до часа. Всё это время у атакующего есть полноценный доступ, и никакого сигнала об этом никто не получит.

Мы строили redb.Identity, свой OAuth 2.1 / OpenID Connect провайдер на .NET, и вопрос «а что если токен украли» пришлось решать не декларативно. Получилось три слоя: не дать украсть, сделать украденное бесполезным, быстро погасить. Ниже разбор каждого, с кодом и с честными границами применимости.

Откуда вообще берётся кража

Разговор про кражу токена обычно упирается в XSS, и это правильно, но список шире. Если токен живёт в браузере, до него дотягиваются:

  • XSS в самом приложении, классика;

  • XSS через зависимость. Скомпрометированный npm-пакет получает тот же доступ к странице, что и ваш собственный код. Вы можете написать безупречное приложение и всё равно раздать токены, потому что где-то в дереве транзитивных зависимостей один пакет сменил владельца. Аудит своего кода этот вектор не закрывает;

  • расширения браузера. Их ставит пользователь, у них есть доступ к DOM и к сети страницы;

  • localStorage. Читается любым JavaScript на том же origin, переживает перезагрузку и закрытие вкладки. Хранить там токен это отдельная дисциплина, о которой уже написано достаточно.

Общее у всех четырёх: злоумышленник уносит сам токен. Дальше он ему не нужен ни браузер жертвы, ни её сеть, ни её сессия.

Слой первый: не дать украсть

Как устроены админки большинства identity-серверов

Возьмите любой зрелый identity-сервер и посмотрите, как сделана его админ-консоль. У Keycloak это React-приложение, у WSO2 Identity Server тоже React (Console и My Account). Обе работают как публичные OAuth-клиенты: браузер проходит code+PKCE, получает access-token и держит его у себя.

Это не небрежность, это исторически сложившийся дефолт для SPA. У него есть цена: access-token с правами администратора identity-сервера лежит в памяти страницы, доступной JavaScript. Любой из четырёх векторов выше на этой странице означает утечку админского токена того самого сервера, который выдаёт вход во все остальные системы компании.

Показательно, что IETF в актуальном BCP по браузерным приложениям (OAuth 2.0 for Browser-Based Applications) рекомендует именно BFF, а хранение токенов в браузере описывает как схему, от которой стоит уходить. То есть индустрия уже проголосовала, просто зрелые продукты тащат совместимость с тем, что писалось раньше.

Что такое BFF, если коротко

Backend-for-Frontend: между браузером и API стоит серверное приложение. Оно проходит OIDC-обмен по back-channel, серверу к серверу, и держит токены у себя. Браузеру достаётся HttpOnly-кука сессии. Токена в браузере нет вообще, и JavaScript до него не дотягивается по определению, а не по договорённости.

Как это сделано у нас

redb.Identity.Web, это референсная админка и личный кабинет. Она построена на Blazor Server плюс cookie-аутентификация:

builder.Services.AddRazorComponents().AddInteractiveServerComponents();// ....AddCookie(options =>{    options.Cookie.Name = "identity.web.session";    options.Cookie.HttpOnly = true;    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;    options.Cookie.SameSite = SameSiteMode.Lax;    options.ExpireTimeSpan = TimeSpan.FromHours(8);    options.SlidingExpiration = true;

Токены после логина кладутся на билет аутентификации, а не отдаются наружу:

AuthenticationTokenExtensions.StoreTokens(props, BuildTokens(result));

Когда серверному коду нужен access-token, чтобы сходить в Identity, он достаёт его из текущего контекста, а не из запроса браузера:

public async Task<string?> GetAccessTokenAsync(CancellationToken ct = default){    var ctx = _accessor.HttpContext;    if (ctx?.User?.Identity is not ClaimsIdentity { IsAuthenticated: true })        return null;    return await ctx.GetTokenAsync(CookieAuthenticationDefaults.AuthenticationScheme, "access_token");}

Промежуточные состояния устроены так же. MFA-challenge, consent-challenge, состояние импersonation, это отдельные HttpOnly-куки под DataProtection, а не поля в localStorage и не query-параметры:

SameSite = SameSiteMode.Lax,HttpOnly = true,

Blazor Server добавляет ещё один уровень, которого у классического BFF нет. Разметка рендерится на сервере, в браузер уходит только diff по SignalR. JavaScript-бандла, который держал бы состояние приложения, физически не существует. Атаковать нечего не потому, что хорошо спрятали, а потому, что там ничего не лежит.

Честная граница: BFF не делает XSS безобидным

Это важно проговорить, иначе получится реклама, а не инженерия.

При BFF атакующий, получивший исполнение JavaScript на странице, всё ещё может дёргать API от имени жертвы: кука прикрепляется браузером автоматически. Что он не может, так это унести токен и работать с него потом.

Разница в масштабе последствий:

Украденный токен (SPA)

XSS при BFF

Откуда действует атакующий

со своей машины, откуда угодно

только из браузера жертвы

Как долго

весь срок жизни токена

пока открыта страница

Заметно ли в логах

нет, обычный трафик

трафик с IP жертвы, ловится поведенчески

Ограничен ли origin

нет

да, плюс CSRF-защита и SameSite

Переживает ли закрытие вкладки

да

нет

BFF превращает «тихую компрометацию на час из другой страны» в «активную сессию в браузере жертвы, пока она смотрит на экран». Это не панацея, это на порядок меньший радиус поражения.

Слой второй: сделать кражу бесполезной

Здесь работает DPoP, RFC 9449, Demonstrating Proof-of-Possession. Идея простая и радикальная: перестать выдавать предъявительские токены.

Клиент генерирует пару ключей и оставляет приватный у себя. При запросе токена он показывает публичный, и сервер вшивает его отпечаток в выданный токен. Дальше каждый запрос к ресурсу несёт отдельный короткий JWT-proof, подписанный приватным ключом и привязанный к конкретному методу и URL:

POST /api/orders HTTP/1.1Authorization: DPoP eyJhbGciOiJSUzI1NiIsInR5cCI6...DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...

Украли токен без приватного ключа, получили бесполезную строку. Сервер потребует proof, а подписать его нечем.

Что реализовано у нас:

  • привязка при выдаче. Отпечаток ключа по RFC 7638 (SHA-256 от JWK, base64url) кладётся в claim cnf.jkt согласно RFC 7800. Токен теперь знает, к какому ключу привязан;

  • DPoP-Nonce по §8, на HMAC-подписанных stateless-нонсах. Сервер может потребовать, чтобы proof был свежим, и при этом не держать состояние по каждому клиенту;

  • хранилище использованных jti в разрезе jkt. Один и тот же proof дважды не пройдёт, даже если его перехватили целиком;

  • только асимметричные алгоритмы, по allow-list, как требует §4.2. Симметричный «proof» не доказывает ничего, потому что ключ известен обеим сторонам;

  • отдельный пакет redb.Identity.Resource.Dpop. Это валидатор для ваших собственных ресурсных API. Проверять proof должен не только сам провайдер, иначе защита кончается на границе identity-сервера, а живут данные дальше.

Отдельно про честность в сравнениях. DPoP сейчас есть и у Keycloak, и у WSO2. Появился он там позже, и у Keycloak долго держался в статусе preview, но говорить «у них этого нет» неправильно. Правильная формулировка звучит иначе: проверьте по своей конкретной версии, включено ли и в каком статусе, потому что preview-фича в проде это отдельный разговор с вашим же ИБ.

Слой третий: погасить быстро

Даже идеальная привязка не отменяет необходимости отозвать доступ. Пользователь уволился, устройство потеряли, инцидент подтвердился. Дальше вопрос один: сколько времени пройдёт, пока отзыв реально доедет до всех систем.

Это, кстати, любимый пункт корпоративных технических заданий, и обычно он в них записан как открытый вопрос, а не как решение.

Что есть:

  • отзыв токенов по RFC 7009, идемпотентный: повторный отзыв возвращает 200, как требует §2.1, и не превращается в источник ошибок в скриптах;

  • ротация refresh-токенов. Использованный refresh отзывается, и повторное предъявление означает, что кто-то работает с копией;

  • idle-timeout сессии. Каждая сессия несёт время последней активности, обновляемое на реальных действиях: обмен refresh, валидация куки, вызов userinfo. Просроченная по бездействию сессия гасится автоматически;

  • backchannel logout в двух режимах. Это интереснее всего, поэтому подробнее.

Классический OIDC Backchannel Logout это push: провайдер стучится в каждое зарегистрированное приложение и сообщает, что сессия завершена. Схема работает ровно до первого сетевого разрыва или упавшей реплики. Приложение, до которого стук не дошёл, продолжает считать сессию живой.

Поэтому у нас рядом с push живёт pull-фид отозванных идентификаторов сессий, с курсором:

POST /api/v1/identity/revoked-sids/addGET  /api/v1/identity/revoked-sids/since?cursor=...

Приложение, поднявшееся после падения или потерявшее связь на десять минут, просто спрашивает «что отозвали с момента моего курсора» и догоняет пропущенное. Ни один отзыв не теряется из-за того, что в момент рассылки узел был недоступен.

И приятная деталь, которая связывает первый слой с третьим. BFF проверяет этот же список на каждом запросе при валидации сессионной куки:

options.Events.OnValidatePrincipal = async ctx =>{    var sid = ctx.Principal?.FindFirst("sid")?.Value;    var sub = ctx.Principal?.FindFirst("sub")?.Value;    // если sid/sub в кластерном списке отозванных, кука сбрасывается

То есть «выйти везде» гасит не только токены, но и живую сессию в интерфейсе, причём на всех репликах, а не только на той, куда пришёл запрос на выход.

Что вокруг трёх слоёв

Защита от кражи токена это верхушка. Ниже лежит рутина, которую видно только если специально смотреть. Перечислю то, что считаю содержательным.

Пароли. Argon2id, формат PHC-строки. История паролей с настраиваемой глубиной, повтор отклоняется.

TOTP с защитой от переигрывания. Проблема стандартной реализации TOTP: код действителен внутри окна допуска, и один и тот же код можно предъявить дважды, если успеть. У нас последний принятый шаг сохраняется, и код из уже использованного шага отвергается, даже когда формально попадает в окно:

if (props.LastTotpStep.HasValue && step <= props.LastTotpStep.Value)    return false;

Плюс строка MFA берётся под блокировку до проверки, чтобы параллельные попытки одного пользователя не разъезжались на чтении-записи.

Одноразовые пароли SMS и email лежат на сервере, а не в состоянии. Сам код хранится хешированным (SHA-256), помечается использованным под LockForUpdate, а в зашифрованное состояние challenge уходит только ссылка на него. Клиент не носит с собой ничего, из чего можно восстановить код.

Recovery-коды одноразовые по-настоящему. Помечаются использованными в той же транзакции, в которой создаётся сессия. Не «сначала пометили, потом создали», а атомарно, иначе на разрыве между двумя операциями появляется окно.

Сравнения секретов в постоянном времени. CryptographicOperations.FixedTimeEquals встречается в шестнадцати файлах: токены сброса пароля, подтверждения почты, смены почты, серверные OTP, bootstrap-секрет. Тайминг-атака на сравнение строк это не экзотика, это то, что находят автоматические сканеры.

Rate limiting на трёх уровнях: по IP, по client_id через token bucket, и отдельный потолок неудачных попыток по паре «IP плюс пользователь» с записью в отдельный канал безопасности.

Порядок обработки заголовков прокси. X-Forwarded-For санируется до того, как его увидят rate limiter и счётчик блокировок. Если сделать наоборот, атакующий подделывает заголовок и обходит оба, а заодно может подставить чужой IP под блокировку. Работает только когда сокетный пир в белом списке доверенных прокси, иначе заголовок игнорируется целиком.

Кэш идемпотентности стоит после авторизации, а не до. Иначе отозванный токен разблокирует закэшированный ответ, который выдали ещё живому.

Приставка __Host- у кук ставится только при Secure=true. Это требование RFC 6265bis §4.1.3.2, и его легко нарушить: выставить приставку, забыть флаг, и получить куку, которую браузер молча отбросит, а вы будете искать причину в коде.

Защита от SSRF на исходящих запросах. Есть ровно две ситуации, когда клиент может заставить наш сервер сходить по своей ссылке: разрешение jwks_uri и загрузка объекта запроса по request_uri. Обе закрыты общим guard’ом, который отвергает loopback, приватные диапазоны RFC 1918, link-local вместе с адресом облачных метаданных 169.254.169.254, и CGNAT-пространство RFC 6598. Открыть приватные адреса можно только явным флагом, для однохостовых тестовых стендов.

Отказ от alg:none. Наш JAR (RFC 9101) принимает подписанный объект запроса, проверяет подпись по ключам клиента и берёт параметры изнутри JWT. Неподписанный объект отвергается всегда, независимо от настроек: он выбрасывает ровно ту гарантию целостности, ради которой JAR и существует. FAPI 2.0 запрещает его прямо.

Сто девять типизированных событий аудита в семи категориях, в плоскую таблицу и опционально мультикастом в Kafka, Elasticsearch, RabbitMQ. Пароли и секреты в аудит не пишутся никогда: ротация клиентского секрета оставляет отметку о факте, а не значение.

Что вообще выставлено наружу

Отдельный вопрос, который часто важнее всех криптографических деталей: какая часть системы вообще доступна из интернета.

У большинства identity-серверов админ-консоль живёт в том же процессе и на том же порту, что и протокольные эндпоинты, по пути вида /admin. Изолировать её можно, для этого есть настройки хоста и обратный прокси, но разделение получается по URL и по конфигурации. Ошибка в правилах прокси или регрессия в настройках хоста, и плоскость управления оказалась снаружи.

У нас разделение проходит по процессам и портам.

Ядро вообще не сетевое. Все эндпоинты redb.Identity.Core живут на маршрутах direct-vm://, внутрипроцессном транспорте. Это не «слушает на localhost», до ядра нет сетевого пути в принципе, кроме как через явно поднятый фасад.

Внутри HTTP-фасада плоскости разведены по портам:

"Http": {  "PublicPort": 5002,  "ManagementPort": null}

PublicPort несёт только /connect/* и /.well-known/*ManagementPort несёт /api/v1/identity/* и /scim/*, и в комментарии к настройке прямым текстом написано, что в проде это должен быть отдельный порт за файрволом. SCIM вдобавок гасится отдельным флагом и может не подниматься вовсе.

Аварийный bootstrap-эндпоинт вынесен отдельно. POST /internal/bootstrap-admin живёт на management-порту, но вне базового пути /api/v1/identity/, чтобы его можно было закрыть правилом файрвола независимо от всего остального. Аутентификации по bearer у него нет по устройству, защита идёт заголовком с секретом, который сравнивается в постоянном времени. CORS на нём отключён намеренно: это back-channel инструмент оператора, из браузера он не вызывается никогда.

Интерфейс это отдельное приложение. redb.Identity.Web, самостоятельный ASP.NET-хост, который ходит в Identity серверным HTTP-клиентом. Другая машина, другой сегмент сети, или вовсе не разворачивается: Identity от этого не перестаёт работать. Поставляется он исходниками, а не пакетом, именно потому что это референс, который вы правите под себя.

Смысл всей конструкции в цене ошибки. Чтобы выставить наружу плоскость управления, у нас нужно сознательно поднять management-порт в интернет или развернуть Web в DMZ. Это трудно сделать случайно.

Внешний арбитр

Всё написанное выше можно утверждать про любой сервер, и обычно так и делают. Проблема в том, что своя проверка находит ровно те баги, про которые вы знали заранее.

Мы прогнали сервер через официальный conformance-suite OpenID Foundation, тот же, которым Фонд сертифицирует провайдеров. Config OP проходит без провалов. Basic OP, это тридцать пять модулей, и FAILED среди них ноль.

Гораздо интереснее то, что он в нас нашёл. Claim’ы из scope (телефон, адрес) попадали в id_token, а id_token пересылается третьим сторонам и пишется в логи как доказательство входа. То есть номер телефона пользователя ездил заметно дальше, чем клиент вообще запрашивал. Это утечка персональных данных, и ни один наш тест её не ловил, потому что мы не знали, что это баг. Подробный разбор того прогона, включая настройку сьюта и полный список найденного, есть отдельной статьёй: как мы прогоняли свой OpenID-сервер через официальный сьют.

Два модуля в отчёте помечены SKIPPED, и оба про неподписанные объекты запроса с alg:none. Пропущены они потому, что сервер отказывается рекламировать небезопасный режим. Читать в conformance-отчёте нужно причину, а не цвет строки.

Марку OpenID Certified™ мы не носим: это товарный знак, он выдаётся Фондом по отдельной платной процедуре. Утверждается ровно то, что верно: сервер прогоняется через официальный сьют, и вот результаты.

Чего пока нет

Раздел, без которого статья была бы рекламой.

Риск-ориентированная аутентификация. Входные данные для неё уже лежат: IP сессии, user-agent, читаемая метка устройства, история входов в аудите, счётчики неудачных попыток. Чего нет, так это движка правил и политики усиления поверх них.

Точнее всего это видно на acr. Сервер сообщает достигнутый уровень: acr со значением 1 для однофакторного входа и 2 для подтверждённого многофакторного, плюс amr с конкретным методом (pwdotpmfahwk). А вот входящий acr_values как требование поднять уровень он не отрабатывает: параметр объявлен в discovery, но заставить сервер переспросить второй фактор им нельзя. Ровно этого и не хватает для шаг-апа, вместе с RFC 9470.

Доверенные устройства как отдельная сущность. Устройство фиксируется в сессии, но записи «этому устройству доверяем до такой-то даты» пока нет.

SAML 2.0. Не реализован ни в одну сторону. Решение осознанное и записанное: для современных интеграций хватает OIDC-федерации, а полноценный SAML это XML-подписи, метаданные, три вида биндингов и Single Logout со всеми его особенностями. Условия, при которых мы за это возьмёмся, тоже записаны, и первое из них это реальный клиент с enterprise-IdP, который не отдаёт OIDC.

Из RFC: Rich Authorization Requests (9396), профиль JWT для access-токенов (9068, заголовок typ=at+jwt), параметр iss в ответе авторизации (9207), шаг-ап (9470). Плюс CIBA и OIDC Federation 1.0.

Отдельно про mTLS, потому что здесь легко ввести в заблуждение. Транспортный mTLS у нас есть: gRPC-фасад умеет требовать клиентский сертификат и пинить его по отпечатку из белого списка, и для management-порта это рекомендованная продовая настройка. Чего нет, так это RFC 8705, а это другое: mTLS как способ аутентификации OAuth-клиента на token-эндпоинте плюс привязка выданного токена к сертификату через cnf.x5t#S256. Первое защищает канал, второе сделало бы токен таким же непереносимым, каким его делает DPoP. Мы пошли путём DPoP.

FAPI 2.0 как профиль не заявляем. Отдельные кирпичи из него на месте: обязательный PAR можно включить для конкретного клиента, alg:none отвергается всегда, для объектов запроса разрешена только асимметрика. Но профиль это не набор галочек, а прохождение соответствующего conformance-плана, и мы его не проходили. Если ваш ИБ требует FAPI, считайте это отдельным объёмом работ, и лучше узнать сейчас.

Что забрать с собой

Если из статьи стоит унести три мысли, то вот они.

Первая. Токен в браузере, это выбор, а не необходимость. BFF сегодня рекомендованная схема, и переход на неё меняет не вероятность компрометации, а её радиус: вместо тихого доступа с чужой машины на час вы получаете активность внутри браузера жертвы, ограниченную временем открытой вкладки и видимую в логах.

Вторая. Предъявительский токен, это тоже выбор. DPoP превращает украденную строку в бесполезную, и включается он на стороне провайдера, а не переписыванием всех клиентов сразу. Проверьте, есть ли он у вашего сервера, и в каком он статусе.

Третья. Скорость отзыва решается архитектурой, а не сроком жизни токена. Push-уведомления приложениям ломаются на первом же сетевом разрыве. Pull-фид с курсором переживает и разрыв, и падение узла, и стоит недорого.

redb.Identity открыт под Apache 2.0, работает на PostgreSQL, MS SQL и SQLite без изменений в коде, и запускается как внутрипроцессный модуль, если сеть между вашими сервисами вам не нужна. Посмотреть можно на GitHub.

Если было полезно, ⭐ на GitHub поможет другим это найти.

Другие мои статьи: redb.ru/articles, ещё на Хабре.

ссылка на оригинал статьи https://habr.com/ru/articles/1079978/