# 세션 쿠키 구현 명세 (Session Cookie Specification) ## 🔐 최종 확정: Secure HttpOnly 세션 쿠키 ### 선택 이유 - ✅ **모놀리식 구조** (FastAPI + MariaDB) - ✅ **중소규모 프로젝트** (30인 미만 회사) - ✅ **보안 최우선** (CSRF/XSS 방어) - ✅ **세션 추적 필요** (로그인 이력, 마스터 강제 로그아웃) - ✅ **JWT의 단점 회피** (상태 비저장, 로그아웃 어려움) --- ## 📋 쿠키 설정 상세 ### 쿠키 생성 (로그인 성공 시) ```python # 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 테이블 ```sql 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 ``` --- ## 🔍 요청 검증 (미들웨어) ### 세션 검증 로직 ```python # 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 라우터에서 사용 ```python # 모든 보호된 엔드포인트에서 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} ``` --- ## 🔐 로그아웃 처리 ### 로그아웃 엔드포인트 ```python @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 ``` --- ## 🛡️ 마스터 강제 로그아웃 ### 특정 사용자의 모든 세션 삭제 ```python 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": "팀원이 제거되었습니다"} ``` --- ## 🧹 자동 정리 (크론 작업) ### 만료된 세션 자동 삭제 ```python # 매시간 실행 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() ``` --- ## 📊 모니터링 ### 활성 세션 통계 ```python 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 ``` --- ## 🧪 테스트 ### 수동 테스트 ```bash # 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 ``` ### 자동 테스트 ```python # 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 ``` --- ## 🔄 프론트엔드 연동 ### 쿠키는 자동으로 전송됨 ```typescript // 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)