مفاتيح المرور و WebAuthn مع Next.js 15: بناء مصادقة بدون كلمة مرور في 2026

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

عصر كلمات المرور يقترب من نهايته. مفاتيح المرور (Passkeys) — المبنية على معيار WebAuthn — تتيح للمستخدمين المصادقة باستخدام القياسات الحيوية أو رقم PIN الجهاز أو مفاتيح الأمان المادية بدلاً من كلمات المرور. في هذا الدليل، ستبني نظام مصادقة كامل بدون كلمة مرور مع Next.js 15 باستخدام مكتبة SimpleWebAuthn.

ما ستتعلمه

بنهاية هذا الدليل، ستكون قادراً على:

  • فهم كيفية عمل مفاتيح المرور و WebAuthn API من الداخل
  • إعداد مشروع Next.js 15 مع TypeScript و Prisma
  • تطبيق تسجيل مفتاح المرور (إنشاء بيانات الاعتماد)
  • بناء تسجيل دخول بدون كلمة مرور باستخدام القياسات الحيوية أو رقم PIN
  • التعامل مع حفل WebAuthn الكامل (التحدي، التصديق، التأكيد)
  • تخزين والتحقق من بيانات الاعتماد في قاعدة بيانات PostgreSQL
  • إضافة مصادقة احتياطية للأجهزة التي لا تدعم مفاتيح المرور
  • نشر نظام مصادقة بدون كلمة مرور جاهز للإنتاج

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js 20+ مثبت (node --version)
  • خبرة في TypeScript (الأنواع، الأنماط العامة، async/await)
  • معرفة بـ Next.js 15 (App Router، Server Components، Route Handlers)
  • PostgreSQL يعمل محلياً أو قاعدة بيانات سحابية (Neon، Supabase، أو ما شابه)
  • متصفح حديث يدعم WebAuthn (Chrome، Safari، Firefox، Edge)
  • جهاز مع إمكانية القياسات الحيوية (Touch ID، Face ID، Windows Hello) أو مفتاح أمان مادي

لماذا مفاتيح المرور؟

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

الميزةكلمات المرورمفاتيح المرور
مقاومة التصيدلانعم — مرتبطة بالمصدر
لا شيء لتذكرهلانعم — القياسات الحيوية أو PIN
إعادة الاستخدام عبر المواقعشائعةمستحيلة — فريدة لكل موقع
خطر اختراق الخادمعالي — تسريب كلمات المرور المشفرةلا يوجد — يتم تخزين المفاتيح العامة فقط
تجربة المستخدممليئة بالعقباتنقرة واحدة أو نظرة

المنصات الرئيسية تدعم مفاتيح المرور بشكل أصلي: Apple iCloud Keychain يزامنها عبر الأجهزة، ومدير كلمات مرور Google يفعل الشيء نفسه على Android وChrome، وWindows Hello يتعامل معها على أجهزة Microsoft. بحلول منتصف 2026، أكثر من 75% من أجهزة المستهلكين تدعم مفاتيح المرور مباشرة.


كيف يعمل WebAuthn

WebAuthn هو معيار W3C الذي يشغّل مفاتيح المرور. يتضمن التدفق ثلاثة أطراف:

  1. الطرف المعتمد (RP) — خادمك
  2. العميل — المتصفح
  3. المصدّق — مستشعر القياسات الحيوية أو مفتاح الأمان المادي

تدفق التسجيل

  1. يولّد الخادم تحدياً (بايتات عشوائية)
  2. يستدعي المتصفح navigator.credentials.create() مع التحدي
  3. ينشئ المصدّق زوج مفاتيح عام/خاص، ويخزن المفتاح الخاص بأمان
  4. يرسل المتصفح المفتاح العام والتصديق إلى الخادم
  5. يتحقق الخادم من التصديق ويخزن المفتاح العام

تدفق المصادقة

  1. يولّد الخادم تحدياً جديداً
  2. يستدعي المتصفح navigator.credentials.get() مع التحدي
  3. يوقّع المصدّق التحدي بالمفتاح الخاص
  4. يرسل المتصفح التأكيد الموقّع إلى الخادم
  5. يتحقق الخادم من التوقيع مقابل المفتاح العام المخزن

المفتاح الخاص لا يغادر الجهاز أبداً. حتى لو تم اختراق قاعدة بياناتك، يحصل المهاجمون فقط على المفاتيح العامة — وهي عديمة الفائدة بدون المفاتيح الخاصة المقفلة داخل أجهزة المستخدمين.


