feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled

- H/F/I/O SS304/GRAPHITE/CS/CS 패턴에서 4개 구성요소 모두 표시
- 기존 SS304 + GRAPHITE → SS304/GRAPHITE/CS/CS로 완전한 구성 표시
- 외부링/필러/내부링/추가구성 모든 정보 포함
- 구매수량 계산 모달에서 정확한 재질 정보 확인 가능
This commit is contained in:
Hyungi Ahn
2025-08-30 14:23:01 +09:00
parent 78d90c7a8f
commit 4f8e395f87
84 changed files with 16297 additions and 2161 deletions

View File

@@ -0,0 +1,63 @@
"""
인증 모듈 초기화
TK-MP-Project 인증 시스템의 모든 컴포넌트를 노출
"""
from .jwt_service import jwt_service, JWTService
from .auth_service import AuthService, get_auth_service
from .auth_controller import router as auth_router
from .middleware import (
auth_middleware,
get_current_user,
get_current_active_user,
require_admin,
require_leader_or_admin,
require_roles,
require_permissions,
get_user_from_token,
check_user_permission,
get_user_permissions_by_role,
get_current_user_optional
)
from .models import (
User,
LoginLog,
UserSession,
Permission,
RolePermission,
UserRepository
)
__all__ = [
# JWT 서비스
'jwt_service',
'JWTService',
# 인증 서비스
'AuthService',
'get_auth_service',
# 라우터
'auth_router',
# 미들웨어 및 의존성
'auth_middleware',
'get_current_user',
'get_current_active_user',
'require_admin',
'require_leader_or_admin',
'require_roles',
'require_permissions',
'get_user_from_token',
'check_user_permission',
'get_user_permissions_by_role',
'get_current_user_optional',
# 모델
'User',
'LoginLog',
'UserSession',
'Permission',
'RolePermission',
'UserRepository'
]

View File

