المشهد الكامل للمصادقة: متى تستخدم الجلسات مقابل JWT، وتدفقات OAuth 2.0 / OIDC، وتدوير رموز التحديث، ومفاتيح المرور (WebAuthn)، وأنماط مصادقة Next.js التي أستخدمها فعلاً.
المصادقة هي المجال الوحيد في تطوير الويب حيث "إنها تعمل" لا تكفي أبداً. خلل في منتقي التاريخ مزعج. خلل في نظام المصادقة يعني اختراقاً للبيانات.
لقد نفّذت المصادقة من الصفر، وهاجرت بين مزودي الخدمة، وتتبعت حوادث سرقة الرموز، وتعاملت مع عواقب قرارات "سنصلح الأمان لاحقاً". هذا المقال هو الدليل الشامل الذي تمنيت لو كان لديّ عندما بدأت. ليس فقط النظرية — بل المقايضات الفعلية، والثغرات الحقيقية، والأنماط التي تصمد تحت ضغط الإنتاج.
سنغطي المشهد الكامل: الجلسات، JWT، OAuth 2.0، مفاتيح المرور، المصادقة متعددة العوامل، والتفويض. بنهاية المقال، ستفهم ليس فقط كيف تعمل كل آلية، بل متى تستخدمها ولماذا توجد البدائل.
هذا هو أول قرار ستواجهه، والإنترنت مليء بالنصائح السيئة حوله. دعني أوضح ما يهم فعلاً.
الجلسات هي النهج الأصلي. ينشئ الخادم سجل جلسة، ويخزّنه في مكان ما (قاعدة بيانات، Redis، ذاكرة)، ويعطي العميل معرّف جلسة مبهم في ملف تعريف ارتباط.
// إنشاء جلسة مبسّط
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;
}المزايا حقيقية:
العيوب أيضاً حقيقية:
JWT يقلب النموذج. بدلاً من تخزين حالة الجلسة على الخادم، تقوم بترميزها في رمز موقّع يحمله العميل.
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://example.com")
.setAudience("https://example.com")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://example.com",
audience: "https://example.com",
});
return payload;
} catch {
return null;
}
}المزايا:
العيوب — وهذه هي التي يتجاهلها الناس:
قاعدتي العامة:
استخدم الجلسات عندما: يكون لديك تطبيق متجانس، تحتاج إبطالاً فورياً، تبني منتجاً موجهاً للمستهلك حيث أمان الحساب حرج، أو متطلبات المصادقة قد تتغير كثيراً.
استخدم JWT عندما: يكون لديك بنية خدمات مصغّرة حيث تحتاج الخدمات للتحقق من الهوية بشكل مستقل، تبني اتصالاً من API إلى API، أو تنفّذ نظام مصادقة طرف ثالث.
عملياً: معظم التطبيقات يجب أن تستخدم الجلسات. حجة "JWT أكثر قابلية للتوسع" تنطبق فقط إذا كان لديك فعلاً مشكلة توسع لا يستطيع تخزين الجلسات حلها — وRedis يتعامل مع ملايين عمليات البحث عن الجلسات في الثانية. رأيت الكثير من المشاريع تختار JWT لأنها تبدو أكثر حداثة، ثم تبني قائمة حظر ونظام رموز تحديث أكثر تعقيداً مما كانت ستكون عليه الجلسات.
حتى لو اخترت المصادقة المبنية على الجلسات، ستواجه JWT من خلال OAuth وOIDC وتكاملات الطرف الثالث. فهم التفاصيل الداخلية أمر لا يقبل التفاوض.
JWT يتكون من ثلاثة أجزاء مفصولة بنقاط: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...
الرأس — يُعلن الخوارزمية ونوع الرمز:
{
"alg": "RS256",
"typ": "JWT"
}الحمولة — تحتوي المطالبات. المطالبات القياسية لها أسماء قصيرة:
{
"sub": "user_123", // الموضوع (عن من يتحدث هذا)
"iss": "https://auth.example.com", // المُصدِر (من أنشأ هذا)
"aud": "https://api.example.com", // الجمهور (من يجب أن يقبل هذا)
"iat": 1709312000, // وقت الإصدار (طابع زمني Unix)
"exp": 1709312900, // انتهاء الصلاحية (طابع زمني Unix)
"role": "admin" // مطالبة مخصصة
}التوقيع — يثبت أن الرمز لم يتم التلاعب به. يُنشأ بتوقيع الرأس والحمولة المشفّرين بمفتاح سري.
HS256 (HMAC-SHA256) — متماثل. نفس السر يوقّع ويتحقق. بسيط، لكن كل خدمة تحتاج التحقق من الرموز يجب أن تملك السر. إذا تم اختراق أي واحدة منها، يمكن للمهاجم تزوير الرموز.
RS256 (RSA-SHA256) — غير متماثل. مفتاح خاص يوقّع، مفتاح عام يتحقق. فقط خادم المصادقة يحتاج المفتاح الخاص. أي خدمة يمكنها التحقق بالمفتاح العام. إذا تم اختراق خدمة تحقق، يمكن للمهاجم قراءة الرموز لكن لا يستطيع تزويرها.
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 كانت:
alg من الرأسalg: "none"، تتخطى التحقق من التوقيع تماماًيمكن للمهاجم أخذ JWT صالح، وتغيير الحمولة (مثلاً، تعيين "role": "admin")، وتعيين alg إلى "none"، وإزالة التوقيع، وإرساله. سيقبله الخادم.
// ثغرة — لا تفعل هذا أبداً
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));
}
// ... التحقق من التوقيع
}الإصلاح بسيط: حدد دائماً الخوارزمية المتوقعة بشكل صريح. لا تدع الرمز يخبرك كيف تتحقق منه.
// آمن — الخوارزمية مكتوبة في الكود، لا تُقرأ من الرمز
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // اقبل فقط RS256 — تجاهل الرأس
});المكتبات الحديثة مثل jose تتعامل مع هذا بشكل صحيح افتراضياً، لكن يجب عليك مع ذلك تمرير خيار algorithms بشكل صريح كدفاع في العمق.
مرتبط بما سبق: إذا كان الخادم مُهيأ لقبول RS256، قد يقوم المهاجم بـ:
alg: "HS256"إذا قرأ الخادم رأس alg وتحوّل للتحقق بـ HS256، يصبح المفتاح العام (الذي يعرفه الجميع) هو السر المشترك. التوقيع صالح. المهاجم زوّر رمزاً.
مرة أخرى، الإصلاح نفسه: لا تثق أبداً بالخوارزمية من رأس الرمز. اكتبها دائماً في الكود.
إذا كنت تستخدم JWT، تحتاج استراتيجية رمز تحديث. إرسال رمز وصول طويل العمر يطلب المشاكل — إذا سُرق، يملك المهاجم الوصول طوال فترة الحياة.
النمط:
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 };
}في كل مرة يستخدم العميل رمز تحديث للحصول على رمز وصول جديد، تُصدر رمز تحديث جديداً وتُبطل القديم:
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 };
}تأمل هذا السيناريو:
بدون كشف إعادة الاستخدام، يحصل المستخدم على خطأ فقط. يستمر المهاجم مع الرمز B. يسجّل المستخدم الدخول مرة أخرى، دون أن يعلم أن حسابه اختُرق.
مع كشف إعادة الاستخدام وإبطال العائلة: عندما يحاول المستخدم استخدام الرمز A المستخدم بالفعل، يكشف النظام إعادة الاستخدام، ويُبطل كل رمز في العائلة (بما فيها B)، ويُجبر كلاً من المستخدم والمهاجم على إعادة المصادقة. يحصل المستخدم على طلب "الرجاء تسجيل الدخول مرة أخرى" وقد يدرك أن شيئاً خاطئاً.
هذا النهج تستخدمه Auth0 وOkta وAuth.js. ليس مثالياً — إذا استخدم المهاجم الرمز قبل المستخدم الشرعي، يصبح المستخدم الشرعي هو من يُطلق تنبيه إعادة الاستخدام. لكنه أفضل ما يمكننا فعله مع رموز الحامل.
OAuth 2.0 وOpenID Connect هما البروتوكولان وراء "تسجيل الدخول عبر Google/GitHub/Apple". فهمهما ضروري حتى لو كنت تستخدم مكتبة، لأنه عندما تتعطل الأمور — وستتعطل — تحتاج معرفة ما يحدث على مستوى البروتوكول.
OAuth 2.0 هو بروتوكول تفويض. يجيب على: "هل يمكن لهذا التطبيق الوصول لبيانات هذا المستخدم؟" النتيجة هي رمز وصول يمنح صلاحيات محددة (نطاقات).
OpenID Connect (OIDC) هو طبقة مصادقة مبنية فوق OAuth 2.0. يجيب على: "من هو هذا المستخدم؟" النتيجة هي رمز هوية (JWT) يحتوي معلومات هوية المستخدم.
عندما "تسجّل الدخول عبر Google"، أنت تستخدم OIDC. تخبر Google تطبيقك من هو المستخدم (مصادقة). قد تطلب أيضاً نطاقات OAuth للوصول لتقويمه أو القرص (تفويض).
هذا هو التدفق الذي يجب استخدامه لتطبيقات الويب. PKCE (مفتاح الإثبات لتبادل الرمز) صُمم أصلاً لتطبيقات الهاتف لكنه الآن مُوصى به لجميع العملاء، بما فيها التطبيقات من جانب الخادم.
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();
}// الخطوة 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 يكشف هذه:
معلمة state تمنع هجمات CSRF على الاستدعاء الراجع لـ OAuth. بدونها:
https://yourapp.com/callback?code=ATTACKER_CODEمع state: يُولّد تطبيقك قيمة عشوائية، يخزّنها في الجلسة، ويضمّنها في رابط التفويض. عندما يأتي الاستدعاء الراجع، تتحقق من تطابق state. لا يستطيع المهاجم تزوير هذا لأنه لا يملك الوصول لجلسة الضحية.
Auth.js هي المكتبة الأكثر شعبية لمصادقة Next.js، ومع وجيه. تتعامل مع تدفقات OAuth وإدارة الجلسات وتدوير رموز التحديث. لكنها تحتاج إعداداً صحيحاً.
// 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",
},
});// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;// 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).*)"],
};// 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" (الافتراضي لبيانات الاعتماد)، لا تحصل على إدارة جلسات قاعدة البيانات. هذا يعني لا إبطال جلسة فوري، لا قائمة جلسات نشطة، لا فرض تسجيل خروج. إذا كنت تحتاج هذه الميزات مع تسجيل دخول بالبريد/كلمة المرور، فكّر في محول قاعدة بيانات مع استراتيجية جلسة مخصصة.
مفاتيح المرور هي مستقبل المصادقة. ليست مبالغة — إنها أكثر أماناً وأسهل استخداماً من كلمات المرور. لا يوجد شيء للتسريب، لا شيء للتصيّد.
مفتاح المرور هو زوج مفاتيح تشفيرية:
لتسجيل الدخول، يثبت المستخدم ملكيته للمفتاح الخاص عن طريق توقيع تحدٍّ. يتحقق خادمك بالمفتاح العام. المفتاح الخاص لا يغادر الجهاز أبداً.
// الخادم: توليد خيارات التسجيل
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;
}// العميل: إنشاء بيانات اعتماد
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),
});
}// الخادم: التحقق من التسجيل
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. معظم المتصفحات الحديثة تدعمها. لكن:
نصيحتي: قدّم مفاتيح المرور كخيار، لا كالطريقة الوحيدة. اسمح لها جنباً إلى جنب مع البريد الإلكتروني/كلمة المرور + المصادقة الثنائية. مع نضوج المنظومة، يمكنك جعل مفاتيح المرور هي الخيار الافتراضي.
المصادقة متعددة العوامل ليست اختيارية لأي تطبيق يتعامل مع بيانات حساسة. إليك كيفية تنفيذها بشكل صحيح.
هذا ما تولّده تطبيقات المصادقة مثل Google Authenticator أو Authy. يشترك كل من الخادم والتطبيق في سر، وكلاهما يُولّد نفس الرمز المكون من 6 أرقام بناءً على الوقت الحالي.
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;
}أهم جزء من المصادقة الثنائية هو أكثر ما يُتجاهل: ماذا يحدث عندما يفقد المستخدم مصادقه؟
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,
})),
});
}وليد دائماً رموز استرداد عند تفعيل المصادقة الثنائية. اعرضها مرة واحدة. أخبر المستخدم بتخزينها بأمان. لا يمكنك عرضها مرة أخرى (أنت تخزّن التجزئات، ليس الرموز الأصلية).
المصادقة تجيب على "من أنت؟" التفويض يجيب على "ماذا يمكنك أن تفعل؟"
أبسط نموذج. المستخدمون لديهم أدوار، والأدوار لديها صلاحيات.
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 });
}
};
}لاحظ posts:write:own أعلاه. هذا نمط شائع: المؤلفون يمكنهم تعديل مقالاتهم الخاصة فقط، ليس مقالات الآخرين.
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% من الحالات بدون التعقيد الكامل.
كل نقطة هنا هي خلل رأيته في الإنتاج:
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 فاز بمسابقة تجزئة كلمات المرور لسبب.
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" فقط للعمليات الحساسة مثل تحويل الأموال.
// في 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=()",
};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. اسم المستخدم وحده لا يكفي — يمكن للمهاجمين حبس المستخدمين الشرعيين.
// سيء — يخبر المهاجمين أي البريد مسجّل
if (!user) return { error: "البريد الإلكتروني غير موجود" };
if (!validPassword) return { error: "كلمة المرور غير صحيحة" };
// جيد — رسالة موحّدة لكلتا الحالتين
if (!user || !validPassword) {
return { error: "بيانات اعتماد غير صالحة" };
}هذا ينطبق أيضاً على إعادة تعيين كلمة المرور. لا تقل "لم نجد حساباً بهذا البريد." قل "إذا كان حساب بهذا البريد موجوداً، أرسلنا رابط إعادة تعيين."
إليك كيف أبني المصادقة لتطبيق Next.js جديد في 2026:
ليس كل تطبيق يحتاج كل شيء. مدونة شخصية تحتاج Google OAuth وانتهى الأمر. منصة SaaS تحتاج القائمة الكاملة. لكن البنية يجب أن تدعم إضافة طبقات حسب الحاجة.
المصادقة ليست شيئاً تبنيه مرة وتنساه. البروتوكولات تتطور، تُكتشف الثغرات، توقعات المستخدمين تتغير. أفضل شيء يمكنك فعله هو فهم الأساسيات بعمق كافٍ لاتخاذ قرارات مستنيرة عندما تتغير الأمور. وأتمنى أن يكون هذا المقال أعطاك ذلك الأساس.