Перейти к содержанию

Регистрация passkey у провайдера

Теперь вход использует устойчивую цепочку OIDC, сессию и обновление. Добавим ещё один способ подтверждения личности у Keycloak — passkey. Приложение не будет хранить приватный ключ или самостоятельно проверять WebAuthn-подпись. Оно направит уже вошедшего пользователя на предусмотренное действие provider. Новый результат — локальная конфигурация passwordless-политики и маршрут регистрации. Реальный credential появится только после самостоятельного подтверждения в поддерживаемом браузере и аутентификаторе; при подготовке устройств и ключей не создавали.

В lesson-12 realm.json задаёт WebAuthn Passwordless Policy: RP ID localhost, обязательную user verification, discoverable credential, attestation none и Enable Passkeys. Required action webauthn-register-passwordless включено, но не назначено всем автоматически. В интерфейсе «Пропуска» появляется ссылка «Зарегистрировать passkey». Этот термин описывает ключевой credential для подтверждения у provider, а не новую роль editor. Пользователь может входить другим способом, сохраняя тот же sub и прежнюю бизнес-политику приложения.

Где находится WebAuthn RP

Для passkey relying party в этой схеме — Keycloak. Браузер взаимодействует с ним и аутентификатором. Node остаётся OIDC-клиентом. Поэтому RP ID связан с именем узла provider, а не с названием нашей JavaScript-папки. В лаборатории везде используем localhost. Если открыть Keycloak как 127.0.0.1, принятый RP ID localhost перестанет соответствовать ожидаемому контексту. Порт не входит в RP ID, но origin браузерной операции учитывает схему и порт согласно WebAuthn-проверкам.

Localhost допускается браузерами как потенциально доверенный контекст, что помогает локальной работе без настоящего домена. Но это не обещание, что любое устройство, ОС, режим браузера и выбранный аутентификатор поддержат одинаковый маршрут. Private browsing, platform authenticator и синхронизированные ключи могут иметь разные ограничения. Не следует писать «нажмите кнопку, и ключ всегда появится». В статье мы показываем конфигурацию и ожидаемый путь, а фактическую поддержку читатель должен проверить в своей среде без отключения требований протокола.

Политика регистрации

User verification required требует подтверждения у аутентификатора, например предусмотренным PIN или локальным биометрическим действием. Это не значит, что сервер получает биометрические данные. Discoverable credential помогает режимам, где пользователь выбирает доступный ключ без предварительной передачи полного credential id от приложения. Attestation none в нашем профиле не устанавливает доверие к конкретной модели устройства. Мы не строим корпоративную политику разрешённых аппаратных ключей; такую задачу нужно рассматривать отдельно вместе с совместимостью и поддержкой.

В Keycloak 26.5.2 passkeys интегрируются в предусмотренные формы после включения соответствующей passwordless-политики. Точные настройки прочитаны в исходной документации версии. Имена отдельных элементов интерфейса могут различаться по локали и теме. Мы не добавляем выдуманный screenshot формы и не утверждаем, что уже наблюдали работу браузера. Полный realm.json и passkeys.md дают проверяемую основу для самостоятельного открытия Admin Console и Account Console.

Application Initiated Action

Route /register-passkey требует существующую локальную сессию. Затем begin создаёт новую обычную OIDC-попытку со свежими state, nonce и verifier, добавляя kc_action=webauthn-register-passwordless. Браузер переходит к Keycloak, который проводит требуемое действие. Не передаём имя пользователя как доказательство личности и не создаём credential endpoint на Node. Провайдер определяет текущую учётную запись по своей операции и политике. Результат возвращается через тот же callback, поэтому сохраняются проверки, разобранные ранее.

Регистрация может потребовать повторного подтверждения личности у provider. Не следует обходить его простым наличием старой Map-сессии приложения: у сторон разные сроки и требования. Переход по ссылке лишь начинает действие. Пользователь может отменить диалог ОС, устройство может не поддерживать credential или provider может завершить вход без нового ключа. Наш callback сохраняет OIDC-сессию, но не трактует сам возврат как доказательство регистрации. Проверять наличие credential нужно в Account Console и при отдельном последующем входе.

