[카테고리:] 미분류

  • 안전하고, 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 패턴 포함)**로 갑니다.