블로그 관리자 기능

By Yoon
·
thumbnail
블로그 관리자 기능에 대한 내용입니다 - 블로그 개발 후기 (2)

글을 시작하며

동적인 블로그를 개발하기 위해서 관리자 기능은 빠질 수 없는 부분입니다.
열심히 작성한 글을 누군가에 의해 쉽게 수정되거나 삭제되면 슬프니까요.

관리자 기능어떻게 개발했는지 이야기해 보려고 합니다.

관리자 기능

생각 정리

개발 시에 블로그 사용자는 두 가지 시나리오를 가질 수 있게 하고 싶었습니다.

  1. Admin은 포스트를 Create, Read, Update, Delete를 할 수 있습니다.
  2. User는 포스트를 Read 할 수 있습니다.

관리자 권한을 얻는 방법을 요약하자면 다음과 같습니다.

  1. 관리자 비밀번호를 암호화합니다.
  2. 입력된 비밀번호와 일치하면 JWT를 발급해 줍니다.
  3. JWT와 로그인이 됐는지를 판단하는 isLogin쿠키에 담습니다.
  4. Next.js Middleware에서 JWT가 유효한지 판단합니다.

암호화 방법 정하기

관리자 비밀번호 암호화로 hash 함수를 이용하기로 했습니다.
hash 함수를 사용하면 비밀번호를 단방향으로 암호화할 수 있으므로, 암호화된 비밀번호를 원래 값으로 복원할 수 없습니다.
관리자 비밀번호를 안전하게 보호할 수 있다고 생각했습니다.

hash 중에서도 bcryptscrypt와 같은 hash 함수들은 Salt와 함께 사용될 수 있어서 보안성이 더욱 강화됩니다.
Salt로 고유한 값을 추가해 주어 비밀번호의 보안성을 더욱 높였습니다.

저는 bcrypt 라이브러리를 사용했습니다.

bcrypt & JWT 발급 & 쿠키 설정

  • 주석을 참고해주세요.

로그인은 loginAction(Server Action)을 사용해 LoginForm(Form)을 다루고 있습니다.
서버에서 폼 다루기에 대한 글을 확인 할 수 있습니다.

// app/(root)/login/loginAction.ts
"use server";

import { compare, hash } from "bcrypt";
import { SignJWT } from "jose";
import { cookies } from "next/headers";

export async function loginAction(
   prevState: {
    success: boolean;
    error: boolean;
    message: string;
  },
  formData: FormData,
) {
  // 1. 로그인 input의 name 속성 값으로 입력된 password 저장
  const inputPW = formData.get("password")?.toString();
  // 2. Salt 값 저장
  const saltRounds = parseInt(process.env.SALT_ROUNDS!);
  // 3. hash 함수와 Salt값으로 암호화 진행하여 저장
  const hashedPW = await hash(process.env.PASSWORD!, saltRounds);
  if (inputPW) {
    // 4-1. 암호화된 값이랑 입력된 값 비교
    const isAdmin = await compare(inputPW, hashedPW);
    if (isAdmin) {
      // 5-1. 일치시 JWT 발급
      const alg = "HS256";
      const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
      const jwt= await new SignJWT({ username: "admin" })
        .setProtectedHeader({ alg })
        .setExpirationTime("24h")
        .sign(secret);
      // 6. 쿠키 설정
      cookies().set("isLogin", "true");
      cookies().set("jwt", jwt, {
        httpOnly: true,
        secure: true,
        maxAge: 60 * 60 * 24,
      });
      return {
        success: true,
        error: false,
        message: "로그인 성공!",
      };
    } else {
      // 5-2. 불일치
      return {
        success: false,
        error: true,
        message: "비밀번호가 일치하지 않습니다.",
      };
    }
  }
  // 4-2. 입력된 값이 없음
  return {
    success: false,
    error: true,
    message: "비밀번호를 입력해주세요!",
  };
}

Next.js의 Middleware로 로그인 여부 판단하기

  • Next.jsMiddleware는 페이지를 렌더링하기 전에 모든 요청을 가로채 서버에서 실행되는 함수입니다.
  • Middleware에서 페이지 렌더링 전에 JWT가 유효한지 검증하여 로그인 인증을 했습니다.
  • 서버의 모든 요청을 가로채기 때문에 무거운 로직을 거치지 않으려고 했습니다.
  • 주석을 참고해 주세요.