الخطوة 1: إعداد المشروع

أنشئ مشروع Next.js 15 جديد مع TypeScript:

npx create-next-app@latest passkeys-demo --typescript --tailwind --app --src-dir --use-npm
cd passkeys-demo

ثبّت التبعيات:

npm install @simplewebauthn/server @simplewebauthn/browser
npm install @prisma/client
npm install -D prisma @simplewebauthn/types

إليك ما يفعله كل حزمة:

  • @simplewebauthn/server — التحقق من WebAuthn على جانب الخادم (التصديق والتأكيد)
  • @simplewebauthn/browser — مساعدات على جانب العميل لاستدعاءات navigator.credentials
  • @prisma/client — ORM لقاعدة البيانات مع أمان الأنواع
  • @simplewebauthn/types — أنواع TypeScript مشتركة

الخطوة 2: مخطط قاعدة البيانات مع Prisma

هيّئ Prisma:

npx prisma init --datasource-provider postgresql

حدّث prisma/schema.prisma لتعريف المستخدمين وبيانات اعتماد مفاتيح المرور:

generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id          String       @id @default(cuid())
  email       String       @unique
  name        String?
  credentials Credential[]
  challenges  Challenge[]
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @updatedAt
}
 
model Credential {
  id                  String   @id @default(cuid())
  credentialId        String   @unique
  credentialPublicKey Bytes
  counter             BigInt   @default(0)
  credentialDeviceType String
  credentialBackedUp  Boolean  @default(false)
  transports          String[] @default([])
  userId              String
  user                User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt           DateTime @default(now())
}
 
model Challenge {
  id        String   @id @default(cuid())
  challenge String
  userId    String?
  user      User?    @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime
  createdAt DateTime @default(now())
}

قرارات التصميم الرئيسية:

  • credentialPublicKey يُخزن كـ Bytes — المفتاح العام COSE الخام
  • counter يتتبع عدد التوقيعات لاكتشاف المصدّقات المستنسخة
  • credentialDeviceType يميّز بيانات الاعتماد أحادية الجهاز مقابل متعددة الأجهزة (المزامنة)
  • credentialBackedUp يشير إلى ما إذا كانت بيانات الاعتماد مزامنة عبر السحابة
  • transports يخزن طريقة اتصال المصدّق (USB، BLE، NFC، داخلي)
  • Challenge له تاريخ انتهاء لمنع هجمات الإعادة

شغّل عملية الترحيل:

npx prisma migrate dev --name init

أنشئ ملف Prisma client الوحيد في src/lib/prisma.ts:

import { PrismaClient } from "@prisma/client";
 
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};
 
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
 
if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

الخطوة 3: إعدادات WebAuthn

أنشئ src/lib/webauthn.ts لتجميع إعدادات الطرف المعتمد:

import type {
  GenerateRegistrationOptionsOpts,
  GenerateAuthenticationOptionsOpts,
} from "@simplewebauthn/server";
 
// إعدادات الطرف المعتمد
export const rpName = "Passkeys Demo";
export const rpID = process.env.WEBAUTHN_RP_ID || "localhost";
export const origin =
  process.env.WEBAUTHN_ORIGIN || `http://localhost:3000`;
 
// مساعد للحصول على المصادر المتوقعة (يدعم عدة مصادر)
export function getExpectedOrigins(): string[] {
  const origins = [origin];
  if (process.env.WEBAUTHN_ADDITIONAL_ORIGINS) {
    origins.push(
      ...process.env.WEBAUTHN_ADDITIONAL_ORIGINS.split(",")
    );
  }
  return origins;
}

أضف متغيرات البيئة في .env:

DATABASE_URL="postgresql://user:password@localhost:5432/passkeys_demo"
WEBAUTHN_RP_ID="localhost"
WEBAUTHN_ORIGIN="http://localhost:3000"

مهم: يجب أن يتطابق rpID مع نطاقك تماماً. للتطوير المحلي يجب أن يكون "localhost". في الإنتاج، استخدم نطاقك بدون البروتوكول — مثلاً "example.com". مفاتيح المرور مرتبطة بهذا المصدر ولن تعمل إذا تغيّر rpID.


الخطوة 4: مسارات API للتسجيل

أنشئ تدفق التسجيل مع نقطتي نهاية: واحدة لتوليد الخيارات وأخرى للتحقق من الاستجابة.

