BioPlayground

🧬
목록으로

프로토콜 공유 웹앱 — 세션 인증·OAuth·IDOR 방어를 함께

실험 프로토콜 공유 웹앱을 만들며 세션 인증, OAuth 2.0 로그인, IDOR(insecure direct object reference) 방어를 실전 코드로 배웁니다.

심화
|
120
|
검증 완료 (2026-07)
실험 프로토콜protocols.io인증OAuth 2.0IDOR세션 관리권한 검증
진행률0/19 (0%)

프로토콜 공유 웹앱 — 세션 인증·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를 브라우저에 전달.

javascript
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: 인증 미들웨어

인증된 사용자만 접근 가능한 엔드포인트에 사용하는 미들웨어.

javascript
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 절에 소유자 조건을 반드시 포함.

javascript
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]);
});

보안 원칙:

  1. WHERE id = $1 뿐만 아니라 소유자 조건도 함께.
  2. 소유자가 아니고 공유도 안 된 프로토콜에 접근 시 404 (403이 아님). "존재하지만 접근 불가" 를 노출하지 않기.
  3. 삭제·수정 엔드포인트도 같은 원칙.

부품 4: OAuth 2.0 소셜 로그인

이메일 로그인 대신 Google/GitHub 등 외부 인증 제공자 사용.

javascript
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이면 필수.

파이프라인 조립

전체 웹앱 흐름:

text
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: 감사 로그

민감 액션은 모두 로그. 누가 언제 무엇을 했는지 추적.

javascript
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)이 있고, 각 역할이 다른 권한을 가짐.

javascript
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 변화 시 무효화.

javascript
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] = 완성 코드로 제공.