Эта страница переведена с английского. Читать страницу на английском
Кто обращается и насколько приложение в этом уверено. У Zeyn есть четыре способа входа:
| Способ | Для чего | Cargo feature |
|---|---|---|
ForwardAuth | прокси единого входа (single sign on) перед приложением устанавливает заголовки | по умолчанию |
JwtAuth | API, MCP и вызовы сервисов с Authorization: Bearer <JWT> | oidc |
OidcAuth | пользователи браузера входят у провайдера OpenID Connect | oidc |
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, OidcAuth | claim, названный через ClaimMap::tenant, строка или список |
accounts | claims пользователя типа 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 запроса. При базовом или публичном пути маршруты переезжают под него.
Поток
- Вышедший пользователь открывает страницу, требующую права, и попадает на
/_zeyn/auth/login. - Сервер создаёт новые
state,nonceи PKCE verifier (по 32 случайных байта), запечатывает их вместе с путёмreturn_toв cookie, живущую десять минут, и перенаправляет на authorization endpoint провайдера сcode_challenge_method=S256. - Провайдер возвращает пользователя с кодом и
state. Сервер открывает cookie, сравниваетstateза постоянное время, удаляет cookie и обменивает код с PKCE verifier (client secret через HTTP basic authentication, если задан). - Подпись ID-токена проверяется по JWKS провайдера, затем
iss,aud,azpпри нескольких аудиториях,exp,nbf,iatиnonce. - Устанавливается cookie сессии, и пользователь возвращается на
return_to. Принимается только локальный путь; всё остальное ведёт на стартовую страницу приложения.
Cookie сессии
На сервере нет хранилища сессий. Сессия живёт в одной 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 он пропускается.