Как убедиться в результате

В локальном сценарии сначала войдите fixture-пользователем, затем откройте регистрацию и завершите действие у Keycloak и устройства. После этого в Account Console, в разделе способов входа, ожидается новый passwordless credential с различимым названием. Название полезно при нескольких ключах и последующем отзыве. Не копируйте приватные значения устройства или действующие recovery secrets в Markdown. Публичный credential id тоже следует показывать только когда это необходимо для разбора, а не ради декоративной «детализации» статьи.

Затем завершите подходящую сессию и попробуйте новый вход через passkey у provider. Наличие действующей Keycloak SSO может скрыть повторное подтверждение, поэтому простой возврат на страницу Node не обязательно показывает использование нового ключа. Нужно понимать выбранный маршрут и состояние browser/provider. При подготовке эти действия не выполнялись. Ожидаемый результат зависит от среды, а курс фиксирует лишь серверный контракт и конфигурацию версии, не универсальное поведение каждого диалога операционной системы.

Изменение realm и будущая потеря ключа

Для нового самостоятельного снимка используйте отдельный compose-проект или вручную включите WebAuthn Register Passwordless и policy в существующем realm. Импорт JSON при старте не обновляет уже сохранённые настройки. Если кнопка у provider не появилась, сначала проверьте настоящий realm в базе, required action и Enable Passkeys. Не ослабляйте user verification или RP ID наугад. Ошибка конфигурации и отсутствие поддержки устройства требуют разных решений, а подмена одного другим может нарушить смысл passwordless-операции.

Новый passkey не устраняет все способы компрометации учётной записи. В нашей лаборатории остаётся резервный пароль, поэтому общая устойчивость зависит и от него. Если удалить пароль и оставить один ключ, потеря устройства может сделать вход невозможным. Перед изменением политики нужен маршрут восстановления. Следующий урок рассмотрит второй независимо зарегистрированный ключ, запасной локальный пароль и отзыв утраченного credential. SMTP, автоматическое подтверждение личности и recovery codes здесь не настроены; мы назовём эти границы прямо, вместо обещания бесплатного полного восстановления.

Для удобства диагностики отделяйте отмену действия пользователем от отсутствия credential. Возвращение в приложение сообщает о завершении принятой OIDC-попытки, а наличие нового ключа относится к состоянию provider. В нашем коде нет отдельного подтверждённого события регистрации в базе Node. Поэтому интерфейс не показывает ложную зелёную отметку «ключ создан» после любого callback и предлагает проверить способ входа в предусмотренной Account Console.

Полный файл passkeys.md из состояния lesson-12:

# Регистрация passkey

realm.json закрепляет passwordless RP ID localhost, user verification required, discoverable credential Yes, attestation none и Enable Passkeys. Required action webauthn-register-passwordless включено, но не назначено всем по умолчанию. Для уже существующего realm проверьте Authentication → Policies → WebAuthn Passwordless Policy и Authentication → Required actions; импорт не обновляет его автоматически.

Войдите anna, откройте /register-passkey. Node начинает обычную новую попытку OIDC с kc_action=webauthn-register-passwordless. После подтверждения в интерфейсе Keycloak браузер/ОС предложит регистрацию. Закрытие окна не означает создание ключа. В Account Console → Account security → Signing in проверьте появление credential. Названия отдельных элементов зависят от темы и локали Keycloak; статья не содержит выдуманного скриншота.

localhost и 127.0.0.1 не взаимозаменяемы для RP ID. Всегда открывайте Keycloak по http://localhost:8080; Node слушает 127.0.0.1, но браузер открывает localhost:5100. Не меняйте это на LAN IP. Localhost допускается браузерами как потенциально доверенный контекст, но поддержка конкретного устройства не гарантирована.

Запасной вход: второй отдельно зарегистрированный passkey и оставленный учебный пароль. Курс не включает recovery codes, SMTP или службу подтверждения личности. Любой реальный выпуск требует отдельной политики восстановления.

Полный файл realm.json из состояния lesson-12:

