Next.js로 간단한 로그인 인증 서버 구현하기

0

1. json-server 설치 및 환경 구성하기

json-server를 이용해서 간단한 로그인 인증 서버를 구현해 보겠습니다. 우선 아래의 명령어로 json-server을 설치해줍니다.

npm install -g json-server

그리고 프로젝트의 루트에 db 폴더를 생성하고 그 안에 db.json을 만들어 줍니다. db.json에는 로그인 인증을 위해 아래와 같이 간단한 유저 데이터를 추가합니다.

{
  "users": [
    { "id": 1, "username": "admin", "password": "admin123", "role": "admin" },
    { "id": 2, "username": "user", "password": "user123", "role": "user" }
  ],
  "posts": [
    { "id": 1, "title": "First Post", "content": "This is the first post.", "author": "admin" },
    { "id": 2, "title": "Second Post", "content": "This is the second post.", "author": "user" }
  ]
}

이제 json-server 설치는 끝났습니다. 아래의 명령어로 json-server를 구동시켜주면 http://localhost:3009/usershttp://localhost:3009/posts를 통해 usersposts 데이터를 받아올 수 있습니다.

json-server --watch db/db.json --port 3009

2. Next.js 프로젝트 생성하기

프로젝트 개요

  • json-server로 3009 포트에 RESTful API를 실행한 상태에서 Next.js를 이용해 로그인 기능을 구현합니다.
  • 로그인 후에는 인증된 사용자를 위한 페이지로 이동합니다.
  • json-server에서 사용할 db.json을 생성해 임시 데이터베이스로 활용합니다.

아직 Next.js 프로젝트가 설치되어 있지 않다면 아래의 명령어로 프로젝트를 생성해주세요. 이번 로그인 인증 서버 구현 예제에서는 타입스크립트를 사용합니다.

npx create-next-app {project_name} --typescript

프로젝트 구조

프로젝트의 구조는 다음과 같습니다.

project-directory/
│
├── .db/
│   └── db.json
├── components/
│   └── LoginForm.tsx
├── entities/
│   └── services/
│       └── auth.ts
├── lib/
│   └── verifyToken.ts
├── pages/
│   └── api/
│       ├── auth.ts
│       ├── posts.ts
│       └── profile.ts
│   └── login.tsx
│   └── profile.tsx
├── next-env.d.ts
├── package.json
├── tsconfig.json
└── README.md

관련 패키지 설치하고 프로젝트 초기화하기

package.json에 아래의 패키지들을 추가하고 초기화해주세요.

{
  "name": "nextjs-auth-project",
  "version": "1.0.0",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "axios": "^1.2.0",
    "jsonwebtoken": "^9.0.2",
    "next": "latest",
    "react": "latest",
    "react-dom": "latest"
  },
  "devDependencies": {
    "@types/jsonwebtoken": "^9.0.2",
    "@types/node": "^18.7.14",
    "@types/react": "^18.0.19",
    "@types/react-dom": "^18.0.6",
    "typescript": "^4.8.3"
  }
}

타입 스크립트 설정을 위해 tsconfig.json 파일을 열고 아래의 내용도 추가해줍니다.

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "strict": true,
    "jsx": "preserve",
    "esModuleInterop": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

next-env.d.ts 파일에 아래의 내요이 없다면 추가해주세요. next-env.d.ts 파일은 Next.js 프로젝트에서 타입스크립트를 사용할 때 필요한 타입 정의를 포함하여 프로젝트 내에서 Next.js API를 쉽게 사용할 수 있도록 돕는 중요한 환경 설정 파일입니다. 보통 자동으로 생성되며 개발자가 직접 수정할 필요는 없습니다.

/// <reference types="next" />
/// <reference types="next/types/global" />

3. 인증 미들웨어와 서비스 구현하기

verifyToken 미들웨어 만들기

lib폴더에 verifyToken.ts 파일을 다음과 같이 추가합니다. verifyToken는 JWT를 검증하기 위한 미들웨어입니다.

import { NextRequest } from "next/server";
import jwt from "jsonwebtoken";

const SECRET_KEY = "your_secret_key";

interface JwtPayload {
  id: number;
  role: string;
}

export interface CustomNextApiRequest extends NextRequest {
  user?: JwtPayload;
}

export default async function verifyToken(req: CustomNextApiRequest): Promise<boolean> {
  const authHeader = req.headers.get("authorization");

  if (authHeader && authHeader.startsWith("Bearer ")) {
    const token = authHeader.substring(7, authHeader.length); // "Bearer " 이후의 문자열을 추출
    try {
      const decoded = jwt.verify(token, SECRET_KEY) as JwtPayload;
      req.user = decoded;
      return true;
    } catch (err) {
      return false;
    }
  } else {
    return false;
  }
}

로그인 API 호출 모듈 만들기

services 폴더에 auth.ts 파일을 다음과 같이 추가합니다. auth.ts 파일은 로그인 API를 호출하는 auth 서비스 모듈입니다.

export interface LoginResponse {
  id: number;
  username: string;
  role: string;
  token: string;
}

export async function login(username: string, password: string): Promise<LoginResponse> {
  const response = await fetch("/api/auth", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ username, password }),
  });

  if (!response.ok) {
    throw new Error("Invalid username or password");
  }

  return await response.json();
}

4. Next.js API 라우트 구현하기

