Files
Aislo/.agent/complete_and_old/auth_session_cookie_spec.md
T

11 KiB

세션 쿠키 구현 명세 (Session Cookie Specification)

🔐 최종 확정: Secure HttpOnly 세션 쿠키

선택 이유

  • 모놀리식 구조 (FastAPI + MariaDB)
  • 중소규모 프로젝트 (30인 미만 회사)
  • 보안 최우선 (CSRF/XSS 방어)
  • 세션 추적 필요 (로그인 이력, 마스터 강제 로그아웃)
  • JWT의 단점 회피 (상태 비저장, 로그아웃 어려움)

📋 쿠키 설정 상세

쿠키 생성 (로그인 성공 시)

# A06_Login/A06_Login_Router.py (예시)

from fastapi import APIRouter
from fastapi.responses import JSONResponse
import secrets
from datetime import datetime, timedelta

router = APIRouter()

@router.post("/api/auth/login")
async def login(email: str, password: str, otp_code: str):
    """
    로그인 엔드포인트 (OTP 인증 완료 후)
    """
    # 1. 사용자 검증 및 OTP 확인
    user = await verify_user_and_otp(email, password, otp_code)
    
    # 2. 세션 ID 생성 (32바이트 난수)
    session_id = secrets.token_urlsafe(32)
    
    # 3. DB에 세션 저장
    await db.create_session(
        session_id=session_id,
        user_id=user.id,
        expires_at=datetime.utcnow() + timedelta(hours=12)
    )
    
    # 4. 응답 생성 + 쿠키 설정
    response = JSONResponse({
        "status": "success",
        "message": "로그인 성공",
        "user_id": user.id,
        "user_email": user.email
    })
    
    response.set_cookie(
        key="session_id",
        value=session_id,
        max_age=43200,          # 12시간 (초 단위: 12 * 60 * 60)
        secure=True,            # HTTPS만 전송 (개발: False 가능)
        httponly=True,          # JavaScript 접근 불가
        samesite="Lax",         # CSRF 방지
        domain=None,            # 현재 도메인만
        path="/"                # 모든 경로에서 전송
    )
    
    return response

쿠키 값 요구사항

항목 설명
쿠키 이름 session_id 표준 이름
32바이트 난수 secrets.token_urlsafe(32)
유효시간 43200초 (12시간) 브라우저 종료 후 삭제
Secure true HTTPS만 (개발 환경: localhost는 예외)
HttpOnly true XSS 방지 필수
SameSite Lax CSRF 방지 (기본값)
Domain None 현재 도메인만
Path / 모든 경로에서 유효

💾 DB 스키마

sessions 테이블

CREATE TABLE sessions (
  id VARCHAR(255) PRIMARY KEY,          -- session_id (쿠키 값)
  user_id INT NOT NULL,                 -- 사용자 ID
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,    -- 생성 시간
  expires_at TIMESTAMP NOT NULL,        -- 만료 시간 (12시간 후)
  last_activity_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,  -- 마지막 활동
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  INDEX (user_id),
  INDEX (expires_at)  -- 만료된 세션 정리용
);

예제 데이터

id: "xY9z_kL2m-nO3pQr4sT5uV6w7xYz_aB1cD2eF3gH"
user_id: 1
created_at: 2026-07-07 10:00:00
expires_at: 2026-07-07 22:00:00
last_activity_at: 2026-07-07 10:15:30

🔍 요청 검증 (미들웨어)

세션 검증 로직

# common_util/common_util_auth.py

from fastapi import Request, HTTPException
from datetime import datetime

async def verify_session(request: Request):
    """
    요청의 세션 쿠키를 검증하고 사용자 정보 반환
    
    Returns:
        dict: {"user_id": int, "user_email": str, "company_id": int}
    
    Raises:
        HTTPException: 401 (세션 없음, 만료, 잘못됨)
    """
    
    # 1. 쿠키에서 세션 ID 추출
    session_id = request.cookies.get("session_id")
    if not session_id:
        raise HTTPException(
            status_code=401,
            detail="로그인 필요"
        )
    
    # 2. DB에서 세션 조회
    session = await db.get_session(session_id)
    if not session:
        raise HTTPException(
            status_code=401,
            detail="유효하지 않은 세션"
        )
    
    # 3. 세션 만료 확인
    if datetime.utcnow() > session.expires_at:
        # 만료된 세션 삭제
        await db.delete_session(session_id)
        raise HTTPException(
            status_code=401,
            detail="세션 만료 (다시 로그인하세요)"
        )
    
    # 4. 활동 타임아웃 확인 (마지막 활동으로부터 4시간)
    time_since_activity = datetime.utcnow() - session.last_activity_at
    if time_since_activity.total_seconds() > 14400:  # 4시간
        await db.delete_session(session_id)
        raise HTTPException(
            status_code=401,
            detail="비활동으로 인한 세션 만료"
        )
    
    # 5. 활동 시간 업데이트
    await db.update_session_activity(session_id)
    
    # 6. 사용자 정보 조회 및 반환
    user = await db.get_user(session.user_id)
    return {
        "user_id": user.id,
        "user_email": user.email,
        "company_id": user.company_id,
        "is_master": user.is_master
    }

FastAPI 라우터에서 사용

# 모든 보호된 엔드포인트에서

from fastapi import Depends

@router.get("/api/projects")
async def list_projects(session: dict = Depends(verify_session)):
    """
    프로젝트 목록 조회 (로그인 필요)
    """
    user_id = session["user_id"]
    company_id = session["company_id"]
    
    projects = await db.get_company_projects(company_id)
    return {"status": "success", "projects": projects}

