← назад к разделу

В понедельник в backoffice маркетплейса выходит новый модератор. Отдел кадров завёл его в Active Directory, выдал ноутбук, и с этой минуты у него есть почта, общие папки и вход в Windows. Дальше он открывает backoffice и видит форму логина Keycloak, в которой его нет. Завести ещё раз? Тогда у сотрудника две учётные записи с двумя паролями, а в день увольнения кто-то должен вспомнить про вторую. Не заводить? Тогда Keycloak должен уметь спрашивать про людей тот каталог, где они уже есть. И ещё одно ожидание, с которым сотрудник приходит: утром он уже ввёл пароль при входе в Windows, и вводить его второй раз в браузере ему кажется странным — почта же не спрашивает.

За обоими ожиданиями стоят две технологии старше самого веба. LDAP отвечает на вопрос «кто у нас есть и в каких он группах», Kerberos — на вопрос «как войти один раз и дальше не вводить пароль». Обе живут в Active Directory, и обе Keycloak умеет подключать так, что вашему сервису достаётся всё тот же JWT.

как пользователь вошёлчто делает Keycloakсервис видитформа логинаучётка заведена в Keycloakпроверяет пароль самкаталог не нуженJWT федерация LDAPучётка в Active Directoryпароль проверяет каталогKeycloak его не копируеттот же JWT Kerberos / SPNEGOбраузер молча шлёт билетбилет верный: экраналогина нет, пароля тожетот же JWT вход разный — токен один; вопрос лишь в том, кто держит интеграциюбез KeycloakLDAP и Kerberos в каждом сервисе3 сервиса × 2 протокола = 6 местс KeycloakLDAP и Kerberos только у него2 места, 3 сервиса — только JWT

Форма, каталог LDAP и билет Kerberos — три разных входа, но наружу Keycloak отдаёт один и тот же JWT. Интеграцию с каталогом и билетами держит одно место, а не каждый сервис: добавился четвёртый сервис — мест интеграции не прибавилось.

Каталог: где на самом деле живёт сотрудник

Пять внутренних систем, в каждой своя таблица пользователей — и один человек, которого надо завести пять раз, а уволить тоже пять раз. Компания решает это одной базой «кто есть кто»: отдел кадров пишет в неё один раз, все системы только читают. Такая база называется каталогом, а протокол, по которому к ней ходят, — LDAP. Разница не терминологическая: LDAP — способ разговаривать, а не сама база. Чаще всего за ним стоит Active Directory (AD), и, подключаясь «по LDAP к AD», вы упираетесь в особенности именно AD — её имена атрибутов, её требования к соединению, её лимиты. Есть каталоги, выросшие вокруг самого протокола, — OpenLDAP, FreeIPA, — но в корпоративной среде их встретишь реже.

Каталог устроен деревом, и адрес записи в нём — путь от корня: CN=Иван Петров,OU=Staff,DC=corp,DC=example. Такой путь называется DN, и именно DN Keycloak попросит указать, когда вы скажете ему, где искать людей, а где группы.

DC=corp,DC=example OU=Staff OU=Groups CN=Иван Петров sAMAccountName: ipetrov mail: ipetrov@corp.example objectGUID: 3f2a… (не меняется) userAccountControl: 512 (514: отключён) memberOf: CN=backoffice-moderators,… CN=backoffice-moderators objectClass: group member: CN=Иван Петров,OU=Staff,… одна связь, записана с обеих сторон

Адрес записи — путь от корня (DN); Keycloak просят указать, в каком OU искать людей, а в каком группы. Членство записано дважды не по недосмотру: memberOf у человека читается одним запросом, а member у группы нужен, чтобы найти всех её участников.

