JWT를 사용하여 Next.js에서 토큰 인증을 구현하는 방법

토큰 기반 인증은 웹 및 모바일 애플리케이션을 허가받지 않은 접근으로부터 보호하는 데 널리 사용되는 방식입니다. Next.js 환경에서는 Next-auth에서 제공하는 강력한 인증 기능을 활용할 수 있습니다.

또 다른 방법으로, JWT(JSON Web Token)를 사용하여 맞춤형 토큰 기반 인증 시스템을 구축할 수도 있습니다. 이렇게 하면 인증 로직을 더욱 세밀하게 제어할 수 있으며, 프로젝트의 특정 요구 사항에 맞춰 시스템을 정확하게 조정할 수 있습니다.

Next.js 프로젝트 구성

프로젝트를 시작하려면 터미널에서 다음 명령을 실행하여 Next.js를 설치합니다.

npx create-next-app@latest next-auth-jwt --experimental-app

이 가이드에서는 app 디렉토리를 사용하는 Next.js 13 버전을 기준으로 설명합니다.

다음으로, npm을 사용하여 필요한 종속성을 프로젝트에 추가합니다.

npm install jose universal-cookie

jose는 JSON 웹 토큰 작업을 위한 다양한 유틸리티를 제공하는 JavaScript 모듈입니다. universal-cookie 종속성은 클라이언트 및 서버 환경 모두에서 브라우저 쿠키를 쉽게 사용할 수 있도록 지원합니다.

로그인 폼 사용자 인터페이스 생성

src/app 디렉토리에서 login이라는 새 폴더를 만들고, 그 안에 page.js 파일을 생성한 후 다음 코드를 입력합니다.

"use client";
import { useRouter } from "next/navigation";

export default function LoginPage() {
    return (
        <form onSubmit={handleSubmit}>
            <label>
                Username:
                <input type="text" name="username" />
            </label>
            <label>
                Password:
                <input type="password" name="password" />
            </label>
            <button type="submit">Login</button>
        </form>
    );
}

위 코드는 사용자 이름과 비밀번호를 입력할 수 있는 간단한 로그인 폼을 브라우저에 렌더링하는 로그인 페이지 컴포넌트를 만듭니다.

코드의 ‘use client’ 문은 app 디렉토리 내에서 서버 전용 코드와 클라이언트 전용 코드를 구분합니다.

특히 handleSubmit 함수는 클라이언트 측에서만 실행되어야 하며, 그렇지 않을 경우 Next.js에서 오류가 발생합니다.

이제 handleSubmit 함수에 대한 코드를 추가해 보겠습니다. 컴포넌트 내에 다음 코드를 추가합니다.

const router = useRouter();

const handleSubmit = async (event) => {
    event.preventDefault();
    const formData = new FormData(event.target);
    const username = formData.get("username");
    const password = formData.get("password");
    const res = await fetch("/api/login", {
        method: "POST",
        body: JSON.stringify({ username, password }),
    });
    const { success } = await res.json();
    if (success) {
        router.push("/protected");
        router.refresh();
    } else {
        alert("Login failed");
    }
};

이 함수는 로그인 폼에서 사용자 자격 증명을 수집하고, API 엔드포인트로 POST 요청을 보내 인증을 수행합니다.

자격 증명이 유효하면 API는 성공 상태를 반환하고, 핸들러 함수는 Next.js 라우터를 사용하여 사용자를 지정된 URL(여기서는 ‘/protected’)로 이동시킵니다.

로그인 API 엔드포인트 정의

src/app 디렉토리 내에 api라는 새 폴더를 만들고, 그 안에 login/route.js 파일을 생성한 후 다음 코드를 추가합니다.

import { SignJWT } from "jose";
import { NextResponse } from "next/server";
import { getJwtSecretKey } from "@/libs/auth";

export async function POST(request) {
    const body = await request.json();
    if (body.username === "admin" && body.password === "admin") {
        const token = await new SignJWT({
            username: body.username,
        })
            .setProtectedHeader({ alg: "HS256" })
            .setIssuedAt()
            .setExpirationTime("30s")
            .sign(getJwtSecretKey());
        const response = NextResponse.json(
            { success: true },
            { status: 200, headers: { "content-type": "application/json" } }
        );
        response.cookies.set({
            name: "token",
            value: token,
            path: "/",
        });
        return response;
    }
    return NextResponse.json({ success: false });
}

이 API는 POST 요청에서 받은 로그인 자격 증명을 모의 데이터와 비교하여 확인합니다.

인증이 성공하면 암호화된 JWT 토큰을 생성하여 응답 쿠키에 포함하여 클라이언트로 보냅니다. 인증에 실패하면 실패 상태 응답을 반환합니다.

토큰 검증 로직 구현

토큰 인증의 첫 단계는 로그인 후 토큰을 생성하는 것입니다. 다음 단계는 토큰 검증 로직을 구현하는 것입니다.

jose 모듈의 jwtVerify 함수를 사용하여 이후 HTTP 요청과 함께 전달된 JWT 토큰을 검증합니다.

src 디렉토리에 libs/auth.js 파일을 만들고 다음 코드를 추가합니다.

import { jwtVerify } from "jose";

export function getJwtSecretKey() {
    const secret = process.env.NEXT_PUBLIC_JWT_SECRET_KEY;
    if (!secret) {
        throw new Error("JWT Secret key is not matched");
    }
    return new TextEncoder().encode(secret);
}

