Блог
Деактивация пользователя не закрывает доступ
Флаг, проверяемый при следующем входе, оставляет в живых все сессии и токены. Как закрыть доступ сразу, в каком порядке и что останется вне досягаемости.
Автор: FoxAuth ·
Эта ошибка была и у нас — в версии 0.7.0 нашего сервера (в 0.8.0 мы её исправили). Выглядела она так.
Отключаем пользователя в консоли. Берём токен доступа,
который его приложение получило десять минут назад, и спрашиваем у сервера, действует ли этот токен
(такой запрос называется интроспекцией). Ответ — "active": true: действует. Открываем приложение в
браузере пользователя — он всё ещё вошёл. В логах приложения пусто: никто не сообщил ему, что
пользователя отключили.
Формально ничего не сломалось. У учётной записи стоял флаг active: false, и код проверял его везде,
где её искал. Новый вход — отказ. Обновление токена — отказ. Вход через device flow и CIBA (способы войти с
другого устройства) — тоже отказ. Так деактивация устроена у большинства. Я сделал так же и думал, что
этого достаточно. Проверка стояла в правильном месте, если спрашивать: «может ли этот человек войти?»
Но администратор, который отключает человека (обычно в спешке), спрашивает другое: «у этого человека
уже нет доступа?» Ответы на эти вопросы разные. Разница между ними — все сессии и токены, выданные до
нажатия кнопки.
Флаг active: false не пускает только при следующем входе, а выданные сессии и токены живут дальше
Деактивация ставит в учётной записи флаг active: false. Этот флаг говорит о будущем: он остановит
следующий вход. Но сессии и токены, которые пользователь получил до деактивации, флаг не трогает.
Когда их выдавали, проверка прошла, и повторять её никто не будет. Сессия в браузере на сервере авторизации
действует, пока не истечёт. Токен доступа действует до своего exp (срока, записанного в самом
токене). Если это opaque-токен (случайная строка без содержимого, о которой знает только сервер),
то на вопрос «действует ли токен?» сервер по-прежнему отвечает «да». Он смотрит только на две вещи:
есть ли ещё сам токен и есть ли разрешение, которое пользователь дал этому приложению (grant). На флаг active он не смотрит.
Наконец, у самого приложения есть своя сессия — именно её пользователь и видит. Проверку входа приложение доверило нашему серверу, поэтому в стандартах его называют доверяющей стороной (relying party). Сколько жить этой сессии, решает само приложение. А ему никто ничего не сообщил.
На схеме ниже — что происходит после отключения пользователя в двух версиях нашего сервера. Сверху — 0.7.0: всё продолжает работать. Снизу — 0.8.0, где это исправлено.
Так что одного флага мало. Нужна ещё операция, которая найдёт и закроет всё, до чего флаг не дотягивается. Мы называем её зачисткой. И порядок её шагов оказался важнее, чем я ожидал.
Сначала запомните, кому сообщить о выходе, потом удаляйте сессии
Самое очевидное решение — две строки: поставить active: false, а потом удалить всё, что принадлежит
пользователю. Это ошибка, и тест вида «сессий больше нет» её не заметит. Ведь доверяющим
сторонам нужно сообщить, что пользователь вышел. Для этого в OpenID Connect есть back-channel logout:
сервер сам, без участия браузера, отправляет приложению logout-токен — сообщение «эта сессия
закончена». Один такой токен — на каждую сессию. В каждом лежит sid — идентификатор сессии. По
нему приложение находит у себя нужную сессию и закрывает её. А хранится sid только в записи сессии. Значит,
сервер должен сначала прочитать сессии и запомнить, каким доверяющим сторонам и с каким sid
сообщить, — и только потом их удалять. Если удалить сессии первыми, sid пропадут вместе с ними, и
приложениям нечего будет отправить. На сервере сессий не останется, а в каждом приложении пользователь
так и останется вошедшим.
Поэтому порядок зашит в код. Одна функция делает оба шага и всегда в одном порядке: сначала оповещает приложения, потом удаляет записи:
export async function revokeAccountAccess( accountId: string): Promise<CascadeResult> { await notifyRelyingParties(accountId); return sweepAccountOwned(accountId);}notifyRelyingParties сначала читает все сессии пользователя. Раньше сервер так не умел: найти записи
пользователя он мог только тем же методом, который их и удалял. Поэтому во все три реализации
хранилища добавился метод findByOwner — он только ищет и ничего не удаляет.
Если какое-то приложение не отвечает, функция не останавливается. Сервер отправляет событие о сбое, как и при обычном выходе, и идёт дальше. Пользователь должен потерять доступ, даже если у одного из приложений не работает адрес для выхода.
Теперь о самой зачистке — удалении всего, что принадлежит пользователю. Это не список таблиц, составленный по памяти. Записи на нашем сервере разложены по областям хранения: сессии, токены, коды и так далее. Каждая область сама указывает, в каком её поле записан владелец. Зачистка просто удаляет из каждой области всё, что принадлежит этому пользователю. Поэтому область, которая появится в будущем релизе, будет зачищаться автоматически. Никому не нужно помнить, что её надо куда-то дописать.
Вариант со списком, который ведут вручную, я писал не раз. Он тихо ломается, как только появляется новая область: её записи остаются жить после пользователя, и никто этого не замечает. Есть и другой очевидный подход — пройти по разрешениям (grant), выданным пользователю. Но так пропустишь большую часть записей. Сессии, незавершённые входы, подключённые вторые факторы и запросы на подтверждение email привязаны к самому пользователю, а не к разрешению.
Проверку токенов (интроспекцию) менять не пришлось. Она и так отвечает active: false, как только
исчезает разрешение, по которому выдан токен. Поэтому, когда зачистка удаляет разрешения, все
opaque-токены перестают действовать одновременно.
Счётчики неудачных попыток входа зачистка не трогает: они не впускают, а защищают
Две области зачистка не трогает специально: счётчик неудачных попыток входа и счётчик попыток сброса пароля. Они привязаны к адресу почты, а не к пользователю. Может показаться, что их забыли. Нет. Эти счётчики никого не впускают — они считают, сколько раз к адресу пытались подобрать пароль. А срочно отключать пользователя вам, скорее всего, придётся как раз во время атаки на него. Если в этот момент обнулить счётчик, атакующий получит новые попытки прямо посреди инцидента. Отсюда правило: зачистка удаляет то, что пускает внутрь, и оставляет то, что не пускает.
Если зачистка удалила не всё, сервер отвечает 500 и называет, что осталось
В нашей админке у журнала аудита два правила. Первое: запись в журнал делается до изменения. Второе: журнал можно только дополнять, старые записи не меняются. Оба правила правильные. Но из-за них нельзя сообщить о частичном сбое самым простым способом — дописать «эти три области не очистились» в запись, которая сделана ещё до попытки.
Поэтому, если после зачистки что-то осталось, деактивация отвечает 500 и перечисляет области, которые
не очистились. Войти пользователь к этому моменту уже не может: флаг ставится первым. Оставшиеся записи
проживут не дольше обычного срока своей области и обратно внутрь не пустят. Чтобы доделать работу,
достаточно отключить пользователя ещё раз. Сервер не ответит «уже отключён», а снова запустит зачистку.
Повтор, который каждый раз что-то делает, выглядит странно — пока вам не понадобится восстановиться
после такого сбоя.
Каталог перезапишет флаг active, поэтому нужна отдельная блокировка
Пользователями может управлять корпоративный каталог, например Entra или Okta. Каталог сам создаёт,
меняет и отключает пользователей на нашем сервере по протоколу SCIM — это называется провижинингом. У
таких пользователей флаг active принадлежит каталогу: каталог записывает его при каждой синхронизации.
Допустим, во время инцидента администратор вручную отключил такого пользователя у нас. Через несколько
минут плановая синхронизация запишет флаг заново и тихо отменит решение администратора. На такую
защиту полагаться нельзя.
Поэтому у нас есть второй флаг — блокировка. Ставит и снимает его только администратор, каталог до
него не дотягивается. Пускать ли пользователя, решает одна функция: она проверяет оба флага, и её
вызывает каждый путь входа. Это так же важно, как сам флаг. Если проверка «пускать ли» существует в
двух копиях, со временем они расходятся, и через отставшую копию проходит заблокированный
пользователь. У нас так уже было. Вход через внешнего провайдера удостоверений (upstream-провайдера)
проверял только active. Заблокированный пользователь, уже связанный с таким провайдером, проходил
этот шаг. Остановила его только следующая проверка.
С профилем OpenID IPSIE (Interoperability Profiling for Secure Identity in the Enterprise — набор требований безопасности для корпоративных систем входа) блокировка расходится. Профиль запрещает менять у себя пользователей, которыми управляет каталог. Я считаю это отступление оправданным. Блокировка касается только входа и не меняет данные пользователя. А реакция на инцидент, которая ждёт синхронизации чужой системы, — это не реакция на инцидент.
Блокировка запускает ту же зачистку, что и деактивация. А вот снять блокировку мы сделали сложнее. Нашим сервером может управлять и ИИ-агент — через MCP (протокол, по которому ИИ-агент вызывает внешние инструменты). Для агента разблокировка считается опасной операцией и без подтверждения не пройдёт. Ведь она снова впускает пользователя, которого администратор счёл взломанным.
И ещё: ни повторное включение, ни разблокировка ничего не возвращают. Согласия пользователя (его «разрешаю» для каждого приложения) удалились вместе с разрешениями. Поэтому после входа пользователь даёт согласие заново. В первый раз это удивляет. Но именно это IPSIE и имеет в виду, когда говорит про «все механизмы доступа и авторизации» (all access mechanisms and authorizations).
JWT-токен доступа отозвать нельзя: он живёт до конца срока
Есть одна вещь, до которой зачистка не дотягивается. Лучше сказать об этом прямо, чем обещать, что
«деактивация сразу закрывает доступ». Это JWT-токен доступа. Сервер ресурсов (API, который принимает
токен) проверяет его сам, по опубликованным ключам, и на сервер авторизации с ним не обращается.
Поэтому нам нечего перехватить и нечего удалить. Такой токен действует до своего exp.
Остаётся одно средство — короткий срок жизни токена. Для объявленного ресурса (API или MCP-сервера, который администратор зарегистрировал как защищённый ресурс) наш сервер выдаёт токены на пятнадцать минут. Администратор может увеличить срок, но не больше чем до суток. IPSIE SL2 допускает пятнадцать минут для обычного токена и шестьдесят — для токена, привязанного к ключу клиента через DPoP. Это разумная граница: столько времени организации стоит быть готовой к тому, что токен ушедшего сотрудника ещё работает. Если сервер ресурсов не готов ждать даже столько, он может запрашивать opaque-токены и проверять каждый через интроспекцию. Это лишний сетевой запрос на каждое обращение, зато отключение пользователя видно сразу. Большинство серверов ресурсов, с которыми я работал, выбрали бы пятнадцать минут.
Когда отключать, когда блокировать, когда выходить везде, а когда удалять
Отключайте (деактивируйте), когда человек больше не должен входить, а пользователем управляете вы. Блокируйте, когда закрыть вход нужно прямо сейчас, а пользователем управляет каталог — или когда вы не доверяете его следующей синхронизации. Нажимайте «Sign out everywhere» («выйти везде»), когда с человеком всё в порядке, а с его устройством — нет. Эта кнопка закрывает все сессии той же зачисткой, но оставляет пользователя активным. Удаляйте, когда должна исчезнуть сама учётная запись. Доступ при этом тоже закрывается: перед удалением выполняются те же шаги.
Часть механизмов вокруг зачистки появилась позже неё самой. Каталог подключается к нашему серверу
через подключение провижининга — запись, которая связывает один каталог с одним бакетом пользователей.
Бакет — так наш сервер называет группу пользователей со своими правилами входа. Когда у бакета
появляется свой адрес, он выпускает токены от своего имени, то есть становится отдельным издателем
(issuer). Если через подключение начинают отключать слишком много пользователей сразу, подключение
ставится на паузу. Эта защита прошла свои тесты. Но CRUD-тест Okta (проверка создания, чтения,
изменения и удаления пользователей) на настоящей организации Okta ещё не запускался. Эндпоинта /Bulk
нет. Okta Universal Logout вызывает ту же зачистку со стороны внешнего провайдера, но на настоящей
организации Okta тоже ни разу не запускался. На сертификацию ничего из этого не подавалось. А вот саму
зачистку мы проверили целиком на работающем сервере: logout-токен доставлен, обновление токена и
интроспекция отклонены, после повторного включения согласие запрошено заново. Вот эту часть я бы и
скопировал.