إطار Next.js

المصادقة في Next.js

30 دقيقة الدرس 17 من 40

مقدمة إلى المصادقة

المصادقة هي ميزة حاسمة في تطبيقات الويب الحديثة. يوفر Next.js خيارات مرنة لتنفيذ المصادقة، من حلول JWT البسيطة إلى موفري خدمات شاملين مثل NextAuth.js (الآن Auth.js). يغطي هذا الدرس التنفيذ الكامل للمصادقة.

مهم: تمت إعادة تسمية NextAuth.js إلى Auth.js. اسم الحزمة الآن هو @auth/nextjs-auth، لكن الوظائف تبقى نفسها. سنستخدم مصطلح "Auth.js" في هذا الدرس.

فهم استراتيجيات المصادقة

قبل تنفيذ المصادقة، افهم الاستراتيجيات المختلفة المتاحة:

  • قائمة على الجلسة: يخزن الخادم بيانات الجلسة، يرسل معرف الجلسة إلى العميل عبر ملف تعريف الارتباط
  • قائمة على الرمز (JWT): يخزن العميل رمزاً يحتوي على بيانات المستخدم، يرسله مع كل طلب
  • OAuth/تسجيل الدخول الاجتماعي: تفويض المصادقة إلى موفري خدمات خارجيين (Google، GitHub، إلخ.)
  • بدون كلمة مرور: روابط سحرية عبر البريد الإلكتروني أو رموز SMS بدلاً من كلمات المرور

تثبيت وتكوين Auth.js

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

# تثبيت Auth.js
npm install next-auth@beta

# تثبيت موفري خدمات إضافيين إذا لزم الأمر
npm install @auth/prisma-adapter
npm install bcryptjs
npm install @types/bcryptjs -D
<!-- lib/auth.ts -->
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import GitHub from 'next-auth/providers/github'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
import bcrypt from 'bcryptjs'

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: {
    strategy: 'jwt',
  },
  pages: {
    signIn: '/login',
    error: '/auth/error',
  },
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
    Credentials({
      name: 'credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          throw new Error('Invalid credentials')
        }

        const user = await prisma.user.findUnique({
          where: {
            email: credentials.email as string
          }
        })

        if (!user || !user?.hashedPassword) {
          throw new Error('Invalid credentials')
        }

        const isCorrectPassword = await bcrypt.compare(
          credentials.password as string,
          user.hashedPassword
        )

        if (!isCorrectPassword) {
          throw new Error('Invalid credentials')
        }

        return user
      }
    })
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id
        token.role = user.role
      }
      return token
    },
    async session({ session, token }) {
      if (token && session.user) {
        session.user.id = token.id as string
        session.user.role = token.role as string
      }
      return session
    }
  }
})
<!-- app/api/auth/[...nextauth]/route.ts -->
import { handlers } from '@/lib/auth'

export const { GET, POST } = handlers
نصيحة أمنية: قم دائماً بتخزين بيانات الاعتماد الحساسة مثل معرفات العملاء والأسرار في متغيرات البيئة، ولا تقم أبداً بإرسالها إلى التحكم في الإصدار.

تكوين البيئة

أنشئ ملف .env الخاص بك مع بيانات الاعتماد الضرورية للمصادقة:

# .env
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-super-secret-key-here

# موفرو OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret

# قاعدة البيانات
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
مهم: قم بإنشاء NEXTAUTH_SECRET آمن باستخدام: openssl rand -base64 32. لا تستخدم أبداً سلسلة بسيطة في الإنتاج.

إنشاء موفري المصادقة

لجعل المصادقة متاحة في جميع أنحاء تطبيقك، أنشئ موفر جلسة:

<!-- app/providers/session-provider.tsx -->
'use client'

import { SessionProvider } from 'next-auth/react'

export function AuthProvider({
  children,
}: {
  children: React.ReactNode
}) {
  return <SessionProvider>{children}</SessionProvider>
}
<!-- app/layout.tsx -->
import { AuthProvider } from './providers/session-provider'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ar">
      <body>
        <AuthProvider>
          {children}
        </AuthProvider>
      </body>
    </html>
  )
}

بناء مكونات تسجيل الدخول

أنشئ صفحة تسجيل دخول شاملة مع خيارات مصادقة متعددة:

<!-- app/login/page.tsx -->
import { LoginForm } from './login-form'
import { SocialLogins } from './social-logins'

export default function LoginPage() {
  return (
    <div className="max-w-md mx-auto mt-8 p-6">
      <h1 className="text-2xl font-bold mb-6">تسجيل الدخول</h1>

      <SocialLogins />

      <div className="relative my-6">
        <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="px-2 bg-white text-gray-500">
            أو تابع باستخدام البريد الإلكتروني
          </span>
        </div>
      </div>

      <LoginForm />
    </div>
  )
}
<!-- app/login/social-logins.tsx -->
'use client'

