MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Rebuild Python FastAPI + React E-Learning

1

Part 1: Pengenalan & Arsitektur: Dari Monolit ke Arsitektur Asinkron Terpisah

2

Part 2: Async ORM & Type Safety: SQLAlchemy 2.0 Async, Pydantic v2 & Pencegahan Masalah N+1

3

Part 3: Engine Ujian CBT Presisi Tinggi & Telemetri Pengawasan Native WebSocket

4

Part 4: Penguatan Keamanan Enterprise: Stateless JWT, RBAC & 1-Click Impersonation

5

Part 5: Pipeline Data Excel Batch Berkecepatan Tinggi & Pengujian Asinkron Otomatis

6

Part 6: Single VPS Production Deployment: Docker Compose, Nginx SSL & Monitoring Pemulihan Otomatis

Artikel ini tersedia dalam Bahasa Inggris

🇬🇧 Read in English
🇮🇩 Bahasa Indonesia📚 Rebuild Python FastAPI + React E-Learning

Python FastAPI E-Learning Part 4: Penguatan Keamanan Enterprise – Stateless JWT, RBAC & 1-Click Impersonation

Perkuat keamanan backend Python FastAPI kelas enterprise: Role-Based Access Control (RBAC) deklaratif via dependency injection, mitigasi serangan path traversal pada file upload, dan engine 1-Click Administrative Impersonation dengan pemulihan sesi instan tanpa re-login.

Muhammad Fari MadyanPenulis

8 menit baca

·

7 Oktober 2026


Tuntutan Keamanan Tingkat Enterprise

Dalam operasional platform pendidikan sekolah, tim IT support dan administrator sistem sering kali berhadapan dengan tiket kendala pengguna yang spesifik:

  • "Seorang guru melapor tidak bisa melihat kiriman tugas siswa di Kelas 9-B."
  • "Seorang siswa melaporkan bahwa nomor soal ujiannya tiba-tiba membeku di tengah tes."

Pada aplikasi monolitik konvensional seperti Laravel, investigasi kendala pengguna semacam ini biasanya diselesaikan melalui sesi server stateful (Auth::loginUsingId($userId)). Namun, pada Single Page Application (SPA) modern yang decoupled, backend bersifat murni stateless. Bagaimana cara seorang super administrator login seketika (impersonate) sebagai siswa atau guru untuk melihat tampilan layar persis seperti yang dialami pengguna tersebut—tanpa meminta kata sandi mereka, tanpa melanggar kepatuhan audit keamanan, dan sanggup kembali ke akun admin dalam satu klik?

Pada proyek mfarim/fastapi-elearning-react, kami merancang arsitektur keamanan enterprise yang memadukan:

  1. Stateless JWT Tokens Bertanda Tangan Kriptografis: Menggunakan python-jose dan BCrypt.
  2. Role-Based Access Control (RBAC) Deklaratif: Ditegakkan melalui FastAPI Dependency Injection.
  3. Engine 1-Click Impersonation & Session Recovery: Menjaga jejak audit (audit trail) tanpa membutuhkan penyimpanan sesi di server.
  4. Proteksi File Upload & Imunitas Path Traversal: Mengamankan penyimpanan berkas dari serangan manipulasi direktori.
