تخطى إلى المحتوى
·16 دقيقة قراءة

المصادقة الحديثة في 2026: JWT، والجلسات، وOAuth، ومفاتيح المرور

المشهد الكامل للمصادقة: متى تستخدم الجلسات مقابل JWT، وتدفقات OAuth 2.0 / OIDC، وتدوير رموز التحديث، ومفاتيح المرور (WebAuthn)، وأنماط مصادقة Next.js التي أستخدمها فعلاً.

مشاركة:X / TwitterLinkedIn

المصادقة هي المجال الوحيد في تطوير الويب حيث "إنها تعمل" لا تكفي أبداً. خلل في منتقي التاريخ مزعج. خلل في نظام المصادقة يعني اختراقاً للبيانات.

لقد نفّذت المصادقة من الصفر، وهاجرت بين مزودي الخدمة، وتتبعت حوادث سرقة الرموز، وتعاملت مع عواقب قرارات "سنصلح الأمان لاحقاً". هذا المقال هو الدليل الشامل الذي تمنيت لو كان لديّ عندما بدأت. ليس فقط النظرية — بل المقايضات الفعلية، والثغرات الحقيقية، والأنماط التي تصمد تحت ضغط الإنتاج.

سنغطي المشهد الكامل: الجلسات، JWT، OAuth 2.0، مفاتيح المرور، المصادقة متعددة العوامل، والتفويض. بنهاية المقال، ستفهم ليس فقط كيف تعمل كل آلية، بل متى تستخدمها و_لماذا_ توجد البدائل.

الجلسات مقابل JWT: المقايضات الحقيقية#

هذا هو أول قرار ستواجهه، والإنترنت مليء بالنصائح السيئة حوله. دعني أوضح ما يهم فعلاً.

المصادقة المبنية على الجلسات#

الجلسات هي النهج الأصلي. ينشئ الخادم سجل جلسة، ويخزّنه في مكان ما (قاعدة بيانات، Redis، ذاكرة)، ويعطي العميل معرّف جلسة مبهم في ملف تعريف ارتباط.

typescript
// إنشاء جلسة مبسّط
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
 
interface Session {
  userId: string;
  createdAt: Date;
  expiresAt: Date;
  ipAddress: string;
  userAgent: string;
}
 
async function createSession(userId: string, request: Request): Promise<string> {
  const sessionId = randomBytes(32).toString("hex");
 
  const session: Session = {
    userId,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 ساعة
    ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
    userAgent: request.headers.get("user-agent") ?? "unknown",
  };
 
  // التخزين في قاعدة البيانات أو Redis
  await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
 
  const cookieStore = await cookies();
  cookieStore.set("session_id", sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 86400,
    path: "/",
  });
 
  return sessionId;
}

المزايا حقيقية:

  • إبطال فوري. احذف سجل الجلسة ويتم تسجيل خروج المستخدم. لا انتظار لانتهاء الصلاحية. هذا مهم عند اكتشاف نشاط مشبوه.
  • رؤية الجلسات. يمكنك عرض جلساتهم النشطة للمستخدمين ("مسجّل الدخول على Chrome، Windows 11، إسطنبول") والسماح لهم بإبطال كل جلسة على حدة.
  • حجم ملف تعريف ارتباط صغير. معرّف الجلسة عادة 64 حرفاً. لا ينمو ملف تعريف الارتباط أبداً.
  • تحكم من جانب الخادم. يمكنك تحديث بيانات الجلسة (ترقية مستخدم إلى مسؤول، تغيير الصلاحيات) ويسري المفعول في الطلب التالي.

العيوب أيضاً حقيقية:

  • استعلام قاعدة بيانات في كل طلب. كل طلب مصادق عليه يحتاج بحث عن الجلسة. مع Redis هذا أقل من ميلي ثانية، لكنه لا يزال تبعية.
  • التوسع الأفقي يتطلب تخزيناً مشتركاً. إذا كان لديك عدة خوادم، فكلها تحتاج الوصول لنفس مخزن الجلسات. الجلسات اللاصقة حل هش.
  • CSRF مصدر قلق. لأن ملفات تعريف الارتباط تُرسل تلقائياً، تحتاج حماية CSRF. ملفات تعريف ارتباط SameSite تحل هذا إلى حد كبير، لكن عليك فهم السبب.

المصادقة المبنية على JWT#

JWT يقلب النموذج. بدلاً من تخزين حالة الجلسة على الخادم، تقوم بترميزها في رمز موقّع يحمله العميل.

typescript
import { SignJWT, jwtVerify } from "jose";
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
 
async function createAccessToken(userId: string, role: string): Promise<string> {
  return new SignJWT({ sub: userId, role })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .setIssuer("https://akousa.net")
    .setAudience("https://akousa.net")
    .sign(secret);
}
 
async function verifyAccessToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret, {
      issuer: "https://akousa.net",
      audience: "https://akousa.net",
    });
    return payload;
  } catch {
    return null;
  }
}

