Kita mulai menutup ancaman pertama dari Bagian 2: Broken Authentication. Target artikel ini: task-tracker-api punya endpoint register dan login yang benar — password tidak pernah disimpan mentah, dan setelah login, user mendapat token yang membuktikan identitasnya di request-request berikutnya.
Kenapa Tidak Boleh Simpan Password Mentah (atau Di-hash Lemah)
Kalau database bocor (dan itu terjadi lebih sering dari yang dibayangkan), password yang tersimpan plaintext langsung bisa dipakai penyerang — bukan cuma untuk aplikasi kita, tapi kemungkinan besar juga aplikasi LAIN, karena banyak orang memakai ulang password yang sama.
Solusinya: hashing — fungsi satu arah yang mengubah password jadi string acak yang (secara praktis) tidak bisa dibalik ke bentuk aslinya. Tapi tidak semua algoritma hash cocok untuk password. MD5 dan SHA1, misalnya, dirancang untuk CEPAT — sifat yang justru berbahaya untuk password, karena memudahkan penyerang mencoba jutaan kombinasi per detik (brute force). Kita pakai bcrypt, yang sengaja dirancang LAMBAT dan punya salt otomatis (nilai acak unik per password, supaya dua user dengan password sama tetap menghasilkan hash yang berbeda).
Hashing dengan bcrypt
import bcrypt
def hash_password(plain_password: str) -> str:
password_bytes = plain_password.encode("utf-8")
hashed = bcrypt.hashpw(password_bytes, bcrypt.gensalt())
return hashed.decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
bcrypt.gensalt() membuat salt acak baru setiap kali dipanggil — makanya hash password yang SAMA akan berbeda-beda tiap kali di-hash ulang, tapi checkpw tetap bisa memverifikasinya karena salt-nya ikut tersimpan di dalam hash itu sendiri.
Model User kita cuma menyimpan hash-nya, tidak pernah password asli:
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(unique=True, index=True, max_length=50)
hashed_password: str
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
Coba langsung dari database sungguhan setelah register:
SELECT username, hashed_password FROM "user";
username | hashed_password
----------+--------------------------------------------------------------
alice | $2b$12$xX0UP187jMo/9kgHvBaD9eLaBR7fvduzuFUXo2jCUrl.Kitgr7KIi
Prefix $2b$12$ menunjukkan algoritma bcrypt dengan cost factor 12 — angka ini menentukan seberapa banyak putaran komputasi yang dilakukan, semakin tinggi semakin lambat (dan semakin sulit di-brute-force), tapi juga semakin lambat untuk login sah. 12 adalah nilai default yang seimbang untuk kebanyakan aplikasi.
Endpoint Register
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def register(payload: UserCreate, session: Session = Depends(get_session)):
existing = session.exec(select(User).where(User.username == payload.username)).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username sudah dipakai")
user = User(username=payload.username, hashed_password=hash_password(payload.password))
session.add(user)
session.commit()
session.refresh(user)
return user
Perhatikan response_model=UserRead, bukan User langsung — persis prinsip Excessive Data Exposure dari Bagian 2. UserRead cuma punya id, username, created_at — hashed_password TIDAK PERNAH ikut di response, walaupun objek user yang di-return dari database punya field itu. FastAPI otomatis memfilternya berdasarkan skema.
JSON Web Token (JWT) — Bukti Identitas yang Bisa Diverifikasi Tanpa Nyimpan Session
Setelah login berhasil, server perlu cara memberi tahu client "kamu sudah terbukti sebagai user X" — dan client perlu cara membuktikan itu di setiap request berikutnya, tanpa harus login ulang tiap kali. JWT adalah token yang berisi data (disebut claims, misalnya "username: alice"), ditandatangani secara digital oleh server memakai secret_key.
Strukturnya tiga bagian dipisah titik: header.payload.signature. Bagian yang penting untuk dipahami: signature dibuat dari kombinasi isi token dan secret_key yang cuma diketahui server. Kalau ada yang mencoba mengubah isi payload (misalnya ganti "sub": "alice" jadi "sub": "admin"), signature-nya jadi tidak cocok lagi, dan server akan menolaknya — TANPA perlu menyimpan daftar token yang valid di database atau memory.
import jwt
from datetime import datetime, timedelta, timezone
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> Optional[str]:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])
except jwt.PyJWTError:
return None
return payload.get("sub")
Dua detail penting:
"exp" (expiration) — token otomatis dianggap tidak valid setelah waktu tertentu (kita set 30 menit). Ini membatasi kerusakan kalau suatu saat token bocor — beda dengan password yang valid selamanya sampai diganti manual.algorithms=[settings.jwt_algorithm] saat decode, bukan dibiarkan terbuka — ini mencegah celah keamanan JWT yang cukup terkenal, di mana penyerang mencoba memaksa server menerima token dengan algoritma lain yang lebih lemah atau bahkan "none".Endpoint Login
@router.post("/login", response_model=Token, dependencies=[Depends(rate_limit_login)])
def login(
form_data: OAuth2PasswordRequestForm = Depends(),
session: Session = Depends(get_session),
):
user = session.exec(select(User).where(User.username == form_data.username)).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Username atau password salah",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(subject=user.username)
return Token(access_token=access_token)
Detail keamanan yang gampang terlewat: pesan error "Username atau password salah" SAMA PERSIS baik untuk "username tidak ditemukan" maupun "password salah". Kalau pesannya beda ("User tidak ditemukan" vs "Password salah"), penyerang bisa memakai API ini untuk menebak username mana saja yang terdaftar (disebut username enumeration) — cukup coba banyak username dan lihat pesan errornya beda atau tidak. dependencies=[Depends(rate_limit_login)] dibahas tuntas di Bagian 4.
Mencoba Alurnya
curl -X POST localhost:8000/auth/register -H "Content-Type: application/json" \
-d '{"username":"alice","password":"supersecret123"}'
# {"id":1,"username":"alice","created_at":"..."}
curl -X POST localhost:8000/auth/login -d "username=alice&password=supersecret123"
# {"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type":"bearer"}
Coba decode payload token itu (bagian tengah, antara dua titik) lewat jwt.io atau python -c "import base64; print(base64.b64decode('...'))" — kamu akan lihat isinya cuma {"sub": "alice", "exp": 1783693476}, tidak ada password atau data sensitif apa pun. JWT itu bisa dibaca siapa saja (cuma tidak bisa dipalsukan) — jangan pernah taruh data rahasia di payload-nya.
Rangkuman
response_model eksplisit supaya hashed_password tidak pernah ter-expose ke response.secret_key, tanpa server perlu menyimpan session.