┌────────────────────────────────────────────────────────────────────────────────────────┐ │ ALUR TOKEN IMPERSONATION STATELESS │ └────────────────────────────────────────────────────────────────────────────────────────┘ 1. Admin memanggil POST /api/v1/auth/admin/impersonate/{studentId} dengan Bearer Token valid (ROLE_ADMIN) │ ▼ ┌──────────────────────────────────────────────────────────────────────────────────┐ │ BACKEND FASTAPI: Menerbitkan Token JWT Impersonation Khusus │ │ Klaim Token: { │ │ "sub": "482", <-- ID Siswa Target │ │ "roles": ["ROLE_STUDENT"], <-- Hak Akses Siswa │ │ "is_impersonating": true, <-- Flag Penanda Banner Frontend │ │ "original_admin_id": "1", <-- ID Administrator Asli untuk Audit │ │ "exp": 1791550000 <-- Masa Berlaku Singkat (misal: 60 menit) │ │ } │ └───────────────────────┬──────────────────────────────────────────────────────────┘ │ ▼ Kembalikan Token ke Klien React ┌──────────────────────────────────────────────────────────────────────────────────┐ │ KLIEN REACT (Zustand Auth Store): │ │ • Beralih ke sudut pandang Siswa Target │ │ • Menampilkan Sticky Warning Banner di bagian atas layar: │ │ • "Anda sedang melihat sebagai Siswa Budi. [Kembali ke Akun Admin]" │ └───────────────────────┬──────────────────────────────────────────────────────────┘ │ 2. Admin selesai melakukan investigasi & menekan tombol [Kembali ke Akun Admin] Memanggil POST /api/v1/auth/admin/stop-impersonate │ ▼ ┌──────────────────────────────────────────────────────────────────────────────────┐ │ BACKEND FASTAPI: Pemulihan Sesi Admin │ │ • Ekstraksi klaim "original_admin_id": 1 dari JWT │ │ • Terbitkan kembali Token Administrator bersih untuk Admin User #1 │ │ • TANPA perlu input password ulang, Nol penyimpanan sesi stateful di server │ └──────────────────────────────────────────────────────────────────────────────────┘

1. Pembuatan & Verifikasi Token Kriptografis

Di backend/app/core/security.py, hashing kata sandi dan pembuatan token JWT menerapkan standar industri yang teruji (passlib dengan BCrypt dan python-jose dengan algoritma HMAC-SHA256):

PYTHON

# backend/app/core/security.py
from datetime import datetime, timedelta
from typing import Optional, Any, Union, List
from jose import jwt, JWTError
from passlib.context import CryptContext
from app.core.config import settings

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password: str) -> str:
    return pwd_context.hash(password)

def create_access_token(
    subject: Union[str, Any],
    roles: List[str],
    extra_claims: Optional[dict] = None,
    expires_delta: Optional[timedelta] = None
) -> str:
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
    
    to_encode = {
        "sub": str(subject),
        "roles": roles,
        "exp": expire,
        "iat": datetime.utcnow()
    }
    if extra_claims:
        to_encode.update(extra_claims)
        
    return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)

def decode_access_token(token: str) -> Optional[dict]:
    try:
        return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
    except JWTError:
        return None

2. RBAC Deklaratif via Dependency Injection

Banyak pengembang menulis logika otorisasi manual yang tersebar di dalam badan fungsi handler (if "ROLE_ADMIN" not in user.roles:), yang rentan terlewat saat penambahan fitur baru.

Di backend/app/api/deps.py, kami mengimplementasikan fungsi tingkat tinggi (higher-order dependency) yang bersih:

PYTHON

# backend/app/api/deps.py
from typing import List, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload

from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)

async def get_current_user(
    token: Optional[str] = Depends(oauth2_scheme),
    db: AsyncSession = Depends(get_db)
) -> User:
    if not token:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Autentikasi dibutuhkan",
            headers={"WWW-Authenticate": "Bearer"},
        )
    
    payload = decode_access_token(token)
    if not payload or not payload.get("sub"):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token tidak valid atau telah kedaluwarsa",
            headers={"WWW-Authenticate": "Bearer"},
        )

    user_id = int(payload.get("sub"))

    # Ambil role dan profil pengguna dalam 1 query terindeks
    stmt = (
        select(User)
        .options(
            selectinload(User.roles),
            selectinload(User.teacher_profile),
            selectinload(User.student_profile)
        )
        .where(User.id == user_id, User.deleted_at.is_(None))
    )
    user = (await db.execute(stmt)).scalars().first()

    if not user or not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Pengguna tidak ditemukan atau akun dinonaktifkan"
        )

    # Sematkan klaim impersonation secara dinamis pada entitas User
    setattr(user, "is_impersonating", payload.get("is_impersonating", False))
    setattr(user, "original_admin_id", payload.get("original_admin_id", None))

    return user

def require_roles(allowed_roles: List[str]):
    """Higher-order dependency untuk menegakkan izin hak akses peran (RBAC)"""
    def role_checker(current_user: User = Depends(get_current_user)) -> User:
        user_roles = [r.name for r in current_user.roles]
        if not any(role in user_roles for role in allowed_roles):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Akses ditolak. Memerlukan salah satu peran: {', '.join(allowed_roles)}",
            )
        return current_user
    return role_checker

