NextJs 풀스택 구축기 4 토큰

OkGyoung·2023년 11월 9일
0

지난 시간에 이어서 보안강화를 위해 토큰을 적용한다.

먼저 API를 만들어 유저만 이용할 수 있도록합니다.
app/api/user/[id]/route.ts

import { prisma } from "@/app/utils/prisma";

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  console.log(params);

  const id = params.id;

  const userPosts = await prisma.post.findMany({
    where: {
      authorId: id,
    },
    include: {
      author: {
        select: {
          email: true,
          name: true,
        },
      },
    },
  });
  return new Response(JSON.stringify(userPosts));
}

/api/user/[ObjectId]에 GET요청시 정상적으로 Post를 보여줍니다. 쉽게 검색하는 로직이 완료된 것 입니다.

하지만 만약 Post가 중요한 문서라면 어떨까요? 위처럼 따로 인증없이도 이용할 수 있다면 보안상 좋지않습니다.

이것을 해결하기위해 NextAuth에서 JWT토큰을 이용합니다.

npm install jsonwebtoken
npm install -D @types/jsonwebtoken

먼저 라이브러리를 설치합니다.

그후 .env 파일에 SECRET_KEY를 만들고 적절한 보안문자를 적어줍니다.

이후 app/utils/jwt.ts 파일을 만들고 jwt를 만드는 함수, 확인하는 함수를 추가합니다.

app/utils/jwt.ts

import jwt, { JwtPayload } from "jsonwebtoken";

interface SignOption {
  expiresIn?: string | number;
}

const DEFAULT_SIGN_OPTION: SignOption = {
  expiresIn: "1h",
};

export function signJwtAccessToken(payload: JwtPayload, options: SignOption = DEFAULT_SIGN_OPTION) {
  const secret_key = process.env.SECRET_KEY;
  const token = jwt.sign(payload, secret_key!, options);
  return token;
}

export function verifyJwt(token: string) {
  try {
    const secret_key = process.env.SECRET_KEY;
    const decoded = jwt.verify(token, secret_key!);
    return decoded as JwtPayload;
  } catch (error) {
    console.log(error);
    return null;
  }
}

자 그럼 이렇게 만들어진 jwt함수들을 app/api/login에 이용해 로그인 시 적용하겠습니다.

만약 process.env.SECRET_KEY와 관련해 오류가 생긴하다면 `${process.env.SECRET_KEY}`로 변경 후에 시도 합니다.

app/api/login/route.ts

import { signJwtAccessToken } from "@/app/utils/jwt";
import { prisma } from "@/app/utils/prisma";
import bcrypt from "bcryptjs";

interface RequestBody {
  username: string;
  password: string;
}

export async function POST(request: Request) {
  const body: RequestBody = await request.json();

  const user = await prisma.user.findFirst({
    where: {
      email: body.username,
    },
  });

  if (user && (await bcrypt.compare(body.password, user.password))) {
    const { password, ...userWithoutPass } = user;

    // 추가된 부분
    const accessToken = signJwtAccessToken(userWithoutPass);
    const result = {
      ...userWithoutPass,
      accessToken,
    };

    return new Response(JSON.stringify(result));
  } else return new Response(JSON.stringify(null));
}

자 이제 accessToken을 만들어서 이를 반환할 수 있습니다.

다음으로 app/api/auth/[...nextauth]/route.ts에서 인증과정을 변경합니다.

app/api/auth/[...nextauth]/route.ts

const handler = NextAuth({
  providers: [
    CredentialsProvider({
    //...
}],
  // 추가된 부분
  callbacks: {
    async jwt({ token, user }) {
      return { ...token, ...user };
    },

    async session({ session, token }) {
      session.user = token as any;
      return session;
    },
  },
});

위 코드처럼 providers 아래에 callbacks를 만들어 jwt과정을 만듭니다.
그리고 이렇게 만들어진 accessToken을 포함하는 user정보를 타입으로 만듭니다.

types/next-auth.d.ts

import NextAuth from "next-auth";

declare module "next-auth" {
  interface Session {
    user: {
      id: number;
      name: string;
      email: string;
      accessToken: string;
    };
  }
}

자 그럼 이제 api를 보호해 보겠습니다.

api/user/[id]/route.ts

import { verifyJwt } from "@/app/utils/jwt";
import { prisma } from "@/app/utils/prisma";

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  // 추가된 부분
  const accessToken = request.headers.get("authorization");
  if (!accessToken || !verifyJwt(accessToken)) {
    return new Response(JSON.stringify({ error: "No Authorization" }), {
      status: 401,
    });
  }

  console.log(params);

  const id = params.id;

  const userPosts = await prisma.post.findMany({
    where: {
      authorId: id,
    },
    include: {
      author: {
        select: {
          email: true,
          name: true,
        },
      },
    },
  });
  return new Response(JSON.stringify(userPosts));
}

