refreshToken을 이용해서 accessToken을 재발급받자!(Feat. rtk query)

허재원·2022년 12월 30일
1

이전 프로젝트 accessToken 저장방식

// slice/authSlice.js
export const signin = createAsyncThunk(
  "auth/signin",
  async (_user, thunkAPI) => {
    try {
      const res = await axios.post("/auth/signin", _user);

      const { accessToken, user } = res.data;

      localStorage.setItem("accessToken", accessToken);
      localStorage.setItem("user", JSON.stringify(user));

      return res.data;
    } catch (err) {
      return thunkAPI.rejectWithValue(err.response.data);
    }
  }
);

사용자가 로그인하면 localStorage.setItem('accessToken', accessToken); 즉, localStorage를 통해 accessToken을 저장하고 이를 헤더에 담아 요청하는 방식을 사용하였다. 이는 페이지를 리프레시하거나 창을 닫고 다시 접속할 때도 로그인 정보가 이어지도록 브라우저에 저장하는 방식이다. 하지만, 이런 방식을 사용하는 경우에 문제점이 있다.

XSS 공격

웹 응용프로그램에 존재하는 취약점을 기반으로 웹 서버와 클라이언트 간 통신 방식인 HTTP 프로토콜 동작과정 중에 발생한다. XSS 공격은 웹사이트 관리자가 아닌 이가 JavaScript를 통해 사이트의 글로벌 변숫값을 가져오거나 그 값을 이용해 사이트인척 API 콜을 요청할 수도 있다.

  • localStorage 안에 세션 id, refreshToken 또는 accessToken을 저장해두면 XSS 취약점을 통해 사용자의 정보(토큰)를 불러오거나, 불러온 값을 이용해 API 콜을 위조할 수 있다.

CSRF 공격

쿠키에 별도로 설정을 가하지 않는다면, 크롬을 제외한 브라우저들은 모든 HTTP 요청에 대해서 쿠키를 전송하게 되는데, 그 요청에는 HTML 문서 요청, HTML 문서에 포함된 이미지 요청, XHR 혹은 Form을 이용한 HTTP 요청 등 모든 요청이 포함된다.

  • localStorage가 아닌 쿠키에 저장하는 경우 발생하는 문제인데, 쿠키에 accessToken을 저장해 인증에 이용하는 구조에 CSRF 취약점이 있다면 인증 정보가 쿠키에 담겨 보내진다. 공격자는 유저 권한으로 정보를 가져오거나 액션을 수행할 수 있다.

비공개 변수 사용

  • 나의 경우에는 redux 전역 상태 token에 로그인 시 accessToken을 담는 방식을 사용하였다.
  • 다른 방식에 비해 보안에서 가장 안전하지만 페이지를 이동하거나, 새로고침만 하여도 토큰 정보가 휘발되어 사용자 경험에 좋지 않아 단독적으로 사용 불가능하다.

이를 해결하기 위해

  • 즉, 사용자가 리프레시하거나 다른 사이트를 다녀왔다면 토큰 정보가 휘발되어 초기화되기 때문에 이 때 쿠키에 httpOnly, secure로 저장된 refreshToken를 사용해 다시 accessToken을 가져와 저장할 수 있도록 로직을 구성하여 휘발되는 문제를 해결했다.
    • httpOnly: 스크립트 상에서 접근이 불가능하도록 한다.
    • secure: 패킷 감청을 막기 위해 https 통신 시에만 해당 쿠키를 사용하도록 한다.

로그인

로그인 시 accessToken 저장

