프로토콜 공유 웹앱 — 세션 인증·OAuth·IDOR 방어를 함께
이 토픽을 마치면
교과서에서 배운 세션 인증, OAuth 2.0, IDOR 을 엮어서, 실험 프로토콜을 안전하게 공유하는 웹앱을 직접 만들 수 있습니다. protocols.io 같은 실전 도구가 왜 정교한 권한 시스템을 갖는지, 그리고 어떻게 그것을 파이썬/Node로 만드는지 이해합니다.
이 글은 교육용 일반 예제 입니다. 실전 배포는 Auth0, Clerk, Supabase Auth 같은 관리형 서비스를 씁니다.
"잠깐, URL만 바꾸면 다른 랩 프로토콜이 다 보이네?" — IDOR의 함정
여러분이 실험 프로토콜 공유 사이트를 처음 만들었다고 합시다.
- 사용자 로그인: OK
- 자기 프로토콜 만들기: OK
- 특정 프로토콜 보기:
/api/protocols/42
친구가 URL을 조금 바꿔서 접근합니다: /api/protocols/43. 다른 랩의 비밀 프로토콜이 열립니다. URL의 숫자만 바꾸면 남의 데이터에 접근되는 것 — IDOR(Insecure Direct Object Reference) 취약점.
이 문제의 원인이 흥미롭습니다. 여러분의 코드는 사용자가 로그인했는지(authentication) 는 확인했지만, 사용자가 이 리소스에 접근할 권한이 있는지(authorization) 는 확인하지 않았습니다. 인증과 인가는 별개입니다.
OWASP Top 10에서 A01: Broken Access Control 이 매년 상위에 있는 이유가 이것입니다. 개발자들이 반복적으로 이 문제를 만듭니다. protocols.io, GitHub, Notion 같은 모든 실전 서비스가 정교한 권한 시스템으로 이 취약점을 방어합니다.
CS의 언어로 이 문제를 표현하면 인터페이스에 authorization 게이트가 없는 상태입니다. API 엔드포인트가 요청을 받으면 누가 이 요청을 하고 있는지 확인하는 것이 인증이고, 이 특정 리소스에 접근할 권한이 있는지 확인하는 것이 인가입니다. 인증만 있으면 시스템은 로그인한 아무나 아무 리소스에 접근할 수 있는 상태가 됩니다.
블랙박스에서 부품으로
부품 1: 세션 기반 인증
가장 기본적인 인증 패턴. 로그인 성공 시 서버가 세션을 만들고, 쿠키로 세션 ID를 브라우저에 전달.
import express from "express";
import session from "express-session";
import bcrypt from "bcrypt";
import pg from "pg";
const app = express();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000
}
}));
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
app.post("/api/login", async (req, res) => {
const { email, password } = req.body;
const result = await pool.query("SELECT id, password_hash FROM users WHERE email = $1", [email]);
if (result.rows.length === 0) {
return res.status(401).json({ error: "Invalid credentials" });
}
const user = result.rows[0];
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: "Invalid credentials" });
}
req.session.userId = user.id;
res.json({ ok: true });
});
app.post("/api/logout", (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});보안 필수 요소:
httpOnly: true— JS에서 쿠키 접근 불가 (XSS로 세션 탈취 방지)secure: true— HTTPS에서만 전송 (프로덕션)sameSite: "lax"— CSRF 부분 방어bcrypt로 비밀번호 해싱 (평문 저장 금지)- 세션 시크릿을 환경 변수에
부품 2: 인증 미들웨어
인증된 사용자만 접근 가능한 엔드포인트에 사용하는 미들웨어.
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: "Login required" });
}
next();
}
app.get("/api/me", requireAuth, async (req, res) => {
const result = await pool.query("SELECT id, email, lab_id FROM users WHERE id = $1", [req.session.userId]);
res.json(result.rows[0]);
});부품 3: 리소스 소유자 검증 (IDOR 방어)
핵심: 리소스 조회 시 WHERE 절에 소유자 조건을 반드시 포함.
app.get("/api/protocols/:id", requireAuth, async (req, res) => {
const { id } = req.params;
const result = await pool.query(
`SELECT p.*
FROM protocols p
WHERE p.id = $1
AND (
p.owner_user_id = $2
OR p.visibility = 'public'
OR EXISTS (
SELECT 1 FROM protocol_shares ps
WHERE ps.protocol_id = p.id AND ps.user_id = $2
)
)`,
[id, req.session.userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: "Not found" });
}
res.json(result.rows[0]);
});보안 원칙:
WHERE id = $1뿐만 아니라 소유자 조건도 함께.- 소유자가 아니고 공유도 안 된 프로토콜에 접근 시 404 (403이 아님). "존재하지만 접근 불가" 를 노출하지 않기.
- 삭제·수정 엔드포인트도 같은 원칙.
부품 4: OAuth 2.0 소셜 로그인
이메일 로그인 대신 Google/GitHub 등 외부 인증 제공자 사용.
import { OAuth2Client } from "google-auth-library";
const oauth = new OAuth2Client({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: process.env.GOOGLE_REDIRECT_URI
});
app.get("/api/auth/google", (req, res) => {
const url = oauth.generateAuthUrl({
access_type: "offline",
scope: ["email", "profile"],
state: generateStateToken(req)
});
res.redirect(url);
});
app.get("/api/auth/google/callback", async (req, res) => {
const { code, state } = req.query;
if (!verifyStateToken(req, state)) {
return res.status(400).json({ error: "Invalid state" });
}
const { tokens } = await oauth.getToken(code);
const ticket = await oauth.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID
});
const payload = ticket.getPayload();
const email = payload.email;
let user = await pool.query("SELECT id FROM users WHERE email = $1", [email]);
if (user.rows.length === 0) {
user = await pool.query(
"INSERT INTO users (email, oauth_provider, oauth_id) VALUES ($1, 'google', $2) RETURNING id",
[email, payload.sub]
);
}
req.session.userId = user.rows[0].id;
res.redirect("/");
});OAuth 필수 보안 요소:
- state 파라미터 — CSRF 방어. 요청 시작 시 랜덤 토큰을 세션에 저장, 콜백에서 검증.
- id_token 검증 — provider의 공개키로 서명 검증.
- audience 검증 — 토큰이 이 앱을 위한 것인지 확인.
- PKCE — Native app이면 필수.
파이프라인 조립
전체 웹앱 흐름:
1. 사용자가 /login 방문
2. 이메일/비번 로그인 or "Sign in with Google" 클릭
3. 성공 시 세션 쿠키 발급, /dashboard로 리다이렉트
4. /dashboard에서 자기 프로토콜 목록 표시
- GET /api/protocols?mine=true (WHERE owner = session.userId)
5. 특정 프로토콜 클릭
- GET /api/protocols/42 (소유자 검증 포함)
6. 공유 대상 사용자 추가
- POST /api/protocols/42/share (본인 소유 검증 후)각 엔드포인트에서 인증(누구인가) + 인가(무엇을 볼 권한이 있는가) 를 반드시 함께.
페이딩 — 여러분이 채워야 할 세 빈칸
빈칸 1: 감사 로그
민감 액션은 모두 로그. 누가 언제 무엇을 했는지 추적.
app.use(async (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = function(body) {
// TODO 1: 요청 정보 수집 (userId, method, path, statusCode)
// TODO 2: audit_logs 테이블에 삽입 (비동기 fire-and-forget)
// TODO 3: originalJson 호출
return originalJson(body);
};
next();
});힌트: pool.query("INSERT INTO audit_logs (user_id, method, path, status) VALUES ($1, $2, $3, $4)", [req.session.userId, req.method, req.path, res.statusCode]).catch(console.error);.
빈칸 2: 역할 기반 접근 제어 (RBAC)
사용자마다 역할(admin, editor, viewer)이 있고, 각 역할이 다른 권한을 가짐.
function requireRole(role) {
return async (req, res, next) => {
// TODO 1: session.userId로 users 테이블 조회
// TODO 2: 사용자의 role이 필요한 role 이상인지 검증
// TODO 3: 아니면 403 반환
next();
};
}
app.delete("/api/protocols/:id", requireAuth, requireRole("admin"), async (req, res) => {
// 관리자만 삭제 가능
});빈칸 3: 세션 하이재킹 방어
세션 쿠키가 탈취돼도 IP/User-Agent 변화 시 무효화.
function detectSessionAnomaly(req, res, next) {
if (!req.session.userId) return next();
const currentIp = req.ip;
const currentUA = req.headers["user-agent"];
// TODO 1: 세션에 저장된 originalIp, originalUA와 비교
// TODO 2: 크게 다르면 세션 파기 후 재로그인 요구
// TODO 3: 정상이면 다음 미들웨어로
next();
}힌트: 첫 로그인 시 req.session.originalIp = req.ip; req.session.originalUA = req.headers["user-agent"];. 이후 요청에서 if (req.session.originalIp !== req.ip) { req.session.destroy(); return res.status(401).json({error: "Session anomaly"}); }.
성찰 — 실전 인증 시스템과의 차이
관리형 서비스: 실전 대다수는 Auth0, Clerk, Supabase Auth, Firebase Auth 같은 관리형 서비스를 씁니다. 여러분이 만든 것은 이 서비스들이 뒤에서 하는 일의 이해.
PASETO / JWT: 세션 대신 자체 검증 가능한 토큰. 세션 저장소가 필요 없어 확장성 유리. 하지만 무효화(로그아웃) 관리가 어려움.
MFA (다단계 인증): TOTP(Google Authenticator), WebAuthn (하드웨어 키). 계정 탈취 시 마지막 방어선.
Zero Trust 아키텍처: 매 요청마다 인증·인가 재검증. 네트워크 위치(사내망)를 신뢰 근거로 사용하지 않음.
Password 대안: Passkey (WebAuthn) 가 최근 표준. 비밀번호 없는 로그인. Apple, Google, MS 모두 지원.
OWASP ASVS: Application Security Verification Standard. 여러분의 앱이 어떤 보안 요구를 만족해야 하는지 계층별 체크리스트.
확장 프로젝트
1. Passkey 지원: WebAuthn 라이브러리로 하드웨어 인증 추가.
2. 팀 워크스페이스: 여러 사용자가 같은 워크스페이스에 속해 프로토콜 공유. 초대 링크.
3. API 토큰 시스템: 세션 대신 API 키로 프로그램적 접근 지원. 스코프별 권한.
4. Rate limiting: brute force 방어. IP당 로그인 시도 5회/분 제한.
이 편의 부품 지도
- [F] 세션 인증: 쿠키·세션 저장소·httpOnly·secure·sameSite.
- [F] OAuth 2.0: Authorization code flow, state 파라미터, id_token 검증.
- [F] IDOR 방어: 리소스 조회 시 소유자 조건 필수. 404 vs 403 선택.
- [W] Express·pg: 라우팅과 DB 접근 (완성 스크립트 제공).
[F] = 여러분이 직접 구현 / [W] = 완성 코드로 제공.