قست بیستم - محافظت از مسیرها در Next.js
بعد از login، مهمترین مرحله این است که مسیرها و صفحات ادمین را محافظت کنیم
محافظت از مسیرها در Next.js: ساخت صفحات فقط برای ادمین
تا اینجا مدل auth را طراحی کردیم و بعد هم ورود امن با bcrypt را ساختیم.
الان ادمین میتواند وارد شود و یک session ساده از طریق cookie دریافت کند. اما هنوز یک مسئله مهم باقی مانده است:
اگر کاربر مهمان مستقیماً آدرس /admin را وارد کند چه میشود؟
اگر هیچ محافظتی نداشته باشیم، ممکن است صفحه مدیریت برای همه قابل مشاهده باشد.
پس بعد از login، مهمترین مرحله این است که مسیرها و صفحات ادمین را محافظت کنیم.
در این قسمت میخواهیم مشخص کنیم:
- کدام مسیرها باید محافظت شوند؟
- session را چطور بررسی کنیم؟
- کاربر مهمان را چطور redirect کنیم؟
- برای پنل ادمین چطور layout جداگانه بسازیم؟
- در نبود دسترسی، چه تجربه کاربری مناسبی داشته باشیم؟
کدام مسیرها باید محافظت شوند؟
اول باید مسیرهای private را دقیق مشخص کنیم.
در این پروژه معمولاً همه این مسیرها باید فقط برای ادمین قابل دسترسی باشند:
/admin/admin/projects/admin/projects/new/admin/projects/[id]/edit/admin/posts/admin/posts/new/admin/posts/[id]/edit
در مقابل، اینها عمومی هستند:
//projects/projects/[slug]/blog/blog/[slug]/contact
پس هر چیزی که زیر /admin قرار میگیرد، باید محافظت شود.
در کنار صفحات، endpointهای مدیریتی هم باید private باشند:
POST /api/projectsPATCH /api/projects/[id]DELETE /api/projects/[id]
نکته مهم این است:
محافظت از page route و محافظت از API route هر دو لازماند.
اگر فقط صفحه را قفل کنیم ولی API را آزاد بگذاریم، باز هم امنیت کامل نداریم.
بررسی session
برای محافظت از مسیرها، اول باید بتوانیم session را بخوانیم.
فرض کن در login، این cookie را ست کردهایم:
admin_session
حالا یک helper ساده برای خواندن کاربر فعلی میسازیم.
// lib/auth/session.ts
import { cookies } from 'next/headers'
import { prisma } from '@/lib/prisma'
export async function getCurrentAdminUser() {
const cookieStore = await cookies()
const session = cookieStore.get('admin_session')
if (!session?.value) {
return null
}
const user = await prisma.adminUser.findUnique({
where: {
id: session.value,
},
})
if (!user) {
return null
}
return user
}
این helper فعلاً ساده است:
- cookie را میخواند
- اگر cookie نبود،
nullبرمیگرداند - اگر بود، کاربر را از دیتابیس پیدا میکند
- اگر کاربر وجود داشت، آن را برمیگرداند
این helper بعداً هم در pageها و هم در APIها قابل استفاده است.
redirect کاربر مهمان
حالا میتوانیم در صفحه ادمین، اگر کاربر وارد نشده بود، او را به صفحه login بفرستیم.
مثلاً در صفحه /admin:
// app/admin/page.tsx
import { redirect } from 'next/navigation'
import { getCurrentAdminUser } from '@/lib/auth/session'
export default async function AdminPage() {
const user = await getCurrentAdminUser()
if (!user) {
redirect('/admin/login')
}
return (
<main>
<h1>Admin Dashboard</h1>
<p>Welcome back, {user.name ?? user.email}</p>
</main>
)
}
در اینجا چون صفحه یک Server Component است، خیلی راحت میتوانیم قبل از render شدن، session را بررسی کنیم.
اگر کاربر لاگین نکرده باشد:
redirect('/admin/login')
و در نتیجه اصلاً محتوای پنل برای او render نمیشود.
این یکی از تمیزترین روشها در App Router است.
محافظت از همه صفحات ادمین با layout
اگر قرار باشد در هر صفحه ادمین این بررسی را دوباره تکرار کنیم، کد تکراری میشود.
برای همین بهتر است کل بخش /admin را با یک layout محافظت کنیم.
مثلاً:
app/admin/layout.tsx
کد:
import { ReactNode } from 'react'
import { redirect } from 'next/navigation'
import { getCurrentAdminUser } from '@/lib/auth/session'
type AdminLayoutProps = {
children: ReactNode
}
export default async function AdminLayout({
children,
}: AdminLayoutProps) {
const user = await getCurrentAdminUser()
if (!user) {
redirect('/admin/login')
}
return (
<div>
<aside>
<p>{user.name ?? user.email}</p>
<nav>
<a href="/admin">Dashboard</a>
<a href="/admin/projects">Projects</a>
<a href="/admin/posts">Posts</a>
</nav>
</aside>
<main>{children}</main>
</div>
)
}
حالا هر صفحهای که داخل /admin باشد، قبل از render شدن از این لایه عبور میکند.
این یعنی:
- فقط یکبار check مینویسیم
- کل ناحیه ادمین محافظت میشود
- ساختار داشبورد منظمتر میشود
صفحه login باید از layout محافظتشده جدا باشد
یک نکته مهم اینجاست:
اگر /admin/login هم داخل همین layout قرار بگیرد، کاربر مهمان هرگز نمیتواند صفحه login را ببیند، چون قبل از آن redirect میشود.
پس بهتر است ساختار مسیرها را جدا کنیم.
مثلاً:
app/
admin/
login/
page.tsx
(dashboard)/
layout.tsx
page.tsx
projects/
page.tsx
در این ساختار:
/admin/loginبیرون از layout محافظتشده است- بقیه صفحات ادمین داخل گروه
(dashboard)قرار میگیرند
مثال:
/admin/login
/admin
/admin/projects
/admin/posts
اما از نظر فایلبندی:
app/admin/login/page.tsx
app/admin/(dashboard)/layout.tsx
app/admin/(dashboard)/page.tsx
app/admin/(dashboard)/projects/page.tsx
این یکی از تمیزترین ساختارها برای پنل ادمین در App Router است.
محافظت از APIهای ادمین
فقط pageها را نباید محافظت کنیم.
endpointها هم باید بررسی session داشته باشند.
مثلاً در POST /api/projects:
import { NextResponse } from 'next/server'
import { getCurrentAdminUser } from '@/lib/auth/session'
export async function POST(request: Request) {
const user = await getCurrentAdminUser()
if (!user) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized',
},
{ status: 401 }
)
}
// ادامه منطق ساخت پروژه
}
همین الگو را برای PATCH و DELETE هم تکرار میکنیم.
این نکته بسیار مهم است:
حتی اگر صفحه ادمین محافظت شده باشد، API باید مستقل از UI هم محافظت شود.
چون هر کسی میتواند خارج از UI هم به endpoint درخواست بزند.
آیا از proxy هم استفاده کنیم؟
در Next.js جدید میتوان از proxy.ts هم برای بعضی بررسیهای اولیه استفاده کرد.
مثلاً برای جلوگیری سریع از دسترسی مهمان به /admin.
اما در این مرحله از آموزش، بهتر است اول مدل واضح و مستقیم را با layout و helper session یاد بگیریم.
یعنی:
- محافظت صفحه با
layout - محافظت API با check داخل
Route Handler
بعداً اگر بخواهی، میتوانی proxy را هم برای redirect سریعتر یا کنترل مرکزیتر اضافه کنی.
اما حتی در آن حالت هم نباید بررسی داخل API را حذف کنی.
layout اختصاصی ادمین
وقتی auth را اضافه میکنیم، معمولاً بهتر است برای ادمین یک layout جداگانه داشته باشیم.
چرا؟
چون UI ادمین با UI عمومی سایت متفاوت است.
مثلاً در layout ادمین معمولاً این بخشها را داریم:
- sidebar
- topbar
- اطلاعات ادمین
- لینک خروج
- منوی مدیریت پروژهها و پستها
نمونه ساده:
import Link from 'next/link'
import { ReactNode } from 'react'
import { redirect } from 'next/navigation'
import { getCurrentAdminUser } from '@/lib/auth/session'
type AdminLayoutProps = {
children: ReactNode
}
export default async function AdminLayout({
children,
}: AdminLayoutProps) {
const user = await getCurrentAdminUser()
if (!user) {
redirect('/admin/login')
}
return (
<div className="min-h-screen grid grid-cols-[240px_1fr]">
<aside className="border-r p-6">
<div className="mb-6">
<p className="font-medium">{user.name ?? 'Admin'}</p>
<p className="text-sm text-gray-400">{user.email}</p>
</div>
<nav className="space-y-3">
<Link href="/admin">Dashboard</Link>
<Link href="/admin/projects">Projects</Link>
<Link href="/admin/posts">Posts</Link>
</nav>
</aside>
<main className="p-8">{children}</main>
</div>
)
}
این ساختار هم از نظر UX بهتر است، هم از نظر معماری.
تجربه کاربری در عدم دسترسی
از نظر فنی میتوانیم کاربر مهمان را مستقیم redirect کنیم.
این روش در بسیاری از مسیرهای private بهترین انتخاب است.
اما از نظر UX باید مشخص کنیم در هر سناریو چه رفتاری میخواهیم:
سناریوی ۱: کاربر مهمان وارد /admin میشود
بهترین رفتار:
redirect('/admin/login')
سناریوی ۲: کاربر login کرده ولی session نامعتبر شده
بهترین رفتار:
- حذف session
- redirect به login
- نمایش پیام مناسب در صفحه login
سناریوی ۳: API بدون auth صدا زده میشود
بهترین رفتار:
{
"success": false,
"error": "Unauthorized"
}
با status:
401
سناریوی ۴: کاربر دسترسی کافی ندارد
اگر بعداً چند نقش تعریف کردی، باید از 403 Forbidden استفاده کنی.
اما فعلاً چون پروژه فقط ادمین دارد، بیشتر با 401 سروکار داریم.
جلوگیری از دسترسی ادمین به صفحه login بعد از ورود
یک بهبود خوب UX این است که اگر ادمین قبلاً login کرده، دیگر صفحه /admin/login را نبیند و مستقیم به داشبورد برود.
مثلاً در صفحه login:
// app/admin/login/page.tsx
import { redirect } from 'next/navigation'
import { getCurrentAdminUser } from '@/lib/auth/session'
import { AdminLoginForm } from '@/components/forms/admin-login-form'
export default async function AdminLoginPage() {
const user = await getCurrentAdminUser()
if (user) {
redirect('/admin')
}
return (
<main>
<h1>Admin Login</h1>
<AdminLoginForm />
</main>
)
}
این باعث میشود تجربه کاربری منطقیتر شود.
ساخت helper برای نیازهای بعدی
بعد از این قسمت، معمولاً خوب است دو helper مجزا داشته باشیم:
getCurrentAdminUser()
requireAdmin()
مثلاً:
// lib/auth/session.ts
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import { prisma } from '@/lib/prisma'
export async function getCurrentAdminUser() {
const cookieStore = await cookies()
const session = cookieStore.get('admin_session')
if (!session?.value) {
return null
}
return prisma.adminUser.findUnique({
where: {
id: session.value,
},
})
}
export async function requireAdmin() {
const user = await getCurrentAdminUser()
if (!user) {
redirect('/admin/login')
}
return user
}
حالا در pageها میتوانیم اینطور استفاده کنیم:
const user = await requireAdmin()
این کد خواناتر میشود و تکرار کمتر میشود.
جمعبندی
در این قسمت، مسیرهای ادمین را برای کاربر احراز هویتنشده بستیم و ساختار محافظت از صفحات را طراحی کردیم.
یاد گرفتیم که:
- همه مسیرهای زیر
/adminباید محافظت شوند - endpointهای مدیریتی هم باید private باشند
- برای بررسی auth، میتوانیم session را از
cookieبخوانیم - با یک helper مثل
getCurrentAdminUser()میشود کار را تمیزتر کرد - در
Server Componentخیلی راحت میتوانیم قبل از render شدن، کاربر را بررسی کنیم - با
layoutمیتوانیم کل ناحیه ادمین را یکجا محافظت کنیم - صفحه login باید بیرون از layout محافظتشده قرار بگیرد
- در صورت نبود دسترسی، بهترین UX معمولاً redirect به login است
تا اینجا سه لایه مهم را ساختیم:
Auth Design
Login with bcrypt
Protected Admin Routes
یعنی حالا میتوانی داشبورد مدیریتی را بسازیم، چون هم login داریم، هم session داریم، هم route protection.