@@ -0,0 +1,393 @@
"""
인증 컨트롤러
TK-FB-Project의 authController.js를 참고하여 FastAPI용으로 구현
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional, List, Dict, Any
from ..database import get_db
from .auth_service import get_auth_service
from .jwt_service import jwt_service
from .models import UserRepository
from ..utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter()
security = HTTPBearer()
# Pydantic 모델들
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
password: str
name: str
email: Optional[EmailStr] = None
access_level: str = 'worker'
department: Optional[str] = None
position: Optional[str] = None
phone: Optional[str] = None
class RefreshTokenRequest(BaseModel):
refresh_token: str
class LoginResponse(BaseModel):
success: bool
access_token: str
refresh_token: str
token_type: str
expires_in: int
user: Dict[str, Any]
redirect_url: str
permissions: List[str]
class RefreshTokenResponse(BaseModel):
success: bool
access_token: str
token_type: str
expires_in: int
user: Dict[str, Any]
class RegisterResponse(BaseModel):
success: bool
message: str
user_id: int
username: str
class LogoutResponse(BaseModel):
success: bool
message: str
class UserInfoResponse(BaseModel):
success: bool
user: Dict[str, Any]
permissions: List[str]
@router.post("/login", response_model=LoginResponse)
async def login(
login_data: LoginRequest,
request: Request,
db: Session = Depends(get_db)
):
"""
사용자 로그인
Args:
login_data: 로그인 정보 (사용자명, 비밀번호)
request: FastAPI Request 객체
db: 데이터베이스 세션
Returns:
LoginResponse: 로그인 결과 (토큰, 사용자 정보 등)
"""
try:
auth_service = get_auth_service(db)
result = await auth_service.login(
username=login_data.username,
password=login_data.password,
request=request
)
return LoginResponse(**result)
except Exception as e:
logger.error(f"Login endpoint error: {str(e)}")
raise
@router.post("/register", response_model=RegisterResponse)
async def register(
register_data: RegisterRequest,
db: Session = Depends(get_db)
):
"""
사용자 등록
Args:
register_data: 등록 정보
db: 데이터베이스 세션
Returns:
RegisterResponse: 등록 결과
"""
try:
auth_service = get_auth_service(db)
result = await auth_service.register(register_data.dict())
return RegisterResponse(**result)
except Exception as e:
logger.error(f"Register endpoint error: {str(e)}")
raise
@router.post("/refresh", response_model=RefreshTokenResponse)
async def refresh_token(
refresh_data: RefreshTokenRequest,
request: Request,
db: Session = Depends(get_db)
):
"""
토큰 갱신
Args:
refresh_data: 리프레시 토큰 정보
request: FastAPI Request 객체
db: 데이터베이스 세션
Returns:
RefreshTokenResponse: 새로운 토큰 정보
"""
try:
auth_service = get_auth_service(db)
result = await auth_service.refresh_token(
refresh_token=refresh_data.refresh_token,
request=request
)
return RefreshTokenResponse(**result)
except Exception as e:
logger.error(f"Refresh token endpoint error: {str(e)}")
raise
@router.post("/logout", response_model=LogoutResponse)
async def logout(
refresh_data: RefreshTokenRequest,
db: Session = Depends(get_db)
):
"""
로그아웃
Args:
refresh_data: 리프레시 토큰 정보
db: 데이터베이스 세션
Returns:
LogoutResponse: 로그아웃 결과
"""
try:
auth_service = get_auth_service(db)
result = await auth_service.logout(refresh_data.refresh_token)
return LogoutResponse(**result)
except Exception as e:
logger.error(f"Logout endpoint error: {str(e)}")
raise
@router.get("/me", response_model=UserInfoResponse)
async def get_current_user_info(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
현재 사용자 정보 조회
Args:
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
UserInfoResponse: 사용자 정보 및 권한
"""
try:
# 토큰 검증
payload = jwt_service.verify_access_token(credentials.credentials)
user_id = payload['user_id']
# 사용자 정보 조회
user_repo = UserRepository(db)
user = user_repo.find_by_id(user_id)
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="사용자를 찾을 수 없거나 비활성화된 계정입니다"
)
# 권한 정보 조회
permissions = user_repo.get_user_permissions(user.role)
return UserInfoResponse(
success=True,
user=user.to_dict(),
permissions=permissions
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Get current user info error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="사용자 정보 조회 중 오류가 발생했습니다"
)
@router.get("/verify")
async def verify_token(
credentials: HTTPAuthorizationCredentials = Depends(security)
):
"""
토큰 검증
Args:
credentials: JWT 토큰
Returns:
Dict: 토큰 검증 결과
"""
try:
payload = jwt_service.verify_access_token(credentials.credentials)
return {
'success': True,
'valid': True,
'user_id': payload['user_id'],
'username': payload['username'],
'role': payload['role'],
'expires_at': payload.get('exp')
}
except HTTPException as e:
return {
'success': False,
'valid': False,
'error': e.detail
}
except Exception as e:
logger.error(f"Token verification error: {str(e)}")
return {
'success': False,
'valid': False,
'error': '토큰 검증 중 오류가 발생했습니다'
}
# 관리자 전용 엔드포인트들
@router.get("/users")
async def get_all_users(
skip: int = 0,
limit: int = 100,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
모든 사용자 목록 조회 (관리자 전용)
Args:
skip: 건너뛸 레코드 수
limit: 조회할 레코드 수
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
Dict: 사용자 목록
"""
try:
# 토큰 검증 및 권한 확인
payload = jwt_service.verify_access_token(credentials.credentials)
if payload['role'] not in ['admin', 'system']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자 권한이 필요합니다"
)
# 사용자 목록 조회
user_repo = UserRepository(db)
users = user_repo.get_all_users(skip=skip, limit=limit)
return {
'success': True,
'users': [user.to_dict() for user in users],
'total_count': len(users)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Get all users error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="사용자 목록 조회 중 오류가 발생했습니다"
)
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
사용자 삭제 (관리자 전용)
Args:
user_id: 삭제할 사용자 ID
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
Dict: 삭제 결과
"""
try:
# 토큰 검증 및 권한 확인
payload = jwt_service.verify_access_token(credentials.credentials)
if payload['role'] not in ['admin', 'system']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자 권한이 필요합니다"
)
# 자기 자신 삭제 방지
if payload['user_id'] == user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="자기 자신은 삭제할 수 없습니다"
)
# 사용자 조회 및 삭제
user_repo = UserRepository(db)
user = user_repo.find_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="해당 사용자를 찾을 수 없습니다"
)
user_repo.delete_user(user)
logger.info(f"User deleted by admin: {user.username} (deleted by: {payload['username']})")
return {
'success': True,
'message': '사용자가 삭제되었습니다',
'deleted_user_id': user_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Delete user error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="사용자 삭제 중 오류가 발생했습니다"
)

