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

Аутентификация

Эта страница переведена с английского. Читать страницу на английском

Кто обращается и насколько приложение в этом уверено. У Zeyn есть четыре способа входа:

СпособДля чегоCargo feature
ForwardAuthпрокси единого входа (single sign on) перед приложением устанавливает заголовкипо умолчанию
JwtAuthAPI, MCP и вызовы сервисов с Authorization: Bearer <JWT>oidc
OidcAuthпользователи браузера входят у провайдера OpenID Connectoidc
Totpвторой фактор для приложений, у входа которых его нетtotp
App::accountsприложение ведёт собственных пользователей: пароли, два фактора, passkeys (docs/accounts.md)accounts

RequireMfa (доступен всегда) запрашивает второй фактор для feature, экрана или action. В сборке по умолчанию нет ни oidc, ни totp, поэтому приложения, которые ими не пользуются, ничего не платят.

zeyn = { version = "0.0.2", features = ["oidc"] }

Протестированные провайдеры: Keycloak и Authentik. Должен работать любой провайдер, который поддерживает discovery OpenID Connect, поток authorization code с PKCE и JWKS.

Bearer-токены для API и MCP

use zeyn::{ClaimMap, JwtAuth};

App::new("Stock").auth(
    JwtAuth::new("https://sso.example.com/realms/main", "stock-api")
        .claims(
            ClaimMap::new()
                .roles("realm_access.roles")
                .role("stock-admins", ["stock.product.read", "stock.product.write"]),
        ),
)
  • Первый аргумент это издатель (issuer) в точности так, как он указан в iss токенов. Ключи подписи берутся из JWKS, указанного в <issuer>/.well-known/openid-configuration; jwks_url задаёт адрес, когда у издателя нет discovery-документа.
  • Второй аргумент это аудитория (audience), которую токен должен назвать в aud. В Keycloak добавьте маппер «Audience» клиенту, запрашивающему токены для API.
  • Принимается только RS256, пока algorithms([...]) не разрешит больше (RS384, RS512, PS256, PS384, PS512, ES256, ES384, EdDSA). none и любые алгоритмы HS* отклоняются всегда: разрешить их нельзя. Ключ используется только для семейства алгоритмов своего типа, поэтому токен, «подписанный» HMAC по открытому ключу, отклоняется.
  • exp обязателен. Для exp, nbf и iat допускается одна минута отклонения (leeway(...), не более пяти минут).
  • Ключи кэшируются на час (key_ttl). Токен с неизвестным kid заставляет загрузить ключи заново, не чаще одного раза в 30 секунд (min_key_refresh), поэтому токены с выдуманными идентификаторами ключей не могут завалить провайдера запросами. Хранится не более 16 ключей, а ответы больше 256 KB отклоняются. Неудачная загрузка сохраняет старые ключи.
  • Отклонённый токен просто не выполняет вход; причина пишется в журнал только на уровне debug.

Арендаторы (Tenants)

Каждый аутентификатор заполняет одни и те же два поля Identity: tenant (собственный арендатор пользователя) и tenants (все остальные арендаторы, в которых пользователь может работать). Identity::member_of(id) истинно для любого из них; суперпользователь (DevAuth) принадлежит каждому арендатору.

АутентификаторОткуда берутся арендаторы
JwtAuth, OidcAuthclaim, названный через ClaimMap::tenant, строка или список
accountsclaims пользователя типа tenant (Claim::new("tenant", "acme")), в порядке добавления
ForwardAuthзаголовок, названный через ForwardAuth::tenant, один арендатор
ваш собственный Authenticatorзадайте identity.tenant и identity.tenants

С App::tenancy(resolver) арендатор запроса определяется по самому запросу (хост или заголовок). Вошедший пользователь должен ему принадлежать; иначе запрос выполняется как без входа, так что публичные экраны продолжают работать, а всё остальное отвечает как для анонимного пользователя. Вошедший пользователь без какого-либо арендатора не принадлежит ни одному. Когда resolver не находит арендатора, применяется собственный арендатор пользователя.

Claims

ClaimMap превращает claims в Identity:

МетодПо умолчаниюПримечания
user("email")preferred_username, иначе subтокен без него отклоняется
tenant("tenant")нетарендатор пользователя; список (массив или через пробел) называет всех арендаторов, которым принадлежит пользователь, первый является собственным. См. «Tenants» ниже
roles("realm_access.roles")нетпуть через точки; роли realm в Keycloak или groups в Authentik
role("clerks", [...])нетправа для обладателей роли или членов группы
permissions("scope")нетclaim, значения которого являются правами; список или строка через пробел
time_zone("zoneinfo")zoneinfoзона пользователя
mfa_acr(["2"])нетзначения acr, означающие второй фактор; см. ниже

