Next.js 애플리케이션에서 HttpOnly 쿠키를 사용한 JWT 기반 인증 구현 방법

0

Next.js 애플리케이션에서 HttpOnly Cookies를 사용하여 JWT 기반 인증을 구현하는 방법에 대해 자세히 알아보겠습니다. HttpOnly Cookies를 사용하면 클라이언트 측 JavaScript에서 쿠키에 접근할 수 없기 때문에 보안이 향상됩니다. 특히 XSS (Cross-Site Scripting) 공격에 대한 방어가 강화됩니다.

아래 단계별 가이드를 통해 HttpOnly Cookies를 설정하고, JWT 토큰을 안전하게 관리하는 방법을 알아보겠습니다.

1. 필요한 패키지 설치

먼저, 쿠키를 다루기 위해 `cookie` 패키지를 설치하고, TypeScript 타입 정의 파일도 설치합니다.

npm install cookie
npm install --save-dev @types/cookie
  • cookie: 서버와 클라이언트 간에 쿠키를 파싱하고 설정하는 데 사용됩니다.
  • @types/cookie: TypeScript용 타입 정의 파일입니다.

2. JWT 유틸리티 수정

기존의 `utils/jwt.ts` 파일을 수정하여 토큰 생성과 검증 기능을 제공합니다.

`utils/jwt.ts`

import jwt from "jsonwebtoken";

export interface JwtPayload {
  userId: string;
  email: string;
}

export const signToken = (payload: JwtPayload): string => {
  return jwt.sign(payload, process.env.JWT_SECRET as string, { expiresIn: "1h" });
};

export const verifyToken = (token: string): JwtPayload | null => {
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as JwtPayload;
    return decoded;
  } catch (error) {
    console.error("JWT 검증 오류:", error);
    return null;
  }
};
설명:
  • `signToken`: 주어진 페이로드로 JWT를 생성합니다.
  • `verifyToken`: 주어진 토큰을 검증하고, 유효하면 페이로드를 반환합니다. 유효하지 않으면 `null`을 반환합니다.

3. API 라우트에서 HttpOnly 쿠키 설정

Next.js의 API 라우트에서 JWT 토큰을 HttpOnly 쿠키로 설정하도록 수정합니다. 이 예제에서는 로그인 API 라우트를 수정합니다.

`app/api/auth/login/route.ts`

import { NextRequest, NextResponse } from "next/server";
import axios from "axios";
import { signToken } from "@/utils/jwt";
import mongooseConnect from "@/lib/mongoose";
import User from "@/models/User";
import bcrypt from "bcryptjs";
import { serialize } from "cookie";

export async function POST(request: NextRequest) {
  try {
    const { email, password } = await request.json();

    if (!email || !password) {
      return NextResponse.json({ error: "이메일과 비밀번호는 필수입니다." }, { status: 400 });
    }

    await mongooseConnect();

    const user = await User.findOne({ email });
    if (!user) {
      return NextResponse.json({ error: "이메일 또는 비밀번호가 잘못되었습니다." }, { status: 400 });
    }

    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      return NextResponse.json({ error: "이메일 또는 비밀번호가 잘못되었습니다." }, { status: 400 });
    }

    const token = signToken({ userId: user._id, email: user.email });

    // HttpOnly 쿠키로 토큰 설정
    const response = NextResponse.json({ message: "로그인 성공" }, { status: 200 });
    response.headers.set(
      "Set-Cookie",
      serialize("token", token, {
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        sameSite: "strict",
        maxAge: 60 * 60, // 1시간
        path: "/",
      })
    );

    return response;
  } catch (error: any) {
    console.error("Login Error:", error.response?.data?.message || error.message);
    return NextResponse.json({ error: "로그인 중 오류가 발생했습니다." }, { status: 500 });
  }
}

설명:

  • 로그인 검증: 사용자가 입력한 이메일과 비밀번호를 검증합니다.
  • JWT 생성: 검증된 사용자 정보로 JWT를 생성합니다.
  • HttpOnly 쿠키 설정: 생성된 JWT를 `Set-Cookie` 헤더를 통해 HttpOnly 쿠키로 설정합니다.
  • `httpOnly: true`: 클라이언트 측 JavaScript에서 쿠키에 접근할 수 없도록 설정합니다.
  • `secure: true`: HTTPS 환경에서만 쿠키가 전송되도록 설정합니다.
  • `sameSite: “strict”`: 크로스 사이트 요청 시 쿠키가 전송되지 않도록 설정합니다.
  • `maxAge`: 쿠키의 만료 시간을 설정합니다.
  • `path: “/”`: 모든 경로에서 쿠키에 접근할 수 있도록 설정합니다.

