4. OAuth 2.0

(1) 이해

1) OAuth

세션 기반 인증이 널리 사용되면서 웹 서비스의 보안성이 향상되었지만, 웹 서비스 간 연동이 늘어나면서 새로운 방식의 인증 메커니즘이 필요해졌다.
OAuth(Open Authorization)API를 통한 서비스 간 접근 위임에 대한 개방형 표준이다. 사용자가 A 서비스의 정보를 B 서비스에서 활용하고자 할 때, 기존 서비스의 비밀번호를 제공하지 않고도 B 서비스 웹사이트나 애플리케이션에 접근 권한을 부여할 수 있는 방식으로, 웹 서비스 간 안전한 데이터 공유와 접근 권한 관리가 가능하도록 한다.
2007년 OAuth 1.0이 등장한 뒤로 웹 환경은 급진적으로 진화했다. 이에 발 맞추어 2012년, 더 넓은 범위의 애플리케이션과 디바이스를 보다 유연하고 안전하게 사용할 수 있는 프로토콜인 OAuth 2.0이 발표되었고 현재에도 사용되고 있다.

2) 인증 흐름

OAuth 모델에는 사용자(리소스 소유자), 소비자(제3자 애플리케이션), 서비스 제공자, 인가 서버 이 네 가지 주요한 역할이 상호작용하면서 인증과 권한 부여가 이루어진다.
[그림 4-2] OAuth 2.0 인증 흐름 [그림 4-2] OAuth 2.0 인증 흐름
[그림 4-2] OAuth 2.0 인증 흐름
page icon
OAuth 2.0 인증 흐름
  1. 클라이언트 등록: 인증 흐름 이전에 애플리케이션은 서비스 제공자에 등록하는 단계를 거치고, 애플리케이션 신원 확인 정보인 client_idclient_secret을 받는다.
  1. 권한 요청: 소비자가 사용자에게 특정 리소스에 대한 접근 권한을 요청한다.
  1. 사용자 인증: 사용자가 인가 서버의 인증 페이지로 리디렉트하여 로그인하고 자신의 권한 제공에 동의한다. 리소스 위임 요청에는 client_id, redirect_uri, response_type, scope 등이 포함된다.
  1. 인가 그랜트 발급: 사용자가 리소스 위임을 승인하면, 소비자는 인가 서버로부터 🔑인가 그랜트를 받고 redirect_uri로 사용자를 리디렉트한다.
  1. 토큰 요청: 소비자는 서버 간 통신을 통해 🔑인가 그랜트를 인가 서버에 제시하여 🪙접근 토큰을 요청한다. 이 요청에는 client_id, client_secret, 인가 그랜트 등이 포함된다.
  1. 토큰 발급: 인가 서버는 소비자와 🔑인가 그랜트를 검증하고 🪙접근 토큰과 🔁갱신 토큰을 발급한다.
  1. 리소스 접근 요청: 소비자는 서비스 제공자에 🪙접근 토큰을 제시하고 보호된 리소스에 접근을 요청한다.
  1. 리소스 접근 허용:서비스 제공자는 접근 토큰과 리소스 접근 권한을 검증하고, 유효할 경우 리소스 접근을 허용한다.
  1. 토큰 갱신: 액세스 토큰이 만료되면, 소비자는 백그라운드에서 🔁갱신 토큰을 사용하여 새로운 접근 토큰을 요청한다.
 
예를 들어, 한 유저가 새로운 앱 A에 'Google 계정으로 로그인'하는 경우를 생각해보자. 이 과정에서 로그인을 시도하는 유저가 사용자, 앱 A는 소비자, Google이 서비스 제공자이자 인가 서버가 된다. 이러한 기능은 보통 아래와 같은 간편 로그인 메뉴를 통해 시도할 수 있다.
[그림 4-3] OAuth을 사용한 SNS 간편 로그인[그림 4-3] OAuth을 사용한 SNS 간편 로그인
[그림 4-3] OAuth을 사용한 SNS 간편 로그인
A 앱은 개발 단계에서 Google에 클라이언트로 등록하는 단계를 거쳤을 것이다. 로그인 메뉴에서 Google 버튼을 누르면 A 앱은 사용자를 Google의 OAuth 인가 서버로 리디렉트한다. 사용자는 Google 로그인 페이지에서 계정 정보를 선택해서 인증한다.
 