Dengan pendekatan ini, setiap endpoint dapat mendeklarasikan izin hak aksesnya secara elegan langsung pada deklarasi rute:

PYTHON

@router.delete("/subjects/{id}")
async def delete_subject(
    id: int,
    db: AsyncSession = Depends(get_db),
    admin: User = Depends(require_roles(["ROLE_ADMIN"])),  # Hanya Super Admin yang berhak
):
    ...

3. Rekayasa Engine 1-Click Administrative Impersonation

Pada berkas backend/app/api/v1/auth.py, endpoint impersonasi memungkinkan administrator beralih identitas secara instan:

PYTHON

# backend/app/api/v1/auth.py
@router.post("/admin/impersonate/{user_id}", response_model=ApiResponse[AuthResponse])
async def impersonate_user(
    user_id: int,
    db: AsyncSession = Depends(get_db),
    admin_user: User = Depends(require_roles(["ROLE_ADMIN"]))
):
    # Cegah impersonasi diri sendiri
    if user_id == admin_user.id:
        raise HTTPException(status_code=400, detail="Tidak dapat meng-impersonate diri sendiri")

    # Ambil data pengguna target
    stmt = (
        select(User)
        .options(
            selectinload(User.roles),
            selectinload(User.teacher_profile),
            selectinload(User.student_profile)
        )
        .where(User.id == user_id, User.deleted_at.is_(None))
    )
    target_user = (await db.execute(stmt)).scalars().first()
    if not target_user:
        raise HTTPException(status_code=404, detail="Pengguna target tidak ditemukan")

    target_roles = [r.name for r in target_user.roles]

    # Terbitkan token stateless khusus dengan audit metadata
    token = create_access_token(
        subject=target_user.id,
        roles=target_roles,
        extra_claims={
            "is_impersonating": True,
            "original_admin_id": admin_user.id,
        },
        expires_delta=timedelta(hours=2)  # Sesi impersonasi otomatis kedaluwarsa dalam 2 jam
    )

    auth_data = build_auth_response(target_user, token, admin_user.id)
    return ApiResponse.ok(
        data=auth_data,
        message=f"Beralih identitas ke {target_user.name} ({target_roles[0]})"
    )

Pemulihan Sesi Instan Tanpa Re-Login (stop-impersonate)

Setelah administrator selesai melakukan diagnosa masalah pada akun target, tombol "Kembali ke Akun Admin" di frontend React akan memanggil:

PYTHON

@router.post("/admin/stop-impersonate", response_model=ApiResponse[AuthResponse])
async def stop_impersonate(
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    # Validasi bahwa token yang aktif memang sesi impersonasi
    if not getattr(current_user, "is_impersonating", False):
        raise HTTPException(status_code=400, detail="Saat ini tidak dalam sesi impersonasi")

    original_admin_id = getattr(current_user, "original_admin_id", None)
    if not original_admin_id:
        raise HTTPException(status_code=400, detail="Identitas administrator asli tidak ditemukan")

    # Ambil kembali data administrator asli
    stmt = (
        select(User)
        .options(selectinload(User.roles))
        .where(User.id == int(original_admin_id), User.deleted_at.is_(None))
    )
    admin_user = (await db.execute(stmt)).scalars().first()
    if not admin_user or "ROLE_ADMIN" not in [r.name for r in admin_user.roles]:
        raise HTTPException(status_code=403, detail="Akun administrator asli sudah tidak valid")

    # Terbitkan kembali token administrator bersih
    admin_token = create_access_token(
        subject=admin_user.id,
        roles=[r.name for r in admin_user.roles]
    )

    return ApiResponse.ok(
        data=build_auth_response(admin_user, admin_token),
        message="Sesi administrator berhasil dipulihkan"
    )

4. Keamanan Unggah Berkas & Mitigasi Serangan Path Traversal

Endpoint unggah berkas (mengunggah PDF materi belajar, foto soal CBT, atau file spreadsheet Excel) merupakan vektor serangan berbahaya jika nama berkas diproses secara naif.

Celah Bahaya Path Traversal

Jika peretas mengunggah file dengan nama ../../../../etc/passwd atau ../../app/main.py, kode naif seperti:

PYTHON

with open(f"/var/www/uploads/{file.filename}", "wb") as f:  # RENTAN SERANGAN!

akan keluar dari direktori upload dan menimpa file konfigurasi sistem operasi.

Mitigasi Komprehensif (backend/app/common/storage.py)

Di backend/app/common/storage.py, kami menerapkan sanitasi nama berkas, prefix acak UUID, dan validasi MIME-type ketat:

PYTHON

# backend/app/common/storage.py
import os
import uuid
import re
from pathlib import Path
from fastapi import UploadFile, HTTPException

ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".pdf", ".docx", ".xlsx", ".mp3"}
ALLOWED_MIME_TYPES = {
    "image/jpeg", "image/png", "application/pdf",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    "audio/mpeg",
}
MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024  # Batas maksimal 25 MB