4. 인증 미들웨어 수정

기존의 인증 미들웨어를 수정하여 Authorization 헤더 대신 쿠키에서 JWT 토큰을 읽도록 변경합니다.

`middlewares/auth.ts`

import { NextRequest, NextResponse } from "next/server";
import { verifyToken, JwtPayload } from "@/utils/jwt";
import { parse } from "cookie";

export const authenticate = (request: NextRequest): JwtPayload | null => {
  const cookie = request.headers.get("cookie");
  if (!cookie) {
    return null;
  }

  const cookies = parse(cookie);
  const token = cookies.token;

  if (!token) {
    return null;
  }

  const decoded = verifyToken(token);
  return decoded;
};
설명:
  • 쿠키 파싱: `cookie` 헤더에서 JWT 토큰을 추출합니다.
  • 토큰 검증: 추출한 토큰을 검증하고, 유효하면 페이로드를 반환합니다.

5. 클라이언트 측 로그인 및 로그아웃 처리

클라이언트 측에서는 이제 JWT 토큰을 쿠키로부터 직접 접근할 수 없으므로, 인증 상태를 관리하는 방식을 변경해야 합니다.

5.1. 로그인 컴포넌트 수정

로그인 후 토큰을 `localStorage`에 저장할 필요가 없으므로, 단순히 로그인 요청을 보내고 상태를 업데이트합니다.

`components/Login.tsx`
import { useState } from "react";

const Login = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleLogin = async () => {
    try {
      const response = await fetch("/api/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password }),
      });

      const data = await response.json();

      if (response.ok) {
        alert("로그인 성공!");
        // 로그인 상태 업데이트 (예: 리다이렉트 또는 상태 관리)
      } else {
        alert(data.error);
      }
    } catch (error: any) {
      console.error("로그인 오류:", error);
      alert("로그인 중 오류가 발생했습니다.");
    }
  };

  return (
    <div>
      <h2>로그인</h2>
      <input
        type="text"
        placeholder="이메일 또는 사용자 이름"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      /><br/>
      <input
        type="password"
        placeholder="비밀번호"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      /><br/>
      <button onClick={handleLogin}>로그인</button>
    </div>
  );
};

export default Login;

5.2. 로그아웃 API 라우트 생성

로그아웃 시 쿠키를 제거하는 API 라우트를 생성합니다.

`app/api/auth/logout/route.ts`
import { NextRequest, NextResponse } from "next/server";
import { serialize } from "cookie";

export async function POST(request: NextRequest) {
  const response = NextResponse.json({ message: "로그아웃 성공" }, { status: 200 });
  response.headers.set(
    "Set-Cookie",
    serialize("token", "", {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      expires: new Date(0), // 쿠키 만료
      path: "/",
    })
  );
  return response;
}

설명:

  • 쿠키 제거: `Set-Cookie` 헤더를 통해 `token` 쿠키를 만료시킵니다.

5.3. 로그아웃 컴포넌트 생성

로그아웃 버튼을 클릭하면 로그아웃 API를 호출하여 쿠키를 제거합니다.

`components/Logout.tsx`
const Logout = () => {
  const handleLogout = async () => {
    try {
      const response = await fetch("/api/auth/logout", {
        method: "POST",
      });

      const data = await response.json();

      if (response.ok) {
        alert("로그아웃 되었습니다.");
        // 로그아웃 상태 업데이트 (예: 리다이렉트 또는 상태 관리)
      } else {
        alert(data.error);
      }
    } catch (error: any) {
      console.error("로그아웃 오류:", error);
      alert("로그아웃 중 오류가 발생했습니다.");
    }
  };

  return <button onClick={handleLogout}>로그아웃</button>;
};

export default Logout;

6. CSRF 방어 고려 사항

HttpOnly 쿠키를 사용할 때는 CSRF (Cross-Site Request Forgery) 공격에 대비해야 합니다. 이를 위해 SameSite 쿠키 속성을 사용하거나, CSRF 토큰을 도입할 수 있습니다.

