본문 바로가기

React

React 앱에서 OAuth 1.0 인증 처리하기

OAuth 1.0은 요청 서명(HMAC-SHA1), nonce, timestamp를 기반으로 동작하는 인증 방식입니다. React 같은 프론트엔드 앱에서 직접 서명을 처리하면 비밀키 노출 위험이 있어, 일반적으로 백엔드 프록시가 서명과 토큰 교환을 담당하고 프론트엔드는 최소한의 흐름만 제어합니다.

1. OAuth 1.0 흐름 요약

일반적인 3-legged OAuth 1.0a 흐름은 다음과 같습니다.

1) 요청 토큰 받기: 서버가 consumer key/secret으로 서명한 POST를 제공자에 보내 요청 토큰(oauth_token, oauth_token_secret)을 받습니다.

2) 사용자 승인: 프론트엔드가 제공자의 authorize URL로 리다이렉트하여 사용자가 승인을 완료합니다.

3) 액세스 토큰 교환: 콜백으로 전달된 oauth_verifier를 서버가 사용하여 access token과 secret을 교환합니다.

4) 서명된 API 호출: 서버가 access token/secret으로 각 API 요청을 서명해 프록시합니다.

2. 왜 프론트엔드에서 직접 서명하지 않나요?

OAuth 1.0은 서명에 consumer secret이 필요합니다. 비밀키를 브라우저 번들에 포함하면 누구나 열람할 수 있으므로 보안상 금지합니다. 따라서 React 앱은 로그인 트리거와 상태 표시만 담당하고, 서명과 토큰 저장은 백엔드가 처리합니다.

3. 아키텍처 설계

- React(SPA): 로그인 버튼, 진행 상태 표시, 완료 후 UI 갱신을 담당합니다.

- Node/Express 프록시: 요청 토큰 발급, 액세스 토큰 교환, OAuth 1.0 서명, 제공자 API 호출을 담당합니다.

- 콜백 경로: 제공자가 서버 콜백(/auth/callback)으로 되돌아오고, 서버가 세션/쿠키를 설정한 뒤 프론트엔드로 리다이렉트합니다.

4. 서버 구현(Node/Express)

아래 예시는 oauth-1.0a 라이브러리를 사용해 HMAC-SHA1 서명을 생성하고, 토큰을 교환하며, 프록시로 서명된 API 요청을 보내는 최소 구현입니다. 실제 제공자 URL로 교체하고 세션 저장소를 적절히 구성하세요.

// server/index.js
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const axios = require('axios');
const OAuth = require('oauth-1.0a');
const crypto = require('crypto');

const app = express();
app.use(cors({ origin: 'http://localhost:3000', credentials: true }));
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// 환경변수: OAUTH_CONSUMER_KEY, OAUTH_CONSUMER_SECRET
const PROVIDER = {
  REQUEST_TOKEN_URL: 'https://api.example.com/oauth/request_token',
  AUTHORIZE_URL: 'https://api.example.com/oauth/authorize',
  ACCESS_TOKEN_URL: 'https://api.example.com/oauth/access_token',
  API_BASE: 'https://api.example.com/1.1' // 예시
};
const FRONTEND_URL = 'http://localhost:3000';
const CALLBACK_URL = 'http://localhost:4000/auth/callback';

const oauth = OAuth({
  consumer: {
    key: process.env.OAUTH_CONSUMER_KEY,
    secret: process.env.OAUTH_CONSUMER_SECRET
  },
  signature_method: 'HMAC-SHA1',
  hash_function(baseString, key) {
    return crypto.createHmac('sha1', key).update(baseString).digest('base64');
  }
});

// 요청 토큰 시크릿 임시 저장 (프로덕션에서는 Redis/DB/세션 사용 권장)
const tempSecrets = new Map(); // key: oauth_token, value: oauth_token_secret

// 1) 요청 토큰 발급
app.get('/auth/request', async (req, res) => {
  try {
    const requestData = {
      url: PROVIDER.REQUEST_TOKEN_URL,
      method: 'POST',
      data: { oauth_callback: CALLBACK_URL }
    };
    const headers = oauth.toHeader(oauth.authorize(requestData));

    const resp = await axios.post(PROVIDER.REQUEST_TOKEN_URL, null, {
      headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' }
    });

    // 응답은 querystring 형식: oauth_token=...&oauth_token_secret=...&oauth_callback_confirmed=true
    const params = new URLSearchParams(resp.data);
    const oauthToken = params.get('oauth_token');
    const oauthTokenSecret = params.get('oauth_token_secret');

    if (!oauthToken || !oauthTokenSecret) {
      return res.status(400).json({ error: 'Invalid request token response' });
    }

    tempSecrets.set(oauthToken, oauthTokenSecret);
    const authorizeUrl = `${PROVIDER.AUTHORIZE_URL}?oauth_token=${oauthToken}`;
    res.json({ authorizeUrl });
  } catch (e) {
    console.error('Request token error:', e.response?.data || e.message);
    res.status(500).json({ error: 'Failed to get request token' });
  }
});