المزايا:

  • لا تخزين من جانب الخادم. الرمز يحتوي على كل شيء بذاته. تتحقق من التوقيع وتقرأ المطالبات. لا استعلام لقاعدة البيانات.
  • يعمل عبر الخدمات. في بنية الخدمات المصغّرة، أي خدمة تملك المفتاح العام يمكنها التحقق من الرمز. لا حاجة لمخزن جلسات مشترك.
  • توسع عديم الحالة. أضف المزيد من الخوادم دون القلق بشأن تقارب الجلسات.

العيوب — وهذه هي التي يتجاهلها الناس:

  • لا يمكنك إبطال JWT. بمجرد إصداره، يكون صالحاً حتى انتهاء صلاحيته. إذا تم اختراق حساب مستخدم، لا يمكنك فرض تسجيل الخروج. يمكنك بناء قائمة حظر، لكنك بذلك أعدت إدخال الحالة من جانب الخادم وفقدت الميزة الرئيسية.
  • حجم الرمز. JWT مع بضع مطالبات عادة 800+ بايت. أضف الأدوار والصلاحيات والبيانات الوصفية وستُرسل كيلوبايتات في كل طلب.
  • الحمولة قابلة للقراءة. الحمولة مشفّرة بـ Base64، وليست مُعمّاة. أي شخص يمكنه فك تشفيرها. لا تضع أبداً بيانات حساسة في JWT.
  • مشاكل انحراف الساعة. إذا كانت ساعات خوادمك مختلفة (يحدث ذلك)، تصبح فحوصات انتهاء الصلاحية غير موثوقة.

متى تستخدم كلاً منهما#

قاعدتي العامة:

استخدم الجلسات عندما: يكون لديك تطبيق متجانس، تحتاج إبطالاً فورياً، تبني منتجاً موجهاً للمستهلك حيث أمان الحساب حرج، أو متطلبات المصادقة قد تتغير كثيراً.

استخدم JWT عندما: يكون لديك بنية خدمات مصغّرة حيث تحتاج الخدمات للتحقق من الهوية بشكل مستقل، تبني اتصالاً من API إلى API، أو تنفّذ نظام مصادقة طرف ثالث.

عملياً: معظم التطبيقات يجب أن تستخدم الجلسات. حجة "JWT أكثر قابلية للتوسع" تنطبق فقط إذا كان لديك فعلاً مشكلة توسع لا يستطيع تخزين الجلسات حلها — وRedis يتعامل مع ملايين عمليات البحث عن الجلسات في الثانية. رأيت الكثير من المشاريع تختار JWT لأنها تبدو أكثر حداثة، ثم تبني قائمة حظر ونظام رموز تحديث أكثر تعقيداً مما كانت ستكون عليه الجلسات.

JWT بالتفصيل#

حتى لو اخترت المصادقة المبنية على الجلسات، ستواجه JWT من خلال OAuth وOIDC وتكاملات الطرف الثالث. فهم التفاصيل الداخلية أمر لا يقبل التفاوض.

تشريح JWT#

JWT يتكون من ثلاثة أجزاء مفصولة بنقاط: header.payload.signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...

الرأس — يُعلن الخوارزمية ونوع الرمز:

json
{
  "alg": "RS256",
  "typ": "JWT"
}

الحمولة — تحتوي المطالبات. المطالبات القياسية لها أسماء قصيرة:

json
{
  "sub": "user_123",       // الموضوع (عن من يتحدث هذا)
  "iss": "https://auth.example.com",  // المُصدِر (من أنشأ هذا)
  "aud": "https://api.example.com",   // الجمهور (من يجب أن يقبل هذا)
  "iat": 1709312000,       // وقت الإصدار (طابع زمني Unix)
  "exp": 1709312900,       // انتهاء الصلاحية (طابع زمني Unix)
  "role": "admin"          // مطالبة مخصصة
}

التوقيع — يثبت أن الرمز لم يتم التلاعب به. يُنشأ بتوقيع الرأس والحمولة المشفّرين بمفتاح سري.

RS256 مقابل HS256: هذا مهم فعلاً#

HS256 (HMAC-SHA256) — متماثل. نفس السر يوقّع ويتحقق. بسيط، لكن كل خدمة تحتاج التحقق من الرموز يجب أن تملك السر. إذا تم اختراق أي واحدة منها، يمكن للمهاجم تزوير الرموز.

RS256 (RSA-SHA256) — غير متماثل. مفتاح خاص يوقّع، مفتاح عام يتحقق. فقط خادم المصادقة يحتاج المفتاح الخاص. أي خدمة يمكنها التحقق بالمفتاح العام. إذا تم اختراق خدمة تحقق، يمكن للمهاجم قراءة الرموز لكن لا يستطيع تزويرها.

typescript
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
 
// RS256 — استخدم هذا عندما تتحقق عدة خدمات من الرموز
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
 
async function signWithRS256(payload: Record<string, unknown>) {
  const privateKey = await importPKCS8(privateKeyPem, "RS256");
 
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "RS256", typ: "JWT" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .sign(privateKey);
}
 
async function verifyWithRS256(token: string) {
  const publicKey = await importSPKI(publicKeyPem, "RS256");
 
  const { payload } = await jwtVerify(token, publicKey, {
    algorithms: ["RS256"], // حرج: قيّد الخوارزميات دائماً
  });
 
  return payload;
}

