Node.js 인터뷰 질문 85

질문: Node.js에서 OAuth 인증을 구현하는 방법과 주요 흐름에 대해 설명해주세요.

답변:

OAuth는 사용자가 비밀번호를 공유하지 않고도 제3자 서비스에 자신의 리소스에 대한 접근 권한을 부여할 수 있는 표준 인증 프로토콜입니다. Node.js에서는 다양한 라이브러리와 프레임워크를 사용하여 OAuth 인증을 구현할 수 있습니다.

1. OAuth 기본 개념

OAuth 인증은 크게 두 가지 버전이 있습니다:

  1. OAuth 1.0a: 보안은 강화되었지만 구현이 복잡합니다.
  2. OAuth 2.0: 더 간단한 흐름과 더 나은 모바일 지원을 제공합니다.

주요 용어:

  • 리소스 소유자: 서비스에 접근 권한을 부여하는 사용자
  • 클라이언트: OAuth를 통해 사용자 데이터에 접근하려는 애플리케이션
  • 리소스 서버: 사용자의 보호된 리소스가 있는 서버
  • 인증 서버: 액세스 토큰을 발급하는 서버
  • 액세스 토큰: 보호된 리소스에 접근하기 위한 자격 증명

2. OAuth 2.0 인증 흐름

2.1 인증 코드 흐름 (가장 일반적인 흐름)

// Express.js를 사용한 OAuth 2.0 인증 코드 흐름 구현
const express = require("express");
const axios = require("axios");
const session = require("express-session");

const app = express();

// 세션 설정
app.use(
  session({
    secret: "your-secret-key",
    resave: false,
    saveUninitialized: true,
  })
);

// OAuth 설정
const oauthConfig = {
  clientID: "YOUR_CLIENT_ID",
  clientSecret: "YOUR_CLIENT_SECRET",
  redirectURI: "http://localhost:3000/callback",
  authURL: "https://provider.com/oauth/authorize",
  tokenURL: "https://provider.com/oauth/token",
  userInfoURL: "https://provider.com/api/user",
};

// 1. 로그인 페이지로 리디렉션
app.get("/login", (req, res) => {
  const authURL = `${oauthConfig.authURL}?client_id=${
    oauthConfig.clientID
  }&redirect_uri=${encodeURIComponent(
    oauthConfig.redirectURI
  )}&response_type=code&scope=read_user`;
  res.redirect(authURL);
});

// 2. 콜백 처리 - 인증 코드를 토큰으로 교환
app.get("/callback", async (req, res) => {
  const code = req.query.code;

  if (!code) {
    return res.status(400).send("인증 코드가 없습니다");
  }

  try {
    // 인증 코드를 액세스 토큰으로 교환
    const tokenResponse = await axios.post(oauthConfig.tokenURL, {
      client_id: oauthConfig.clientID,
      client_secret: oauthConfig.clientSecret,
      code,
      redirect_uri: oauthConfig.redirectURI,
      grant_type: "authorization_code",
    });

    const { access_token, refresh_token } = tokenResponse.data;

    // 토큰을 세션에 저장
    req.session.accessToken = access_token;
    req.session.refreshToken = refresh_token;

    // 사용자 정보 가져오기
    const userResponse = await axios.get(oauthConfig.userInfoURL, {
      headers: { Authorization: `Bearer ${access_token}` },
    });

    req.session.user = userResponse.data;

    res.redirect("/profile");
  } catch (error) {
    console.error("OAuth 오류:", error.response?.data || error.message);
    res.status(500).send("인증 과정에서 오류가 발생했습니다");
  }
});

// 3. 보호된 리소스 접근
app.get("/profile", (req, res) => {
  if (!req.session.user) {
    return res.redirect("/login");
  }

  res.json(req.session.user);
});

app.listen(3000, () => {
  console.log("서버가 http://localhost:3000에서 실행 중입니다");
});

2.2 암시적 흐름 (SPA 애플리케이션 용)

// 프론트엔드에서 처리하는 암시적 흐름
function initiateImplicitFlow() {
  const authURL = `https://provider.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=${encodeURIComponent(
    window.location.origin + "/callback"
  )}&response_type=token&scope=read_user`;

  window.location.href = authURL;
}

// 콜백 처리 (프론트엔드)
function handleCallback() {
  const hash = window.location.hash.substring(1);
  const params = new URLSearchParams(hash);
  const accessToken = params.get("access_token");

  if (accessToken) {
    // 액세스 토큰을 localStorage에 저장
    localStorage.setItem("accessToken", accessToken);

    // 사용자 정보 가져오기
    fetchUserInfo(accessToken);
  }
}