OidcAuth читает claims ID-токена. Keycloak кладёт роли realm только в access-токен, если у маппера «realm roles» клиента не включено «Add to ID token»; включите его (или добавьте oidc-usermodel-realm-role-mapper с id.token.claim). Authentik кладёт groups в ID-токен при scope profile.

auth_time (иначе iat) становится Identity::authenticated_at, который читают action с reauth.

Вход из браузера

use zeyn::{ClaimMap, OidcAuth};

let oidc = OidcAuth::new(
    "https://sso.example.com/realms/main",
    "stock-web",
    "https://stock.example.com",
    config.secret("auth.session_key")?,
)
.client_secret(config.secret("auth.client_secret")?)
.claims(ClaimMap::new().roles("realm_access.roles").role("stock-admins", ["stock.product.write"]));

App::new("Stock").oidc(oidc)

App::oidc делает его аутентификатором, login_url, адресом выхода по бездействию и адресом повторной аутентификации. Он добавляет три маршрута:

МаршрутЧто делает
GET /_zeyn/auth/login?return_to=/pathначинает вход и перенаправляет к провайдеру
GET /_zeyn/auth/callbackобратное перенаправление от провайдера
GET /_zeyn/auth/logoutзавершает сессию здесь и у провайдера

У провайдера зарегистрируйте <public url>/_zeyn/auth/callback как redirect URI и <public url>/ как post logout redirect URI. Публичный URL задаётся конфигурацией и никогда не берётся из заголовка Host запроса. При базовом или публичном пути маршруты переезжают под него.

Поток

  1. Вышедший пользователь открывает страницу, требующую права, и попадает на /_zeyn/auth/login.
  2. Сервер создаёт новые state, nonce и PKCE verifier (по 32 случайных байта), запечатывает их вместе с путём return_to в cookie, живущую десять минут, и перенаправляет на authorization endpoint провайдера с code_challenge_method=S256.
  3. Провайдер возвращает пользователя с кодом и state. Сервер открывает cookie, сравнивает state за постоянное время, удаляет cookie и обменивает код с PKCE verifier (client secret через HTTP basic authentication, если задан).
  4. Подпись ID-токена проверяется по JWKS провайдера, затем iss, aud, azp при нескольких аудиториях, exp, nbf, iat и nonce.
  5. Устанавливается cookie сессии, и пользователь возвращается на return_to. Принимается только локальный путь; всё остальное ведёт на стартовую страницу приложения.

На сервере нет хранилища сессий. Сессия живёт в одной cookie, zeyn_session:

  • Зашифрована и аутентифицирована с помощью AES-256-GCM. Ключ выводится из ключа сессии (Config::secret, не менее 32 байт; более короткий останавливает приложение при запуске). Имя cookie входит в привязку, поэтому значение, запечатанное для одной cookie, никогда не откроется как другая. Изменённый байт или cookie, запечатанная другим ключом, просто не выполняет вход.
  • Она содержит пользователя, арендатора, часовой пояс, роли, которые сопоставляет ClaimMap, прямые права, время входа, использовался ли второй фактор, конец сессии и, при refresh-токенах, refresh-токен. Но не ID-токен и не access-токен.
  • HttpOnly, SameSite=Lax (обратное перенаправление от провайдера это межсайтовая навигация) и Secure, когда публичный URL начинается с https:// (или App::secure_cookies(true)).
  • Сессия, которая получилась бы больше 3800 байт, отклоняется при входе с записью в журнал и никогда не обрезается. Сопоставляйте меньше ролей или отключите refresh-токены.
  • Она живёт session_lifetime (восемь часов, если не задано иное). Смена ключа сессии выводит всех из системы.

Защита от CSRF не меняется: каждый POST runtime по-прежнему требует токен zeyn_csrf в заголовке x-zeyn-csrf, какая бы cookie ни выполняла вход пользователя.

Refresh-токены

refresh_tokens(true) хранит refresh-токен в cookie. Когда токены истекают, следующий запрос обновляет их до своего выполнения и отправляет обновлённую cookie обратно. Отклонённое обновление (пользователь отключён или вышел у провайдера) завершает сессию. Без refresh-токенов сессия живёт session_lifetime, что бы ни происходило у провайдера. Обновление выполняется одним слоем над всем приложением, поэтому охвачены все маршруты, в том числе необязательных feature.

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

Выход

/_zeyn/auth/logout удаляет cookie сессии и отправляет пользователя на end_session_endpoint провайдера с client_id и post_logout_redirect_uri. Без этого endpoint пользователь попадает на стартовую страницу приложения.

Клиенты API и MCP того же приложения

OidcAuth::accept_bearer("stock-api") также принимает bearer access-токены от того же издателя для этой аудитории, с тем же сопоставлением claims. Запрос с заголовком Authorization: Bearer оценивается только по его токену.

Второй фактор и повышение уровня (step up)