import { signIn } from 'next-auth/react'
import { useState } from 'react'

export function SocialLogins() {
  const [isLoading, setIsLoading] = useState<string | null>(null)

  const handleSocialLogin = async (provider: string) => {
    try {
      setIsLoading(provider)
      await signIn(provider, { callbackUrl: '/dashboard' })
    } catch (error) {
      console.error(`${provider} sign in error:`, error)
    } finally {
      setIsLoading(null)
    }
  }

  return (
    <div className="space-y-3">
      <button
        onClick={() => handleSocialLogin('google')}
        disabled={isLoading === 'google'}
        className="w-full flex items-center justify-center gap-3 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
      >
        <GoogleIcon />
        {isLoading === 'google' ? 'جاري التحميل...' : 'تابع مع Google'}
      </button>

      <button
        onClick={() => handleSocialLogin('github')}
        disabled={isLoading === 'github'}
        className="w-full flex items-center justify-center gap-3 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
      >
        <GitHubIcon />
        {isLoading === 'github' ? 'جاري التحميل...' : 'تابع مع GitHub'}
      </button>
    </div>
  )
}
<!-- app/login/login-form.tsx -->
'use client'

import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'

export function LoginForm() {
  const router = useRouter()
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    setIsLoading(true)
    setError(null)

    const formData = new FormData(e.currentTarget)
    const email = formData.get('email') as string
    const password = formData.get('password') as string

    try {
      const result = await signIn('credentials', {
        email,
        password,
        redirect: false,
      })

      if (result?.error) {
        setError('بريد إلكتروني أو كلمة مرور غير صالحة')
        return
      }

      router.push('/dashboard')
      router.refresh()
    } catch (error) {
      setError('حدث خطأ ما. يرجى المحاولة مرة أخرى.')
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      {error && (
        <div className="p-3 text-sm text-red-500 bg-red-50 rounded-lg">
          {error}
        </div>
      )}

      <div>
        <label htmlFor="email" className="block text-sm font-medium mb-1">
          البريد الإلكتروني
        </label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium mb-1">
          كلمة المرور
        </label>
        <input
          id="password"
          name="password"
          type="password"
          required
          className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>

      <button
        type="submit"
        disabled={isLoading}
        className="w-full px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
      >
        {isLoading ? 'جاري تسجيل الدخول...' : 'تسجيل الدخول'}
      </button>
    </form>
  )
}

مسار API للتسجيل

أنشئ مسار API للتعامل مع تسجيل المستخدم:

<!-- app/api/register/route.ts -->
import { NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'

export async function POST(request: Request) {
  try {
    const { name, email, password } = await request.json()

    // التحقق من الصحة
    if (!name || !email || !password) {
      return NextResponse.json(
        { error: 'حقول مطلوبة مفقودة' },
        { status: 400 }
      )
    }

    if (password.length < 8) {
      return NextResponse.json(
        { error: 'يجب أن تكون كلمة المرور 8 أحرف على الأقل' },
        { status: 400 }
      )
    }

    // التحقق من وجود المستخدم
    const existingUser = await prisma.user.findUnique({
      where: { email }
    })

    if (existingUser) {
      return NextResponse.json(
        { error: 'المستخدم موجود بالفعل' },
        { status: 400 }
      )
    }

    // تشفير كلمة المرور
    const hashedPassword = await bcrypt.hash(password, 12)

    // إنشاء المستخدم
    const user = await prisma.user.create({
      data: {
        name,
        email,
        hashedPassword,
      }
    })

    return NextResponse.json(
      { message: 'تم إنشاء المستخدم بنجاح', userId: user.id },
      { status: 201 }
    )
  } catch (error) {
    console.error('خطأ في التسجيل:', error)
    return NextResponse.json(
      { error: 'حدث خطأ ما' },
      { status: 500 }
    )
  }
}

حماية المسارات باستخدام Middleware

استخدم وسيط Next.js لحماية المسارات وإعادة توجيه المستخدمين غير المصادق عليهم:

<!-- middleware.ts -->
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'

export default auth((req) => {
  const isLoggedIn = !!req.auth
  const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard')
  const isOnAdminPanel = req.nextUrl.pathname.startsWith('/admin')
  const isOnAuthPage = req.nextUrl.pathname.startsWith('/login') ||
                       req.nextUrl.pathname.startsWith('/register')

  // إعادة التوجيه إلى تسجيل الدخول عند الوصول إلى المسارات المحمية دون تسجيل الدخول
  if ((isOnDashboard || isOnAdminPanel) && !isLoggedIn) {
    return NextResponse.redirect(new URL('/login', req.url))
  }

  // إعادة التوجيه إلى لوحة المعلومات عند الوصول إلى صفحات المصادقة أثناء تسجيل الدخول
  if (isOnAuthPage && isLoggedIn) {
    return NextResponse.redirect(new URL('/dashboard', req.url))
  }

  // التحقق من دور المسؤول لمسارات المسؤول
  if (isOnAdminPanel && req.auth?.user?.role !== 'admin') {
    return NextResponse.redirect(new URL('/dashboard', req.url))
  }

  return NextResponse.next()
})

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*', '/login', '/register']
}
فوائد Middleware: يعمل على الحافة قبل معالجة الطلب، مما يوفر فحوصات مصادقة سريعة دون الوصول إلى قاعدة البيانات أو مسارات API.

