339 lines
9.0 KiB
Python
339 lines
9.0 KiB
Python
|
|
"""인증 시스템 (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
|