[번역]Next.js Authentication

Simon·2024년 10월 31일
0
post-thumbnail

인증(Authentication)

인증에 대한 이해는 애플리케이션의 데이터를 보호하는 데 매우 중요합니다. 이 페이지에서는 인증을 구현할 때 React와 Next.js의 어떤 기능을 사용해야 하는지 안내합니다.

시작하기 전에, 과정을 세 가지 개념으로 나눠보면 도움이 됩니다.

  1. 인증 (Authentication): 사용자가 본인이 맞는지 확인하는 과정으로, 주로 사용자 이름과 비밀번호 같은 정보로 신원을 증명합니다.

  2. 세션 관리(Session Management): 여러 요청 간에 사용자의 인증 상태를 유지하고 추적합니다.

  3. 권한 부여(Authorization): 사용자가 접근할 수 있는 경로와 데이터를 결정합니다.

이 다이어그램은 React와 Next.js 기능을 사용한 인증 흐름을 보여줍니다.

이 페이지의 예제에서는 교육적인 목적을 위해 기본적인 사용자 이름과 비밀번호 인증 과정을 설명합니다. 사용자 맞춤 인증 솔루션을 구현할 수도 있지만, 보안과 간편함을 위해 인증 라이브러리 사용을 권장합니다. 이러한 라이브러리는 인증, 세션 관리, 권한 부여를 위한 내장 솔루션을 제공하며, 소셜 로그인, 다단계 인증, 역할 기반 접근 제어와 같은 추가 기능도 포함되어 있습니다. 인증 라이브러리 목록은 ‘인증 라이브러리’ 섹션에서 확인할 수 있습니다.

가입 및 로그인 기능

React의 서버 액션과 useFormState를 활용하여 <form> 요소를 사용하여 사용자 자격 증명을 캡처하고, 폼 필드를 검증하며, 인증 공급자의 API 또는 데이터베이스를 호출할 수 있습니다.

서버 액션은 항상 서버에서 실행되므로 인증 로직을 처리하는 안전한 환경을 제공합니다.

다음은 회원가입/로그인 기능을 구현하는 단계입니다:

  1. 사용자 자격 증명 입력 받기
    사용자 자격 증명을 입력받기 위해, 제출 시 서버 액션을 호출하는 폼을 만듭니다. 예를 들어, 사용자의 이름, 이메일, 비밀번호를 입력받는 회원가입 폼은 다음과 같습니다:

app/ui/signup-form.tsx

import { signup } from '@/app/actions/auth'
 
export function SignupForm() {
  return (
    <form action={signup}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" placeholder="Email" />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      <button type="submit">Sign Up</button>
    </form>
  )
}

app/actions/auth.tsx

export async function signup(formData: FormData) {}
  1. 서버에서 폼 필드 검증
    서버에서 폼 필드를 검증하기 위해 서버 액션을 사용합니다. 만약 인증 공급자가 폼 검증을 제공하지 않는다면, ZodYup과 같은 스키마 검증 라이브러리를 사용할 수 있습니다.

예를 들어, Zod를 사용하여 적절한 오류 메시지와 함께 폼 스키마를 정의할 수 있습니다:

app/lib/definitions.ts

import { z } from 'zod'
 
export const SignupFormSchema = z.object({
  name: z
    .string()
    .min(2, { message: 'Name must be at least 2 characters long.' })
    .trim(),
  email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
  password: z
    .string()
    .min(8, { message: 'Be at least 8 characters long' })
    .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
    .regex(/[0-9]/, { message: 'Contain at least one number.' })
    .regex(/[^a-zA-Z0-9]/, {
      message: 'Contain at least one special character.',
    })
    .trim(),
})
 
export type FormState =
  | {
      errors?: {
        name?: string[]
        email?: string[]
        password?: string[]
      }
      message?: string
    }
  | undefined

인증 공급자의 API나 데이터베이스에 불필요한 호출을 방지하기 위해, 정의된 스키마와 일치하지 않는 폼 필드가 있을 경우 서버 액션에서 조기에 반환할 수 있습니다.