[그림 4-4] Google 인증 페이지로 리디렉트[그림 4-4] Google 인증 페이지로 리디렉트
[그림 4-4] Google 인증 페이지로 리디렉트
[그림 4-5] Google 로그인 화면[그림 4-5] Google 로그인 화면
[그림 4-5] Google 로그인 화면
Google은 사용자에게 A 앱이 요청하는 권한에 대해 동의를 요청한다. 사용자가 동의하면 Google 인가 서버는 A 앱에 사용자의 승인을 나타내는 인가 그랜트를 발급한다.
[그림 4-6] A 앱에 대한 권한 동의 화면[그림 4-6] A 앱에 대한 권한 동의 화면
[그림 4-6] A 앱에 대한 권한 동의 화면
사용자는 A 앱으로 리디렉트 된다. A 앱은 서버 간 통신을 통해 인가 그랜트로 Google 인가 서버에 접근 토큰을 요청한다. 인가 서버는 인가 그랜트의 유효성을 검증하고 A 앱에게 접근 토큰갱신 토큰을 발급한다(인가 그랜트 ↔ 토큰). A 앱은 접근 토큰을 사용해 Google에게 사용자의 계정 정보 일부를 요청한다. Google은 접근 토큰의 유효성과 리소스 권한을 검증한 뒤, 요청된 사용자 계정 정보를 A 앱에게 제공한다(접근 토큰 ↔ 계정 정보). A 앱은 Google의 정보를 바탕으로 사용자의 로그인 프로세스를 완료한다. 이후 접근 토큰이 만료되면, A 앱은 백그라운드에서 갱신 토큰을 사용하여 새로운 접근 토큰을 요청한다(갱신 토큰 ↔ 접근 토큰). 이를 통해 사용자는 다시 로그인할 필요 없이 지속적으로 서비스를 이용할 수 있다.
OAuth 용어 정리
OAuth 용어 정리
  • 사용자(User/Resource Owner): 보호된 리소스에 대한 접근 권한을 가진 사용자
  • 소비자(Consumer): 보호된 리소스에 접근하는 애플리케이션. 요청자(Client)의 역할
  • 서비스 제공자(Service Provider): 보호된 리소스를 제공하는 서버(API Server)
  • 인가 서버(Authorization Server): 소비자의 인증 및 권한 부여를 처리하는 서버. 서비스 제공자와 동일할 수 있음
  • 접근 토큰(Access Token): 보호된 리소스에 대한 접근 권한을 나타내는 자격 증명
  • 인가 그랜트(Authorization Grant): 최초 인증 시 접근 토큰을 얻기 위해 사용하는 자격 증명. 사용자의 승인을 나타냄
  • 갱신 토큰(Refresh Token): 접근 토큰 만료 후 재인증 시 접근 토큰을 얻기 위해 사용하는 자격 증명
 

(2) 프론트엔드의 OAuth 2.0 구현과 보안

OAuth 2.0에서 프론트엔드의 주요 역할은 사용자를 인증 서버로 리디렉트하고, 그랜트 유형에 따른 인증 흐름을 처리하며, 받은 토큰을 안전하게 관리하는 것이다.

1) 리디렉트 보안

OAuth 2.0의 인증 과정에는 인증 서버로의 리디렉션이 포함된다. 앞서 여러 보안 위협에서 살펴본 것처럼, 리디렉트 기능은 보안 위험을 초래할 수 있기 때문에 적절한 보안 처리가 필요하다.
  1. 리디렉트 URI 검증
    1. 동적으로 생성된 리디렉트 URI는 보안 위험이 있으므로 정적 리디렉트 URI만을 사용해야 한다. 'Google 계정으로 로그인' 예시에서는 미리 등록해 둔 redirect_uri로 리디렉트된다.
  1. 상태(State) 매개변수 사용
    1. CSRF 공격을 방지하기 위해 상태(state) 매개변수 사용을 고려할 수 있다. 상태 매개변수는 랜덤하게 생성된 문자열로 CSRF 토큰과 유사한 역할을 한다.
      이 값은 클라이언트 요청 시 생성되어 요청에 포함되고, 인증 서버는 사용자 인증 완료 후 응답에 이 매개변수를 포함하여 전달한다. 클라이언트는 요청과 응답의 상태 매개변수를 비교하여 동일 세션 여부를 검증한다.

2) 그랜트 유형(Grant Types)

사용자의 승인을 나타내는 인가 그랜트에는 여러 종류가 있어서 다양한 인증 시나리오에 대응할 수 있다. 프론트엔드는 서비스 제공자가 지원하는 그랜트 유형을 이해하고 그에 맞는 인증 흐름을 구현해야 한다. 주요 그랜트 유형들은 다음과 같다.
  1. 인증 코드 그랜트
    1. 클라이언트가 그랜트로 인증 코드를 받고, 이를 통해 접근 토큰을 얻는 방식. 가장 대표적인 그랜트 유형으로 비교적 더 안전하다. 위의 'Google 계정으로 로그인' 예시에서 사용한 방법이 이것이다.
  1. 암묵적 그랜트
    1. 클라이언트가 인증 코드 없이 직접 접근 토큰을 얻는 방식. 로직이 간단하지만 상대적으로 보안성이 낮다.
  1. PKCE(Proof Key for Code Exchange)
    1. 인증 코드 그랜트의 보안 강화 버전으로, 클라이언트가 코드를 교환할 때 추가적인 보안 계층을 제공하는 방식

