Skip to content

로그인 관련 토큰 관리 전략

Choi Jeongmin edited this page Dec 1, 2024 · 1 revision

📄 로그인 관련 토큰 관리 전략

‘Ask-It’ 에서 클라이언트의 로그인 관련 토큰, 즉 Access Token과 Refresh Token을 관리하는 전략에 대해 기술합니다.

🧩 배경 및 필요성

‘Ask-It’ 에서는 사용자 인증이 필수적인 여러 기능이 존재합니다. 예를 들어, 세션 생성하기나 참여했던 세션 기록 보기 등이 있습니다.

이를 위해 ‘Ask-It’ 에서는 토큰 기반 인증 방식을 사용하며, 다음과 같은 요구 사항이 있었습니다.

  1. Access Token의 유출 가능성을 최소화해야 합니다.
  2. 유효기간 만료 시 자동으로 Access Token을 갱신해 사용자의 끊김 없는 경험을 보장해야 합니다.
  3. 여러 API 요청마다 반복되는 인증 로직을 간소화해야 합니다.

따라서 이를 해결하기 위해 최적화된 토큰 관리 전략을 설계했습니다.

🔍 기술적 분석 및 비교

Access Token을 인메모리에서 관리하는 방식과 스토리지를 이용하는 방식에는 차이가 있습니다. 여기에서 보안성과 사용 편의성의 균형을 고려해 선택했습니다.

인메모리

Access Token을 메모리에서만 유지하며, 브라우저가 종료되면 토큰도 사라지는 방식입니다.

  • 장점
    1. XSS 공격으로부터 안전합니다.
      • Access Token이 JavaScript 코드에서 접근 가능한 스토리지에 저장되지 않으므로 탈취될 위험이 낮아집니다.
    2. 생명주기
      • 토큰이 메모리에서만 유지되므로 브라우저 탭이나 창을 닫으면 자동으로 사라져 세션이 종료됩니다. 이를 통해 해당 데이터가 브라우저에 장기적으로 저장되지 않습니다.
  • 단점
    1. 유지
      • 브라우저가 새로고침을 하거나 페이지를 닫으면 메모리도 초기화되므로 Access Token을 잃게 됩니다.
      • Refresh Token을 활용해 새로고침 시 토큰을 다시 가져오는 추가 로직이 필요합니다.
    2. 복잡성
      • 새로고침과 같은 상태 초기화 상황을 처리하려면 Refresh Token을 사용해 Access Token을 재발급받는 로직이 필요합니다.

스토리지

Access Token을 localStorage 또는 sessionStorage와 같은 브라우저 저장소에 저장하는 방식입니다.

  • 장점
    1. 편의성
      • 새로고침, 브라우저 닫기 후 재열기 시에도 Access Token이 유지됩니다.
      • Refresh Token 요청 없이 바로 API 요청이 가능하므로 사용자 경험이 부드럽습니다 (Access Token을 캐싱하는 듯한 효과).
    2. 간단한 구현
      • 브라우저 저장소에서 바로 읽어올 수 있어 추가적인 상태 관리 로직이 필요 없습니다.
      • 상태 초기화를 신경 쓸 필요 없이 저장소에 의존하면 됩니다.
  • 단점
    1. 보안 취약점
      • 위에서 언급한 내용처럼, XSS 공격에 의해 토큰이 탈취될 수 있습니다.
    2. 성능
      • 스토리지에서 검색하고 읽고 쓰는 것은 느리지만, 사실 이 프로젝트에서 해당 행위가 많지는 않아서 큰 이유가 되지는 않았습니다.
특징 인메모리 관리 스토리지 이용
보안 XSS에 강함 XSS에 취약
유지 가능성 새로고침 및 브라우저 종료 시 토큰 초기화 새로고침 및 종료 후에도 유지
편의성 Refresh Token 기반 토큰 재발급 필요 간단한 구현 가능
사용 사례 보안이 최우선인 애플리케이션 사용자 경험이 더 중요한 경우

따라서, ‘Ask-It’ 의 프론트엔드에서는 인메모리 + Refresh Token 기반 관리를 선택했습니다.

  1. Access Token은 메모리에서 관리
    • 보안을 위해 Access Token은 메모리에 저장하여 XSS 공격에 대비합니다.
  2. Refresh Token은 HTTP-Only 쿠키로 관리
    • 브라우저에서 접근할 수 없는 쿠키로 Refresh Token을 저장하여 CSRF 공격 방지와 세션 연속성을 유지합니다.
  3. 새로고침 처리
    • 새로고침 시 Refresh Token을 통해 Access Token을 다시 발급받고 전역 상태를 복구합니다.

🗺️ 문제 해결 과정

토큰 관리와 갱신을 위해 일관된 구조를 설계하고 구현했습니다.

전역상태