القاعدة: استخدم RS256 كلما عبرت الرموز حدود الخدمة. استخدم HS256 فقط عندما تكون نفس الخدمة توقّع وتتحقق.

هجوم alg: none#

هذه أشهر ثغرة JWT، وهي بسيطة بشكل محرج. بعض مكتبات JWT كانت:

  1. تقرأ حقل alg من الرأس
  2. تستخدم أي خوارزمية يحددها
  3. إذا كان alg: "none"، تتخطى التحقق من التوقيع تماماً

يمكن للمهاجم أخذ JWT صالح، وتغيير الحمولة (مثلاً، تعيين "role": "admin")، وتعيين alg إلى "none"، وإزالة التوقيع، وإرساله. سيقبله الخادم.

typescript
// ثغرة — لا تفعل هذا أبداً
function verifyJwt(token: string) {
  const [headerB64, payloadB64, signature] = token.split(".");
  const header = JSON.parse(atob(headerB64));
 
  if (header.alg === "none") {
    // "لا حاجة لتوقيع" — كارثي
    return JSON.parse(atob(payloadB64));
  }
 
  // ... التحقق من التوقيع
}

الإصلاح بسيط: حدد دائماً الخوارزمية المتوقعة بشكل صريح. لا تدع الرمز يخبرك كيف تتحقق منه.

typescript
// آمن — الخوارزمية مكتوبة في الكود، لا تُقرأ من الرمز
const { payload } = await jwtVerify(token, key, {
  algorithms: ["RS256"], // اقبل فقط RS256 — تجاهل الرأس
});

المكتبات الحديثة مثل jose تتعامل مع هذا بشكل صحيح افتراضياً، لكن يجب عليك مع ذلك تمرير خيار algorithms بشكل صريح كدفاع في العمق.

هجوم خلط الخوارزميات#

مرتبط بما سبق: إذا كان الخادم مُهيأ لقبول RS256، قد يقوم المهاجم بـ:

  1. الحصول على المفتاح العام للخادم (إنه عام، في النهاية)
  2. إنشاء رمز بـ alg: "HS256"
  3. توقيعه باستخدام المفتاح العام كسر HMAC

إذا قرأ الخادم رأس alg وتحوّل للتحقق بـ HS256، يصبح المفتاح العام (الذي يعرفه الجميع) هو السر المشترك. التوقيع صالح. المهاجم زوّر رمزاً.

مرة أخرى، الإصلاح نفسه: لا تثق أبداً بالخوارزمية من رأس الرمز. اكتبها دائماً في الكود.

تدوير رموز التحديث#

إذا كنت تستخدم JWT، تحتاج استراتيجية رمز تحديث. إرسال رمز وصول طويل العمر يطلب المشاكل — إذا سُرق، يملك المهاجم الوصول طوال فترة الحياة.

النمط:

  • رمز الوصول: قصير العمر (15 دقيقة). يُستخدم لطلبات API.
  • رمز التحديث: طويل العمر (30 يوماً). يُستخدم فقط للحصول على رمز وصول جديد.
typescript
import { randomBytes } from "crypto";
 
interface RefreshTokenRecord {
  tokenHash: string;
  userId: string;
  familyId: string;  // يجمّع الرموز المرتبطة معاً
  used: boolean;
  expiresAt: Date;
  createdAt: Date;
}
 