3) 토큰 저장과 갱신

토큰(Token)¹⁾사용자의 인증 정보를 의미하는 문자열이다. 토큰에는 일반적으로 사용자 ID, 권한 정보, 만료 시간 등의 정보가 포함되어 있다. OAuth 2.0에서는 접근 토큰과 갱신 토큰을 나누어 관리함으로써 토큰 탈취시의 피해를 줄인다.

① 접근 토큰(Access Token)

1시간 이내의 짧은 수명을 가지며, 일반적으로 클라이언트 측 메모리에 저장된다. 이는 API 요청마다 빠르게 접근할 수 있지만 동시에 XSS 공격에 취약하다. 더 안전한 방법으로 HttpOnly 쿠키보관을 고려할 수도 있다.

② 갱신 토큰(Refresh Token)

접근 토큰보다 수명이 길고, 접근 토큰 만료 시 새로운 접근 토큰을 발급 받을 때 사용한다(갱신 토큰 ↔ 접근 토큰). 일반적으로 HttpOnly/Secure 쿠키에 저장되며, 필요시엔 값을 암호화해 로컬 스토리지에 저장하는 방법을 고려할 수 있다.

③ 토큰 만료 감지 및 자동 갱신

프론트엔드에서는 접근 토큰의 만료를 감지하고 갱신 요청을 보내는 작업이 필요하다. 이는 서버 응답의 상태 코드나 토큰에 포함된 만료 시간을 확인하여 이루어진다. 토큰 만료가 감지되면, 저장된 갱신 토큰을 사용하여 인증 서버에 요청을 보내고 새로운 접근 토큰을 발급 받는다. 새 접근 토큰은 안전하게 저장되어 이후의 API 요청에 사용된다. 이 과정은 사용자 경험을 방해하지 않고 백그라운드에서 자동으로 수행되어야 한다.

¹⁾ 토큰에 대한 자세한 설명과 예시 코드는
5. 토큰 기반 인증
을 참고한다.
 

(3) 실습

1) OAuth 2.0 구현시 고려 사항

  • 클라이언트 시크릿이 프론트엔드에 노출되지 않도록 한다.
  • 리디렉트 URI는 등록된 URI와 정확히 일치하도록 한다.
  • 요구사항에 따라 토큰을 안전하게 저장할 위치를 결정한다.
  • CLIENT_SECRET을 포함한 작업은 반드시 서버 측에서 처리한다.

2) 프론트엔드 예시 코드

HTML과 JavaScript를 사용하여 Google 계정을 통한 간단한 소셜 로그인 기능을 구현해보자.
'Google 계정으로 로그인' 기능을 사용하려면 먼저 Google Cloud Console에서 프로젝트를 설정하고 클라이언트 ID를 발급하는 과정이 필요하다.
프로젝트 설정과 클라이언트 ID 발급
프로젝트 설정과 클라이언트 ID 발급
  1. https://console.cloud.google.com/에 접속해서 새 프로젝트를 생성하거나 기존 프로젝트를 선택함(로고 우측 선택기에서 프로젝트 선택 가능)
    1. notion imagenotion image
  1. 좌상단 메뉴 아이콘을 누르고 API 및 서비스>사용자 인증 정보로 이동
    1. notion imagenotion image
  1. 사용자 인증 정보 만들기>OAuth 클라이언트 ID선택
    1. notion imagenotion image
  1. 동의 화면 구성>User Type: 외부>만들기를 선택하고, 앱 이름/이메일/개발자 연락처 등을 입력한 뒤 저장
  1. 범위 추가 또는 삭제 눌러 '../auth/userinfo.profile' 등 원하는 정보 범위를 선택하고 업데이트저장
    1. notion imagenotion image
  1. 필요한 경우 테스트 사용자를 추가하고 저장한 뒤 요약 정보 확인
  1. 사용자 인증 정보 만들기>OAuth 클라이언트 ID로 돌아가서 애플리케이션 유형으로 웹 애플리케이션 선택 후 앱 이름 입력
    1. notion imagenotion image
  1. '승인된 JavaScript 원본'에 웹 서비스 주소, '승인된 리디렉션 URI'에 로그인 후 리디렉트할 주소를 입력 후 만들기 선택
    1. notion imagenotion image
      notion imagenotion image
  1. 'OAuth 클라이언트 생성됨' 팝업창에서 '클라이언트 ID'와 '클라이언트 보안 비밀번호'를 확인하고 복사해 둠
 