export async function verifyJwtToken(token) {
    try {
        const { payload } = await jwtVerify(token, getJwtSecretKey());
        return payload;
    } catch (error) {
        return null;
    }
}

비밀 키는 토큰 서명 및 검증에 사용됩니다. 서버는 디코딩된 토큰 서명을 예상 서명과 비교하여 토큰의 유효성을 확인하고 사용자의 요청을 허용할 수 있습니다.

루트 디렉토리에 .env 파일을 만들고 고유한 비밀 키를 추가합니다.

NEXT_PUBLIC_JWT_SECRET_KEY=your_secret_key

보호된 경로 생성

인증된 사용자만 접근할 수 있는 경로를 생성합니다. src/app 디렉토리에 protected/page.js 파일을 만들고 다음 코드를 추가합니다.

export default function ProtectedPage() {
    return <h1>Very protected page</h1>;
}

인증 상태 관리를 위한 Hook 생성

src 디렉토리에 Hooks라는 새 폴더를 만들고 그 안에 useAuth/index.js 파일을 생성한 후 다음 코드를 추가합니다.

"use client" ;
import React from "react";
import Cookies from "universal-cookie";
import { verifyJwtToken } from "@/libs/auth";

export function useAuth() {
    const [auth, setAuth] = React.useState(null);

    const getVerifiedtoken = async () => {
        const cookies = new Cookies();
        const token = cookies.get("token") ?? null;
        const verifiedToken = await verifyJwtToken(token);
        setAuth(verifiedToken);
    };
    React.useEffect(() => {
        getVerifiedtoken();
    }, []);
    return auth;
}

이 Hook은 클라이언트 측에서 인증 상태를 관리합니다. 쿠키에서 JWT 토큰을 가져와 verifyJwtToken 함수를 사용하여 검증하고, 인증된 사용자 정보를 인증 상태로 설정합니다.

이렇게 하면 다른 컴포넌트에서 인증된 사용자 정보에 접근하여 활용할 수 있습니다. UI 업데이트, API 요청, 사용자 역할에 따른 콘텐츠 렌더링 등 다양한 상황에서 필수적입니다.

이 예제에서는 Hook을 사용하여 사용자의 인증 상태에 따라 홈페이지에서 다른 콘텐츠를 렌더링합니다.

Redux Toolkit을 사용하거나 Jotai와 같은 상태 관리 도구를 사용하여 상태 관리를 처리하는 방법도 고려해 볼 수 있습니다. 이러한 접근 방식은 여러 컴포넌트에서 전역적으로 인증 상태 또는 다른 정의된 상태에 접근할 수 있도록 보장합니다.

app/page.js 파일을 열고 기존 코드를 제거한 후 다음 코드를 추가합니다.

"use client" ;

import { useAuth } from "@/hooks/useAuth";
import Link from "next/link";
export default function Home() {
    const auth = useAuth();
    return <>
        <h1>Public Home Page</h1>
        <header>
            <nav>
                {auth ? (
                    <p>logged in</p>
                ) : (
                    <Link href="/login">Login</Link>
                )}
            </nav>
        </header>
    </>
}

위 코드는 useAuth Hook을 사용하여 인증 상태를 관리합니다. 사용자가 인증되지 않았을 때 로그인 페이지 링크를 표시하고, 인증되었을 때는 ‘logged in’ 텍스트를 표시합니다.

보호된 경로에 대한 접근을 제어하는 미들웨어 추가

src 디렉토리에 middleware.js 파일을 생성하고 다음 코드를 추가합니다.

import { NextResponse } from "next/server";
import { verifyJwtToken } from "@/libs/auth";

const AUTH_PAGES = ["/login"];

const isAuthPages = (url) => AUTH_PAGES.some((page) => page.startsWith(url));

export async function middleware(request) {

    const { url, nextUrl, cookies } = request;
    const { value: token } = cookies.get("token") ?? { value: null };
    const hasVerifiedToken = token && (await verifyJwtToken(token));
    const isAuthPageRequested = isAuthPages(nextUrl.pathname);

    if (isAuthPageRequested) {
        if (!hasVerifiedToken) {
            const response = NextResponse.next();
            response.cookies.delete("token");
            return response;
        }
        const response = NextResponse.redirect(new URL(`/`, url));
        return response;
    }

    if (!hasVerifiedToken) {
        const searchParams = new URLSearchParams(nextUrl.searchParams);
        searchParams.set("next", nextUrl.pathname);
        const response = NextResponse.redirect(
            new URL(`/login?${searchParams}`, url)
        );
        response.cookies.delete("token");
        return response;
    }

    return NextResponse.next();

}
export const config = { matcher: ["/login", "/protected/:path*"] };

이 미들웨어 코드는 가드 역할을 합니다. 사용자가 보호된 페이지에 접근하려고 할 때 인증 여부를 확인하고, 인증되지 않은 사용자를 로그인 페이지로 리디렉션합니다.

Next.js 애플리케이션 보안

토큰 인증은 효과적인 보안 메커니즘이지만, 무단 접근으로부터 애플리케이션을 보호하는 유일한 전략은 아닙니다.

변화하는 사이버 보안 환경에 맞춰 애플리케이션을 강화하려면 잠재적인 보안 허점과 취약성을 포괄적으로 해결하여 철저한 보호를 보장하는 것이 중요합니다.