안전하고, SSR 친화적으로


주제: 인증/인가(OAuth2/OIDC, JWT/세션, RBAC/ABAC, 토큰 로테이션)


  • 브라우저는 BFF + 서버 세션 쿠키(HTTPOnly, Secure), API 클라이언트/모바일은 JWT Bearer로 분리
  • OIDC Authorization Code + PKCE 고정, state/nonce 필수
  • 액세스 토큰 짧게(5–10분), 리프레시 토큰 회전형(Reuse Detection) + jti/디바이스 바인딩
  • CSRF: 쿠키 기반 쓰기 요청은 더블 서브밋 토큰 or SameSite=Strict/Lax + 커스텀 헤더
  • RBAC + 필요한 곳에 ABAC(리소스 소유·조직·속성), 멀티테넌시는 org_id를 1급 시민으로
  • 키 로테이션: RSA/ECDSA 키에 kid 부여, JWKS 공개, 과거 키 보관(검증용)
  • 2FA(TOTP) + WebAuthn(선택), 로그인 시도 레이트리밋/브루트포스 방어
  • 감사 로그: 로그인/권한 변경/토큰 재발급/강제 로그아웃

1) 아키텍처 결정: BFF vs. 순수 토큰

  • 권장: Next.js BFF(Route Handlers)FastAPI
    • 브라우저 → 서버 세션 쿠키(HTTPOnly)로 SSR/ISR에 유리
    • 모바일·서버투서버 → JWT Bearer
  • 장점: 토큰이 프론트 JS에 노출되지 않음(XSS에 강함), CSRF만 잘 처리하면 OK
[Browser]
   ↕ (cookie, csrf)
[Next.js (BFF)]
   ↕ (service token / mTLS)
[FastAPI]
   ↔ Redis/Postgres (세션/권한)
   ↔ OIDC Provider (로그인)

2) OIDC 플로우(권장 설정)

  • Authorization Code + PKCE (공개클라이언트)
  • 쿠키:
    • 세션: Secure; HttpOnly; SameSite=Lax(일반)
    • OIDC 중간 상태(state/nonce) 쿠키: SameSite=None; Secure
  • 리다이렉트 도메인·경로 고정(오픈 리다이렉트 방지)

Next.js 콜백(Route Handler) 스케치

// app/api/auth/callback/route.ts
import { NextResponse } from 'next/server';
import { exchangeCodeForTokens, verifyIdToken } from '@/lib/oidc';

export async function GET(req: Request) {
  const url = new URL(req.url);
  const code = url.searchParams.get('code')!;
  const state = url.searchParams.get('state')!;
  // 1) state 검증
  await verifyStateFromCookie(state);

  // 2) 토큰 교환 + ID 토큰 검증(nonce 포함)
  const { id_token, refresh_token, access_token } = await exchangeCodeForTokens(code);
  const claims = await verifyIdToken(id_token);

  // 3) 서버 세션 발급(쿠키)
  const session = await createServerSession({ sub: claims.sub, org_id: claims.org_id });
  const res = NextResponse.redirect(new URL('/', req.url));
  res.cookies.set('sid', session.id, {
    httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60*60*24*7
  });
  // 리프레시 토큰은 서버 저장(또는 암호화해 쿠키 분리)
  await storeRefreshToken(session.id, refresh_token);
  return res;
}

3) 토큰 전략: 짧은 Access + 회전 Refresh

액세스 토큰(JWT)

  • 만료(Exp) 5–10분, aud, iss, sub, org_id, scope, jti 포함
  • 서버 간(Next BFF→FastAPI) 호출에 사용(헤더)

리프레시 토큰(서버 저장 or 암호화)

  • 회전(Rotation): 매 재발급마다 새 RT 발급, 이전 RT가 재사용되면 전부 폐기(Reuse Detection)
  • 디바이스 바인딩: RT 메타에 UA/IP 해시, 세션ID 저장

FastAPI 리프레시 엔드포인트(요지)

