[Next.JS] 사용자 인증 및 401을 위한 middleware 설정

jm4293·2024년 11월 5일
0

Next.JS 처음입니다. 찾아보며 기능적으로는 동작하지만 이러한 방법이 효율적인 방법인지는 더욱 찾아봐야합니다

로그인 유지 및 JWT의 rest api status 401의 대응

리액트 프로젝트만 해왔으므로 axios에서 401에 대해 대응만 해주면 가능하다고 생각했지만 결코 아니다.
Next.JS에서는 서버 컴포넌트로 동작해야하며 공식문서를 보면 fetch로 사용하는 코드가 보여 fetch로 진행하기로 하였다.

결국 두 가지의 방식으로 진행

  1. 클라이언트
    • axios
    • react-query
  2. 서버 컴포넌트
    • fetch
  • Next.JS의 서버 컴포넌트도 결국 서버이기 때문에 http only cookie를 읽거나 수정 삭제가 가능하기에 이러한 판단을 하게 되었다.

1. 일단 서버(Nest.JS)의 로그인 성공 코드를 보며

  async signIn(body: AuthSignInRequestDto, res: Response) {
    const { email, password } = body;

    const user = await this.userRepository.findUserByEmail(email);

    if (!user) {
      throw AuthResponseDto.Fail('일치하는 사용자가 없습니다.');
    }

    const isMatch = await bcrypt.compare(password, user.password);

    if (!isMatch) {
      throw AuthResponseDto.Fail('비밀번호가 일치하지 않습니다.');
    }

    const accessToken = await this.generateToken({ user_seq: user.seq, email: user.email, name: user.name }, '1h');
    const refreshToken = await this.generateToken({ user_seq: user.seq, email: user.email, name: user.name }, '5d');

    res.cookie('accessToken', accessToken, {
      httpOnly: true,
      // sameSite: 'strict',
      maxAge: 60 * 1000 * 60,
    });

    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      // sameSite: 'strict',
      maxAge: 60 * 1000 * 60 * 24,
    });

    // res.setHeader('Authorization', `Bearer ${accessToken}`);

    const responseData = { email: user.email, name: user.name };

    return res.send(AuthResponseDto.Success('로그인 성공', responseData));
  }
  1. 아직 https 설정을 안하였기 때문에 strict 설정은 안했으며
  2. JWT의 accessToken는 1시간, JWT의 refreshToken는 5일의 시간을 설정
  3. accessToken을 가진 accessToken cookie도 1시간
  4. refreshToken을 가진 refreshToken cookie은 1일을 설정
  5. 이러한 설정이면 로그인 성공하고 1시간이 지나면 서버는 401을 response를 보낼 것이며 이때 refreshToken을 이용하여 accessToken을 재발급 받아야 하며, 1일(하루)가 지나면 refreshToken도 만료가 되기때문에 refreshToken cookie가 있어도 로그인 인증이 불가능 할 것이다.
  • cookie는 항상 rest api를 서버로 요청할 때마다 자동적으로 request header에 cookie를 포함하여 서버로 전송한다는 개념을 갖고있어야 한다.
  • 서버에서 모든 설정을 완료된 cookie를 response로 보내주기 때문에 클라이언트에서는 따로 작업을 할 필요가 없다.

2. 로그인 완료가 되면 게시판으로 이동

클라이언트의 로그인 성공 코드

import { useRouter } from 'next/navigation';

export default function useAuthMutation() {
  const router = useRouter();

  const onSignInMutation = useMutation<MutationResponse<IAuthSignInRes>, MutationError, IAuthSignInReq>({
    mutationFn: (data) => AuthApi.signIn(data),
    onSuccess: () => {
      router.push('/board');
    },
  });
  
  ...
}
'use client'(클라이언트)의 버튼을 이용하여 axios를 호출하기 때문에 react-query를 이용하여 성공 시 router.push('/board')로 이동

3. 이제 중요한 middleware가 나타난다.

Nest.JS에서는 서버 컴포넌트를 요청 받게되면 해당하는 컴포넌트를 서버에서 즉시 만들던가, 만들어진 컴포넌트를 클라이언트로 전달하여 아무것도없는 html이 아닌 무언가가 작성되어있는 html을 전달한다.

사실 html을 전달하는 것이 아닌 js를 전달하는 것이지만 해당 내용은 다음에...

클라이언트 -> request -> middleware -> FE Server(Nest.JS) -> response -> 클라이언트