아래 코드는 인증 코드 그랜트 방식을 사용하여 인증 코드를 접근 토큰과 갱신 토큰으로 교환하는 과정을 거친다. 이 예시에서 두 토큰은 임의로 로컬 변수로 저장하고, 백엔드 없이 코드가 동작하도록 CLIENT_SECRET을 클라이언트 측에서 다루었다.
그러나 실제 구현시에는 토큰의 더 안전한 저장 위치를 고려해야 하며, CLIENT_SECRET 클라이언트 측에 절대 노출되지 않도록, 토큰 교환과 토큰 갱신 과정을 백엔드에서 수행해야 한다.(임의의 코드로 표시된 작업을 백엔드에서 수행하고, 실제 프론트엔드에서는 프론트 엔드 코드로 표시된 작업을 진행한다.)
<!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>구글 OAuth 2.0 인증 예시</title> <script src="https://apis.google.com/js/platform.js"></script> </head> <body> <button id="login-btn">Google 계정으로 로그인</button> <div id="user-info" style="display: none"> <p>안녕, <span id="user-name"></span>!</p> <button id="logout-btn">로그아웃</button> </div> <script src="app.js"></script> </body> </html>
// app.js const CLIENT_ID = "111111111-AAAAAAAAA.apps.googleusercontent.com"; // 클라이언트 ID const CLIENT_SECRET = '00000-000-AAAAAAAAA_AAAAAAAAA'; // 실제 구현시 서버에서 안전하게 관리해야 함 const REDIRECT_URI = 'https://example.com'; // 승인된 리디렉션 URI const scopes = ['profile']; // 리소스 범위 // 토큰을 임의로 로컬에 보관 let accessToken = null; let refreshToken = null; // DOM 요소 참조 const loginBtn = document.getElementById('login-btn'); const logoutBtn = document.getElementById('logout-btn'); const userInfo = document.getElementById('user-info'); const userName = document.getElementById('user-name'); // 로그인 처리 function signIn() { const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=${scopes.join(' ')}&response_type=code&access_type=offline&prompt=consent`; window.location.href = authUrl; } // 로그아웃 처리 async function signOut() { accessToken = null; refreshToken = null; updateUI(false); userName.textContent = ''; window.location.href = REDIRECT_URI; } // OAuth 응답 처리 async function handleAuthResponse() { const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); if (code) { try { const tokens = await exchangeCodeForTokens(code); accessToken = tokens.access_token; refreshToken = tokens.refresh_token; updateUI(true); fetchUserInfo(); } catch (error) { console.error('코드를 토큰으로 교환 실패:', error); } } } // 인가 코드를 토큰으로 교환 async function exchangeCodeForTokens(code) { const response = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, redirect_uri: REDIRECT_URI, grant_type: 'authorization_code', code: code, }), }); if (!response.ok) { throw new Error('코드를 토큰으로 교환하는 데 실패했습니다.'); } return response.json(); } // 토큰 갱신 async function refreshAccessToken() { try { const response = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, grant_type: 'refresh_token', refresh_token: refreshToken, }), }); if (!response.ok) { throw new Error('접근 토큰 갱신에 실패했습니다.'); } const data = await response.json(); accessToken = data.access_token; console.log('접근 토큰 갱신함'); } catch (error) { console.error('접근 토큰 갱신 실패:', error); signOut(); // 갱신 실패 시 로그아웃 } } // 사용자 정보 가져오기 async function fetchUserInfo() { try { const response = await fetch( 'https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${accessToken}`, }, } ); if (!response.ok) { if (response.status === 401) { // 접근 토큰이 만료된 경우 await refreshAccessToken(); // 갱신 토큰으로 접근 토큰 갱신 return fetchUserInfo(); // 새로운 접근 토큰으로 재시도 } throw new Error('사용자 정보를 가져오는 데 실패했습니다.'); } const data = await response.json(); displayUserInfo(data); } catch (error) { console.error('사용자 정보 가져오기 실패:', error); } } // UI 업데이트: 로그인 여부에 따른 UI 변경 function updateUI(isSignedIn) { if (isSignedIn) { loginBtn.style.display = 'none'; userInfo.style.display = 'block'; } else { loginBtn.style.display = 'block'; userInfo.style.display = 'none'; } } // UI 업데이트: 사용자 정보 표시 function displayUserInfo(profile) { userName.textContent = profile.name; } // Google API 클라이언트 초기화 function initClient() { loginBtn.addEventListener('click', signIn); logoutBtn.addEventListener('click', signOut); handleAuthResponse(); } window.onload = initClient; // 클라이언트 초기화 실행