"""인증 시스템 (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