// store/apis/authApiSlice.ts
export const authApiSlice = apiSlice.injectEndpoints({
  endpoints: (builder) => ({
    signin: builder.mutation<AccessTokenProps, SignUpProps>({
      query: (credentials) => ({
        url: '/auth/signin',
        method: 'POST',
        body: { ...credentials },
      }),
    }),
  }),
});
// store/slices/authSlice.ts
const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    setCredentials: (state, action: PayloadAction<TokenProps>) => {
      const { accessToken } = action.payload;
      state.token = accessToken;
    }
  }
})
  • 이 과정을 크게 말할 것 없이 accessToken을 받아와서 로그인 페이지에서 dispatch(setCredential({ accessToken })을 통해 token 상태에 저장하도록 하였다.
  • 그리고 이렇게 하는 경우에는 위에서 계속 말했지만 사용자가 새로고침하는 경우, 다른 사이트를 다녀온 경우 토큰이 휘발되어 사라지게 된다.

refreshToken 저장

backend/controllers/auth.js
res.cookie("jwt", newRefreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: "None",
  maxAge: 24 * 60 * 60 * 1000,
});
  • refreshToken을 해당 쿠키 옵션을 주어 XSS, CSRF에 안전할 수 있는 쿠키를 보내줘 로그인 시 쿠키에 저장하도록 하였다.

accessToken 재발급

// store/apis/authApiSlice.ts
export const authApiSlice = apiSlice.injectEndpoints({
  endpoints: (builder) => ({
    signin: builder.mutation<AccessTokenProps, SignUpProps>({
      query: (credentials) => ({
        url: '/auth/signin',
        method: 'POST',
        body: { ...credentials },
      }),
    }),
    refresh: builder.query<AccessTokenProps, void>({
      query: () => ({
        url: '/refresh',
      }),
      async onQueryStarted(args, { dispatch, queryFulfilled }) {
        try {
          const { data: { accessToken } } = await queryFulfilled;
          dispatch(setCredentials({ accessToken }));
        } catch (err) {}
      },
    }),
  }),
});

export const { useSigninMutation, useLazyRefreshQuery } = authApiSlice;
  • /refresh로 요청할 쿼리를 만들고, 이를 사용해 사용자 로그인 상태를 유지하려고 한다.
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Outlet } from 'react-router-dom';
import { useLazyRefreshQuery } from 'store/apis/authApiSlice';
import { selectCurrentToken } from 'store/slices/authSlice';

export const PersistLogin = () => {
  const token = useSelector(selectCurrentToken);

  const [refresh] = useLazyRefreshQuery();

  useEffect(() => {
    const verifyRefreshToken = async () => {
      try {
        await refresh();
      } catch (err) {
        console.error(err);
      }
    };
    
    if (!token) verifyRefreshToken();
  }, []);

  return <Outlet />;
};
  • 위의 로직을 통해 만약 token 상태가 null인 경우에 verifyRefreshToken이 실행되고, refreshToken 쿠키를 가지고 서버에서 해당 유저의 정보를 확인 후 다시 accessToken을 넘겨준다.

accessToken, refreshToken 처리과정

  1. 백엔드 -> 로그인 시 인증 서버로부터 accessToken, refreshToken을 받아온다.
    • 프론트 -> accessToken은 메모리(변수)에 저장한다.
    • 백엔드 -> refreshTokenhttpOnly, secure 옵션으로 지정해 보내준다.
  2. 프론트엔드 -> 권한이 필요한 요청 시 AuthorizationaccessToken을 담아 요청한다.
  3. 프론트엔드 -> 사용자가 리프레시하거나, accessToken이 만료되었거나, 다른 페이지로 잠시 이동하고 돌아왔을 경우, 쿠키에 저장된 refreshToken을 통해 /refresh API 요청을 하고, 다시 accessToken을 메모리(변수)에 저장한다.

useAuth 훅 생성

import jwtDecode, { JwtPayload } from 'jwt-decode';
import { selectCurrentToken } from 'store/slices/authSlice';
import ROLES from 'config/roles';
import { useAppSelector } from './useAppStore';

type TokenProps = {
  id: string;
  roles: number[];
};

export const useAuth = () => {
  const token = useAppSelector(selectCurrentToken);

  if (token) {
    const { id, roles } = jwtDecode<TokenProps>(token);

    return { id, roles };
  }

  return { id: '', roles: [] };
};
  • 해당 훅을 생성하고, 이를 메인페이지(지금 메인페이지, 로그인 페이지밖에 없어서,,) 사용한다면 다음처럼 로그인 후에 /refresh 요청을 통해 사용자 로그인 정보가 들어간 객체가 반환될 것이다.

0개의 댓글