first commit
This commit is contained in:
BIN
app/__pycache__/config.cpython-312.pyc
Normal file
BIN
app/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/database.cpython-312.pyc
Normal file
BIN
app/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/api/__pycache__/admin.cpython-312.pyc
Normal file
BIN
app/api/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/api/__pycache__/auth.cpython-312.pyc
Normal file
BIN
app/api/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/api/__pycache__/tunnel.cpython-312.pyc
Normal file
BIN
app/api/__pycache__/tunnel.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/api/__pycache__/vms.cpython-312.pyc
Normal file
BIN
app/api/__pycache__/vms.cpython-312.pyc
Normal file
Binary file not shown.
275
app/api/admin.py
Normal file
275
app/api/admin.py
Normal file
@@ -0,0 +1,275 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.schemas.admin import (
|
||||
UserCreate, UserUpdate, UserInfo, UserListResponse,
|
||||
VMAccessCreate, VMAccessUpdate, VMAccessInfo, VMAccessListResponse,
|
||||
AdminResponse
|
||||
)
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.vm import VMAccess
|
||||
from app.utils.security import hash_password
|
||||
from app.utils.exceptions import PermissionDeniedError, NotFoundError, BadRequestError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def require_admin(current_user: CurrentUser = Depends(get_current_user)):
|
||||
"""관리자 권한 확인"""
|
||||
if current_user.role != "admin":
|
||||
raise PermissionDeniedError("관리자 권한이 필요합니다")
|
||||
return current_user
|
||||
|
||||
# ==================== 사용자 관리 ====================
|
||||
|
||||
@router.get("/users", response_model=UserListResponse)
|
||||
async def get_users(
|
||||
current_user: CurrentUser = Depends(require_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""사용자 목록 조회"""
|
||||
users = db.query(User).all()
|
||||
|
||||
user_list = [
|
||||
UserInfo(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
role=user.role.value,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
last_login=user.last_login
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
return UserListResponse(total=len(user_list), users=user_list)
|
||||
|
||||
@router.post("/users", response_model=AdminResponse)
|
||||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
current_user: CurrentUser = Depends(require_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""사용자 생성"""
|
||||
# 중복 확인
|
||||
if db.query(User).filter(User.username == user_data.username).first():
|
||||
raise BadRequestError("이미 존재하는 사용자명입니다")
|
||||
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise BadRequestError("이미 존재하는 이메일입니다")
|
||||
|
||||
# 역할 유효성 검사
|
||||
if user_data.role not in ["admin", "user"]:
|
||||
raise BadRequestError("역할은 'admin' 또는 'user'만 가능합니다")
|
||||
|
||||
# 사용자 생성
|
||||
new_user = User(
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
hashed_password=hash_password(user_data.password),
|
||||
full_name=user_data.full_name,
|
||||
role=UserRole.ADMIN if user_data.role == "admin" else UserRole.USER,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
|
||||
return AdminResponse(
|
||||
success=True,
|
||||
message=f"사용자 '{user_data.username}'이(가) 생성되었습니다"
|
||||
)
|
||||
|
||||
@router.put("/users/{user_id}", response_model=AdminResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_data: UserUpdate,
|
||||
current_user: CurrentUser = Depends(require_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""사용자 정보 수정"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise NotFoundError("사용자를 찾을 수 없습니다")
|
||||
|
||||
# 이메일 중복 확인
|
||||
if user_data.email and user_data.email != user.email:
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise BadRequestError("이미 존재하는 이메일입니다")
|
||||
user.email = user_data.email
|
||||
|
||||
# 비밀번호 변경
|
||||
if user_data.password:
|
||||
user.hashed_password = hash_password(user_data.password)
|
||||
|
||||
# 기타 정보 업데이트
|
||||
if user_data.full_name is not None:
|
||||
user.full_name = user_data.full_name
|
||||
|
||||
if user_data.role:
|
||||
if user_data.role not in ["admin", "user"]:
|
||||
raise BadRequestError("역할은 'admin' 또는 'user'만 가능합니다")
|
||||
user.role = UserRole.ADMIN if user_data.role == "admin" else UserRole.USER
|
||||
|
||||
if user_data.is_active is not None:
|
||||
user.is_active = user_data.is_active
|
||||
|
||||
db.commit()
|
||||
|
||||
return AdminResponse(
|
||||
success=True,
|
||||
message=f"사용자 '{user.username}'의 정보가 수정되었습니다"
|
||||
)
|
||||
|
||||
@router.delete("/users/{user_id}", response_model=AdminResponse)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: CurrentUser = Depends(require_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""사용자 삭제"""
|
||||
# 자기 자신은 삭제 불가
|
||||
if user_id == current_user.id:
|
||||
raise BadRequestError("자기 자신은 삭제할 수 없습니다")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise NotFoundError("사용자를 찾을 수 없습니다")
|
||||
|
||||
username = user.username
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
return AdminResponse(
|
||||
success=True,
|
||||
message=f"사용자 '{username}'이(가) 삭제되었습니다"
|
||||
)
|
||||
|
||||
# ==================== VM 접근 권한 관리 ====================
|
||||
|
||||
@router.get("/vm-access", response_model=VMAccessListResponse)
|
||||
async def get_vm_access_list(
|
||||
current_user: CurrentUser = Depends(require_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""VM 접근 권한 목록 조회"""
|
||||
accesses = db.query(VMAccess).join(User).all()
|
||||
|
||||
access_list = [
|
||||
VMAccessInfo(
|
||||
id=access.id,
|
||||
user_id=access.user_id,
|
||||
username=access.user.username,
|
||||
vm_id=access.vm_id,
|
||||
node=access.node,
|
||||
vm_name=access.vm_name,
|
||||
static_ip=access.static_ip,
|
||||
rdp_username=access.rdp_username,
|
||||
rdp_port=access.rdp_port,
|
||||
is_active=access.is_active,
|
||||
created_at=access.created_at
|
||||
)
|
||||
for access in accesses
|
||||
]
|
||||
|
||||
return VMAccessListResponse(total=len(access_list), accesses=access_list)
|
||||
|
||||
@router.post("/vm-access", response_model=AdminResponse)
|
||||
async def create_vm_access(
|
||||
access_data: VMAccessCreate,
|
||||
current_user: CurrentUser = Depends(require_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""VM 접근 권한 부여"""
|
||||
# 사용자 존재 확인
|
||||
user = db.query(User).filter(User.id == access_data.user_id).first()
|
||||
if not user:
|
||||
raise NotFoundError("사용자를 찾을 수 없습니다")
|
||||
|
||||
# 중복 확인
|
||||
existing = db.query(VMAccess).filter(
|
||||
VMAccess.user_id == access_data.user_id,
|
||||
VMAccess.vm_id == access_data.vm_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise BadRequestError("이미 해당 사용자에게 VM 접근 권한이 있습니다")
|
||||
|
||||
# 권한 생성
|
||||
access = VMAccess(
|
||||
user_id=access_data.user_id,
|
||||
vm_id=access_data.vm_id,
|
||||
node=access_data.node,
|
||||
static_ip=access_data.static_ip,
|
||||
rdp_username=access_data.rdp_username,
|
||||
rdp_password=access_data.rdp_password,
|
||||
rdp_port=access_data.rdp_port,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.add(access)
|
||||
db.commit()
|
||||
|
||||
return AdminResponse(
|
||||
success=True,
|
||||
message=f"사용자 '{user.username}'에게 VM {access_data.vm_id} 접근 권한이 부여되었습니다"
|
||||
)
|
||||
|
||||
@router.put("/vm-access/{access_id}", response_model=AdminResponse)
|
||||
async def update_vm_access(
|
||||
access_id: int,
|
||||
access_data: VMAccessUpdate,
|
||||
current_user: CurrentUser = Depends(require_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""VM 접근 권한 수정"""
|
||||
access = db.query(VMAccess).filter(VMAccess.id == access_id).first()
|
||||
if not access:
|
||||
raise NotFoundError("접근 권한을 찾을 수 없습니다")
|
||||
|
||||
# 정보 업데이트
|
||||
if access_data.static_ip is not None:
|
||||
access.static_ip = access_data.static_ip
|
||||
|
||||
if access_data.rdp_username is not None:
|
||||
access.rdp_username = access_data.rdp_username
|
||||
|
||||
if access_data.rdp_password is not None:
|
||||
access.rdp_password = access_data.rdp_password
|
||||
|
||||
if access_data.rdp_port is not None:
|
||||
access.rdp_port = access_data.rdp_port
|
||||
|
||||
if access_data.is_active is not None:
|
||||
access.is_active = access_data.is_active
|
||||
|
||||
db.commit()
|
||||
|
||||
return AdminResponse(
|
||||
success=True,
|
||||
message=f"VM {access.vm_id} 접근 권한이 수정되었습니다"
|
||||
)
|
||||
|
||||
@router.delete("/vm-access/{access_id}", response_model=AdminResponse)
|
||||
async def delete_vm_access(
|
||||
access_id: int,
|
||||
current_user: CurrentUser = Depends(require_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""VM 접근 권한 삭제"""
|
||||
access = db.query(VMAccess).filter(VMAccess.id == access_id).first()
|
||||
if not access:
|
||||
raise NotFoundError("접근 권한을 찾을 수 없습니다")
|
||||
|
||||
vm_id = access.vm_id
|
||||
db.delete(access)
|
||||
db.commit()
|
||||
|
||||
return AdminResponse(
|
||||
success=True,
|
||||
message=f"VM {vm_id} 접근 권한이 삭제되었습니다"
|
||||
)
|
||||
101
app/api/auth.py
Normal file
101
app/api/auth.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.schemas.auth import UserRegister, UserLogin, Token, UserResponse, CurrentUser
|
||||
from app.services.auth_service import auth_service
|
||||
from app.utils.jwt_handler import decode_token
|
||||
from app.utils.exceptions import AuthenticationError
|
||||
|
||||
router = APIRouter()
|
||||
security = HTTPBearer()
|
||||
|
||||
# 현재 사용자 가져오기 의존성
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> CurrentUser:
|
||||
"""JWT 토큰에서 현재 사용자 정보 추출"""
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
|
||||
if not payload:
|
||||
raise AuthenticationError("유효하지 않은 토큰입니다")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise AuthenticationError("토큰 정보가 올바르지 않습니다")
|
||||
|
||||
user = auth_service.get_user_by_id(db, int(user_id))
|
||||
if not user:
|
||||
raise AuthenticationError("사용자를 찾을 수 없습니다")
|
||||
|
||||
if not user.is_active:
|
||||
raise AuthenticationError("비활성화된 계정입니다")
|
||||
|
||||
return CurrentUser(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active
|
||||
)
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(user_data: UserRegister, db: Session = Depends(get_db)):
|
||||
"""
|
||||
회원가입
|
||||
|
||||
새로운 사용자 계정을 생성합니다.
|
||||
"""
|
||||
user = auth_service.register_user(db, user_data)
|
||||
return user
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(login_data: UserLogin, db: Session = Depends(get_db)):
|
||||
"""
|
||||
로그인
|
||||
|
||||
사용자 인증 후 JWT 토큰을 발급합니다.
|
||||
"""
|
||||
user = auth_service.authenticate_user(db, login_data.username, login_data.password)
|
||||
tokens = auth_service.create_tokens(user)
|
||||
return tokens
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
현재 사용자 정보 조회
|
||||
|
||||
JWT 토큰을 기반으로 현재 로그인한 사용자 정보를 반환합니다.
|
||||
"""
|
||||
user = auth_service.get_user_by_id(db, current_user.id)
|
||||
return user
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
토큰 갱신
|
||||
|
||||
Refresh Token을 사용하여 새로운 Access Token을 발급합니다.
|
||||
"""
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise AuthenticationError("유효하지 않은 Refresh Token입니다")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
user = auth_service.get_user_by_id(db, int(user_id))
|
||||
|
||||
if not user or not user.is_active:
|
||||
raise AuthenticationError("사용자를 찾을 수 없습니다")
|
||||
|
||||
tokens = auth_service.create_tokens(user)
|
||||
return tokens
|
||||
66
app/api/ssh_credentials.py
Normal file
66
app/api/ssh_credentials.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, timedelta
|
||||
from app.api.auth import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.services.temp_ssh_password_service import temp_ssh_password_manager
|
||||
import os
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class SshVerifyRequest(BaseModel):
|
||||
"""SSH 비밀번호 검증 요청"""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@router.post("/credentials")
|
||||
async def get_ssh_credentials(current_user: CurrentUser = Depends(get_current_user)):
|
||||
"""
|
||||
JWT 토큰으로 임시 SSH 자격증명 발급
|
||||
|
||||
Returns:
|
||||
SSH 연결 정보 및 임시 비밀번호
|
||||
"""
|
||||
# 임시 비밀번호 생성 (1시간 유효)
|
||||
temp_password = temp_ssh_password_manager.generate_password(
|
||||
username=current_user.username,
|
||||
validity_hours=1
|
||||
)
|
||||
|
||||
# SSH 서버 정보 (외부 접속용)
|
||||
ssh_host = os.getenv("SSH_HOST", "api.mouse84.com") # 외부 DDNS
|
||||
ssh_port = int(os.getenv("SSH_PORT", "54054")) # 외부 포트 (내부 22로 포워딩)
|
||||
|
||||
# 만료 시간 계산
|
||||
expires_at = datetime.utcnow() + timedelta(hours=1)
|
||||
|
||||
return {
|
||||
"ssh_host": ssh_host,
|
||||
"ssh_port": ssh_port,
|
||||
"ssh_username": current_user.username,
|
||||
"ssh_password": temp_password,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"expires_in_seconds": 3600
|
||||
}
|
||||
|
||||
@router.post("/verify")
|
||||
async def verify_ssh_password(request: SshVerifyRequest):
|
||||
"""
|
||||
임시 SSH 비밀번호 검증 (PAM 인증용)
|
||||
|
||||
Args:
|
||||
request: 사용자명과 비밀번호
|
||||
|
||||
Returns:
|
||||
200: 비밀번호 유효
|
||||
401: 비밀번호 무효
|
||||
"""
|
||||
is_valid = temp_ssh_password_manager.verify_password(
|
||||
username=request.username,
|
||||
password=request.password
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
return {"valid": True, "message": "Password verified"}
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
127
app/api/tunnel.py
Normal file
127
app/api/tunnel.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.schemas.tunnel import (
|
||||
TunnelCreateRequest,
|
||||
TunnelCreateResponse,
|
||||
TunnelStatusResponse,
|
||||
TunnelCloseResponse,
|
||||
TunnelInfo
|
||||
)
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.api.auth import get_current_user
|
||||
from app.services.ssh_tunnel_service import ssh_tunnel_manager
|
||||
from app.services.proxmox_service import proxmox_service
|
||||
from app.models.vm import VMAccess
|
||||
from app.utils.exceptions import NotFoundError, BadRequestError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/create", response_model=TunnelCreateResponse)
|
||||
async def create_tunnel(
|
||||
request: TunnelCreateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
SSH 터널 생성
|
||||
|
||||
VM에 접속하기 위한 SSH Local Port Forwarding 터널을 생성합니다.
|
||||
클라이언트는 반환된 local_port로 RDP 연결을 시도해야 합니다.
|
||||
"""
|
||||
# VM IP 주소 확인
|
||||
# 1. 요청에 IP가 포함되어 있으면 사용 (Guest Agent 없이도 가능)
|
||||
ip_address = request.vm_ip
|
||||
|
||||
# 2. 없으면 Guest Agent로 조회
|
||||
if not ip_address:
|
||||
try:
|
||||
ip_address = await proxmox_service.get_vm_ip(request.node, request.vm_id)
|
||||
if ip_address:
|
||||
print(f"✅ Guest Agent에서 IP 조회 성공: {ip_address}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Guest Agent IP 조회 실패: {str(e)}")
|
||||
ip_address = None
|
||||
|
||||
# 3. Guest Agent 실패 시 VMAccess 테이블의 static_ip 사용
|
||||
if not ip_address:
|
||||
vm_access = db.query(VMAccess).filter(
|
||||
VMAccess.user_id == current_user.id,
|
||||
VMAccess.vm_id == request.vm_id,
|
||||
VMAccess.node == request.node
|
||||
).first()
|
||||
|
||||
if vm_access and vm_access.static_ip:
|
||||
ip_address = vm_access.static_ip
|
||||
print(f"✅ Static IP 사용: {ip_address}")
|
||||
else:
|
||||
raise BadRequestError(
|
||||
"VM의 IP 주소를 확인할 수 없습니다. "
|
||||
"관리자 패널에서 고정 IP를 설정하거나 QEMU Guest Agent를 설치하세요."
|
||||
)
|
||||
|
||||
try:
|
||||
# SSH 터널 생성
|
||||
tunnel_info = await ssh_tunnel_manager.create_tunnel(
|
||||
user_id=current_user.id,
|
||||
vm_id=request.vm_id,
|
||||
remote_host=ip_address,
|
||||
remote_port=3389 # RDP 포트
|
||||
)
|
||||
|
||||
return TunnelCreateResponse(
|
||||
success=True,
|
||||
message="SSH 터널이 생성되었습니다",
|
||||
session_id=tunnel_info["session_id"],
|
||||
tunnel_info=TunnelInfo(
|
||||
session_id=tunnel_info["session_id"],
|
||||
local_port=tunnel_info["local_port"],
|
||||
remote_host=tunnel_info["remote_host"],
|
||||
remote_port=tunnel_info["remote_port"],
|
||||
vm_id=request.vm_id,
|
||||
is_active=True,
|
||||
created_at=None # 자동 설정
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return TunnelCreateResponse(
|
||||
success=False,
|
||||
message=f"터널 생성 실패: {str(e)}",
|
||||
session_id="",
|
||||
tunnel_info=None
|
||||
)
|
||||
|
||||
@router.get("/{session_id}/status", response_model=TunnelStatusResponse)
|
||||
async def get_tunnel_status(
|
||||
session_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
터널 상태 조회
|
||||
|
||||
활성 터널의 상태를 확인합니다.
|
||||
"""
|
||||
status = await ssh_tunnel_manager.get_tunnel_status(session_id)
|
||||
|
||||
if not status:
|
||||
raise NotFoundError("터널을 찾을 수 없습니다")
|
||||
|
||||
return TunnelStatusResponse(**status)
|
||||
|
||||
@router.delete("/{session_id}", response_model=TunnelCloseResponse)
|
||||
async def close_tunnel(
|
||||
session_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
터널 종료
|
||||
|
||||
SSH 터널을 종료하고 리소스를 정리합니다.
|
||||
"""
|
||||
success = await ssh_tunnel_manager.close_tunnel(session_id)
|
||||
|
||||
return TunnelCloseResponse(
|
||||
success=success,
|
||||
message="터널이 종료되었습니다" if success else "터널 종료에 실패했습니다",
|
||||
session_id=session_id
|
||||
)
|
||||
141
app/api/vms.py
Normal file
141
app/api/vms.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.schemas.vm import VMInfo, VMListResponse, VMDetail, VMControlRequest, VMControlResponse
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.api.auth import get_current_user
|
||||
from app.services.proxmox_service import proxmox_service
|
||||
from app.models.vm import VMAccess
|
||||
from app.utils.exceptions import NotFoundError, PermissionDeniedError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/my", response_model=VMListResponse)
|
||||
async def get_my_vms(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
내 VM 목록 조회
|
||||
|
||||
현재 사용자가 접근 가능한 VM 목록을 반환합니다.
|
||||
"""
|
||||
# Proxmox에서 모든 VM 가져오기
|
||||
all_vms = await proxmox_service.get_all_vms()
|
||||
|
||||
# 현재 사용자의 VMAccess 정보 조회
|
||||
vm_accesses = db.query(VMAccess).filter(
|
||||
VMAccess.user_id == current_user.id,
|
||||
VMAccess.is_active == True
|
||||
).all()
|
||||
|
||||
# vm_id를 키로 하는 딕셔너리 생성
|
||||
access_map = {access.vm_id: access for access in vm_accesses}
|
||||
|
||||
vm_list = []
|
||||
for vm in all_vms:
|
||||
vm_id = vm["vmid"]
|
||||
access = access_map.get(vm_id)
|
||||
|
||||
# VMAccess가 있으면 해당 정보 사용, 없으면 기본값
|
||||
vm_info = VMInfo(
|
||||
vm_id=vm_id,
|
||||
node=vm["node"],
|
||||
name=vm.get("name", "Unknown"),
|
||||
status=vm.get("status", "unknown"),
|
||||
ip_address=access.static_ip if access else None, # Static IP 자동 설정
|
||||
cpus=vm.get("cpus", 0),
|
||||
memory=vm.get("maxmem", 0) // (1024 * 1024), # bytes to MB
|
||||
memory_usage=vm.get("mem", 0) // (1024 * 1024) if vm.get("mem") else None,
|
||||
cpu_usage=vm.get("cpu", 0),
|
||||
can_start=True,
|
||||
can_stop=True,
|
||||
can_reboot=True,
|
||||
can_connect=True,
|
||||
rdp_username=access.rdp_username if access else None, # RDP 사용자명
|
||||
rdp_password=access.rdp_password if access else None, # RDP 비밀번호
|
||||
rdp_port=access.rdp_port if access else 3389 # RDP 포트
|
||||
)
|
||||
vm_list.append(vm_info)
|
||||
|
||||
return VMListResponse(total=len(vm_list), vms=vm_list)
|
||||
|
||||
@router.get("/{vm_id}", response_model=VMDetail)
|
||||
async def get_vm_detail(
|
||||
vm_id: int,
|
||||
node: str,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
VM 상세 정보 조회
|
||||
"""
|
||||
# VM 상태 조회
|
||||
status = await proxmox_service.get_vm_status(node, vm_id)
|
||||
|
||||
if not status:
|
||||
raise NotFoundError(f"VM {vm_id}를 찾을 수 없습니다")
|
||||
|
||||
# IP 조회 제거 - 연결에 필요하지 않음
|
||||
return VMDetail(
|
||||
vm_id=vm_id,
|
||||
node=node,
|
||||
name=status.get("name", "Unknown"),
|
||||
status=status.get("status", "unknown"),
|
||||
ip_address=None, # IP 조회 안 함
|
||||
cpus=status.get("cpus", 0),
|
||||
memory=status.get("maxmem", 0) // (1024 * 1024),
|
||||
memory_usage=status.get("mem", 0) // (1024 * 1024) if status.get("mem") else None,
|
||||
cpu_usage=status.get("cpu", 0),
|
||||
uptime=status.get("uptime"),
|
||||
rdp_port=3389,
|
||||
has_guest_agent=False # Guest Agent 불필요
|
||||
)
|
||||
|
||||
@router.post("/{vm_id}/start", response_model=VMControlResponse)
|
||||
async def start_vm(
|
||||
vm_id: int,
|
||||
node: str,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""VM 시작"""
|
||||
success = await proxmox_service.start_vm(node, vm_id)
|
||||
|
||||
return VMControlResponse(
|
||||
success=success,
|
||||
message="VM이 시작되었습니다" if success else "VM 시작에 실패했습니다",
|
||||
vm_id=vm_id,
|
||||
action="start"
|
||||
)
|
||||
|
||||
@router.post("/{vm_id}/stop", response_model=VMControlResponse)
|
||||
async def stop_vm(
|
||||
vm_id: int,
|
||||
node: str,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""VM 종료"""
|
||||
success = await proxmox_service.stop_vm(node, vm_id)
|
||||
|
||||
return VMControlResponse(
|
||||
success=success,
|
||||
message="VM이 종료되었습니다" if success else "VM 종료에 실패했습니다",
|
||||
vm_id=vm_id,
|
||||
action="stop"
|
||||
)
|
||||
|
||||
@router.post("/{vm_id}/reboot", response_model=VMControlResponse)
|
||||
async def reboot_vm(
|
||||
vm_id: int,
|
||||
node: str,
|
||||
current_user: CurrentUser = Depends(get_current_user)
|
||||
):
|
||||
"""VM 재시작"""
|
||||
success = await proxmox_service.reboot_vm(node, vm_id)
|
||||
|
||||
return VMControlResponse(
|
||||
success=success,
|
||||
message="VM이 재시작되었습니다" if success else "VM 재시작에 실패했습니다",
|
||||
vm_id=vm_id,
|
||||
action="reboot"
|
||||
)
|
||||
53
app/config.py
Normal file
53
app/config.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
import os
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Application
|
||||
APP_NAME: str = "VConnect API"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = True
|
||||
API_V1_PREFIX: str = "/api"
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "sqlite:///./vconnect.db"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Proxmox
|
||||
PROXMOX_HOST: str = "https://pve.mouse84.com:8006"
|
||||
PROXMOX_API_TOKEN: str = ""
|
||||
PROXMOX_VERIFY_SSL: bool = False
|
||||
|
||||
# SSH Gateway
|
||||
SSH_HOST: str = ""
|
||||
SSH_PORT: int = 22
|
||||
SSH_USERNAME: str = ""
|
||||
SSH_KEY_PATH: str = ""
|
||||
SSH_PASSWORD: str = ""
|
||||
|
||||
# Tunnel Port Range
|
||||
TUNNEL_PORT_MIN: int = 50000
|
||||
TUNNEL_PORT_MAX: int = 60000
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:8080", "http://localhost:3000"]
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/vconnect.log"
|
||||
|
||||
# Admin
|
||||
ADMIN_USERNAME: str = "admin"
|
||||
ADMIN_PASSWORD: str = "admin123"
|
||||
ADMIN_EMAIL: str = "admin@example.com"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
settings = Settings()
|
||||
25
app/database.py
Normal file
25
app/database.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.config import settings
|
||||
|
||||
# SQLAlchemy 엔진 생성
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {},
|
||||
echo=settings.DEBUG
|
||||
)
|
||||
|
||||
# 세션 팩토리
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Base 클래스
|
||||
Base = declarative_base()
|
||||
|
||||
# DB 세션 의존성
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
94
app/main.py
Normal file
94
app/main.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from app.config import settings
|
||||
from app.database import engine, Base
|
||||
from app.api import auth, vms, tunnel, admin, ssh_credentials
|
||||
import logging
|
||||
|
||||
# 로깅 설정
|
||||
logging.basicConfig(
|
||||
level=settings.LOG_LEVEL,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 라이프사이클 이벤트
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 시작 시
|
||||
logger.info("🚀 VConnect API 서버 시작")
|
||||
|
||||
# DB 테이블 생성
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 관리자 계정 생성 (없으면)
|
||||
from app.services.auth_service import auth_service
|
||||
from app.database import SessionLocal
|
||||
from app.schemas.auth import UserRegister
|
||||
from app.models.user import UserRole
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing_admin = auth_service.get_user_by_username(db, settings.ADMIN_USERNAME)
|
||||
if not existing_admin:
|
||||
admin_data = UserRegister(
|
||||
username=settings.ADMIN_USERNAME,
|
||||
email=settings.ADMIN_EMAIL,
|
||||
password=settings.ADMIN_PASSWORD,
|
||||
full_name="Administrator"
|
||||
)
|
||||
admin_user = auth_service.register_user(db, admin_data)
|
||||
admin_user.role = UserRole.ADMIN
|
||||
db.commit()
|
||||
logger.info(f"✅ 관리자 계정 생성: {settings.ADMIN_USERNAME}")
|
||||
except Exception as e:
|
||||
logger.error(f"관리자 계정 생성 실패: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
yield
|
||||
|
||||
# 종료 시
|
||||
logger.info("🛑 VConnect API 서버 종료")
|
||||
|
||||
# FastAPI 앱 생성
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="Zero-Port, Zero-VPN SSH 기반 Proxmox VM 원격접속 플랫폼",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS 설정
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# API 라우터 등록
|
||||
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}/auth", tags=["인증"])
|
||||
app.include_router(vms.router, prefix=f"{settings.API_V1_PREFIX}/vms", tags=["VM 관리"])
|
||||
app.include_router(tunnel.router, prefix=f"{settings.API_V1_PREFIX}/tunnel", tags=["터널 관리"])
|
||||
app.include_router(admin.router, prefix=f"{settings.API_V1_PREFIX}/admin", tags=["관리자"])
|
||||
app.include_router(ssh_credentials.router, prefix=f"{settings.API_V1_PREFIX}/ssh", tags=["SSH 자격증명"])
|
||||
|
||||
# Health Check
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"app_name": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION
|
||||
}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"message": "VConnect API Server",
|
||||
"version": settings.APP_VERSION,
|
||||
"docs": "/docs"
|
||||
}
|
||||
BIN
app/models/__pycache__/user.cpython-312.pyc
Normal file
BIN
app/models/__pycache__/user.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/models/__pycache__/vm.cpython-312.pyc
Normal file
BIN
app/models/__pycache__/vm.cpython-312.pyc
Normal file
Binary file not shown.
43
app/models/audit_log.py
Normal file
43
app/models/audit_log.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import enum
|
||||
|
||||
class AuditAction(str, enum.Enum):
|
||||
LOGIN = "login"
|
||||
LOGOUT = "logout"
|
||||
VM_CONNECT = "vm_connect"
|
||||
VM_DISCONNECT = "vm_disconnect"
|
||||
VM_START = "vm_start"
|
||||
VM_STOP = "vm_stop"
|
||||
VM_REBOOT = "vm_reboot"
|
||||
TUNNEL_CREATE = "tunnel_create"
|
||||
TUNNEL_CLOSE = "tunnel_close"
|
||||
USER_CREATE = "user_create"
|
||||
USER_UPDATE = "user_update"
|
||||
USER_DELETE = "user_delete"
|
||||
ACCESS_DENIED = "access_denied"
|
||||
|
||||
class AuditLog(Base):
|
||||
"""감사 로그 - 모든 중요 작업 기록"""
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
username = Column(String(50)) # 비정규화 (삭제된 사용자 추적)
|
||||
|
||||
action = Column(Enum(AuditAction), nullable=False, index=True)
|
||||
resource_type = Column(String(50)) # "vm", "user", "tunnel"
|
||||
resource_id = Column(String(100)) # VM ID, User ID 등
|
||||
|
||||
ip_address = Column(String(50))
|
||||
user_agent = Column(String(255))
|
||||
|
||||
details = Column(Text) # JSON 형태로 추가 정보 저장
|
||||
success = Column(Integer, default=True)
|
||||
error_message = Column(Text)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AuditLog(user='{self.username}', action='{self.action}', created_at='{self.created_at}')>"
|
||||
28
app/models/user.py
Normal file
28
app/models/user.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
import enum
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(100), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(100))
|
||||
role = Column(Enum(UserRole), default=UserRole.USER, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
last_login = Column(DateTime(timezone=True))
|
||||
|
||||
vm_accesses = relationship("VMAccess", back_populates="user")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}', role='{self.role}')>"
|
||||
62
app/models/vm.py
Normal file
62
app/models/vm.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
class VMAccess(Base):
|
||||
"""사용자별 VM 접근 권한"""
|
||||
__tablename__ = "vm_access"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
vm_id = Column(Integer, nullable=False) # Proxmox VM ID
|
||||
node = Column(String(50), nullable=False) # Proxmox 노드명
|
||||
vm_name = Column(String(100))
|
||||
|
||||
# RDP 접속 정보
|
||||
rdp_username = Column(String(50))
|
||||
rdp_password = Column(String(255)) # 암호화 저장
|
||||
rdp_port = Column(Integer, default=3389)
|
||||
|
||||
# 권한
|
||||
can_start = Column(Boolean, default=True)
|
||||
can_stop = Column(Boolean, default=True)
|
||||
can_reboot = Column(Boolean, default=True)
|
||||
can_connect = Column(Boolean, default=True)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Guest Agent 없을 때 사용할 고정 IP
|
||||
static_ip = Column(String(50), nullable=True)
|
||||
|
||||
# 관계
|
||||
user = relationship("User", back_populates="vm_accesses")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<VMAccess(user_id={self.user_id}, vm_id={self.vm_id}, node='{self.node}')>"
|
||||
|
||||
|
||||
class SSHTunnel(Base):
|
||||
"""활성 SSH 터널 세션"""
|
||||
__tablename__ = "ssh_tunnels"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
vm_id = Column(Integer, nullable=False)
|
||||
|
||||
# 터널 정보
|
||||
local_port = Column(Integer, nullable=False) # 클라이언트가 사용할 포트
|
||||
remote_host = Column(String(50), nullable=False) # VM IP
|
||||
remote_port = Column(Integer, nullable=False) # VM RDP 포트
|
||||
|
||||
# 세션 정보
|
||||
session_id = Column(String(100), unique=True, index=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
closed_at = Column(DateTime(timezone=True))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SSHTunnel(session_id='{self.session_id}', local_port={self.local_port})>"
|
||||
BIN
app/schemas/__pycache__/admin.cpython-312.pyc
Normal file
BIN
app/schemas/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/schemas/__pycache__/auth.cpython-312.pyc
Normal file
BIN
app/schemas/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/schemas/__pycache__/tunnel.cpython-312.pyc
Normal file
BIN
app/schemas/__pycache__/tunnel.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/schemas/__pycache__/vm.cpython-312.pyc
Normal file
BIN
app/schemas/__pycache__/vm.cpython-312.pyc
Normal file
Binary file not shown.
71
app/schemas/admin.py
Normal file
71
app/schemas/admin.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
# 사용자 관리
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
full_name: Optional[str] = None
|
||||
role: str = "user" # admin or user
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
password: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
full_name: Optional[str]
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime]
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
total: int
|
||||
users: List[UserInfo]
|
||||
|
||||
# VM 접근 권한 관리
|
||||
class VMAccessCreate(BaseModel):
|
||||
user_id: int
|
||||
vm_id: int
|
||||
node: str
|
||||
static_ip: Optional[str] = None
|
||||
rdp_username: Optional[str] = None
|
||||
rdp_password: Optional[str] = None
|
||||
rdp_port: int = 3389
|
||||
|
||||
class VMAccessUpdate(BaseModel):
|
||||
static_ip: Optional[str] = None
|
||||
rdp_username: Optional[str] = None
|
||||
rdp_password: Optional[str] = None
|
||||
rdp_port: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class VMAccessInfo(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
username: str # 조인해서 가져옴
|
||||
vm_id: int
|
||||
node: str
|
||||
vm_name: Optional[str]
|
||||
static_ip: Optional[str]
|
||||
rdp_username: Optional[str]
|
||||
rdp_port: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
class VMAccessListResponse(BaseModel):
|
||||
total: int
|
||||
accesses: List[VMAccessInfo]
|
||||
|
||||
# 공통 응답
|
||||
class AdminResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
51
app/schemas/auth.py
Normal file
51
app/schemas/auth.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from app.models.user import UserRole
|
||||
|
||||
# 회원가입 요청
|
||||
class UserRegister(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=6)
|
||||
full_name: Optional[str] = None
|
||||
|
||||
# 로그인 요청
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
# 토큰 응답
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
# 토큰 페이로드
|
||||
class TokenPayload(BaseModel):
|
||||
sub: str # user_id
|
||||
exp: datetime
|
||||
role: UserRole
|
||||
|
||||
# 사용자 응답
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
full_name: Optional[str]
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# 현재 사용자 정보
|
||||
class CurrentUser(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
44
app/schemas/tunnel.py
Normal file
44
app/schemas/tunnel.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
# 터널 생성 요청
|
||||
class TunnelCreateRequest(BaseModel):
|
||||
vm_id: int
|
||||
node: str
|
||||
vm_ip: Optional[str] = None # Guest Agent 없이 수동으로 IP 지정 가능
|
||||
|
||||
# 터널 생성 응답
|
||||
class TunnelCreateResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
session_id: str
|
||||
tunnel_info: Optional['TunnelInfo'] = None
|
||||
|
||||
# 터널 정보
|
||||
class TunnelInfo(BaseModel):
|
||||
session_id: str
|
||||
local_port: int
|
||||
remote_host: str
|
||||
remote_port: int
|
||||
vm_id: int
|
||||
vm_name: Optional[str] = None
|
||||
rdp_username: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# 터널 상태 응답
|
||||
class TunnelStatusResponse(BaseModel):
|
||||
session_id: str
|
||||
is_active: bool
|
||||
uptime_seconds: Optional[int] = None
|
||||
created_at: datetime
|
||||
|
||||
# 터널 종료 응답
|
||||
class TunnelCloseResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
session_id: str
|
||||
52
app/schemas/vm.py
Normal file
52
app/schemas/vm.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
# VM 정보 응답
|
||||
class VMInfo(BaseModel):
|
||||
vm_id: int
|
||||
node: str
|
||||
name: str
|
||||
status: str
|
||||
ip_address: Optional[str] = None
|
||||
cpus: int
|
||||
memory: int # MB
|
||||
memory_usage: Optional[int] = None
|
||||
cpu_usage: Optional[float] = None
|
||||
uptime: Optional[int] = None
|
||||
|
||||
# 접근 권한 정보
|
||||
can_start: bool = True
|
||||
can_stop: bool = True
|
||||
can_reboot: bool = True
|
||||
can_connect: bool = True
|
||||
|
||||
# RDP 연결 정보 (VMAccess에서 가져옴)
|
||||
rdp_username: Optional[str] = None
|
||||
rdp_password: Optional[str] = None
|
||||
rdp_port: int = 3389
|
||||
|
||||
# VM 목록 응답
|
||||
class VMListResponse(BaseModel):
|
||||
total: int
|
||||
vms: list[VMInfo]
|
||||
|
||||
# VM 상세 정보
|
||||
class VMDetail(VMInfo):
|
||||
rdp_port: int = 3389
|
||||
rdp_username: Optional[str] = None
|
||||
has_guest_agent: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# VM 제어 요청
|
||||
class VMControlRequest(BaseModel):
|
||||
action: str # "start", "stop", "reboot"
|
||||
|
||||
# VM 제어 응답
|
||||
class VMControlResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
vm_id: int
|
||||
action: str
|
||||
BIN
app/services/__pycache__/auth_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/auth_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/proxmox_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/proxmox_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/ssh_tunnel_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/ssh_tunnel_service.cpython-312.pyc
Normal file
Binary file not shown.
87
app/services/auth_service.py
Normal file
87
app/services/auth_service.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.auth import UserRegister, Token
|
||||
from app.utils.security import hash_password, verify_password
|
||||
from app.utils.jwt_handler import create_access_token, create_refresh_token
|
||||
from app.utils.exceptions import AuthenticationError, ConflictError
|
||||
|
||||
class AuthService:
|
||||
"""인증 관련 비즈니스 로직"""
|
||||
|
||||
@staticmethod
|
||||
def register_user(db: Session, user_data: UserRegister) -> User:
|
||||
"""사용자 등록"""
|
||||
# 중복 체크
|
||||
if db.query(User).filter(User.username == user_data.username).first():
|
||||
raise ConflictError("이미 존재하는 사용자명입니다")
|
||||
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise ConflictError("이미 존재하는 이메일입니다")
|
||||
|
||||
# 새 사용자 생성
|
||||
new_user = User(
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
hashed_password=hash_password(user_data.password),
|
||||
full_name=user_data.full_name,
|
||||
role=UserRole.USER
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
|
||||
@staticmethod
|
||||
def authenticate_user(db: Session, username: str, password: str) -> User:
|
||||
"""사용자 인증"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not user:
|
||||
raise AuthenticationError("사용자를 찾을 수 없습니다")
|
||||
|
||||
if not user.is_active:
|
||||
raise AuthenticationError("비활성화된 계정입니다")
|
||||
|
||||
if not verify_password(password, user.hashed_password):
|
||||
raise AuthenticationError("비밀번호가 일치하지 않습니다")
|
||||
|
||||
# 마지막 로그인 시간 업데이트
|
||||
user.last_login = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def create_tokens(user: User) -> Token:
|
||||
"""JWT 토큰 생성"""
|
||||
token_data = {
|
||||
"sub": str(user.id),
|
||||
"username": user.username,
|
||||
"role": user.role.value
|
||||
}
|
||||
|
||||
access_token = create_access_token(token_data)
|
||||
refresh_token = create_refresh_token(token_data)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=1800 # 30분
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
|
||||
"""사용자 ID로 조회"""
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_username(db: Session, username: str) -> Optional[User]:
|
||||
"""사용자명으로 조회"""
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
|
||||
auth_service = AuthService()
|
||||
140
app/services/proxmox_service.py
Normal file
140
app/services/proxmox_service.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import httpx
|
||||
from typing import List, Optional, Dict
|
||||
from app.config import settings
|
||||
import json
|
||||
|
||||
class ProxmoxService:
|
||||
"""Proxmox VE API 통신 서비스"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.PROXMOX_HOST
|
||||
self.api_token = settings.PROXMOX_API_TOKEN
|
||||
self.verify_ssl = settings.PROXMOX_VERIFY_SSL
|
||||
|
||||
async def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict:
|
||||
"""Proxmox API 요청"""
|
||||
url = f"{self.base_url}/api2/json{endpoint}"
|
||||
headers = {"Authorization": self.api_token}
|
||||
|
||||
async with httpx.AsyncClient(verify=self.verify_ssl, timeout=30.0) as client:
|
||||
response = await client.request(method, url, headers=headers, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_nodes(self) -> List[Dict]:
|
||||
"""노드 목록 조회"""
|
||||
result = await self._make_request("GET", "/nodes")
|
||||
return result.get("data", [])
|
||||
|
||||
async def get_vms(self, node: str) -> List[Dict]:
|
||||
"""특정 노드의 VM 목록 조회"""
|
||||
result = await self._make_request("GET", f"/nodes/{node}/qemu")
|
||||
return result.get("data", [])
|
||||
|
||||
async def get_all_vms(self) -> List[Dict]:
|
||||
"""모든 노드의 VM 목록 조회"""
|
||||
nodes = await self.get_nodes()
|
||||
print(f"DEBUG: 노드 목록: {nodes}")
|
||||
|
||||
all_vms = []
|
||||
|
||||
for node in nodes:
|
||||
node_name = node.get("node")
|
||||
if node_name:
|
||||
vms = await self.get_vms(node_name)
|
||||
print(f"DEBUG: {node_name} 노드의 VM 목록: {vms}")
|
||||
for vm in vms:
|
||||
vm["node"] = node_name
|
||||
all_vms.append(vm)
|
||||
|
||||
print(f"DEBUG: 전체 VM 개수: {len(all_vms)}")
|
||||
print(f"DEBUG: 전체 VM 목록: {all_vms}")
|
||||
return all_vms
|
||||
|
||||
async def get_vm_status(self, node: str, vm_id: int) -> Dict:
|
||||
"""VM 상태 조회"""
|
||||
result = await self._make_request("GET", f"/nodes/{node}/qemu/{vm_id}/status/current")
|
||||
return result.get("data", {})
|
||||
|
||||
async def get_vm_ip(self, node: str, vm_id: int) -> Optional[str]:
|
||||
"""QEMU Guest Agent를 통해 VM IP 주소 조회"""
|
||||
try:
|
||||
result = await self._make_request(
|
||||
"GET",
|
||||
f"/nodes/{node}/qemu/{vm_id}/agent/network-get-interfaces"
|
||||
)
|
||||
|
||||
interfaces = result.get("data", {}).get("result", [])
|
||||
|
||||
for iface in interfaces:
|
||||
# loopback 제외
|
||||
if "loopback" in iface.get("name", "").lower():
|
||||
continue
|
||||
|
||||
ip_addresses = iface.get("ip-addresses", [])
|
||||
for ip in ip_addresses:
|
||||
if ip.get("ip-address-type") == "ipv4":
|
||||
address = ip.get("ip-address")
|
||||
# 사설 IP만 반환
|
||||
if self._is_private_ip(address):
|
||||
return address
|
||||
|
||||
return None
|
||||
except:
|
||||
return None
|
||||
|
||||
async def start_vm(self, node: str, vm_id: int) -> bool:
|
||||
"""VM 시작"""
|
||||
try:
|
||||
await self._make_request("POST", f"/nodes/{node}/qemu/{vm_id}/status/start")
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
async def stop_vm(self, node: str, vm_id: int) -> bool:
|
||||
"""VM 종료"""
|
||||
try:
|
||||
await self._make_request("POST", f"/nodes/{node}/qemu/{vm_id}/status/stop")
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
async def reboot_vm(self, node: str, vm_id: int) -> bool:
|
||||
"""VM 재시작"""
|
||||
try:
|
||||
await self._make_request("POST", f"/nodes/{node}/qemu/{vm_id}/status/reboot")
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def _is_private_ip(self, ip: str) -> bool:
|
||||
"""사설 IP 주소 확인"""
|
||||
if not ip:
|
||||
return False
|
||||
|
||||
parts = ip.split(".")
|
||||
if len(parts) != 4:
|
||||
return False
|
||||
|
||||
try:
|
||||
first = int(parts[0])
|
||||
second = int(parts[1])
|
||||
|
||||
# 10.0.0.0/8
|
||||
if first == 10:
|
||||
return True
|
||||
|
||||
# 172.16.0.0/12
|
||||
if first == 172 and 16 <= second <= 31:
|
||||
return True
|
||||
|
||||
# 192.168.0.0/16
|
||||
if first == 192 and second == 168:
|
||||
return True
|
||||
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
proxmox_service = ProxmoxService()
|
||||
220
app/services/ssh_tunnel_service.py
Normal file
220
app/services/ssh_tunnel_service.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import asyncio
|
||||
import asyncssh
|
||||
import secrets
|
||||
from typing import Dict, Optional
|
||||
from datetime import datetime
|
||||
from app.config import settings
|
||||
from app.utils.exceptions import InternalServerError
|
||||
|
||||
|
||||
class SSHTunnelManager:
|
||||
"""SSH 터널 관리"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_tunnels: Dict[str, 'TunnelSession'] = {}
|
||||
self.used_ports = set()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 포트 관리자
|
||||
# ---------------------------------------------------------
|
||||
def _get_available_port(self) -> int:
|
||||
"""사용 가능한 포트 찾기"""
|
||||
for port in range(settings.TUNNEL_PORT_MIN, settings.TUNNEL_PORT_MAX):
|
||||
if port not in self.used_ports:
|
||||
self.used_ports.add(port)
|
||||
return port
|
||||
raise InternalServerError("사용 가능한 포트가 없습니다")
|
||||
|
||||
def _release_port(self, port: int):
|
||||
"""포트 해제"""
|
||||
self.used_ports.discard(port)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 터널 생성
|
||||
# ---------------------------------------------------------
|
||||
async def create_tunnel(
|
||||
self,
|
||||
user_id: int,
|
||||
vm_id: int,
|
||||
remote_host: str,
|
||||
remote_port: int = 3389,
|
||||
vm_name: str = "",
|
||||
rdp_username: str = ""
|
||||
) -> Dict:
|
||||
"""
|
||||
SSH 터널 생성
|
||||
"""
|
||||
|
||||
session_id = secrets.token_urlsafe(32)
|
||||
local_port = self._get_available_port()
|
||||
|
||||
try:
|
||||
ssh_config = {
|
||||
"host": settings.SSH_HOST,
|
||||
"port": settings.SSH_PORT,
|
||||
"username": settings.SSH_USERNAME,
|
||||
"known_hosts": None
|
||||
}
|
||||
|
||||
if settings.SSH_KEY_PATH:
|
||||
ssh_config["client_keys"] = [settings.SSH_KEY_PATH]
|
||||
elif settings.SSH_PASSWORD:
|
||||
ssh_config["password"] = settings.SSH_PASSWORD
|
||||
else:
|
||||
raise InternalServerError("SSH 인증 정보가 설정되지 않았습니다")
|
||||
|
||||
# SSH 연결 생성
|
||||
conn = await asyncssh.connect(**ssh_config)
|
||||
|
||||
# 로컬 포트 포워딩 (외부 접속 허용)
|
||||
listener = await conn.forward_local_port(
|
||||
'0.0.0.0', # 모든 인터페이스에서 리스닝 (외부 접속 허용)
|
||||
local_port,
|
||||
remote_host,
|
||||
remote_port
|
||||
)
|
||||
|
||||
# 세션 저장
|
||||
tunnel_session = TunnelSession(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
vm_id=vm_id,
|
||||
local_port=local_port,
|
||||
remote_host=remote_host,
|
||||
remote_port=remote_port,
|
||||
connection=conn,
|
||||
listener=listener,
|
||||
created_at=datetime.utcnow(),
|
||||
vm_name=vm_name or "",
|
||||
rdp_username=rdp_username or ""
|
||||
)
|
||||
|
||||
self.active_tunnels[session_id] = tunnel_session
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"local_port": local_port,
|
||||
"remote_host": remote_host,
|
||||
"remote_port": remote_port,
|
||||
"ssh_host": settings.SSH_HOST,
|
||||
"vm_name": vm_name or "",
|
||||
"rdp_username": rdp_username or "",
|
||||
"created_at": tunnel_session.created_at.isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self._release_port(local_port)
|
||||
raise InternalServerError(f"SSH 터널 생성 실패: {str(e)}")
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 터널 종료
|
||||
# ---------------------------------------------------------
|
||||
async def close_tunnel(self, session_id: str) -> bool:
|
||||
tunnel = self.active_tunnels.get(session_id)
|
||||
if not tunnel:
|
||||
return False
|
||||
|
||||
try:
|
||||
if tunnel.listener:
|
||||
tunnel.listener.close()
|
||||
await tunnel.listener.wait_closed()
|
||||
|
||||
if tunnel.connection:
|
||||
tunnel.connection.close()
|
||||
await tunnel.connection.wait_closed()
|
||||
|
||||
self._release_port(tunnel.local_port)
|
||||
|
||||
del self.active_tunnels[session_id]
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"터널 종료 오류: {e}")
|
||||
return False
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 터널 상태 조회
|
||||
# ---------------------------------------------------------
|
||||
async def get_tunnel_status(self, session_id: str) -> Optional[Dict]:
|
||||
tunnel = self.active_tunnels.get(session_id)
|
||||
if not tunnel:
|
||||
return None
|
||||
|
||||
is_active = tunnel.connection is not None and tunnel.listener is not None
|
||||
uptime = (datetime.utcnow() - tunnel.created_at).total_seconds()
|
||||
|
||||
# 항상 null 반환하지 않도록 빈 문자열 처리
|
||||
vm_name = tunnel.vm_name or ""
|
||||
rdp_username = tunnel.rdp_username or ""
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"is_active": is_active,
|
||||
"uptime_seconds": int(uptime),
|
||||
"created_at": tunnel.created_at.isoformat(),
|
||||
"vm_id": tunnel.vm_id,
|
||||
"vm_name": vm_name,
|
||||
"rdp_username": rdp_username,
|
||||
"tunnel_info": {
|
||||
"session_id": session_id,
|
||||
"local_port": tunnel.local_port,
|
||||
"remote_host": tunnel.remote_host,
|
||||
"remote_port": tunnel.remote_port,
|
||||
"vm_id": tunnel.vm_id,
|
||||
"vm_name": vm_name,
|
||||
"rdp_username": rdp_username,
|
||||
"is_active": is_active
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 비활성 터널 정리
|
||||
# ---------------------------------------------------------
|
||||
async def cleanup_inactive_tunnels(self):
|
||||
to_remove = []
|
||||
|
||||
for sid, tunnel in self.active_tunnels.items():
|
||||
if tunnel.connection is None or tunnel.listener is None:
|
||||
to_remove.append(sid)
|
||||
|
||||
for sid in to_remove:
|
||||
await self.close_tunnel(sid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 터널 세션 클래스
|
||||
# ---------------------------------------------------------
|
||||
class TunnelSession:
|
||||
"""터널 세션 정보"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: int,
|
||||
vm_id: int,
|
||||
local_port: int,
|
||||
remote_host: str,
|
||||
remote_port: int,
|
||||
connection,
|
||||
listener,
|
||||
created_at: datetime,
|
||||
vm_name: str = "",
|
||||
rdp_username: str = ""
|
||||
):
|
||||
self.session_id = session_id
|
||||
self.user_id = user_id
|
||||
self.vm_id = vm_id
|
||||
self.local_port = local_port
|
||||
self.remote_host = remote_host
|
||||
self.remote_port = remote_port
|
||||
self.connection = connection
|
||||
self.listener = listener
|
||||
self.created_at = created_at
|
||||
|
||||
# 절대 null 반환 방지
|
||||
self.vm_name = vm_name or ""
|
||||
self.rdp_username = rdp_username or ""
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
ssh_tunnel_manager = SSHTunnelManager()
|
||||
78
app/services/temp_ssh_password_service.py
Normal file
78
app/services/temp_ssh_password_service.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import secrets
|
||||
import hashlib
|
||||
from typing import Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class TempSshPasswordManager:
|
||||
"""임시 SSH 비밀번호 관리"""
|
||||
|
||||
def __init__(self):
|
||||
# 메모리 기반 저장소 (프로덕션에서는 Redis 사용 권장)
|
||||
self._passwords: Dict[str, dict] = {}
|
||||
|
||||
def generate_password(self, username: str, validity_hours: int = 1) -> str:
|
||||
"""
|
||||
임시 SSH 비밀번호 생성
|
||||
|
||||
Args:
|
||||
username: 사용자명
|
||||
validity_hours: 유효 시간 (기본 1시간)
|
||||
|
||||
Returns:
|
||||
임시 비밀번호
|
||||
"""
|
||||
# 안전한 랜덤 비밀번호 생성 (32자)
|
||||
temp_password = secrets.token_urlsafe(32)
|
||||
|
||||
# 해시 저장 (실제 비밀번호는 저장하지 않음)
|
||||
password_hash = hashlib.sha256(temp_password.encode()).hexdigest()
|
||||
|
||||
# 만료 시간 계산
|
||||
expires_at = datetime.utcnow() + timedelta(hours=validity_hours)
|
||||
|
||||
# 저장
|
||||
self._passwords[username] = {
|
||||
"password_hash": password_hash,
|
||||
"expires_at": expires_at,
|
||||
"created_at": datetime.utcnow()
|
||||
}
|
||||
|
||||
return temp_password
|
||||
|
||||
def verify_password(self, username: str, password: str) -> bool:
|
||||
"""
|
||||
비밀번호 검증
|
||||
|
||||
Args:
|
||||
username: 사용자명
|
||||
password: 검증할 비밀번호
|
||||
|
||||
Returns:
|
||||
유효 여부
|
||||
"""
|
||||
if username not in self._passwords:
|
||||
return False
|
||||
|
||||
stored = self._passwords[username]
|
||||
|
||||
# 만료 확인
|
||||
if datetime.utcnow() > stored["expires_at"]:
|
||||
del self._passwords[username]
|
||||
return False
|
||||
|
||||
# 비밀번호 확인
|
||||
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
return password_hash == stored["password_hash"]
|
||||
|
||||
def cleanup_expired(self):
|
||||
"""만료된 비밀번호 정리"""
|
||||
now = datetime.utcnow()
|
||||
expired = [
|
||||
username for username, data in self._passwords.items()
|
||||
if now > data["expires_at"]
|
||||
]
|
||||
for username in expired:
|
||||
del self._passwords[username]
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
temp_ssh_password_manager = TempSshPasswordManager()
|
||||
BIN
app/utils/__pycache__/exceptions.cpython-312.pyc
Normal file
BIN
app/utils/__pycache__/exceptions.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/utils/__pycache__/jwt_handler.cpython-312.pyc
Normal file
BIN
app/utils/__pycache__/jwt_handler.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/utils/__pycache__/security.cpython-312.pyc
Normal file
BIN
app/utils/__pycache__/security.cpython-312.pyc
Normal file
Binary file not shown.
31
app/utils/exceptions.py
Normal file
31
app/utils/exceptions.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
class AuthenticationError(HTTPException):
|
||||
"""인증 오류"""
|
||||
def __init__(self, detail: str = "인증에 실패했습니다"):
|
||||
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
|
||||
|
||||
class PermissionDeniedError(HTTPException):
|
||||
"""권한 부족 오류"""
|
||||
def __init__(self, detail: str = "권한이 없습니다"):
|
||||
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
||||
|
||||
class NotFoundError(HTTPException):
|
||||
"""리소스를 찾을 수 없음"""
|
||||
def __init__(self, detail: str = "리소스를 찾을 수 없습니다"):
|
||||
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
||||
|
||||
class BadRequestError(HTTPException):
|
||||
"""잘못된 요청"""
|
||||
def __init__(self, detail: str = "잘못된 요청입니다"):
|
||||
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||
|
||||
class ConflictError(HTTPException):
|
||||
"""충돌 오류 (중복 등)"""
|
||||
def __init__(self, detail: str = "이미 존재하는 리소스입니다"):
|
||||
super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
||||
|
||||
class InternalServerError(HTTPException):
|
||||
"""서버 내부 오류"""
|
||||
def __init__(self, detail: str = "서버 오류가 발생했습니다"):
|
||||
super().__init__(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail)
|
||||
38
app/utils/jwt_handler.py
Normal file
38
app/utils/jwt_handler.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from app.config import settings
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""JWT Access Token 생성"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""JWT Refresh Token 생성"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""JWT 토큰 디코드"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
def verify_token(token: str) -> bool:
|
||||
"""토큰 유효성 검증"""
|
||||
payload = decode_token(token)
|
||||
return payload is not None
|
||||
12
app/utils/security.py
Normal file
12
app/utils/security.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from passlib.context import CryptContext
|
||||
|
||||
# bcrypt 컨텍스트
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""비밀번호 해시화"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""비밀번호 검증"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
Reference in New Issue
Block a user