توليد خيارات التسجيل

أنشئ src/app/api/auth/register/options/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpName, rpID } from "@/lib/webauthn";
 
export async function POST(request: NextRequest) {
  try {
    const { email, name } = await request.json();
 
    if (!email) {
      return NextResponse.json(
        { error: "البريد الإلكتروني مطلوب" },
        { status: 400 }
      );
    }
 
    // البحث عن المستخدم أو إنشائه
    let user = await prisma.user.findUnique({
      where: { email },
      include: { credentials: true },
    });
 
    if (!user) {
      user = await prisma.user.create({
        data: { email, name: name || email.split("@")[0] },
        include: { credentials: true },
      });
    }
 
    // الحصول على بيانات الاعتماد الموجودة لاستثنائها
    const excludeCredentials = user.credentials.map((cred) => ({
      id: cred.credentialId,
      type: "public-key" as const,
      transports: cred.transports as AuthenticatorTransport[],
    }));
 
    // توليد خيارات التسجيل
    const options = await generateRegistrationOptions({
      rpName,
      rpID,
      userName: user.email,
      userDisplayName: user.name || user.email,
      attestationType: "none",
      excludeCredentials,
      authenticatorSelection: {
        residentKey: "preferred",
        userVerification: "preferred",
        authenticatorAttachment: "platform",
      },
      supportedAlgorithmIDs: [-7, -257],
    });
 
    // تخزين التحدي مع تاريخ الانتهاء
    await prisma.challenge.create({
      data: {
        challenge: options.challenge,
        userId: user.id,
        expiresAt: new Date(Date.now() + 5 * 60 * 1000),
      },
    });
 
    return NextResponse.json({
      options,
      userId: user.id,
    });
  } catch (error) {
    console.error("خطأ في خيارات التسجيل:", error);
    return NextResponse.json(
      { error: "فشل في توليد خيارات التسجيل" },
      { status: 500 }
    );
  }
}

التحقق من التسجيل

أنشئ src/app/api/auth/register/verify/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID, getExpectedOrigins } from "@/lib/webauthn";
 
export async function POST(request: NextRequest) {
  try {
    const { credential, userId } = await request.json();
 
    // البحث عن التحدي المخزن
    const storedChallenge = await prisma.challenge.findFirst({
      where: {
        userId,
        expiresAt: { gt: new Date() },
      },
      orderBy: { createdAt: "desc" },
    });
 
    if (!storedChallenge) {
      return NextResponse.json(
        { error: "انتهت صلاحية التحدي أو لم يُعثر عليه" },
        { status: 400 }
      );
    }
 
    // التحقق من استجابة التسجيل
    const verification = await verifyRegistrationResponse({
      response: credential,
      expectedChallenge: storedChallenge.challenge,
      expectedOrigin: getExpectedOrigins(),
      expectedRPID: rpID,
      requireUserVerification: false,
    });
 
    if (!verification.verified || !verification.registrationInfo) {
      return NextResponse.json(
        { error: "فشل التحقق" },
        { status: 400 }
      );
    }
 
    const {
      credential: registrationCredential,
      credentialDeviceType,
      credentialBackedUp,
    } = verification.registrationInfo;
 
    // تخزين بيانات الاعتماد
    await prisma.credential.create({
      data: {
        credentialId: registrationCredential.id,
        credentialPublicKey: Buffer.from(
          registrationCredential.publicKey
        ),
        counter: registrationCredential.counter,
        credentialDeviceType,
        credentialBackedUp,
        transports: credential.response.transports || [],
        userId,
      },
    });
 
    // تنظيف التحدي المستخدم
    await prisma.challenge.delete({
      where: { id: storedChallenge.id },
    });
 
    return NextResponse.json({
      verified: true,
      credentialDeviceType,
      credentialBackedUp,
    });
  } catch (error) {
    console.error("خطأ في التحقق من التسجيل:", error);
    return NextResponse.json(
      { error: "فشل التحقق" },
      { status: 500 }
    );
  }
}

الخطوة 5: مسارات API للمصادقة

توليد خيارات المصادقة

أنشئ src/app/api/auth/login/options/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID } from "@/lib/webauthn";
 