이 구조를 이해하는데 엄청 많은 시간을 소비하게되었다...
// 미들웨어의 역할
// 미들웨어는 클라이언트의 요청을 가로채고, 필요에 따라 요청이나 응답을 수정하거나 리다이렉션 할 수 있는 기능을 제공합니다.
// 미들웨어는 주로 인증이나 권한 검사, 요청 기록, 리다이렉트 등의 작업에 사용됩니다.

import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { verifyToken } from '@/utils/verify';

export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  if (pathname === '/') {
    return NextResponse.redirect(new URL('/auth', req.url));
  }

  const renewNextResponse = NextResponse.next();
  const cookieStore = cookies();

  const accessToken = cookieStore.get('accessToken')?.value || '';
  const refreshToken = cookieStore.get('refreshToken')?.value || '';

  if (!refreshToken) {
    return NextResponse.redirect(new URL('/auth?expired=true', req.url));
  }

  const verifyAccessToken = await verifyToken(accessToken);
  const verifyRefreshToken = await verifyToken(refreshToken);

  if (!verifyRefreshToken) {
    return NextResponse.redirect(new URL('/auth?expired=true', req.url));
  }

  if (!verifyAccessToken && verifyRefreshToken) {
    try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}:${process.env.NEXT_PUBLIC_API_PORT}/${process.env.NEXT_PUBLIC_GLOBAL_PREFIX}/auth/refresh-token`,
        {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ refreshToken }),
        },
      );

      const responseData = await response.json();

      const renewAccessToken = responseData.data.accessToken;

      renewNextResponse.cookies.set('accessToken', renewAccessToken, { httpOnly: true, maxAge: 60 * 60 });
    } catch (error) {
      console.error('refresh token error', error);
    }
  }

  return renewNextResponse;
}

export const config = {
  matcher: ['/', '/board/:path*'],
};
클라이언트가 FE server로 요청하면 middleware을 거치게 되며 이쪽에서 해당하는 요청이 로그인된(인증이 완료된) 사용자인지 또는 만료된 사용자 요청인지 검증을 하며 검증이 완료된 요청이면 통과시켜준다.
FE server 이기 때문에 http only cookie이지만 해당하는 token의 값을 불러올 수 있으며 불러온 token의 값을 JWT을 검증하는 라이브러리를 통해 검증을 하며 만료된 token이라면 refreshToken을 이용하여 accessToken을 재발급 받는다.

4. jose의 라이브러리를 통해 JWT인증 코드

import { decodeJwt, jwtVerify } from 'jose';

export async function verifyToken(token: string) {
  try {
    const secretKey = new TextEncoder().encode(process.env.NEXT_PUBLIC_JWT_SECRET_KEY);

    if (!secretKey) {
      console.error('JWT_SECRET_KEY is not defined.');
      return null;
    }

    const { payload } = await jwtVerify(token, secretKey);
    return payload;
  } catch (error) {
    console.error('JWT verification error:', error);
    return null;
  }
}

export function decodeToken(token: string) {
  try {
    return decodeJwt(token);
  } catch (error) {
    console.error('JWT decoding error:', error);
    return null;
  }
}

5. renewAccessToken 설정

      renewNextResponse.cookies.set('accessToken', renewAccessToken, { httpOnly: true, maxAge: 60 * 60 });
이 쪽 코드가 애매한데 서버(Nest.JS)와 동일하게 http only와 maxAge를 1시간으로 적용하였는데 서버(Nest.JS)와 FE server(Next.JS)를 각각 cookie를 설정하는게 맞는건지 궁금하다.

6. middleware 인증 통과 이후 fetch 요청 코드

import { cookies } from 'next/headers';

export default async function BoardListPage({ searchParams }: IProps) {
  const accessToken = cookies().get('accessToken');
  const currentPage = searchParams.page ? parseInt(searchParams.page, 10) : 1;

  let boardList: IBoardListRes[] = [];
  let totalCount = 0;

  try {
    const response = await FetchConfig.get<{ list: IBoardListRes[]; totalCount: number }>({
      url: 'board/board-list',
      queryString: { page: currentPage, count: BOARD_ITEM_COUNT },
      headers: { Cookie: `accessToken=${accessToken?.value}` },
    });

    const { data, result, message } = response;

    boardList = data.list;
    totalCount = data.totalCount;
  } catch (error) {
    console.error('API 호출 중 에러 발생', error);
  }

  return (
    ...
  );
}
서버 컴포넌트에서 fetch를 사용하기 전에 cookie().get('accessToken')을 가지고와 fetch의 header에 적용하여 api요청을 하게된다.

Git hub 코드

profile
무언가 만드는 것을 좋아합니다

0개의 댓글

관련 채용 정보