class StorageService:
    def __init__(self, base_dir: str = "storage/uploads"):
        self.base_dir = Path(base_dir).resolve()
        self.base_dir.mkdir(parents=True, exist_ok=True)

    def sanitize_filename(self, filename: str) -> str:
        # Bersihkan karakter manipulasi path dan karakter liar
        clean_name = os.path.basename(filename)
        clean_name = re.sub(r"[^a-zA-Z0-9_.-]", "_", clean_name)
        # Tambahkan prefix UUID untuk mencegah tabrakan nama dan tebakan nama berkas
        return f"{uuid.uuid4().hex}_{clean_name}"

    async def save_file(self, upload_file: UploadFile) -> str:
        # 1. Validasi ekstensi berkas
        ext = Path(upload_file.filename).suffix.lower()
        if ext not in ALLOWED_EXTENSIONS:
            raise HTTPException(status_code=400, detail=f"Ekstensi berkas {ext} tidak diizinkan")

        # 2. Validasi MIME type
        if upload_file.content_type not in ALLOWED_MIME_TYPES:
            raise HTTPException(status_code=400, detail=f"Tipe konten {upload_file.content_type} tidak diizinkan")

        # 3. Baca berkas dengan proteksi ukuran
        content = await upload_file.read()
        if len(content) > MAX_FILE_SIZE_BYTES:
            raise HTTPException(status_code=400, detail="Ukuran berkas melebihi batas 25MB")

        # 4. Batasi penyimpanan mutlak hanya di dalam base_dir
        safe_name = self.sanitize_filename(upload_file.filename)
        destination = (self.base_dir / safe_name).resolve()

        # Proteksi Path Traversal: Pastikan path tujuan tidak keluar dari base_dir
        if not str(destination).startswith(str(self.base_dir)):
            raise HTTPException(status_code=400, detail="Percobaan path traversal terdeteksi")

        with open(destination, "wb") as f:
            f.write(content)

        return safe_name

storage_service = StorageService()

Ringkasan Rekomendasi Arsitektural

  1. Jaga mekanisme impersonasi tetap stateless dan teraudit: Sisipkan original_admin_id langsung pada klaim JWT daripada mengelola status di server, menghasilkan performa tinggi dan jejak audit yang akurat.
  2. Sentralisasi izin peran dalam dependency deklaratif: Menggunakan higher-order function require_roles(["ROLE_ADMIN"]) mengeliminasi risiko kelalaian pengecekan otorisasi pada setiap endpoint.
  3. Wajib lakukan sanitasi path pada penyimpanan berkas: Padukan os.path.basename, UUID prefix, dan verifikasi destination.resolve().startswith(base_dir) untuk memproteksi server dari serangan path traversal.

Pada Part 5, kita akan membahas pengolahan data berskala besar dan rekayasa kualitas kode: Pipeline Streaming Data Excel Berkecepatan Tinggi dengan openpyxl, serta Pengujian Asinkron Otomatis Menggunakan Pytest dan GitHub Actions CI/CD.

Lanjut Membaca

Previous article thumbnail

← Artikel Sebelumnya

Python FastAPI E-Learning Part 3: High-Precision CBT Exam Engine & Native WebSocket Proctoring

Artikel Selanjutnya →

Python FastAPI E-Learning Part 5: High-Throughput Excel Pipelines & Async Automated Testing with Pytest

Next article thumbnail