MATIN MOLKARA
SERIES: آموزش Next js — PART 20 OF 28

قسمت نوزدهم - پیاده‌سازی ورود امن با 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 را ارسال می‌کند، باید چند مرحله انجام شود:

  1. داده ورودی خوانده شود
  2. داده با Zod اعتبارسنجی شود
  3. کاربر با ایمیل پیدا شود
  4. اگر کاربر وجود نداشت، خطای ورود برگردد
  5. اگر وجود داشت، رمز با bcrypt.compare() بررسی شود
  6. اگر رمز درست بود، 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 found
  • Password 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 باید عمومی و امن باشد
// 0 comments
#Next.js#bcrypt#آموزش#login

نظرات (0)

نظر خود را بنویسید