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, SystemSettings, ReferralReward from ..models.user import generate_referral_code from ..schemas import UserCreate, UserUpdate, UserResponse, Token from ..schemas.user import PromoPreferenceUpdate, PromoPreferenceResponse 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, User.deleted_at.is_(None) # 삭제되지 않은 사용자만 ).first() if existing: raise HTTPException(status_code=400, detail="Email already registered") # 삭제된 사용자가 있다면 완전히 제거 (동일 이메일 재가입 허용) deleted_user = db.query(User).filter( User.email == user_data.email, User.deleted_at.isnot(None) ).first() if deleted_user: # 관련 데이터 삭제 from ..models import CarView, PerformanceCheckView, ChargeHistory, Inquiry, Notification db.query(CarView).filter(CarView.user_id == deleted_user.id).delete() db.query(PerformanceCheckView).filter(PerformanceCheckView.user_id == deleted_user.id).delete() db.query(ChargeHistory).filter(ChargeHistory.user_id == deleted_user.id).delete() db.query(Inquiry).filter(Inquiry.user_id == deleted_user.id).delete() db.query(Notification).filter(Notification.user_id == deleted_user.id).delete() db.delete(deleted_user) db.commit() # 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) # 레퍼럴 가입 보상 처리 if user.referred_by: # 추천인 찾기 referrer = db.query(User).filter(User.referral_code == user.referred_by).first() if referrer: # 설정에서 보상 CC 가져오기 sys_settings = db.query(SystemSettings).first() reward_cc = sys_settings.referral_signup_reward_cc if sys_settings else 10.0 # 추천인에게 CC 지급 (프로모션 CC - 출금 불가) referrer.cc_balance += reward_cc # 레퍼럴 보상 기록 생성 referral_reward = ReferralReward( referrer_id=referrer.id, referred_user_id=user.id, reward_type="signup", payment_amount=0.0, reward_amount=reward_cc, status="credited", credited_at=datetime.utcnow(), ) db.add(referral_reward) db.commit() 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 user: # 비밀번호 재설정 필요 여부 체크 if getattr(user, 'password_reset_required', False): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Password reset required. Too many failed login attempts.", headers={"WWW-Authenticate": "Bearer"}, ) # 비밀번호 검증 if not verify_password(form_data.password, user.password_hash): # 실패 횟수 증가 failed_attempts = getattr(user, 'failed_login_attempts', 0) or 0 user.failed_login_attempts = failed_attempts + 1 # 20회 이상 실패 시 비밀번호 재설정 필요 if user.failed_login_attempts >= 20: user.password_reset_required = True db.commit() raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Account locked. Password reset required due to too many failed attempts.", headers={"WWW-Authenticate": "Bearer"}, ) db.commit() remaining = 20 - user.failed_login_attempts raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Incorrect email or password. {remaining} attempts remaining before account lock.", headers={"WWW-Authenticate": "Bearer"}, ) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", headers={"WWW-Authenticate": "Bearer"}, ) # 삭제된 사용자 체크 if user.deleted_at: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="This account has been deleted", headers={"WWW-Authenticate": "Bearer"}, ) # 비활성화된 사용자 체크 if not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="This account has been deactivated", headers={"WWW-Authenticate": "Bearer"}, ) # 로그인 성공 - 실패 횟수 초기화 user.failed_login_attempts = 0 db.commit() 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 @router.get("/me/promo-preference", response_model=PromoPreferenceResponse) def get_promo_preference(current_user: User = Depends(get_current_user)): """프로모션 선호 차종 조회""" return current_user @router.put("/me/promo-preference", response_model=PromoPreferenceResponse) def update_promo_preference( promo_update: PromoPreferenceUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """프로모션 선호 차종 설정""" current_user.promo_preferred_maker = promo_update.promo_preferred_maker current_user.promo_preferred_model = promo_update.promo_preferred_model current_user.promo_email_enabled = promo_update.promo_email_enabled 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, "promo_preferred_maker": user.promo_preferred_maker, "promo_preferred_model": user.promo_preferred_model, "promo_email_enabled": user.promo_email_enabled, "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 }