Next.js의 app 폴더에 아래와 같이 api 파일을 추가합니다. page 방식이라면 pages 폴더에 추가해줍니다.

app/api/auth/route.ts

// app/api/auth/route.ts

import { NextRequest, NextResponse } from "next/server";
import jwt from "jsonwebtoken";
import axios from "axios";

const SECRET_KEY = "your_secret_key";
const TOKEN_EXPIRY = "1h"; // 1시간

interface User {
  id: number;
  username: string;
  password: string;
  role: string;
}

interface LoginResponse {
  id: number;
  username: string;
  role: string;
  token: string;
}

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

  try {
    // json-server로부터 사용자 데이터를 가져오기
    const { data: users } = await axios.get<User[]>("http://localhost:3009/users");
    const user = users.find((u) => u.username === username && u.password === password);

    if (user) {
      const token = jwt.sign({ id: user.id, role: user.role }, SECRET_KEY, {
        expiresIn: TOKEN_EXPIRY,
      });
      const response: LoginResponse = {
        id: user.id,
        username: user.username,
        role: user.role,
        token,
      };
      return NextResponse.json(response, { status: 200 });
    } else {
      return NextResponse.json({ message: "Invalid username or password" }, { status: 401 });
    }
  } catch (error) {
    console.error("Error fetching users:", error);
    return NextResponse.json({ message: "Internal Server Error" }, { status: 500 });
  }
}

app/api/profile/route.ts

// app/api/profile/route.ts

import { NextRequest, NextResponse } from "next/server";
import verifyToken, { CustomNextApiRequest } from "@/lib/verifyToken";

interface UserProfile {
  id: number;
  username: string;
  role: string;
}

export async function GET(request: NextRequest) {
  const customRequest = request as unknown as CustomNextApiRequest;
  const isValidToken = await verifyToken(customRequest);

  if (!isValidToken) {
    return NextResponse.json({ message: "Unauthorized" }, { status: 403 });
  }

  const profile: UserProfile = {
    id: customRequest.user!.id,
    username: customRequest.user!.role === "admin" ? "Admin User" : "Regular User",
    role: customRequest.user!.role,
  };

  return NextResponse.json(profile, { status: 200 });
}

로그인 및 프로필 페이지 만들기

LoginForm.tsx

아래의 폴더에 LoginForm.tsx 컴포넌트 파일을 만들어줍니다. 로그인용 컴포넌트로 사용합니다.

// components/LoginForm.tsx

import React, { useState } from "react";
import { login, LoginResponse } from "@/services/auth";

interface LoginFormProps {
  onLoginSuccess: (token: string) => void;
}

const LoginForm: React.FC<LoginFormProps> = ({ onLoginSuccess }) => {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (event: React.FormEvent) => {
    event.preventDefault();
    setError(null);

    try {
      const response: LoginResponse = await login(username, password);
      localStorage.setItem("token", response.token);
      onLoginSuccess(response.token);
    } catch (e) {
      setError("Invalid username or password");
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2 className="text-2xl">Login</h2>
      {error && <p style={{ color: "red" }}>{error}</p>}
      <div>
        <label>
          Username:
          <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="border h-10" />
        </label>
      </div>
      <div>
        <label>
          Password:
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="border h-10"
          />
        </label>
      </div>
      <button type="submit">Login</button>
    </form>
  );
};

export default LoginForm;

로그인 페이지 만들기

아래에 로그인 페이지를 추가합니다.

// app/login/page.tsx

"use client";

import { useRouter } from "next/navigation";
import React from "react";
import LoginForm from "@/components/LoginForm";

const LoginPage = () => {
  const router = useRouter();

  const handleLoginSuccess = (token: string) => {
    router.push("/profile");
  };

  return (
    <div>
      <LoginForm onLoginSuccess={handleLoginSuccess} />
    </div>
  );
};

export default LoginPage;

프로필 페이지 만들기

로그인 후 이동할 프로필 페이지를 만들어줍니다.

// app/profile/page.tsx

"use client";

import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";

interface UserProfile {
  id: number;
  username: string;
  role: string;
}

const ProfilePage = () => {
  const router = useRouter();
  const [profile, setProfile] = useState<UserProfile | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchProfile = async () => {
      const token = localStorage.getItem("token");

      if (!token) {
        router.push("/login");
        return;
      }

      const response = await fetch("/api/profile", {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });

      if (response.ok) {
        const profileData: UserProfile = await response.json();
        setProfile(profileData);
      } else {
        setError("Failed to fetch profile.");
        router.push("/login");
      }
    };

    fetchProfile();
  }, [router]);

  const handleLogout = () => {
    localStorage.removeItem("token");
    router.push("/login");
  };

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

  if (!profile) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <h1 className="text-2xl">Profile Page</h1>
      <p>
        <strong>ID:</strong> {profile.id}
      </p>
      <p>
        <strong>Username:</strong> {profile.username}
      </p>
      <p>
        <strong>Role:</strong> {profile.role}
      </p>
      <button onClick={handleLogout}>Logout</button>
    </div>
  );
};

export default ProfilePage;

마치며

이번 프로젝트에서는 json-server와 Next.js를 이용해 간단한 로그인 인증 서버를 구현해 보았습니다. 이를 통해 JWT를 활용한 인증 미들웨어의 중요성을 이해하고, 다양한 페이지를 통해 인증된 사용자에게만 특정 정보를 제공하는 방법을 학습할 수 있었던 것 같습니다.

Leave a Reply