Gila - nextauth 적용(app router, middleware redirection)

박상준

2024년 10월 15일

1

Next.js
Gila
Auth.js

nextauth를 적용을 왜했을까..?

저번 포스팅에서 적었지만 이미 서버액션으로 발급된 토큰을 쿠키에 저장하는 과정을 거쳤다. 지금 상태에서도 토큰을 저장하고 잘 가져올수 있지만 약간 아쉬운 부분이 있었다.

유지보수성

우선 가장 아쉬웠던 부분은 여러 로직으로 분리가 되어있다는 것이다.

const login = async (email: string, password: string): Promise<ActionType<AuthResponse>> => {
  try {
    const response = await api.post('/auth/login', { email, password });
    const { data } = response;
    const token = data.accessToken;

    cookies().set('GilaToken', token);
    return { success: true, message: '성공' };

가장 먼저 로그인했을때 직접 쿠키에 토큰을 세팅해주는 부분이다.

const getAccessToken = async (): Promise<string | null> => {
  const cookieStore = cookies();
  const accessToken = cookieStore.get('GilaToken');

  if (!accessToken) return null;

  return accessToken.value;
};

그리고 직접 쿠키에 저장된 토큰을 가져오는 과정이다.

try {
  api.interceptors.request.use(
    async (config) => {
      const token = await getAccessToken();
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
      }
      return config;
      ...

이렇게 토큰을 가져와서 axios interceptor로 설정하는 코드이다. 지금 간단하게 보아도 여러 곳에서 토큰을 세팅하고 가져오는 과정을 거치고 있다.

보안

그리고 보안부분에서도 나는 nextauth를 선호한다. 서버액션을 통해 직접 쿠키를 세팅하면 토큰이 그대로 쿠키에 저장된다. 하지만 nextauth를 사용하면 로그인성공시에 나온 토큰을 nextauth서버에 저장하고 저장한 세션에 대한 토큰을 자체적으로 발행해서 그 토큰을 쿠키에 저장한다. 포스트 이미지 이렇게 직접 쿠키에 토큰을 저장할 경우 토큰 값이 노출되는 것을 확인할 수 있다.

이러한 이유로 nextauth를 사용하면 좋지 않을까 싶은 생각에 마이그레이션을 진행해보기로 했다.

적용시키기

authOption 설정

전에 사용했던 방식은 page router에서 사용하는 방식이라 그대로 적용할 수가 없다. 그래서 app router방식으로 변경시켜보겠다.

우선 /app/api/auth/[...nextauth]의 경로에 route.ts로 파일을 만들어 준다. 이 파일이 nextauth에서 사용할 파일이다.

import NextAuth from 'next-auth';
...
const handler = NextAuth(authOption);

export { handler as GET, handler as POST };

그리고 페이지 라우터에서는 바로 authOption을 설정했지만 앱라우터에서는 위의 코드처럼 만들어 줘야한다. 그래야 정상적으로 인증 기능을 사용할 수 있다. 이제 우리가 사용한 authOption을 그대로 사용하면 된다.

export const authOption = {
  providers: [
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        email: { label: 'email', type: 'email' },
        password: { label: 'password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials) return null;
        try {
          const { email, password } = credentials;
          const response = await api.post('/auth/login', { email, password });
          return response?.data;
        } catch (error) {
          // 해당 구간에서 에러 메세지를 받아서 다시 에러를 던져줘야 form에서 에러메세지 처리 가능
          if (axios.isAxiosError(error)) {
            throw new Error(error.response?.data.message);
          }
          throw new Error('로그인 실패');
        }
      },
    }),
  ],

  secret: process.env.NEXTAUTH_SECRET,

  session: {
    maxAge: 60 * 60,
  },

  pages: {
    signIn: '/login',
  },

  callbacks: {
    async jwt({ token, user }: { token: JWT; user: User }) {
      const copyToken = { ...token };
      if (user) {
        copyToken.accessToken = user?.accessToken;
      }
      return copyToken;
    },
    async session({ session, token }: { session: Session; token: JWT }) {
      const copySession = { ...session };
      copySession.accessToken = token.accessToken;
      return copySession;
    },
  },
};

간단하게 설명하면 로그인했을때 유저정보와 토큰을 받는데 jwt에 accessToken 정보를 추가해주고 이 토큰을 사용할 수 있도록 session에도 accessToken을 추가해줬다. 그리고 시간은 우선 1시간으로 설정해줬다.

  async function onSubmit(values: z.infer<typeof formSchema>) {
    const { email, password } = values;
    // nextauth 메소드인 signIn으로 로그인, 해당 형식으로 작성해주시면 됩니다.
    const result = await signIn('credentials', {
      email,
      password,
      redirect: false, // 로그인했을때 페이지 리다이렉션 제거
    });
    if (result?.ok) {
      router.replace('/');
    } else if (result?.error) {
      // next-auth에서 던져준 에러를 받아서 사용함
      toast.error(result?.error);
    }
  }

그리고 nextauth의 singIn으로 위에서 작성한 옵션으로 로그인을 시켜준다. 로그인 결과에 따라 페이지를 이동시키거나 토스트를 발생시켜준다. 이벤트가 발생할때마다 페이지가 리다이렉션되는 것을 막기위해 redirect: false를 설정해줬다.

fetcher 설정

fetcher라고 거창한 기능이 아니라 axios interceptor를 사용해 헤더에 토큰을 심어주는 과정이다.

try {
  api.interceptors.request.use(
    async (config) => {
      // nextauth 서버 세션에 저장된 토큰값 가져오기
      const token = await getServerSession(authOption);
      if (token) {
        // eslint-disable-next-line no-param-reassign
        config.headers.Authorization = `Bearer ${token.accessToken}`;
      }
      return config;
    },
    (error) => {
      return Promise.reject(error);
    },
  );
} catch (error) {
  /* empty */
}

getServerSessioin을 사용하면 로그인했을때 저장한 accessToken을 사용할수 있게된다. 그래서 nextauth 서버에 token이 있을 경우 api의 헤더에 토큰값을 저장하는 것이다.

middleware 설정

미들웨어를 설정해줘야한다. 그냥 평범하게 nextauth를 사용해서 로그인 여부를 판별해 인증이 필요한 페이지에서 리다이렉션 시켜주는 것은 간단하다.

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

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

nextauth 기본 미들웨어를 사용하면 간단하게 구현이 된다. 하지만 우리는 추가적으로 로그인시에 /login, /register페이지에 접근하지 못하도록 리다이렉션을 추가하기로 했다. 그래서 기본 미들웨어가 아닌 커스텀이 필요했다.

const protectiedPageList = ['/mypage'];
const publicPageList = ['/login', '/register'];
...
export const config = {
  mathcher: [...protectiedPageList, ...publicPageList],
};

우선 두가지 경우의 경로를 감지해서 동작할 것이다. 로그인이 필요한 페이지는 /mypage밖에 없고 그리고 다시 접근을 막아햐하는 페이지는 /login, /register밖에 없다. 그래서 두개의 경로를 구분해서 넣어뒀다.

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

  const isProtectiedRoutes = protectiedPageList.includes(pathname);
  const ispublicRoutes = publicPageList.includes(pathname);

  if (isProtectiedRoutes) return protectiedAuth(req, !!token);
  if (ispublicRoutes) return publicAuth(req, !!token);
}

이제 미들웨어에서 설정을 해준다. getToken서버에서 jwt토큰을 가져온다. 이 토큰이 있다는 것은 인증이 되었다는 것이다. 그리고 페이지 경로를 받아서 해당 경로에 미들웨어를 적용시킬지 확인한다. 만약 /login페이지로 접근하면 ispublicRoutes의 값이 true가 되면서 publicAuth가 실행되게 된다.

const protectiedAuth = async (req: NextRequest, token: boolean) => {
  const url = req.nextUrl.clone();

  if (!token) {
    url.pathname = '/login';

    return NextResponse.redirect(url);
  }
};

const FALLBACK_URL = '/mypage';

const publicAuth = async (req: NextRequest, token: boolean) => {
  const url = req.nextUrl.clone();

  if (token) {
    url.pathname = FALLBACK_URL;

    return NextResponse.redirect(url);
  }
};

public경로와 protectied경로를 확인해서 실행할 미들웨어 함수이다. 우선 protectied경로로 접근했을때 토큰이 존재하지 않으면 /login으로 리다이렉션 시켜준다. 반대로 public경로로 접근했을때 토큰이 존재하면 /mypage로 이동시켜준다.

여기에서 callbackUrl도 설정이 가능하지만 위에서 얘기했듯이 로그인시에 에러처리를 따로 해주기 위해 리다이렉션 옵션은 꺼뒀다. 그래서 미들웨어에서 callbackUrl을 설정할 필요가 없다.

결론

이렇게 적용해서 이전보다 개선된 점이 있었는가? 사실 잘 모르겠다. 나야 전에 써봤으니까 그나마 어떤 방식으로 동작하고 어떤 값들이 필요한지 알고 있지만 처음 해보는 사람이 하기에는 절대 개선됬다고 보기 어렵다. 가장 향상되었다고 느껴지는 것은 보안부분이다. 포스트 이미지 전에는 직접 토큰값을 쿠키에 저장하는 방식이였지만 지금은 nextauth 서버를 통해 그 데이터를 받아오고 있으며 브라우저쿠키에 저장된 값은 nextauth서버에 저장된 세션에 대한 토큰이다. 그래서 클라이언트에서 토큰값을 확인할 방법은 없다.

아예 확인이 안되는 것은 아니다. 만약 useSession과 같은 클라이언트에서 세션 값을 사용하도록 설정하면 브라우저 네트워크 리스폰스에서 확인이 가능하다. 하지만 getToken이나 getServerSession으로 서버컴포넌트로 사용하면 확인이 안된다.

그리고 인증과 관련된 로직을 한곳에서 관리할 수 있다는 장점도 생겼다. 하지만 nextauth를 사용하기 위해 nextauth에서 사용할 타입파일도 또 생겼고 인증 코드 자체도 길어졌다. 처음이 어려운 법이니 나중에 익숙해지면 좋지 않을까 싶다.

마무리

이번에도 nextauth를 적용시켜봤다. 전보다 더 많은 상황에서 사용하려고 하다보니 머리아팠지만...결국 구현하긴 했다. 아직도 제대로 사용하고 있는건지, 제대로 이해하고 있는건지 헷갈리지만 일단 지금 사용은 가능하니까 다행이다. 나중에 서비스를 구현하면서 이거 안된다는 얘기가 안들리길 바랄뿐. 만약 나중에 oAuth기능이 추가된다면 nextauth를 사용한 보람이 생길것이다. 지금은 시간 여유가 있으니 이렇게 했지만 앞으로 작업에 속도를 조금더 올려야하지 않을까 싶다.

다음 포스트는 다른 페이지 작업하는 내용이 될것이다. 아직 내가 어떤 역할을 할지 미정이지만 이번처럼 복잡한것도 나쁘지 않다는 생각이다.

개의 댓글