async function issueTokenPair(userId: string) {
  const familyId = randomBytes(16).toString("hex");
 
  const accessToken = await createAccessToken(userId);
  const refreshToken = randomBytes(64).toString("hex");
  const refreshTokenHash = await hashToken(refreshToken);
 
  // تخزين سجل رمز التحديث
  await db.refreshToken.create({
    data: {
      tokenHash: refreshTokenHash,
      userId,
      familyId,
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  return { accessToken, refreshToken };
}

التدوير عند كل استخدام#

في كل مرة يستخدم العميل رمز تحديث للحصول على رمز وصول جديد، تُصدر رمز تحديث جديداً وتُبطل القديم:

typescript
async function rotateTokens(incomingRefreshToken: string) {
  const tokenHash = await hashToken(incomingRefreshToken);
  const record = await db.refreshToken.findUnique({
    where: { tokenHash },
  });
 
  if (!record) {
    // الرمز غير موجود — احتمال سرقة
    return null;
  }
 
  if (record.expiresAt < new Date()) {
    // الرمز منتهي الصلاحية
    await db.refreshToken.delete({ where: { tokenHash } });
    return null;
  }
 
  if (record.used) {
    // هذا الرمز تم استخدامه بالفعل.
    // شخص ما يعيد تشغيله — إما المستخدم الشرعي
    // أو مهاجم. في كلتا الحالتين، اقتل العائلة بأكملها.
    await db.refreshToken.deleteMany({
      where: { familyId: record.familyId },
    });
 
    console.error(
      `كُشف إعادة استخدام رمز تحديث للمستخدم ${record.userId}، العائلة ${record.familyId}. تم إبطال جميع الرموز في العائلة.`
    );
 
    return null;
  }
 
  // وسم الرمز الحالي كمستخدم (لا تحذفه — نحتاجه لكشف إعادة الاستخدام)
  await db.refreshToken.update({
    where: { tokenHash },
    data: { used: true },
  });
 
  // إصدار زوج جديد بنفس معرّف العائلة
  const newRefreshToken = randomBytes(64).toString("hex");
  const newRefreshTokenHash = await hashToken(newRefreshToken);
 
  await db.refreshToken.create({
    data: {
      tokenHash: newRefreshTokenHash,
      userId: record.userId,
      familyId: record.familyId,  // نفس العائلة
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  const newAccessToken = await createAccessToken(record.userId);
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

لماذا يهم إبطال العائلة#

تأمل هذا السيناريو:

  1. يسجّل المستخدم الدخول، يحصل على رمز تحديث A
  2. يسرق المهاجم رمز التحديث A
  3. يستخدم المهاجم A للحصول على زوج جديد (رمز وصول + رمز تحديث B)
  4. يحاول المستخدم استخدام A (الذي لا يزال لديه) للتحديث

بدون كشف إعادة الاستخدام، يحصل المستخدم على خطأ فقط. يستمر المهاجم مع الرمز B. يسجّل المستخدم الدخول مرة أخرى، دون أن يعلم أن حسابه اختُرق.

مع كشف إعادة الاستخدام وإبطال العائلة: عندما يحاول المستخدم استخدام الرمز A المستخدم بالفعل، يكشف النظام إعادة الاستخدام، ويُبطل كل رمز في العائلة (بما فيها B)، ويُجبر كلاً من المستخدم والمهاجم على إعادة المصادقة. يحصل المستخدم على طلب "الرجاء تسجيل الدخول مرة أخرى" وقد يدرك أن شيئاً خاطئاً.

هذا النهج تستخدمه Auth0 وOkta وAuth.js. ليس مثالياً — إذا استخدم المهاجم الرمز قبل المستخدم الشرعي، يصبح المستخدم الشرعي هو من يُطلق تنبيه إعادة الاستخدام. لكنه أفضل ما يمكننا فعله مع رموز الحامل.

OAuth 2.0 وOIDC#

OAuth 2.0 وOpenID Connect هما البروتوكولان وراء "تسجيل الدخول عبر Google/GitHub/Apple". فهمهما ضروري حتى لو كنت تستخدم مكتبة، لأنه عندما تتعطل الأمور — وستتعطل — تحتاج معرفة ما يحدث على مستوى البروتوكول.

التمييز الأساسي#

OAuth 2.0 هو بروتوكول تفويض. يجيب على: "هل يمكن لهذا التطبيق الوصول لبيانات هذا المستخدم؟" النتيجة هي رمز وصول يمنح صلاحيات محددة (نطاقات).

OpenID Connect (OIDC) هو طبقة مصادقة مبنية فوق OAuth 2.0. يجيب على: "من هو هذا المستخدم؟" النتيجة هي رمز هوية (JWT) يحتوي معلومات هوية المستخدم.

عندما "تسجّل الدخول عبر Google"، أنت تستخدم OIDC. تخبر Google تطبيقك من هو المستخدم (مصادقة). قد تطلب أيضاً نطاقات OAuth للوصول لتقويمه أو القرص (تفويض).

تدفق رمز التفويض مع PKCE#

هذا هو التدفق الذي يجب استخدامه لتطبيقات الويب. PKCE (مفتاح الإثبات لتبادل الرمز) صُمم أصلاً لتطبيقات الهاتف لكنه الآن مُوصى به لجميع العملاء، بما فيها التطبيقات من جانب الخادم.

typescript
import { randomBytes, createHash } from "crypto";
 
// الخطوة 1: توليد قيم PKCE وإعادة توجيه المستخدم
function initiateOAuthFlow() {
  // محقق الرمز: سلسلة عشوائية من 43-128 حرفاً
  const codeVerifier = randomBytes(32)
    .toString("base64url")
    .slice(0, 43);
 
  // تحدي الرمز: تجزئة SHA256 للمحقق، مشفرة بـ base64url
  const codeChallenge = createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
 
  // الحالة: قيمة عشوائية لحماية CSRF
  const state = randomBytes(16).toString("hex");
 
  // خزّن كليهما في الجلسة (من جانب الخادم!) قبل إعادة التوجيه
  // لا تضع محقق الرمز أبداً في ملف تعريف ارتباط أو معلمة URL
  session.codeVerifier = codeVerifier;
  session.oauthState = state;
 
  const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
  authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
  authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("scope", "openid email profile");
  authUrl.searchParams.set("state", state);
  authUrl.searchParams.set("code_challenge", codeChallenge);
  authUrl.searchParams.set("code_challenge_method", "S256");
 
  return authUrl.toString();
}
typescript
// الخطوة 2: معالجة الاستدعاء الراجع
async function handleOAuthCallback(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  const error = url.searchParams.get("error");
 
  // التحقق من أخطاء المزود
  if (error) {
    throw new Error(`خطأ OAuth: ${error}`);
  }
 
  // التحقق من تطابق الحالة (حماية CSRF)
  if (state !== session.oauthState) {
    throw new Error("عدم تطابق الحالة — هجوم CSRF محتمل");
  }
 
  // تبادل رمز التفويض مقابل الرموز
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code!,
      redirect_uri: "https://example.com/api/auth/callback/google",
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      code_verifier: session.codeVerifier, // PKCE: يثبت أننا بدأنا هذا التدفق
    }),
  });
 
  const tokens = await tokenResponse.json();
  // tokens.access_token — لاستدعاءات API إلى Google
  // tokens.id_token — JWT مع هوية المستخدم (OIDC)
  // tokens.refresh_token — للحصول على رموز وصول جديدة
 
  // الخطوة 3: التحقق من رمز الهوية واستخراج معلومات المستخدم
  const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
 
  return {
    googleId: idTokenPayload.sub,
    email: idTokenPayload.email,
    name: idTokenPayload.name,
    picture: idTokenPayload.picture,
  };
}

نقاط النهاية الثلاث#

كل مزود OAuth/OIDC يكشف هذه:

  1. نقطة نهاية التفويض — حيث تعيد توجيه المستخدم لتسجيل الدخول ومنح الصلاحيات. تُرجع رمز تفويض.
  2. نقطة نهاية الرمز — حيث يتبادل خادمك رمز التفويض مقابل رموز الوصول/التحديث/الهوية. هذا استدعاء من خادم لخادم.
  3. نقطة نهاية معلومات المستخدم — حيث يمكنك جلب بيانات إضافية للملف الشخصي باستخدام رمز الوصول. مع OIDC، معظم هذا موجود بالفعل في رمز الهوية.

معلمة الحالة#

معلمة state تمنع هجمات CSRF على الاستدعاء الراجع لـ OAuth. بدونها:

  1. يبدأ المهاجم تدفق OAuth على جهازه، يحصل على رمز تفويض
  2. يصنع المهاجم رابطاً: https://yourapp.com/callback?code=ATTACKER_CODE
  3. يخدع المهاجم الضحية للنقر عليه (رابط بريد إلكتروني، صورة مخفية)
  4. يتبادل تطبيقك رمز المهاجم ويربط حساب Google الخاص بالمهاجم بجلسة الضحية

مع state: يُولّد تطبيقك قيمة عشوائية، يخزّنها في الجلسة، ويضمّنها في رابط التفويض. عندما يأتي الاستدعاء الراجع، تتحقق من تطابق state. لا يستطيع المهاجم تزوير هذا لأنه لا يملك الوصول لجلسة الضحية.

Auth.js (NextAuth) مع Next.js App Router#

Auth.js هي المكتبة الأكثر شعبية لمصادقة Next.js، ومع وجيه. تتعامل مع تدفقات OAuth وإدارة الجلسات وتدوير رموز التحديث. لكنها تحتاج إعداداً صحيحاً.

الإعداد الأساسي#

typescript
// src/lib/auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials) => {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }
 
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });
 
        if (!user?.hashedPassword) {
          return null;
        }
 
        const isValid = await verifyPassword(
          credentials.password as string,
          user.hashedPassword
        );
 
        if (!isValid) {
          return null;
        }
 
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        };
      },
    }),
  ],
  session: {
    strategy: "jwt", // أو "database" للجلسات من جانب الخادم
    maxAge: 30 * 24 * 60 * 60, // 30 يوماً
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.sub!;
        session.user.role = token.role as string;
      }
      return session;
    },
  },
  pages: {
    signIn: "/auth/login",
    error: "/auth/error",
  },
});
typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
 