export async function POST(request: NextRequest) {
  try {
    const { email } = await request.json();
 
    let allowCredentials: {
      id: string;
      type: "public-key";
      transports?: AuthenticatorTransport[];
    }[] = [];
 
    if (email) {
      const user = await prisma.user.findUnique({
        where: { email },
        include: { credentials: true },
      });
 
      if (user) {
        allowCredentials = user.credentials.map((cred) => ({
          id: cred.credentialId,
          type: "public-key" as const,
          transports: cred.transports as AuthenticatorTransport[],
        }));
      }
    }
 
    const options = await generateAuthenticationOptions({
      rpID,
      userVerification: "preferred",
      allowCredentials:
        allowCredentials.length > 0 ? allowCredentials : undefined,
    });
 
    const user = email
      ? await prisma.user.findUnique({ where: { email } })
      : null;
 
    await prisma.challenge.create({
      data: {
        challenge: options.challenge,
        userId: user?.id || null,
        expiresAt: new Date(Date.now() + 5 * 60 * 1000),
      },
    });
 
    return NextResponse.json({ options });
  } catch (error) {
    console.error("خطأ في خيارات المصادقة:", error);
    return NextResponse.json(
      { error: "فشل في توليد خيارات المصادقة" },
      { status: 500 }
    );
  }
}

التحقق من المصادقة

أنشئ src/app/api/auth/login/verify/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID, getExpectedOrigins } from "@/lib/webauthn";
import { cookies } from "next/headers";
 
export async function POST(request: NextRequest) {
  try {
    const { credential } = await request.json();
 
    // البحث عن بيانات الاعتماد في قاعدة البيانات
    const storedCredential = await prisma.credential.findUnique({
      where: { credentialId: credential.id },
      include: { user: true },
    });
 
    if (!storedCredential) {
      return NextResponse.json(
        { error: "لم يُعثر على بيانات الاعتماد" },
        { status: 400 }
      );
    }
 
    // البحث عن التحدي المخزن
    const storedChallenge = await prisma.challenge.findFirst({
      where: {
        expiresAt: { gt: new Date() },
        OR: [
          { userId: storedCredential.userId },
          { userId: null },
        ],
      },
      orderBy: { createdAt: "desc" },
    });
 
    if (!storedChallenge) {
      return NextResponse.json(
        { error: "انتهت صلاحية التحدي أو لم يُعثر عليه" },
        { status: 400 }
      );
    }
 
    // التحقق من استجابة المصادقة
    const verification = await verifyAuthenticationResponse({
      response: credential,
      expectedChallenge: storedChallenge.challenge,
      expectedOrigin: getExpectedOrigins(),
      expectedRPID: rpID,
      credential: {
        id: storedCredential.credentialId,
        publicKey: new Uint8Array(storedCredential.credentialPublicKey),
        counter: Number(storedCredential.counter),
        transports:
          storedCredential.transports as AuthenticatorTransport[],
      },
      requireUserVerification: false,
    });
 
    if (!verification.verified) {
      return NextResponse.json(
        { error: "فشلت المصادقة" },
        { status: 400 }
      );
    }
 
    // تحديث العداد (مهم للأمان)
    await prisma.credential.update({
      where: { id: storedCredential.id },
      data: {
        counter: verification.authenticationInfo.newCounter,
      },
    });
 
    // تنظيف التحدي
    await prisma.challenge.delete({
      where: { id: storedChallenge.id },
    });
 
    // إنشاء جلسة
    const cookieStore = await cookies();
    cookieStore.set("session", storedCredential.userId, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      maxAge: 60 * 60 * 24 * 7,
      path: "/",
    });
 
    return NextResponse.json({
      verified: true,
      user: {
        id: storedCredential.user.id,
        email: storedCredential.user.email,
        name: storedCredential.user.name,
      },
    });
  } catch (error) {
    console.error("خطأ في التحقق من المصادقة:", error);
    return NextResponse.json(
      { error: "فشلت المصادقة" },
      { status: 500 }
    );
  }
}

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


الخطوة 6: خطافات جانب العميل

أنشئ خطافاً مخصصاً لإدارة تدفق WebAuthn في src/hooks/use-passkey.ts:

"use client";
 
import {
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthn,
} from "@simplewebauthn/browser";
import { useState, useCallback } from "react";
 
interface UsePasskeyReturn {
  isSupported: boolean;
  isLoading: boolean;
  error: string | null;
  register: (email: string, name?: string) => Promise<boolean>;
  login: (email?: string) => Promise<boolean>;
}
 