위의 코드로 인해서 헤더에서 authorization 가져오고 올바른 토큰일 때 정보를 반환합니다.

물론 이렇게 api자체를 막을 수 있지만 미들웨어를 이용하여 페이지 자체를 막을 수 있습니다.

먼저 src폴더 혹은 app에 middleware.ts 파일을 만듭니다.

middleware.ts

export { default } from 'next-auth/middleware'

export const config = {
  matcher: ['/userposts/:path*'],
}

그리고 위처럼 막고 싶은 페이지를 막으면 가능합니다.

막을 임시 페이지를 만들어 보겠습니다.
app/userposts/page.tsx

import React from "react";

function UserPosts() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      UserPosts
    </main>
  );
}

export default UserPosts;

그 후 로그인과 비로그인으로 나누어 접속을 시도하면 확인할 수 있습니다.

자 그럼 핵심 로그인 기능은 완료입니다. 그렇다면 로그인 페이지를 적절하게 커스텀해서 만들어 보겠습니다.

app/api/auth/[...nextauth]/route.ts

//...
  pages: {
    signIn: "/signin",
  },
// 여기가 추가된 부분

});

export { handler as GET, handler as POST };
//...

마지막 부분에 pages를 추가합니다.
쉽게 signIn경로는 /signin이라고 선언하는 것입니다.

그후 해당 페이지를 만들어서 커스텀해줍니다.
app/signin/page.tsx

'use client'
import React, { useRef } from 'react'
import { signIn } from 'next-auth/react'

function Login() {
  const emailRef = useRef(null)
  const passwordRef = useRef(null)

// 수정된 부분
  const handleSubmit = async () => {
    // console.log(emailRef.current)
    // console.log(passwordRef.current)

    const result = await signIn("credentials", {
      username: emailRef.current,
      password: passwordRef.current,
      redirect: true,
      callbackUrl: "/",
    });
  }

  return (
    <main className='flex min-h-screen flex-col items-center space-y-10 p-24'>
      <h1 className='text-4xl font-semibold'>Login</h1>
      <div>
        <div>
          <label
            htmlFor='email'
            className='block text-sm text-gray-800 dark:text-gray-200'
          >
            Email
          </label>

          <div className='mt-1'>
            <input
              ref={emailRef}
              onChange={(e: any) => {
                emailRef.current = e.target.value
              }}
              id='email'
              name='email'
              type='email'
              required
              autoFocus={true}
              className='mt-2 block w-full rounded-md border bg-white px-4 py-2 text-gray-700 focus:border-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-40 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:focus:border-blue-300'
            />
          </div>
        </div>

        <div className='mt-4'>
          <label
            htmlFor='password'
            className='block text-sm text-gray-800 dark:text-gray-200'
          >
            Password
          </label>
          <div className='mt-1'>
            <input
              type='password'
              id='password'
              name='password'
              ref={passwordRef}
              onChange={(e: any) => (passwordRef.current = e.target.value)}
              className='mt-2 block w-full rounded-md border bg-white px-4 py-2 text-gray-700 focus:border-blue-400 focus:outline-none focus:ring focus:ring-blue-300 focus:ring-opacity-40 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:focus:border-blue-300'
            />
          </div>
        </div>

        <div className='mt-6'>
          <button
            onClick={handleSubmit}
            className='w-full transform rounded-md bg-gray-700 px-4 py-2 tracking-wide text-white transition-colors duration-200 hover:bg-gray-600 focus:bg-gray-600 focus:outline-none'
          >
            Log In
          </button>
        </div>
      </div>
    </main>
  )
}

export default Login

그러면 새로운 모습으로 페이지가 변경된것을 알 수 있습니다. 중요한 부분은 위처럼 signIn함수를 통해서 적절하게 로직을 실행해야합니다.

더 자세히 알고싶다면 링크를 참조해주세요!

profile
이유를 생각하는 개발자

0개의 댓글