export const { GET, POST } = handlers;

حماية المسارات#

typescript
// src/middleware.ts
import { auth } from "@/lib/auth";
 
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAuthPage = req.nextUrl.pathname.startsWith("/auth");
  const isProtectedPage = req.nextUrl.pathname.startsWith("/dashboard");
  const isApiRoute = req.nextUrl.pathname.startsWith("/api");
 
  // إعادة توجيه المستخدمين المسجلين بعيداً عن صفحات المصادقة
  if (isAuthPage && isLoggedIn) {
    return Response.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  // حماية صفحات لوحة التحكم
  if (isProtectedPage && !isLoggedIn) {
    return Response.redirect(new URL("/auth/login", req.nextUrl));
  }
 
  // حماية مسارات API
  if (isApiRoute && !isLoggedIn && !req.nextUrl.pathname.startsWith("/api/auth")) {
    return new Response("غير مصرّح", { status: 401 });
  }
});
 
export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

الوصول للجلسة في مكونات الخادم#

typescript
// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
 
export default async function DashboardPage() {
  const session = await auth();
 
  if (!session?.user) {
    redirect("/auth/login");
  }
 
  return (
    <div>
      <h1>مرحباً، {session.user.name}</h1>
      <p>الدور: {session.user.role}</p>
    </div>
  );
}

الفخ الشائع: مزود بيانات الاعتماد#

مزود بيانات الاعتماد في Auth.js يحمل فارقاً مهماً: عند استخدام strategy: "jwt" (الافتراضي لبيانات الاعتماد)، لا تحصل على إدارة جلسات قاعدة البيانات. هذا يعني لا إبطال جلسة فوري، لا قائمة جلسات نشطة، لا فرض تسجيل خروج. إذا كنت تحتاج هذه الميزات مع تسجيل دخول بالبريد/كلمة المرور، فكّر في محول قاعدة بيانات مع استراتيجية جلسة مخصصة.