6.1. SameSite 속성 활용

앞서 설정한 `sameSite: “strict”` 속성은 크로스 사이트 요청 시 쿠키가 전송되지 않도록 하여 CSRF 공격을 어느 정도 방어합니다. 추가적으로 CSRF 토큰을 도입하면 보안을 강화할 수 있습니다.

6.2. CSRF 토큰 도입

CSRF 토큰을 사용하여 요청의 출처를 검증할 수 있습니다. 예를 들어, `csrf` 패키지를 사용하여 토큰을 생성하고 검증할 수 있습니다.

  • 참고: Next.js에서는 next-csrf와 같은 라이브러리를 사용할 수 있습니다.

7. 환경 변수 설정

JWT 서명에 사용할 비밀 키를 환경 변수로 설정합니다. 이 키는 절대 공개되지 않아야 하므로, `.env.local` 파일에 저장하고 `.gitignore`에 추가합니다.

`.env.local`

MONGODB_URI=mongodb://localhost:27017/mindcrash
JWT_SECRET=your_super_secret_jwt_key_here

주의사항:

  • 비밀 키 보호: `JWT_SECRET`는 복잡하고 예측하기 어려운 문자열로 설정해야 합니다.
  • 환경 변수 로드: Next.js는 `.env.local` 파일을 자동으로 로드합니다. 서버를 재시작하여 변경 사항이 반영되도록 합니다.

8. 전체 코드 예제

8.1. `models/User.ts`

import mongoose, { Schema, Document, model } from "mongoose";

export interface IUser extends Document {
  email: string;
  password: string;
}

const UserSchema: Schema<IUser> = new Schema({
  email: { type: String, required: true, unique: true, lowercase: true },
  password: { type: String, required: true },
});

export default mongoose.models.User || model<IUser>("User", UserSchema);

8.2. `app/api/auth/login/route.ts`

import { NextRequest, NextResponse } from "next/server";
import { signToken } from "@/utils/jwt";
import mongooseConnect from "@/lib/mongoose";
import User from "@/models/User";
import bcrypt from "bcryptjs";
import { serialize } from "cookie";

export async function POST(request: NextRequest) {
  try {
    const { email, password } = await request.json();

    if (!email || !password) {
      return NextResponse.json({ error: "이메일과 비밀번호는 필수입니다." }, { status: 400 });
    }

    await mongooseConnect();

    const user = await User.findOne({ email });
    if (!user) {
      return NextResponse.json({ error: "이메일 또는 비밀번호가 잘못되었습니다." }, { status: 400 });
    }

    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) {
      return NextResponse.json({ error: "이메일 또는 비밀번호가 잘못되었습니다." }, { status: 400 });
    }

    const token = signToken({ userId: user._id, email: user.email });

    // HttpOnly 쿠키로 토큰 설정
    const response = NextResponse.json({ message: "로그인 성공" }, { status: 200 });
    response.headers.set(
      "Set-Cookie",
      serialize("token", token, {
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        sameSite: "strict",
        maxAge: 60 * 60, // 1시간
        path: "/",
      })
    );

    return response;
  } catch (error: any) {
    console.error("Login Error:", error.response?.data?.message || error.message);
    return NextResponse.json({ error: "로그인 중 오류가 발생했습니다." }, { status: 500 });
  }
}

8.3. `app/api/auth/logout/route.ts`

import { NextRequest, NextResponse } from "next/server";
import { serialize } from "cookie";

export async function POST(request: NextRequest) {
  const response = NextResponse.json({ message: "로그아웃 성공" }, { status: 200 });
  response.headers.set(
    "Set-Cookie",
    serialize("token", "", {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      expires: new Date(0), // 쿠키 만료
      path: "/",
    })
  );
  return response;
}

8.4. `middlewares/auth.ts`

import { NextRequest, NextResponse } from "next/server";
import { verifyToken, JwtPayload } from "@/utils/jwt";
import { parse } from "cookie";

export const authenticate = (request: NextRequest): JwtPayload | null => {
  const cookie = request.headers.get("cookie");
  if (!cookie) {
    return null;
  }

  const cookies = parse(cookie);
  const token = cookies.token;

  if (!token) {
    return null;
  }

  const decoded = verifyToken(token);
  return decoded;
};

