- 모든 카테고리 구매신청 기능 완성 (PIPE, FITTING, VALVE, FLANGE, GASKET, BOLT, SUPPORT, SPECIAL, UNKNOWN) - 구매신청 완료 항목: 회색 배경, 체크박스 비활성화, '구매신청완료' 배지 표시 - 전체 선택/구매신청 시 이미 구매신청된 항목 자동 제외 - 구매신청 quantity 타입 에러 수정 (문자열 -> 정수 변환) SUPPORT 카테고리 (구 U-BOLT): - U-BOLT -> SUPPORT로 카테고리명 변경 - 클램프, 유볼트, 우레탄블럭슈 분류 개선 - 테이블 헤더: 선택-종류-타입-크기-디스크립션-추가요구-사용자요구-수량 - 크기 정보 main_nom 필드에서 가져오기 (배관 인치) - 엑셀 내보내기 형식 조정 SPECIAL 카테고리: - SPECIAL 키워드 자재 자동 분류 (SPECIFICATION 제외) - 파일 업로드 시 SPECIAL 카테고리 처리 로직 추가 - 도면번호 필드 추가 (drawing_name, line_no) - 타입 필드: 크기/스케줄/재질 제외한 핵심 정보 표시 - 엑셀 DWG_NAME, LINE_NUM 컬럼 파싱 및 저장 FITTING 카테고리: - 테이블 컬럼 너비 조정 (선택 2%, 종류 8.5%, 수량 12%) 구매신청 관리: - 엑셀 재다운로드 형식 개선 (BOM 페이지와 동일한 형식) - 그룹화된 자재 정보 포함하여 저장 및 다운로드
397 lines
16 KiB
Python
397 lines
16 KiB
Python
"""
|
|
인증 서비스
|
|
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="아이디 또는 비밀번호가 올바르지 않습니다"
|
|
)
|
|
|
|
# 계정 상태 확인 (새로운 status 체계)
|
|
if hasattr(user, 'status'):
|
|
if user.status == 'pending':
|
|
await self._record_login_failure(user.user_id, ip_address, user_agent, 'pending_account')
|
|
logger.warning(f"Login failed - pending account: {username}")
|
|
raise TKMPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
message="계정 승인 대기 중입니다. 관리자 승인 후 이용 가능합니다"
|
|
)
|
|
elif user.status == 'suspended':
|
|
await self._record_login_failure(user.user_id, ip_address, user_agent, 'suspended_account')
|
|
logger.warning(f"Login failed - suspended account: {username}")
|
|
raise TKMPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
message="계정이 정지되었습니다. 관리자에게 문의하세요"
|
|
)
|
|
elif user.status == 'deleted':
|
|
await self._record_login_failure(user.user_id, ip_address, user_agent, 'deleted_account')
|
|
logger.warning(f"Login failed - deleted account: {username}")
|
|
raise TKMPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
message="삭제된 계정입니다"
|
|
)
|
|
else:
|
|
# 하위 호환성: status 필드가 없으면 is_active 사용
|
|
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)
|