الوصول إلى الجلسة من جانب الخادم

الوصول إلى بيانات الجلسة في مكونات الخادم ومسارات API:

<!-- 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) {
    redirect('/login')
  }

  return (
    <div>
      <h1>مرحباً، {session.user?.name}</h1>
      <p>البريد الإلكتروني: {session.user?.email}</p>
    </div>
  )
}
<!-- app/api/user/profile/route.ts -->
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

export async function GET() {
  const session = await auth()

  if (!session?.user?.id) {
    return NextResponse.json(
      { error: 'غير مصرح' },
      { status: 401 }
    )
  }

  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: {
      id: true,
      name: true,
      email: true,
      image: true,
      createdAt: true,
    }
  })

  return NextResponse.json({ user })
}

الوصول إلى الجلسة من جانب العميل

استخدم خطاف useSession في مكونات العميل:

<!-- app/components/user-menu.tsx -->
'use client'

import { useSession, signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation'

export function UserMenu() {
  const { data: session, status } = useSession()
  const router = useRouter()

  if (status === 'loading') {
    return <div>جاري التحميل...</div>
  }

  if (!session) {
    return (
      <button onClick={() => router.push('/login')}>
        تسجيل الدخول
      </button>
    )
  }

  return (
    <div className="flex items-center gap-4">
      <img
        src={session.user?.image || '/default-avatar.png'}
        alt={session.user?.name || 'المستخدم'}
        className="w-8 h-8 rounded-full"
      />
      <span>{session.user?.name}</span>
      <button
        onClick={() => signOut({ callbackUrl: '/' })}
        className="text-red-600 hover:text-red-700"
      >
        تسجيل الخروج
      </button>
    </div>
  )
}

التحكم في الوصول بناءً على الدور

قم بتنفيذ التفويض القائم على الدور للتحكم الدقيق في الوصول:

<!-- lib/auth-utils.ts -->
import { auth } from './auth'
import { redirect } from 'next/navigation'

export async function requireAuth() {
  const session = await auth()

  if (!session) {
    redirect('/login')
  }

  return session
}

export async function requireAdmin() {
  const session = await requireAuth()

  if (session.user?.role !== 'admin') {
    redirect('/dashboard')
  }

  return session
}
<!-- app/admin/page.tsx -->
import { requireAdmin } from '@/lib/auth-utils'

export default async function AdminPage() {
  const session = await requireAdmin()

  return (
    <div>
      <h1>لوحة تحكم المسؤول</h1>
      <p>مرحباً، {session.user?.name}</p>
    </div>
  )
}
تمرين عملي:
  1. قم بإعداد Auth.js مع موفري خدمات على الأقل (Google وبيانات الاعتماد)
  2. أنشئ صفحات التسجيل وتسجيل الدخول مع التحقق من الصحة الصحيح
  3. قم بتنفيذ وسيط لحماية مسارات لوحة المعلومات
  4. أضف التحكم في الوصول القائم على الدور لمسارات المسؤول
  5. أنشئ صفحة ملف تعريف المستخدم حيث يمكن للمستخدمين تحديث معلوماتهم
  6. قم بتنفيذ وظيفة إعادة تعيين كلمة المرور باستخدام روابط سحرية عبر البريد الإلكتروني

أفضل ممارسات إدارة الجلسة

  • استخدم دائماً HTTPS في الإنتاج لحماية ملفات تعريف الارتباط للجلسة
  • اضبط خيارات ملف تعريف الارتباط المناسبة (secure، httpOnly، sameSite)
  • قم بتنفيذ مهلة الجلسة وآليات التحديث
  • قم بتخزين الحد الأدنى من البيانات في JWTs للحفاظ على صغر حجمها
  • استخدم جلسات قاعدة البيانات للتطبيقات الحساسة التي تتطلب إلغاء فوري
  • قم دائماً بتشفير كلمات المرور باستخدام bcrypt (عامل التكلفة 12+)
  • لا تسجل أو تعرض بيانات المصادقة الحساسة أبداً

الخلاصة

المصادقة في Next.js مبسطة مع Auth.js، حيث توفر OAuth وبيانات الاعتماد والمصادقة بدون كلمة مرور جاهزة. ادمج الحماية من جانب الخادم مع الوسيط، واستخدم الجلسات بشكل مناسب في كل من مكونات الخادم والعميل، ونفذ التحكم في الوصول القائم على الدور للتطبيقات الآمنة والجاهزة للإنتاج.