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:
- Stateless JWT Tokens Bertanda Tangan Kriptografis: Menggunakan
python-josedan BCrypt. - Role-Based Access Control (RBAC) Deklaratif: Ditegakkan melalui FastAPI Dependency Injection.
- Engine 1-Click Impersonation & Session Recovery: Menjaga jejak audit (audit trail) tanpa membutuhkan penyimpanan sesi di server.
- Proteksi File Upload & Imunitas Path Traversal: Mengamankan penyimpanan berkas dari serangan manipulasi direktori.
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
- Jaga mekanisme impersonasi tetap stateless dan teraudit: Sisipkan
original_admin_idlangsung pada klaim JWT daripada mengelola status di server, menghasilkan performa tinggi dan jejak audit yang akurat. - Sentralisasi izin peran dalam dependency deklaratif: Menggunakan higher-order function
require_roles(["ROLE_ADMIN"])mengeliminasi risiko kelalaian pengecekan otorisasi pada setiap endpoint. - Wajib lakukan sanitasi path pada penyimpanan berkas: Padukan
os.path.basename, UUID prefix, dan verifikasidestination.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.
