Node.js 인터뷰 질문 85
질문: Node.js에서 OAuth 인증을 구현하는 방법과 주요 흐름에 대해 설명해주세요.
답변:
OAuth는 사용자가 비밀번호를 공유하지 않고도 제3자 서비스에 자신의 리소스에 대한 접근 권한을 부여할 수 있는 표준 인증 프로토콜입니다. Node.js에서는 다양한 라이브러리와 프레임워크를 사용하여 OAuth 인증을 구현할 수 있습니다.
1. OAuth 기본 개념
OAuth 인증은 크게 두 가지 버전이 있습니다:
- OAuth 1.0a: 보안은 강화되었지만 구현이 복잡합니다.
- 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 인증을 구현하는 주요 방법과 흐름:
기본 OAuth 2.0 흐름:
- 인증 코드 흐름: 서버 사이드 앱에 적합
- 암시적 흐름: 단일 페이지 앱(SPA)에 적합
- 패스워드 자격 증명 흐름: 자체 로그인이 필요한 경우
- 클라이언트 자격 증명 흐름: 서버 간 통신
Passport.js 활용:
- 다양한 OAuth 제공자 통합
- 인증 로직 추상화
- 세션 관리 지원
보안 모범 사례:
- HTTPS 사용
- 상태 매개변수를 통한 CSRF 방지
- PKCE 확장 사용 (공개 클라이언트용)
- 토큰 안전한 토큰 안전한 보관 및 암호화
- 정기적인 토큰 새로 고침
실제 구현시 주의사항:
- 클라이언트 비밀은 서버에만 저장
- OAuth 콜백은 HTTPS URL 사용
- 최소 권한 범위(scope) 요청
- 리프레시 토큰 안전하게 저장
- 접근 토큰 만료 처리 자동화
Node.js에서는 express와 passport.js를 조합하면 다양한 OAuth 제공자(Google, Facebook, GitHub 등)와의 통합을 상대적으로 쉽게 구현할 수 있으며, 사용자 인증 방식에 유연성을 제공할 수 있습니다.