export function usePasskey(): UsePasskeyReturn {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const isSupported = browserSupportsWebAuthn();
 
  const register = useCallback(
    async (email: string, name?: string): Promise<boolean> => {
      setIsLoading(true);
      setError(null);
 
      try {
        // الخطوة 1: الحصول على خيارات التسجيل من الخادم
        const optionsRes = await fetch(
          "/api/auth/register/options",
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ email, name }),
          }
        );
 
        if (!optionsRes.ok) {
          throw new Error("فشل في الحصول على خيارات التسجيل");
        }
 
        const { options, userId } = await optionsRes.json();
 
        // الخطوة 2: إنشاء بيانات الاعتماد عبر API المتصفح
        const credential = await startRegistration({
          optionsJSON: options,
        });
 
        // الخطوة 3: التحقق مع الخادم
        const verifyRes = await fetch(
          "/api/auth/register/verify",
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ credential, userId }),
          }
        );
 
        if (!verifyRes.ok) {
          throw new Error("فشل التحقق من التسجيل");
        }
 
        const result = await verifyRes.json();
        return result.verified;
      } catch (err) {
        const message =
          err instanceof Error ? err.message : "فشل التسجيل";
        setError(message);
        return false;
      } finally {
        setIsLoading(false);
      }
    },
    []
  );
 
  const login = useCallback(
    async (email?: string): Promise<boolean> => {
      setIsLoading(true);
      setError(null);
 
      try {
        const optionsRes = await fetch("/api/auth/login/options", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ email }),
        });
 
        if (!optionsRes.ok) {
          throw new Error("فشل في الحصول على خيارات المصادقة");
        }
 
        const { options } = await optionsRes.json();
 
        const credential = await startAuthentication({
          optionsJSON: options,
        });
 
        const verifyRes = await fetch("/api/auth/login/verify", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ credential }),
        });
 
        if (!verifyRes.ok) {
          throw new Error("فشلت المصادقة");
        }
 
        const result = await verifyRes.json();
        return result.verified;
      } catch (err) {
        const message =
          err instanceof Error ? err.message : "فشل تسجيل الدخول";
        setError(message);
        return false;
      } finally {
        setIsLoading(false);
      }
    },
    []
  );
 
  return { isSupported, isLoading, error, register, login };
}

الخطوة 7: مكوّن واجهة التسجيل

أنشئ src/components/passkey-register.tsx:

"use client";
 
import { useState } from "react";
import { usePasskey } from "@/hooks/use-passkey";
 
export function PasskeyRegister() {
  const { isSupported, isLoading, error, register } = usePasskey();
  const [email, setEmail] = useState("");
  const [name, setName] = useState("");
  const [success, setSuccess] = useState(false);
 
  if (!isSupported) {
    return (
      <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
        <p className="text-yellow-800">
          متصفحك لا يدعم مفاتيح المرور. يرجى استخدام متصفح حديث
          مثل Chrome أو Safari أو Firefox.
        </p>
      </div>
    );
  }
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const result = await register(email, name);
    if (result) {
      setSuccess(true);
    }
  };
 
  if (success) {
    return (
      <div className="rounded-lg border border-green-200 bg-green-50 p-6 text-center">
        <h3 className="text-lg font-semibold text-green-800">
          تم إنشاء مفتاح المرور!
        </h3>
        <p className="mt-2 text-green-600">
          تم تسجيل مفتاح المرور الخاص بك. يمكنك الآن تسجيل الدخول
          باستخدام بصمة الإصبع أو الوجه أو رقم PIN الجهاز.
        </p>
      </div>
    );
  }
 
  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label
          htmlFor="email"
          className="block text-sm font-medium text-gray-700"
        >
          البريد الإلكتروني
        </label>
        <input
          id="email"
          type="email"
          required
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
          placeholder="you@example.com"
        />
      </div>
 
      <div>
        <label
          htmlFor="name"
          className="block text-sm font-medium text-gray-700"
        >
          الاسم المعروض (اختياري)
        </label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
          placeholder="أحمد محمد"
        />
      </div>
 
      {error && (
        <div className="rounded-md bg-red-50 p-3">
          <p className="text-sm text-red-700">{error}</p>
        </div>
      )}
 
      <button
        type="submit"
        disabled={isLoading}
        className="w-full rounded-md bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
      >
        {isLoading ? "جاري إنشاء مفتاح المرور..." : "إنشاء مفتاح المرور"}
      </button>
    </form>
  );
}