app/actions/auth.ts

import { SignupFormSchema, FormState } from '@/app/lib/definitions'
 
export async function signup(state: FormState, formData: FormData) {
  // Validate form fields
  const validatedFields = SignupFormSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
  })
 
  // If any form fields are invalid, return early
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }
 
  // Call the provider or db to create a user...
}

다시 <SignupForm />으로 돌아가서, React의 useFormState 훅을 사용하여 폼 제출 중에 검증 오류를 표시할 수 있습니다

app/ui/signup-form.tsx

'use client'
 
import { useFormState, useFormStatus } from 'react-dom'
import { signup } from '@/app/actions/auth'
 
export function SignupForm() {
  const [state, action] = useFormState(signup, undefined)
 
  return (
    <form action={action}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" placeholder="Name" />
      </div>
      {state?.errors?.name && <p>{state.errors.name}</p>}
 
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" placeholder="Email" />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}
 
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
      </div>
      {state?.errors?.password && (
        <div>
          <p>Password must:</p>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}
      <SubmitButton />
    </form>
  )
}
 
function SubmitButton() {
  const { pending } = useFormStatus()
 
  return (
    <button disabled={pending} type="submit">
      Sign Up
    </button>
  )
}

참고 사항

  • 이 예제에서는 Next.js 앱 라우터에 포함된 React의 useFormState 훅을 사용합니다. React 19를 사용 중이라면 useActionState를 대신 사용하세요. 자세한 내용은 React 문서를 참조하세요.

  • React 19에서는 useFormStatus가 반환하는 객체에 data, method, action과 같은 추가 키가 포함됩니다. React 19가 아닌 경우에는 pending 키만 사용할 수 있습니다. 또한 React 19에서는 useActionState가 반환된 상태에 pending 키도 포함합니다.

  • 데이터를 수정하기 전에 사용자가 해당 작업을 수행할 권한이 있는지도 항상 확인해야 합니다. 자세한 내용은 '인증 및 권한 부여'를 참조하세요.

  1. 사용자 생성 또는 사용자 자격 증명 확인
    폼 필드를 검증한 후, 인증 공급자의 API나 데이터베이스를 호출하여 새로운 사용자 계정을 생성하거나 사용자가 존재하는지 확인할 수 있습니다.

이전 예제에서 이어서 설명

app/actions/auth.ts

export async function signup(state: FormState, formData: FormData) {
  // 1. Validate form fields
  // ...
 
  // 2. Prepare data for insertion into database
  const { name, email, password } = validatedFields.data
  // e.g. Hash the user's password before storing it
  const hashedPassword = await bcrypt.hash(password, 10)
 
  // 3. Insert the user into the database or call an Auth Library's API
  const data = await db
    .insert(users)
    .values({
      name,
      email,
      password: hashedPassword,
    })
    .returning({ id: users.id })
 
  const user = data[0]
 
  if (!user) {
    return {
      message: 'An error occurred while creating your account.',
    }
  }
 
  // TODO:
  // 4. Create user session
  // 5. Redirect user
}

사용자 계정을 성공적으로 생성하거나 자격 증명을 확인한 후, 사용자의 인증 상태를 관리하기 위한 세션을 생성할 수 있습니다. 세션 관리 방식에 따라 세션은 쿠키나 데이터베이스, 또는 두 곳 모두에 저장될 수 있습니다. 더 자세한 내용은 '세션 관리' 섹션에서 확인하세요.

  • 위 예제는 교육 목적으로 인증 단계를 상세히 설명하여 다소 장황해 보일 수 있습니다. 이를 통해 안전한 인증 솔루션을 직접 구현하는 과정이 얼마나 복잡해질 수 있는지 강조하고 있으며, 인증 라이브러리를 사용하여 과정을 단순화하는 것도 고려해 보세요.

  • 또한 사용자 경험을 개선하기 위해, 회원가입 단계 초기에 중복 이메일이나 사용자 이름을 미리 확인하는 것이 좋습니다. 예를 들어, 사용자가 사용자 이름을 입력하는 중이거나 입력란이 포커스를 잃을 때 확인을 수행할 수 있습니다. 이를 통해 불필요한 폼 제출을 방지하고 즉각적인 피드백을 제공할 수 있습니다. 요청 빈도 관리는 use-debounce와 같은 라이브러리를 사용하여 디바운스 처리로 가능합니다.

세션 관리

세션 관리는 사용자 인증 상태가 여러 요청 간에 유지되도록 보장하는 역할을 합니다. 여기에는 세션이나 토큰을 생성하고, 저장하고, 갱신하고, 삭제하는 과정이 포함됩니다.

세션에는 두 가지 유형이 있습니다.

  1. 무상태(Stateless): 세션 데이터(또는 토큰)가 브라우저의 쿠키에 저장됩니다. 이 쿠키는 요청마다 서버로 전송되어 세션을 검증할 수 있도록 합니다. 이 방식은 비교적 간단하지만, 올바르게 구현되지 않으면 보안이 취약할 수 있습니다.
  1. 데이터 베이스(Database): 세션 데이터가 데이터베이스에 저장되고, 사용자의 브라우저에는 암호화된 세션 ID만 전달됩니다. 이 방식은 더 안전하지만, 구현이 복잡하고 서버 자원을 더 많이 사용할 수 있습니다.

참고 사항
두 가지 방식 중 하나 또는 둘 다 사용할 수 있지만, iron-session이나 Jose와 같은 세션 관리 라이브러리를 사용하는 것을 권장합니다.

무상태(Stateless) 세션

무상태 세션을 생성하고 관리하려면 다음 단계들을 따라야 합니다.

  1. 세션 서명에 사용할 비밀 키를 생성하고, 환경 변수로 저장합니다.

  2. 세션 관리 라이브러리를 사용해 세션 데이터를 암호화/복호화하는 로직을 작성합니다.

  3. Next.js의 쿠키 API를 사용하여 쿠키를 관리합니다.

위 내용에 더해, 사용자가 애플리케이션으로 돌아올 때 세션을 갱신하는 기능과, 로그아웃 시 세션을 삭제하는 기능도 추가하는 것을 고려해 보세요.

참고사항
인증 라이브러리에 세션 관리가 포함되어 있는지 확인하세요.

  1. 비밀 키 생성

    비밀 키 생성 세션을 서명하기 위해 비밀 키를 생성하는 방법에는 여러 가지가 있습니다. 예를 들어, 터미널에서 openssl 명령어를 사용하여 생성할 수 있습니다

openssl rand -base64 32

이 명령어는 32자 길이의 랜덤 문자열을 생성하며, 이를 비밀 키로 사용하고 환경 변수 파일에 저장할 수 있습니다.

.env

SESSION_SECRET=your_secret_key``

그런 다음 세션 관리 로직에서 이 키를 참조할 수 있습니다.
app/lib/session.js

const secretKey = process.env.SESSION_SECRET
  1. 세션 암호화 및 복호화

    다음으로, 선호하는 세션 관리 라이브러리를 사용하여 세션을 암호화하고 복호화할 수 있습니다. 이전 예제에서 이어서, Edge Runtime과 호환되는 Jose와 React의 서버 전용 패키지를 사용하여 세션 관리 로직이 서버에서만 실행되도록 합니다."

app/lib/session.ts

import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { SessionPayload } from '@/app/lib/definitions'
 
const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)
 