Identity::mfa истинно, когда пользователь подтвердил второй фактор:

  • с OidcAuth и JwtAuth, когда amr называет mfa, otp, hwk, swk, sc, sms, биометрию или другой второй фактор (RFC 8176), либо когда acr входит в ClaimMap::mfa_acr;
  • с Totp, после проверенного кода.

RequireMfa запрашивает его:

Feature::new("payroll").guard(RequireMfa::new())

App::new("Back office").interceptor(RequireMfa::new().screen("bank_accounts").action("approve_payment"))

Как guard feature он охватывает всю feature. Как перехватчик (interceptor) он охватывает перечисленные экраны и action, а при обоих пустых списках каждый вызов.

Когда фактора нет, загрузка страницы перенаправляется на адрес step up с return_to. Другие вызовы отвечают 401 с кодом mfa_required (тип проблемы urn:zeyn:problem:mfa_required в API); runtime открывает страницу step up в новой вкладке и повторяет запрос, когда пользователь возвращается, как и для reauth_required.

С OidcAuth адрес step up это /_zeyn/auth/login?mfa=1. Он отправляет acr_values (значения mfa_acr) с prompt=login и max_age=0. Если новый ID-токен всё ещё не показывает второй фактор, callback отклоняет его вместо зацикливания.

  • Keycloak: задайте в browser flow уровни аутентификации (условный шаг OTP или WebAuthn на уровне 2) и используйте mfa_acr(["2"]). Keycloak отвечает acr с достигнутым уровнем или с его именем, когда клиент сопоставляет имена уровням (acr.loa.map); тогда перечислите эти имена.
  • Authentik: Authentik сообщает, как пользователь вошёл, через amr, когда поток использовал соответствующие этапы. Используйте для приложения поток с этапом проверки аутентификатора.

Action с reauth при OidcAuth идут на /_zeyn/auth/login?reauth=1, который отправляет prompt=login и max_age=0, так что новый auth_time свежий.

Встроенный TOTP

Для приложений, у входа которых нет второго фактора (прокси без MFA, аутентификатор, специфичный для приложения):

use zeyn::{Totp, TotpStore};

App::new("Back office")
    .auth(ForwardAuth::new("x-forwarded-user"))
    .totp(Totp::new(TotpTable::new(pool), config.secret("auth.totp_key")?).issuer("Back office"))
  • /_zeyn/auth/totp показывает пользователю без второго фактора регистрацию: QR-код, нарисованный как SVG на сервере (без скрипта и запроса изображения), ключ для ввода вручную и поле для кода. Секрет ждёт в запечатанной cookie десять минут, пока код не докажет, что он есть у приложения.
  • После регистрации пользователь один раз видит десять кодов восстановления. Хранилище держит только их хеши с ключом (HMAC-SHA256 с ключом, выведенным из ключа TOTP), поэтому одну лишь копию хранилища нельзя подобрать офлайн. Каждый действует один раз.
  • Сохранённый секрет запечатан ключом TOTP и привязан к пользователю, поэтому одно хранилище не может порождать коды, а записи нельзя подменять между пользователями.
  • Зарегистрированные пользователи подтверждают там кодом или кодом восстановления. Код принимается один раз (сохраняется последний использованный временной шаг) с допуском в один шаг рассинхронизации часов в обе стороны.
  • Попытки ограничены на пользователя (пять за пять минут, если attempts не говорит иное) через rate store приложения, так что несколько серверов делят счётчик с RedisStore.
  • Проверенный код устанавливает запечатанную cookie zeyn_mfa на lifetime (двенадцать часов, если не задано иное), которая помечает identity признаком mfa и считается свежим входом для action с reauth.
  • Зарегистрированный фактор нельзя заменить только из сессии; его сброс является задачей администратора в хранилище.
  • Формы отправляются на тот же origin с токеном zeyn_csrf в поле формы.

TotpStore это два async-метода, load(user) и save(user, record); MemoryTotpStore предназначен для тестов. Держите ключ TOTP стабильным: записи, запечатанные другим ключом, больше не открываются.

Тестирование

crates/zeyn/tests/oidc.rs прогоняет каждый поток против небольшого издателя внутри теста, который подписывает ключом, созданным для запуска, и проверяет, что отклоняются истёкшие токены, неверные аудитории и издатели, alg=none, HMAC-токен с ключом из открытого ключа, изменённые claims, подделанные cookie, повторно использованный или не совпавший state и неверный nonce. crates/zeyn/tests/totp.rs покрывает регистрацию, проверку, повтор, коды восстановления и лимит запросов.

С Keycloak в режиме разработки те же потоки прогоняются и против него:

docker run --rm -p 8080:8080 -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak start-dev
ZEYN_TEST_OIDC=http://127.0.0.1:8080 cargo test -p zeyn --features oidc --test oidc

Тест создаёт realm zeyn-test с собственным клиентом и пользователем и удаляет его в конце. Без ZEYN_TEST_OIDC он пропускается.