هذه الصفحة مترجمة من الإنجليزية. اقرأ الصفحة الإنجليزية
من الذي يستدعي، ومدى تأكد التطبيق من ذلك. لدى Zeyn أربع طرق للدخول:
| الطريقة | تُستخدم من أجل | ميزة Cargo |
|---|---|---|
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) أو شاشة أو إجراء. البناء الافتراضي لا يتضمن oidc ولا totp، فالتطبيقات التي لا تستخدمهما لا تدفع شيئًا.
zeyn = { version = "0.0.2", features = ["oidc"] }المزوّدان اللذان جرى اختبارهما هما Keycloak وAuthentik. ينبغي أن يعمل أي مزوّد يتبع اكتشاف OpenID Connect (discovery) وتدفق رمز التفويض (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يضبط العنوان عندما لا تكون للمُصدِر وثيقة اكتشاف. - الوسيط الثاني هو الجمهور (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 | مطالبات المستخدم من النوع tenant (Claim::new("tenant", "acme"))، بترتيب إضافتها |
ForwardAuth | الترويسة المسماة بـ ForwardAuth::tenant، مستأجر واحد |
Authenticator الخاص بك | اضبط identity.tenant وidentity.tenants |
مع App::tenancy(resolver) يأتي مستأجر الطلب من الطلب نفسه (المضيف أو الترويسة). يجب أن ينتمي المستخدم المسجَّل دخوله إليه؛ وإلا يعمل الطلب كأنه بلا تسجيل دخول، فتبقى الشاشات العامة تعمل ويُجاب عن كل ما عداها كما يُجاب مستخدم مجهول. المستخدم المسجَّل دخوله دون أي مستأجر لا ينتمي إلى أي مستأجر. وعندما لا يجد المُحلِّل (resolver) أي مستأجر، يُطبَّق مستأجر المستخدم نفسه.
المطالبات (Claims)
يحوّل ClaimMap المطالبات إلى Identity:
| الدالة | الافتراضي | ملاحظات |
|---|---|---|
user("email") | preferred_username، وإلا sub | يُرفض الرمز الذي لا يحتويها |
tenant("tenant") | لا شيء | مستأجر المستخدم؛ القائمة (مصفوفة أو نص مفصول بمسافات) تسمّي كل مستأجر ينتمي إليه المستخدم، والأول هو مستأجره الخاص. انظر "المستأجرون" أدناه |
roles("realm_access.roles") | لا شيء | مسار منقّط؛ أدوار realm في Keycloak، أو groups في Authentik |
role("clerks", [...]) | لا شيء | صلاحيات لحاملي دور أو أعضاء مجموعة |
permissions("scope") | لا شيء | مطالبة قيمها صلاحيات؛ قائمة أو نص مفصول بمسافات |
time_zone("zoneinfo") | zoneinfo | منطقة المستخدم الزمنية |
mfa_acr(["2"]) | لا شيء | قيم acr التي تعني عاملًا ثانيًا؛ انظر أدناه |
يقرأ OidcAuth مطالبات رمز الهوية (ID token). يضع Keycloak أدوار realm في رمز الوصول (access token) فقط ما لم يكن في مُحوِّل "realm roles" الخاص بالعميل الخيار "Add to ID token" مفعّلًا؛ فعّله (أو أضف oidc-usermodel-realm-role-mapper مع id.token.claim). أما Authentik فيضع groups في رمز الهوية مع النطاق profile.
يصبح auth_time (وإلا iat) هو Identity::authenticated_at، الذي تقرؤه إجراءات 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>/ كعنوان إعادة التوجيه بعد تسجيل الخروج. يُضبط العنوان العام بالإعداد، ولا يؤخذ أبدًا من ترويسة Host في الطلب. مع وجود مسار أساسي أو عام تنتقل المسارات تحته.
التدفق
- يفتح مستخدم غير مسجَّل الدخول صفحة تحتاج إلى صلاحية فيذهب إلى
/_zeyn/auth/login. - ينشئ الخادم
stateوnonceومُتحقِّق PKCE جديدة (32 بايتًا عشوائيًا لكل منها)، ويختمها مع مسارreturn_toفي كعكة (cookie) تعيش عشر دقائق، ثم يعيد التوجيه إلى نقطة التفويض لدى المزوّد معcode_challenge_method=S256. - يعيد المزوّد المستخدم برمز و
state. يفتح الخادم الكعكة، ويقارنstateفي زمن ثابت، ويحذف الكعكة، ويبدّل الرمز بمُتحقِّق PKCE (سر العميل عبر مصادقة HTTP basic عند ضبطه). - يُفحص توقيع رمز الهوية مقابل JWKS لدى المزوّد، ثم
issوaudوazpعند وجود عدة جماهير، وexpوnbfوiatوnonce. - تُضبط كعكة الجلسة ويعود المستخدم إلى
return_to. لا يُقبل هناك إلا مسار محلي؛ وأي شيء آخر يذهب إلى صفحة بدء التطبيق.
كعكة الجلسة
لا يوجد مخزن جلسات على الخادم. تعيش الجلسة في كعكة واحدة هي zeyn_session:
- مشفّرة ومصادَق عليها بـ AES-256-GCM. يُشتق المفتاح من مفتاح الجلسة (
Config::secret، 32 بايتًا على الأقل؛ والمفتاح الأقصر يوقف التطبيق عند بدء التشغيل). اسم الكعكة مرتبط به، فلا تُفتح قيمة مختومة لكعكة ما على أنها كعكة أخرى. البايت المتغيّر، أو الكعكة المختومة بمفتاح آخر، لا يُسجَّل دخولها ببساطة. - تحتوي المستخدم والمستأجر والمنطقة الزمنية والأدوار التي يربطها
ClaimMapوالصلاحيات المباشرة ووقت تسجيل الدخول وهل استُخدم عامل ثانٍ ونهاية الجلسة، ومع رموز التحديث (refresh tokens) رمز التحديث. لا رمز الهوية ولا رمز الوصول. HttpOnlyوSameSite=Lax(إعادة التوجيه الراجعة من المزوّد تنقّل عبر مواقع مختلفة) وSecureعندما يكون العنوان العامhttps://(أوApp::secure_cookies(true)).- الجلسة التي تتجاوز 3800 بايت تُرفض عند تسجيل الدخول مع رسالة في السجل، ولا تُقطع أبدًا. اربط أدوارًا أقل أو أوقف رموز التحديث.
- تدوم
session_lifetime(ثماني ساعات ما لم يُضبط غير ذلك). تغيير مفتاح الجلسة يُخرج الجميع.
لا تتغير حماية CSRF: كل POST من بيئة التشغيل يحتاج دائمًا إلى رمز zeyn_csrf في الترويسة x-zeyn-csrf، أيًّا كانت الكعكة التي تسجّل دخول المستخدم.
رموز التحديث
refresh_tokens(true) يحتفظ برمز التحديث في الكعكة. عندما تنتهي صلاحية الرموز، يجدّدها الطلب التالي قبل أن يعمل ويرسل الكعكة المجددة مرة أخرى. التحديث المرفوض (عُطِّل المستخدم أو سُجِّل خروجه عند المزوّد) ينهي الجلسة. دون رموز التحديث تدوم الجلسة session_lifetime، مهما حدث عند المزوّد. يعمل التحديث كطبقة واحدة فوق التطبيق كله، فكل مسار مشمول، بما فيها مسارات الميزات الاختيارية.
إذا كان المزوّد يدوّر رموز التحديث ويلغي القديم فورًا، فقد يؤدي طلبان يجدّدان في اللحظة نفسها إلى تسجيل خروج المستخدم. أبقِ التدوير متوقفًا أو اسمح بإعادة الاستخدام لفترة قصيرة.
تسجيل الخروج
يحذف /_zeyn/auth/logout كعكة الجلسة ويرسل المستخدم إلى end_session_endpoint لدى المزوّد مع client_id وpost_logout_redirect_uri. دون تلك النقطة يذهب المستخدم إلى صفحة بدء التطبيق.
عملاء API وMCP للتطبيق نفسه
OidcAuth::accept_bearer("stock-api") يقبل أيضًا رموز وصول bearer من المُصدِر نفسه لذلك الجمهور، بنفس ربط المطالبات. الطلب الذي يحمل ترويسة 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) لميزة يشمل الميزة كلها. وكمعترض (interceptor) يشمل الشاشات والإجراءات المدرجة، أو كل استدعاء عندما تكون القائمتان فارغتين.
عندما يغيب العامل، يُعاد توجيه تحميل الصفحة إلى عنوان التصعيد مع return_to. أما الاستدعاءات الأخرى فترد بـ 401 مع الشفرة mfa_required (نوع المشكلة urn:zeyn:problem:mfa_required في API)؛ وتفتح بيئة التشغيل صفحة التصعيد في علامة تبويب جديدة وتعيد الطلب عند عودة المستخدم، كما تفعل مع reauth_required.
مع OidcAuth يكون عنوان التصعيد /_zeyn/auth/login?mfa=1. يرسل acr_values (قيم mfa_acr) مع prompt=login وmax_age=0. وإذا بقي رمز الهوية الجديد خاليًا من أي عامل ثانٍ، يرفضه الاستدعاء الراجع (callback) بدل الدوران في حلقة.
- Keycloak: امنح تدفق المتصفح مستويات مصادقة (خطوة OTP شرطية أو WebAuthn عند المستوى 2) واستخدم
mfa_acr(["2"]). يجيب Keycloak بـacrعلى شكل المستوى الذي تحقق، أو باسمه عندما يربط العميل الأسماء بالمستويات (acr.loa.map)؛ فأدرج تلك الأسماء حينئذٍ. - Authentik: يبلّغ Authentik عن كيفية تسجيل المستخدم دخوله عبر
amrعندما يستخدم التدفق تلك المراحل. استخدم تدفقًا فيه مرحلة تحقق من المصادِق (authenticator validation) للتطبيق.
تذهب إجراءات 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التسجيل (enrollment) لمستخدم بلا عامل ثانٍ: رمز QR مرسوم بصيغة SVG على الخادم (دون سكربت ودون طلب صورة)، والمفتاح لكتابته يدويًا، وحقل للرمز. يبقى السر في كعكة مختومة عشر دقائق إلى أن يثبت رمز أن التطبيق يملكه. - بعد التسجيل يرى المستخدم عشرة رموز استرداد مرة واحدة. يحتفظ المخزن بتجزئاتها المفتاحية فقط (HMAC-SHA256 بمفتاح مشتق من مفتاح TOTP)، فلا يمكن تخمينها دون اتصال من نسخة من المخزن وحده. يعمل كل منها مرة واحدة.
- السر المخزن مختوم بمفتاح TOTP ومرتبط بالمستخدم، فلا يستطيع المخزن وحده توليد رموز، ولا يمكن تبديل السجلات بين المستخدمين.
- المستخدمون المسجَّلون يتحققون هناك برمز أو برمز استرداد. يُقبل الرمز مرة واحدة ( تُخزَّن آخر خطوة زمنية مستخدمة) مع خطوة واحدة من انحراف الساعة في أي اتجاه.
- تُحدَّد المحاولات لكل مستخدم (خمس كل خمس دقائق ما لم تذكر
attemptsغير ذلك) عبر مخزن المعدل في التطبيق، فتتشارك عدة خوادم العدّ معRedisStore. - يضبط الرمز الذي تم التحقق منه كعكة
zeyn_mfaمختومة لمدةlifetime(اثنتا عشرة ساعة ما لم يُضبط غير ذلك) تعلّم الهوية بـmfaوتُحتسب تسجيل دخول حديثًا لإجراءاتreauth. - لا يمكن استبدال عامل مسجَّل من جلسة وحدها؛ إعادة تعيينه مهمة مسؤول في المخزن.
- ترسل النماذج إلى الأصل (origin) نفسه مع رمز
zeyn_csrfكحقل في النموذج.
TotpStore دالتان غير متزامنتين، load(user) وsave(user, record)؛ وMemoryTotpStore للاختبارات. أبقِ مفتاح TOTP ثابتًا: السجلات المختومة بمفتاح آخر لا تُفتح بعد ذلك.
الاختبار
يشغّل crates/zeyn/tests/oidc.rs كل تدفق مقابل مُصدِر صغير داخل الاختبار يوقّع بمفتاح صُنع لهذا التشغيل، ويتحقق من رفض الرموز المنتهية والجماهير والمُصدِرين الخاطئين وalg=none ورمز HMAC موقّع بالمفتاح العام والمطالبات المتغيّرة والكعكات العبث بها وstate المعاد استخدامه أو غير المطابق وnonce الخاطئ. ويغطي crates/zeyn/tests/totp.rs التسجيل والتحقق وإعادة التشغيل (replay) ورموز الاسترداد وحد المعدل.
مع 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 يُتخطى.