export async function encrypt(payload: SessionPayload) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(encodedKey)
}
 
export async function decrypt(session: string | undefined = '') {
  try {
    const { payload } = await jwtVerify(session, encodedKey, {
      algorithms: ['HS256'],
    })
    return payload
  } catch (error) {
    console.log('Failed to verify session')
  }
}


페이로드에는 이후 요청에서 사용될 최소한의 고유 사용자 데이터, 예를 들어 사용자 ID, 역할 등을 포함해야 합니다. 개인 식별 정보(전화번호, 이메일 주소, 신용카드 정보 등)나 비밀번호와 같은 민감한 데이터는 포함해서는 안 됩니다.

  1. 쿠키 설정(권장 옵션)

    세션을 쿠키에 저장하려면 Next.js의 쿠키 API를 사용합니다. 쿠키는 서버에서 설정해야 하며, 다음과 같은 권장 옵션을 포함해야 합니다.

  • HttpOnly: 클라이언트 측 JavaScript가 쿠키에 접근하는 것을 방지합니다.

  • Secure: 쿠키를 전송할 때 HTTPS를 사용합니다.

  • SameSite: 쿠키가 교차 사이트 요청과 함께 전송될 수 있는지를 지정합니다.

  • Max-Age 또는 Expires: 일정 기간 후 쿠키를 삭제합니다.

  • Path: 쿠키의 URL 경로를 정의합니다."

