728x90
반응형
1. OAuth2.0이란
OAuth2.0은 제3자 애플리케이션이 사용자의 리소스에 안전하게 접근할 수 있도록 하는 인가 프레임워크입니다. 사용자는 비밀번호를 공유하지 않고 특정 리소스에 대한 접근 권한을 부여할 수 있습니다.
1.1 OAuth2.0 역할
Resource Owner: 사용자 (자원 소유자)
Client: 애플리케이션 (접근 요청자)
Authorization Server: 인가 서버 (토큰 발급)
Resource Server: 자원 서버 (보호된 API)
1.2 주요 Grant Types
1. Authorization Code: 웹 서버 애플리케이션
2. Client Credentials: 서버 간 통신
3. Password: 신뢰할 수 있는 자사 앱
4. Refresh Token: 토큰 갱신
2. Authorization Code Flow
가장 일반적인 방식으로, 웹 애플리케이션에서 사용됩니다.
2.1 흐름
1. 사용자 → 클라이언트: 로그인 요청
2. 클라이언트 → 인가 서버: 인가 요청 (redirect)
3. 사용자 → 인가 서버: 로그인 및 권한 승인
4. 인가 서버 → 클라이언트: 인가 코드 (redirect)
5. 클라이언트 → 인가 서버: 코드로 토큰 교환
6. 인가 서버 → 클라이언트: 액세스 토큰 발급
7. 클라이언트 → 리소스 서버: API 요청 (토큰 포함)
3. Google OAuth 구현
3.1 설정
npm install passport passport-google-oauth20 express-session
// Google Cloud Console에서 OAuth 2.0 클라이언트 ID 생성
// https://console.cloud.google.com/apis/credentials
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const CALLBACK_URL = 'http://localhost:3000/auth/google/callback';
3.2 Passport 설정
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-session-secret',
resave: false,
saveUninitialized: false
}));
app.use(passport.initialize());
app.use(passport.session());
// 사용자 저장소
const users = new Map();
// Google 전략 설정
passport.use(new GoogleStrategy(
{
clientID: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
callbackURL: CALLBACK_URL
},
async (accessToken, refreshToken, profile, done) => {
try {
// 사용자 찾기 또는 생성
let user = users.get(profile.id);
if (!user) {
user = {
id: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
picture: profile.photos[0].value,
provider: 'google'
};
users.set(profile.id, user);
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// 세션 직렬화
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser((id, done) => {
const user = users.get(id);
done(null, user);
});
// 라우트
app.get('/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email']
})
);
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/');
}
);
app.get('/logout', (req, res) => {
req.logout(() => {
res.redirect('/');
});
});
app.get('/profile', ensureAuthenticated, (req, res) => {
res.json({ user: req.user });
});
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.status(401).json({ error: 'Not authenticated' });
}
app.listen(3000);
반응형
4. GitHub OAuth 구현
npm install passport-github2
const GitHubStrategy = require('passport-github2').Strategy;
passport.use(new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/github/callback'
},
async (accessToken, refreshToken, profile, done) => {
try {
let user = users.get(`github:${profile.id}`);
if (!user) {
user = {
id: `github:${profile.id}`,
username: profile.username,
name: profile.displayName,
email: profile.emails?.[0]?.value,
avatar: profile.photos[0].value,
provider: 'github'
};
users.set(user.id, user);
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
app.get('/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/');
}
);
5. JWT와 함께 사용 (SPA용)
세션 대신 JWT를 사용하는 방식입니다.
const express = require('express');
const passport = require('passport');
const jwt = require('jsonwebtoken');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const app = express();
const JWT_SECRET = process.env.JWT_SECRET;
passport.use(new GoogleStrategy(
{
clientID: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
callbackURL: CALLBACK_URL
},
(accessToken, refreshToken, profile, done) => {
const user = {
id: profile.id,
email: profile.emails[0].value,
name: profile.displayName
};
return done(null, user);
}
));
app.use(passport.initialize());
// 인가 요청
app.get('/auth/google',
passport.authenticate('google', {
session: false,
scope: ['profile', 'email']
})
);
// 콜백 - JWT 발급
app.get('/auth/google/callback',
passport.authenticate('google', { session: false }),
(req, res) => {
const token = jwt.sign(
{ userId: req.user.id, email: req.user.email },
JWT_SECRET,
{ expiresIn: '7d' }
);
// SPA로 토큰 전달
res.redirect(`http://localhost:3001/auth/callback?token=${token}`);
}
);
// 인증 미들웨어
function authenticate(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
}
app.get('/api/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});
app.listen(3000);
6. OAuth2.0 서버 구현
자체 OAuth2.0 서버를 구현합니다.
npm install oauth2-server
const express = require('express');
const OAuth2Server = require('oauth2-server');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 데이터 저장소 (실제로는 DB 사용)
const clients = new Map([
['client_id_1', {
id: 'client_id_1',
secret: 'client_secret_1',
redirectUris: ['http://localhost:3001/callback'],
grants: ['authorization_code', 'refresh_token']
}]
]);
const users = new Map([
['user1', { id: 'user1', username: 'user1', password: 'password1' }]
]);
const authorizationCodes = new Map();
const accessTokens = new Map();
const refreshTokens = new Map();
// OAuth2 서버 모델
const model = {
// 클라이언트 가져오기
getClient: async (clientId, clientSecret) => {
const client = clients.get(clientId);
if (!client) return null;
if (clientSecret && client.secret !== clientSecret) return null;
return {
id: client.id,
redirectUris: client.redirectUris,
grants: client.grants
};
},
// 인가 코드 저장
saveAuthorizationCode: async (code, client, user) => {
const authCode = {
authorizationCode: code.authorizationCode,
expiresAt: code.expiresAt,
redirectUri: code.redirectUri,
scope: code.scope,
client: { id: client.id },
user: { id: user.id }
};
authorizationCodes.set(code.authorizationCode, authCode);
return authCode;
},
// 인가 코드 가져오기
getAuthorizationCode: async (authorizationCode) => {
return authorizationCodes.get(authorizationCode);
},
// 인가 코드 삭제
revokeAuthorizationCode: async (code) => {
return authorizationCodes.delete(code.authorizationCode);
},
// 액세스 토큰 저장
saveToken: async (token, client, user) => {
const savedToken = {
accessToken: token.accessToken,
accessTokenExpiresAt: token.accessTokenExpiresAt,
refreshToken: token.refreshToken,
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
scope: token.scope,
client: { id: client.id },
user: { id: user.id }
};
accessTokens.set(token.accessToken, savedToken);
if (token.refreshToken) {
refreshTokens.set(token.refreshToken, savedToken);
}
return savedToken;
},
// 액세스 토큰 가져오기
getAccessToken: async (accessToken) => {
return accessTokens.get(accessToken);
},
// 리프레시 토큰 가져오기
getRefreshToken: async (refreshToken) => {
return refreshTokens.get(refreshToken);
},
// 리프레시 토큰 삭제
revokeToken: async (token) => {
return refreshTokens.delete(token.refreshToken);
},
// 사용자 검증 (password grant용)
getUser: async (username, password) => {
const user = users.get(username);
if (!user || user.password !== password) return null;
return { id: user.id };
}
};
const oauth = new OAuth2Server({ model });
// 인가 엔드포인트
app.get('/authorize', async (req, res) => {
// 로그인 페이지 표시 또는 이미 로그인된 경우 권한 승인 페이지 표시
const { client_id, redirect_uri, state } = req.query;
res.send(`
<form action="/authorize" method="post">
<input type="hidden" name="client_id" value="${client_id}">
<input type="hidden" name="redirect_uri" value="${redirect_uri}">
<input type="hidden" name="state" value="${state}">
<input type="hidden" name="response_type" value="code">
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<button type="submit">Authorize</button>
</form>
`);
});
app.post('/authorize', async (req, res) => {
const { username, password } = req.body;
const user = users.get(username);
if (!user || user.password !== password) {
return res.status(401).send('Invalid credentials');
}
const request = new OAuth2Server.Request(req);
const response = new OAuth2Server.Response(res);
try {
const code = await oauth.authorize(request, response, {
authenticateHandler: {
handle: () => ({ id: user.id })
}
});
const redirectUri = new URL(req.body.redirect_uri);
redirectUri.searchParams.set('code', code.authorizationCode);
if (req.body.state) {
redirectUri.searchParams.set('state', req.body.state);
}
res.redirect(redirectUri.toString());
} catch (error) {
res.status(error.code || 500).json({ error: error.message });
}
});
// 토큰 엔드포인트
app.post('/token', async (req, res) => {
const request = new OAuth2Server.Request(req);
const response = new OAuth2Server.Response(res);
try {
const token = await oauth.token(request, response);
res.json({
access_token: token.accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: token.refreshToken
});
} catch (error) {
res.status(error.code || 500).json({ error: error.message });
}
});
// 보호된 리소스
app.get('/api/profile', async (req, res) => {
const request = new OAuth2Server.Request(req);
const response = new OAuth2Server.Response(res);
try {
const token = await oauth.authenticate(request, response);
const user = users.get(token.user.id);
res.json({ user: { id: user.id, username: user.username } });
} catch (error) {
res.status(error.code || 401).json({ error: error.message });
}
});
app.listen(3000);
7. 다중 제공자 지원
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const GitHubStrategy = require('passport-github2').Strategy;
const FacebookStrategy = require('passport-facebook').Strategy;
// 사용자 찾기 또는 생성 함수
async function findOrCreateUser(provider, profile) {
const providerId = `${provider}:${profile.id}`;
let user = users.get(providerId);
if (!user) {
user = {
id: providerId,
email: profile.emails?.[0]?.value,
name: profile.displayName,
picture: profile.photos?.[0]?.value,
provider
};
users.set(providerId, user);
}
return user;
}
// Google
passport.use(new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback'
},
async (accessToken, refreshToken, profile, done) => {
const user = await findOrCreateUser('google', profile);
done(null, user);
}
));
// GitHub
passport.use(new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/auth/github/callback'
},
async (accessToken, refreshToken, profile, done) => {
const user = await findOrCreateUser('github', profile);
done(null, user);
}
));
// Facebook
passport.use(new FacebookStrategy(
{
clientID: process.env.FACEBOOK_CLIENT_ID,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
callbackURL: '/auth/facebook/callback',
profileFields: ['id', 'emails', 'name', 'picture']
},
async (accessToken, refreshToken, profile, done) => {
const user = await findOrCreateUser('facebook', profile);
done(null, user);
}
));
// 라우트
['google', 'github', 'facebook'].forEach(provider => {
app.get(`/auth/${provider}`,
passport.authenticate(provider, { scope: ['profile', 'email'] })
);
app.get(`/auth/${provider}/callback`,
passport.authenticate(provider, { failureRedirect: '/login' }),
(req, res) => res.redirect('/')
);
});
8. PKCE (코드 교환용 증명 키)
SPA나 모바일 앱을 위한 보안 강화 방법입니다.
const crypto = require('crypto');
// PKCE 코드 생성
function generatePKCE() {
// Code Verifier: 43-128자의 랜덤 문자열
const codeVerifier = crypto.randomBytes(32)
.toString('base64url');
// Code Challenge: Verifier의 SHA256 해시
const codeChallenge = crypto.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return { codeVerifier, codeChallenge };
}
// 클라이언트 측
const { codeVerifier, codeChallenge } = generatePKCE();
// 인가 요청 URL
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('client_id', 'your-client-id');
authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// codeVerifier는 로컬에 저장
sessionStorage.setItem('code_verifier', codeVerifier);
// 토큰 교환 시 codeVerifier 포함
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: 'http://localhost:3000/callback',
client_id: 'your-client-id',
code_verifier: sessionStorage.getItem('code_verifier')
})
});
결론
OAuth2.0은 제3자 애플리케이션에 안전하게 접근 권한을 부여하는 표준 프로토콜입니다. Passport.js로 Google, GitHub 등의 소셜 로그인을 쉽게 구현할 수 있습니다. Authorization Code 플로우가 가장 안전하며, SPA에서는 JWT와 함께 사용하거나 PKCE를 적용합니다. 자체 OAuth2.0 서버 구현이 필요하면 oauth2-server 패키지를 사용할 수 있습니다.
728x90
반응형
'Node.js' 카테고리의 다른 글
| Node.js의 JSON 웹 토큰(JWT) 사용법 (0) | 2026.03.14 |
|---|---|
| Node.js의 Socket.IO 사용법 (0) | 2026.03.14 |
| Node.js의 인증 및 인가(Authentication and Authorization) (0) | 2026.03.13 |
| Node.js의 웹소켓(WebSocket) 사용법 (0) | 2026.03.13 |
| Node.js의 GraphQL API 만들기 (0) | 2026.03.12 |