Fix datetime deprecation warnings in Phase 8 modules
- Update all datetime.utcnow() to datetime.now(UTC) for Python 3.12+ compatibility - Update all datetime.utcfromtimestamp() to datetime.fromtimestamp(..., UTC) - Fix dataclass default_factory to use lambda: datetime.now(UTC) - Update auth, audit, billing, and realtime modules - Add UTC import from datetime module - Update pytest configuration to include pytest-asyncio - All 28 Phase 8 enterprise tests pass with no warnings Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
338
ontology_platform/ont_platform/auth/auth.py
Normal file
338
ontology_platform/ont_platform/auth/auth.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""인증 시스템 (JWT, API 키).
|
||||
|
||||
Phase 8: 멀티테넌트 인증
|
||||
"""
|
||||
|
||||
import os
|
||||
import jwt
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from typing import Optional, Dict
|
||||
|
||||
from fastapi import HTTPException, Depends, Header
|
||||
from ont_platform.auth.models import (
|
||||
User,
|
||||
Organization,
|
||||
APIKey,
|
||||
TokenPayload,
|
||||
CurrentUser,
|
||||
AuthCredentials,
|
||||
)
|
||||
|
||||
# 환경 변수
|
||||
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||
|
||||
|
||||
class JWTAuth:
|
||||
"""JWT 기반 토큰 인증."""
|
||||
|
||||
@staticmethod
|
||||
def create_token(
|
||||
user_id: str,
|
||||
org_id: str,
|
||||
email: str,
|
||||
role: str,
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
) -> str:
|
||||
"""JWT 토큰 생성."""
|
||||
if expires_delta is None:
|
||||
expires_delta = timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
|
||||
|
||||
exp = datetime.now(UTC) + expires_delta
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"org_id": org_id,
|
||||
"email": email,
|
||||
"role": role,
|
||||
"exp": int(exp.timestamp()),
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def verify_token(token: str) -> TokenPayload:
|
||||
"""JWT 토큰 검증."""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
|
||||
# 토큰 만료 확인
|
||||
exp = payload.get("exp")
|
||||
if exp and datetime.fromtimestamp(exp, UTC) < datetime.now(UTC):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Token has expired",
|
||||
)
|
||||
|
||||
return TokenPayload(
|
||||
user_id=payload["user_id"],
|
||||
org_id=payload["org_id"],
|
||||
email=payload["email"],
|
||||
role=payload["role"],
|
||||
exp=exp,
|
||||
)
|
||||
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid token",
|
||||
)
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Token has expired",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def refresh_token(token: str) -> str:
|
||||
"""토큰 갱신."""
|
||||
payload = JWTAuth.verify_token(token)
|
||||
return JWTAuth.create_token(
|
||||
user_id=payload.user_id,
|
||||
org_id=payload.org_id,
|
||||
email=payload.email,
|
||||
role=payload.role,
|
||||
)
|
||||
|
||||
|
||||
class APIKeyAuth:
|
||||
"""API 키 기반 인증."""
|
||||
|
||||
@staticmethod
|
||||
def generate_key() -> str:
|
||||
"""새 API 키 생성 (클라이언트에게만 보여줌)."""
|
||||
return f"sk_{secrets.token_urlsafe(32)}"
|
||||
|
||||
@staticmethod
|
||||
def hash_key(api_key: str) -> str:
|
||||
"""API 키 해시 (DB에 저장)."""
|
||||
return hashlib.sha256(api_key.encode()).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
async def verify_key(api_key: str) -> Dict[str, str]:
|
||||
"""API 키 검증 → org_id, user_id 반환.
|
||||
|
||||
실제 구현에서는 DB 조회가 필요합니다.
|
||||
"""
|
||||
key_hash = APIKeyAuth.hash_key(api_key)
|
||||
|
||||
# TODO: DB에서 조회
|
||||
# api_key_record = await db.get_api_key_by_hash(key_hash)
|
||||
# if not api_key_record or not api_key_record.is_active:
|
||||
# raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
# 현재는 모의 구현
|
||||
if not api_key.startswith("sk_"):
|
||||
raise HTTPException(status_code=401, detail="Invalid API key format")
|
||||
|
||||
return {
|
||||
"org_id": "org_123",
|
||||
"user_id": "user_456",
|
||||
}
|
||||
|
||||
|
||||
# 의존성 함수 (FastAPI)
|
||||
|
||||
|
||||
async def get_token_from_header(
|
||||
authorization: Optional[str] = Header(None),
|
||||
) -> str:
|
||||
"""헤더에서 토큰 추출."""
|
||||
if not authorization:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing authorization header",
|
||||
)
|
||||
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid authorization header format",
|
||||
)
|
||||
|
||||
return parts[1]
|
||||
|
||||
|
||||
async def get_api_key_from_header(
|
||||
x_api_key: Optional[str] = Header(None),
|
||||
) -> str:
|
||||
"""헤더에서 API 키 추출."""
|
||||
if not x_api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing API key",
|
||||
)
|
||||
|
||||
return x_api_key
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authorization: Optional[str] = Header(None),
|
||||
x_api_key: Optional[str] = Header(None),
|
||||
) -> CurrentUser:
|
||||
"""현재 인증된 사용자 반환 (JWT 또는 API 키)."""
|
||||
|
||||
# JWT 토큰으로 인증 시도
|
||||
if authorization:
|
||||
try:
|
||||
token = await get_token_from_header(authorization)
|
||||
payload = JWTAuth.verify_token(token)
|
||||
|
||||
return CurrentUser(
|
||||
user_id=payload.user_id,
|
||||
org_id=payload.org_id,
|
||||
email=payload.email,
|
||||
username=payload.email.split("@")[0],
|
||||
role=payload.role,
|
||||
is_active=True,
|
||||
)
|
||||
except HTTPException:
|
||||
pass # API 키 시도
|
||||
|
||||
# API 키로 인증 시도
|
||||
if x_api_key:
|
||||
try:
|
||||
result = await APIKeyAuth.verify_key(x_api_key)
|
||||
|
||||
return CurrentUser(
|
||||
user_id=result["user_id"],
|
||||
org_id=result["org_id"],
|
||||
email=f"api_user_{result['user_id']}@api",
|
||||
username=f"api_{result['user_id']}",
|
||||
role="api",
|
||||
is_active=True,
|
||||
)
|
||||
except HTTPException:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication failed (provide JWT token or API key)",
|
||||
)
|
||||
|
||||
|
||||
class PasswordHasher:
|
||||
"""비밀번호 해싱 (Argon2 대신 간단한 해시 사용)."""
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""비밀번호 해싱."""
|
||||
# 실제 운영에서는 bcrypt/argon2 사용
|
||||
salt = secrets.token_hex(8)
|
||||
hashed = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode(),
|
||||
salt.encode(),
|
||||
100000,
|
||||
).hex()
|
||||
return f"{salt}${hashed}"
|
||||
|
||||
@staticmethod
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
"""비밀번호 검증."""
|
||||
try:
|
||||
salt, hashed_pw = hashed.split("$")
|
||||
new_hash = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode(),
|
||||
salt.encode(),
|
||||
100000,
|
||||
).hex()
|
||||
return new_hash == hashed_pw
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""인증 서비스."""
|
||||
|
||||
@staticmethod
|
||||
async def register_user(
|
||||
org: Organization,
|
||||
email: str,
|
||||
username: str,
|
||||
password: str,
|
||||
role: str = "viewer",
|
||||
) -> tuple[User, str]:
|
||||
"""사용자 등록."""
|
||||
user = User(
|
||||
org_id=org.id,
|
||||
email=email,
|
||||
username=username,
|
||||
hashed_password=PasswordHasher.hash_password(password),
|
||||
role=role,
|
||||
)
|
||||
|
||||
# TODO: DB에 저장
|
||||
# await db.create_user(user)
|
||||
|
||||
token = JWTAuth.create_token(
|
||||
user_id=user.id,
|
||||
org_id=org.id,
|
||||
email=email,
|
||||
role=role,
|
||||
)
|
||||
|
||||
return user, token
|
||||
|
||||
@staticmethod
|
||||
async def login(
|
||||
org_id: str,
|
||||
email: str,
|
||||
password: str,
|
||||
) -> tuple[User, str]:
|
||||
"""사용자 로그인."""
|
||||
# TODO: DB에서 사용자 조회
|
||||
# user = await db.get_user_by_email(email, org_id)
|
||||
# if not user or not PasswordHasher.verify_password(password, user.hashed_password):
|
||||
# raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# 모의 구현
|
||||
user = User(
|
||||
id="user_123",
|
||||
org_id=org_id,
|
||||
email=email,
|
||||
username=email.split("@")[0],
|
||||
role="editor",
|
||||
)
|
||||
|
||||
token = JWTAuth.create_token(
|
||||
user_id=user.id,
|
||||
org_id=org_id,
|
||||
email=email,
|
||||
role=user.role,
|
||||
)
|
||||
|
||||
# TODO: 마지막 로그인 시간 업데이트
|
||||
# user.last_login = datetime.utcnow()
|
||||
# await db.update_user(user)
|
||||
|
||||
return user, token
|
||||
|
||||
@staticmethod
|
||||
async def create_api_key(
|
||||
org_id: str,
|
||||
user_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
) -> APIKey:
|
||||
"""API 키 생성."""
|
||||
api_key = APIKeyAuth.generate_key()
|
||||
api_key_record = APIKey(
|
||||
org_id=org_id,
|
||||
key_hash=APIKeyAuth.hash_key(api_key),
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# TODO: DB에 저장
|
||||
# await db.create_api_key(api_key_record)
|
||||
|
||||
# 클라이언트에게 원본 키만 한 번 반환
|
||||
api_key_record.original_key = api_key
|
||||
|
||||
return api_key_record
|
||||
Reference in New Issue
Block a user