각 옵션에 대한 자세한 내용은 MDN을 참조하세요.

app/lib/session.ts

import 'server-only'
import { cookies } from 'next/headers'
 
export async function createSession(userId: string) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  
   // 2. Encrypt the session ID
  const session = await encrypt({ userId, expiresAt })
 
  // 3. Store the session in cookies for optimistic auth checks
  const cookieStore = await cookies()
  cookieStore().set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}

서버 액션에서 createSession() 함수를 호출하고, redirect() API를 사용하여 사용자를 적절한 페이지로 리디렉션할 수 있습니다.

app/actions/auth.ts

import { createSession } from '@/app/lib/session'
 
export async function signup(state: FormState, formData: FormData) {
  // Previous steps:
  // 1. Validate form fields
  // 2. Prepare data for insertion into database
  // 3. Insert the user into the database or call an Library API
 
  // Current steps:
  // 4. Create user session
  await createSession(user.id)
  // 5. Redirect user
  redirect('/profile')
}

  • 쿠키는 클라이언트 측의 변조를 방지하기 위해 서버에서 설정해야 합니다.
  • 🎥 시청하기: Next.js로 무상태 세션 및 인증에 대해 더 알아보기 → YouTube

세션 업데이트(또는 갱신)

세션의 만료 시간을 연장할 수도 있습니다. 이는 사용자가 애플리케이션에 다시 접근했을 때 로그인이 유지되도록 하는 데 유용합니다.

app/lib/session.ts

import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
 
export async function updateSession() {
  const session = (await cookies()).get('session')?.value
  const payload = await decrypt(session)
 
  if (!session || !payload) {
    return null
  }
 
  const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
 
  const cookieStore = await cookies()
  cookieStore().set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: 'lax',
    path: '/',
  })
}

팁: 사용자 세션을 확장하는 데 사용할 수 있는 새로 고침(refresh) 토큰 인증 라이브러리가 지원하는지 확인하세요.

세션 삭제

세션을 삭제하려면 쿠키를 삭제하세요.

app/lib/session.ts

import 'server-only'
import { cookies } from 'next/headers'
 
export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore().delete('session')
}

그런 다음 애플리케이션에서 deleteSession() 함수를 재사용할 수 있습니다(예: 로그아웃 시)

app/actions/auth.ts

import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'
 
export async function logout() {
  deleteSession()
  redirect('/login')
}

데이터베이스 세션

데이터베이스 세션을 생성하고 관리하려면 다음 단계를 따라야 합니다.

  1. 세션 데이터를 저장할 데이터베이스에 테이블을 생성합니다(또는 인증 라이브러리가 이를 처리하는지 확인합니다).

  2. 세션을 삽입, 업데이트 및 삭제하는 기능을 구현합니다.

  3. 세션 ID를 사용자의 브라우저에 저장하기 전에 암호화하고, 데이터베이스와 쿠키가 동기화되도록 합니다(선택 사항이지만 미들웨어에서 낙관적인 인증 검사를 위해 권장됨)."