async function fetchUserInfo(accessToken) {
  try {
    const response = await fetch("https://provider.com/api/user", {
      headers: { Authorization: `Bearer ${accessToken}` },
    });

    if (response.ok) {
      const user = await response.json();
      // 사용자 정보 처리
      displayUserInfo(user);
    }
  } catch (error) {
    console.error("사용자 정보 가져오기 오류:", error);
  }
}

3. Passport.js를 사용한 OAuth 구현

Passport.js는 Node.js에서 인증을 구현하는 가장 인기 있는 라이브러리로, 다양한 OAuth 제공자에 대한 지원을 제공합니다.

const express = require("express");
const session = require("express-session");
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;

const app = express();

// 세션 설정
app.use(
  session({
    secret: "your-secret-key",
    resave: false,
    saveUninitialized: true,
  })
);

// Passport 초기화
app.use(passport.initialize());
app.use(passport.session());

// 사용자 직렬화/역직렬화 (세션에 저장할 정보)
passport.serializeUser((user, done) => {
  done(null, user);
});

passport.deserializeUser((obj, done) => {
  done(null, obj);
});

// Google OAuth 전략 설정
passport.use(
  new GoogleStrategy(
    {
      clientID: "YOUR_GOOGLE_CLIENT_ID",
      clientSecret: "YOUR_GOOGLE_CLIENT_SECRET",
      callbackURL: "http://localhost:3000/auth/google/callback",
    },
    (accessToken, refreshToken, profile, done) => {
      // 여기서 사용자 정보를 데이터베이스에 저장하는 로직 구현
      // 예: User.findOrCreate({ googleId: profile.id }, function (err, user) {
      //   return done(err, user);
      // });

      // 간단한 예시: 프로필 정보 반환
      return done(null, {
        id: profile.id,
        displayName: profile.displayName,
        email: profile.emails[0].value,
        accessToken,
      });
    }
  )
);

// 인증 시작 라우트
app.get(
  "/auth/google",
  passport.authenticate("google", { scope: ["profile", "email"] })
);

// OAuth 콜백 라우트
app.get(
  "/auth/google/callback",
  passport.authenticate("google", { failureRedirect: "/login" }),
  (req, res) => {
    // 성공적인 인증 후 홈페이지로 리디렉션
    res.redirect("/profile");
  }
);

// 프로필 페이지 (보호된 라우트)
app.get("/profile", isAuthenticated, (req, res) => {
  res.json(req.user);
});

// 로그아웃
app.get("/logout", (req, res) => {
  req.logout();
  res.redirect("/");
});

// 인증 확인 미들웨어
function isAuthenticated(req, res, next) {
  if (req.isAuthenticated()) {
    return next();
  }
  res.redirect("/login");
}

app.listen(3000, () => {
  console.log("서버가 http://localhost:3000에서 실행 중입니다");
});

4. 소셜 로그인 통합 (여러 제공자)

여러 OAuth 제공자를 지원하는 애플리케이션 구현:

const express = require("express");
const session = require("express-session");
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const FacebookStrategy = require("passport-facebook").Strategy;
const GithubStrategy = require("passport-github2").Strategy;

const app = express();

// 세션 및 Passport 설정 (위와 동일)
// ...

// Google OAuth 전략
passport.use(
  new GoogleStrategy(
    {
      clientID: "GOOGLE_CLIENT_ID",
      clientSecret: "GOOGLE_CLIENT_SECRET",
      callbackURL: "http://localhost:3000/auth/google/callback",
    },
    (accessToken, refreshToken, profile, done) => {
      return done(null, {
        provider: "google",
        id: profile.id,
        name: profile.displayName,
        email: profile.emails[0].value,
        photo: profile.photos[0].value,
      });
    }
  )
);

// Facebook OAuth 전략
passport.use(
  new FacebookStrategy(
    {
      clientID: "FACEBOOK_APP_ID",
      clientSecret: "FACEBOOK_APP_SECRET",
      callbackURL: "http://localhost:3000/auth/facebook/callback",
      profileFields: ["id", "displayName", "photos", "email"],
    },
    (accessToken, refreshToken, profile, done) => {
      return done(null, {
        provider: "facebook",
        id: profile.id,
        name: profile.displayName,
        email: profile.emails ? profile.emails[0].value : "",
        photo: profile.photos ? profile.photos[0].value : "",
      });
    }
  )
);

// GitHub OAuth 전략
passport.use(
  new GithubStrategy(
    {
      clientID: "GITHUB_CLIENT_ID",
      clientSecret: "GITHUB_CLIENT_SECRET",
      callbackURL: "http://localhost:3000/auth/github/callback",
      scope: ["user:email"],
    },
    (accessToken, refreshToken, profile, done) => {
      return done(null, {
        provider: "github",
        id: profile.id,
        name: profile.displayName || profile.username,
        email: profile.emails ? profile.emails[0].value : "",
        photo: profile.photos ? profile.photos[0].value : "",
      });
    }
  )
);