مفاتيح المرور و WebAuthn#

مفاتيح المرور هي مستقبل المصادقة. ليست مبالغة — إنها أكثر أماناً وأسهل استخداماً من كلمات المرور. لا يوجد شيء للتسريب، لا شيء للتصيّد.

كيف تعمل#

مفتاح المرور هو زوج مفاتيح تشفيرية:

  • المفتاح الخاص يبقى على جهاز المستخدم (أو يُزامن عبر iCloud/Google Password Manager)
  • المفتاح العام يُخزّن على خادمك

لتسجيل الدخول، يثبت المستخدم ملكيته للمفتاح الخاص عن طريق توقيع تحدٍّ. يتحقق خادمك بالمفتاح العام. المفتاح الخاص لا يغادر الجهاز أبداً.

التسجيل#

typescript
// الخادم: توليد خيارات التسجيل
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";
 
const rpName = "تطبيقي";
const rpID = "example.com";
const origin = "https://example.com";
 
async function generateRegistration(userId: string) {
  const user = await db.user.findUnique({ where: { id: userId } });
 
  // الحصول على بيانات الاعتماد الموجودة لمنع التسجيل المكرر
  const existingCredentials = await db.credential.findMany({
    where: { userId },
  });
 
  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID: userId,
    userName: user!.email,
    userDisplayName: user!.name,
    excludeCredentials: existingCredentials.map((c) => ({
      id: c.credentialId,
      type: "public-key",
    })),
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred",
    },
  });
 
  // خزّن التحدي للتحقق لاحقاً
  await redis.set(`webauthn:challenge:${userId}`, options.challenge, "EX", 300);
 
  return options;
}
typescript
// العميل: إنشاء بيانات اعتماد
import { startRegistration } from "@simplewebauthn/browser";
 
async function registerPasskey() {
  // الحصول على الخيارات من الخادم
  const options = await fetch("/api/auth/webauthn/register").then((r) =>
    r.json()
  );
 
  // يُنشئ المتصفح زوج المفاتيح ويطلب القياسات الحيوية/رقم PIN
  const registration = await startRegistration(options);
 
  // إرسال الاستجابة للتحقق على الخادم
  await fetch("/api/auth/webauthn/register", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(registration),
  });
}
typescript
// الخادم: التحقق من التسجيل
async function verifyRegistration(userId: string, response: RegistrationResponseJSON) {
  const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
 
  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: expectedChallenge!,
    expectedOrigin: origin,
    expectedRPID: rpID,
  });
 
  if (verification.verified && verification.registrationInfo) {
    await db.credential.create({
      data: {
        userId,
        credentialId: Buffer.from(
          verification.registrationInfo.credentialID
        ).toString("base64url"),
        publicKey: Buffer.from(
          verification.registrationInfo.credentialPublicKey
        ).toString("base64url"),
        counter: verification.registrationInfo.counter,
      },
    });
  }
 
  return verification.verified;
}

لماذا مفاتيح المرور أفضل#

  • مقاومة التصيّد. مفتاح المرور مرتبط بالنطاق. حتى لو أدخل المستخدم بيانات اعتماده في موقع تصيّد، لن يعمل مفتاح المرور لأن النطاق خاطئ.
  • لا شيء للتسريب. لا تُخزّن كلمات مرور (أو تجزئات) على الخادم. فقط مفاتيح عامة.
  • لا شيء لإعادة الاستخدام. كل موقع يحصل على مفتاح مختلف. لا مشكلة إعادة استخدام كلمات المرور عبر المواقع.
  • تجربة مستخدم أبسط. لمس بصمتك أسهل من كتابة P@ssw0rd!2026.

الحقيقة الحالية#

مفاتيح المرور مدعومة من Apple وGoogle وMicrosoft. معظم المتصفحات الحديثة تدعمها. لكن:

  • ليس كل مستخدم لديه جهاز متوافق
  • مزامنة المفاتيح بين المنصات لا تزال معقدة
  • استرداد الحساب صعب إذا فقد المستخدم جميع أجهزته

نصيحتي: قدّم مفاتيح المرور كخيار، لا كالطريقة الوحيدة. اسمح لها جنباً إلى جنب مع البريد الإلكتروني/كلمة المرور + المصادقة الثنائية. مع نضوج المنظومة، يمكنك جعل مفاتيح المرور هي الخيار الافتراضي.

المصادقة متعددة العوامل (MFA)#

المصادقة متعددة العوامل ليست اختيارية لأي تطبيق يتعامل مع بيانات حساسة. إليك كيفية تنفيذها بشكل صحيح.

TOTP (كلمة مرور لمرة واحدة مبنية على الوقت)#

هذا ما تولّده تطبيقات المصادقة مثل Google Authenticator أو Authy. يشترك كل من الخادم والتطبيق في سر، وكلاهما يُولّد نفس الرمز المكون من 6 أرقام بناءً على الوقت الحالي.

typescript
import { createHmac, randomBytes } from "crypto";
 
function generateTOTPSecret(): string {
  return randomBytes(20).toString("hex");
}
 