У записи сотрудника есть несколько атрибутов, которые будут встречаться дальше в каждом разделе. sAMAccountName — короткий логин (ipetrov), под которым человек входит в Windows. objectGUID — постоянный идентификатор записи: логин при смене фамилии могут переименовать, GUID останется. memberOf — список групп, в которых он состоит; в AD это вычисляемый атрибут, зеркало поля member на стороне самой группы. userAccountControl — набор флагов состояния: 512 у обычной включённой учётки, 514 у отключённой (добавился бит 2). Это последнее поле и есть та кнопка «уволить», на которую нажимает отдел кадров.

Приложению от каталога нужны две операции. Первая — поиск: «найди запись с sAMAccountName=ipetrov и отдай её атрибуты». Вторая — проверка пароля, и делается она не сравнением хешей, а попыткой войти в каталог под этой учёткой; операция называется bind. Каталог принял bind — пароль верный. Отсюда два следствия, которых не видно, пока всё работает. По простому LDAP на порту 389 пароль при bind уходит открытым текстом, поэтому в проде — только LDAPS на 636 или StartTLS; AD ещё и откажется менять пароль по незащищённому соединению. И второе: AD отдаёт результаты поиска порциями не больше тысячи записей (лимит MaxPageSize), так что клиент, не умеющий листать страницы, увидит первую тысячу сотрудников и решит, что остальных нет.

Федерация: Keycloak подключается к каталогу

LDAP-клиент в каждом сервисе — это учётка для bind в каждом, устройство дерева в каждом и починка в каждом, когда AD переедет на новый контроллер домена. Keycloak забирает это к себе одной настройкой: провайдер User Federation типа LDAP. После неё вход по форме ищет пользователя в каталоге и там же проверяет пароль, а наружу уходит обычный JWT. Ниже ключевые поля провайдера в том виде, в каком их принимает kcadm.sh create components; каждое отвечает на конкретный вопрос, и неверный ответ ломает вход по-своему.

{
  "name": "corp-ad",
  "providerId": "ldap",
  "providerType": "org.keycloak.storage.UserStorageProvider",
  "config": {
    "vendor": ["ad"],
    "connectionUrl": ["ldaps://dc1.corp.example:636"],
    "bindDn": ["CN=svc-keycloak,OU=Service,DC=corp,DC=example"],
    "bindCredential": ["${vault.corp-ad-bind}"],
    "usersDn": ["OU=Staff,DC=corp,DC=example"],
    "customUserSearchFilter": ["(memberOf:1.2.840.113556.1.4.1941:=CN=backoffice,OU=Groups,DC=corp,DC=example)"],
    "usernameLDAPAttribute": ["sAMAccountName"],
    "rdnLDAPAttribute": ["cn"],
    "uuidLDAPAttribute": ["objectGUID"],
    "userObjectClasses": ["person, organizationalPerson, user"],
    "editMode": ["READ_ONLY"],
    "importEnabled": ["true"],
    "pagination": ["true"],
    "changedSyncPeriod": ["300"],
    "fullSyncPeriod": ["86400"],
    "cachePolicy": ["MAX_LIFESPAN"],
    "maxLifespan": ["300000"]
  }
}

Кто попадает. usersDn задаёт поддерево, а фильтр — кого из него брать. Без фильтра в Keycloak приедут все записи подряд, включая служебные учётки принтеров и уволенных, которых не удалили. В фильтре выше — правило AD с номером 1.2.840.113556.1.4.1941: «состоит в группе, в том числе через вложенные группы». Обычное memberOf= вложенности не видит, и сотрудник, попавший в backoffice через группу своего отдела, в Keycloak не появится.

Как узнаётся. uuidLDAPAttribute — objectGUID, а не логин. Сотруднику сменили фамилию и с ней sAMAccountName; Keycloak должен понять, что это тот же человек с теми же сессиями и ролями, а не завести второго.

Import Users. Включён по умолчанию: после первого входа или синхронизации у Keycloak появляется своя копия записи. Пароля в копии нет — проверять его Keycloak всегда ходит в каталог операцией bind. Копия нужна: в ней живут сессии, согласия и роли, назначенные внутри Keycloak. Но с этого момента о человеке знают уже двое, и в разделе про источники правды это аукнется.