8.5. `app/api/data/route.ts`

import { NextRequest, NextResponse } from "next/server";
import mongooseConnect from "@/lib/mongoose";
import Data from "@/models/annotation";
import { authenticate } from "@/middlewares/auth";

export async function GET(request: NextRequest) {
  const user = authenticate(request);

  if (!user) {
    return NextResponse.json({ error: "인증되지 않았습니다." }, { status: 401 });
  }

  try {
    await mongooseConnect();
    const data = await Data.find({});
    return NextResponse.json(data, { status: 200 });
  } catch (error) {
    console.error("GET Error:", error);
    return NextResponse.json({ error: "데이터를 가져오는 중 오류 발생" }, { status: 500 });
  }
}

export async function POST(request: NextRequest) {
  const user = authenticate(request);

  if (!user) {
    return NextResponse.json({ error: "인증되지 않았습니다." }, { status: 401 });
  }

  try {
    const body = await request.json();
    await mongooseConnect();
    const newData = new Data(body);
    await newData.save();
    return NextResponse.json(newData, { status: 201 });
  } catch (error) {
    console.error("POST Error:", error);
    return NextResponse.json({ error: "데이터를 저장하는 중 오류 발생" }, { status: 500 });
  }
}

8.6. `components/Login.tsx`

import { useState } from "react";

const Login = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleLogin = async () => {
    try {
      const response = await fetch("/api/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password }),
      });

      const data = await response.json();

      if (response.ok) {
        alert("로그인 성공!");
        // 로그인 상태 업데이트 (예: 리다이렉트 또는 상태 관리)
      } else {
        alert(data.error);
      }
    } catch (error: any) {
      console.error("로그인 오류:", error);
      alert("로그인 중 오류가 발생했습니다.");
    }
  };

  return (
    <div>
      <h2>로그인</h2>
      <input
        type="text"
        placeholder="이메일 또는 사용자 이름"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      /><br/>
      <input
        type="password"
        placeholder="비밀번호"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      /><br/>
      <button onClick={handleLogin}>로그인</button>
    </div>
  );
};

export default Login;

8.7. `components/Logout.tsx`

const Logout = () => {
  const handleLogout = async () => {
    try {
      const response = await fetch("/api/auth/logout", {
        method: "POST",
      });

      const data = await response.json();

      if (response.ok) {
        alert("로그아웃 되었습니다.");
        // 로그아웃 상태 업데이트 (예: 리다이렉트 또는 상태 관리)
      } else {
        alert(data.error);
      }
    } catch (error: any) {
      console.error("로그아웃 오류:", error);
      alert("로그아웃 중 오류가 발생했습니다.");
    }
  };

  return <button onClick={handleLogout}>로그아웃</button>;
};

export default Logout;

8.8. `components/FetchData.tsx`

import { useEffect, useState } from "react";

