[로그인] 토큰 갱신 이슈 해결 과정 - "로그인이 하루만에 풀려요"

fejigu·2024년 7월 2일
1

React Native Project

목록 보기
21/21
post-thumbnail

🚨 문제 상황

→ 현재 우리앱에서 로그인 후 하루가 경과하면 자동으로 로그아웃이 된다. 이는 사용자 경험 측면에서 사용자 불편, 재로그인 부담, 만족도 저하, 보안 신뢰성 등 여러 가지 부정적인 영향을 미치므로 최우선 에픽으로 해결하고자 한다.




🔎 문제 원인이라고 생각되는 부분

→ 우리앱은 로그인 요청을 aws cognito로 하고 있으면, cognito로부터 받은 accessToken의 유효기간이 1일이다. 해당 accessToken의 유효기간 1일이 경과하면refreshToken으로 토큰을 갱신 받지 못하거나, 갱신 받은 토큰을 AuthHeader에 저장하지 않는 것 같다.




🔎 로그로 보는 문제 원인

1. AsyncStorage에서 리프레시 토큰을 가져오려고 했으나 실패했다

LOG  Refresh token from AsyncStorage: null
LOG  refresh token이 없을 때 찍힘, Refresh token is missing, logging out...

2. 토큰 갱신에 실패했다

INFO  !session: 갱신 실패
ERROR  모든 오류가 발생할 때 찍힘 [AxiosError: Request failed with status code 401]



🔎 해결을 위한 접근 방법

일단 나는 refresh token을 활용해 access token을 갱신하는 방식으로 구현했다.

1. 로그인 시 리프레시 토큰이 AsyncStorage에 제대로 저장되었는지 확인해보자

export const setAuthHeader = async ({
  accessToken,
  refreshToken,
  idToken,
}: {
  accessToken: string;
  refreshToken: string;
  idToken: string;
}) => {
  console.log("2.setAuthToken_Setting new tokens:", {
    accessToken,
    refreshToken,
    idToken,
  });
  axios.defaults.headers.common.Authorization = `Bearer ${idToken}`;
  await asyncStorageSet(
    ["@access_token", accessToken],
    ["@id_token", idToken],
    ["@refresh_token", refreshToken]
  );
  console.log("! 로그인 할 때 AsyncStorage에 토큰을 저장하고 있니, 그 중 @refresh_token은",refreshToken);
  // 10ms 대기하는 함수를 Promise로 감싸기
  const delay = (ms: number) =>
    new Promise((resolve) => setTimeout(resolve, ms));
  await delay(20); //axios.defaults.headers set 하는데 시간 좀 걸려서 해놓음
  console.log(
    "2-2. setAuthToken_Auth header set:",
    axios.defaults.headers.common.Authorization
  );
  return;
};

→ 가장 먼저 위와 같이 로그를 찍어서, 리프레시 토큰이 저장되고 있는지 확인해보았다. 아래와 같이 리프레시 토큰은 저장되고 있는 것을 확인할 수 있었다. 그럼 다음 스텝을 확인해보자.

2. 앱이 재시작될 때 AsyncStorage에서 토큰을 올바르게 불러오는지 확인해보자

export const initToken = async () => {
  const idToken = await AsyncStorage.getItem("@id_token").catch((e) =>
    console.error("getAuthToken Error:", e)
  );
  console.log("!!토큰 axios에 적용하기, Auth header set with ID token.");
  idToken
    ? (axios.defaults.headers.common.Authorization = `Bearer ${idToken}`)
    : clearAuthHeader();
};

→ 다음으로, 앱이 재시작될 때 AsyncStorage에서 토큰을 올바르게 불러오는지 확인해보았다. 이 또한 로그가 제대로 찍히는 것을 확인할 수 있었다.

3. 리프레시 토큰을 사용하여 새로운 액세스 토큰을 받아오는 로직이 제대로 작동하는지 확인해보자