الخطوة 8: مكوّن واجهة تسجيل الدخول

أنشئ src/components/passkey-login.tsx:

"use client";
 
import { useState } from "react";
import { usePasskey } from "@/hooks/use-passkey";
import { useRouter } from "next/navigation";
 
export function PasskeyLogin() {
  const { isSupported, isLoading, error, login } = usePasskey();
  const [email, setEmail] = useState("");
  const router = useRouter();
 
  if (!isSupported) {
    return (
      <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
        <p className="text-yellow-800">
          متصفحك لا يدعم مفاتيح المرور.
        </p>
      </div>
    );
  }
 
  const handleEmailLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    const result = await login(email);
    if (result) {
      router.push("/dashboard");
    }
  };
 
  const handleQuickLogin = async () => {
    const result = await login();
    if (result) {
      router.push("/dashboard");
    }
  };
 
  return (
    <div className="space-y-6">
      <button
        onClick={handleQuickLogin}
        disabled={isLoading}
        className="w-full rounded-md bg-indigo-600 px-4 py-3 text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
      >
        {isLoading ? (
          "جاري التحقق..."
        ) : (
          <span className="flex items-center justify-center gap-2">
            <svg
              className="h-5 w-5"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4"
              />
            </svg>
            تسجيل الدخول بمفتاح المرور
          </span>
        )}
      </button>
 
      <div className="relative">
        <div className="absolute inset-0 flex items-center">
          <div className="w-full border-t border-gray-300" />
        </div>
        <div className="relative flex justify-center text-sm">
          <span className="bg-white px-2 text-gray-500">
            أو أدخل بريدك الإلكتروني
          </span>
        </div>
      </div>
 
      <form onSubmit={handleEmailLogin} className="space-y-4">
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
          placeholder="you@example.com"
        />
        <button
          type="submit"
          disabled={isLoading || !email}
          className="w-full rounded-md border border-indigo-600 px-4 py-2 text-indigo-600 hover:bg-indigo-50 disabled:opacity-50"
        >
          المتابعة بالبريد الإلكتروني
        </button>
      </form>
 
      {error && (
        <div className="rounded-md bg-red-50 p-3">
          <p className="text-sm text-red-700">{error}</p>
        </div>
      )}
    </div>
  );
}

الخطوة 9: صفحات المصادقة

أنشئ صفحة المصادقة الرئيسية في src/app/auth/page.tsx:

"use client";
 
import { useState } from "react";
import { PasskeyRegister } from "@/components/passkey-register";
import { PasskeyLogin } from "@/components/passkey-login";
 
export default function AuthPage() {
  const [mode, setMode] = useState<"login" | "register">("login");
 
  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50">
      <div className="w-full max-w-md space-y-8 rounded-xl bg-white p-8 shadow-lg">
        <div className="text-center">
          <h1 className="text-2xl font-bold text-gray-900">
            {mode === "login" ? "مرحباً بعودتك" : "إنشاء حساب"}
          </h1>
          <p className="mt-2 text-gray-600">
            {mode === "login"
              ? "سجّل الدخول بمفتاح المرور"
              : "سجّل مفتاح مرور جديد"}
          </p>
        </div>
 
        {mode === "login" ? <PasskeyLogin /> : <PasskeyRegister />}
 
        <div className="text-center">
          <button
            onClick={() =>
              setMode(mode === "login" ? "register" : "login")
            }
            className="text-sm text-indigo-600 hover:text-indigo-500"
          >
            {mode === "login"
              ? "تحتاج حساباً؟ سجّل الآن"
              : "لديك مفتاح مرور بالفعل؟ سجّل الدخول"}
          </button>
        </div>
      </div>
    </div>
  );
}

الخطوة 10: إدارة الجلسات والمسارات المحمية

أنشئ مساعد الجلسة في src/lib/session.ts:

import { cookies } from "next/headers";
import { prisma } from "./prisma";
 
export async function getSession() {
  const cookieStore = await cookies();
  const sessionCookie = cookieStore.get("session");
 
  if (!sessionCookie?.value) {
    return null;
  }
 
  const user = await prisma.user.findUnique({
    where: { id: sessionCookie.value },
    select: {
      id: true,
      email: true,
      name: true,
      credentials: {
        select: {
          id: true,
          credentialDeviceType: true,
          credentialBackedUp: true,
          createdAt: true,
        },
      },
    },
  });
 
  return user;
}

