import random import secrets import string from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from google.oauth2 import id_token from google.auth.transport import requests as google_requests from sqlalchemy.orm import Session, selectinload from app.config import settings from app.database import get_db from app.dependencies import get_current_user from app.models.email_verification_token import EmailVerificationToken from app.models.user import User from app.models.user_profile import UserProfile from app.schemas.auth import ( ForgotPasswordRequest, ResetPasswordRequest, SendVerificationRequest, VerifyEmailRequest, ) from app.schemas.user import ( GoogleLoginRequest, LoginRequest, LoginResponse, UserCreate, UserDetailResponse, UserResponse, ) from app.security import create_access_token, hash_password, verify_password from app.services.auth_service import forgot_password, reset_password from app.services.email_service import send_verification_email router = APIRouter(prefix="/auth", tags=["auth"]) def _generate_verification_token() -> str: return secrets.token_urlsafe(48) @router.post("/login", response_model=LoginResponse) def login(data: LoginRequest, db: Session = Depends(get_db)): user = db.query(User).options(selectinload(User.profile)).filter( (User.email == data.login) | (User.username == data.login), User.deleted_at.is_(None), ).first() if not user or not verify_password(data.password, user.password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"message": "Kredensial autentikasi tidak ada atau tidak valid."}, ) if user.email_verified_at is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"message": "Email belum diverifikasi. Silakan verifikasi email terlebih dahulu."}, ) access_token = create_access_token({"sub": user.id}) return LoginResponse( access_token=access_token, user=user, ) @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) def register(data: UserCreate, db: Session = Depends(get_db)): existing = db.query(User).filter( (User.email == data.email) | (User.username == data.username) ).first() if existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"message": "Email atau username sudah terdaftar."}, ) user = User( email=data.email, username=data.username, password=hash_password(data.password), status="INACTIVE", ) db.add(user) db.flush() try: profile_data = { "user_id": user.id, "full_name": data.full_name, } if data.phone is not None: profile_data["phone"] = data.phone if data.address is not None: profile_data["address"] = data.address if data.pob is not None: profile_data["pob"] = data.pob if data.dob is not None: profile_data["dob"] = data.dob profile = UserProfile(**profile_data) db.add(profile) raw_token = _generate_verification_token() hashed_token = hash_password(raw_token) verif_token = EmailVerificationToken( user_id=user.id, token=hashed_token, expires_at=datetime.now(timezone.utc) + timedelta(hours=24), ) db.add(verif_token) db.commit() except: db.rollback() raise verify_url = f"{settings.FRONTEND_URL}/auth/verify-email?token={raw_token}" try: send_verification_email(user.email, verify_url) except Exception: pass db.refresh(user) return user @router.post("/google-login", response_model=LoginResponse) def google_login(data: GoogleLoginRequest, db: Session = Depends(get_db)): try: idinfo = id_token.verify_oauth2_token( data.credential, google_requests.Request(), settings.GOOGLE_CLIENT_ID, ) except Exception: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"message": "Kredensial Google tidak valid atau telah kedaluwarsa."}, ) email = idinfo.get("email") name = idinfo.get("name", "") if not email: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"message": "Tidak dapat mengambil email dari akun Google."}, ) user = db.query(User).options(selectinload(User.profile)).filter( User.email == email, User.deleted_at.is_(None), ).first() if not user: base_username = email.split("@")[0] username = base_username suffix = 1 while db.query(User).filter(User.username == username).first(): suffix += 1 username = f"{base_username}{suffix}" random_password = "".join(random.choices(string.ascii_letters + string.digits, k=32)) user = User( email=email, username=username, password=hash_password(random_password), email_verified_at=datetime.now(timezone.utc), status="ONBOARDING", ) db.add(user) db.flush() profile = UserProfile( user_id=user.id, full_name=name, ) db.add(profile) db.commit() db.refresh(user) else: if user.email_verified_at is None: user.email_verified_at = datetime.now(timezone.utc) if not user.profile: profile = UserProfile( user_id=user.id, full_name=name, ) db.add(profile) elif user.profile.full_name != name: user.profile.full_name = name db.commit() db.refresh(user) access_token = create_access_token({"sub": user.id}) return LoginResponse( access_token=access_token, user=user, ) @router.post("/verify-email") def verify_email(data: VerifyEmailRequest, db: Session = Depends(get_db)): now = datetime.now(timezone.utc) tokens = db.query(EmailVerificationToken).filter( EmailVerificationToken.used_at.is_(None), ).all() matched = None for t in tokens: if verify_password(data.token, t.token): matched = t break if not matched: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"message": "Token tidak valid atau sudah kedaluwarsa."}, ) if matched.expires_at.replace(tzinfo=timezone.utc) < now: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"message": "Token tidak valid atau sudah kedaluwarsa."}, ) matched.used_at = now user = db.query(User).filter(User.id == matched.user_id).first() if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"message": "Token tidak valid atau sudah kedaluwarsa."}, ) user.email_verified_at = now user.status = "ONBOARDING" db.query(EmailVerificationToken).filter( EmailVerificationToken.user_id == user.id, EmailVerificationToken.used_at.is_(None), ).update({"used_at": now}) db.commit() return {"message": "Email berhasil diverifikasi. Silakan masuk ke akun Anda."} @router.post("/send-verification") def send_verification(data: SendVerificationRequest, db: Session = Depends(get_db)): user = db.query(User).filter( User.email == data.email, User.deleted_at.is_(None), ).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"message": "Email tidak ditemukan."}, ) if user.email_verified_at is not None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"message": "Email sudah diverifikasi."}, ) now = datetime.now(timezone.utc) active_token = db.query(EmailVerificationToken).filter( EmailVerificationToken.user_id == user.id, EmailVerificationToken.used_at.is_(None), EmailVerificationToken.expires_at > now, ).first() if active_token: return {"message": "Link verifikasi sudah dikirim ke email Anda dan masih aktif."} db.query(EmailVerificationToken).filter( EmailVerificationToken.user_id == user.id, EmailVerificationToken.used_at.is_(None), ).update({"used_at": now}) raw_token = _generate_verification_token() hashed_token = hash_password(raw_token) verif_token = EmailVerificationToken( user_id=user.id, token=hashed_token, expires_at=now + timedelta(hours=24), ) db.add(verif_token) db.commit() verify_url = f"{settings.FRONTEND_URL}/auth/verify-email?token={raw_token}" try: send_verification_email(user.email, verify_url) except Exception: pass return {"message": "Link verifikasi telah dikirim ke email Anda."} @router.get("/me", response_model=UserDetailResponse) def get_me( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): user = db.query(User).options(selectinload(User.profile)).filter( User.id == current_user.id ).first() return user @router.post("/forgot-password") def forgot_password_endpoint(data: ForgotPasswordRequest, db: Session = Depends(get_db)): return forgot_password(db, data.email) @router.post("/reset-password") def reset_password_endpoint(data: ResetPasswordRequest, db: Session = Depends(get_db)): return reset_password(db, data.token, data.password)