""" 인증 서비스 TK-FB-Project의 auth.service.js를 참고하여 FastAPI용으로 구현 """ from typing import Dict, Any, Optional, Tuple from datetime import datetime, timedelta from fastapi import HTTPException, status, Request from sqlalchemy.orm import Session from .models import User, UserRepository from .jwt_service import jwt_service from ..utils.logger import get_logger from ..utils.error_handlers import TKMPException logger = get_logger(__name__) class AuthService: """인증 서비스 클래스""" def __init__(self, db: Session): self.db = db self.user_repo = UserRepository(db) async def login(self, username: str, password: str, request: Request) -> Dict[str, Any]: """ 사용자 로그인 Args: username: 사용자명 password: 비밀번호 request: FastAPI Request 객체 Returns: Dict[str, Any]: 로그인 결과 (토큰, 사용자 정보 등) Raises: TKMPException: 로그인 실패 시 """ try: # 클라이언트 정보 추출 ip_address = self._get_client_ip(request) user_agent = request.headers.get('user-agent', 'unknown') logger.info(f"Login attempt for username: {username} from IP: {ip_address}") # 입력 검증 if not username or not password: await self._record_login_failure(None, ip_address, user_agent, 'missing_credentials') raise TKMPException( message="사용자명과 비밀번호를 입력해주세요", status_code=status.HTTP_400_BAD_REQUEST ) # 사용자 조회 user = self.user_repo.find_by_username(username) if not user: await self._record_login_failure(None, ip_address, user_agent, 'user_not_found') logger.warning(f"Login failed - user not found: {username}") raise TKMPException( status_code=status.HTTP_401_UNAUTHORIZED, message="아이디 또는 비밀번호가 올바르지 않습니다" ) # 계정 활성화 상태 확인 if not user.is_active: await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_disabled') logger.warning(f"Login failed - account disabled: {username}") raise TKMPException( status_code=status.HTTP_403_FORBIDDEN, message="비활성화된 계정입니다. 관리자에게 문의하세요" ) # 계정 잠금 상태 확인 if user.is_locked(): remaining_time = int((user.locked_until - datetime.utcnow()).total_seconds() / 60) await self._record_login_failure(user.user_id, ip_address, user_agent, 'account_locked') logger.warning(f"Login failed - account locked: {username}") raise TKMPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, message=f"계정이 잠겨있습니다. {remaining_time}분 후에 다시 시도하세요" ) # 비밀번호 확인 if not user.check_password(password): # 로그인 실패 처리 user.increment_failed_attempts() self.user_repo.update_user(user) await self._record_login_failure(user.user_id, ip_address, user_agent, 'invalid_password') logger.warning(f"Login failed - invalid password: {username}") # 계정 잠금 확인 if user.failed_login_attempts >= 5: logger.warning(f"Account locked due to failed attempts: {username}") raise TKMPException( message="아이디 또는 비밀번호가 올바르지 않습니다", status_code=status.HTTP_401_UNAUTHORIZED ) # 로그인 성공 처리 user.reset_failed_attempts() user.update_last_login() self.user_repo.update_user(user) # 토큰 생성 user_data = user.to_dict() access_token = jwt_service.create_access_token(user_data) refresh_token = jwt_service.create_refresh_token(user.user_id) # 세션 생성 expires_at = datetime.utcnow() + timedelta(days=7) session = self.user_repo.create_session( user_id=user.user_id, refresh_token=refresh_token, expires_at=expires_at, ip_address=ip_address, user_agent=user_agent ) # 로그인 성공 기록 self.user_repo.record_login_log( user_id=user.user_id, ip_address=ip_address, user_agent=user_agent, status='success' ) # 리디렉션 URL 결정 redirect_url = self._get_redirect_url(user.role) logger.info(f"Login successful for user: {username} (role: {user.role})") return { 'success': True, 'access_token': access_token, 'refresh_token': refresh_token, 'token_type': 'bearer', 'expires_in': 24 * 3600, # 24시간 (초) 'user': user_data, 'redirect_url': redirect_url, 'permissions': self.user_repo.get_user_permissions(user.role) } except TKMPException: raise except Exception as e: logger.error(f"Login service error for {username}: {str(e)}") raise TKMPException( message="로그인 처리 중 서버 오류가 발생했습니다", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ) async def refresh_token(self, refresh_token: str, request: Request) -> Dict[str, Any]: """ 토큰 갱신 Args: refresh_token: 리프레시 토큰 request: FastAPI Request 객체 Returns: Dict[str, Any]: 새로운 토큰 정보 """ try: # 리프레시 토큰 검증 payload = jwt_service.verify_refresh_token(refresh_token) user_id = payload['user_id'] # 세션 확인 session = self.user_repo.find_session_by_token(refresh_token) if not session or session.is_expired() or not session.is_active: logger.warning(f"Invalid or expired refresh token for user_id: {user_id}") raise TKMPException( status_code=status.HTTP_401_UNAUTHORIZED, message="유효하지 않거나 만료된 리프레시 토큰입니다" ) # 사용자 조회 user = self.user_repo.find_by_id(user_id) if not user or not user.is_active: logger.warning(f"User not found or inactive for token refresh: {user_id}") raise TKMPException( status_code=status.HTTP_401_UNAUTHORIZED, message="사용자를 찾을 수 없거나 비활성화된 계정입니다" ) # 새 액세스 토큰 생성 user_data = user.to_dict() new_access_token = jwt_service.create_access_token(user_data) logger.info(f"Token refreshed for user: {user.username}") return { 'success': True, 'access_token': new_access_token, 'token_type': 'bearer', 'expires_in': 24 * 3600, 'user': user_data } except TKMPException: raise except Exception as e: logger.error(f"Token refresh error: {str(e)}") raise TKMPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message="토큰 갱신 중 서버 오류가 발생했습니다" ) async def logout(self, refresh_token: str) -> Dict[str, Any]: """ 로그아웃 Args: refresh_token: 리프레시 토큰 Returns: Dict[str, Any]: 로그아웃 결과 """ try: # 세션 찾기 및 비활성화 session = self.user_repo.find_session_by_token(refresh_token) if session: session.deactivate() self.user_repo.update_user(session.user) logger.info(f"User logged out: user_id {session.user_id}") return { 'success': True, 'message': '로그아웃되었습니다' } except Exception as e: logger.error(f"Logout error: {str(e)}") raise TKMPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message="로그아웃 처리 중 오류가 발생했습니다" ) async def register(self, user_data: Dict[str, Any]) -> Dict[str, Any]: """ 사용자 등록 Args: user_data: 사용자 정보 Returns: Dict[str, Any]: 등록 결과 """ try: # 필수 필드 검증 required_fields = ['username', 'password', 'name'] for field in required_fields: if not user_data.get(field): raise TKMPException( status_code=status.HTTP_400_BAD_REQUEST, message=f"{field}는 필수 입력 항목입니다" ) # 중복 사용자명 확인 existing_user = self.user_repo.find_by_username(user_data['username']) if existing_user: raise TKMPException( status_code=status.HTTP_409_CONFLICT, message="이미 존재하는 사용자명입니다" ) # 이메일 중복 확인 (이메일이 제공된 경우) if user_data.get('email'): existing_email = self.user_repo.find_by_email(user_data['email']) if existing_email: raise TKMPException( status_code=status.HTTP_409_CONFLICT, message="이미 사용 중인 이메일입니다" ) # 역할 매핑 role_map = { 'admin': 'admin', 'system': 'system', 'group_leader': 'leader', 'support_team': 'support', 'worker': 'user' } access_level = user_data.get('access_level', 'worker') role = role_map.get(access_level, 'user') # 사용자 생성 new_user_data = { 'username': user_data['username'], 'name': user_data['name'], 'email': user_data.get('email'), 'role': role, 'access_level': access_level, 'department': user_data.get('department'), 'position': user_data.get('position'), 'phone': user_data.get('phone') } user = User(**new_user_data) user.set_password(user_data['password']) self.db.add(user) self.db.commit() self.db.refresh(user) logger.info(f"User registered successfully: {user.username}") return { 'success': True, 'message': '사용자 등록이 완료되었습니다', 'user_id': user.user_id, 'username': user.username } except TKMPException: raise except Exception as e: self.db.rollback() logger.error(f"User registration error: {str(e)}") raise TKMPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, message="사용자 등록 중 서버 오류가 발생했습니다" ) def _get_client_ip(self, request: Request) -> str: """클라이언트 IP 주소 추출""" # X-Forwarded-For 헤더 확인 (프록시 환경) forwarded_for = request.headers.get('x-forwarded-for') if forwarded_for: return forwarded_for.split(',')[0].strip() # X-Real-IP 헤더 확인 real_ip = request.headers.get('x-real-ip') if real_ip: return real_ip # 직접 연결된 클라이언트 IP return request.client.host if request.client else 'unknown' def _get_redirect_url(self, role: str) -> str: """역할에 따른 리디렉션 URL 결정""" redirect_urls = { 'system': '/admin/system', 'admin': '/admin/dashboard', 'leader': '/dashboard/leader', 'support': '/dashboard/support', 'user': '/dashboard' } return redirect_urls.get(role, '/dashboard') async def _record_login_failure(self, user_id: Optional[int], ip_address: str, user_agent: str, failure_reason: str): """로그인 실패 기록""" try: if user_id: self.user_repo.record_login_log( user_id=user_id, ip_address=ip_address, user_agent=user_agent, status='failed', failure_reason=failure_reason ) except Exception as e: logger.error(f"Failed to record login failure: {str(e)}") def get_auth_service(db: Session) -> AuthService: """인증 서비스 팩토리 함수""" return AuthService(db)