# app/auth.py
from fastapi import APIRouter, Depends, HTTPException, Response
from datetime import datetime, timedelta
import secrets

rt_store = ... # Redis/DB

@router.post("/auth/refresh")
async def refresh(resp: Response, session=Depends(get_session)):
    prev = await rt_store.get(session.id)  # 이전 RT 해시/상태
    body = await read_refresh_request()    # 쿠키/헤더에서 RT 추출(서버 저장 권장)
    if not verify_rt(prev, body):
        # Reuse Detection: 세션 전부 폐기
        await revoke_all(session.user_id)
        raise HTTPException(401, "refresh reuse detected")

    new_at = mint_access_token(sub=session.user_id, org_id=session.org_id, ttl=600)
    new_rt = secrets.token_urlsafe(64)
    await rt_store.set(session.id, hash(new_rt), ex=60*60*24*30)
    set_rotation_cookie(resp, new_rt)
    return {"access_token": new_at, "token_type": "Bearer", "expires_in": 600}

4) CSRF 방어(쿠키 세션일 때 필수)

  • 더블 서브밋: 쿠키(csrf) + 헤더(X-CSRF-Token) 값 동일성 검증
  • SameSite=Lax로 기본 방어, 크로스사이트 POST 필요시 전용 엔드포인트 + 토큰 검증
// Next.js: 쓰기 요청 시 헤더에 CSRF 첨부
await fetch('/api/orders', {
  method: 'POST',
  headers: { 'X-CSRF-Token': getCsrfFromCookie() },
  body: JSON.stringify(payload),
});
# FastAPI 미들웨어
@app.middleware("http")
async def verify_csrf(request, call_next):
    if request.method in ("POST","PUT","PATCH","DELETE"):
        c = request.cookies.get("csrf")
        h = request.headers.get("X-CSRF-Token")
        if not c or not h or c != h:
            raise HTTPException(403, "CSRF")
    return await call_next(request)

5) 권한 모델: RBAC + ABAC

  • RBAC: role in {"owner","admin","editor","viewer"}
  • ABAC: 요청 리소스의 org_id, owner_id, labels 등 속성 평가
  • 멀티테넌시: 모든 데이터 키에 org_id 포함, DB는 RLS 고려

FastAPI 의사코드

def guard(*, any_of_roles=None, require_org_match=False):
    async def dep(user=Depends(get_user), resource=Depends(get_resource)):
        if any_of_roles and user.role not in any_of_roles:
            raise HTTPException(403)
        if require_org_match and user.org_id != resource.org_id:
            raise HTTPException(403)
        return user
    return dep

@app.delete("/projects/{id}")
async def delete_project(project=Depends(load_project),
                         user=Depends(guard(any_of_roles={"owner","admin"}, require_org_match=True))):
    ...

6) 세션 관리(장치별 제어)

  • 세션 저장소: Redis Hash(TTL), 필드: user_id, org_id, ua_hash, ip_hash, last_seen, revoked
  • 기능: 모든 기기 로그아웃, 특정 기기만 로그아웃, 동시 세션 제한
# 세션 검증(미들웨어)
sid = request.cookies.get("sid")
session = await redis.hgetall(f"sid:{sid}")
if not session or session.get("revoked") == "1":
    raise HTTPException(401)
await redis.hset(f"sid:{sid}", mapping={"last_seen": int(time.time())})

7) 2FA/TOTP & WebAuthn(선택)

  • TOTP: pyotp로 시크릿 발급 → QR 등록 → 로그인 시 6자리 검증
  • 백업 코드 발급(일회용 10개)
  • WebAuthn/Passkey: 보안 키·플랫폼 인증자로 피싱 저항성 ↑

8) 로그인 보안 & 속도제한

  • 아이디 단서 주지 않기(“이메일 또는 비밀번호가 올바르지 않습니다.”)
  • 레이트리밋: IP·계정 키로 5분 10회 등
  • 브루트포스 감지: 실패 n회 → 캡차/지연
await rate_limit(request, key_prefix=f"login:{username}", limit=10, window_sec=300)

