토큰으로 소셜 로그인 연동 및 서비스를 이용하는 Authentication 로직 🔒

Server The SOPT·2022년 7월 22일
1
post-thumbnail

✏️ 작성자: 김소현
📛 작성자의 한마디: 토큰 어렵다 @!

안녕하세요, 솝트 30기 앱잼에서 헬푸미 팀에서 서버를 담당하고 있는 사람입니다. 🐣 헬푸미의 기능 중 하나인 소셜 로그인을 연동하기 위해서 access token과 refresh token을 활용한 인증 방식을 공부하고 적용할 수 있었습니다. 개인적으로 꽤 어려웠던 부분이어서 기록으로 남기고자 합니다.


소셜 로그인으로 서비스 회원가입 하기

서비스를 이용하다 보면 꽤 많은 곳에서 소셜 로그인을 볼 수 있습니다. 로그인 하면 서비스에 회원가입 되거나 로그인 되어 서비스 기능을 사용할 수 있습니다. 그렇다면 이 로직은 어떻게 이뤄지는 것일까요? 소셜 로그인과 서비스가 연동되는 과정을 설명해보겠습니다.

먼저 사용자가 소셜 서비스에 로그인을 하게 되면, 소셜 서비스 쪽에서는 사용자에게 access token을 제공합니다. (여기서 사용자는 access token으로 소셜 서비스를 이용할 수 있습니다.)

소셜 서비스에서 받아 온 access token을 서비스로 보내면 서비스에서는 회원 인증을 거쳐 회원가입 또는 로그인 시킨 후 access tokenrefresh token을 제공합니다. 단, 여기서 서비스가 제공하는 access tokenrefresh token은 소셜 서비스의 토큰과는 다른 것임을 명시해야 합니다. (서비스 이미지는 헬푸미 서비스 로고로 대체하겠습니다.)


카카오 서비스의 access token으로 회원 인증하는 코드를 살펴봅시다.

const kakaoAuth = async (kakaoAccessToken: string) => {
  try {
    const user = await axios({
      method: "get",
      url: "https://kapi.kakao.com/v2/user/me",
      headers: {
        Authorization: `Bearer ${kakaoAccessToken}`,
      },
    });

    const userId = user.data.id;

    if (!userId) return execptionMessage.INVALID_USER;

    if (!user.data.kakao_account) {
      return {
        userId: userId,
        email: null,
      };
    }

    const kakaoUser: SocialUser = {
      userId: userId,
      email: user.data.kakao_account.email,
    };

    return kakaoUser;
  } catch (error) {
    logger.e("KakaoAuth error", error);
    return null;
  }
};

./src/config/auth.ts 파일에서 axios 모듈을 활용하여 해당 url로 카카오 서비스의 access token을 보내서 회원 인증에 성공하면 회원 정보를 받을 수 있습니다. 위 코드에서는 소셜 서비스 회원의 _id 값과 email 값을 가져오고 있습니다. _id 값은 필수적으로 넘어오는 값이지만, email 값은 사용자가 정보 제공 동의 체크 유무에 따라 넘어올 수도, 그렇지 않을 수도 있기 때문에 nullable 처리 해주어야 합니다.


class KakaoAuthStrategy implements SocialAuthStrategy {
  execute(accessToken: string): Promise<any> {
    return auth.kakaoAuth(accessToken);
  }
}

./src/config/services/SocialAuthStrategy.ts 파일에서 kakaoAuth 함수에서 값( {_userId: '...', email: '...'} )을 반환 받아옵니다. 이 구조는 같은 헬푸미 팀의 다른 서버 파트 담당자가 설계하여 리팩토링 코드 하셨습니다. ( 관련 글 보러가기 )


export type SocialPlatform = "kakao" | "naver" | "apple";

const getUser = async (social: SocialPlatform, accessToken: string) => {
  try {
    const user = await authStrategy[social].execute(accessToken);
    return user;
  } catch (error) {
    logger.e(error);
    throw error;
  }
};

./src/services/UserService.ts 파일에서 authStrategy 함수에서 소셜 로그인의 회원 정보를 받아와 다시 반환 합니다.


/**
 * @route POST /auth
 * @desc Authenticate user & Get token
 * @access Private
 */