Edit Mode. READ_ONLY: импортированные поля из Keycloak не правятся, попытка сменить почту в консоли — ошибка. WRITABLE: правки уезжают в AD, для чего служебной учётке нужны права на запись, а соединению — LDAPS, иначе смена пароля упрётся в отказ AD. UNSYNCED: правки остаются в копии Keycloak и в каталог не попадают — так получают два разных профиля одного сотрудника. Для каталога, которым владеет отдел кадров, ответ почти всегда READ_ONLY.

Синхронизация и кэш. changedSyncPeriod в 300 секунд — раз в пять минут Keycloak спрашивает у каталога, чьи записи изменились с прошлого раза, и обновляет копии; fullSyncPeriod — полная сверка раз в сутки. cachePolicy отвечает за то, сколько Keycloak доверяет копии в памяти, не перечитывая её; значение по умолчанию DEFAULT означает «пока не сбросят», и это первое, что стоит поменять, если важна скорость реакции на блокировку. pagination — про лимит в тысячу записей из прошлого раздела.

Из групп каталога — в роли Keycloak

Провайдер подключён, модераторы входят корпоративной учёткой — и все получают 403. Keycloak знает, кто они, но не знает, что им можно: в токене нет ролей. Права в компании раздаются через группы AD (backoffice-moderators, backoffice-finance), а сервис проверяет роли Keycloak (moderator); между ними нужен перевод, и делается он в два шага.

Первый шаг — маппер group-ldap-mapper на провайдере: откуда брать группы (Groups DN = OU=Groups,DC=corp,DC=example), по какому атрибуту у группы видны участники (member) и как узнавать группы конкретного пользователя. Здесь развилка с ценой. Стратегия по атрибуту memberOf — один запрос, но AD не показывает в нём вложенные группы: модератор, состоящий в backoffice-moderators через backoffice-all, для Keycloak окажется без групп. Рекурсивная стратегия по member находит вложенность, но обходит дерево групп запросом на каждый уровень — на входе каждого сотрудника. Режим маппера — тоже READ_ONLY: группы в Keycloak появляются зеркалом каталога, и добавить в них кого-то со стороны Keycloak нельзя.

Второй шаг — уже внутри Keycloak: группе /backoffice-moderators назначается realm-роль moderator. После этого у любого, кто попал в группу в AD, роль оказывается в токене сама, тем же путём, что описан в статье про realm, client и роли:

{
  "sub": "8a1f6c2e-…",
  "preferred_username": "ipetrov",
  "email": "ipetrov@corp.example",
  "realm_access": { "roles": ["moderator"] }
}

Есть и короткий путь — role-ldap-mapper, который превращает группы AD в роли напрямую, без групп Keycloak. Он проще, но тогда набор ролей задаёт каталог, и выдать сотруднику роль, под которую в AD нет группы, из Keycloak уже не получится. Два шага через группы оставляют связку «группа → роль» в руках администратора Keycloak.

Где это ломается: группу в AD переименовали. Для Keycloak это не переименование, а исчезновение одной группы и появление другой; связка с ролью висела на старой, и после синхронизации модераторы снова получают 403. Роли живут в Keycloak, а группы — в каталоге, поэтому переименование группы у отдела кадров — событие, о котором администратор Keycloak должен узнать до того, как его увидят пользователи.

Kerberos: вход без формы

Утром сотрудник ввёл пароль при входе в Windows. Почта, общие папки, внутренний портал — ни один из них пароль не спросил. Это работа Kerberos, и его идея — билеты вместо пароля, причём билеты двух сортов.

В центре домена стоит KDC — центр выдачи билетов; в AD его роль играет контроллер домена. При входе в Windows рабочая станция получает от KDC TGT — «билет на получение билетов», корешок на рабочий день (по умолчанию в AD — десять часов). Дальше пароль не нужен: когда сотрудник открывает почту, станция молча предъявляет TGT и получает отдельный билет именно для почты; открывает портал — отдельный билет для портала. Сервис видит билет, выписанный лично ему, и по нему узнаёт человека.

