Step 8: OAuth 2.0 인증 시스템 및 프로필 기능 구현

- 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 <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2025-09-10 17:37:16 +09:00
parent bf05e173cc
commit 1467766f3d
10 changed files with 1143 additions and 4 deletions

View File

@ -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()