9) 키 로테이션 & JWKS

  • 서명키: RSA-256 또는 ES256, kid 부여
  • 현재키(active) + 과거키(n-1, n-2) 보관, JWKS 엔드포인트 제공
  • 토큰 헤더의 kid로 검증 키 선택
# JWKS 예시
@app.get("/.well-known/jwks.json")
async def jwks():
    return {"keys": [public_jwk_active, public_jwk_prev]}

10) 보안 헤더 & 쿠키 규칙

  • Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  • Content-Security-Policy 최소 설정(스크립트/이미지 도메인 화이트리스트)
  • 쿠키는 항상 Secure; HttpOnly (읽기 필요하면 별도 non-HTTPOnly 쿠키 분리)
  • 세션 고정 방지: 로그인·권한상승 시 새 sid 재발급

11) 감사 로그(누가/언제/무엇을)

  • 이벤트: 로그인 성공/실패, 비밀번호 변경, 역할 변경, 토큰 재발급, 세션 종료
  • 필드: user_id, org_id, ip, ua_hash, result, reason, trace_id
  • 불변 스토리지(append-only) + 보존정책

12) “복붙” 스니펫 모음

A. FastAPI: JWT 서명/검증(키 로테이션)

import jwt, time
from typing import Dict

ACTIVE_KID = "2025-10-01"
PRIVATE_KEYS: Dict[str, str] = {...}  # PEM
PUBLIC_KEYS: Dict[str, str] = {...}   # PEM

def mint_access_token(sub: str, org_id: str, ttl=600):
    now = int(time.time())
    payload = {"iss":"https://auth.example.com","sub":sub,"org_id":org_id,"iat":now,"exp":now+ttl,"jti":uuid4().hex}
    return jwt.encode(payload, PRIVATE_KEYS[ACTIVE_KID], algorithm="RS256", headers={"kid": ACTIVE_KID})

def verify_jwt(token: str):
    headers = jwt.get_unverified_header(token)
    kid = headers["kid"]
    pub = PUBLIC_KEYS[kid]
    return jwt.decode(token, pub, algorithms=["RS256"], audience=None, options={"require":["exp","iat","sub"]})

B. Next.js: 페이지 보호(미들웨어)

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
  const sid = req.cookies.get('sid')?.value;
  if (!sid && req.nextUrl.pathname.startsWith('/app')) {
    const url = req.nextUrl.clone(); url.pathname = '/login';
    return NextResponse.redirect(url);
  }
  return NextResponse.next();
}

C. 더블 서브밋 CSRF 생성

// layout.tsx
import { cookies } from 'next/headers';
export default function Layout({ children }) {
  const csrf = crypto.randomUUID();
  cookies().set('csrf', csrf, { httpOnly: false, sameSite: 'lax', secure: true });
  return <html><body data-csrf={csrf}>{children}</body></html>;
}

13) 2주 액션 플랜(템플릿)

  1. OIDC Code+PKCE 연결, state/nonce·콜백 도메인 고정
  2. BFF 세션 쿠키 도입(HTTPOnly, Secure, SameSite=Lax) + CSRF 미들웨어
  3. 액세스 10분/리프레시 회전형 + Reuse Detection 구현
  4. RBAC 기본 + ABAC(조직·소유자 매칭) 가드 의존성 적용
  5. 로그인 레이트리밋 + 감사 로그 파이프라인
  6. JWKS 엔드포인트와 키 로테이션 절차 문서화/크론 준비
  7. 고위험 액션에 2FA 옵션 추가(비밀번호 변경·권한상승)

마무리

  • 브라우저 세션은 BFF+쿠키, API는 JWT — 이 이원화가 운영을 단순화하고 보안을 끌어올립니다.
  • 토큰 회전·키 로테·감사 로그를 초기에 “규칙”으로 박아두면, 스케일 커질수록 안정성이 좋아집니다.

다음은 **6편. API 게이트웨이·레이트리밋·엣지(BFF 패턴 포함)**로 갑니다.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다