260706_1_계정, 로그인, 보안 초안 작성

This commit is contained in:
2026-07-07 19:17:13 +09:00
parent 404941c47a
commit f8633bb1fe
4696 changed files with 6110 additions and 642171 deletions
@@ -0,0 +1,426 @@
# 세션 쿠키 구현 명세 (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)