🔐 로그아웃 처리

로그아웃 엔드포인트

@router.post("/api/auth/logout")
async def logout(request: Request):
    """
    로그아웃 (세션 삭제)
    """
    session_id = request.cookies.get("session_id")
    
    if session_id:
        # DB에서 세션 삭제
        await db.delete_session(session_id)
    
    # 응답 생성 + 쿠키 삭제
    response = JSONResponse({
        "status": "success",
        "message": "로그아웃 성공"
    })
    
    # 쿠키 삭제 (max_age=0)
    response.delete_cookie(
        key="session_id",
        path="/",
        secure=True,
        httponly=True,
        samesite="Lax"
    )
    
    return response

🛡️ 마스터 강제 로그아웃

특정 사용자의 모든 세션 삭제

async def force_logout_user(user_id: int):
    """
    사용자의 모든 세션 삭제 (마스터가 팀원을 내보낼 때)
    """
    await db.delete_all_user_sessions(user_id)
    # DB 쿼리: DELETE FROM sessions WHERE user_id = ?

# 마스터가 팀원을 내보낼 때
@router.post("/api/admin/member/remove")
async def remove_member(
    member_user_id: int,
    session: dict = Depends(verify_session)
):
    """마스터가 팀원 내보내기"""
    
    # 권한 확인: 현재 사용자가 마스터인지
    requester = await db.get_user(session["user_id"])
    if not requester.is_master:
        raise HTTPException(status_code=403, detail="권한 없음")
    
    # 팀원 상태 변경
    await db.update_user_status(member_user_id, "INACTIVE")
    
    # 해당 팀원의 모든 세션 삭제 (즉시 로그아웃)
    await force_logout_user(member_user_id)
    
    return {"status": "success", "message": "팀원이 제거되었습니다"}

🧹 자동 정리 (크론 작업)

만료된 세션 자동 삭제

# 매시간 실행

async def cleanup_expired_sessions():
    """만료된 세션 자동 삭제"""
    deleted_count = await db.delete_expired_sessions(
        before_date=datetime.utcnow()
    )
    logger.info(f"✅ {deleted_count}개의 만료된 세션 삭제")

# SQL
# DELETE FROM sessions WHERE expires_at < NOW()

📊 모니터링

활성 세션 통계

async def get_active_sessions_stats():
    """활성 세션 통계 (관리자 대시보드용)"""
    
    stats = {
        "total_active_sessions": await db.count_active_sessions(),
        "total_active_users": await db.count_active_users(),
        "expired_sessions_today": await db.count_expired_sessions_today(),
        "by_company": await db.get_active_sessions_by_company()
    }
    
    return stats

🧪 테스트

수동 테스트

# 1. 로그인 (쿠키 받기)
curl -i -X POST http://localhost:8000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "pass123", "otp_code": "123456"}'

# 응답:
# Set-Cookie: session_id=...; Max-Age=43200; Path=/; Secure; HttpOnly; SameSite=Lax

# 2. 쿠키와 함께 요청 (자동으로 포함됨)
curl -i http://localhost:8000/api/projects \
  -b "session_id=..." \
  -H "Accept: application/json"

# 3. 로그아웃
curl -i -X POST http://localhost:8000/api/auth/logout \
  -b "session_id=..."

# 응답:
# Set-Cookie: session_id=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax

자동 테스트

# test_session_cookie.py

import pytest
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

def test_login_creates_session_cookie():
    """로그인 시 세션 쿠키 생성"""
    response = client.post("/api/auth/login", json={
        "email": "test@example.com",
        "password": "password123",
        "otp_code": "123456"
    })
    
    assert response.status_code == 200
    assert "session_id" in response.cookies
    assert response.cookies["session_id"].get("secure")
    assert response.cookies["session_id"].get("httponly")

def test_protected_endpoint_requires_session():
    """보호된 엔드포인트는 세션 필요"""
    response = client.get("/api/projects")
    assert response.status_code == 401

def test_logout_deletes_session():
    """로그아웃 시 세션 쿠키 삭제"""
    # 먼저 로그인
    login_response = client.post("/api/auth/login", json={...})
    
    # 그 후 로그아웃
    logout_response = client.post("/api/auth/logout")
    assert logout_response.status_code == 200
    assert logout_response.cookies["session_id"].get("max_age") == 0

🔄 프론트엔드 연동

쿠키는 자동으로 전송됨

// A06_Login_Api_Fetch.ts

export async function login(email: string, password: string, otpCode: string) {
    const response = await fetch("http://localhost:8000/api/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",  // 쿠키 자동 포함
        body: JSON.stringify({ email, password, otp_code: otpCode })
    });
    
    return response.json();
}

// 이후 요청에서도 credentials: "include"를 사용하면 자동으로 쿠키 전송됨
export async function getProjects() {
    const response = await fetch("http://localhost:8000/api/projects", {
        credentials: "include"  // 쿠키 자동 포함
    });
    
    return response.json();
}

최종 체크리스트

  • sessions 테이블 생성
  • 로그인 API에서 쿠키 생성 (set_cookie)
  • 세션 검증 미들웨어 구현
  • 모든 보호된 엔드포인트에 Depends(verify_session) 추가
  • 로그아웃 API 구현 (쿠키 삭제)
  • 마스터 강제 로그아웃 구현
  • 자동 정리 크론 작업 추가
  • 프론트엔드 credentials: "include" 설정
  • 수동/자동 테스트 통과

확정일: 2026-07-07
상태: 최종 확정 (v1.0)