예시

app/lib/session.ts

import cookies from 'next/headers'
import { db } from '@/app/lib/db'
import { encrypt } from '@/app/lib/session'
 
export async function createSession(id: number) {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
 
  // 1. Create a session in the database
  const data = await db
    .insert(sessions)
    .values({
      userId: id,
      expiresAt,
    })
    // Return the session ID
    .returning({ id: sessions.id })
 
  const sessionId = data[0].id
 
  // 2. Encrypt the session ID
  const session = await encrypt({ sessionId, expiresAt })
 
  // 3. Store the session in cookies for optimistic auth checks
  const cookieStore = await cookies()
  cookieStore().set('session', session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: 'lax',
    path: '/',
  })
}

  • 빠른 데이터 검색을 위해 Vercel Redis와 같은 데이터베이스를 사용하는 것을 고려해 보세요. 하지만 기본 데이터베이스에 세션 데이터를 유지하고 데이터 요청을 결합하여 쿼리 수를 줄이는 방법도 가능합니다.
  • 더 고급 사용 사례를 위해 데이터베이스 세션을 사용할 수 있습니다. 예를 들어 사용자가 마지막으로 로그인한 시간, 활성 장치 수를 추적하거나 사용자가 모든 장치에서 로그아웃할 수 있는 기능을 제공하는 등의 용도로 활용할 수 있습니다.

세션 관리 구현 후, 사용자가 애플리케이션 내에서 접근할 수 있는 것과 수행할 수 있는 작업을 제어하기 위해 권한 부여 로직을 추가해야 합니다. 권한 부여 섹션으로 이동하여 더 자세히 알아보세요.

권한 부여(Authorization)

사용자가 인증되고 세션이 생성되면, 애플리케이션 내에서 사용자가 접근하고 수행할 수 있는 권한을 제어하기 위해 권한 부여(authorization)를 구현할 수 있습니다.

주요 권한 부여 확인 유형은 두 가지입니다:

  1. 낙관적(Optimistic): 사용자가 쿠키에 저장된 세션 데이터를 사용하여 특정 경로에 접근하거나 행동을 수행할 수 있는 권한이 있는지 확인하는 방법입니다. 이러한 확인은 UI 요소를 보여주거나 숨기거나, 권한이나 역할에 따라 사용자를 리디렉션하는 등의 빠른 작업에 유용합니다.

  2. 안전(Secure): 사용자가 데이터베이스에 저장된 세션 데이터를 사용하여 특정 경로에 접근하거나 행동을 수행할 수 있는 권한이 있는지 확인하는 방법입니다. 이러한 체크는 더 안전하며, 민감한 데이터에 접근하거나 특정 작업을 수행해야 하는 경우에 사용됩니다.

두 경우 모두 다음을 권장합니다.

미들웨어를 통한 낙관적인 체크 (선택 사항)

미들웨어를 사용하여 사용자의 권한에 따라 리다이렉트하는 경우가 있습니다. 여기 몇 가지 상황이 있습니다:

  • 낙관적인 체크 수행: 미들웨어는 모든 경로에서 실행되므로, 리다이렉트 로직을 중앙 집중화하고 인가되지 않은 사용자를 미리 필터링하는 좋은 방법입니다.

  • 사용자 간 데이터 공유가 있는 정적 경로 보호: 예를 들어, 유료 콘텐츠 뒤에 숨겨진 데이터와 같은 경우에 해당합니다.

그러나 미들웨어는 모든 경로에서 실행되기 때문에, 미리 가져온 경로를 포함하여 성능 문제를 방지하기 위해 데이터베이스 체크는 피하고 세션을 쿠키에서만 읽는 것이 중요합니다.

예시

middleware.ts

