قسمت نوزدهم - پیادهسازی ورود امن با bcrypt در Next.js
بهجای ذخیره مستقیم رمز عبور، باید آن را با `bcrypt` هش کنیم و در زمان login هم رمز واردشده را با نسخه هششده مقایسه کنیم.
پیادهسازی ورود امن با bcrypt در Next.js
در قسمت قبل، طراحی کلی احراز هویت برای پنل ادمین را مشخص کردیم. دیدیم که در این پروژه auth فقط برای ادمین است، نه برای همه کاربران سایت. همچنین به این نتیجه رسیدیم که باید:
- یک مدل کاربر ادمین در دیتابیس داشته باشیم
- صفحه ورود بسازیم
- بعد از ورود موفق، session ایجاد کنیم
- مسیرهای ادمین را محافظت کنیم
حالا وقت آن است که مهمترین بخش ورود امن را پیادهسازی کنیم:
Password Hashing
یعنی بهجای ذخیره مستقیم رمز عبور، آن را با bcrypt هش کنیم و در زمان login هم رمز واردشده را با نسخه هششده مقایسه کنیم.
چرا رمز plain text خطرناک است؟
فرض کن کاربر ادمین این رمز را دارد:
myStrongPassword123
اگر این مقدار را مستقیم در دیتابیس ذخیره کنیم، یعنی رمز بهصورت plain text نگهداری شده است.
مثلاً چیزی شبیه این:
email: admin@example.com
password: myStrongPassword123
این یکی از بدترین کارهایی است که در auth میتوان انجام داد.
چرا؟
چون اگر دیتابیس لو برود یا کسی به دادهها دسترسی پیدا کند، رمز واقعی کاربر مستقیم قابل مشاهده است.
این فقط مشکل همین پروژه نیست. خیلی از کاربران عادت دارند از یک رمز در چند سرویس مختلف استفاده کنند. در نتیجه نشت یک رمز ساده میتواند آسیب بزرگتری ایجاد کند.
برای همین، رمز عبور نباید هرگز به شکل خام ذخیره شود.
هش کردن رمز با bcrypt
راهحل درست این است که قبل از ذخیره رمز، آن را هش کنیم.
bcrypt یکی از کتابخانههای شناختهشده برای این کار است.
وقتی رمز را هش میکنیم، خروجی چیزی شبیه این میشود:
$2b$10$wYQK...
این خروجی دیگر خود رمز اصلی نیست.
در زمان login هم بهجای اینکه رمز ذخیرهشده را decode کنیم، رمز واردشده را دوباره با هش مقایسه میکنیم.
برای نصب bcrypt:
npm install bcrypt
npm install -D @types/bcrypt
حالا میتوانیم یک helper برای هش کردن رمز بسازیم.
// lib/auth/password.ts
import bcrypt from 'bcrypt'
const SALT_ROUNDS = 10
export async function hashPassword(password: string) {
return bcrypt.hash(password, SALT_ROUNDS)
}
export async function verifyPassword(
password: string,
passwordHash: string
) {
return bcrypt.compare(password, passwordHash)
}
در اینجا:
hashPassword()برای ساخت هش استفاده میشودverifyPassword()برای مقایسه رمز خام با هش ذخیرهشده استفاده میشود
ساخت ادمین اولیه
چون در این پروژه signup عمومی نداریم، باید ادمین اولیه را خودمان بسازیم.
میتوانی این کار را با یک script یا seed انجام دهی.
مثلاً:
// prisma/seed.ts
import { PrismaClient } from '@prisma/client'
import { hashPassword } from '../lib/auth/password'
const prisma = new PrismaClient()
async function main() {
const email = 'admin@example.com'
const password = '12345678'
const passwordHash = await hashPassword(password)
await prisma.adminUser.upsert({
where: { email },
update: {},
create: {
email,
passwordHash,
name: 'Admin',
},
})
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (error) => {
console.error(error)
await prisma.$disconnect()
process.exit(1)
})
این فایل یک ادمین اولیه میسازد.
نکته مهم این است که در دیتابیس، خود password ذخیره نمیشود، بلکه فقط passwordHash ذخیره میشود.
برای اجرای seed:
npx prisma db seed
در پروژه واقعی بهتر است ایمیل و رمز اولیه را از env بخوانی، نه اینکه مستقیم داخل فایل بنویسی.
بررسی اطلاعات ورود
وقتی کاربر فرم login را ارسال میکند، باید چند مرحله انجام شود:
- داده ورودی خوانده شود
- داده با
Zodاعتبارسنجی شود - کاربر با ایمیل پیدا شود
- اگر کاربر وجود نداشت، خطای ورود برگردد
- اگر وجود داشت، رمز با
bcrypt.compare()بررسی شود - اگر رمز درست بود، session ساخته شود
اول schema ورود را تعریف میکنیم:
// lib/validations/auth.ts
import { z } from 'zod'
export const loginSchema = z.object({
email: z
.email('ایمیل معتبر نیست.')
.trim()
.toLowerCase(),
password: z
.string()
.min(8, 'رمز عبور باید حداقل 8 کاراکتر باشد.'),
})
حالا میرویم سراغ endpoint login.
ساخت login flow
فرض کنیم مسیر API ورود این باشد:
/app/api/admin/login/route.ts
نسخه اولیه:
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { loginSchema } from '@/lib/validations/auth'
import { verifyPassword } from '@/lib/auth/password'
export async function POST(request: Request) {
try {
const body = await request.json()
const result = loginSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
success: false,
errors: result.error.flatten().fieldErrors,
},
{ status: 400 }
)
}
const { email, password } = result.data
const user = await prisma.adminUser.findUnique({
where: { email },
})
if (!user) {
return NextResponse.json(
{
success: false,
error: 'Invalid email or password',
},
{ status: 401 }
)
}
const isValidPassword = await verifyPassword(
password,
user.passwordHash
)
if (!isValidPassword) {
return NextResponse.json(
{
success: false,
error: 'Invalid email or password',
},
{ status: 401 }
)
}
return NextResponse.json({
success: true,
message: 'Login successful',
})
} catch {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
تا اینجا login را از نظر بررسی اطلاعات ساختیم، اما هنوز session ذخیره نکردهایم.
ساخت session ساده با cookie
برای session، فعلاً یک مدل ساده و آموزشی استفاده میکنیم.
بعد از login موفق، یک cookie روی پاسخ تنظیم میکنیم.
برای این کار از cookies() در Next.js استفاده میکنیم.
// app/api/admin/login/route.ts
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { loginSchema } from '@/lib/validations/auth'
import { verifyPassword } from '@/lib/auth/password'
export async function POST(request: Request) {
try {
const body = await request.json()
const result = loginSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
success: false,
errors: result.error.flatten().fieldErrors,
},
{ status: 400 }
)
}
const { email, password } = result.data
const user = await prisma.adminUser.findUnique({
where: { email },
})
if (!user) {
return NextResponse.json(
{
success: false,
error: 'Invalid email or password',
},
{ status: 401 }
)
}
const isValidPassword = await verifyPassword(
password,
user.passwordHash
)
if (!isValidPassword) {
return NextResponse.json(
{
success: false,
error: 'Invalid email or password',
},
{ status: 401 }
)
}
const cookieStore = await cookies()
cookieStore.set('admin_session', user.id, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return NextResponse.json({
success: true,
message: 'Login successful',
})
} catch {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
این نسخه آموزشی است و برای شروع خوب است، اما از نظر امنیتی بهتر است در نسخه نهایی فقط user.id خام را داخل cookie نگذاریم و session را به شکل امنتر مدیریت کنیم.
فعلاً چون در مرحله آموزش هستیم، میخواهیم اول flow را واضح کنیم:
Validate -> Find User -> Compare Password -> Set Cookie
فرم login در سمت client
حالا صفحه ورود را به endpoint وصل میکنیم.
'use client'
import { FormEvent, useState } from 'react'
import { useRouter } from 'next/navigation'
export function AdminLoginForm() {
const router = useRouter()
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setPending(true)
setError('')
const formData = new FormData(event.currentTarget)
const payload = {
email: String(formData.get('email') ?? ''),
password: String(formData.get('password') ?? ''),
}
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const result = await response.json()
setPending(false)
if (!response.ok) {
setError(result.error ?? 'Login failed')
return
}
router.push('/admin')
router.refresh()
}
return (
<form onSubmit={handleSubmit}>
<input type="email" name="email" placeholder="Email" />
<input type="password" name="password" placeholder="Password" />
<button type="submit" disabled={pending}>
{pending ? 'Logging in...' : 'Login'}
</button>
{error && <p>{error}</p>}
</form>
)
}
بعد از login موفق:
- کاربر به
/adminهدایت میشود router.refresh()باعث میشود state سروری صفحه بهروز شود
مدیریت خطا
در auth نباید خطاها را بیدقت برگردانیم.
مثلاً اگر بگوییم:
Email not foundPassword is incorrect
در واقع داریم اطلاعات اضافه به مهاجم میدهیم.
بهتر است برای هر دو حالت یک پیام عمومی بدهیم:
Invalid email or password
این کار باعث میشود کسی نتواند فقط با تست ایمیلها بفهمد کدام حساب در سیستم وجود دارد.
همچنین باید خطاهای اعتبارسنجی را از خطاهای ورود نامعتبر جدا کنیم:
400برای داده ورودی نامعتبر401برای اطلاعات ورود اشتباه500برای خطای داخلی سرور
ساخت logout
برای کامل شدن flow، به خروج از حساب هم نیاز داریم.
مثلاً:
/app/api/admin/logout/route.ts
کد:
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function POST() {
const cookieStore = await cookies()
cookieStore.delete('admin_session')
return NextResponse.json({
success: true,
message: 'Logged out successfully',
})
}
و در client:
async function logout() {
await fetch('/api/admin/logout', {
method: 'POST',
})
}
نکات امنیتی
در پیادهسازی login با bcrypt چند نکته مهم وجود دارد:
- هرگز رمز خام در دیتابیس ذخیره نشود
- هرگز رمز خام را log نکنیم
- از پیام خطای خیلی دقیق برای login استفاده نکنیم
- cookie را با
httpOnlyتنظیم کنیم - در production از
secure: trueاستفاده کنیم - برای مسیرهای ادمین فقط به وجود cookie اکتفا نکنیم؛ اعتبار آن را هم بررسی کنیم
- برای login endpoint میتوان بعداً rate limit هم اضافه کرد
جمعبندی
در این قسمت، ورود امن برای ادمین را با bcrypt طراحی و پیادهسازی کردیم.
یاد گرفتیم که:
- ذخیره رمز بهصورت
plain textبسیار خطرناک است - باید رمز را قبل از ذخیره هش کنیم
- برای هش و مقایسه رمز از
bcryptاستفاده میکنیم - در زمان login، کاربر را با ایمیل پیدا میکنیم
- رمز واردشده را با
passwordHashمقایسه میکنیم - اگر ورود موفق بود، session را با
cookieنگه میداریم - برای logout باید cookie حذف شود
- پیام خطای login باید عمومی و امن باشد