// 2) 콜백: oauth_token + oauth_verifier 수신 후 액세스 토큰 교환
app.get('/auth/callback', async (req, res) => {
  const { oauth_token, oauth_verifier } = req.query;
  const tempSecret = tempSecrets.get(oauth_token);
  if (!oauth_token || !oauth_verifier || !tempSecret) {
    return res.status(400).send('Invalid callback parameters');
  }

  try {
    const token = { key: oauth_token, secret: tempSecret };
    const requestData = {
      url: PROVIDER.ACCESS_TOKEN_URL,
      method: 'POST',
      data: { oauth_verifier }
    };
    const headers = oauth.toHeader(oauth.authorize(requestData, token));

    const resp = await axios.post(PROVIDER.ACCESS_TOKEN_URL, null, {
      headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' }
    });

    const params = new URLSearchParams(resp.data);
    const accessToken = params.get('oauth_token');
    const accessSecret = params.get('oauth_token_secret');

    // 실무: HttpOnly 쿠키에 저장하거나 서버 세션에 보관 후 사용자 세션 생성
    // 여기서는 데모로 메모리에 저장 (키는 예시로 accessToken)
    tempSecrets.set(accessToken, accessSecret);

    // 프론트엔드로 리다이렉트 (로그인 완료 상태 표시용)
    res.redirect(`${FRONTEND_URL}/auth/success?token=${encodeURIComponent(accessToken)}`);
  } catch (e) {
    console.error('Access token error:', e.response?.data || e.message);
    res.status(500).send('Failed to exchange access token');
  }
});

// 3) 서명된 API 프록시 예시
app.get('/api/profile', async (req, res) => {
  const accessToken = req.query.token; // 데모: 쿼리로 전달 (실무는 세션/쿠키)
  const accessSecret = tempSecrets.get(accessToken);
  if (!accessToken || !accessSecret) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  try {
    const token = { key: accessToken, secret: accessSecret };
    const requestData = {
      url: `${PROVIDER.API_BASE}/account/verify_credentials.json`,
      method: 'GET'
    };
    const headers = oauth.toHeader(oauth.authorize(requestData, token));

    const resp = await axios.get(requestData.url, { headers });
    res.json(resp.data);
  } catch (e) {
    console.error('API proxy error:', e.response?.data || e.message);
    res.status(e.response?.status || 500).json({ error: 'API proxy failed' });
  }
});

app.listen(4000, () => {
  console.log('OAuth server listening on http://localhost:4000');
});

5. React 구현(로그인 트리거와 상태 표시)

프론트엔드는 요청 토큰을 발급받아 authorize URL로 리다이렉트하고, 콜백 후 성공 페이지에서 API를 호출합니다.

// src/App.jsx
import { useEffect, useState } from 'react';

function App() {
  const [token, setToken] = useState(null);
  const [profile, setProfile] = useState(null);

  // 콜백 성공 페이지에서 ?token=... 파싱
  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const t = params.get('token');
    if (t) setToken(t);
  }, []);

  const handleLogin = async () => {
    const resp = await fetch('http://localhost:4000/auth/request');
    const data = await resp.json();
    if (data.authorizeUrl) {
      window.location.assign(data.authorizeUrl);
    }
  };

  const loadProfile = async () => {
    if (!token) return;
    const resp = await fetch(`http://localhost:4000/api/profile?token=${encodeURIComponent(token)}`);
    const data = await resp.json();
    setProfile(data);
  };

  return (
    <div style={{ padding: 24 }}>
      <h2>OAuth 1.0 로그인 데모</h2>
      {!token ? (
        <button onClick={handleLogin}>제공자 로그인</button>
      ) : (
        <div>
          <p>로그인 완료. 토큰: {token}</p>
          <button onClick={loadProfile}>프로필 불러오기</button>
          {profile && (
            <pre>{JSON.stringify(profile, null, 2)}</pre>
          )}
        </div>
      )}
    </div>
  );
}

export default App;

6. 서명 포인트 이해하기(HMAC-SHA1)

OAuth 1.0 서명은 다음 순서를 따릅니다.

- 파라미터 정규화: oauth_consumer_key, oauth_token, oauth_nonce, oauth_timestamp, oauth_signature_method, oauth_version(옵션), 쿼리/바디 파라미터를 사전순으로 정렬합니다.

- 베이스 문자열 생성: METHOD&&percent-encode(URL)&&percent-encode(normalized params) 형태입니다.

- 서명키: percent-encode(consumer_secret) + '&' + percent-encode(token_secret).

- HMAC-SHA1으로 베이스 문자열을 서명 후 Base64 인코딩합니다.

라이브러리(oauth-1.0a)가 대부분 처리하므로, 올바른 URL/메서드/파라미터를 제공하고 Authorization 헤더를 사용하면 됩니다.

7. 보안 및 운영 팁

- 비밀키 노출 금지: consumer secret은 절대 프론트엔드 번들에 포함하지 않습니다.

- 토큰 저장: HttpOnly 쿠키 또는 서버 세션에 저장하고, 클라이언트에는 최소 정보만 전달합니다.

- CORS/콜백: 제공자 콜백은 서버로 수신한 뒤 프론트엔드로 리다이렉트합니다.

- 시간 동기화: timestamp 검증 실패를 줄이려 서버 시간 동기화(NTP)를 유지합니다.

- 오류 대응: 401(Signature invalid) 발생 시 파라미터 정렬, URL 인코딩, nonce 중복을 점검합니다.

- 로깅: 요청/응답(민감정보 마스킹)과 베이스 문자열을 디버그 로그로 남기면 문제 추적에 유리합니다.

8. 실전 체크리스트

- 제공자 문서의 endpoint, 파라미터 요구사항(oauth_callback 전달 방식 등)을 정확히 반영합니다.

- HTTPS를 사용하고 리다이렉트 URL을 화이트리스트에 등록합니다.

- 프록시 서버에 rate limit, 재시도 전략, 장애 알림을 구성합니다.

- 테스트 계정으로 요청 토큰/액세스 토큰 흐름을 반복 검증하고 엣지 케이스(승인 취소, 만료 토큰)를 처리합니다.

9. 마무리

React 앱에서 OAuth 1.0을 안전하게 처리하려면 프론트는 흐름 제어, 백엔드는 서명과 토큰 관리를 분리하는 것이 핵심입니다. 위 구조를 기반으로 제공자 사양에 맞게 엔드포인트를 조정하면 안정적으로 배포할 수 있습니다.