import { NextRequest, NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'
 
// 1. Specify protected and public routes
const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']
 
export default async function middleware(req: NextRequest) {
  // 2. Check if the current route is protected or public
  const path = req.nextUrl.pathname
  const isProtectedRoute = protectedRoutes.includes(path)
  const isPublicRoute = publicRoutes.includes(path)
 
  // 3. Decrypt the session from the cookie
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)
 
  // 4. Redirect to /login if the user is not authenticated
  if (isProtectedRoute && !session?.userId) {
    return NextResponse.redirect(new URL('/login', req.nextUrl))
  }
 
  // 5. Redirect to /dashboard if the user is authenticated
  if (
    isPublicRoute &&
    session?.userId &&
    !req.nextUrl.pathname.startsWith('/dashboard')
  ) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
  }
 
  return NextResponse.next()
}
 
// Routes Middleware should not run on
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}

미들웨어는 초기 체크에 유용할 수 있지만, 데이터를 보호하는 데 있어 유일한 방어선이 되어서는 안 됩니다. 보안 검사는 데이터 소스에 최대한 가깝게 수행해야 하며, 더 많은 정보는 데이터 접근 계층(Data Access Layer)을 참조하세요.

팁:

  • 미들웨어에서는 req.cookies.get('session').value를 사용하여 쿠키를 읽을 수 있습니다.

  • 미들웨어는 엣지 런타임(Edge Runtime)을 사용하므로, 인증(auth) 라이브러리와 세션 관리 라이브러리가 호환되는지 확인하세요.

  • 미들웨어의 matcher 속성을 사용하여 미들웨어가 실행될 경로를 지정할 수 있습니다. 하지만 인증(auth)의 경우, 모든 경로에서 미들웨어가 실행되는 것이 권장됩니다.

데이터 액세스 계층(DAL) 생성

데이터 요청과 인증 로직을 중앙에서 관리할 수 있도록 DAL(Data Access Layer)을 생성하는 것이 좋습니다.

DAL에는 사용자가 애플리케이션과 상호작용할 때 세션을 확인하는 기능이 포함되어야 합니다. 최소한 이 기능은 세션의 유효성을 검사하고, 이후 요청을 처리할 때 필요한 사용자 정보를 반환하거나 리다이렉트 하는 역할을 해야 합니다.

예를 들어, verifySession() 함수를 포함한 별도의 파일을 만들어 DAL을 구성할 수 있습니다. 그런 다음 React의 cache API를 사용하여 React 렌더링 동안 해당 함수의 반환 값을 메모이제이션하여 성능을 최적화할 수 있습니다.

app/lib/dal.ts

import 'server-only'
 
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
 
export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value
  const session = await decrypt(cookie)
 
  if (!session?.userId) {
    redirect('/login')
  }
 
  return { isAuth: true, userId: session.userId }
})

그런 다음 데이터 요청, 서버 작업(Server Actions), 경로 핸들러(Route Handlers)에서 verifySession() 함수를 호출할 수 있습니다.

app/lib/dal.ts

export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null
 
  try {
    const data = await db.query.users.findMany({
      where: eq(users.id, session.userId),
      // Explicitly return the columns you need rather than the whole user object
      columns: {
        id: true,
        name: true,
        email: true,
      },
    })
 
    const user = data[0]
 
    return user
  } catch (error) {
    console.log('Failed to fetch user')
    return null
  }
})

팁:

  • DAL(Data Access Layer)은 요청 시점에 데이터를 보호하는 데 사용할 수 있습니다. 하지만 사용자가 공유하는 정적 라우트의 경우, 데이터는 요청 시점이 아닌 빌드 시점에 가져옵니다. 정적 라우트를 보호하려면 미들웨어를 사용하세요.

  • 안전한 검사를 위해, 세션 ID를 데이터베이스와 비교하여 세션이 유효한지 확인할 수 있습니다. React의 캐시 함수(cache function)를 사용하여 렌더링 중 중복된 데이터베이스 요청을 방지하세요.

  • 관련된 데이터 요청을 JavaScript 클래스에 통합하고, 메서드 호출 전에 verifySession()을 실행하도록 설정하는 것도 좋은 방법입니다.

