Ana içeriğe geç
Kesit Python
İleri · Web ve API

Kimlik doğrulama ve yetkilendirme: oturum, JWT, OAuth2 ve RBAC

Kimlik doğrulamayı yetkilendirmeden ayırır; oturum ile token arasında seçim yapar, JWT'yi exp/aud/iss ve anahtar döndürmeyle doğru doğrular, refresh token döndürür, OAuth2 + PKCE akışını kurar, RBAC/ABAC ile IDOR açıklarını Pusula İK izin verisi üzerinde kapatırsın.

Uygulamada aç, 20 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

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.

KavramSorduğu soruBaşarısızsa HTTP koduPusula İK örneği
Kimlik doğrulama (authentication, authN)Sen kimsin?401 UnauthorizedToken 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ı
401 adı yanıltıcıdırHTTP standardında 401'in adı "Unauthorized" olsa da anlamı "kimliğini doğrulayamadım"dır. Kim olduğunu bildiğin ama izin vermediğin durum 403'tür. Kaydın varlığını bile sızdırmak istemiyorsan 404 döndürmek de meşru bir seçimdir.

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.

ÖzellikSunucu oturumu + çerezİmzalı token (JWT)
Sunucuda durumVar (oturum deposu)Yok ya da az (iptal listesi)
Anında iptalKolay: kaydı silZor: süre dolana kadar geçerli; iptal listesi gerekir
ÖlçeklemeOrtak depo isterHer servis anahtarla doğrular
Uygun olduğu yerTarayıcıdaki klasik web uygulamasıMobil, servisler arası çağrı, birden çok API
Başlıca riskCSRF (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.

ClaimAnlamıDoğrulamada ne yapılır?
subKonu: kullanıcı kimliğiYetki kararında kullanılır
expBitiş zamanı (Unix saniye)simdi >= exp ise reddet (küçük saat kayması toleransı olabilir)
nbf, iatBundan önce geçersiz / üretilme anısimdi < nbf ise reddet
issTokenı kim ürettiBeklenen yayıncıya eşit olmalı
audToken kimin içinBu servis listede yoksa reddet; başka API'nin tokenı burada geçmemeli
jtiTokenın benzersiz kimliğiİptal listesinde mi diye bakılır
scopeVerilen kapsamlarUç 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"))
Bilinen JWT saldırılarıalg=none: başlıkta algoritmayı "none" yapıp imzayı silmek; tokendaki 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.

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.

  1. 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.
  2. Yetki kontrolünü tek bir yerde topla (servis katmanı, Django'da get_queryset); her view'da yeniden yazma.
  3. Tahmin edilemez kimlik (UUID) kullanmak saldırıyı zorlaştırır ama yetki kontrolünün yerini tutmaz.
  4. 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 + PKCEWeb, mobil, tek sayfalı uygulama (kullanıcı var)Önerilen
Client credentialsServisten servise (kullanıcı yok): bordro işi izin API'sini çağırırÖnerilen
Refresh tokenAccess token yenilemeDöndürmeyle birlikte
Implicit, Password (ROPC)Eski SPA'lar, birinci taraf uygulamalarOAuth 2.1 ile kaldırıldı
  1. Uygulama rastgele bir code_verifier üretir, SHA-256 özetinin base64url hâlini code_challenge olarak hesaplar; ayrıca CSRF'e karşı rastgele bir state üretir.
  2. Kullanıcıyı /authorize?response_type=code&client_id=...&scope=openid izin:oku&code_challenge=...&code_challenge_method=S256&state=... adresine yönlendirir.
  3. Kullanıcı yetki sunucusunda giriş yapar ve onay verir; sunucu redirect_uri?code=...&state=... ile geri döner. Uygulama state'in kendi ürettiğiyle aynı olduğunu kontrol eder.
  4. Uygulama arka kanaldan /token uç noktasına code ve code_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)
    ...
Kendi bilgisayarında denepip 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ı:

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.

Gerçek işteKurumsal projelerde kimlik sistemini genellikle sıfırdan yazmazsın: şirketin kimlik sağlayıcısı (Entra ID, Okta, Keycloak) OIDC ile bağlanır, uygulama yalnız tokenı doğrular ve yetki kurallarını uygular. Senden beklenen; tokenı doğru doğrulamak (alg, imza, exp, aud, iss), kapsam ve rolleri doğru eşlemek ve her uç noktada nesne düzeyinde kontrolü unutmamaktır. Güvenlik testlerinde ilk denenen şey de budur: iki kullanıcıyla giriş yapıp birinin kimliğini diğerinin isteğine koymak.
Uzmanlaş

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.

Alıştırmalara geç (20)