Neden önemli?
Pusula A.Ş.'nin İK portalını düşün. Çalışan kendi izinlerini görür ve yeni izin ister; yönetici yalnız kendi ekibinin izinlerini onaylar; İK uzmanı herkesi görür ama maaş ekranına giremez; bordro servisi gece çalışıp izin günlerini çeker. Bir kullanıcının kim olduğunu kanıtlamak ile neye dokunabileceğine karar vermek ayrı işlerdir ve ikisinden birindeki tek bir açık, 2.199 çalışanın sağlık izni kayıtlarını herkese açar. OWASP listesinde "Bozuk erişim kontrolü" (Broken Access Control) yıllardır ilk sırada; bu dersi bu yüzden ayrı tuttuk.
Web güvenliği dersinde parolayı tuzlu PBKDF2 ile saklamayı, HMAC ile imzalı token üretmeyi ve hmac.compare_digest ile sabit zamanlı karşılaştırmayı gördün. Burada o temelin üstüne gerçek bir kimlik sistemi kuruyoruz: oturum ve token seçimi, JWT'nin doğru doğrulanması, access/refresh döngüsü, OAuth2 akışları, rol ve öznitelik tabanlı yetki, nesne düzeyinde yetki (IDOR) ve parola sıfırlama.
| Kavram | Sorduğu soru | Başarısızsa HTTP kodu | Pusula İK örneği |
|---|---|---|---|
| Kimlik doğrulama (authentication, authN) | Sen kimsin? | 401 Unauthorized | Token yok, süresi dolmuş ya da imzası bozuk |
| Yetkilendirme (authorization, authZ) | Bunu yapmaya iznin var mı? | 403 Forbidden (ya da 404) | Çalışan, başkasının izin kaydını onaylamaya çalışıyor |
| Hesap verebilirlik (audit) | Kim, ne zaman, ne yaptı? | - | "Rıza Karabulut 19 numaralı izni onayladı" kaydı |
Oturum çerezi mi, token mı?
Klasik yöntemde sunucu giriş başarılı olunca rastgele bir oturum kimliği (session id) üretir, bunu sunucu tarafında (veritabanı ya da Redis) saklar ve tarayıcıya çerez olarak verir. Token yönteminde ise sunucu kullanıcının bilgilerini (claim) imzalayıp verir; sonraki isteklerde imzayı doğrulamak yeter, depoya bakmak gerekmez.
| Özellik | Sunucu oturumu + çerez | İmzalı token (JWT) |
|---|---|---|
| Sunucuda durum | Var (oturum deposu) | Yok ya da az (iptal listesi) |
| Anında iptal | Kolay: kaydı sil | Zor: süre dolana kadar geçerli; iptal listesi gerekir |
| Ölçekleme | Ortak depo ister | Her servis anahtarla doğrular |
| Uygun olduğu yer | Tarayıcıdaki klasik web uygulaması | Mobil, servisler arası çağrı, birden çok API |
| Başlıca risk | CSRF (SameSite ve CSRF token ile kapanır) | XSS ile çalınma (localStorage), uzun ömür |
Tarayıcıda hangi yöntemi seçersen seç, kimlik taşıyan çerez HttpOnly (JavaScript okuyamaz), Secure (yalnız HTTPS) ve SameSite=Lax ya da Strict bayraklarıyla verilmelidir. Tek sayfalı uygulamalarda yaygın ve güvenli desen: kısa ömürlü access token bellekte, refresh token HttpOnly çerezde.
Giriş akışı ve deneme sınırlama
Parolanın nasıl saklandığını web güvenliği dersinde gördün. Giriş uç noktasının iki ek görevi var: kullanıcı numaralandırmayı (user enumeration) önlemek ve kaba kuvvet (brute force) denemelerini yavaşlatmak. "Böyle bir kullanıcı yok" ile "parola yanlış" mesajları farklıysa saldırgan geçerli e-postaları toplar; bu yüzden tek bir mesaj kullan. Deneme sınırlaması için her hesap (ve ayrıca her IP) için kayan bir pencerede başarısız denemeleri say. Saati dışarıdan vermek, kodu test edilebilir yapar.
from collections import defaultdict, deque
class GirisKoruyucu:
def __init__(self, limit=3, pencere=300):
self.limit, self.pencere = limit, pencere
self.hatalar = defaultdict(deque) # hesap -> başarısız deneme zamanları
def izin_var_mi(self, hesap, simdi):
q = self.hatalar[hesap]
while q and q[0] <= simdi - self.pencere: # pencereden çıkanları at
q.popleft()
return len(q) < self.limit
def basarisiz(self, hesap, simdi):
self.hatalar[hesap].append(simdi)
k = GirisKoruyucu()
for t in (0, 10, 20, 30, 400):
izin = k.izin_var_mi("cem@pusula.com", t)
print(t, "deneyebilir" if izin else "429 Too Many Requests")
if izin:
k.basarisiz("cem@pusula.com", t)
Üçüncü başarısız denemeden sonra pencere dolana kadar istek reddedilir; 400. saniyede eski kayıtlar pencereden düştüğü için yeniden denenebilir. Gerçek sistemde sayaç Redis'te tutulur (bunu Redis ve kuyruk dersinde göreceksin) ve kilit yerine artan gecikme ya da CAPTCHA tercih edilir; kalıcı kilit, saldırganın başkasının hesabını kilitlemesine izin verir.
JWT'yi doğru kullanmak
JWT (JSON Web Token) üç base64url parçadan oluşur: başlık.yük.imza. Başlıkta algoritma (alg) ve anahtar kimliği (kid), yükte claim'ler bulunur. Yük şifreli değildir, yalnız imzalıdır; içine parola, TC kimlik no ya da maaş koyma. İmzalı olmak "değiştirilemez" demektir, "gizli" demek değildir.
| Claim | Anlamı | Doğrulamada ne yapılır? |
|---|---|---|
sub | Konu: kullanıcı kimliği | Yetki kararında kullanılır |
exp | Bitiş zamanı (Unix saniye) | simdi >= exp ise reddet (küçük saat kayması toleransı olabilir) |
nbf, iat | Bundan önce geçersiz / üretilme anı | simdi < nbf ise reddet |
iss | Tokenı kim üretti | Beklenen yayıncıya eşit olmalı |
aud | Token kimin için | Bu servis listede yoksa reddet; başka API'nin tokenı burada geçmemeli |
jti | Tokenın benzersiz kimliği | İptal listesinde mi diye bakılır |
scope | Verilen kapsamlar | Uç nokta gereken kapsamı ister |
Aşağıdaki kutu PyJWT kütüphanesinin HS256 için ürettiği tokenın aynısını saf Python ile üretir ve doğrular. Sırayla: algoritmayı sabitle, imzayı sabit zamanlı karşılaştır, sonra claim'lere bak.
import base64, hashlib, hmac, json
def b64e(b):
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def b64d(s):
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def imzala(yuk, anahtar):
baslik = {"alg": "HS256", "typ": "JWT"}
govde = b64e(json.dumps(baslik, separators=(",", ":")).encode()) + "." + \
b64e(json.dumps(yuk, separators=(",", ":")).encode())
imza = hmac.new(anahtar, govde.encode(), hashlib.sha256).digest()
return govde + "." + b64e(imza)
def dogrula(token, anahtar, simdi, aud, iss):
b, y, i = token.split(".")
if json.loads(b64d(b)).get("alg") != "HS256": # alg=none saldırısına kapı açma
return "algoritma reddedildi"
beklenen = hmac.new(anahtar, f"{b}.{y}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(beklenen, b64d(i)):
return "imza geçersiz"
yuk = json.loads(b64d(y))
if simdi >= yuk["exp"]:
return "süresi dolmuş"
if yuk.get("iss") != iss or aud not in (yuk["aud"] if isinstance(yuk["aud"], list) else [yuk["aud"]]):
return "yanlış yayıncı ya da hedef"
return yuk
ANAHTAR = b"pusula-ik-ornek-anahtar-32-bayt!"
t = imzala({"sub": "418", "iss": "pusula-sso", "aud": "izin-api", "exp": 1_000_900}, ANAHTAR)
print(t[:40] + "...")
print(dogrula(t, ANAHTAR, 1_000_000, "izin-api", "pusula-sso"))
print(dogrula(t, ANAHTAR, 1_000_000, "bordro-api", "pusula-sso"))
print(dogrula(t, ANAHTAR, 1_001_000, "izin-api", "pusula-sso"))
sahte = t.rsplit(".", 1)[0][:-2] + "fQ." + t.rsplit(".", 1)[1]
print(dogrula(sahte, ANAHTAR, 1_000_000, "izin-api", "pusula-sso"))
alg alanına güvenen kütüphane kandırılır. Algoritma karışıklığı: RS256 bekleyen sunucuya, açık anahtarı HMAC anahtarı gibi kullanılan HS256 token yollamak. Çözüm aynı: kabul edilen algoritmaları sunucuda sabitle (jwt.decode(t, key, algorithms=["HS256"])), tokendaki alg'a göre davranma.Anahtar döndürme ve kid
İmza anahtarı bir gün değişecek (sızma, periyodik döndürme). Başlıktaki kid hangi anahtarla imzalandığını söyler; sunucu birkaç anahtarı aynı anda tanır: yeni tokenlar yeni anahtarla imzalanır, eski anahtar yalnız doğrulama için en uzun token ömrü kadar tutulur, sonra silinir. Asimetrik algoritmalarda (RS256, ES256) açık anahtarlar bir JWKS uç noktasında yayınlanır.
Access ve refresh token
JWT'yi iptal etmek zor olduğu için access token kısa ömürlü olur (5–15 dakika). Kullanıcıyı her 15 dakikada bir giriş ekranına göndermemek için yanında uzun ömürlü bir refresh token verilir; bu token yalnız yetki sunucusuna gider ve yeni bir access token alır. Refresh token sunucuda (özeti) saklanan, iptal edilebilen bir kayıttır.
- Döndürme (rotation): her yenilemede eski refresh token geçersiz olur, yenisi verilir.
- Yeniden kullanım tespiti (reuse detection): geçersiz olmuş bir refresh token tekrar gelirse token çalınmış demektir; o "aileye" ait tüm tokenlar iptal edilir, kullanıcı yeniden giriş yapar.
- İptal listesi (denylist): çıkışta ya da hesap kapatılınca access tokenın
jti'si süresi dolana kadar listede tutulur.
import itertools
sayac = itertools.count(1)
gecerli, kullanilmis, aile_of = {}, set(), {}
def ver(kullanici, aile):
t = f"rt-{next(sayac)}"
gecerli[t] = kullanici
aile_of[t] = aile
return t
def yenile(t):
if t in kullanilmis: # çalıntı token tekrar geldi
aile = aile_of[t]
for x in [x for x in gecerli if aile_of[x] == aile]:
del gecerli[x]
return "ALARM: aile iptal edildi"
kullanici = gecerli.pop(t)
kullanilmis.add(t)
return ver(kullanici, aile_of[t])
r1 = ver("emp-418", aile="cihaz-A")
r2 = yenile(r1)
print(r1, "->", r2)
print(yenile(r1)) # saldırgan eski tokenı denedi
print("geçerli tokenlar:", gecerli)
Yetkilendirme modelleri: RBAC ve ABAC
RBAC (Role-Based Access Control) kullanıcıya rol, role izin verir: calisan → izin:oku_kendi, izin:iste; yonetici → calisan'ın her şeyi + izin:onayla. Basit ve denetlenebilirdir. Ama "yönetici yalnız kendi ekibinin iznini onaylar" kuralını rol tek başına ifade edemez. Burada ABAC (Attribute-Based Access Control) devreye girer: karar, kullanıcının, kaynağın ve ortamın özniteliklerine bakan kurallarla verilir (kaynak.calisan.manager_id == kullanici.emp_id, mesai saati, şehir...). Pratikte ikisi birlikte kullanılır: rol kaba kapıyı açar, öznitelik kuralı satırı süzer.
import veri
calisanlar = {c["emp_id"]: c for c in veri.kayitlar("pusula_ik", "ik_employees")}
izinler = veri.kayitlar("pusula_ik", "ik_leaves")
ROL_IZIN = {"calisan": {"izin:oku"}, "yonetici": {"izin:oku", "izin:onayla"}, "ik": {"izin:oku", "izin:onayla"}}
def gorebilir_mi(kullanici, izin_kaydi):
if "izin:oku" not in ROL_IZIN[kullanici["rol"]]: # RBAC kapısı
return False
if kullanici["rol"] == "ik":
return True
sahip = calisanlar[izin_kaydi["emp_id"]]
return sahip["emp_id"] == kullanici["emp_id"] or sahip["manager_id"] == kullanici["emp_id"] # ABAC
riza = {"emp_id": 13, "rol": "yonetici"}
cem = {"emp_id": 418, "rol": "calisan"}
print("Rıza görebilir:", sum(gorebilir_mi(riza, k) for k in izinler))
print("Cem görebilir:", sum(gorebilir_mi(cem, k) for k in izinler))
Rıza Karabulut'un doğrudan bağlı 122 kişisi (ayrılanlar dahil) var; 1.042 ekip izni ve kendi 4 izni ona görünür. Cem yalnız kendi 7 kaydını görür. Gerçek sistemde bu süzme Python döngüsüyle değil SQL'in WHERE koşuluyla yapılır; aksi hâlde tüm tabloyu çekip süzersin.
Nesne düzeyinde yetki ve IDOR
IDOR (Insecure Direct Object Reference), kullanıcının URL'deki kimliği değiştirerek başkasının kaydına ulaşmasıdır: GET /izinler/3319 Cem'in yıllık iznini döndürüyorsa, /izinler/3320 denemek başkasının hastalık iznini açar. Uç noktada "giriş yapmış mı?" kontrolü vardır ama "bu kayıt onun mu?" kontrolü yoktur. API güvenliği listesinde (OWASP API Top 10) bu açık BOLA (Broken Object Level Authorization) adıyla ilk sıradadır.
- Kaydı her zaman kullanıcının kapsamıyla sorgula:
WHERE leave_id = ? AND emp_id = ?ya da yönetici için ekip alt sorgusu. - Yetki kontrolünü tek bir yerde topla (servis katmanı, Django'da
get_queryset); her view'da yeniden yazma. - Tahmin edilemez kimlik (UUID) kullanmak saldırıyı zorlaştırır ama yetki kontrolünün yerini tutmaz.
- Yetkisiz erişimde 404 dönmek kaydın varlığını gizler; denemeleri logla.
OAuth2 ve OpenID Connect
OAuth2 bir yetki devri çerçevesidir: kullanıcı (resource owner), bir uygulamaya (client) kendi parolasını vermeden, yetki sunucusunun (authorization server) verdiği token ile kaynak sunucusundaki (resource server) verisine sınırlı erişim tanır. OAuth2 tek başına "kim giriş yaptı" sorusunu cevaplamaz; bunun için üstüne kurulan OpenID Connect (OIDC) bir id_token (kullanıcı bilgisi taşıyan JWT) ekler. "Google ile giriş", kurumsal SSO (tek oturum açma; Azure AD/Entra ID, Okta, Keycloak) OIDC'dir.
| Akış (grant) | Kim kullanır? | Durum |
|---|---|---|
| Authorization code + PKCE | Web, mobil, tek sayfalı uygulama (kullanıcı var) | Önerilen |
| Client credentials | Servisten servise (kullanıcı yok): bordro işi izin API'sini çağırır | Önerilen |
| Refresh token | Access token yenileme | Döndürmeyle birlikte |
| Implicit, Password (ROPC) | Eski SPA'lar, birinci taraf uygulamalar | OAuth 2.1 ile kaldırıldı |
- Uygulama rastgele bir
code_verifierüretir, SHA-256 özetinin base64url hâlinicode_challengeolarak hesaplar; ayrıca CSRF'e karşı rastgele birstateüretir. - Kullanıcıyı
/authorize?response_type=code&client_id=...&scope=openid izin:oku&code_challenge=...&code_challenge_method=S256&state=...adresine yönlendirir. - Kullanıcı yetki sunucusunda giriş yapar ve onay verir; sunucu
redirect_uri?code=...&state=...ile geri döner. Uygulamastate'in kendi ürettiğiyle aynı olduğunu kontrol eder. - Uygulama arka kanaldan
/tokenuç noktasınacodevecode_verifier'ı yollar. Sunucu verifier'ın özetini saklı challenge ile karşılaştırır; tutarsa access, refresh (ve OIDC'de id) token verir.
import base64, hashlib
def challenge(verifier):
ozet = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(ozet).rstrip(b"=").decode()
# RFC 7636 ekindeki resmi örnek değerler
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
print(challenge(verifier))
print(challenge(verifier) == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
PKCE (Proof Key for Code Exchange) sayesinde yönlendirme sırasında code çalınsa bile, verifier'ı bilmeyen saldırgan onu tokena çeviremez. Client credentials akışında ise kullanıcı ve tarayıcı yoktur: servis kendi client_id ve client_secret'ı (ya da imzalı bir JWT'yi) doğrudan /token'a yollar ve yalnız kendi kapsamlarını alır.
FastAPI'de güvenlik bağımlılıkları
FastAPI dersinde Depends'i gördün. Kimlik doğrulama da bir bağımlılıktır: OAuth2PasswordBearer, Authorization: Bearer ... başlığını okur ve OpenAPI belgesine kilit simgesini ekler; Security ise Depends'in kapsam (scope) taşıyan hâlidir.
# pip install "fastapi[standard]" pyjwt
from typing import Annotated
import jwt
from fastapi import FastAPI, HTTPException, Security, status
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
ANAHTAR = "ortam-degiskeninden-oku"
oauth2 = OAuth2PasswordBearer(tokenUrl="token", scopes={"izin:oku": "İzinleri oku", "izin:onayla": "İzin onayla"})
app = FastAPI()
def aktif_kullanici(guvenlik: SecurityScopes, token: Annotated[str, Security(oauth2)]):
try:
yuk = jwt.decode(token, ANAHTAR, algorithms=["HS256"], audience="izin-api", issuer="pusula-sso")
except jwt.PyJWTError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Geçersiz token",
headers={"WWW-Authenticate": "Bearer"})
eksik = set(guvenlik.scopes) - set(yuk.get("scope", "").split())
if eksik:
raise HTTPException(status.HTTP_403_FORBIDDEN, f"Eksik kapsam: {sorted(eksik)}")
return yuk
@app.post("/izinler/{izin_id}/onay")
def onayla(izin_id: int, k: Annotated[dict, Security(aktif_kullanici, scopes=["izin:onayla"])]):
# kapsam yetmez: bu izin, k["sub"] yöneticisinin ekibinden mi? (nesne düzeyinde kontrol)
...
pip install "fastapi[standard]" pyjwt kur, dosyayı main.py olarak kaydet, fastapi dev main.py ile başlat ve http://127.0.0.1:8000/docs sayfasındaki "Authorize" düğmesini dene. Bu kutu tarayıcıda çalışmaz; alıştırmalar aynı mantığı saf Python ile kuruyor.Django'da kimlik ve izinler
Django kullanıcı modeli, gruplar ve model başına otomatik izinlerle (add_, change_, delete_, view_) RBAC'ı hazır verir. Nesne düzeyinde kontrolü ise sen yazarsın; en güvenli yer, kullanıcının görebileceği satırları döndüren get_queryset'tir. DRF izin sınıflarını İleri Django dersinde yazacaksın.
from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import get_object_or_404
from django.db.models import Q
@login_required
@permission_required("izin.view_izin", raise_exception=True) # RBAC: 403
def izin_detay(request, pk):
u = request.user
kapsam = Q(calisan=u.calisan) | Q(calisan__yonetici=u.calisan) # ABAC: kendi + ekip
izin = get_object_or_404(Izin.objects.filter(kapsam), pk=pk) # IDOR yerine 404
...
Parola sıfırlama ve e-posta doğrulama
Parola sıfırlama, giriş ekranının arka kapısıdır; zayıf tasarlanırsa parolayı bilmeden hesap ele geçirilir. Güvenli tasarımın kuralları:
- Yanıt, e-posta kayıtlı olsun olmasın aynıdır: "Hesap varsa bağlantı gönderdik."
- Token
secrets.token_urlsafe(32)ile üretilir; veritabanında yalnız SHA-256 özeti saklanır (veritabanı sızsa da bağlantı kullanılamaz). - Kısa ömürlüdür (15–60 dk) ve tek kullanımlıktır; yeni istek eski tokenları geçersiz kılar.
- Başarılı sıfırlamadan sonra kullanıcının tüm oturumları ve refresh tokenları iptal edilir ve kullanıcıya bilgilendirme e-postası gider.
- Bağlantı adresi yapılandırmadan gelir, isteğin
Hostbaşlığından kurulmaz (Host başlığı zehirleme).
E-posta doğrulama bağlantısı da aynı kalıptır; ek olarak purpose alanı tutulur ki doğrulama tokenı sıfırlamada kullanılamasın.
Kriptografiyi kütüphaneye bırak
Bu dersteki saf Python imzalar öğretim içindir. Üretimde PyJWT ya da authlib kullan, algorithms listesini her zaman ver, audience ve issuer doğrulamasını aç. Servisler arası güvende asimetrik imza (RS256/ES256) tercih edilir: yalnız kimlik sağlayıcısı özel anahtarı bilir, diğer servisler JWKS'ten açık anahtarı çeker.
Politikayı koddan ayır
Kurallar çoğaldıkça if rol == ... zincirleri dağılır. Olgun sistemler yetki kararını tek bir "politika karar noktasına" toplar: Open Policy Agent (Rego), Cedar ya da Google Zanzibar esinli ilişki tabanlı (ReBAC) sistemler (OpenFGA, SpiceDB). Uygulama yalnız "bu özne bu eylemi bu kaynakta yapabilir mi?" diye sorar.
Denetim izi ve en az yetki
Her yetki kararını (özellikle reddedilenleri) yapılandırılmış log olarak kaydet; birden çok 403 üreten kullanıcı IDOR taraması yapıyor olabilir. Servis hesaplarına yalnız gereken kapsamları ver, adım adım yükseltme (step-up authentication) ile hassas işlemlerde (maaş görüntüleme) ikinci faktör iste.