أنشئ الـ middleware لحماية المسارات. حدّث src/middleware.ts:

import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  const session = request.cookies.get("session");
 
  if (request.nextUrl.pathname.startsWith("/dashboard")) {
    if (!session?.value) {
      return NextResponse.redirect(new URL("/auth", request.url));
    }
  }
 
  if (request.nextUrl.pathname === "/auth") {
    if (session?.value) {
      return NextResponse.redirect(
        new URL("/dashboard", request.url)
      );
    }
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/dashboard/:path*", "/auth"],
};

الخطوة 11: إدارة مفاتيح المرور

اسمح للمستخدمين بإضافة مفاتيح مرور إضافية وتسجيل الخروج. أنشئ src/components/passkey-manager.tsx:

"use client";
 
import { usePasskey } from "@/hooks/use-passkey";
import { useRouter } from "next/navigation";
 
export function PasskeyManager() {
  const { isLoading, error, register } = usePasskey();
  const router = useRouter();
 
  const handleAddPasskey = async () => {
    const res = await fetch("/api/auth/me");
    if (!res.ok) return;
    const { email } = await res.json();
    await register(email);
    router.refresh();
  };
 
  const handleSignOut = async () => {
    await fetch("/api/auth/logout", { method: "POST" });
    router.push("/auth");
  };
 
  return (
    <div className="mt-6 flex gap-3">
      <button
        onClick={handleAddPasskey}
        disabled={isLoading}
        className="rounded-md border border-indigo-600 px-4 py-2 text-indigo-600 hover:bg-indigo-50 disabled:opacity-50"
      >
        {isLoading ? "جاري الإضافة..." : "إضافة مفتاح مرور آخر"}
      </button>
      <button
        onClick={handleSignOut}
        className="rounded-md border border-red-300 px-4 py-2 text-red-600 hover:bg-red-50"
      >
        تسجيل الخروج
      </button>
      {error && <p className="text-sm text-red-600">{error}</p>}
    </div>
  );
}

الخطوة 12: واجهة مشروطة لدعم WebAuthn

ليست جميع المتصفحات والأجهزة تدعم مفاتيح المرور بشكل متساوٍ. أنشئ مكوّن مساعد للتراجع التدريجي في src/components/webauthn-check.tsx:

"use client";
 
import {
  browserSupportsWebAuthn,
  platformAuthenticatorIsAvailable,
} from "@simplewebauthn/browser";
import { useEffect, useState } from "react";
 
interface WebAuthnStatus {
  webauthnSupported: boolean;
  platformSupported: boolean;
  checked: boolean;
}
 
export function useWebAuthnStatus(): WebAuthnStatus {
  const [status, setStatus] = useState<WebAuthnStatus>({
    webauthnSupported: false,
    platformSupported: false,
    checked: false,
  });
 
  useEffect(() => {
    async function check() {
      const webauthnSupported = browserSupportsWebAuthn();
      const platformSupported = webauthnSupported
        ? await platformAuthenticatorIsAvailable()
        : false;
      setStatus({
        webauthnSupported,
        platformSupported,
        checked: true,
      });
    }
    check();
  }, []);
 
  return status;
}

اختبار التطبيق

الاختبار المحلي

شغّل خادم التطوير:

npx prisma migrate dev
npm run dev

افتح http://localhost:3000/auth واختبر:

  1. التسجيل — أدخل بريدك الإلكتروني، انقر "إنشاء مفتاح المرور"، وصادق باستخدام Touch ID / Windows Hello
  2. تسجيل الدخول — انقر "تسجيل الدخول بمفتاح المرور" وتحقق بالقياسات الحيوية
  3. لوحة التحكم — تأكد من رؤية مفاتيح المرور المسجلة
  4. إضافة مفتاح مرور — أضف مفتاح مرور ثاني من جهاز أو متصفح مختلف

الاختبار على الهاتف

لاختبار على جهاز محمول أثناء التطوير:

npx localtunnel --port 3000

WebAuthn يتطلب سياقاً آمناً. يعمل فقط على مصادر https:// أو localhost. عند الاختبار على الهاتف، تحتاج HTTPS — استخدم خدمة نفق أو انشر في بيئة مرحلية.


استكشاف الأخطاء وإصلاحها

"انتهت العملية أو لم يُسمح بها"

هذا عادة يعني:

  • ألغى المستخدم موجه القياسات الحيوية
  • انتهت مهلة المصدّق (الافتراضي 60 ثانية)
  • rpID لا يتطابق مع المصدر الحالي

