SSO(Single Sign-On) 구현 완벽 가이드 - OAuth 2.0부터 SAML까지 실전 예제로 배우기
여러 서비스를 운영하다 보면 사용자들이 각 서비스마다 로그인해야 하는 불편함을 겪게 돼요. SSO(Single Sign-On)는 한 번의 로그인으로 여러 시스템에 접근할 수 있게 해주는 필수 인증 메커니즘이에요. 이 글에서는 OAuth 2.0, SAML, JWT 기반 SSO 구현 방법을 실무 코드와 함께 상세히 다룰 거예요. 보안 취약점과 흔한 실수까지 함께 살펴보면서, 여러분의 서비스에 바로 적용할 수 있는 실전 노하우를 얻어가세요.
SSO가 필요한 이유와 핵심 개념
SSO는 단순히 편의성만을 위한 기능이 아니에요. 보안 측면에서도 큰 이점이 있죠. 사용자가 여러 개의 비밀번호를 관리하지 않아도 되니 약한 비밀번호를 사용하거나 같은 비밀번호를 재사용하는 위험이 줄어들어요.
SSO의 핵심 구성 요소는 다음과 같아요:
- Identity Provider (IdP): 사용자 인증을 담당하는 중앙 인증 서버
- Service Provider (SP): 실제 서비스를 제공하는 애플리케이션
- 인증 토큰: 사용자의 인증 상태를 나타내는 증표 (JWT, SAML Assertion 등)
- 세션 관리: 로그인 상태를 유지하는 메커니즘
SSO 구현 방식은 크게 세 가지로 나뉘어요. OAuth 2.0/OIDC 기반은 모던 웹 애플리케이션에 적합하고, SAML 기반은 엔터프라이즈 환경에서 많이 사용되며, JWT 기반 커스텀 SSO는 자체 서비스 간 통합에 유용해요.
OAuth 2.0 기반 SSO 구현하기
OAuth 2.0은 가장 널리 사용되는 SSO 프로토콜이에요. 특히 OpenID Connect(OIDC)와 함께 사용하면 인증과 인가를 모두 처리할 수 있죠.
Authorization Code Flow 구현
가장 안전한 OAuth 2.0 플로우인 Authorization Code Flow를 구현해볼게요. 이 방식은 클라이언트 시크릿을 안전하게 보호할 수 있어요.
// Express.js 기반 OAuth 2.0 SSO 서버 구현
const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const app = express();
const PORT = 3000;
// 인증 코드 저장소 (실무에서는 Redis 등 사용)
const authCodes = new Map();
const refreshTokens = new Map();
// 클라이언트 정보 (실무에서는 DB에 저장)
const clients = {
'client-app-1': {
clientSecret: 'super-secret-key',
redirectUris: ['http://localhost:8080/callback'],
allowedScopes: ['profile', 'email']
}
};
// JWT 서명 키 (환경변수로 관리 필수!)
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'refresh-secret';
/**
* 1단계: 인증 요청 엔드포인트
* 클라이언트가 사용자를 이 URL로 리다이렉트
*/
app.get('/oauth/authorize', (req, res) => {
const { client_id, redirect_uri, response_type, scope, state } = req.query;
// 필수 파라미터 검증
if (!client_id || !redirect_uri || response_type !== 'code') {
return res.status(400).json({ error: 'invalid_request' });
}
// 클라이언트 검증
const client = clients[client_id];
if (!client || !client.redirectUris.includes(redirect_uri)) {
return res.status(401).json({ error: 'unauthorized_client' });
}
// 실제로는 로그인 페이지를 보여주고 사용자 인증 수행
// 여기서는 간소화를 위해 자동 승인
const authCode = crypto.randomBytes(32).toString('hex');
// 인증 코드 저장 (5분 유효)
authCodes.set(authCode, {
clientId: client_id,
redirectUri: redirect_uri,
scope: scope,
userId: 'user-123', // 실제로는 인증된 사용자 ID
expiresAt: Date.now() + 5 * 60 * 1000
});
// 클라이언트로 리다이렉트 (state는 CSRF 방지용)
res.redirect(`${redirect_uri}?code=${authCode}&state=${state}`);
});
/**
* 2단계: 토큰 발급 엔드포인트
* 클라이언트가 인증 코드를 액세스 토큰으로 교환
*/
app.post('/oauth/token', express.json(), (req, res) => {
const { grant_type, code, client_id, client_secret, redirect_uri, refresh_token } = req.body;
// Refresh Token 플로우
if (grant_type === 'refresh_token') {
const tokenData = refreshTokens.get(refresh_token);
if (!tokenData || tokenData.clientId !== client_id) {
return res.status(401).json({ error: 'invalid_grant' });
}
// 새 액세스 토큰 발급
const accessToken = jwt.sign(
{ sub: tokenData.userId, scope: tokenData.scope },
JWT_SECRET,
{ expiresIn: '1h' }
);
return res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600
});
}
// Authorization Code 플로우
if (grant_type !== 'authorization_code') {
return res.status(400).json({ error: 'unsupported_grant_type' });
}
// 클라이언트 인증
const client = clients[client_id];
if (!client || client.clientSecret !== client_secret) {
return res.status(401).json({ error: 'invalid_client' });
}
// 인증 코드 검증
const authData = authCodes.get(code);
if (!authData || authData.clientId !== client_id ||
authData.redirectUri !== redirect_uri ||
authData.expiresAt < Date.now()) {
return res.status(401).json({ error: 'invalid_grant' });
}
// 인증 코드는 1회용
authCodes.delete(code);
// 액세스 토큰 발급 (JWT 사용)
const accessToken = jwt.sign(
{
sub: authData.userId,
scope: authData.scope,
iss: 'https://auth.example.com',
aud: client_id
},
JWT_SECRET,
{ expiresIn: '1h' }
);
// 리프레시 토큰 발급
const newRefreshToken = crypto.randomBytes(32).toString('hex');
refreshTokens.set(newRefreshToken, {
clientId: client_id,
userId: authData.userId,
scope: authData.scope
});
res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: newRefreshToken,
scope: authData.scope
});
});
/**
* 사용자 정보 엔드포인트 (OIDC UserInfo)
*/
app.get('/oauth/userinfo', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'invalid_token' });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, JWT_SECRET);
// 실제로는 DB에서 사용자 정보 조회
res.json({
sub: decoded.sub,
name: 'John Doe',
email: 'john@example.com',
email_verified: true
});
} catch (error) {
res.status(401).json({ error: 'invalid_token' });
}
});
app.listen(PORT, () => {
console.log(`OAuth 2.0 Server running on port ${PORT}`);
});왜 Authorization Code Flow를 사용하나요? Implicit Flow와 달리 클라이언트 시크릿을 사용해 추가 인증 계층을 제공하고, 액세스 토큰이 브라우저 히스토리에 남지 않아 더 안전해요.
SAML 기반 엔터프라이즈 SSO 구현
SAML(Security Assertion Markup Language)은 주로 B2B 환경이나 대기업에서 사용하는 SSO 프로토콜이에요. XML 기반이라 복잡하지만, 엔터프라이즈 레벨의 보안 요구사항을 충족시켜요.
// Node.js에서 passport-saml을 사용한 SAML SP 구현
const express = require('express');
const passport = require('passport');
const SamlStrategy = require('passport-saml').Strategy;
const session = require('express-session');
const fs = require('fs');
const app = express();
// 세션 설정 (실무에서는 Redis 세션 스토어 사용)
app.use(session({
secret: 'your-session-secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS 환경에서만
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24시간
}
}));
app.use(passport.initialize());
app.use(passport.session());
// SAML 전략 설정
passport.use(new SamlStrategy(
{
// IdP 설정 (예: Okta, Azure AD)
entryPoint: 'https://idp.example.com/sso/saml',
issuer: 'https://sp.example.com/metadata',
callbackUrl: 'https://sp.example.com/login/callback',
// 인증서 설정 (IdP의 공개 인증서)
cert: fs.readFileSync('./idp-cert.pem', 'utf-8'),
// SP 인증서 (선택사항, 서명된 요청 시 필요)
privateCert: fs.readFileSync('./sp-key.pem', 'utf-8'),
decryptionPvk: fs.readFileSync('./sp-key.pem', 'utf-8'),
// 서명 옵션
signatureAlgorithm: 'sha256',
digestAlgorithm: 'sha256',
// 요청/응답 서명 검증
wantAssertionsSigned: true,
wantAuthnResponseSigned: true,
// 사용자 식별자 매핑
identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
},
/**
* SAML 응답 검증 콜백
* profile에는 IdP에서 전달한 사용자 정보가 포함됨
*/
function(profile, done) {
// 사용자 정보 매핑 및 저장
const user = {
id: profile.nameID,
email: profile.email || profile.nameID,
firstName: profile.firstName,
lastName: profile.lastName,
department: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department'],
roles: profile.roles || []
};
// 실무에서는 DB에서 사용자 조회 또는 생성
// findOrCreateUser(user, (err, dbUser) => {
// return done(err, dbUser);
// });
return done(null, user);
}
));
// Passport 직렬화/역직렬화
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser((id, done) => {
// 실제로는 DB에서 사용자 조회
done(null, { id: id });
});
/**
* SAML 메타데이터 엔드포인트
* IdP 설정 시 이 URL을 등록
*/
app.get('/metadata', (req, res) => {
res.type('application/xml');
res.send(`<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="https://sp.example.com/metadata">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://sp.example.com/login/callback"
index="0"/>
</SPSSODescriptor>
</EntityDescriptor>`);
});
/**
* SAML 로그인 시작
* 사용자를 IdP로 리다이렉트
*/
app.get('/login',
passport.authenticate('saml', {
failureRedirect: '/login-error',
failureFlash: true
})
);
/**
* SAML 응답 콜백
* IdP가 인증 후 이 엔드포인트로 POST
*/
app.post('/login/callback',
passport.authenticate('saml', {
failureRedirect: '/login-error',
failureFlash: true
}),
(req, res) => {
// 로그인 성공 후 원래 페이지로 리다이렉트
const returnTo = req.session.returnTo || '/dashboard';
delete req.session.returnTo;
res.redirect(returnTo);
}
);
/**
* SAML 로그아웃
* Single Logout (SLO) 지원
*/
app.get('/logout', (req, res) => {
req.logout(() => {
// IdP에도 로그아웃 요청 (SLO)
// SAML SLO 요청 생성 및 전송
res.redirect('/');
});
});
/**
* 인증 미들웨어
*/
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
req.session.returnTo = req.originalUrl;
res.redirect('/login');
}
// 보호된 라우트 예시
app.get('/dashboard', ensureAuthenticated, (req, res) => {
res.json({
message: 'Welcome to dashboard',
user: req.user
});
});
app.listen(3000, () => {
console.log('SAML SP running on port 3000');
});SAML을 선택하는 이유는? 많은 엔터프라이즈 IdP(Okta, Azure AD, Ping Identity)가 SAML을 기본 지원하고, XML 서명을 통해 강력한 무결성 검증이 가능해요. 또한 Single Logout(SLO) 같은 고급 기능을 표준으로 제공하죠.
JWT 기반 커스텀 SSO 토큰 설계
자체 서비스 간 SSO를 구축할 때는 JWT를 활용한 커스텀 솔루션이 효율적이에요. 복잡한 프로토콜 없이 가볍게 구현할 수 있죠.
// JWT 기반 SSO 토큰 서비스
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
class SSOTokenService {
constructor(config) {
// RSA 키 페어 사용 (비대칭 암호화)
// 공개키는 여러 서비스에 배포, 개인키는 인증 서버만 보유
this.privateKey = config.privateKey;
this.publicKey = config.publicKey;
this.issuer = config.issuer || 'https://sso.example.com';
this.tokenExpiry = config.tokenExpiry || '1h';
}
/**
* SSO 토큰 발급
* @param {Object} user - 사용자 정보
* @param {Array} audiences - 토큰이 유효한 서비스 목록
* @returns {string} JWT 토큰
*/
issueToken(user, audiences = []) {
const payload = {
// 표준 클레임
iss: this.issuer, // 발급자
sub: user.id, // 주체 (사용자 ID)
aud: audiences, // 대상 (서비스 목록)
exp: Math.floor(Date.now() / 1000) + 3600, // 만료 시간
iat: Math.floor(Date.now() / 1000), // 발급 시간
jti: crypto.randomUUID(), // 토큰 고유 ID (재사용 방지)
// 커스텀 클레임
email: user.email,
name: user.name,
roles: user.roles || [],
permissions: user.permissions || [],
// 세션 정보
session_id: this.generateSessionId(),
// 보안 플래그
mfa_verified: user.mfaVerified || false,
device_id: user.deviceId
};
// RS256 알고리즘으로 서명
return jwt.sign(payload, this.privateKey, {
algorithm: 'RS256',
header: {
kid: 'sso-key-2024-01' // 키 식별자 (키 로테이션 시 유용)
}
});
}
/**
* 토큰 검증 및 파싱
* @param {string} token - JWT 토큰
* @param {string} audience - 현재 서비스 식별자
* @returns {Object} 검증된 페이로드
*/
verifyToken(token, audience) {
try {
const decoded = jwt.verify(token, this.publicKey, {
algorithms: ['RS256'],
issuer: this.issuer,
audience: audience,
clockTolerance: 30 // 시계 오차 허용 (30초)
});
// 추가 검증 로직
if (this.isTokenRevoked(decoded.jti)) {
throw new Error('Token has been revoked');
}
return decoded;
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new Error('Token expired');
} else if (error.name === 'JsonWebTokenError') {
throw new Error('Invalid token');
}
throw error;
}
}
/**
* 리프레시 토큰 발급
* 액세스 토큰보다 긴 유효기간
*/
issueRefreshToken(user) {
const payload = {
iss: this.issuer,
sub: user.id,
type: 'refresh',
jti: crypto.randomUUID(),
exp: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60) // 30일
};
return jwt.sign(payload, this.privateKey, {
algorithm: 'RS256'
});
}
/**
* 세션 ID 생성
* 분산 환경에서 세션 추적용
*/
generateSessionId() {
return crypto.randomBytes(32).toString('hex');
}
/**
* 토큰 폐기 확인
* Redis 등에서 폐기된 토큰 목록 조회
*/
isTokenRevoked(tokenId) {
// 실제 구현에서는 Redis 체크
// return redis.sismember('revoked_tokens', tokenId);
return false;
}
/**
* 토큰 폐기 (로그아웃 시)
*/
async revokeToken(tokenId, expiresIn) {
// Redis에 토큰 ID 저장 (만료 시간까지)
// await redis.setex(`revoked:${tokenId}`, expiresIn, '1');
}
}
/**
* Express 미들웨어로 사용
*/
function createSSOMiddleware(ssoService, serviceName) {
return function(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
try {
const payload = ssoService.verifyToken(token, serviceName);
// 요청 객체에 사용자 정보 추가
req.user = {
id: payload.sub,
email: payload.email,
name: payload.name,
roles: payload.roles,
permissions: payload.permissions,
sessionId: payload.session_id
};
// 권한 검증 헬퍼 함수 추가
req.hasRole = (role) => payload.roles.includes(role);
req.hasPermission = (perm) => payload.permissions.includes(perm);
next();
} catch (error) {
res.status(401).json({ error: error.message });
}
};
}
// 사용 예시
const fs = require('fs');
const ssoService = new SSOTokenService({
privateKey: fs.readFileSync('./private.pem'),
publicKey: fs.readFileSync('./public.pem'),
issuer: 'https://sso.example.com'
});
// 서비스 A에서 토큰 검증
const express = require('express');
const app = express();
const ssoMiddleware = createSSOMiddleware(ssoService, 'service-a');
app.get('/api/protected', ssoMiddleware, (req, res) => {
if (!req.hasRole('admin')) {
return res.status(403).json({ error: 'Admin role required' });
}
res.json({
message: 'Protected resource',
user: req.user
});
});
module.exports = { SSOTokenService, createSSOMiddleware };JWT를 사용하는 이유는? 상태를 저장하지 않는(stateless) 특성 덕분에 서버 부하가 적고, 마이크로서비스 환경에서 각 서비스가 독립적으로 토큰을 검증할 수 있어요. 또한 JSON 포맷이라 파싱이 쉽고 디버깅이 편해요.
세션 관리와 크로스 도메인 쿠키 처리
SSO에서 가장 까다로운 부분 중 하나가 여러 도메인 간 세션을 유지하는 거예요. 특히 쿠키의 SameSite 정책 때문에 더 복잡해졌죠.
// 크로스 도메인 SSO 세션 관리
const express = require('express');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const app = express();
// Redis 클라이언트 생성 (세션 공유용)
const redisClient = createClient({
host: 'redis.example.com',
port: 6379,
password: process.env.REDIS_PASSWORD
});
redisClient.connect().catch(console.error);
app.use(cookieParser());
/**
* 세션 설정
* 여러 도메인에서 공유 가능하도록 구성
*/
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
name: 'sso.sid', // 세션 쿠키 이름
cookie: {
// 크로스 도메인 설정
domain: '.example.com', // 서브도메인 공유
path: '/',
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'none', // 크로스 사이트 허용
maxAge: 24 * 60 * 60 * 1000 // 24시간
},
resave: false,
saveUninitialized: false,
// 프록시 신뢰 (로드 밸런서 뒤에 있을 때)
proxy: true
}));
/**
* 토큰 기반 크로스 도메인 SSO
* 쿠키가 작동하지 않는 환경 대응
*/
class CrossDomainSSOHandler {
/**
* SSO 토큰을 URL 파라미터로 전달
* @param {string} targetUrl - 이동할 서비스 URL
* @param {Object} user - 사용자 정보
*/
static generateSSOLink(targetUrl, user, ssoService) {
// 일회성 토큰 생성 (5분 유효)
const oneTimeToken = ssoService.issueToken(user, ['*'], {
expiresIn: '5m',
type: 'cross_domain_sso'
});
// URL에 토큰 첨부
const url = new URL(targetUrl);
url.searchParams.set('sso_token', oneTimeToken);
return url.toString();
}
/**
* SSO 토큰 검증 및 세션 생성
*/
static async handleSSOToken(req, res, ssoService) {
const { sso_token } = req.query;
if (!sso_token) {
return res.redirect('/login');
}
try {
// 토큰 검증
const payload = ssoService.verifyToken(sso_token, '*');
// 토큰 타입 확인
if (payload.type !== 'cross_domain_sso') {
throw new Error('Invalid token type');
}
// 일회성 토큰은 사용 후 즉시 폐기
await ssoService.revokeToken(payload.jti, 300);
// 세션 생성
req.session.userId = payload.sub;
req.session.email = payload.email;
req.session.roles = payload.roles;
// 토큰 제거한 URL로 리다이렉트 (보안)
const cleanUrl = new URL(req.originalUrl, `https://${req.hostname}`);
cleanUrl.searchParams.delete('sso_token');
res.redirect(cleanUrl.pathname + cleanUrl.search);
} catch (error) {
console.error('SSO token verification failed:', error);
res.redirect('/login?error=invalid_sso_token');
}
}
}
/**
* iframe 기반 SSO (레거시 지원)
* 서드파티 쿠키 제한 환경에서 대안
*/
app.get('/sso/check', (req, res) => {
// PostMessage API로 상태 전달
res.send(`
<!DOCTYPE html>
<html>
<head><title>SSO Check</title></head>
<body>
<script>
(function() {
const isAuthenticated = ${!!req.session.userId};
const userData = ${JSON.stringify(req.session.userId ? {
id: req.session.userId,
email: req.session.email
} : null)};
// 부모 윈도우로 메시지 전송
window.parent.postMessage({
type: 'sso_check_result',
authenticated: isAuthenticated,
user: userData
}, '*'); // 실제로는 특정 origin 지정
})();
</script>
</body>
</html>
`);
});
/**
* CORS 설정
* 여러 도메인에서 API 호출 시
*/
const cors = require('cors');
app.use(cors({
origin: [
'https://app1.example.com',
'https://app2.example.com',
'https://app3.example.com'
],
credentials: true, // 쿠키 포함 허용
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
module.exports = { CrossDomainSSOHandler };크로스 도메인 처리의 핵심은? SameSite=None과 Secure 플래그를 함께 사용해야 하고, 서드파티 쿠키를 차단하는 브라우저를 위해 토큰 기반 대안을 준비해야 해요. 또한 CORS 설정에서 credentials: true를 잊지 말아야 하죠.
흔한 실수와 보안 취약점 방지하기
SSO 구현 시 자주 발생하는 실수들을 살펴볼게요. 이런 실수들은 심각한 보안 문제로 이어질 수 있어요.
실수 1: 토큰 검증 누락
잘못된 예시:
// 위험! 토큰을 디코딩만 하고 서명 검증 안 함
app.get('/api/user', (req, res) => {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.decode(token); // 서명 검증 없음!
res.json({ user: decoded });
});올바른 예시:
app.get('/api/user', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Token required' });
}
try {
// 반드시 verify 사용 (서명 검증 포함)
const decoded = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'], // 알고리즘 명시 (알고리즘 혼동 공격 방지)
issuer: EXPECTED_ISSUER,
audience: SERVICE_NAME,
clockTolerance: 30
});
// 토큰 폐기 여부 확인
if (await isRevoked(decoded.jti)) {
return res.status(401).json({ error: 'Token revoked' });
}
res.json({ user: decoded });
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});실수 2: State 파라미터 미사용 (CSRF 취약점)
잘못된 예시:
// OAuth 콜백에서 state 검증 안 함
app.get('/callback', async (req, res) => {
const { code } = req.query;
// CSRF 공격에 취약!
const tokens = await exchangeCodeForTokens(code);
req.session.accessToken = tokens.access_token;
res.redirect('/dashboard');
});올바른 예시:
const crypto = require('crypto');
// 인증 시작 시 state 생성
app.get('/login', (req, res) => {
const state = crypto.randomBytes(32).toString('hex');
// 세션에 state 저장
req.session.oauthState = state;
const authUrl = `https://idp.example.com/authorize?` +
`client_id=${CLIENT_ID}&` +
`redirect_uri=${REDIRECT_URI}&` +
`response_type=code&` +
`state=${state}`; // state 포함
res.redirect(authUrl);
});
// 콜백에서 state 검증
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// state 검증 (CSRF 방지)
if (!state || state !== req.session.oauthState) {
return res.status(400).json({ error: 'Invalid state parameter' });
}
// 사용한 state는 즉시 삭제
delete req.session.oauthState;
const tokens = await exchangeCodeForTokens(code);
req.session.accessToken = tokens.access_token;
res.redirect('/dashboard');
});실수 3: 리프레시 토큰을 로컬 스토리지에 저장
잘못된 예시:
// 클라이언트 측 - XSS에 취약!
fetch('/oauth/token', {
method: 'POST',
body: JSON.stringify({ code: authCode })
})
.then(res => res.json())
.then(data => {
// 위험! localStorage는 JavaScript로 접근 가능
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
});올바른 예시:
// 서버 측 - HttpOnly 쿠키 사용
app.post('/oauth/token', async (req, res) => {
const { code } = req.body;
const tokens = await exchangeCodeForTokens(code);
// 리프레시 토큰은 HttpOnly 쿠키에 저장
res.cookie('refresh_token', tokens.refresh_token, {
httpOnly: true, // JavaScript 접근 불가
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30일
path: '/oauth/refresh' // 특정 경로에만
});
// 액세스 토큰만 응답 (메모리에 저장)
res.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in
});
});
// 클라이언트 측 - 메모리에만 저장
let accessToken = null;
fetch('/oauth/token', {
method: 'POST',
credentials: 'include', // 쿠키 포함
body: JSON.stringify({ code: authCode })
})
.then(res => res.json())
.then(data => {
// 메모리에만 저장 (탭 닫으면 사라짐)
accessToken = data.access_token;
});성능 최적화와 모니터링
SSO는 모든 인증의 중심이라 성능이 중요해요. 병목 현상을 제거하고 안정성을 높이는 방법을 살펴볼게요.
// Redis 캐싱을 활용한 성능 최적화
const Redis = require('ioredis');
const redis = new Redis({
host: 'redis.example.com',
port: 6379,
password: process.env.REDIS_PASSWORD,
// 연결 풀 설정
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: true
});
class OptimizedSSOService {
constructor(ssoTokenService) {
this.tokenService = ssoTokenService;
this.cachePrefix = 'sso:user:';
this.cacheTTL = 300; // 5분
}
/**
* 사용자 정보 캐싱
* DB 조회 최소화
*/
async getUserInfo(userId) {
const cacheKey = `${this.cachePrefix}${userId}`;
try {
// 캐시 먼저 확인
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 캐시 미스 시 DB 조회
const user = await this.fetchUserFromDB(userId);
// 캐시 저장
await redis.setex(
cacheKey,
this.cacheTTL,
JSON.stringify(user)
);
return user;
} catch (error) {
console.error('Cache error:', error);
// 캐시 실패 시에도 DB에서 조회
return this.fetchUserFromDB(userId);
}
}
/**
* 토큰 블랙리스트 캐싱
* 매번 DB 조회하지 않도록
*/
async isTokenRevoked(tokenId) {
const key = `sso:revoked:${tokenId}`;
const exists = await redis.exists(key);
return exists === 1;
}
/**
* 배치 사용자 조회 (N+1 문제 해결)
*/
async getUsersInfoBatch(userIds) {
const pipeline = redis.pipeline();
// 한 번에 여러 키 조회
userIds.forEach(id => {
pipeline.get(`${this.cachePrefix}${id}`);
});
const results = await pipeline.exec();
const users = [];
const missingIds = [];
results.forEach(([err, data], index) => {
if (data) {
users[index] = JSON.parse(data);
} else {
missingIds.push(userIds[index]);
}
});
// 캐시 미스된 사용자만 DB 조회
if (missingIds.length > 0) {
const dbUsers = await this.fetchUsersFromDB(missingIds);
// DB 결과를 캐시에 저장
const cachePipeline = redis.pipeline();
dbUsers.forEach(user => {
cachePipeline.setex(
`${this.cachePrefix}${user.id}`,
this.cacheTTL,
JSON.stringify(user)
);
users[userIds.indexOf(user.id)] = user;
});
await cachePipeline.exec();
}
return users;
}
/**
* 토큰 검증 결과 캐싱
* 동일 토큰 반복 검증 최적화
*/
async verifyTokenCached(token, audience) {
// 토큰 해시를 캐시 키로 사용
const crypto = require('crypto');
const tokenHash = crypto
.createHash('sha256')
.update(token)
.digest('hex');
const cacheKey = `sso:token:${tokenHash}`;
// 캐시 확인
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 캐시 미스 시 실제 검증
const payload = this.tokenService.verifyToken(token, audience);
// 검증 결과 캐싱 (토큰 만료 시간까지)
const ttl = payload.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.setex(cacheKey, ttl, JSON.stringify(payload));
}
return payload;
}
async fetchUserFromDB(userId) {
// 실제 DB 조회 로직
return { id: userId, name: 'User' };
}
async fetchUsersFromDB(userIds) {
// 실제 배치 DB 조회 로직
return userIds.map(id => ({ id, name: 'User' }));
}
}
/**
* SSO 모니터링 및 로깅
*/
class SSOMonitor {
/**
* 로그인 이벤트 추적
*/
static async logAuthEvent(eventType, userId, metadata = {}) {
const event = {
timestamp: new Date().toISOString(),
type: eventType,
userId: userId,
ip: metadata.ip,
userAgent: metadata.userAgent,
success: metadata.success,
errorCode: metadata.errorCode,
duration: metadata.duration
};
// ELK 스택, DataDog 등으로 전송
console.log(JSON.stringify(event));
// 실패 이벤트 카운트 (브루트 포스 감지)
if (!metadata.success) {
await this.incrementFailedAttempts(userId, metadata.ip);
}
}
/**
* 실패 시도 카운팅
* Rate limiting에 활용
*/
static async incrementFailedAttempts(userId, ip) {
const key = `sso:failed:${userId || ip}`;
const attempts = await redis.incr(key);
// 첫 실패 시 TTL 설정 (1시간)
if (attempts === 1) {
await redis.expire(key, 3600);
}
// 5회 이상 실패 시 알림
if (attempts >= 5) {
console.warn(`Suspicious activity: ${userId || ip} - ${attempts} failed attempts`);
// 알림 발송 로직
}
return attempts;
}
/**
* 메트릭 수집
*/
static async recordMetric(metricName, value, tags = {}) {
// Prometheus, StatsD 등으로 전송
const metric = {
name: metricName,
value: value,
tags: tags,
timestamp: Date.now()
};
// 예: 평균 토큰 검증 시간
// recordMetric('sso.token.verify.duration', 45, { service: 'api' });
}
}
module.exports = { OptimizedSSOService, SSOMonitor };성능 최적화의 핵심은? 캐싱을 적극 활용하되, 보안에 영향 없는 범위 내에서 해야 해요. 토큰 검증 결과는 캐싱해도 되지만, 폐기 여부 확인은 반드시 실시간으로 해야 하죠. 또한 모니터링을 통해 이상 징후를 조기에 발견하는 것이 중요해요.
결론: SSO 구현 체크리스트
SSO 구현은 복잡하지만, 올바르게 구축하면 사용자 경험과 보안을 동시에 개선할 수 있어요. 핵심 포인트를 정리하면:
필수 구현 사항:
- 토큰 서명 검증은 반드시 수행 (jwt.verify 사용)
- State 파라미터로 CSRF 공격 방지
- 리프레시 토큰은 HttpOnly 쿠키에 저장
- 토큰 폐기 메커니즘 구현 (로그아웃 시)
- HTTPS 강제 적용
보안 강화:
- Rate limiting으로 브루트 포스 공격 차단
- 토큰에 audience 클레임 포함 (서비스별 격리)
- 정기적인 키 로테이션
- MFA 연동 고려
- 감사 로그 기록
성능 최적화:
- Redis 캐싱으로 DB 부하 감소
- 토큰 검증 결과 캐싱
- 배치 조회로 N+1 문제 해결
- Connection pooling 활용
실무 적용 팁:
엔터프라이즈 환경이라면 SAML을, 모던 웹 앱이라면 OAuth 2.0/OIDC를, 자체 서비스 간 통합이라면 JWT 기반 커스텀 SSO를 선택하세요. 처음부터 완벽할 필요는 없어요. 기본적인 OAuth 2.0 구현으로 시작해서 점진적으로 기능을 추가하는 것을 추천해요.
관련 추천 주제:
- OAuth 2.0 PKCE(Proof Key for Code Exchange) 구현하기 - 네이티브 앱과 SPA를 위한 보안 강화 방법
- 다중 요소 인증(MFA) 연동 가이드 - TOTP, SMS, 생체 인증을 SSO에 통합하는 방법
- 마이크로서비스 환경에서의 분산 세션 관리 - Redis Sentinel, JWT를 활용한 확장 가능한 세션 아키텍처
여러분의 서비스에 맞는 SSO를 구축해서 사용자들에게 매끄러운 인증 경험을 제공해보세요!
'인증|사용자 관리' 카테고리의 다른 글
| OAuth 2.0 동작 원리 완벽 정리 - 10분만에 이해하는 소셜 로그인 인증 구조 (1) | 2026.03.12 |
|---|---|
| JWT 인증 구현 완벽 가이드 - Node.js와 Spring Boot 실전 예제로 배우기 (0) | 2026.03.12 |