рабочая станция KDC Keycloak вход в Windows: AS-REQот пароля — производные, не он сам TGT: корешок на 10 часов открыл sso.corp: TGS-REQ, показал TGT билет для HTTP/sso.corp.example Authorization: Negotiate «билет» открыл keytab'ом: это ipetrovJWTпо стрелкам 3–6 пароль не ходит вовсе

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

Про пароль ходит упрощение «он никогда не покидает машину». Точнее так: сервисам, которым предъявляют билеты, пароль не достаётся вовсе — они его не видят и видеть не должны. А в самом первом разговоре с KDC от пароля по сети уходят производные данные, и на них строятся известные атаки на Kerberos. Формулировка «пароль не расходится по сервисам» верна, «пароль не покидает машину вообще» — нет.

Два числа, которые придётся эксплуатировать. Билет содержит время выдачи, и KDC с сервисом принимают его только при расхождении часов не больше пяти минут: рабочая станция с уехавшими часами получит отказ, а в браузере это выглядит как «просто показалась форма логина». И билет несёт список групп сотрудника: у человека из нескольких сотен групп он перерастает 8 КБ, а это размер одного заголовка, на котором nginx по умолчанию отвечает 400, и до Keycloak такой запрос не доходит.

SPNEGO: билет доезжает до Keycloak

Веб-сервер — такой же сервис домена, как почта, и билет для него получается тем же путём. Трудность в другом: браузер не станет отдавать билет первому встречному сайту, а сайт должен уметь билет открыть. Мост между Kerberos и HTTP называется SPNEGO: сайт отвечает 401 с заголовком WWW-Authenticate: Negotiate, а браузер — если сайту доверяет — повторяет запрос с билетом в Authorization: Negotiate ….

На стороне Keycloak нужны три вещи. Имя сервиса в домене (SPN) вида HTTP/sso.corp.example, зарегистрированное на служебной учётке. Файл keytab с ключом этой учётки — им Keycloak открывает билеты. И включённый шаг Kerberos в браузерном потоке входа. SPN и keytab администратор домена делает одной командой:

ktpass /princ HTTP/sso.corp.example@CORP.EXAMPLE /mapuser CORP\svc-keycloak ^
       /crypto AES256-SHA1 /ptype KRB5_NT_PRINCIPAL /pass * /out sso.keytab

В провайдере федерации за это отвечает раздел Kerberos integration — те же поля config, что и выше:

"allowKerberosAuthentication": ["true"],
"kerberosRealm": ["CORP.EXAMPLE"],
"serverPrincipal": ["HTTP/sso.corp.example@CORP.EXAMPLE"],
"keyTab": ["/etc/keycloak/sso.keytab"]

Шаг Kerberos в потоке browser по умолчанию выключен, и включать его надо как Alternative, а не Required. Разница — ровно тот случай, когда из дома видна форма: Alternative означает «попробуй билет, не вышло — следующий шаг, форма логина». Required сломает вход всем, у кого билета нет: продавцам, сотрудникам на удалёнке, любому браузеру вне домена.

Со стороны браузера доверие задаётся политикой, а не появляется само: в Chrome и Edge это AuthServerAllowlist со значением вроде *.corp.example, в Firefox — network.negotiate-auth.trusted-uris. Без неё браузер на Negotiate промолчит, Keycloak покажет форму, и сотрудник в офисе увидит то же, что и дома, — а причина будет на ноутбуке, а не на сервере.

Отсюда главная особенность отладки SPNEGO: почти любая ошибка выглядит одинаково — форма логина вместо тихого входа, а причины различаются механикой. Браузер ответил не Kerberos, а NTLM — сайт не в доверенной зоне или KDC недосягаем, как с домашнего Wi-Fi без VPN; Keycloak NTLM не понимает и переходит к форме. Один SPN зарегистрирован на двух учётках — KDC зашифрует билет ключом не той, и keytab его не откроет. Служебной учётке сменили пароль — у ключа в keytab остался старый номер версии (kvno), и билеты новым ключом не открываются. Часы разошлись больше чем на пять минут. Билет не влез в заголовок на обратном прокси. Все они читаются в логе Keycloak с включённым debug у провайдера, а не на экране пользователя.