// 인증 라우트 - Google
app.get(
  "/auth/google",
  passport.authenticate("google", { scope: ["profile", "email"] })
);
app.get(
  "/auth/google/callback",
  passport.authenticate("google", { failureRedirect: "/login" }),
  (req, res) => res.redirect("/profile")
);

// 인증 라우트 - Facebook
app.get(
  "/auth/facebook",
  passport.authenticate("facebook", { scope: ["email"] })
);
app.get(
  "/auth/facebook/callback",
  passport.authenticate("facebook", { failureRedirect: "/login" }),
  (req, res) => res.redirect("/profile")
);

// 인증 라우트 - GitHub
app.get("/auth/github", passport.authenticate("github"));
app.get(
  "/auth/github/callback",
  passport.authenticate("github", { failureRedirect: "/login" }),
  (req, res) => res.redirect("/profile")
);

// 프로필 및 로그아웃 라우트 (위와 동일)
// ...

5. 토큰 새로 고침 및 관리

토큰은 보안을 위해 제한된 수명을 가지므로, 리프레시 토큰을 사용하여 액세스 토큰을 갱신해야 합니다:

// 토큰 새로 고침 함수
async function refreshAccessToken(refreshToken) {
  try {
    const response = await axios.post("https://provider.com/oauth/token", {
      client_id: "YOUR_CLIENT_ID",
      client_secret: "YOUR_CLIENT_SECRET",
      refresh_token: refreshToken,
      grant_type: "refresh_token",
    });

    return {
      accessToken: response.data.access_token,
      refreshToken: response.data.refresh_token || refreshToken, // 새 리프레시 토큰이 없으면 기존 것 사용
    };
  } catch (error) {
    console.error("토큰 새로 고침 오류:", error);
    throw error;
  }
}

// API 요청 시 토큰 검증 및 새로 고침
async function apiRequest(url, accessToken, refreshToken) {
  try {
    const response = await axios.get(url, {
      headers: { Authorization: `Bearer ${accessToken}` },
    });

    return response.data;
  } catch (error) {
    // 토큰이 만료된 경우 (401 Unauthorized)
    if (error.response && error.response.status === 401 && refreshToken) {
      // 토큰 새로 고침
      const tokens = await refreshAccessToken(refreshToken);

      // 새 토큰으로 API 요청 재시도
      const retryResponse = await axios.get(url, {
        headers: { Authorization: `Bearer ${tokens.accessToken}` },
      });

      // 새 토큰 저장 (세션 또는 데이터베이스에)
      // saveTokens(tokens);

      return retryResponse.data;
    }

    throw error;
  }
}

6. PKCE(Proof Key for Code Exchange) 보안 향상

PKCE는 권한 부여 코드 유출 공격을 방지하는 보안 확장입니다:

const crypto = require("crypto");
const base64url = require("base64url");

// PKCE 코드 검증기 생성
function generateCodeVerifier() {
  return base64url(crypto.randomBytes(32));
}

// 코드 검증기에서 코드 도전 생성
function generateCodeChallenge(verifier) {
  const hash = crypto.createHash("sha256").update(verifier).digest();
  return base64url(hash);
}

// PKCE를 사용한 OAuth 흐름
app.get("/login", (req, res) => {
  // 코드 검증기 생성 및 세션에 저장
  const codeVerifier = generateCodeVerifier();
  req.session.codeVerifier = codeVerifier;

  // 코드 도전 생성
  const codeChallenge = generateCodeChallenge(codeVerifier);

  // 인증 URL 생성
  const authURL = `${oauthConfig.authURL}?client_id=${
    oauthConfig.clientID
  }&redirect_uri=${encodeURIComponent(
    oauthConfig.redirectURI
  )}&response_type=code&scope=read_user&code_challenge=${codeChallenge}&code_challenge_method=S256`;

  res.redirect(authURL);
});

// 콜백 처리 시 코드 검증기 포함
app.get("/callback", async (req, res) => {
  const code = req.query.code;
  const codeVerifier = req.session.codeVerifier;

  if (!code || !codeVerifier) {
    return res.status(400).send("인증 코드 또는 코드 검증기가 없습니다");
  }

  try {
    // 코드 검증기를 포함하여 토큰 요청
    const tokenResponse = await axios.post(oauthConfig.tokenURL, {
      client_id: oauthConfig.clientID,
      client_secret: oauthConfig.clientSecret,
      code,
      redirect_uri: oauthConfig.redirectURI,
      grant_type: "authorization_code",
      code_verifier: codeVerifier,
    });

    // 나머지 처리는 동일
    // ...
  } catch (error) {
    // 오류 처리
    // ...
  }
});