const FetchData = () => {
  const [data, setData] = useState<any[]>([]);
  const [error, setError] = useState<string | null>(null);

  const fetchData = async () => {
    try {
      const response = await fetch("/api/data", {
        method: "GET",
        credentials: "include", // 쿠키를 포함시키기 위해 설정
      });

      const result = await response.json();

      if (response.ok) {
        setData(result);
      } else {
        setError(result.error);
      }
    } catch (error: any) {
      console.error("데이터 가져오기 오류:", error);
      setError("데이터를 가져오는 중 오류가 발생했습니다.");
    }
  };

  useEffect(() => {
    fetchData();
  }, []);

  if (error) {
    return <div>{error}</div>;
  }

  return (
    <div>
      <h2>데이터 리스트</h2>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};

export default FetchData;
설명:
  • `credentials: “include”`: 클라이언트 측에서 쿠키를 자동으로 포함하여 API 요청을 보냅니다.

9. 보안 고려 사항

HttpOnly Cookies를 사용하여 JWT를 관리할 때, 다음과 같은 보안 고려 사항을 반드시 염두에 두어야 합니다.

9.1. HTTPS 사용

  • 필수: HttpOnly 쿠키는 민감한 정보를 포함하므로, **HTTPS**를 사용하여 데이터 전송 시 암호화하는 것이 필수적입니다. 특히, 프로덕션 환경에서는 반드시 HTTPS를 적용해야 합니다.

9.2. SameSite 속성 설정

  • SameSite=Strict: 크로스 사이트 요청 시 쿠키가 전송되지 않도록 하여 CSRF 공격을 방어할 수 있습니다.
  • SameSite=Lax: 일부 크로스 사이트 요청에서는 쿠키가 전송됩니다.
  • SameSite=None: 모든 크로스 사이트 요청에서 쿠키가 전송됩니다. 이 경우 `Secure` 속성을 반드시 설정해야 합니다.

추천: `SameSite=Strict` 또는 `SameSite=Lax`를 사용하여 CSRF 공격을 방어합니다.

9.3. CSRF 방어

HttpOnly 쿠키는 자동으로 전송되므로, CSRF 공격에 취약할 수 있습니다. 이를 방어하기 위해 다음과 같은 방법을 고려할 수 있습니다.

  • SameSite 속성: 위에서 설명한 대로, `SameSite=Strict` 또는 `SameSite=Lax`를 설정합니다.
  • CSRF 토큰: 요청 시 CSRF 토큰을 포함시켜 요청의 출처를 검증합니다. 이를 위해 `next-csrf`와 같은 라이브러리를 사용할 수 있습니다.

9.4. 쿠키 만료 시간 설정

JWT 토큰의 만료 시간을 적절하게 설정하여 보안을 강화합니다. 너무 긴 만료 시간은 보안 위험을 증가시키고, 너무 짧은 만료 시간은 사용자 경험을 저해할 수 있습니다.

추천: 초기 설정에서는 1시간 정도로 설정하고, 필요에 따라 Refresh Token을 도입하여 토큰을 갱신하는 방식을 고려합니다.

9.5. 비밀 키 관리

`JWT_SECRET`는 매우 중요한 정보이므로, 다음을 준수해야 합니다.

  • 환경 변수로 관리: `.env.local` 파일에 저장하고, 절대 버전 관리 시스템(Git 등)에 포함시키지 않습니다.
  • 복잡한 키 사용: 충분히 긴 랜덤 문자열을 사용하여 예측 불가능하도록 합니다.

9.6. 비밀번호 보안

  • 비밀번호 해싱: 비밀번호는 반드시 해싱하여 저장해야 합니다. 예제에서는 `bcrypt`를 사용하였습니다.
  • 강력한 비밀번호 정책: 사용자가 강력한 비밀번호를 설정하도록 유도합니다.

9.7. 입력 검증

사용자로부터 입력받는 모든 데이터는 철저히 검증하여 인젝션 공격을 방어합니다.

9.8. 에러 메시지 관리

에러 메시지에 민감한 정보를 포함하지 않도록 주의합니다. 공격자가 시스템의 내부 구조를 파악하는 데 도움이 될 수 있습니다.

9.9. Rate Limiting

API 엔드포인트에 Rate Limiting을 적용하여 DDoS 공격을 방어합니다. 이를 위해 `next-rate-limit`과 같은 라이브러리를 사용할 수 있습니다.

9.10. 보안 헤더 설정

Helmet과 같은 미들웨어를 사용하여 HTTP 보안 헤더를 설정합니다. 이는 XSS, Clickjacking 등 다양한 웹 공격을 방어하는 데 도움이 됩니다.

결론

Next.js 애플리케이션에서 HttpOnly Cookies를 사용하여 JWT 기반 인증을 구현하는 방법을 단계별로 알아보았습니다. 이 방식을 통해 클라이언트 측 JavaScript에서 JWT 토큰에 접근할 수 없으므로 보안이 향상됩니다.

요약하면:

  • JWT 유틸리티 설정: 토큰 생성 및 검증 기능을 제공합니다.
  • API 라우트 수정: 로그인 시 HttpOnly 쿠키로 토큰을 설정하고, 로그아웃 시 쿠키를 제거합니다.
  • 인증 미들웨어 수정: 쿠키에서 토큰을 읽어 인증을 수행합니다.
  • 클라이언트 측 수정: 쿠키를 통해 인증 상태를 관리하고, API 요청 시 쿠키를 포함합니다.
  • 보안 고려 사항 준수: HTTPS 사용, SameSite 속성 설정, CSRF 방어, 비밀 키 관리 등을 철저히 합니다.

이렇게 Next.js 애플리케이션에서 WordPress와 연동한 보안 인증 기능을 성공적으로 구현할 수 있습니다.

답글 남기기