본문 바로가기
Web

[인증] JWT로 로그인 인증 2. Access Token과 Refresh Token 사용하기 (Koa, next-auth)

by r4bb1t 2023. 6. 9.
반응형

3년 전 글에서 추가적으로, 보안 문제 개선을 위해 Access Token과 Refresh Token을 사용하는 방법을 백엔드(Koa)와 프론트엔드(Next, next-auth)에서 다루어보려고 합니다.

쿠키 몬스터. 여전히 귀엽습니다.

우선 Access Token과 Refresh Token의 개념은 다음과 같습니다.

  • Access Token: 인증이 필요한 요청마다, 요청에 포함하여 인증을 위해 사용하는 토큰
  • Refresh Token: Access Token이 만료되면, 새로운 Access Token 발급을 위해 사용하는 토큰

토큰을 두 가지로 나누어 사용하는 이유는, 이미 널리 알려져 있지만 간단히 이야기하면 다음과 같습니다.

  1. Access Token이 탈취될 경우를 대비해 Access Token의 유효기간을 짧게 설정합니다. 이러면 Access Token이 탈취되더라도 해당 토큰을 이용한 요청이 가능한 시간이 얼마 안 됩니다. (보통 30분~1시간 정도)
  2. 그렇다고 로그인을 30분마다 하는 것은 매우 귀찮습니다. 그걸 위해 Refresh Token을 사용하는데, 이 Refresh Token은 상대적으로 긴 유효기간(보통 1주~2주)을 가지고 있습니다. 서버에서 Access Token이 만료되었다고 클라이언트에게 알려주면, 클라이언트는 새로운 Access Token을 발급받기 위해 Refresh Token을 포함한 요청을 보내 새 Access Token을 발급받습니다.

이 Access Token과 Refresh Token을 어디에 저장하고, 어떻게 보낼 것이냐에 대해서는 많은 의견이 있습니다. localstorage, httponly cookie, local variable 등의 방법이 있는데요. 저 역시 많은 고민을 해보았으나, HTTPS 프로토콜을 사용한다고 가정하고 웹 검색을 통해 Access Token은 로컬 변수(local variable)에, Refresh Token은 httponly cookie에 저장하는 방안으로 코드를 짜 보았습니다.

이 경우에 다음과 같은 시나리오를 생각해볼 수 있습니다.

  • 로그인 시, 서버는 Access Token은 Response의 Authorization Header로 반환하고, Refresh Token은 Authorization Header로 저장합니다. 이 때 클라이언트는 받은 Access Token을 로컬 변수에 저장합니다.
  • 인증이 필요한 요청의 경우 클라이언트는 Access Token을 Authorization Header에 담아 서버로 요청합니다. 서버에서는 Access Token을 검증(verify)합니다.
    • Access Token의 유효기간이 만료되었을 경우(TokenExpiredError)에는 클라이언트에 새로운 Access Token을 발급받으라는 응답을 보냅니다. 클라이언트는 재발급을 요청합니다.
    • Access Token의 서명이 잘못되어있는 등의 이유로 유효하지 않을 경우(InvalidTokenError)에는 에러를 내보냅니다. 클라이언트는 에러가 나면 로그아웃합니다.
  • reissue 요청이 올 경우 Refresh Token으로 Access Token을 재발급합니다. 이 때 Refresh Token이 유효하지 않거나 만료되었을 경우 Access Token을 재발급하지 않고 에러를 내보냅니다. 클라이언트는 에러가 나면 로그아웃합니다.

추가적으로, 서버가 Refresh Token을 저장하고 있기도 합니다. 이 경우 Redis 등의 인메모리 데이터 구조 저장소를 사용하여 효율성을 도모합니다. 이 경우, 새 Access Token을 발급받는 요청을 받았을 때 저장된 Refresh Token과 대조하여 유효할 경우에만 발급하므로, 현재 로그인한 유저를 강제로 로그아웃 시키는 등의 관리가 가능합니다.

클라이언트 측 (NextAuth)

NextAuth의 signIn, jwt callback 등에 로직을 넣으려고 하였으나, server-side이기 때문에 쿠키 설정 등에 문제가 있었고, 제가 생각했던 로그인 로직과, NextAuth의 의의가 다르다고 생각하여 우선 소셜 로그인이 성공할 경우 클라이언트의 소셜 로그인 라우터로 라우팅하여 그곳에서 로직을 처리하기로 하였습니다. NextAuth는 기본적으로 소셜 로그인을 한 이후, 그 정보를 가지고 뭔가를 하기에 유리합니다. 그러나 저는 소셜 로그인에 성공하였을 때 그 정보를 기반으로 서버의 User를 가져와서 처리해야 하는 로직들이 많았고 소셜 로그인은 첫 로그인 이후에는 별로 큰 의미가 없다고 생각하여 그렇게 하였습니다.

