feat: SWG 가스켓 전체 구성 정보 표시 개선
Some checks failed
SonarQube Analysis / SonarQube Scan (push) Has been cancelled
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:
63
backend/app/auth/__init__.py
Normal file
63
backend/app/auth/__init__.py
Normal 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'
|
||||
]
|
||||
393
backend/app/auth/auth_controller.py
Normal file
393
backend/app/auth/auth_controller.py
Normal 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="사용자 삭제 중 오류가 발생했습니다"
|
||||
)
|
||||
372
backend/app/auth/auth_service.py
Normal file
372
backend/app/auth/auth_service.py
Normal 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)
|
||||
251
backend/app/auth/jwt_service.py
Normal file
251
backend/app/auth/jwt_service.py
Normal 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()
|
||||
305
backend/app/auth/middleware.py
Normal file
305
backend/app/auth/middleware.py
Normal 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
354
backend/app/auth/models.py
Normal 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
|
||||
Reference in New Issue
Block a user