// middleware.ts
import { jwtVerify } from "jose";
import { NextResponse, NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  // 1. JWT 값을 쿠키에서 꺼내옴.
  const jwt = request.cookies.get("jwt")?.value;
  // 2. secret 값을 꺼내옴
  const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
  try {
    // 3-1. 토큰 값이 없다면 에러를 발생
    if (!jwt) {
      throw new Error("There is no jwt!");
    }
    // 3-2. 토큰 값이 유효한지 검사
    await jwtVerify(jwt, secret);
    // 4-1. 응답을 이어감
    return NextResponse.next();
  } catch (error) {
    // 4-2. 토큰이 없거나 검증에 실패한 경우
    // 5-1. 인증이 필요한 Route handlers인 경우 Error Response
    if (request.nextUrl.pathname.startsWith("/api/admin")) {
      return NextResponse.json({ error: "Not authorized" }, { status: 401 });
    }
    // 5-2. 인증이 필요한 페이지인 경우, 로그인 페이지로 이동
    return NextResponse.redirect(
      new URL(`/login?redirect=${request.nextUrl.pathname}`, request.url),
      { headers: { "Set-Cookie": "isLogin=false; Path=/" } },
    );
  }
}

// 해당 경로에 대해 검사
export const config = {
  matcher: ["/write", "/posts/:slug/edit", "/api/admin/:path*"],
};

Next.js에서 쿠키 다루기

  • 관리자 기능을 개발하면서 쿠키를 어떤 상황에서 어떻게 사용했는지 이야기해 보겠습니다.

1. Server Action & Route Handlers에서 쿠키 다루기

  • 로그인, 로그아웃 시에 사용
  • 쿠키 Set 할 때 사용, Next.js에서 메서드 제공
  1. 로그인
// app/(root)/login/loginAction.ts
"use server";

import { cookies } from "next/headers";

export async function loginAction( // 생략.. ) {
  // 생략..
  cookies().set("isLogin", "true");
  cookies().set("jwt", jwt, {
    httpOnly: true,
    secure: true,
    maxAge: 60 * 60 * 24,
    });
}
  1. 로그아웃
// app/(route-handlers)/api/admin/logout/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  try {
    const headers = new Headers();
    headers.append("Set-Cookie", "jwt=deleted; Path=/");
    headers.append("Set-Cookie", "isLogin=false; Path=/");
    return NextResponse.json({ message: "logout success" }, { headers });
  } catch (error) {
    return NextResponse.json(
      { error: "Internal Server Error" },
      { status: 500 },
    );
  }
}

2. 클라이언트 컴포넌트에서 쿠키 다루기

  • 로그인 여부 판단 시에 사용
  • 쿠키 Get 할 때 사용, react-cookie 라이브러리 사용
// app/_components/Header/LoginButton.tsx
"use client";

import { useCookies } from "react-cookie";

export default function LoginButton() {
  // 생략..
  const [cookies] = useCookies(["isLogin"]);

  if (pathname === "/login") {
    return null;
  }

  async function handleLogoutClick() {
    try {
      await fetch("/api/admin/logout", {
        method: "POST",
      });
      // 생략..
      router.replace("/");
      router.refresh();
    } catch (error) {
      // 생략..
    }
  }

  return (
    <li>
      {cookies.isLogin ? (
        <div>
          <Link href="/write">Write</Link>
          <button onClick={handleLogoutClick}>Logout</button>
        </div>
      ) : (
        <Link href={`/login?redirect=${pathname}`}>Admin</Link>
      )}
    </li>
  );
}

로그아웃

  • 로그아웃은 button 태그의 onClick 속성을 사용하므로 Client Component에서 작동해야 합니다.
  • /api/admin/logout요청이 가면 JWT를 유효하지 않은 값(여기서는 deleted), isLogin 값을 false로 바꾸어 로그아웃되도록 했습니다.
  • 로그아웃 성공 시 /(홈)으로 이동되며, refresh(새로고침) 해 Header가 쿠키 값에 따라 반영되도록 해주었습니다.
// app/_components/Header/LoginButton.tsx
"use client";

export default function LoginButton() {
  const pathname = usePathname();
  const router = useRouter();
  const [cookies] = useCookies(["isLogin"]);

  if (pathname === "/login") {
    return null;
  }

  async function handleLogoutClick() {
    try {
      await fetch("/api/admin/logout", {
        method: "POST",
      });
     // 생략..
      router.replace("/");
      router.refresh();
    } catch (error) {
      // 생략..
    }
  }

  return (
    <li>
      {cookies.isLogin ? (
        <div>
          <Link href="/write">Write</Link>
          <button onClick={handleLogoutClick}>Logout</button>
        </div>
      ) : (
        <Link href={`/login?redirect=${pathname}`}>Admin</Link>
      )}
    </li>
  );
}

결과

  • User

user

  • Admin

admin


글을 마치며

배운점

  • 비밀번호 암호화 방법
  • Server Action & Route Handlers & Client Component에서 쿠키 다루기
  • Next.js middleware

마무리

지금까지 블로그 관리자 기능에 대한 내용입니다.

읽어주셔서 감사합니다.

YoonLog 깃허브 레포