데이터 전송 개체(DTO) 사용

데이터를 가져올 때, 애플리케이션에서 실제로 사용하는 필요한 데이터만 반환하는 것이 좋습니다. 예를 들어, 사용자 데이터를 가져올 때 비밀번호, 전화번호 등 전체 사용자 객체가 아니라 사용자 ID와 이름 같은 필요한 정보만 반환하는 것이 좋습니다.

하지만 반환되는 데이터 구조를 제어할 수 없거나, 팀 작업 중 전체 객체가 클라이언트에 전달되지 않도록 하고 싶을 때는 클라이언트에 노출해도 안전한 필드를 지정하는 전략을 사용할 수 있습니다.

app/lib/dto.ts

import 'server-only'
import { getUser } from '@/app/lib/dal'
 
function canSeeUsername(viewer: User) {
  return true
}
 
function canSeePhoneNumber(viewer: User, team: string) {
  return viewer.isAdmin || team === viewer.team
}
 
export async function getProfileDTO(slug: string) {
  const data = await db.query.users.findMany({
    where: eq(users.slug, slug),
    // Return specific columns here
  })
  const user = data[0]
 
  const currentUser = await getUser(user.id)
 
  // Or return only what's specific to the query here
  return {
    username: canSeeUsername(currentUser) ? user.username : null,
    phonenumber: canSeePhoneNumber(currentUser, user.team)
      ? user.phonenumber
      : null,
  }
}

데이터 요청과 인증 로직을 데이터 접근 계층(DAL)에 중앙 집중화하고 DTO(Data Transfer Object)를 사용하면, 모든 데이터 요청의 보안과 일관성을 보장할 수 있습니다. 이를 통해 애플리케이션이 확장되더라도 유지보수, 감사, 디버깅이 쉬워집니다.

참고사항

  • DTO(Data Transfer Object)를 정의하는 방법에는 toJSON() 메서드를 사용하는 것부터 개별 함수, JS 클래스를 사용하는 것까지 다양한 방식이 있습니다. 이는 JavaScript 패턴이지 React나 Next.js의 기능이 아니므로, 애플리케이션에 가장 적합한 패턴을 찾기 위해 연구해 보는 것이 좋습니다.

  • Next.js 보안 모범 사례에 대해 더 알고 싶다면, 우리의 "Next.js 보안" 기사에서 자세히 알아보세요.

서버 컴포넌트

서버 컴포넌트에서의 인증 검사는 역할 기반 접근 제어에 유용합니다. 예를 들어, 사용자의 역할에 따라 조건부로 컴포넌트를 렌더링할 수 있습니다:

app/dashboard/page.tsx

import { verifySession } from '@/app/lib/dal'
 
export default function Dashboard() {
  const session = await verifySession()
  const userRole = session?.user?.role // Assuming 'role' is part of the session object
 
  if (userRole === 'admin') {
    return <AdminDashboard />
  } else if (userRole === 'user') {
    return <UserDashboard />
  } else {
    redirect('/login')
  }
}

예시에서는 DAL에서 제공하는 verifySession() 함수를 사용하여 'admin', 'user', 및 권한 없는 역할을 확인합니다. 이 패턴은 각 사용자가 자신의 역할에 적합한 컴포넌트와만 상호작용하도록 보장합니다.

레이아웃 및 인증 확인

부분 렌더링(Partial Rendering) 때문에 레이아웃에서 체크를 수행할 때 주의해야 합니다. 레이아웃은 내비게이션 시 다시 렌더링되지 않으므로, 사용자의 세션이 모든 경로 변경 시마다 체크되지 않습니다.

대신, 데이터 자원이나 조건부로 렌더링될 컴포넌트에 가까운 곳에서 체크를 수행해야 합니다.

예를 들어, 사용자 데이터를 가져와서 내비게이션에 사용자 이미지를 표시하는 공유 레이아웃을 고려해 보세요. 인증 검사를 레이아웃에서 수행하는 대신, 레이아웃에서 사용자 데이터를 가져오는 getUser()를 호출하고, 인증 체크는 DAL에서 수행해야 합니다.