‘Ask-It’ 에서는 전역 상태로 토큰을 관리합니다. AuthStore라는 스토어를 하나 만들고, 그 내부에 Access Token 값과 현재 로그인 상태인지를 알 수 있는 액션을 추가했습니다.

interface AuthStore {
  userId?: number;
  accessToken?: string;
  isLogin: () => boolean;
  setAuthInformation: ({
    userId,
    accessToken,
  }: {
    userId?: number;
    accessToken?: string;
  }) => void;
  clearAuthInformation: () => void;
}
  • 로그인시에는 setAuthInformation 이라는 액션을 통해 유저 아이디와, 토큰 값을 저장하도록 했습니다.
  • 로그아웃시에는 cleareAuthInformation 이라는 액션을 통해 메모리에서 유저와 관련된 상태를 제거합니다.

새로고침과 초기 진입시 토큰 값

Refresh Token은 서버에서 HTTP-Only 쿠키로 설정되어 클라이언트가 직접 접근하지 못합니다.

새로고침이나 초기 진입시 항상 Refresh Token의 여부와 상관없이 서버로 토큰 재발급을 시도하도록 설계를 했습니다.

beforeLoad: () => {
  const { isLogin, setAuthInformation } = useAuthStore.getState();

  if (!isLogin())
    refresh()
      .then((res) => {
        setAuthInformation(res);
      })
      .catch(console.error);
},
  • 위와 같은 방식으로 새로고침되거나 새롭게 들어오는 경우 렌더링 전 서버로 토큰 재발급을 시도하고 전역 상태를 업데이트합니다.
  • 이러한 방식으로 라우팅으로 접근할 수 있는 페이지에서 해당 경우를 처리하고, 전역 상태를 복구해야 하는 경우 해당 로직들이 추가로 포함되어 있습니다.

Access Token 헤더 삽입 / 만료 처리

‘Ask-It’ 프론트엔드 개발에서는 axios를 사용하고 있습니다. 이는 요청, 응답을 받았을 때 특정 작업을 추가하기 위함입니다. 인터셉터를 활용하기 위해 사용하고 있습니다.

import axios from 'axios';

import { useAuthStore } from '@/features/auth';
import { PostRefreshResponseDTO } from '@/features/auth/auth.dto';

axios.interceptors.request.use(
  (config) => {
    const nextConfig = { ...config };
    const { accessToken } = useAuthStore.getState();
    if (accessToken) nextConfig.headers.Authorization = `Bearer ${accessToken}`;
    return nextConfig;
  },
  (error) => Promise.reject(error),
);

axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    const {
      config,
      response: { status, data },
    } = error;

    if (status === 401 && data.message === '유효하지 않은 액세스 토큰입니다.') {
      const originalRequest = config;

      const response = await fetch('/api/auth/token', {
        method: 'POST',
        credentials: 'include',
      });

      const { accessToken, userId } =
        (await response.json()) as PostRefreshResponseDTO;

      const { setAuthInformation, clearAuthInformation } =
        useAuthStore.getState();

      if (accessToken) {
        setAuthInformation({ accessToken, userId });
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        return axios(originalRequest);
      }

      clearAuthInformation();
    }

    return Promise.reject(error);
  },
);
  • 위 코드에서 모든 요청 시에 전역 상태에 접근해서 Access Token이 있다면 해당 토큰을 삽입하고 요청을 전송하고 있습니다.
    • ‘Ask-It’ 은 익명 사용자도 있기 때문에, 토큰 없이도 많은 API가 정상적으로 동작해야 하므로 데이터의 유무에 따라 토큰을 삽입하는 정도로 처리하고 있습니다.
  • 이후 응답에서 Access Token의 만료로 인한 실패 시 토큰 재발급을 1회 시도하고 성공하면 같은 요청에 헤더를 새롭게 추가하여 다시 시도합니다. 만약 재발급에 실패하면 현재 로그인 상태를 모두 지우고 에러를 그대로 외부로 반환하고 있습니다.
  • 이 코드를 통해 로그인, 로그아웃 시 결과를 전역 상태에 담기만 하면 모든 요청과 응답에 대해 일관된 처리를 할 수 있었습니다.

📈 결과 및 성과

  • 보안성 강화
    • 브라우저 저장소를 사용하지 않아 Access Token의 유출 가능성을 줄였습니다.
    • Refresh Token은 HTTP-Only 쿠키로 설정해 CSRF 공격으로부터 안전하게 보호했습니다.
  • 사용자 경험 개선
    • 토큰 갱신과 전역 상태 복구를 자동화하여 사용자 세션이 끊기지 않는 매끄러운 경험을 제공했습니다.
  • 개발 효율성 증가
    • axios 인터셉터를 활용해 인증 로직을 일관되게 구성했습니다.
    • API 호출에는 인증 및 인가와 관련된 로직이 제거되어 쉽게 개발할 수 있었습니다.
Clone this wiki locally