Уволенный сотрудник: сколько он ещё внутри

Отдел кадров отключил учётку модератора в AD в 09:00. Вопрос, на который придётся ответить службе безопасности: в какой момент он перестал попадать в backoffice? Ответ — не одно время, а несколько, потому что о блокировке узнают по очереди.

09:00 HR отключил учётку в AD: userAccountControl 514 09:00 вход по паролю закрыт: bind в AD отвергнут 09:00 новый билет Kerberos KDC не выдаст до 09:05 выданный access-JWT сервисы ещё принимают до синхронизации копия в Keycloak: enabled=true, refresh проходит до 19:00 билет в кэше рабочей станции годен 10 часов

Одна блокировка и несколько моментов, когда о ней узнают. Ответ на вопрос «когда он перестал попадать внутрь» задаёт самое длинное звено, и с настройками по умолчанию это бессрочный кэш копии в Keycloak.

Вход по паролю закрывается сразу: пароль проверяется bind'ом в каталог, а AD отключённой учётке bind не даёт. С Kerberos сложнее. Новых билетов KDC ей тоже не выдаст, но билет, полученный до 09:00, лежит в кэше рабочей станции и годен до конца своего срока — по умолчанию тех же десяти часов. Keycloak такой билет откроет и найдёт по нему сотрудника — в своей копии, где enabled ещё стоит в true. Флаг userAccountControl из AD в копию переносит маппер msad-user-account-control-mapper (Keycloak заводит его сам, если у провайдера указан vendor Active Directory), но переносит тогда, когда Keycloak перечитывает запись: по синхронизации изменённых пользователей — в примере выше раз в пять минут — или по истечении кэша (maxLifespan). С настройками по умолчанию, где синхронизация выключена, а кэш бессрочный, Keycloak будет считать сотрудника действующим, пока кто-то не сбросит кэш руками.

Дальше в дело вступают токены из статьи про их сроки: выданный access-JWT сервисы принимают до его exp, ничего ни у кого не спрашивая, а вот обновить его по refresh Keycloak откажется, как только сам сочтёт пользователя отключённым. Итого цепочка задержек складывается из четырёх звеньев — срок билета, период синхронизации или кэша, срок access, момент refresh, — и ответ задаёт самое длинное. Укорачивать надо его: синхронизация изменённых раз в несколько минут, кэш с maxLifespan, access на пять минут. Для действий, где и пять минут много — выплаты, выгрузка персональных данных, — проверка блокировки делается в самом сервисе по своим данным, как для заблокированного продавца. И отдельно: увольнение в регламенте отдела кадров должно включать «отключить в Keycloak и завершить сессии» — единственную кнопку, которая срабатывает мгновенно.

Два источника правды: каталог, Keycloak и ваша база

Сервису заказов нужно показывать, какой модератор одобрил карточку: имя, почта, отдел. Соблазн — завести у себя таблицу сотрудников и раз в час выгружать её из Keycloak по Admin API. Теперь запись о человеке живёт в трёх местах: в AD, куда пишет отдел кадров; в копии Keycloak, которую обновляет синхронизация; в вашей таблице, которую обновляет расписание. Каждая ступень добавляет свою задержку — во что она обходится при увольнении, только что посчитали, — а поле, которое можно поменять в двух местах, рано или поздно расходится, и кто прав, решает не правило, а порядок срабатывания расписаний.

