First commit

This commit is contained in:
kyy
2026-01-15 14:12:41 +09:00
commit b28238cfd3
15 changed files with 1949 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Dependencies
# Ignore the node_modules directory in the sso-demo folder
sso-demo/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment variables
# Never commit .env files with sensitive credentials
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDEs and editors folders
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
# OS generated files
.DS_Store
Thumbs.db

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Use an official Node.js runtime as a parent image
FROM node:18-alpine
# Set the working directory in the container
WORKDIR /usr/src/app
# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
# Install application dependencies
RUN npm install
# Copy the rest of the application source code
COPY . .
# Expose the port the app runs on
EXPOSE 3000
# Define the command to run the application
CMD [ "npm", "start" ]

50
README.md Normal file
View File

@@ -0,0 +1,50 @@
# Express.js SSO 데모 (Docker Compose)
이 프로젝트는 Express.js 애플리케이션에서 SSO(Single Sign-On) 로그인 흐름을 구현한 간단한 데모입니다. `docker-compose`를 사용하여 간편하게 컨테이너 환경에서 실행할 수 있도록 설정되어 있습니다.
## 주요 기능
- **팝업 기반 인증**: 페이지 이동 없이 팝업 창을 통해 사용자에게 쾌적한 로그인 경험을 제공합니다.
- **JWT 처리**: URL 쿼리 파라미터로 전달된 JWT(JSON Web Token)를 안전하게 처리합니다.
- **자동 사용자 생성**: SSO 로그인이 성공했을 때, 해당 사용자가 존재하지 않으면 자동으로 신규 사용자를 생성합니다.
- **세션 관리**: `express-session`을 사용하여 사용자의 로그인 상태를 유지합니다.
- **SSO 제공자 시뮬레이션**: 외부 SSO 제공자의 동작을 모방하는 간단한 HTML 페이지를 포함하여 쉬운 테스트 환경을 제공합니다.
## 요구 사항
- [Docker](https://www.docker.com/products/docker-desktop/)
- [Docker Compose](https://docs.docker.com/compose/install/) (Docker Desktop에 포함되어 있음)
## 애플리케이션 실행하기
1. **저장소를 클론하거나 코드를 다운로드합니다.**
2. **프로젝트 루트 디렉토리에서 아래 명령어를 실행하여 Docker 컨테이너를 빌드하고 실행합니다.**
```bash
docker-compose up --build
```
`-d` 플래그를 추가하면 백그라운드에서 실행할 수 있습니다 (`docker-compose up --build -d`).
3. **브라우저를 엽니다.**
[http://localhost:3000](http://localhost:3000) 주소로 접속합니다.
4. **애플리케이션 종료하기**
```bash
docker-compose down
```
## 개발 환경
- `docker-compose.yml`은 `sso-demo` 디렉토리를 컨테이너의 작업 디렉토리로 마운트합니다.
- `nodemon`이 설치되어 있어, 로컬에서 소스 코드를 수정하면 컨테이너 안의 서버가 자동으로 재시작됩니다.
## 동작 원리
1. 사용자가 메인 페이지의 "Login with SSO" 버튼을 클릭합니다.
2. 시뮬레이션된 SSO 제공자의 로그인 페이지(`/sso_popup.html`)가 팝업 창으로 열립니다.
3. 사용자가 팝업 창의 "Confirm Login" 버튼을 클릭합니다.
4. 팝업 창은 테스트용 JWT를 생성하여 `window.postMessage`를 통해 메인 애플리케이션 창으로 전송합니다.
5. 메인 창은 토큰을 수신한 뒤, `?token=` 쿼리 파라미터에 토큰을 추가하여 페이지를 새로고침합니다.
6. Express 백엔드가 이 요청을 가로챕니다. `ssoHandler` 미들웨어는 토큰을 처리하고, 토큰의 `sub` 클레임을 기반으로 사용자를 찾거나 생성한 뒤, 사용자 ID를 세션에 저장합니다.
7. 미들웨어는 주소창을 깔끔하게 정리하기 위해 토큰 없이 원래 URL로 리다렉트합니다.
8. 이제 페이지는 로그인된 사용자 정보를 표시하며 렌더링됩니다.

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
version: '3.8'
services:
web:
build:
context: ./sso-demo
dockerfile: ../Dockerfile
container_name: sso-demo-container
ports:
- "3000:3000"
volumes:
- ./sso-demo:/usr/src/app
- /usr/src/app/node_modules
environment:
- NODE_ENV=development

124
docs/sso_express_guide.md Normal file
View File

@@ -0,0 +1,124 @@
# Express.js SSO 연동 구현 계획
이 문서는 `sso_integration_guide.md`에서 검증된 팝업 기반 SSO 인증 흐름을 Express.js 환경에 맞게 구현하기 위한 기술 계획을 상세히 설명합니다.
## 1. 프로젝트 목표
- 외부 SSO 인증을 통해 Express.js 애플리케이션에 사용자를 자동으로 로그인 및 회원가입시키는 기능을 구현합니다.
- `express-session`을 활용하여 안정적인 세션 관리 시스템을 구축합니다.
- EJS 또는 유사한 템플릿 엔진을 사용하여 로그인 상태에 따른 동적 UI를 제공합니다.
---
## 2. 핵심 로그인 플로우 (Sequence Diagram)
WordPress 구현과 거의 동일한 흐름을 따르되, 백엔드 기술 스택만 Express.js로 변경됩니다.
```mermaid
sequenceDiagram
actor User
participant Express Frontend (Browser JS)
participant SSO Service (Popup)
participant Express Backend (Node.js)
User->>Express Frontend (Browser JS): "SSO 로그인" 버튼 클릭
Express Frontend (Browser JS)->>SSO Service (Popup): window.open() 팝업 실행
SSO Service (Popup)-->>User: 로그인 UI 표시
User->>SSO Service (Popup): 아이디/패스워드 입력 및 로그인
SSO Service (Popup)->>SSO Service (Popup): 인증 처리 및 JWT 생성
SSO Service (Popup)->>Express Frontend (Browser JS): postMessage로 JWT 전송
Note right of SSO Service (Popup): 팝업창 닫힘
Express Frontend (Browser JS)->>Express Frontend (Browser JS): JWT 수신 후, `?token=[JWT]` 파라미터와 함께 페이지 새로고침
Express Frontend (Browser JS)->>Express Backend (Node.js): 페이지 요청 (GET /?token=...)
Express Backend (Node.js)->>Express Backend (Node.js): **SSO 처리 미들웨어**에서 `token` 파라미터 감지
Express Backend (Node.js)->>Express Backend (Node.js): JWT 디코딩하여 `sub` 클레임 추출
Express Backend (Node.js)->>Express Backend (Node.js): DB에서 `sso_sub` 값으로 사용자 조회
alt 사용자 없음
Express Backend (Node.js)->>Express Backend (Node.js): 신규 사용자 생성 (DB에 저장)
Express Backend (Node.js)->>Express Backend (Node.js): `sso_sub` 메타 정보 함께 저장
end
Express Backend (Node.js)->>Express Backend (Node.js): **req.session**에 사용자 정보 저장
Express Backend (Node.js)-->>Express Frontend (Browser JS): 세션 쿠키 생성 후 `token` 제거된 URL로 리다이렉트
Express Frontend (Browser JS)->>Express Backend (Node.js): 최종 페이지 요청 (GET /)
Express Backend (Node.js)-->>Express Frontend (Browser JS): 로그인된 페이지 렌더링
Express Frontend (Browser JS)-->>User: 로그인 완료된 화면 표시
```
---
## 3. 상세 구현 계획
### A. 백엔드 (Express.js)
1. **필수 라이브러리 설치**
- `express`: 웹 프레임워크
- `express-session`: 세션 관리를 위한 미들웨어
- `jsonwebtoken`: JWT 파싱 및 검증
- (선택) `connect-redis` 또는 `connect-mongo`: 프로덕션 환경에서의 세션 스토어
- (선택) ORM (`Sequelize`, `Mongoose` 등) 또는 DB 드라이버 (`pg`, `mysql2` 등)
2. **세션 미들웨어 설정 (`app.js` 또는 `server.js`)**
- `express-session`을 초기화하고 애플리케이션의 모든 라우트 이전에 전역 미들웨어로 등록합니다. 보안을 위해 `secret` 키는 환경 변수로 관리합니다.
3. **SSO 처리 미들웨어 구현 (`middleware/ssoHandler.js`)**
- 요청이 들어올 때마다 `req.query.token`이 있는지 확인하는 미들웨어를 작성합니다.
- 토큰이 존재하면 다음 로직을 실행합니다.
- **JWT 파싱:** `jsonwebtoken.decode()`를 사용해 토큰의 Payload를 추출합니다. (서명 검증은 `verify`를 사용해야 하지만, 여기서는 `sub` 추출이 주 목적)
- **사용자 식별:** `sub` 클레임을 기준으로 데이터베이스에서 사용자를 조회합니다. (`User.findOne({ where: { sso_sub: decoded.sub } })`)
- **자동 회원가입:** 사용자가 없으면 `sub` 값과 함께 신규 사용자를 생성합니다. (WordPress 교훈: `email` 등 부가 정보에 의존하지 않고, `sub`을 유일한 식별자로 사용)
- **세션 생성:** 조회 또는 생성된 사용자 정보를 `req.session.user` 객체에 저장합니다. (`req.session.user = { id: user.id, username: user.username };`)
- **리다이렉트:** `res.redirect('/')`를 사용하여 `token` 쿼리 파라미터를 URL에서 제거하고 깨끗한 페이지로 리다이렉트합니다.
- 토큰이 없으면 `next()`를 호출하여 다음 미들웨어 또는 라우트로 제어를 넘깁니다.
4. **라우트 및 뷰 렌더링**
- 메인 라우트 핸들러에서 `req.session.user` 객체의 존재 여부를 확인합니다.
- 뷰를 렌더링할 때 `user` 객체를 전달하여, 프론트엔드에서 로그인 상태에 따라 다른 UI를 보여줄 수 있도록 합니다. (`res.render('index', { user: req.session.user });`)
- 로그아웃 라우트를 만들어 `req.session.destroy()`를 호출하여 세션을 파기합니다.
### B. 프론트엔드 (EJS 템플릿 + Client-Side JS)
1. **동적 UI 렌더링 (`views/index.ejs`)**
- 백엔드에서 전달된 `user` 객체의 존재 여부에 따라 조건부 렌더링을 구현합니다.
```html
<% if (user) { %>
<span>환영합니다, <%= user.username %>님!</span>
<a href="/logout">로그아웃</a>
<% } else { %>
<button id="sso-login-btn">Baron SSO 로그인</button>
<% } %>
```
2. **팝업 스크립트 (`public/js/sso.js`)**
- WordPress 구현과 동일한 로직을 사용합니다.
- **팝업 실행:** 로그인 버튼 클릭 시 `window.open`으로 SSO 페이지를 팝업으로 엽니다.
- **토큰 수신:** `window.addEventListener('message', ...)`로 SSO 팝업이 보낸 `postMessage`를 수신합니다.
- (WordPress 교훈) `event.data`가 단순 문자열이 아닌 `{ type: 'LOGIN_SUCCESS', token: '...' }` 형태의 객체일 수 있으므로, `event.data.token` 속성을 명확히 확인하고 추출합니다.
- **페이지 리로드:** 수신한 토큰을 `?token=` 쿼리 파라미터로 붙여 페이지를 새로고침합니다. 이 동작이 백엔드의 SSO 처리 미들웨어를 트리거합니다.
### C. 권장 파일 구조
```
/my-express-app
|-- /middleware
| |-- ssoHandler.js # SSO 토큰 처리 미들웨어
|-- /models
| |-- user.js # 사용자 데이터베이스 모델
|-- /public
| |-- /js
| | |-- sso.js # 프론트엔드 팝업 스크립트
|-- /routes
| |-- index.js # 메인, 로그아웃 등 라우트
|-- /views
| |-- index.ejs # 메인 페이지 뷰
| |-- layout.ejs # 공통 레이아웃
|-- app.js # Express 앱 초기화 및 설정
|-- package.json
```
## 4. 핵심 교훈 적용
- **사용자 식별:** WordPress 구현의 교훈에 따라, JWT의 `email` 필드가 아닌 `sub` 클레임을 사용자의 고유하고 영구적인 식별자로 사용합니다.
- **데이터 형식:** `postMessage`로 전달되는 데이터의 구조를 명확히 인지하고, 프론트엔드에서 `event.data.token`과 같이 정확한 경로로 토큰을 추출하도록 코드를 작성합니다.
- **느슨한 결합:** 프론트엔드는 토큰을 백엔드로 전달하는 역할만 하고, 실제 사용자 조회, 생성, 세션 관리는 모두 백엔드(미들웨어)에서 처리하여 역할을 명확히 분리합니다.

5
sso-demo/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
# Ignore dependencies, as they will be installed inside the container
node_modules
# Ignore npm debug logs
npm-debug.log*

35
sso-demo/app.js Normal file
View File

@@ -0,0 +1,35 @@
const express = require('express');
const session = require('express-session');
const path = require('path');
const ssoHandler = require('./middleware/ssoHandler');
const indexRouter = require('./routes/index');
const app = express();
// View engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Session middleware setup
app.use(session({
secret: 'a-very-secret-key-that-should-be-in-env-vars', // In production, use environment variables
resave: false,
saveUninitialized: true,
cookie: { secure: false } // In production, set secure: true if using HTTPS
}));
// Static files setup
app.use(express.static(path.join(__dirname, 'public')));
// SSO token handler middleware
app.use(ssoHandler);
// Routes
app.use('/', indexRouter);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
module.exports = app;

View File

@@ -0,0 +1,51 @@
const jwt = require('jsonwebtoken');
const User = require('../models/user');
async function ssoHandler(req, res, next) {
// 1. Check if the token is in the query parameters
const { token } = req.query;
if (token) {
try {
// 2. Decode the JWT to get the payload
// In a real app, you MUST verify the token signature using jwt.verify()
// For this demo, we'll just decode to inspect the payload.
const decoded = jwt.decode(token);
if (!decoded || !decoded.sub) {
return res.status(400).send('Invalid token: "sub" claim is missing.');
}
// 3. Find user by 'sub' claim
let user = await User.findBySsoSub(decoded.sub);
// 4. If user doesn't exist, create a new one (auto-registration)
if (!user) {
user = await User.createUser({ sso_sub: decoded.sub });
}
// 5. Save user information in the session
req.session.userId = user.id;
// 6. Redirect to the same URL without the token parameter
const redirectUrl = req.path;
return res.redirect(redirectUrl);
} catch (error) {
console.error('SSO handling failed:', error);
return res.status(500).send('An error occurred during SSO processing.');
}
}
// Attach user to request if session exists
if (req.session.userId) {
res.locals.user = await User.findById(req.session.userId);
} else {
res.locals.user = null;
}
return next();
}
module.exports = ssoHandler;

46
sso-demo/models/user.js Normal file
View File

@@ -0,0 +1,46 @@
// In-memory user store for demonstration purposes
// In a real application, this would be a database model (e.g., Sequelize, Mongoose)
const users = [];
let nextId = 1;
/**
* Finds a user by their SSO subject ID.
* @param {string} ssoSub - The SSO subject ID.
* @returns {Promise<object|null>} The user object or null if not found.
*/
async function findBySsoSub(ssoSub) {
return users.find(user => user.sso_sub === ssoSub) || null;
}
/**
* Finds a user by their internal ID.
* @param {number} id - The user's internal ID.
* @returns {Promise<object|null>} The user object or null if not found.
*/
async function findById(id) {
return users.find(user => user.id === id) || null;
}
/**
* Creates a new user.
* @param {object} userData - The user data.
* @param {string} userData.sso_sub - The SSO subject ID.
* @returns {Promise<object>} The newly created user object.
*/
async function createUser({ sso_sub }) {
const newUser = {
id: nextId++,
sso_sub: sso_sub,
// In a real app, you might get a username/email from the JWT or prompt the user
username: `user_${sso_sub.substring(0, 5)}`
};
users.push(newUser);
return newUser;
}
module.exports = {
findBySsoSub,
findById,
createUser
};

1415
sso-demo/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
sso-demo/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "sso-demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon app.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"ejs": "^4.0.1",
"express": "^5.2.1",
"express-session": "^1.18.2",
"jsonwebtoken": "^9.0.3",
"nodemon": "^3.1.11"
}
}

37
sso-demo/public/js/sso.js Normal file
View File

@@ -0,0 +1,37 @@
document.addEventListener('DOMContentLoaded', () => {
const ssoLoginButton = document.getElementById('sso-login-btn');
if (ssoLoginButton) {
ssoLoginButton.addEventListener('click', () => {
// Open the SSO provider's login page in a popup
const ssoUrl = '/sso_popup.html'; // This is our simulated SSO provider
const popupWidth = 500;
const popupHeight = 600;
const left = (screen.width / 2) - (popupWidth / 2);
const top = (screen.height / 2) - (popupHeight / 2);
const popup = window.open(
ssoUrl,
'ssoLogin',
`width=${popupWidth},height=${popupHeight},top=${top},left=${left}`
);
// Listen for a message from the popup
window.addEventListener('message', (event) => {
// IMPORTANT: In a real app, verify the origin of the message for security
// if (event.origin !== 'https://your-sso-provider.com') {
// return;
// }
// Check if the message contains the expected data structure
if (event.data && event.data.type === 'LOGIN_SUCCESS' && event.data.token) {
popup.close();
// Reload the page with the token in the query string
// This will be handled by our backend ssoHandler middleware
window.location.search = `?token=${event.data.token}`;
}
}, { once: true }); // Use 'once' to automatically remove the listener after it's called
});
}
});

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SSO Login</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 20px; }
button { padding: 10px 20px; font-size: 16px; cursor: pointer; }
</style>
</head>
<body>
<h2>Simulated SSO Provider</h2>
<p>Click the button below to simulate a successful login.</p>
<button id="confirm-login-btn">Confirm Login</button>
<script>
document.getElementById('confirm-login-btn').addEventListener('click', () => {
// --- Create a dummy JWT for demonstration ---
// Header (no changes needed)
const header = { alg: 'HS256', typ: 'JWT' };
// Payload with a random 'sub' to simulate different users
const payload = {
sub: `sso-user-${Math.random().toString(36).substring(2, 10)}`,
name: 'John Doe',
iat: Math.floor(Date.now() / 1000)
};
// In a real JWT, the signature would be generated with a secret key.
// For the demo, we only need the header and payload.
const dummyToken = [
btoa(JSON.stringify(header)),
btoa(JSON.stringify(payload)),
'dummy-signature'
].join('.');
// --- End of dummy JWT creation ---
// Send the token back to the parent window that opened the popup
// In a real app, the targetOrigin should be the specific URL of your application
window.opener.postMessage({
type: 'LOGIN_SUCCESS',
token: dummyToken
}, '*');
});
</script>
</body>
</html>

20
sso-demo/routes/index.js Normal file
View File

@@ -0,0 +1,20 @@
const express = require('express');
const router = express.Router();
// GET home page
router.get('/', (req, res, next) => {
// The ssoHandler middleware has already attached the user to res.locals
res.render('index', { user: res.locals.user });
});
// GET logout
router.get('/logout', (req, res, next) => {
req.session.destroy((err) => {
if (err) {
return next(err);
}
res.redirect('/');
});
});
module.exports = router;

33
sso-demo/views/index.ejs Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Express SSO Demo</title>
<style>
body { font-family: sans-serif; text-align: center; padding-top: 50px; }
.user-info { margin-bottom: 20px; }
#sso-login-btn, .logout-link {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Welcome to the Express SSO Demo</h1>
<div class="user-info">
<% if (user) { %>
<span>Welcome, <strong><%= user.username %></strong>!</span>
<p><a href="/logout" class="logout-link">Logout</a></p>
<% } else { %>
<p>You are not logged in.</p>
<button id="sso-login-btn">Login with SSO</button>
<% } %>
</div>
<script src="/js/sso.js"></script>
</body>
</html>