{
  "realm": "professorweb",
  "enabled": true,
  "sslRequired": "none",
  "registrationAllowed": false,
  "resetPasswordAllowed": false,
  "accessTokenLifespan": 120,
  "ssoSessionIdleTimeout": 1800,
  "ssoSessionMaxLifespan": 3600,
  "roles": {
    "realm": [
      {
        "name": "editor"
      },
      {
        "name": "reader"
      }
    ]
  },
  "clients": [
    {
      "clientId": "propusk",
      "enabled": true,
      "protocol": "openid-connect",
      "publicClient": true,
      "fullScopeAllowed": true,
      "standardFlowEnabled": true,
      "directAccessGrantsEnabled": false,
      "implicitFlowEnabled": false,
      "serviceAccountsEnabled": false,
      "redirectUris": [
        "http://localhost:5100/callback"
      ],
      "webOrigins": [],
      "attributes": {
        "pkce.code.challenge.method": "S256",
        "post.logout.redirect.uris": "http://localhost:5100/"
      },
      "defaultClientScopes": [
        "profile",
        "email",
        "roles"
      ],
      "protocolMappers": [
        {
          "name": "realm-roles-for-propusk",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-usermodel-realm-role-mapper",
          "consentRequired": false,
          "config": {
            "claim.name": "realm_roles",
            "jsonType.label": "String",
            "multivalued": "true",
            "id.token.claim": "true",
            "access.token.claim": "true",
            "userinfo.token.claim": "true"
          }
        }
      ]
    },
    {
      "clientId": "reader",
      "enabled": true,
      "protocol": "openid-connect",
      "publicClient": true,
      "fullScopeAllowed": true,
      "standardFlowEnabled": true,
      "directAccessGrantsEnabled": false,
      "implicitFlowEnabled": false,
      "serviceAccountsEnabled": false,
      "redirectUris": [
        "http://localhost:5101/callback"
      ],
      "webOrigins": [],
      "attributes": {
        "pkce.code.challenge.method": "S256",
        "post.logout.redirect.uris": "http://localhost:5101/"
      },
      "defaultClientScopes": [
        "profile",
        "email",
        "roles"
      ],
      "protocolMappers": [
        {
          "name": "realm-roles-for-propusk",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-usermodel-realm-role-mapper",
          "consentRequired": false,
          "config": {
            "claim.name": "realm_roles",
            "jsonType.label": "String",
            "multivalued": "true",
            "id.token.claim": "true",
            "access.token.claim": "true",
            "userinfo.token.claim": "true"
          }
        }
      ]
    }
  ],
  "users": [
    {
      "username": "anna",
      "enabled": true,
      "firstName": "Анна",
      "lastName": "Редактор",
      "email": "anna@example.invalid",
      "emailVerified": true,
      "realmRoles": [
        "editor",
        "reader"
      ],
      "credentials": [
        {
          "type": "password",
          "value": "Local-Anna-only-2026!",
          "temporary": false
        }
      ]
    },
    {
      "username": "boris",
      "enabled": true,
      "firstName": "Борис",
      "lastName": "Читатель",
      "email": "boris@example.invalid",
      "emailVerified": true,
      "realmRoles": [
        "reader"
      ],
      "credentials": [
        {
          "type": "password",
          "value": "Local-Boris-only-2026!",
          "temporary": false
        }
      ]
    }
  ],
  "webAuthnPolicyPasswordlessRpEntityName": "ProfessorWeb Local",
  "webAuthnPolicyPasswordlessRpId": "localhost",
  "webAuthnPolicyPasswordlessSignatureAlgorithms": [
    "ES256",
    "RS256"
  ],
  "webAuthnPolicyPasswordlessAttestationConveyancePreference": "none",
  "webAuthnPolicyPasswordlessRequireResidentKey": "Yes",
  "webAuthnPolicyPasswordlessUserVerificationRequirement": "required",
  "webAuthnPolicyPasswordlessPasskeysEnabled": true,
  "requiredActions": [
    {
      "alias": "webauthn-register-passwordless",
      "name": "Webauthn Register Passwordless",
      "providerId": "webauthn-register-passwordless",
      "enabled": true,
      "defaultAction": false,
      "priority": 70
    }
  ]
}

Оглавление серии · Исходники всех шагов · Учебные маршруты

Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.