7. OAuth 모범 사례 및 보안 고려사항

7.1 보안 모범 사례

// 보안 강화를 위한 Express 앱 설정
const helmet = require("helmet");

// 보안 헤더 설정
app.use(helmet());

// HTTPS 강제 적용 (프로덕션 환경)
if (process.env.NODE_ENV === "production") {
  app.use((req, res, next) => {
    if (!req.secure && req.get("x-forwarded-proto") !== "https") {
      return res.redirect(`https://${req.get("host")}${req.url}`);
    }
    next();
  });
}

// 상태 매개변수를 사용한 CSRF 방지
app.get("/login", (req, res) => {
  // 랜덤 상태 생성
  const state = crypto.randomBytes(16).toString("hex");
  req.session.oauthState = state;

  // 상태 매개변수를 포함한 인증 URL
  const authURL = `${oauthConfig.authURL}?client_id=${
    oauthConfig.clientID
  }&redirect_uri=${encodeURIComponent(
    oauthConfig.redirectURI
  )}&response_type=code&scope=read_user&state=${state}`;

  res.redirect(authURL);
});

// 콜백에서 상태 확인
app.get("/callback", (req, res) => {
  const { code, state } = req.query;

  // 상태 검증
  if (!state || state !== req.session.oauthState) {
    return res
      .status(403)
      .send("인증 상태가 유효하지 않습니다 (CSRF 공격 가능성)");
  }

  // 상태 정리
  delete req.session.oauthState;

  // 나머지 처리 계속
  // ...
});

7.2 토큰 저장 및 보안

// 토큰 암호화 및 복호화 함수
const crypto = require("crypto");

const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; // 32바이트 키
const IV_LENGTH = 16;

function encryptToken(token) {
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv(
    "aes-256-cbc",
    Buffer.from(ENCRYPTION_KEY),
    iv
  );
  let encrypted = cipher.update(token);
  encrypted = Buffer.concat([encrypted, cipher.final()]);
  return iv.toString("hex") + ":" + encrypted.toString("hex");
}

function decryptToken(encryptedToken) {
  const parts = encryptedToken.split(":");
  const iv = Buffer.from(parts[0], "hex");
  const encryptedText = Buffer.from(parts[1], "hex");
  const decipher = crypto.createDecipheriv(
    "aes-256-cbc",
    Buffer.from(ENCRYPTION_KEY),
    iv
  );
  let decrypted = decipher.update(encryptedText);
  decrypted = Buffer.concat([decrypted, decipher.final()]);
  return decrypted.toString();
}

// 데이터베이스에 암호화된 토큰 저장 예시
function saveUserTokens(userId, accessToken, refreshToken) {
  // 토큰 암호화
  const encryptedAccessToken = encryptToken(accessToken);
  const encryptedRefreshToken = encryptToken(refreshToken);

  // 데이터베이스에 저장 (MongoDB 예시)
  // UserModel.findByIdAndUpdate(userId, {
  //   $set: {
  //     accessToken: encryptedAccessToken,
  //     refreshToken: encryptedRefreshToken,
  //     tokenUpdatedAt: new Date()
  //   }
  // }).exec();
}

요약

Node.js에서 OAuth 인증을 구현하는 주요 방법과 흐름:

  1. 기본 OAuth 2.0 흐름:

    • 인증 코드 흐름: 서버 사이드 앱에 적합
    • 암시적 흐름: 단일 페이지 앱(SPA)에 적합
    • 패스워드 자격 증명 흐름: 자체 로그인이 필요한 경우
    • 클라이언트 자격 증명 흐름: 서버 간 통신
  2. Passport.js 활용:

    • 다양한 OAuth 제공자 통합
    • 인증 로직 추상화
    • 세션 관리 지원
  3. 보안 모범 사례:

    • HTTPS 사용
    • 상태 매개변수를 통한 CSRF 방지
    • PKCE 확장 사용 (공개 클라이언트용)
    • 토큰 안전한 토큰 안전한 보관 및 암호화
    • 정기적인 토큰 새로 고침
  4. 실제 구현시 주의사항:

    • 클라이언트 비밀은 서버에만 저장
    • OAuth 콜백은 HTTPS URL 사용
    • 최소 권한 범위(scope) 요청
    • 리프레시 토큰 안전하게 저장
    • 접근 토큰 만료 처리 자동화

Node.js에서는 express와 passport.js를 조합하면 다양한 OAuth 제공자(Google, Facebook, GitHub 등)와의 통합을 상대적으로 쉽게 구현할 수 있으며, 사용자 인증 방식에 유연성을 제공할 수 있습니다.

results matching ""

    No results matching ""