import NextAuth from "next-auth";
import { User } from "./user";
import { JWT } from "next-auth/jwt";

declare module "next-auth" {
  interface Session {
    user: User;
  }
  interface User {
    id: string;
    name?: string;
    image?: string;
    email?: string;
  }
}

우선 세션에서 기본적인 정보를 담고있기 위해 next-auth.d.ts 파일을 만들어 nextauth의 session interface를 확장해줍니다.

import NextAuth, { Session } from "next-auth";
import KakaoProvider from "next-auth/providers/kakao";

export default NextAuth({
  secret: process.env.JWT_SECRET,
  session: {
    strategy: "jwt",
  },
  providers: [
    KakaoProvider({
      clientId: process.env.KAKAO_ID!,
      clientSecret: process.env.KAKAO_SECRET!,
    }),
  ],
  callbacks: {
    signIn: async ({ user, account, profile }) => {
      if (account) {
        return true;
      }
      return "/auth";
    },

    jwt: async ({ token, trigger, user, session }) => {
      if (user) {
        token.id = user.id;
        token.name = user.name;
        token.email = user.email;
        token.picture = user.image;
      }
      if (trigger === "update" && session?.accessToken) {
        token["accessToken"] = session.accessToken;
      }
      return token;
    },

    session: async ({ session, token }) => {
      session.user = {
        id: token.id as string,
        name: token.name as string,
        email: token.email as string,
        image: token.picture as string,
      };
      session.accessToken = token.accessToken as string;

      return session;
    },
  },
});

콜백들을 이용해서 우선 provider에서 받아온 정보들을 session으로 넘겨줍니다.

<button
  onClick={() => {
    signIn("kakao", { callbackUrl: "/auth/sns?provider=kakao" });
  }}
>
  카카오로 가입 / 로그인
</button>

그리고 카카오로 가입 / 로그인 버튼을 클릭하면 nextauth의 signIn 함수를 불러오고, callbackUrl로 카카오 로그인이 성공할 경우 로직을 처리할 곳으로 리다이렉트 시켜줍니다.

const snsSignIn = useCallback(async () => {
  if (!router.query.provider || !session) {
    signOut();
    router.push("/auth?type=invalidaccess");
    return;
  }
  const result = await axios.post(
    `${process.env.NEXT_PUBLIC_API_HOST}/api/auth/sns`,
    JSON.stringify({
      id: session.user.id,
      username: session.user.name,
      email: session.user.email,
      image: session.user.image,
      provider: router.query.provider,
    }),
    {
      withCredentials: true,
    }
  );
  if (result.status === 200) {
    update({ accessToken: result.headers["authorization"] });
    router.push("/explore");
  } else {
    signOut();
    router.push("/auth?type=snserror");
  }
}, [router, status]);

로그인에 성공할 경우 update 함수로 session을 업데이트 해주고, 에러가 날 경우 nextauth의 signOut를 호출하여 세션을 지워줍니다.

const getMe = useCallback(async () => {
  try {
    const data = (
      await axios.get(`${process.env.NEXT_PUBLIC_API_HOST}/api/user/me`, {
        withCredentials: true,
        headers: {
          Authorization: `${session?.accessToken}`,
        },
      })
    ).data as MeResponse;
    if (data.user) setMe(data.user);
  } catch (e) {
    if (e instanceof AxiosError) {
      if (e.response?.data === "AccessTokenExpiredError") {
        await reissue();
        return;
      }
    }
  }
}, [reissue, session?.accessToken]);

인증 정보를 이용해 본인 계정 정보를 불러오는 로직입니다. 서버에서 토큰이 만료되었다고 알려주면 토큰을 재발급하고, 재발급할 경우 session에 update되기 때문에 다시 해당 콜백이 불러와집니다.

const reissue = async () => {
  if (!session) {
    router.push("/auth");
    return false;
  }
  const result = await axios.get(
    `${process.env.NEXT_PUBLIC_API_HOST}/api/auth/reissue`,
    {
      withCredentials: true,
    }
  );
  if (result.status === 200) {
    update({ accessToken: result.headers["authorization"] });
    return true;
  } else {
    signOut({ callbackUrl: "/auth" });
    return false;
  }
};

