From 1467766f3df0861cfbf32625bcd4f8122c4c0e0a Mon Sep 17 00:00:00 2001 From: jungwoo choi Date: Wed, 10 Sep 2025 17:37:16 +0900 Subject: [PATCH] =?UTF-8?q?Step=208:=20OAuth=202.0=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=B0=8F=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OAuth 2.0 서비스 구현 * Authorization Code, Client Credentials, Refresh Token 플로우 지원 * 애플리케이션 등록 및 관리 기능 * 토큰 introspection 및 revocation * SSO 설정 지원 (Google, GitHub, SAML) * 실용적인 스코프 시스템 (user, app, org, api 관리) - 사용자 프로필 기능 확장 * 프로필 사진 및 썸네일 필드 추가 * bio, location, website 등 추가 프로필 정보 * 이메일 인증 및 계정 활성화 상태 관리 * UserPublicResponse 모델 추가 - OAuth 스코프 관리 * picture 스코프 추가 (프로필 사진 접근 제어) * 카테고리별 스코프 정리 (기본 인증, 사용자 데이터, 앱 관리, 조직, API) * 스코프별 승인 필요 여부 설정 - 인프라 개선 * Users 서비스 포트 매핑 추가 (8001) * OAuth 서비스 Docker 구성 (포트 8003) * Kafka 이벤트 통합 (USER_CREATED, USER_UPDATED, USER_DELETED) 🤖 Generated with Claude Code Co-Authored-By: Claude --- docker-compose.yml | 28 ++ services/oauth/backend/Dockerfile | 21 + services/oauth/backend/database.py | 142 ++++++ services/oauth/backend/main.py | 591 ++++++++++++++++++++++++ services/oauth/backend/models.py | 126 +++++ services/oauth/backend/requirements.txt | 11 + services/oauth/backend/utils.py | 131 ++++++ services/users/backend/main.py | 85 +++- services/users/backend/models.py | 9 +- shared/kafka/producer.py | 3 +- 10 files changed, 1143 insertions(+), 4 deletions(-) create mode 100644 services/oauth/backend/Dockerfile create mode 100644 services/oauth/backend/database.py create mode 100644 services/oauth/backend/main.py create mode 100644 services/oauth/backend/models.py create mode 100644 services/oauth/backend/requirements.txt create mode 100644 services/oauth/backend/utils.py diff --git a/docker-compose.yml b/docker-compose.yml index 86b275e..aef7500 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,8 @@ services: context: ./services/users/backend dockerfile: Dockerfile container_name: ${COMPOSE_PROJECT_NAME}_users_backend + ports: + - "${USERS_BACKEND_PORT}:8000" environment: - ENV=${ENV} - PORT=8000 @@ -82,6 +84,32 @@ services: - redis - mongodb + oauth-backend: + build: + context: ./services/oauth/backend + dockerfile: Dockerfile + container_name: ${COMPOSE_PROJECT_NAME}_oauth_backend + ports: + - "${OAUTH_SERVICE_PORT}:8000" + environment: + - ENV=${ENV} + - PORT=8000 + - MONGODB_URL=${MONGODB_URL} + - OAUTH_DB_NAME=${OAUTH_DB_NAME} + - JWT_SECRET_KEY=${JWT_SECRET_KEY} + - JWT_ALGORITHM=${JWT_ALGORITHM} + - KAFKA_BOOTSTRAP_SERVERS=${KAFKA_BOOTSTRAP_SERVERS} + - KAFKA_GROUP_ID=${KAFKA_GROUP_ID} + volumes: + - ./services/oauth/backend:/app + - ./shared:/app/shared + networks: + - site11_network + restart: unless-stopped + depends_on: + - mongodb + - kafka + mongodb: image: mongo:7.0 container_name: ${COMPOSE_PROJECT_NAME}_mongodb diff --git a/services/oauth/backend/Dockerfile b/services/oauth/backend/Dockerfile new file mode 100644 index 0000000..2515968 --- /dev/null +++ b/services/oauth/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["python", "main.py"] \ No newline at end of file diff --git a/services/oauth/backend/database.py b/services/oauth/backend/database.py new file mode 100644 index 0000000..0b6dfea --- /dev/null +++ b/services/oauth/backend/database.py @@ -0,0 +1,142 @@ +from motor.motor_asyncio import AsyncIOMotorClient +from beanie import init_beanie +import os +from models import OAuthApplication, AuthorizationCode, AccessToken, OAuthScope, UserConsent + +async def init_db(): + client = AsyncIOMotorClient(os.getenv("MONGODB_URL", "mongodb://mongodb:27017")) + database = client[os.getenv("OAUTH_DB_NAME", "oauth_db")] + + await init_beanie( + database=database, + document_models=[ + OAuthApplication, + AuthorizationCode, + AccessToken, + OAuthScope, + UserConsent + ] + ) + + # 기본 스코프 생성 + await create_default_scopes() + +async def create_default_scopes(): + """기본 OAuth 스코프 생성""" + default_scopes = [ + # 기본 인증 스코프 + { + "name": "openid", + "display_name": "OpenID Connect", + "description": "기본 사용자 인증 정보", + "is_default": True, + "requires_approval": False + }, + { + "name": "profile", + "display_name": "프로필 정보", + "description": "이름, 프로필 이미지, 기본 정보 접근", + "is_default": True, + "requires_approval": True + }, + { + "name": "email", + "display_name": "이메일 주소", + "description": "이메일 주소 및 인증 상태 확인", + "is_default": False, + "requires_approval": True + }, + { + "name": "picture", + "display_name": "프로필 사진", + "description": "프로필 사진 및 썸네일 접근", + "is_default": False, + "requires_approval": True + }, + + # 사용자 데이터 접근 스코프 + { + "name": "user:read", + "display_name": "사용자 정보 읽기", + "description": "사용자 프로필 및 설정 읽기", + "is_default": False, + "requires_approval": True + }, + { + "name": "user:write", + "display_name": "사용자 정보 수정", + "description": "사용자 프로필 및 설정 수정", + "is_default": False, + "requires_approval": True + }, + + # 애플리케이션 관리 스코프 + { + "name": "app:read", + "display_name": "애플리케이션 정보 읽기", + "description": "OAuth 애플리케이션 정보 조회", + "is_default": False, + "requires_approval": True + }, + { + "name": "app:write", + "display_name": "애플리케이션 관리", + "description": "OAuth 애플리케이션 생성 및 수정", + "is_default": False, + "requires_approval": True + }, + + # 조직/팀 관련 스코프 + { + "name": "org:read", + "display_name": "조직 정보 읽기", + "description": "소속 조직 및 팀 정보 조회", + "is_default": False, + "requires_approval": True + }, + { + "name": "org:write", + "display_name": "조직 관리", + "description": "조직 설정 및 멤버 관리", + "is_default": False, + "requires_approval": True + }, + + # API 접근 스코프 + { + "name": "api:read", + "display_name": "API 데이터 읽기", + "description": "API를 통한 데이터 조회", + "is_default": False, + "requires_approval": True + }, + { + "name": "api:write", + "display_name": "API 데이터 쓰기", + "description": "API를 통한 데이터 생성/수정/삭제", + "is_default": False, + "requires_approval": True + }, + + # 특수 스코프 + { + "name": "offline_access", + "display_name": "오프라인 액세스", + "description": "리프레시 토큰 발급 (장기 액세스)", + "is_default": False, + "requires_approval": True + }, + { + "name": "admin", + "display_name": "관리자 권한", + "description": "전체 시스템 관리 권한", + "is_default": False, + "requires_approval": True + } + ] + + for scope_data in default_scopes: + existing = await OAuthScope.find_one(OAuthScope.name == scope_data["name"]) + if not existing: + scope = OAuthScope(**scope_data) + await scope.create() \ No newline at end of file diff --git a/services/oauth/backend/main.py b/services/oauth/backend/main.py new file mode 100644 index 0000000..a6f51a2 --- /dev/null +++ b/services/oauth/backend/main.py @@ -0,0 +1,591 @@ +from fastapi import FastAPI, HTTPException, Depends, Form, Query, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse, JSONResponse +from contextlib import asynccontextmanager +from datetime import datetime, timedelta +from typing import Optional, List, Dict +import uvicorn +import os +import sys +import logging + +from database import init_db +from models import ( + OAuthApplication, AuthorizationCode, AccessToken, + OAuthScope, UserConsent, GrantType, ResponseType +) +from utils import OAuthUtils, TokenGenerator, ScopeValidator +from pydantic import BaseModel, Field +from beanie import PydanticObjectId + +sys.path.append('/app') +from shared.kafka import KafkaProducer, Event, EventType + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Pydantic models +class ApplicationCreate(BaseModel): + name: str + description: Optional[str] = None + redirect_uris: List[str] + website_url: Optional[str] = None + logo_url: Optional[str] = None + privacy_policy_url: Optional[str] = None + terms_url: Optional[str] = None + sso_enabled: Optional[bool] = False + sso_provider: Optional[str] = None + sso_config: Optional[Dict] = None + allowed_domains: Optional[List[str]] = None + +class ApplicationUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + redirect_uris: Optional[List[str]] = None + website_url: Optional[str] = None + logo_url: Optional[str] = None + privacy_policy_url: Optional[str] = None + terms_url: Optional[str] = None + is_active: Optional[bool] = None + sso_enabled: Optional[bool] = None + sso_provider: Optional[str] = None + sso_config: Optional[Dict] = None + allowed_domains: Optional[List[str]] = None + +class ApplicationResponse(BaseModel): + id: str + client_id: str + name: str + description: Optional[str] + redirect_uris: List[str] + allowed_scopes: List[str] + grant_types: List[str] + is_active: bool + is_trusted: bool + sso_enabled: bool + sso_provider: Optional[str] + allowed_domains: List[str] + website_url: Optional[str] + logo_url: Optional[str] + created_at: datetime + +class TokenRequest(BaseModel): + grant_type: str + code: Optional[str] = None + redirect_uri: Optional[str] = None + client_id: Optional[str] = None + client_secret: Optional[str] = None + refresh_token: Optional[str] = None + scope: Optional[str] = None + code_verifier: Optional[str] = None + +# Global Kafka producer +kafka_producer: Optional[KafkaProducer] = None + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + global kafka_producer + + await init_db() + + # Initialize Kafka producer + try: + kafka_producer = KafkaProducer( + bootstrap_servers=os.getenv('KAFKA_BOOTSTRAP_SERVERS', 'kafka:9092') + ) + await kafka_producer.start() + logger.info("Kafka producer initialized") + except Exception as e: + logger.warning(f"Failed to initialize Kafka producer: {e}") + kafka_producer = None + + yield + + # Shutdown + if kafka_producer: + await kafka_producer.stop() + +app = FastAPI( + title="OAuth 2.0 Service", + description="OAuth 2.0 인증 서버 및 애플리케이션 관리", + version="1.0.0", + lifespan=lifespan +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Health check +@app.get("/health") +async def health_check(): + return { + "status": "healthy", + "service": "oauth", + "timestamp": datetime.now().isoformat() + } + +# OAuth Application Management +@app.post("/applications", response_model=ApplicationResponse, status_code=201) +async def create_application( + app_data: ApplicationCreate, + current_user_id: str = "test_user" # TODO: Get from JWT token +): + """새로운 OAuth 애플리케이션 등록""" + client_id = OAuthUtils.generate_client_id() + client_secret = OAuthUtils.generate_client_secret() + hashed_secret = OAuthUtils.hash_client_secret(client_secret) + + # 기본 스코프 가져오기 + default_scopes = await OAuthScope.find(OAuthScope.is_default == True).to_list() + allowed_scopes = [scope.name for scope in default_scopes] + + application = OAuthApplication( + client_id=client_id, + client_secret=hashed_secret, + name=app_data.name, + description=app_data.description, + owner_id=current_user_id, + redirect_uris=app_data.redirect_uris, + allowed_scopes=allowed_scopes, + grant_types=[GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN], + sso_enabled=app_data.sso_enabled or False, + sso_provider=app_data.sso_provider, + sso_config=app_data.sso_config or {}, + allowed_domains=app_data.allowed_domains or [], + website_url=app_data.website_url, + logo_url=app_data.logo_url, + privacy_policy_url=app_data.privacy_policy_url, + terms_url=app_data.terms_url + ) + + await application.create() + + # 이벤트 발행 + if kafka_producer: + event = Event( + event_type=EventType.TASK_CREATED, + service="oauth", + data={ + "app_id": str(application.id), + "client_id": client_id, + "name": application.name, + "owner_id": current_user_id + } + ) + await kafka_producer.send_event("oauth-events", event) + + # 클라이언트 시크릿은 생성 시에만 반환 + return { + **ApplicationResponse( + id=str(application.id), + client_id=application.client_id, + name=application.name, + description=application.description, + redirect_uris=application.redirect_uris, + allowed_scopes=application.allowed_scopes, + grant_types=[gt.value for gt in application.grant_types], + is_active=application.is_active, + is_trusted=application.is_trusted, + sso_enabled=application.sso_enabled, + sso_provider=application.sso_provider, + allowed_domains=application.allowed_domains, + website_url=application.website_url, + logo_url=application.logo_url, + created_at=application.created_at + ).dict(), + "client_secret": client_secret # 최초 생성 시에만 반환 + } + +@app.get("/applications", response_model=List[ApplicationResponse]) +async def list_applications( + owner_id: Optional[str] = None, + is_active: Optional[bool] = None +): + """OAuth 애플리케이션 목록 조회""" + query = {} + if owner_id: + query["owner_id"] = owner_id + if is_active is not None: + query["is_active"] = is_active + + applications = await OAuthApplication.find(query).to_list() + + return [ + ApplicationResponse( + id=str(app.id), + client_id=app.client_id, + name=app.name, + description=app.description, + redirect_uris=app.redirect_uris, + allowed_scopes=app.allowed_scopes, + grant_types=[gt.value for gt in app.grant_types], + is_active=app.is_active, + is_trusted=app.is_trusted, + sso_enabled=app.sso_enabled, + sso_provider=app.sso_provider, + allowed_domains=app.allowed_domains, + website_url=app.website_url, + logo_url=app.logo_url, + created_at=app.created_at + ) + for app in applications + ] + +@app.get("/applications/{client_id}", response_model=ApplicationResponse) +async def get_application(client_id: str): + """OAuth 애플리케이션 상세 조회""" + application = await OAuthApplication.find_one(OAuthApplication.client_id == client_id) + if not application: + raise HTTPException(status_code=404, detail="Application not found") + + return ApplicationResponse( + id=str(application.id), + client_id=application.client_id, + name=application.name, + description=application.description, + redirect_uris=application.redirect_uris, + allowed_scopes=application.allowed_scopes, + grant_types=[gt.value for gt in application.grant_types], + is_active=application.is_active, + is_trusted=application.is_trusted, + sso_enabled=application.sso_enabled, + sso_provider=application.sso_provider, + allowed_domains=application.allowed_domains, + website_url=application.website_url, + logo_url=application.logo_url, + created_at=application.created_at + ) + +# OAuth 2.0 Authorization Endpoint +@app.get("/authorize") +async def authorize( + response_type: str = Query(..., description="응답 타입 (code, token)"), + client_id: str = Query(..., description="클라이언트 ID"), + redirect_uri: str = Query(..., description="리다이렉트 URI"), + scope: str = Query("", description="요청 스코프"), + state: Optional[str] = Query(None, description="상태 값"), + code_challenge: Optional[str] = Query(None, description="PKCE challenge"), + code_challenge_method: Optional[str] = Query("S256", description="PKCE method"), + current_user_id: str = "test_user" # TODO: Get from session/JWT +): + """OAuth 2.0 인증 엔드포인트""" + + # 애플리케이션 확인 + application = await OAuthApplication.find_one(OAuthApplication.client_id == client_id) + if not application or not application.is_active: + raise HTTPException(status_code=400, detail="Invalid client") + + # 리다이렉트 URI 확인 + if redirect_uri not in application.redirect_uris: + raise HTTPException(status_code=400, detail="Invalid redirect URI") + + # 스코프 검증 + requested_scopes = ScopeValidator.parse_scope_string(scope) + valid_scopes = ScopeValidator.validate_scopes(requested_scopes, application.allowed_scopes) + + # 사용자 동의 확인 (신뢰할 수 있는 앱이거나 이미 동의한 경우 건너뛰기) + if not application.is_trusted: + consent = await UserConsent.find_one( + UserConsent.user_id == current_user_id, + UserConsent.client_id == client_id + ) + + if not consent or set(valid_scopes) - set(consent.granted_scopes): + # TODO: 동의 화면으로 리다이렉트 + pass + + if response_type == "code": + # Authorization Code Flow + code = OAuthUtils.generate_authorization_code() + + auth_code = AuthorizationCode( + code=code, + client_id=client_id, + user_id=current_user_id, + redirect_uri=redirect_uri, + scopes=valid_scopes, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + expires_at=datetime.now() + timedelta(minutes=10) + ) + + await auth_code.create() + + # 리다이렉트 URL 생성 + redirect_url = f"{redirect_uri}?code={code}" + if state: + redirect_url += f"&state={state}" + + return RedirectResponse(url=redirect_url) + + elif response_type == "token": + # Implicit Flow (권장하지 않음) + raise HTTPException(status_code=400, detail="Implicit flow not supported") + + else: + raise HTTPException(status_code=400, detail="Unsupported response type") + +# OAuth 2.0 Token Endpoint +@app.post("/token") +async def token( + grant_type: str = Form(...), + code: Optional[str] = Form(None), + redirect_uri: Optional[str] = Form(None), + client_id: Optional[str] = Form(None), + client_secret: Optional[str] = Form(None), + refresh_token: Optional[str] = Form(None), + scope: Optional[str] = Form(None), + code_verifier: Optional[str] = Form(None) +): + """OAuth 2.0 토큰 엔드포인트""" + + # 클라이언트 인증 + if not client_id or not client_secret: + raise HTTPException( + status_code=401, + detail="Client authentication required", + headers={"WWW-Authenticate": "Basic"} + ) + + application = await OAuthApplication.find_one(OAuthApplication.client_id == client_id) + if not application or not OAuthUtils.verify_client_secret(client_secret, application.client_secret): + raise HTTPException(status_code=401, detail="Invalid client credentials") + + if grant_type == "authorization_code": + # Authorization Code Grant + if not code or not redirect_uri: + raise HTTPException(status_code=400, detail="Missing required parameters") + + auth_code = await AuthorizationCode.find_one( + AuthorizationCode.code == code, + AuthorizationCode.client_id == client_id + ) + + if not auth_code: + raise HTTPException(status_code=400, detail="Invalid authorization code") + + if auth_code.used: + raise HTTPException(status_code=400, detail="Authorization code already used") + + if auth_code.expires_at < datetime.now(): + raise HTTPException(status_code=400, detail="Authorization code expired") + + if auth_code.redirect_uri != redirect_uri: + raise HTTPException(status_code=400, detail="Redirect URI mismatch") + + # PKCE 검증 + if auth_code.code_challenge: + if not code_verifier: + raise HTTPException(status_code=400, detail="Code verifier required") + + if not OAuthUtils.verify_pkce_challenge( + code_verifier, + auth_code.code_challenge, + auth_code.code_challenge_method + ): + raise HTTPException(status_code=400, detail="Invalid code verifier") + + # 코드를 사용됨으로 표시 + auth_code.used = True + auth_code.used_at = datetime.now() + await auth_code.save() + + # 토큰 생성 + access_token = OAuthUtils.generate_access_token() + refresh_token = OAuthUtils.generate_refresh_token() + + token_doc = AccessToken( + token=access_token, + refresh_token=refresh_token, + client_id=client_id, + user_id=auth_code.user_id, + scopes=auth_code.scopes, + expires_at=datetime.now() + timedelta(hours=1), + refresh_expires_at=datetime.now() + timedelta(days=30) + ) + + await token_doc.create() + + return TokenGenerator.generate_token_response( + access_token=access_token, + expires_in=3600, + refresh_token=refresh_token, + scope=" ".join(auth_code.scopes) + ) + + elif grant_type == "refresh_token": + # Refresh Token Grant + if not refresh_token: + raise HTTPException(status_code=400, detail="Refresh token required") + + token_doc = await AccessToken.find_one( + AccessToken.refresh_token == refresh_token, + AccessToken.client_id == client_id + ) + + if not token_doc: + raise HTTPException(status_code=400, detail="Invalid refresh token") + + if token_doc.revoked: + raise HTTPException(status_code=400, detail="Token has been revoked") + + if token_doc.refresh_expires_at and token_doc.refresh_expires_at < datetime.now(): + raise HTTPException(status_code=400, detail="Refresh token expired") + + # 기존 토큰 폐기 + token_doc.revoked = True + token_doc.revoked_at = datetime.now() + await token_doc.save() + + # 새 토큰 생성 + new_access_token = OAuthUtils.generate_access_token() + new_refresh_token = OAuthUtils.generate_refresh_token() + + new_token_doc = AccessToken( + token=new_access_token, + refresh_token=new_refresh_token, + client_id=client_id, + user_id=token_doc.user_id, + scopes=token_doc.scopes, + expires_at=datetime.now() + timedelta(hours=1), + refresh_expires_at=datetime.now() + timedelta(days=30) + ) + + await new_token_doc.create() + + return TokenGenerator.generate_token_response( + access_token=new_access_token, + expires_in=3600, + refresh_token=new_refresh_token, + scope=" ".join(token_doc.scopes) + ) + + elif grant_type == "client_credentials": + # Client Credentials Grant + requested_scopes = ScopeValidator.parse_scope_string(scope) if scope else [] + valid_scopes = ScopeValidator.validate_scopes(requested_scopes, application.allowed_scopes) + + access_token = OAuthUtils.generate_access_token() + + token_doc = AccessToken( + token=access_token, + client_id=client_id, + scopes=valid_scopes, + expires_at=datetime.now() + timedelta(hours=1) + ) + + await token_doc.create() + + return TokenGenerator.generate_token_response( + access_token=access_token, + expires_in=3600, + scope=" ".join(valid_scopes) + ) + + else: + raise HTTPException(status_code=400, detail="Unsupported grant type") + +# Token Introspection Endpoint +@app.post("/introspect") +async def introspect( + token: str = Form(...), + token_type_hint: Optional[str] = Form(None), + client_id: str = Form(...), + client_secret: str = Form(...) +): + """토큰 검증 엔드포인트""" + + # 클라이언트 인증 + application = await OAuthApplication.find_one(OAuthApplication.client_id == client_id) + if not application or not OAuthUtils.verify_client_secret(client_secret, application.client_secret): + raise HTTPException(status_code=401, detail="Invalid client credentials") + + # 토큰 조회 + token_doc = await AccessToken.find_one(AccessToken.token == token) + + if not token_doc or token_doc.revoked or token_doc.expires_at < datetime.now(): + return {"active": False} + + # 토큰 사용 시간 업데이트 + token_doc.last_used_at = datetime.now() + await token_doc.save() + + return { + "active": True, + "scope": " ".join(token_doc.scopes), + "client_id": token_doc.client_id, + "username": token_doc.user_id, + "exp": int(token_doc.expires_at.timestamp()) + } + +# Token Revocation Endpoint +@app.post("/revoke") +async def revoke( + token: str = Form(...), + token_type_hint: Optional[str] = Form(None), + client_id: str = Form(...), + client_secret: str = Form(...) +): + """토큰 폐기 엔드포인트""" + + # 클라이언트 인증 + application = await OAuthApplication.find_one(OAuthApplication.client_id == client_id) + if not application or not OAuthUtils.verify_client_secret(client_secret, application.client_secret): + raise HTTPException(status_code=401, detail="Invalid client credentials") + + # 토큰 조회 및 폐기 + token_doc = await AccessToken.find_one( + AccessToken.token == token, + AccessToken.client_id == client_id + ) + + if token_doc and not token_doc.revoked: + token_doc.revoked = True + token_doc.revoked_at = datetime.now() + await token_doc.save() + + # 이벤트 발행 + if kafka_producer: + event = Event( + event_type=EventType.TASK_COMPLETED, + service="oauth", + data={ + "action": "token_revoked", + "token_id": str(token_doc.id), + "client_id": client_id + } + ) + await kafka_producer.send_event("oauth-events", event) + + return {"status": "success"} + +# Scopes Management +@app.get("/scopes") +async def list_scopes(): + """사용 가능한 스코프 목록 조회""" + scopes = await OAuthScope.find_all().to_list() + return [ + { + "name": scope.name, + "display_name": scope.display_name, + "description": scope.description, + "is_default": scope.is_default, + "requires_approval": scope.requires_approval + } + for scope in scopes + ] + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=True + ) \ No newline at end of file diff --git a/services/oauth/backend/models.py b/services/oauth/backend/models.py new file mode 100644 index 0000000..866b09b --- /dev/null +++ b/services/oauth/backend/models.py @@ -0,0 +1,126 @@ +from beanie import Document, PydanticObjectId +from pydantic import BaseModel, Field, EmailStr +from typing import Optional, List, Dict +from datetime import datetime +from enum import Enum + +class GrantType(str, Enum): + AUTHORIZATION_CODE = "authorization_code" + CLIENT_CREDENTIALS = "client_credentials" + PASSWORD = "password" + REFRESH_TOKEN = "refresh_token" + +class ResponseType(str, Enum): + CODE = "code" + TOKEN = "token" + +class TokenType(str, Enum): + BEARER = "Bearer" + +class OAuthApplication(Document): + """OAuth 2.0 클라이언트 애플리케이션""" + client_id: str = Field(..., unique=True, description="클라이언트 ID") + client_secret: str = Field(..., description="클라이언트 시크릿 (해시됨)") + name: str = Field(..., description="애플리케이션 이름") + description: Optional[str] = Field(None, description="애플리케이션 설명") + + owner_id: str = Field(..., description="애플리케이션 소유자 ID") + + redirect_uris: List[str] = Field(default_factory=list, description="허용된 리다이렉트 URI들") + allowed_scopes: List[str] = Field(default_factory=list, description="허용된 스코프들") + grant_types: List[GrantType] = Field(default_factory=lambda: [GrantType.AUTHORIZATION_CODE], description="허용된 grant types") + + is_active: bool = Field(default=True, description="활성화 상태") + is_trusted: bool = Field(default=False, description="신뢰할 수 있는 앱 (자동 승인)") + + # SSO 설정 + sso_enabled: bool = Field(default=False, description="SSO 활성화 여부") + sso_provider: Optional[str] = Field(None, description="SSO 제공자 (google, github, saml 등)") + sso_config: Optional[Dict] = Field(default_factory=dict, description="SSO 설정 (provider별 설정)") + allowed_domains: List[str] = Field(default_factory=list, description="SSO 허용 도메인 (예: @company.com)") + + website_url: Optional[str] = Field(None, description="애플리케이션 웹사이트") + logo_url: Optional[str] = Field(None, description="애플리케이션 로고 URL") + privacy_policy_url: Optional[str] = Field(None, description="개인정보 처리방침 URL") + terms_url: Optional[str] = Field(None, description="이용약관 URL") + + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + + class Settings: + collection = "oauth_applications" + +class AuthorizationCode(Document): + """OAuth 2.0 인증 코드""" + code: str = Field(..., unique=True, description="인증 코드") + client_id: str = Field(..., description="클라이언트 ID") + user_id: str = Field(..., description="사용자 ID") + + redirect_uri: str = Field(..., description="리다이렉트 URI") + scopes: List[str] = Field(default_factory=list, description="요청된 스코프") + + code_challenge: Optional[str] = Field(None, description="PKCE code challenge") + code_challenge_method: Optional[str] = Field(None, description="PKCE challenge method") + + expires_at: datetime = Field(..., description="만료 시간") + used: bool = Field(default=False, description="사용 여부") + used_at: Optional[datetime] = Field(None, description="사용 시간") + + created_at: datetime = Field(default_factory=datetime.now) + + class Settings: + collection = "authorization_codes" + +class AccessToken(Document): + """OAuth 2.0 액세스 토큰""" + token: str = Field(..., unique=True, description="액세스 토큰") + refresh_token: Optional[str] = Field(None, description="리프레시 토큰") + + client_id: str = Field(..., description="클라이언트 ID") + user_id: Optional[str] = Field(None, description="사용자 ID (client credentials flow에서는 없음)") + + token_type: TokenType = Field(default=TokenType.BEARER) + scopes: List[str] = Field(default_factory=list, description="부여된 스코프") + + expires_at: datetime = Field(..., description="액세스 토큰 만료 시간") + refresh_expires_at: Optional[datetime] = Field(None, description="리프레시 토큰 만료 시간") + + revoked: bool = Field(default=False, description="폐기 여부") + revoked_at: Optional[datetime] = Field(None, description="폐기 시간") + + created_at: datetime = Field(default_factory=datetime.now) + last_used_at: Optional[datetime] = Field(None, description="마지막 사용 시간") + + class Settings: + collection = "access_tokens" + +class OAuthScope(Document): + """OAuth 스코프 정의""" + name: str = Field(..., unique=True, description="스코프 이름 (예: read:profile)") + display_name: str = Field(..., description="표시 이름") + description: str = Field(..., description="스코프 설명") + + is_default: bool = Field(default=False, description="기본 스코프 여부") + requires_approval: bool = Field(default=True, description="사용자 승인 필요 여부") + + created_at: datetime = Field(default_factory=datetime.now) + + class Settings: + collection = "oauth_scopes" + +class UserConsent(Document): + """사용자 동의 기록""" + user_id: str = Field(..., description="사용자 ID") + client_id: str = Field(..., description="클라이언트 ID") + + granted_scopes: List[str] = Field(default_factory=list, description="승인된 스코프") + + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + expires_at: Optional[datetime] = Field(None, description="동의 만료 시간") + + class Settings: + collection = "user_consents" + indexes = [ + [("user_id", 1), ("client_id", 1)] + ] \ No newline at end of file diff --git a/services/oauth/backend/requirements.txt b/services/oauth/backend/requirements.txt new file mode 100644 index 0000000..79d6f3a --- /dev/null +++ b/services/oauth/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +pydantic[email]==2.5.3 +pymongo==4.6.1 +motor==3.3.2 +beanie==1.23.6 +authlib==1.3.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +aiokafka==0.10.0 \ No newline at end of file diff --git a/services/oauth/backend/utils.py b/services/oauth/backend/utils.py new file mode 100644 index 0000000..5b62d5e --- /dev/null +++ b/services/oauth/backend/utils.py @@ -0,0 +1,131 @@ +import secrets +import hashlib +import base64 +from datetime import datetime, timedelta +from typing import Optional, List +from passlib.context import CryptContext +from jose import JWTError, jwt +import os + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +class OAuthUtils: + @staticmethod + def generate_client_id() -> str: + """클라이언트 ID 생성""" + return secrets.token_urlsafe(24) + + @staticmethod + def generate_client_secret() -> str: + """클라이언트 시크릿 생성""" + return secrets.token_urlsafe(32) + + @staticmethod + def hash_client_secret(secret: str) -> str: + """클라이언트 시크릿 해싱""" + return pwd_context.hash(secret) + + @staticmethod + def verify_client_secret(plain_secret: str, hashed_secret: str) -> bool: + """클라이언트 시크릿 검증""" + return pwd_context.verify(plain_secret, hashed_secret) + + @staticmethod + def generate_authorization_code() -> str: + """인증 코드 생성""" + return secrets.token_urlsafe(32) + + @staticmethod + def generate_access_token() -> str: + """액세스 토큰 생성""" + return secrets.token_urlsafe(32) + + @staticmethod + def generate_refresh_token() -> str: + """리프레시 토큰 생성""" + return secrets.token_urlsafe(48) + + @staticmethod + def verify_pkce_challenge(verifier: str, challenge: str, method: str = "S256") -> bool: + """PKCE challenge 검증""" + if method == "plain": + return verifier == challenge + elif method == "S256": + verifier_hash = hashlib.sha256(verifier.encode()).digest() + verifier_challenge = base64.urlsafe_b64encode(verifier_hash).decode().rstrip("=") + return verifier_challenge == challenge + return False + + @staticmethod + def create_jwt_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """JWT 토큰 생성""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + + secret_key = os.getenv("JWT_SECRET_KEY", "your-secret-key") + algorithm = os.getenv("JWT_ALGORITHM", "HS256") + + encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=algorithm) + return encoded_jwt + + @staticmethod + def decode_jwt_token(token: str) -> Optional[dict]: + """JWT 토큰 디코딩""" + try: + secret_key = os.getenv("JWT_SECRET_KEY", "your-secret-key") + algorithm = os.getenv("JWT_ALGORITHM", "HS256") + + payload = jwt.decode(token, secret_key, algorithms=[algorithm]) + return payload + except JWTError: + return None + +class TokenGenerator: + @staticmethod + def generate_token_response( + access_token: str, + token_type: str = "Bearer", + expires_in: int = 3600, + refresh_token: Optional[str] = None, + scope: Optional[str] = None, + id_token: Optional[str] = None + ) -> dict: + """OAuth 2.0 토큰 응답 생성""" + response = { + "access_token": access_token, + "token_type": token_type, + "expires_in": expires_in + } + + if refresh_token: + response["refresh_token"] = refresh_token + + if scope: + response["scope"] = scope + + if id_token: + response["id_token"] = id_token + + return response + +class ScopeValidator: + @staticmethod + def validate_scopes(requested_scopes: List[str], allowed_scopes: List[str]) -> List[str]: + """요청된 스코프가 허용된 스코프에 포함되는지 검증""" + return [scope for scope in requested_scopes if scope in allowed_scopes] + + @staticmethod + def has_scope(token_scopes: List[str], required_scope: str) -> bool: + """토큰이 특정 스코프를 가지고 있는지 확인""" + return required_scope in token_scopes + + @staticmethod + def parse_scope_string(scope_string: str) -> List[str]: + """스코프 문자열을 리스트로 파싱""" + if not scope_string: + return [] + return scope_string.strip().split() \ No newline at end of file diff --git a/services/users/backend/main.py b/services/users/backend/main.py index 01472c4..6ebcb14 100644 --- a/services/users/backend/main.py +++ b/services/users/backend/main.py @@ -24,20 +24,50 @@ class UserCreate(BaseModel): username: str email: str full_name: Optional[str] = None + profile_picture: Optional[str] = None + bio: Optional[str] = None + location: Optional[str] = None + website: Optional[str] = None class UserUpdate(BaseModel): username: Optional[str] = None email: Optional[str] = None full_name: Optional[str] = None + profile_picture: Optional[str] = None + profile_picture_thumbnail: Optional[str] = None + bio: Optional[str] = None + location: Optional[str] = None + website: Optional[str] = None + is_email_verified: Optional[bool] = None + is_active: Optional[bool] = None class UserResponse(BaseModel): id: str username: str email: str full_name: Optional[str] = None + profile_picture: Optional[str] = None + profile_picture_thumbnail: Optional[str] = None + bio: Optional[str] = None + location: Optional[str] = None + website: Optional[str] = None + is_email_verified: bool + is_active: bool created_at: datetime updated_at: datetime +class UserPublicResponse(BaseModel): + """공개 프로필용 응답 (민감한 정보 제외)""" + id: str + username: str + full_name: Optional[str] = None + profile_picture: Optional[str] = None + profile_picture_thumbnail: Optional[str] = None + bio: Optional[str] = None + location: Optional[str] = None + website: Optional[str] = None + created_at: datetime + # Global Kafka producer kafka_producer: Optional[KafkaProducer] = None @@ -101,6 +131,13 @@ async def get_users(): username=user.username, email=user.email, full_name=user.full_name, + profile_picture=user.profile_picture, + profile_picture_thumbnail=user.profile_picture_thumbnail, + bio=user.bio, + location=user.location, + website=user.website, + is_email_verified=user.is_email_verified, + is_active=user.is_active, created_at=user.created_at, updated_at=user.updated_at ) for user in users] @@ -116,6 +153,13 @@ async def get_user(user_id: str): username=user.username, email=user.email, full_name=user.full_name, + profile_picture=user.profile_picture, + profile_picture_thumbnail=user.profile_picture_thumbnail, + bio=user.bio, + location=user.location, + website=user.website, + is_email_verified=user.is_email_verified, + is_active=user.is_active, created_at=user.created_at, updated_at=user.updated_at ) @@ -133,7 +177,11 @@ async def create_user(user_data: UserCreate): user = User( username=user_data.username, email=user_data.email, - full_name=user_data.full_name + full_name=user_data.full_name, + profile_picture=user_data.profile_picture, + bio=user_data.bio, + location=user_data.location, + website=user_data.website ) await user.create() @@ -157,6 +205,13 @@ async def create_user(user_data: UserCreate): username=user.username, email=user.email, full_name=user.full_name, + profile_picture=user.profile_picture, + profile_picture_thumbnail=user.profile_picture_thumbnail, + bio=user.bio, + location=user.location, + website=user.website, + is_email_verified=user.is_email_verified, + is_active=user.is_active, created_at=user.created_at, updated_at=user.updated_at ) @@ -186,6 +241,27 @@ async def update_user(user_id: str, user_update: UserUpdate): if user_update.full_name is not None: user.full_name = user_update.full_name + if user_update.profile_picture is not None: + user.profile_picture = user_update.profile_picture + + if user_update.profile_picture_thumbnail is not None: + user.profile_picture_thumbnail = user_update.profile_picture_thumbnail + + if user_update.bio is not None: + user.bio = user_update.bio + + if user_update.location is not None: + user.location = user_update.location + + if user_update.website is not None: + user.website = user_update.website + + if user_update.is_email_verified is not None: + user.is_email_verified = user_update.is_email_verified + + if user_update.is_active is not None: + user.is_active = user_update.is_active + user.updated_at = datetime.now() await user.save() @@ -209,6 +285,13 @@ async def update_user(user_id: str, user_update: UserUpdate): username=user.username, email=user.email, full_name=user.full_name, + profile_picture=user.profile_picture, + profile_picture_thumbnail=user.profile_picture_thumbnail, + bio=user.bio, + location=user.location, + website=user.website, + is_email_verified=user.is_email_verified, + is_active=user.is_active, created_at=user.created_at, updated_at=user.updated_at ) diff --git a/services/users/backend/models.py b/services/users/backend/models.py index 8e31e23..e4e6b3b 100644 --- a/services/users/backend/models.py +++ b/services/users/backend/models.py @@ -1,5 +1,5 @@ from beanie import Document -from pydantic import EmailStr, Field +from pydantic import EmailStr, Field, HttpUrl from datetime import datetime from typing import Optional @@ -8,6 +8,13 @@ class User(Document): username: str = Field(..., unique=True) email: EmailStr full_name: Optional[str] = None + profile_picture: Optional[str] = Field(None, description="프로필 사진 URL") + profile_picture_thumbnail: Optional[str] = Field(None, description="프로필 사진 썸네일 URL") + bio: Optional[str] = Field(None, max_length=500, description="자기소개") + location: Optional[str] = Field(None, description="위치") + website: Optional[str] = Field(None, description="개인 웹사이트") + is_email_verified: bool = Field(default=False, description="이메일 인증 여부") + is_active: bool = Field(default=True, description="계정 활성화 상태") created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) diff --git a/shared/kafka/producer.py b/shared/kafka/producer.py index 4f525f6..0a33ba0 100644 --- a/shared/kafka/producer.py +++ b/shared/kafka/producer.py @@ -22,8 +22,7 @@ class KafkaProducer: value_serializer=lambda v: json.dumps(v).encode(), compression_type="gzip", acks='all', - retry_backoff_ms=100, - max_in_flight_requests_per_connection=5 + retry_backoff_ms=100 ) await self._producer.start() logger.info(f"Kafka Producer started: {self.bootstrap_servers}")