function useAxiosInterceptor() {
  const setIsAuth = useSetRecoilState(IsAuthorizedState);
  const responseHandler = (response) => response;
  const onPressExpiredLogin = () => {
    clearAuthHeader();
    setIsAuth(false);
  };
  const errorHandler = async (error) => {
    console.error("모든 오류가 발생할 때 찍힘", error);
    const originalRequest = error.config;
    if (!axios.isAxiosError(error)) return Promise.reject(error);
    if (error.response?.status === 401 && !originalRequest?._retry) {
      originalRequest._retry = true;
      const refreshToken = await AsyncStorage.getItem("@refresh_token").catch(
        (e) =>
          console.error(
            "useAxiosInterceptor.tsx axios.interceptors Error @refresh_token:",
            e
          )
      );
      console.log("Refresh token from AsyncStorage:", refreshToken);
      if (!refreshToken) {
        console.log(
          "refresh token이 없을 때 찍힘, Refresh token is missing, logging out..."
        );
        Alert.alert(
          "Expired Login",
          "Your login has expired. Please log in again...",
          [{ text: "OK", onPress: onPressExpiredLogin }]
        );
        return Promise.reject(error);
      }
      console.log("refresh token을 사용하려고 할 때 찍힘", refreshToken);
      const session = await getNewRefreshTokenData(refreshToken).catch((e) => {
        console.error(
          "토큰 갱신 과정에서 에러가 발생했을 때 찍힘, Error fetching new tokens:",
          e
        );
        return null;
      });
      if (session === null) {
        console.log(
          "새로운 세션을 얻지 못했을 때 찍힘, Session is null, logging out..."
        );
        Alert.alert(
          "Expired Login",
          "Your login has expired. Please log in again....",
          [{ text: "OK", onPress: onPressExpiredLogin }]
        );
        return Promise.reject(error);
      }
      const newAccessToken = session.getAccessToken().getJwtToken();
      const newRefreshToken = session.getRefreshToken().getToken();
      const newIdToken = session.getIdToken().getJwtToken();
      await setAuthHeader({
        accessToken: newAccessToken,
        refreshToken: newRefreshToken,
        idToken: newIdToken,
      });
      console.log(
        "새로운 토큰을 성공적으로 수신했을 때 찍힘, New tokens received:",
        {
          accessToken: newAccessToken,
          refreshToken: newRefreshToken,
          idToken: newIdToken,
        }
      );
      return axios.request(originalRequest);
    }
    return Promise.reject(error);
  };
  useEffect(() => {
    const responseInterceptor = axios.interceptors.response.use(
      responseHandler,
      errorHandler
    );
    return () => {
      axios.interceptors.response.eject(responseInterceptor); // 기존 코드와 동일
    };
  }, []);
}
1. 액세스 토큰이 만료되어 401 에러가 발생했을 때, 리프레시 토큰을 사용하여 새로운 액세스 토큰과 아이디 토큰을 발급 받는다. 
2. 새로운 토큰을 AsyncStorage에 저장하고, axios의 기본 헤더에 설정한다. 
3. 기존 요청을 다시 시도한다. 
4. AsyncStorage에서 리프레시 토큰을 가져오지 못한 경우, 로그아웃을 요청한다.

→ 먼저 토큰이 만료되었을 때, useAxiosInterceptor 함수는 아래와 같은 로직으로 가져야한다고 주석으로 작성 후 코드를 수정했다. 기존 이슈가 생기던 코드에서는 리프레시 토큰 저장이 되지 않고 있었고(Log로 찍어보면 null), null인 상태로 헤더에 설정하고 있었다.

// authTestUtils.ts
// 토큰 테스트를 위해 작성한 테스트 코드
import AsyncStorage from "@react-native-async-storage/async-storage";
import { asyncStorageSet } from "./func/setAuthToken";
// 토큰 삭제를 위한 함수
export const setExpiredTokenForTest = async () => {
  const expiredAccessToken = "your_expired_access_token"; // 만료된 액세스 토큰
  const validRefreshToken = "your_valid_refresh_token"; // 유효한 리프레시 토큰
  const expiredIdToken = "your_expired_id_token"; // 만료된 아이디 토큰
  await AsyncStorage.setItem("@access_token", expiredAccessToken);
  await AsyncStorage.setItem("@refresh_token", validRefreshToken);
  await AsyncStorage.setItem("@id_token", expiredIdToken);
  console.log("만료된 토큰 설정을 위한 함수 작동 완료");
};
// HomeScreen.tsx
// 테스트를 위한 버튼을 추가
...
      {/* 토큰 갱신 로직 테스트를 위한 작성한 코드*/}
        <View style={styles.testButtonContainer}>
          // 버튼 1
          <Button
            title="Expire Tokens for Test"
            onPress={async () => {
              await setExpiredTokenForTest();
            }}
          />
		 // 버튼 2
          <Button title="Check Stored Tokens" onPress={checkStoredTokens} />
         // 버튼 3
          <Button title="Make API Call" onPress={makeApiCall} />
        </View>
...

그리고 토큰이 만료되려면 1일이 경과해야하기 때문에, 테스트 코드를 작성해서 테스트해보았다.

1. 버튼 1으로 강제로 만료된 토큰을 기본 헤더로 설정한다.
2. 버튼 2으로 제대로 저장했는지 확인한다.
3. 버튼 3으로 토큰이 만료된 상태에서 API 요청을 보내 토큰 갱신 로직이 작동하는지 확인한다.

위 로직대로 실행해보니 만료된 토큰 설정을 위한 함수가 작동하고, accessToken, refreshToken, idToken이 저장되는 것을 확인할 수 있었고, AsyncStorage에 저장한 refreshToken을 가져오고, refreshToken으로 토큰 갱신 요청 하는 것을 확인할 수 있었다. 그리고 최종적으로 새롭게 세션이 요청되고 로그아웃 되지 않는 것을 확인할 수 있었다.




  • 혼자 로그인 및 토큰 관련 로직을 작성하고, 이슈에 대해서도 위와 같은 방식으로 접근하여 해결하였는데 더 좋은 방법이 있을 것 같다. 만약 적합한 방법이 있다면 시도해보고 그 또한 기록으로 남겨둬야겠다.
profile
신규 서비스의 기획부터 개발, 운영까지 전 과정을 경험한 주니어 📱

0개의 댓글