AccessToken을 재발급하는 부분의 코드입니다.

서버 측 (Koa)

우선 인증이 필요한 요청에서, Access Token을 검증하고 만료되었을 경우 에러를 throw하는 미들웨어를 작성합니다. 이 미들웨어는 인증이 필요한 라우트들에 달아줍니다.

import { Context } from "koa";
import { getToken, verifyToken } from "../utils/jwt";
import { AppDataSource } from "../data-source";
import { User } from "../entities/user.entity";
import { JsonWebTokenError, TokenExpiredError } from "jsonwebtoken";
import { CustomPayload } from "../types/auth";

export const reissue = async (
  ctx: Context,
  next: (ctx: Context) => Promise<any>
) => {
  try {
    const accessToken = getToken(ctx.header.authorization);

    const info = verifyToken(accessToken) as CustomPayload;

    const repo = AppDataSource.getRepository(User);
    const user = await repo.findOneBy({ id: info.id });
    if (!user) {
      throw new JsonWebTokenError("Invalid User");
    }

    ctx.state.user = user;
    next(ctx);
  } catch (e) {
    if (e instanceof TokenExpiredError) {
      ctx.status = 401;
      ctx.body = "AccessTokenExpiredError";
    } else if (e instanceof JsonWebTokenError) {
      ctx.status = 401;
    } else {
      ctx.status = 500;
    }
    return;
  }
};

한 번 유저를 가져왔으니, ctx.state.user에 그 user값을 넣어 다음 미들웨어에서 추가적으로 DB에 검색하지 않아도 될 수 있도록 짜보았습니다.

export const sns = async (ctx: Context) => {
  try {
    const data = JSON.parse(ctx.request.rawBody) as Auth;
    const userId = `${data.provider}-${data.id}`;
    const repo = AppDataSource.getRepository(User);
    const user = await repo.findOneBy({
      id: userId,
    });

    let info;

    if (user) {
      info = user;
    } else {
      info = {
        id: `${data.provider}-${data.id}`,
        username: data.username || getRandomName(),
        email: data.email || null,
        image: data.image || null,
        provider: data.provider,
      };
      const newUser = repo.create(info);
      await repo.insert(newUser);
    }

    const accessToken = await generateToken(info, "access");
    const refreshToken = await generateToken(info, "refresh");

    ctx.status = 200;
    ctx.set("Authorization", `Bearer ${accessToken}`);
    ctx.cookies.set("refreshToken", refreshToken, {
      path: "/",
    });
  } catch (e) {
    ctx.status = 500;
    console.log(e);
    if (e instanceof Error) ctx.body = e.name;
  }
};

Nextauth를 이용해 받아온 id, username, email, image, provider로 유저를 생성하거나, 로그인합니다. 추후 provider가 추가될 때를 대비해서 아이디는 provider와 provider에서 제공한 아이디를 같이 붙여서 사용하였습니다.

export const reissue = async (ctx: Context) => {
  try {
    const refreshToken = ctx.cookies.get("refreshToken");
    const info = verifyToken(refreshToken);

    const repo = AppDataSource.getRepository(User);
    const user = await repo.findOneBy({
      id: info.id,
    });
    if (!user) {
      ctx.status = 401;
      return;
    }

    let newInfo = {
      id: user.id,
      username: user.username,
      email: user.email,
      image: user.image,
      provider: user.provider,
    };

    const accessToken = await generateToken(newInfo, "access");
    const newRefreshToken = await generateToken(newInfo, "refresh");

    ctx.set("Authorization", `Bearer ${accessToken}`);
    ctx.status = 200;
    ctx.cookies.set("refreshToken", newRefreshToken, {
      path: "/",
    });
  } catch (e) {
    console.log(e);
    if (e instanceof JsonWebTokenError) {
      ctx.status = 401;
      ctx.body = JSON.stringify({
        error: "InvalidRefreshTokenError",
      });
    }
    return;
  }
};

재발급 라우트 코드입니다. 새 Access Token을 발급할 때, Refresh Token도 새로 업데이트해주었습니다.

생각보다 짜는 데 어렵고 우여곡절이 많아서, 한번 정리해보면 좋을 것 같아 작성했습니다. 잘못된 정보가 있다면 댓글로 알려주세요! 감사합니다.

 

 

반응형

댓글