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

عصر كلمات المرور يقترب من نهايته. مفاتيح المرور (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 الذي يشغّل مفاتيح المرور. يتضمن التدفق ثلاثة أطراف:
- الطرف المعتمد (RP) — خادمك
- العميل — المتصفح
- المصدّق — مستشعر القياسات الحيوية أو مفتاح الأمان المادي
تدفق التسجيل
- يولّد الخادم تحدياً (بايتات عشوائية)
- يستدعي المتصفح
navigator.credentials.create()مع التحدي - ينشئ المصدّق زوج مفاتيح عام/خاص، ويخزن المفتاح الخاص بأمان
- يرسل المتصفح المفتاح العام والتصديق إلى الخادم
- يتحقق الخادم من التصديق ويخزن المفتاح العام
تدفق المصادقة
- يولّد الخادم تحدياً جديداً
- يستدعي المتصفح
navigator.credentials.get()مع التحدي - يوقّع المصدّق التحدي بالمفتاح الخاص
- يرسل المتصفح التأكيد الموقّع إلى الخادم
- يتحقق الخادم من التوقيع مقابل المفتاح العام المخزن
المفتاح الخاص لا يغادر الجهاز أبداً. حتى لو تم اختراق قاعدة بياناتك، يحصل المهاجمون فقط على المفاتيح العامة — وهي عديمة الفائدة بدون المفاتيح الخاصة المقفلة داخل أجهزة المستخدمين.
الخطوة 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 واختبر:
- التسجيل — أدخل بريدك الإلكتروني، انقر "إنشاء مفتاح المرور"، وصادق باستخدام Touch ID / Windows Hello
- تسجيل الدخول — انقر "تسجيل الدخول بمفتاح المرور" وتحقق بالقياسات الحيوية
- لوحة التحكم — تأكد من رؤية مفاتيح المرور المسجلة
- إضافة مفتاح مرور — أضف مفتاح مرور ثاني من جهاز أو متصفح مختلف
الاختبار على الهاتف
لاختبار على جهاز محمول أثناء التطوير:
npx localtunnel --port 3000WebAuthn يتطلب سياقاً آمناً. يعمل فقط على مصادر 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
تمثل مفاتيح المرور أهم تحسين لمصادقة الويب منذ عقود. فهي تلغي كلمات المرور تماماً، وتقاوم التصيد بالتصميم، وتوفر تجربة مستخدم أفضل من أي نظام مبني على كلمات المرور.
ناقش مشروعك معنا
نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.
دعنا نجد أفضل الحلول لاحتياجاتك.
مقالات ذات صلة

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

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

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