Files
AutonetSellCar/backend/app/api/auth.py
AutonetSellCar Deploy 1f0dcb1ddb Initial commit: AutonetSellCar platform with deployment system
- Frontend: Next.js 14 with TypeScript
- Backend: FastAPI with SQLAlchemy
- Agent: Carmodoo sync agent
- Deployment: Docker Compose based staging/production setup
- Scripts: Automated deployment with rollback support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 13:24:39 +09:00

547 lines
17 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from jose import JWTError, jwt
from pydantic import BaseModel
from typing import Optional
import bcrypt
from ..database import get_db
from ..models import User
from ..models.user import generate_referral_code
from ..schemas import UserCreate, UserUpdate, UserResponse, Token
from ..config import get_settings
router = APIRouter(prefix="/auth", tags=["auth"])
settings = get_settings()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.email == email).first()
if user is None:
raise credentials_exception
return user
# Optional 인증 scheme
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def get_current_user_optional(
token: str = Depends(oauth2_scheme_optional),
db: Session = Depends(get_db)
) -> User | None:
"""선택적 인증 - 토큰이 없거나 유효하지 않아도 None 반환"""
if not token:
return None
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
email: str = payload.get("sub")
if email is None:
return None
except JWTError:
return None
user = db.query(User).filter(User.email == email).first()
return user
def get_current_admin_user(
current_user: User = Depends(get_current_user)
) -> User:
"""관리자 권한 확인"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
return current_user
@router.post("/register", response_model=UserResponse)
def register(user_data: UserCreate, db: Session = Depends(get_db)):
"""회원가입"""
from ..models.user import VerificationCode
from datetime import datetime
existing = db.query(User).filter(User.email == user_data.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
# Check if email was verified (pre-registration verification)
email_verified = False
verification = db.query(VerificationCode).filter(
VerificationCode.email == user_data.email,
VerificationCode.code_type == "email",
VerificationCode.verified_at.isnot(None)
).order_by(VerificationCode.verified_at.desc()).first()
if verification:
email_verified = True
# Generate unique referral code
referral_code = generate_referral_code()
while db.query(User).filter(User.referral_code == referral_code).first():
referral_code = generate_referral_code()
user = User(
email=user_data.email,
password_hash=get_password_hash(user_data.password),
name=user_data.name,
phone=user_data.phone,
country=user_data.country,
referral_code=referral_code,
referred_by=getattr(user_data, 'referred_by', None),
email_verified=email_verified,
email_verified_at=datetime.utcnow() if email_verified else None,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login", response_model=Token)
def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""로그인"""
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": user.email})
return Token(access_token=access_token)
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)):
"""현재 사용자 정보"""
return current_user
@router.put("/me", response_model=UserResponse)
def update_me(
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""사용자 정보 수정"""
if user_update.name is not None:
current_user.name = user_update.name
if user_update.phone is not None:
current_user.phone = user_update.phone
if user_update.country is not None:
current_user.country = user_update.country
db.commit()
db.refresh(current_user)
return current_user
# Admin User Management Endpoints
@router.get("/admin/users")
def admin_get_users(
page: int = 1,
page_size: int = 20,
search: str = None,
is_dealer: bool = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""모든 사용자 목록 조회 (관리자) - 삭제된 사용자 제외"""
query = db.query(User).filter(
User.is_admin == False,
User.deleted_at.is_(None) # 삭제되지 않은 사용자만
)
if search:
query = query.filter(
(User.email.ilike(f"%{search}%")) |
(User.name.ilike(f"%{search}%")) |
(User.phone.ilike(f"%{search}%"))
)
if is_dealer is not None:
query = query.filter(User.is_dealer == is_dealer)
total = query.count()
users = query.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"users": [
{
"id": user.id,
"email": user.email,
"name": user.name,
"phone": user.phone,
"country": user.country,
"cc_balance": user.cc_balance,
"is_dealer": user.is_dealer,
"referral_code": user.referral_code,
"referred_by": user.referred_by,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
for user in users
],
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/admin/users/{user_id}")
def admin_get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""특정 사용자 상세 정보 (관리자)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"id": user.id,
"email": user.email,
"name": user.name,
"phone": user.phone,
"country": user.country,
"cc_balance": user.cc_balance,
"is_admin": user.is_admin,
"is_dealer": user.is_dealer,
"referral_code": user.referral_code,
"referred_by": user.referred_by,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
@router.put("/admin/users/{user_id}")
def admin_update_user(
user_id: int,
user_update: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""사용자 정보 수정 (관리자)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_update.name is not None:
user.name = user_update.name
if user_update.phone is not None:
user.phone = user_update.phone
if user_update.country is not None:
user.country = user_update.country
db.commit()
db.refresh(user)
return {
"id": user.id,
"email": user.email,
"name": user.name,
"phone": user.phone,
"country": user.country,
"cc_balance": user.cc_balance,
"is_dealer": user.is_dealer,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
@router.put("/admin/users/{user_id}/cc")
def admin_adjust_cc(
user_id: int,
amount: float,
reason: str = "Admin adjustment",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""사용자 CC 잔액 조정 (관리자)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.cc_balance = user.cc_balance + amount
if user.cc_balance < 0:
user.cc_balance = 0
db.commit()
db.refresh(user)
return {
"message": f"CC balance adjusted by {amount}",
"new_balance": user.cc_balance
}
# ===== 사용자 탈퇴 =====
class WithdrawalRequest(BaseModel):
"""탈퇴 요청"""
reason: Optional[str] = None
password: str # 본인 확인용
@router.post("/withdraw")
def request_withdrawal(
request: WithdrawalRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""사용자 탈퇴 요청"""
# 비밀번호 확인
if not verify_password(request.password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Incorrect password")
# 이미 탈퇴 요청한 경우
if current_user.withdrawal_requested_at:
raise HTTPException(status_code=400, detail="Withdrawal already requested")
# 관리자는 탈퇴 불가
if current_user.is_admin:
raise HTTPException(status_code=400, detail="Admin cannot withdraw")
# 탈퇴 요청 기록
current_user.withdrawal_requested_at = datetime.utcnow()
current_user.withdrawal_reason = request.reason
current_user.is_active = False # 계정 비활성화
db.commit()
return {
"message": "Withdrawal request submitted. Your account has been deactivated.",
"withdrawal_requested_at": current_user.withdrawal_requested_at.isoformat()
}
@router.post("/withdraw/cancel")
def cancel_withdrawal(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""탈퇴 요청 취소 (아직 삭제되지 않은 경우)"""
if not current_user.withdrawal_requested_at:
raise HTTPException(status_code=400, detail="No withdrawal request found")
if current_user.deleted_at:
raise HTTPException(status_code=400, detail="Account already deleted")
# 탈퇴 요청 취소
current_user.withdrawal_requested_at = None
current_user.withdrawal_reason = None
current_user.is_active = True
db.commit()
return {"message": "Withdrawal request cancelled. Your account is active again."}
# ===== 관리자 사용자 삭제 =====
@router.delete("/admin/users/{user_id}")
def admin_delete_user(
user_id: int,
hard_delete: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""사용자 삭제 (관리자 전용)
- hard_delete=False: 소프트 삭제 (deleted_at 설정)
- hard_delete=True: 완전 삭제 (DB에서 제거)
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# 관리자는 삭제 불가
if user.is_admin:
raise HTTPException(status_code=400, detail="Cannot delete admin user")
# 자기 자신은 삭제 불가
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
user_email = user.email
if hard_delete:
# 완전 삭제 - 관련 데이터도 함께 삭제
from ..models import CarView, PerformanceCheckView, ChargeHistory, Inquiry, Notification
db.query(CarView).filter(CarView.user_id == user_id).delete()
db.query(PerformanceCheckView).filter(PerformanceCheckView.user_id == user_id).delete()
db.query(ChargeHistory).filter(ChargeHistory.user_id == user_id).delete()
db.query(Inquiry).filter(Inquiry.user_id == user_id).delete()
db.query(Notification).filter(Notification.user_id == user_id).delete()
db.delete(user)
db.commit()
return {
"message": f"User {user_email} permanently deleted",
"deleted_user_id": user_id,
"hard_delete": True
}
else:
# 소프트 삭제
user.deleted_at = datetime.utcnow()
user.is_active = False
db.commit()
return {
"message": f"User {user_email} soft deleted",
"deleted_user_id": user_id,
"deleted_at": user.deleted_at.isoformat(),
"hard_delete": False
}
@router.get("/admin/users/withdrawn")
def admin_get_withdrawn_users(
page: int = 1,
page_size: int = 20,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""탈퇴 요청한 사용자 목록 (관리자)"""
query = db.query(User).filter(
User.withdrawal_requested_at.isnot(None),
User.deleted_at.is_(None) # 아직 삭제되지 않은 사용자만
)
total = query.count()
users = query.order_by(User.withdrawal_requested_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"users": [
{
"id": user.id,
"email": user.email,
"name": user.name,
"phone": user.phone,
"country": user.country,
"cc_balance": user.cc_balance,
"is_dealer": user.is_dealer,
"withdrawal_requested_at": user.withdrawal_requested_at.isoformat() if user.withdrawal_requested_at else None,
"withdrawal_reason": user.withdrawal_reason,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
for user in users
],
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/admin/users/deleted")
def admin_get_deleted_users(
page: int = 1,
page_size: int = 20,
search: str = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""삭제된 사용자 목록 (관리자)"""
query = db.query(User).filter(
User.deleted_at.isnot(None) # 삭제된 사용자만
)
if search:
query = query.filter(
(User.email.ilike(f"%{search}%")) |
(User.name.ilike(f"%{search}%")) |
(User.phone.ilike(f"%{search}%"))
)
total = query.count()
users = query.order_by(User.deleted_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"users": [
{
"id": user.id,
"email": user.email,
"name": user.name,
"phone": user.phone,
"country": user.country,
"cc_balance": user.cc_balance,
"is_dealer": user.is_dealer,
"referral_code": user.referral_code,
"referred_by": user.referred_by,
"deleted_at": user.deleted_at.isoformat() if user.deleted_at else None,
"withdrawal_reason": user.withdrawal_reason,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
for user in users
],
"total": total,
"page": page,
"page_size": page_size
}
@router.post("/admin/users/{user_id}/restore")
def admin_restore_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""삭제된 사용자 복원 (관리자)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.deleted_at:
raise HTTPException(status_code=400, detail="User is not deleted")
user.deleted_at = None
user.is_active = True
user.withdrawal_requested_at = None
user.withdrawal_reason = None
db.commit()
return {
"message": f"User {user.email} restored successfully",
"user_id": user_id
}