View File

@@ -0,0 +1,372 @@
"""
인증 서비스
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)

View File

@@ -0,0 +1,251 @@
"""
JWT 토큰 관리 서비스
TK-FB-Project의 JWT 구현을 참고하여 FastAPI용으로 구현
"""
import jwt
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from fastapi import HTTPException, status
import os
from ..config import get_settings
from ..utils.logger import get_logger
settings = get_settings()
logger = get_logger(__name__)
# JWT 설정
JWT_SECRET = os.getenv('JWT_SECRET', 'tkmp-secret-key-2025')
JWT_REFRESH_SECRET = os.getenv('JWT_REFRESH_SECRET', 'tkmp-refresh-secret-2025')
JWT_ALGORITHM = 'HS256'
ACCESS_TOKEN_EXPIRE_HOURS = int(os.getenv('JWT_EXPIRES_IN_HOURS', '24'))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv('JWT_REFRESH_EXPIRES_IN_DAYS', '7'))
class JWTService:
"""JWT 토큰 관리 서비스"""
@staticmethod
def create_access_token(user_data: Dict[str, Any]) -> str:
"""
Access Token 생성
Args:
user_data: 사용자 정보 딕셔너리
Returns:
str: JWT Access Token
"""
try:
# 토큰 만료 시간 설정
expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
# 토큰 페이로드 구성
payload = {
'user_id': user_data['user_id'],
'username': user_data['username'],
'name': user_data['name'],
'role': user_data['role'],
'access_level': user_data['access_level'],
'exp': expire,
'iat': datetime.utcnow(),
'type': 'access'
}
# JWT 토큰 생성
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
logger.debug(f"Access token created for user: {user_data['username']}")
return token
except Exception as e:
logger.error(f"Access token creation failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="토큰 생성에 실패했습니다"
)
@staticmethod
def create_refresh_token(user_id: int) -> str:
"""
Refresh Token 생성
Args:
user_id: 사용자 ID
Returns:
str: JWT Refresh Token
"""
try:
# 토큰 만료 시간 설정
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
# 토큰 페이로드 구성
payload = {
'user_id': user_id,
'exp': expire,
'iat': datetime.utcnow(),
'type': 'refresh'
}
# JWT 토큰 생성
token = jwt.encode(payload, JWT_REFRESH_SECRET, algorithm=JWT_ALGORITHM)
logger.debug(f"Refresh token created for user_id: {user_id}")
return token
except Exception as e:
logger.error(f"Refresh token creation failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="리프레시 토큰 생성에 실패했습니다"
)
@staticmethod
def verify_access_token(token: str) -> Dict[str, Any]:
"""
Access Token 검증
Args:
token: JWT Access Token
Returns:
Dict[str, Any]: 토큰 페이로드
Raises:
HTTPException: 토큰 검증 실패 시
"""
try:
# JWT 토큰 디코딩
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
# 토큰 타입 확인
if payload.get('type') != 'access':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="잘못된 토큰 타입입니다"
)
# 필수 필드 확인
required_fields = ['user_id', 'username', 'role']
for field in required_fields:
if field not in payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"토큰에 {field} 정보가 없습니다"
)
logger.debug(f"Access token verified for user: {payload['username']}")
return payload
except jwt.ExpiredSignatureError:
logger.warning("Access token expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="토큰이 만료되었습니다"
)
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid access token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 토큰입니다"
)
except Exception as e:
logger.error(f"Access token verification failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="토큰 검증에 실패했습니다"
)
@staticmethod
def verify_refresh_token(token: str) -> Dict[str, Any]:
"""
Refresh Token 검증
Args:
token: JWT Refresh Token
Returns:
Dict[str, Any]: 토큰 페이로드
Raises:
HTTPException: 토큰 검증 실패 시
"""
try:
# JWT 토큰 디코딩
payload = jwt.decode(token, JWT_REFRESH_SECRET, algorithms=[JWT_ALGORITHM])
# 토큰 타입 확인
if payload.get('type') != 'refresh':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="잘못된 리프레시 토큰 타입입니다"
)
# 필수 필드 확인
if 'user_id' not in payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="토큰에 사용자 정보가 없습니다"
)
logger.debug(f"Refresh token verified for user_id: {payload['user_id']}")
return payload
except jwt.ExpiredSignatureError:
logger.warning("Refresh token expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="리프레시 토큰이 만료되었습니다"
)
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid refresh token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 리프레시 토큰입니다"
)
except Exception as e:
logger.error(f"Refresh token verification failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="리프레시 토큰 검증에 실패했습니다"
)
@staticmethod
def get_token_expiry_info(token: str, token_type: str = 'access') -> Dict[str, Any]:
"""
토큰 만료 정보 조회
Args:
token: JWT Token
token_type: 토큰 타입 ('access' 또는 'refresh')
Returns:
Dict[str, Any]: 토큰 만료 정보
"""
try:
secret = JWT_SECRET if token_type == 'access' else JWT_REFRESH_SECRET
payload = jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
exp_timestamp = payload.get('exp')
iat_timestamp = payload.get('iat')
if exp_timestamp:
exp_datetime = datetime.fromtimestamp(exp_timestamp)
remaining_time = exp_datetime - datetime.utcnow()
return {
'expires_at': exp_datetime,
'issued_at': datetime.fromtimestamp(iat_timestamp) if iat_timestamp else None,
'remaining_seconds': int(remaining_time.total_seconds()),
'is_expired': remaining_time.total_seconds() <= 0
}
return {'error': '토큰에 만료 시간 정보가 없습니다'}
except Exception as e:
logger.error(f"Token expiry info retrieval failed: {str(e)}")
return {'error': str(e)}
# JWT 서비스 인스턴스
jwt_service = JWTService()

View File

@@ -0,0 +1,305 @@
"""
인증 및 권한 미들웨어
JWT 토큰 기반 인증과 역할 기반 접근 제어(RBAC) 구현
"""
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import List, Optional, Callable, Any
from functools import wraps
import inspect
from ..database import get_db
from .jwt_service import jwt_service
from .models import UserRepository
from ..utils.logger import get_logger
logger = get_logger(__name__)
security = HTTPBearer()
class AuthMiddleware:
"""인증 미들웨어 클래스"""
def __init__(self):
self.security = HTTPBearer()
async def get_current_user(
self,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> dict:
"""
현재 사용자 정보 조회
Args:
credentials: JWT 토큰
db: 데이터베이스 세션
Returns:
dict: 사용자 정보
Raises:
HTTPException: 인증 실패 시
"""
try:
# 토큰 검증
payload = jwt_service.verify_access_token(credentials.credentials)
user_id = payload['user_id']
# 사용자 정보 조회
user_repo = UserRepository(db)
user = user_repo.find_by_id(user_id)
if not user:
logger.warning(f"User not found for token: user_id {user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="사용자를 찾을 수 없습니다"
)
if not user.is_active:
logger.warning(f"Inactive user attempted access: {user.username}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="비활성화된 계정입니다"
)
if user.is_locked():
logger.warning(f"Locked user attempted access: {user.username}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="잠긴 계정입니다"
)
# 사용자 정보와 토큰 페이로드 결합
user_info = user.to_dict()
user_info.update({
'token_user_id': payload['user_id'],
'token_username': payload['username'],
'token_role': payload['role']
})
return user_info
except HTTPException:
raise
except Exception as e:
logger.error(f"Get current user error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="인증 처리 중 오류가 발생했습니다"
)
async def get_current_active_user(
self,
current_user: dict = Depends(get_current_user)
) -> dict:
"""
현재 활성 사용자 정보 조회 (별칭)
Args:
current_user: 현재 사용자 정보
Returns:
dict: 사용자 정보
"""
return current_user
def require_roles(self, allowed_roles: List[str]):
"""
특정 역할을 요구하는 의존성 함수 생성
Args:
allowed_roles: 허용된 역할 목록
Returns:
Callable: 의존성 함수
"""
async def role_checker(
current_user: dict = Depends(self.get_current_user)
) -> dict:
user_role = current_user.get('role')
if user_role not in allowed_roles:
logger.warning(
f"Access denied for user {current_user.get('username')} "
f"with role {user_role}. Required roles: {allowed_roles}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(allowed_roles)}"
)
return current_user
return role_checker
def require_permissions(self, required_permissions: List[str]):
"""
특정 권한을 요구하는 의존성 함수 생성
Args:
required_permissions: 필요한 권한 목록
Returns:
Callable: 의존성 함수
"""
async def permission_checker(
current_user: dict = Depends(self.get_current_user),
db: Session = Depends(get_db)
) -> dict:
user_role = current_user.get('role')
# 사용자 권한 조회
user_repo = UserRepository(db)
user_permissions = user_repo.get_user_permissions(user_role)
# 필요한 권한 확인
missing_permissions = []
for permission in required_permissions:
if permission not in user_permissions:
missing_permissions.append(permission)
if missing_permissions:
logger.warning(
f"Permission denied for user {current_user.get('username')} "
f"with role {user_role}. Missing permissions: {missing_permissions}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"이 기능을 사용하려면 다음 권한이 필요합니다: {', '.join(missing_permissions)}"
)
# 사용자 정보에 권한 정보 추가
current_user['permissions'] = user_permissions
return current_user
return permission_checker
def require_admin(self):
"""관리자 권한을 요구하는 의존성 함수"""
return self.require_roles(['admin', 'system'])
def require_leader_or_admin(self):
"""팀장 이상 권한을 요구하는 의존성 함수"""
return self.require_roles(['admin', 'system', 'leader'])
# 전역 인증 미들웨어 인스턴스
auth_middleware = AuthMiddleware()
# 편의를 위한 의존성 함수들
get_current_user = auth_middleware.get_current_user
get_current_active_user = auth_middleware.get_current_active_user
require_admin = auth_middleware.require_admin
require_leader_or_admin = auth_middleware.require_leader_or_admin
def require_roles(allowed_roles: List[str]):
"""역할 기반 접근 제어 데코레이터"""
return auth_middleware.require_roles(allowed_roles)
def require_permissions(required_permissions: List[str]):
"""권한 기반 접근 제어 데코레이터"""
return auth_middleware.require_permissions(required_permissions)
# 추가 유틸리티 함수들
async def get_user_from_token(token: str, db: Session) -> Optional[dict]:
"""
토큰에서 사용자 정보 추출 (미들웨어 없이 직접 사용)
Args:
token: JWT 토큰
db: 데이터베이스 세션
Returns:
Optional[dict]: 사용자 정보 또는 None
"""
try:
payload = jwt_service.verify_access_token(token)
user_id = payload['user_id']
user_repo = UserRepository(db)
user = user_repo.find_by_id(user_id)
if user and user.is_active and not user.is_locked():
return user.to_dict()
return None
except Exception as e:
logger.error(f"Get user from token error: {str(e)}")
return None
def check_user_permission(user_role: str, required_permission: str, db: Session) -> bool:
"""
사용자 권한 확인
Args:
user_role: 사용자 역할
required_permission: 필요한 권한
db: 데이터베이스 세션
Returns:
bool: 권한 보유 여부
"""
try:
user_repo = UserRepository(db)
user_permissions = user_repo.get_user_permissions(user_role)
return required_permission in user_permissions
except Exception as e:
logger.error(f"Check user permission error: {str(e)}")
return False
def get_user_permissions_by_role(role: str, db: Session) -> List[str]:
"""
역할별 권한 목록 조회
Args:
role: 사용자 역할
db: 데이터베이스 세션
Returns:
List[str]: 권한 목록
"""
try:
user_repo = UserRepository(db)
return user_repo.get_user_permissions(role)
except Exception as e:
logger.error(f"Get user permissions by role error: {str(e)}")
return []
# 선택적 인증 (토큰이 있으면 검증, 없으면 None 반환)
async def get_current_user_optional(
request: Request,
db: Session = Depends(get_db)
) -> Optional[dict]:
"""
선택적 사용자 인증 (토큰이 있으면 검증, 없으면 None)
Args:
request: FastAPI Request 객체
db: 데이터베이스 세션
Returns:
Optional[dict]: 사용자 정보 또는 None
"""
try:
# Authorization 헤더 확인
authorization = request.headers.get('authorization')
if not authorization or not authorization.startswith('Bearer '):
return None
token = authorization.split(' ')[1]
return await get_user_from_token(token, db)
except Exception as e:
logger.debug(f"Optional auth failed: {str(e)}")
return None

354
backend/app/auth/models.py Normal file
View File

@@ -0,0 +1,354 @@
"""
인증 시스템 모델
TK-FB-Project의 사용자 모델을 참고하여 SQLAlchemy 기반으로 구현
"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import text
import bcrypt
from ..database import Base
from ..utils.logger import get_logger
logger = get_logger(__name__)
class User(Base):
"""사용자 모델"""
__tablename__ = "users"
user_id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
password = Column(String(255), nullable=False)
name = Column(String(100), nullable=False)
email = Column(String(100), index=True)
# 권한 관리
role = Column(String(20), default='user', nullable=False)
access_level = Column(String(20), default='worker', nullable=False)
# 계정 상태 관리
is_active = Column(Boolean, default=True, nullable=False)
failed_login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True)
# 추가 정보
department = Column(String(50))
position = Column(String(50))
phone = Column(String(20))
# 타임스탬프
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_login_at = Column(DateTime, nullable=True)
# 관계 설정
login_logs = relationship("LoginLog", back_populates="user", cascade="all, delete-orphan")
sessions = relationship("UserSession", back_populates="user", cascade="all, delete-orphan")
def __repr__(self):
return f"<User(username='{self.username}', name='{self.name}', role='{self.role}')>"
def to_dict(self) -> Dict[str, Any]:
"""사용자 정보를 딕셔너리로 변환 (비밀번호 제외)"""
return {
'user_id': self.user_id,
'username': self.username,
'name': self.name,
'email': self.email,
'role': self.role,
'access_level': self.access_level,
'is_active': self.is_active,
'department': self.department,
'position': self.position,
'phone': self.phone,
'created_at': self.created_at,
'last_login_at': self.last_login_at
}
def check_password(self, password: str) -> bool:
"""비밀번호 확인"""
try:
return bcrypt.checkpw(password.encode('utf-8'), self.password.encode('utf-8'))
except Exception as e:
logger.error(f"Password check failed for user {self.username}: {str(e)}")
return False
def set_password(self, password: str):
"""비밀번호 설정 (해싱)"""
try:
salt = bcrypt.gensalt()
self.password = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
except Exception as e:
logger.error(f"Password hashing failed for user {self.username}: {str(e)}")
raise
def is_locked(self) -> bool:
"""계정 잠금 상태 확인"""
if self.locked_until is None:
return False
return datetime.utcnow() < self.locked_until
def lock_account(self, minutes: int = 15):
"""계정 잠금"""
self.locked_until = datetime.utcnow() + timedelta(minutes=minutes)
logger.warning(f"User account locked: {self.username} for {minutes} minutes")
def unlock_account(self):
"""계정 잠금 해제"""
self.locked_until = None
self.failed_login_attempts = 0
logger.info(f"User account unlocked: {self.username}")
def increment_failed_attempts(self):
"""로그인 실패 횟수 증가"""
self.failed_login_attempts += 1
if self.failed_login_attempts >= 5:
self.lock_account()
def reset_failed_attempts(self):
"""로그인 실패 횟수 초기화"""
self.failed_login_attempts = 0
self.locked_until = None
def update_last_login(self):
"""마지막 로그인 시간 업데이트"""
self.last_login_at = datetime.utcnow()
class LoginLog(Base):
"""로그인 이력 모델"""
__tablename__ = "login_logs"
log_id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
login_time = Column(DateTime, default=func.now())
ip_address = Column(String(45))
user_agent = Column(Text)
login_status = Column(String(20), nullable=False) # 'success' or 'failed'
failure_reason = Column(String(100))
session_duration = Column(Integer) # 세션 지속 시간 (초)
created_at = Column(DateTime, default=func.now())
# 관계 설정
user = relationship("User", back_populates="login_logs")
def __repr__(self):
return f"<LoginLog(user_id={self.user_id}, status='{self.login_status}', time='{self.login_time}')>"
class UserSession(Base):
"""사용자 세션 모델 (Refresh Token 관리)"""
__tablename__ = "user_sessions"
session_id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
refresh_token = Column(String(500), nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
ip_address = Column(String(45))
user_agent = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# 관계 설정
user = relationship("User", back_populates="sessions")
def __repr__(self):
return f"<UserSession(user_id={self.user_id}, expires_at='{self.expires_at}')>"
def is_expired(self) -> bool:
"""세션 만료 여부 확인"""
return datetime.utcnow() > self.expires_at
def deactivate(self):
"""세션 비활성화"""
self.is_active = False
class Permission(Base):
"""권한 모델"""
__tablename__ = "permissions"
permission_id = Column(Integer, primary_key=True, index=True)
permission_name = Column(String(50), unique=True, nullable=False)
description = Column(Text)
module = Column(String(30), index=True) # 모듈별 권한 관리
created_at = Column(DateTime, default=func.now())
def __repr__(self):
return f"<Permission(name='{self.permission_name}', module='{self.module}')>"
class RolePermission(Base):
"""역할-권한 매핑 모델"""
__tablename__ = "role_permissions"
role_permission_id = Column(Integer, primary_key=True, index=True)
role = Column(String(20), nullable=False, index=True)
permission_id = Column(Integer, ForeignKey("permissions.permission_id", ondelete="CASCADE"))
created_at = Column(DateTime, default=func.now())
# 관계 설정
permission = relationship("Permission")
def __repr__(self):
return f"<RolePermission(role='{self.role}', permission_id={self.permission_id})>"
class UserRepository:
"""사용자 데이터 접근 계층"""
def __init__(self, db: Session):
self.db = db
def find_by_username(self, username: str) -> Optional[User]:
"""사용자명으로 사용자 조회"""
try:
return self.db.query(User).filter(User.username == username).first()
except Exception as e:
logger.error(f"Failed to find user by username {username}: {str(e)}")
return None
def find_by_id(self, user_id: int) -> Optional[User]:
"""사용자 ID로 사용자 조회"""
try:
return self.db.query(User).filter(User.user_id == user_id).first()
except Exception as e:
logger.error(f"Failed to find user by id {user_id}: {str(e)}")
return None
def find_by_email(self, email: str) -> Optional[User]:
"""이메일로 사용자 조회"""
try:
return self.db.query(User).filter(User.email == email).first()
except Exception as e:
logger.error(f"Failed to find user by email {email}: {str(e)}")
return None
def create_user(self, user_data: Dict[str, Any]) -> User:
"""새 사용자 생성"""
try:
user = User(**user_data)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
logger.info(f"User created: {user.username}")
return user
except Exception as e:
self.db.rollback()
logger.error(f"Failed to create user: {str(e)}")
raise
def update_user(self, user: User) -> User:
"""사용자 정보 업데이트"""
try:
self.db.commit()
self.db.refresh(user)
logger.info(f"User updated: {user.username}")
return user
except Exception as e:
self.db.rollback()
logger.error(f"Failed to update user {user.username}: {str(e)}")
raise
def delete_user(self, user: User):
"""사용자 삭제"""
try:
self.db.delete(user)
self.db.commit()
logger.info(f"User deleted: {user.username}")
except Exception as e:
self.db.rollback()
logger.error(f"Failed to delete user {user.username}: {str(e)}")
raise
def get_all_users(self, skip: int = 0, limit: int = 100) -> List[User]:
"""모든 사용자 조회"""
try:
return self.db.query(User).offset(skip).limit(limit).all()
except Exception as e:
logger.error(f"Failed to get all users: {str(e)}")
return []
def get_user_permissions(self, role: str) -> List[str]:
"""사용자 역할에 따른 권한 목록 조회"""
try:
query = text("""
SELECT p.permission_name
FROM permissions p
JOIN role_permissions rp ON p.permission_id = rp.permission_id
WHERE rp.role = :role
""")
result = self.db.execute(query, {"role": role})
return [row[0] for row in result.fetchall()]
except Exception as e:
logger.error(f"Failed to get permissions for role {role}: {str(e)}")
return []
def record_login_log(self, user_id: int, ip_address: str, user_agent: str,
status: str, failure_reason: str = None):
"""로그인 이력 기록"""
try:
log = LoginLog(
user_id=user_id,
ip_address=ip_address,
user_agent=user_agent,
login_status=status,
failure_reason=failure_reason
)
self.db.add(log)
self.db.commit()
logger.debug(f"Login log recorded for user_id {user_id}: {status}")
except Exception as e:
self.db.rollback()
logger.error(f"Failed to record login log: {str(e)}")
def create_session(self, user_id: int, refresh_token: str, expires_at: datetime,
ip_address: str, user_agent: str) -> UserSession:
"""사용자 세션 생성"""
try:
session = UserSession(
user_id=user_id,
refresh_token=refresh_token,
expires_at=expires_at,
ip_address=ip_address,
user_agent=user_agent
)
self.db.add(session)
self.db.commit()
self.db.refresh(session)
logger.debug(f"Session created for user_id {user_id}")
return session
except Exception as e:
self.db.rollback()
logger.error(f"Failed to create session: {str(e)}")
raise
def find_session_by_token(self, refresh_token: str) -> Optional[UserSession]:
"""리프레시 토큰으로 세션 조회"""
try:
return self.db.query(UserSession).filter(
UserSession.refresh_token == refresh_token,
UserSession.is_active == True
).first()
except Exception as e:
logger.error(f"Failed to find session by token: {str(e)}")
return None
def deactivate_user_sessions(self, user_id: int):
"""사용자의 모든 세션 비활성화"""
try:
self.db.query(UserSession).filter(
UserSession.user_id == user_id
).update({"is_active": False})
self.db.commit()
logger.info(f"All sessions deactivated for user_id {user_id}")
except Exception as e:
self.db.rollback()
logger.error(f"Failed to deactivate sessions for user_id {user_id}: {str(e)}")
raise