function generateTOTP(secret: string, timeStep = 30): string {
  const time = Math.floor(Date.now() / 1000 / timeStep);
  const timeBuffer = Buffer.alloc(8);
  timeBuffer.writeBigInt64BE(BigInt(time));
 
  const hmac = createHmac("sha1", Buffer.from(secret, "hex"));
  hmac.update(timeBuffer);
  const hash = hmac.digest();
 
  const offset = hash[hash.length - 1] & 0x0f;
  const code =
    ((hash[offset] & 0x7f) << 24) |
    ((hash[offset + 1] & 0xff) << 16) |
    ((hash[offset + 2] & 0xff) << 8) |
    (hash[offset + 3] & 0xff);
 
  return (code % 1000000).toString().padStart(6, "0");
}
 
function verifyTOTP(secret: string, userCode: string, window = 1): boolean {
  // التحقق من خطوة زمنية أو أكثر إلى الأمام والخلف (يعالج انحراف الساعة)
  for (let i = -window; i <= window; i++) {
    const time = Math.floor(Date.now() / 1000 / 30) + i;
    const timeBuffer = Buffer.alloc(8);
    timeBuffer.writeBigInt64BE(BigInt(time));
 
    const hmac = createHmac("sha1", Buffer.from(secret, "hex"));
    hmac.update(timeBuffer);
    const hash = hmac.digest();
 
    const offset = hash[hash.length - 1] & 0x0f;
    const code =
      ((hash[offset] & 0x7f) << 24) |
      ((hash[offset + 1] & 0xff) << 16) |
      ((hash[offset + 2] & 0xff) << 8) |
      (hash[offset + 3] & 0xff);
 
    const expected = (code % 1000000).toString().padStart(6, "0");
 
    if (expected === userCode) {
      return true;
    }
  }
  return false;
}

رموز الاسترداد#

أهم جزء من المصادقة الثنائية هو أكثر ما يُتجاهل: ماذا يحدث عندما يفقد المستخدم مصادقه؟

typescript
import { randomBytes } from "crypto";
 
function generateRecoveryCodes(count = 10): string[] {
  return Array.from({ length: count }, () =>
    randomBytes(4).toString("hex").toUpperCase()
  );
}
 
// خزّنها مجزّأة — تعامل معها ككلمات مرور
async function storeRecoveryCodes(userId: string, codes: string[]) {
  const hashedCodes = await Promise.all(
    codes.map((code) => hashToken(code))
  );
 
  await db.recoveryCode.createMany({
    data: hashedCodes.map((hash) => ({
      userId,
      codeHash: hash,
      used: false,
    })),
  });
}

وليد دائماً رموز استرداد عند تفعيل المصادقة الثنائية. اعرضها مرة واحدة. أخبر المستخدم بتخزينها بأمان. لا يمكنك عرضها مرة أخرى (أنت تخزّن التجزئات، ليس الرموز الأصلية).

أنماط التفويض#

المصادقة تجيب على "من أنت؟" التفويض يجيب على "ماذا يمكنك أن تفعل؟"

التحكم بالوصول المبني على الأدوار (RBAC)#

أبسط نموذج. المستخدمون لديهم أدوار، والأدوار لديها صلاحيات.

typescript
const ROLES = {
  admin: [
    "users:read",
    "users:write",
    "users:delete",
    "posts:read",
    "posts:write",
    "posts:delete",
    "settings:manage",
  ],
  editor: ["posts:read", "posts:write", "posts:delete"],
  author: ["posts:read", "posts:write:own"],
  viewer: ["posts:read"],
} as const;
 
type Role = keyof typeof ROLES;
type Permission = (typeof ROLES)[Role][number];
 
function hasPermission(role: Role, permission: Permission): boolean {
  return (ROLES[role] as readonly string[]).includes(permission);
}
 
// استخدام في middleware
function requirePermission(permission: Permission) {
  return async (req: Request) => {
    const session = await auth();
    if (!session?.user?.role) {
      return new Response("غير مصرّح", { status: 401 });
    }
 
    if (!hasPermission(session.user.role as Role, permission)) {
      return new Response("ممنوع", { status: 403 });
    }
  };
}

صلاحية ":own" — الملكية#

لاحظ posts:write:own أعلاه. هذا نمط شائع: المؤلفون يمكنهم تعديل مقالاتهم الخاصة فقط، ليس مقالات الآخرين.

typescript
async function canEditPost(userId: string, postId: string): Promise<boolean> {
  const session = await auth();
  if (!session?.user) return false;
 
  const role = session.user.role as Role;
 
  // المسؤولون والمحررون يمكنهم تعديل أي مقال
  if (hasPermission(role, "posts:write")) return true;
 
  // المؤلفون يمكنهم تعديل مقالاتهم فقط
  if (hasPermission(role, "posts:write:own")) {
    const post = await db.post.findUnique({ where: { id: postId } });
    return post?.authorId === userId;
  }
 
  return false;
}

هذا ليس التحكم بالوصول المبني على السمات كاملاً، لكنه يغطي 90% من الحالات بدون التعقيد الكامل.

قائمة تدقيق أمان الإنتاج#

كل نقطة هنا هي خلل رأيته في الإنتاج:

تخزين كلمات المرور#

