first commit

This commit is contained in:
unknown
2025-12-08 21:35:55 +09:00
commit f343f405f7
5357 changed files with 923703 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

275
app/api/admin.py Normal file
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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"
}

Binary file not shown.

Binary file not shown.

43
app/models/audit_log.py Normal file
View 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
View 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
View 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})>"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

71
app/schemas/admin.py Normal file
View 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
View 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
View 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
View 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

Binary file not shown.

View 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()

View 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()

View 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()

View 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()

Binary file not shown.

Binary file not shown.

Binary file not shown.

31
app/utils/exceptions.py Normal file
View 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
View 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
View 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)