تخطي إلى المحتوى

المصادقة

هذه الصفحة مترجمة من الإنجليزية. اقرأ الصفحة الإنجليزية

من الذي يستدعي، ومدى تأكد التطبيق من ذلك. لدى Zeyn أربع طرق للدخول:

الطريقةتُستخدم من أجلميزة Cargo
ForwardAuthوكيل تسجيل دخول موحد (single sign on) أمام التطبيق يضبط الترويساتافتراضية
JwtAuthواجهات API ومنصة MCP واستدعاءات الخدمات بالترويسة Authorization: Bearer <JWT>oidc
OidcAuthمستخدمو المتصفح يسجلون الدخول عند مزوّد OpenID Connectoidc
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 في الطلب. مع وجود مسار أساسي أو عام تنتقل المسارات تحته.

التدفق

  1. يفتح مستخدم غير مسجَّل الدخول صفحة تحتاج إلى صلاحية فيذهب إلى /_zeyn/auth/login.
  2. ينشئ الخادم state وnonce ومُتحقِّق PKCE جديدة (32 بايتًا عشوائيًا لكل منها)، ويختمها مع مسار return_to في كعكة (cookie) تعيش عشر دقائق، ثم يعيد التوجيه إلى نقطة التفويض لدى المزوّد مع code_challenge_method=S256.
  3. يعيد المزوّد المستخدم برمز وstate. يفتح الخادم الكعكة، ويقارن state في زمن ثابت، ويحذف الكعكة، ويبدّل الرمز بمُتحقِّق PKCE (سر العميل عبر مصادقة HTTP basic عند ضبطه).
  4. يُفحص توقيع رمز الهوية مقابل JWKS لدى المزوّد، ثم iss وaud وazp عند وجود عدة جماهير، وexp وnbf وiat وnonce.
  5. تُضبط كعكة الجلسة ويعود المستخدم إلى 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 يُتخطى.