typescript
import { hash, verify } from "@node-rs/argon2";
 
// التجزئة — استخدم argon2id مع معلمات معقولة
async function hashPassword(password: string): Promise<string> {
  return hash(password, {
    memoryCost: 65536, // 64 ميجابايت
    timeCost: 3,
    outputLen: 32,
    parallelism: 4,
  });
}
 
// التحقق
async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
  return verify(hashedPassword, password);
}

استخدم argon2id. ليس bcrypt (سقف 72 حرفاً). ليس scrypt (معلمات أصعب في الضبط). ليس SHA-256 (غير مناسب لكلمات المرور). argon2id فاز بمسابقة تجزئة كلمات المرور لسبب.

أمان ملفات تعريف الارتباط#

typescript
cookieStore.set("session_id", sessionId, {
  httpOnly: true,     // لا وصول من JavaScript — يمنع XSS من قراءة الجلسة
  secure: true,       // HTTPS فقط — يمنع الاعتراض
  sameSite: "lax",    // حماية CSRF — يسمح بالتنقل من أعلى المستوى
  maxAge: 86400,      // انتهاء الصلاحية بالثواني
  path: "/",          // متاح على كامل الموقع
  domain: ".example.com",  // متاح على النطاقات الفرعية
});

sameSite: "lax" هو الإعداد الافتراضي الصحيح. يحمي من CSRF بينما يسمح بالروابط الواردة بالعمل (لن يتم تسجيل خروج المستخدم لمجرد أنه نقر رابطاً لموقعك من بريد إلكتروني). استخدم "strict" فقط للعمليات الحساسة مثل تحويل الأموال.

الرؤوس التي يجب تعيينها#

typescript
// في middleware.ts أو next.config.ts
const securityHeaders = {
  "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
  "X-Content-Type-Options": "nosniff",
  "X-Frame-Options": "DENY",
  "Referrer-Policy": "strict-origin-when-cross-origin",
  "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
};

تحديد معدل نقاط نهاية المصادقة#

typescript
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
 
const loginLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "15 m"), // 5 محاولات لكل 15 دقيقة
  prefix: "ratelimit:login",
});
 
async function handleLogin(request: Request) {
  const ip = request.headers.get("x-forwarded-for") ?? "unknown";
  const { success, remaining, reset } = await loginLimiter.limit(ip);
 
  if (!success) {
    return new Response("محاولات كثيرة جداً", {
      status: 429,
      headers: {
        "Retry-After": Math.ceil((reset - Date.now()) / 1000).toString(),
      },
    });
  }
 
  // ... تابع تسجيل الدخول
}

حدد دائماً معدل تسجيل الدخول حسب IP وحسب اسم المستخدم. IP وحده لا يكفي — يمكن للمهاجمين التوزيع عبر عدة عناوين IP. اسم المستخدم وحده لا يكفي — يمكن للمهاجمين حبس المستخدمين الشرعيين.

عدم كشف وجود المستخدم#

typescript
// سيء — يخبر المهاجمين أي البريد مسجّل
if (!user) return { error: "البريد الإلكتروني غير موجود" };
if (!validPassword) return { error: "كلمة المرور غير صحيحة" };
 
// جيد — رسالة موحّدة لكلتا الحالتين
if (!user || !validPassword) {
  return { error: "بيانات اعتماد غير صالحة" };
}

هذا ينطبق أيضاً على إعادة تعيين كلمة المرور. لا تقل "لم نجد حساباً بهذا البريد." قل "إذا كان حساب بهذا البريد موجوداً، أرسلنا رابط إعادة تعيين."

التجميع معاً#

إليك كيف أبني المصادقة لتطبيق Next.js جديد في 2026:

  1. Auth.js مع محول قاعدة البيانات — للجلسات والمزودين OAuth
  2. مزودو OAuth (Google، GitHub) كتسجيل دخول أساسي — أقل احتكاك، لا كلمات مرور لإدارتها
  3. مزود البريد/كلمة المرور كبديل — مع argon2id لتجزئة كلمات المرور
  4. TOTP المصادقة الثنائية — للحسابات عالية القيمة، مع رموز استرداد
  5. مفاتيح المرور — كخيار، تنمو لتكون الطريقة الأساسية
  6. RBAC — مع صلاحيات واضحة ودعم الملكية
  7. تحديد المعدل — على جميع نقاط نهاية المصادقة
  8. تسجيل الأحداث — لكل حدث مصادقة (تسجيل دخول، خروج، فشل، تحديث رمز)

ليس كل تطبيق يحتاج كل شيء. مدونة شخصية تحتاج Google OAuth وانتهى الأمر. منصة SaaS تحتاج القائمة الكاملة. لكن البنية يجب أن تدعم إضافة طبقات حسب الحاجة.

المصادقة ليست شيئاً تبنيه مرة وتنساه. البروتوكولات تتطور، تُكتشف الثغرات، توقعات المستخدمين تتغير. أفضل شيء يمكنك فعله هو فهم الأساسيات بعمق كافٍ لاتخاذ قرارات مستنيرة عندما تتغير الأمور. وأتمنى أن يكون هذا المقال أعطاك ذلك الأساس.

مقالات ذات صلة