"NotAllowedError: الطلب غير مسموح به"

تحقق من هذه الأسباب الشائعة:

  • WebAuthn يتطلب سياقاً آمناً (HTTPS أو localhost)
  • يجب أن تكون الصفحة نشطة — التبويبات في الخلفية لا يمكنها تشغيل WebAuthn
  • على Safari، يجب أن يأتي الاستدعاء من إيماءة مستخدم (معالج نقرة)

أخطاء عدم تطابق العداد

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


قائمة فحص نشر الإنتاج

قبل الانتقال للإنتاج، تحقق من:

  • تعيين WEBAUTHN_RP_ID لنطاق الإنتاج
  • تعيين WEBAUTHN_ORIGIN لعنوان URL الإنتاجي
  • تمكين HTTPS — WebAuthn لن يعمل بدونه
  • استبدال جلسة الكوكي المبسطة بمكتبة جلسات مناسبة
  • إضافة تحديد المعدل لنقاط نهاية المصادقة
  • إعداد تنظيف التحديات المنتهية
  • إضافة مصادقة احتياطية بكلمة مرور للأجهزة القديمة
  • الاختبار عبر المتصفحات: Chrome، Safari، Firefox، Edge

الخطوات التالية

الآن بعد أن أصبحت المصادقة بدون كلمة مرور تعمل، فكّر في هذه التحسينات:

  • الواجهة المشروطة — استخدم PublicKeyCredential.isConditionalMediationAvailable() لعرض مفاتيح المرور في قائمة الملء التلقائي للمتصفح
  • نهج هجين — اسمح للمستخدمين بتسجيل كلمة مرور أولاً، ثم إضافة مفتاح مرور لاحقاً كمسار ترقية
  • استعادة الحساب — طبّق أكواد الاستعادة أو استعادة الحساب عبر البريد الإلكتروني
  • سجل التدقيق — تتبع أحداث المصادقة (تسجيلات الدخول الناجحة، المحاولات الفاشلة)
  • المصادقة متعددة العوامل — ادمج مفاتيح المرور مع عامل ثانٍ للعمليات عالية الأمان

الخاتمة

لقد بنيت نظام مصادقة كامل بدون كلمة مرور باستخدام مفاتيح المرور و WebAuthn مع Next.js 15. يتعامل تطبيقك مع:

  • تسجيل بيانات الاعتماد مع التحقق البيومتري أو رقم PIN
  • تسجيل الدخول بدون كلمة مرور يدعم التدفقات القابلة للاكتشاف والمبنية على البريد الإلكتروني
  • إدارة الجلسات مع مسارات محمية و middleware
  • إدارة مفاتيح المرور تسمح للمستخدمين بإضافة بيانات اعتماد متعددة
  • التراجع التدريجي للمتصفحات بدون دعم WebAuthn

تمثل مفاتيح المرور أهم تحسين لمصادقة الويب منذ عقود. فهي تلغي كلمات المرور تماماً، وتقاوم التصيد بالتصميم، وتوفر تجربة مستخدم أفضل من أي نظام مبني على كلمات المرور.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على React Three Fiber + Next.js: بناء تجارب ويب ثلاثية الأبعاد تفاعلية من الصفر.

ناقش مشروعك معنا

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

دعنا نجد أفضل الحلول لاحتياجاتك.

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

إضافة المصادقة لتطبيق Next.js 15 باستخدام Auth.js v5: البريد الإلكتروني وOAuth والتحكم بالأدوار

تعلم كيفية إضافة نظام مصادقة جاهز للإنتاج لتطبيق Next.js 15 باستخدام Auth.js v5. يغطي هذا الدليل الشامل تسجيل الدخول عبر Google OAuth وبيانات الاعتماد بالبريد الإلكتروني وكلمة المرور والمسارات المحمية والتحكم بالوصول حسب الأدوار.

30 د قراءة·

بناء مشروع SaaS متكامل باستخدام Next.js 15 و Stripe و Auth.js v5

تعلم كيفية بناء تطبيق SaaS جاهز للإنتاج باستخدام Next.js 15 مع نظام اشتراكات Stripe ومصادقة Auth.js v5. يغطي هذا الدليل التفصيلي إعداد المشروع وتسجيل الدخول عبر OAuth وخطط الأسعار ومعالجة Webhooks والمسارات المحمية.

35 د قراءة·