이렇게 하면 애플리케이션 내에서 getUser()가 호출되는 모든 위치에서 인증 검사가 수행되므로, 개발자가 사용자가 데이터에 접근할 권한이 있는지 확인하는 것을 잊는 것을 방지할 수 있습니다.

app/layout.tsx

export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUser();
 
  return (
    // ...
  )
}

app/lib/dal.ts

export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) return null
 
  // Get user ID from session and fetch data
})

참고사항

  • 단일 페이지 애플리케이션(SPA)에서 일반적으로 사용되는 패턴은 사용자가 인증되지 않은 경우 레이아웃이나 최상위 컴포넌트에서 null을 반환하는 것입니다. 하지만 이 패턴은 권장되지 않습니다. Next.js 애플리케이션은 여러 진입점을 가지므로, 중첩된 경로 세그먼트나 서버 액세스(Server Actions)에 대한 접근을 차단하지 못합니다.

서버 액션(Server Actions)

서버 액션을 공개 API 엔드포인트와 동일한 보안 고려 사항으로 취급하고, 사용자가 변형(mutation)을 수행할 수 있는 권한이 있는지 확인해야 합니다.

app/lib/actions.ts

'use server'
import { verifySession } from '@/app/lib/dal'
 
export async function serverAction(formData: FormData) {
  const session = await verifySession()
  const userRole = session?.user?.role
 
  // Return early if user is not authorized to perform the action
  if (userRole !== 'admin') {
    return null
  }
 
  // Proceed with the action for authorized users
}

라우트 핸들러

라우트 핸들러를 공개 API 엔드포인트와 동일한 보안 고려 사항으로 취급하고, 사용자가 해당 라우트 핸들러에 접근할 수 있는 권한이 있는지 확인해야 합니다.

예시

app/api/route.ts

import { verifySession } from '@/app/lib/dal'
 
export async function GET() {
  // User authentication and role verification
  const session = await verifySession()
 
  // Check if the user is authenticated
  if (!session) {
    // User is not authenticated
    return new Response(null, { status: 401 })
  }
 
  // Check if the user has the 'admin' role
  if (session.user.role !== 'admin') {
    // User is authenticated but does not have the right permissions
    return new Response(null, { status: 403 })
  }
 
  // Continue for authorized users
}

위의 예시는 두 단계의 보안 검사를 포함한 라우트 핸들러를 보여줍니다. 먼저 활성 세션이 있는지 확인한 후, 로그인한 사용자가 'admin'인지 검증합니다.

Context Providers

인증을 위한 컨텍스트 제공자(context providers)를 사용하는 것은 중첩 구조(interleaving) 덕분에 효과적입니다. 하지만 React 컨텍스트는 서버 컴포넌트(Server Components)에서 지원되지 않기 때문에 클라이언트 컴포넌트(Client Components)에만 적용 가능합니다.

이러한 방식은 동작하지만, 모든 자식 서버 컴포넌트는 먼저 서버에서 렌더링되며, 이 경우 컨텍스트 제공자의 세션 데이터에 접근할 수 없습니다.

app/layout.ts

import { ContextProvider } from 'auth-lib'
 
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <ContextProvider>{children}</ContextProvider>
      </body>
    </html>
  )
}
"use client";
 
import { useSession } from "auth-lib";
 
export default function Profile() {
  const { userId } = useSession();
  const { data } = useSWR(`/api/user/${userId}`, fetcher)
 
  return (
    // ...
  );
}

클라이언트 컴포넌트에서 세션 데이터가 필요한 경우(예: 클라이언트 측 데이터 페칭을 위한 경우), React의 taintUniqueValue API를 사용하여 민감한 세션 데이터가 클라이언트에 노출되는 것을 방지해야 합니다.

Next.js 공식 문서

profile
포기란 없습니다.

0개의 댓글

관련 채용 정보