Рабочее правило — у каждого поля ровно один владелец. Кто человек — знает каталог; Keycloak — его зеркало в режиме READ_ONLY, а не вторая редакция. Что с ним происходит в вашем сервисе — знает ваш сервис: какие карточки он одобрил, какие настройки выставил. Имя и почту сервис не хранит как источник, а читает из токена при каждом запросе; понадобился список всех модераторов для выпадающего списка — запрос в Admin API Keycloak с коротким кэшем, а не своя копия. Заодно меньше персональных данных, которые придётся удалять по запросу человека, и меньше мест, где про это забудут.

Связывать свои записи с человеком нужно по sub из токена — и здесь ловушка, которая всплывает через год. sub — идентификатор пользователя в Keycloak, а не objectGUID из каталога. Он стабилен ровно до тех пор, пока живёт провайдер федерации: удалили провайдер — Keycloak удалил вместе с ним все импортированные копии; настроили заново — копии появились с новыми идентификаторами. Логины те же, люди те же, а все ваши записи ссылаются на sub, которых больше нет. Если связь с сотрудником должна пережить переезд Keycloak, в токен добавляют атрибут каталога: у импортированной копии есть атрибут LDAP_ID с objectGUID, и маппер протокола выносит его в отдельный claim.

Когда так не делают

Не всякий каталог — LDAP. Если компания живёт в Microsoft 365 или Google Workspace, каталог там облачный, и LDAP наружу у него нет (у Entra ID — только через отдельную платную службу). Такой каталог подключают к Keycloak не федерацией, а как внешний Identity Provider по OIDC или SAML: Keycloak становится брокером, пользователь входит на стороне облака, а копия записи в Keycloak появляется при первом входе. Вопрос двух источников правды никуда не девается — просто вместо синхронизации по LDAP профиль обновляется при каждом входе.

Не всякий вход — Kerberos. Билет получает только машина в домене с доступом к KDC: ноутбук в кафе без VPN, телефон, браузер подрядчика билета не имеют и увидят форму. Поэтому SPNEGO — удобство для офиса поверх обычного входа, а не замена ему; там, где офиса нет, ставку делают на форму со вторым фактором, и Kerberos не планируют вовсе.

Не всякий пользователь — сотрудник. Продавцов и покупателей маркетплейса в AD нет и не будет: отдел кадров их не заводит. Они остаются учётками Keycloak с обычной формой, и часто — в отдельном realm, чтобы ни один фильтр федерации не мог случайно смешать сотрудников с клиентами.

Коротко

  • Каталог — единственный владелец записи о сотруднике. Keycloak подключается к нему провайдером федерации и держит копию, но пароль проверяет bind'ом в каталог при каждом входе.
  • Пользователь без прав — норма сразу после подключения: права дают маппер групп LDAP и связка группы с ролью внутри Keycloak; вложенные группы memberOf не видит.
  • Kerberos даёт вход без формы только в домене: TGT на день, отдельный билет на каждый сервис, часы в пределах пяти минут. SPNEGO включают шагом Alternative — так вне офиса остаётся форма.
  • Почти любая ошибка SPNEGO выглядит как форма логина; причины — доверенная зона в браузере, SPN, keytab, часы, размер заголовка — читаются в логе Keycloak, а не на экране.
  • Блокировка в AD доходит до сервисов за самое длинное из четырёх звеньев: срок билета, синхронизация или кэш копии, срок access, refresh. Мгновенно срабатывает только отключение в самом Keycloak.
  • Своя таблица сотрудников — третья копия. Сервис хранит только своё и ссылается на sub, помня, что sub — идентификатор Keycloak, а не objectGUID, и живёт вместе с провайдером федерации.

Что почитать дальше

  • Что такое Keycloak — почему вход выносят из приложений в отдельный сервис; федерация и Kerberos — продолжение того же решения.
  • Realm, client, роли — как группы и роли попадают в токен; этим заканчивается перевод групп каталога в права.
  • Токены и их сроки — почему заблокированный продавец ещё пять минут внутри; та же арифметика, что и с уволенным сотрудником.
  • Keycloak и Spring Security — что делает ваш сервис с JWT; единственная часть, которая от каталога и билетов не зависит.