const getUser = async (req: Request, res: Response) => {
  const social = req.body.social;
  const token = req.body.token;

  if (!social || !token) {
    return res
      .status(sc.UNAUTHORIZED)
      .send(BaseResponse.failure(sc.UNAUTHORIZED, message.NULL_VALUE_TOKEN));
  }
  try {
    const user = await UserService.getUser(social, token);

./src/controllers/UserController.ts 파일에서 request body에서 social(소셜 서비스명) 값과 token(소셜 서비스에서 발급받은 access token) 값을 받아옵니다. (받아 온 값이 없을 경우 401 에러를 반환합니다.) 받아 온 값을 Service로 넘겨서 소셜 서비스의 회원 정보를 받아옵니다.


    const existUser = await UserService.findUserById(
      (user as SocialUser).userId,
      social,
    );
    if (!existUser) {
      const data = createUser(social, user);

      return res
        .status(sc.CREATED)
        .send(
          BaseResponse.success(sc.CREATED, message.SIGN_UP_SUCCESS, await data),
        );
    }

소셜 서비스 회원 정보 중 _id 값을 가지고 서비스의 DB에서의 유저 존재를 체크합니다. 여기서 해당하는 유저가 존재하면 이미 서비스에 회원가입이 되어 있는 유저이고, 그렇지 않으면 신규 유저라는 것이 됩니다.


async function createUser(social: string, user: SocialUser) {
  const refreshToken = jwt.createRefresh();
  const newUser = await UserService.signUpUser(
    social,
    (user as SocialUser).userId,
    (user as SocialUser).email,
    refreshToken,
  );
  const accessToken = jwt.sign(newUser._id, newUser.email);

  return {
    user: newUser,
    accessToken: accessToken,
    refreshToken: refreshToken,
  };
}

유저가 존재하지 않으면, createUser 함수로 social (소셜 서비스명) 값과 user (소셜 서비스 회원 정보) 값을 보내 회원가입을 진행합니다. refresh token을 발급하여 소셜 서비스 및 해당 회원 정보와 함께 Service로 보내 회원가입을 진행한 후 결과 값을 받아옵니다. 그 후 회원의 _id 값과 email 값을 암호화하여 access token을 발급 받아와 유저 정보 및 refresh token 값과 함께 반환하여 회원가입 처리 합니다.


const createRefresh = () => {
  const refreshToken = jwt.sign({}, config.jwtSecret, { expiresIn: "14d" });
  return refreshToken;
};

refresh token./src/modules/jwtHandler.ts 파일에서 위와 같이 암호화 하여 발급한 후 반환합니다. (유효기간은 14일로 지정함.)

const signUpUser = async (
  social: string,
  socialId: string,
  email: string,
  refreshToken: string,
) => {
  try {
    let user;

    user = new User({
		// 서비스에 필요한 유저 정보
      refreshToken: refreshToken,
    });

    await user.save();

    return user;
  } catch (error) {
    logger.e("", error);
    throw error;
  }
};

서비스에 필요한 유저 정보와 refresh token 값을 넣어 새로운 유저를 생성합니다. (회원가입 완료 !~!)

const sign = (userId: Types.ObjectId, email: string) => {
  const payload = {
    id: userId,
    email: email,
  };

  const accessToken = jwt.sign(payload, config.jwtSecret, { expiresIn: "1h" });
  return accessToken;
};

access token./src/module/jwtHandler.ts 파일에서 유저 정보로 암호화 하여 반환합니다. (유효기간은 1시간으로 지정함.)


다시 UserController.ts 파일로 돌아와서,

    const refreshToken = jwt.createRefresh();
    const accessToken = jwt.sign(existUser._id, existUser.email);

    await UserService.updateRefreshToken(existUser._id, refreshToken);

    const data = {
      user: existUser,
      accessToken: accessToken,
      refreshToken: refreshToken,
    };

    return res
      .status(sc.OK)
      .send(BaseResponse.success(sc.OK, message.SIGN_IN_SUCCESS, data));

이미 존재하는 유저이면 refresh tokenaccess token 값을 발급하여 유저 정보와 함께 결과로 반환합니다. 반환하기 전에 유저 정보에 refresh token 을 업데이트 해야 합니다. (로그인 완료 !~!)


Access Token과 Refresh Token은 왜 필요할까?

소셜 로그인을 연동할 때, access tokenrefresh token으로 굳이 왜 2개의 토큰이 필요한 지 이해가 되지 않았었습니다. 그래서 구글링으로 알아낸 사실은 보안상 문제였습니다.

서비스의 유저는 access token으로 인증을 받아 서비스를 이용하는데, 중간에 이 토큰 값이 유출된다면 보안상의 위험성이 발생합니다.
이를 방지하기 위해서 유저의 토큰은 유출되기 전에 자주 바뀌어야 한다는 것입니다. 그래서 access token의 유효시간을 1시간으로 잡았습니다.

유저가 수시로 로그인을 해서 access token 값을 업데이트 해도 되겠지만, 유저는 매우 불편함을 느낄텐데 계속 불편한 서비스를 이용하고 싶을까요?

그래서 refresh token으로 access token이 만료되었을 때 새로 발급하도록 합니다. refresh token 값은 유저의 DB에 저장되어 쉽게 노출되지 않아서 상대적으로 안전합니다.
하지만 100% 유출되지 않는다는 보장은 없으니 14일 정도의 만료 기간을 잡아 유저로부터 지속적으로 로그인 하여 토큰 값을 업데이트 시킵니다.


그렇다면 refresh token으로 어떻게 access token을 새로 발급받을 수 있을까요?

유저의 refresh token을 서비스로 넘기면, 서비스는 토큰 값을 DB에서 존재함을 판단하고, 존재한다면 새로운 access token 값을 다시 넘겨줍니다.


코드로도 살펴볼까요?

    const access = jwt.verify(accessToken as string);

    if (access === exceptionMessage.TOKEN_INVALID) {
      return res
        .status(statusCode.UNAUTHORIZED)
        .send(
          BaseResponse.failure(statusCode.UNAUTHORIZED, message.INVALID_TOKEN),
        );
    }

./src/controllers/TokenController.ts 파일에서 토큰을 재발급 합니다. 먼저 access token을 복호화 합니다. 유효한 토큰이라면 유저의 정보(_idemail 값)를 얻을 것입니다. 하지만 그렇지 않다면 에러가 발생합니다. 복호화 했을 때 유효하지 않다고 판단되면 401 상태코드를 반환합니다.

const verify = (token: string) => {
  try {
    const decoded = jwt.verify(token, config.jwtSecret);
    return decoded;
  } catch (error) {
    if ((error as JsonWebTokenError).message === "jwt expired") {
      logger.e("만료된 토큰입니다.", error);
      return em.TOKEN_EXPIRED;
    }
    if ((error as JsonWebTokenError).message === "invalid signature") {
      logger.e("유효하지 않은 토큰입니다.", error);
      return em.TOKEN_INVALID;
    }
    logger.e("유효하지 않은 토큰입니다.", error);

    return em.TOKEN_INVALID;
  }
};

복호화 하는 코드는 ./src/module/jwtHandler.ts 파일에서 처리 합니다. 복호화 했을 때 회원 정보를 얻으면 그대로 반환하고, 에러가 발생하였으면 에러의 종류에 따라 적당한 값을 보내줍니다.


다시 Controller 파일로 돌아와서,

    const refresh = jwt.verify(refreshToken as string);

    if (refresh === exceptionMessage.TOKEN_INVALID) {
      return res
        .status(statusCode.UNAUTHORIZED)
        .send(
          BaseResponse.failure(statusCode.UNAUTHORIZED, message.INVALID_TOKEN),
        );
    }

    if (refresh === exceptionMessage.TOKEN_EXPIRED) {
      return res
        .status(statusCode.UNAUTHORIZED)
        .send(
          BaseResponse.failure(statusCode.UNAUTHORIZED, message.EXPIRED_TOKEN),
        );
    }

유효한 access token임을 확인했으면, 다음으로 refresh token을 복호화 하여 확인합니다. refresh token이 유효하지 않거나 만료되었으면 401 에러를 반환합니다.


    const user = await UserService.findUserByRfToken(refreshToken as string);
    if (!user) {
      return res
        .status(statusCode.UNAUTHORIZED)
        .send(
          BaseResponse.failure(statusCode.UNAUTHORIZED, message.INVALID_TOKEN),
        );
    }

    const data = {
      accessToken: jwt.sign(user._id, user.email),
      refreshToken: refreshToken,
    };

    return res
      .status(statusCode.OK)
      .send(
        BaseResponse.success(statusCode.OK, message.CREATE_TOKEN_SUCCESS, data),
      );

유효한 refresh token 이면 DB에서 해당 값을 가진 유저를 찾습니다. 유저를 찾지 못하면 401 에러를 반환합니다. 해당 값을 가진 유저가 있으면 access token을 새로 발급하여 반환함으로써 제공합니다.



🥚🐣🐥
소셜 로그인 연동과 refresh token 이라는 것을 처음 접해보기도 하고, 이해가 쉽게 되지도 않았었는데 글을 쓰다보니 글이 길어질수록 왜 어려워했는 지 기억도 나고, 일주일 동안 찾아 본 기억도 새록새록 나네요.

다른 소셜 로그인 연동을 앞으로 처음 접해볼 분들께 저보다 좀 더 편하게 공부하길 바라는 마음에서 소셜 로그인 연동과 토큰을 주제로 정해 글을 작성해보았습니다.

감사합니다 !!

profile
대학생연합 IT벤처창업 동아리 SOPT 30기 SERVER 파트 기술 블로그입니다.

0개의 댓글