Ders 04 / 23
Belirteç Tabanlı Kimlik Doğrulama
Asılın isteğin taşıdığı imzalı değerin içine yazılması ve doğrulamanın yerel bir hesaba inmesi: donuk ile kendi kendine yeten belirteç ayrımı, taşıyıcı şeması, imzanın neyi güvenceye aldığı ve çıkışın anında etkili olmamasının ölçülmesi.
İçindekiler
Oturum deposu bir bedel taşıyordu: her istek deponun okunmasını gerektiriyor, uygulama yatay ölçeklendiğinde depo bütün süreçlerin ulaşabildiği bir yerde durmak zorunda kalıyordu. Bedelin kaynağı, isteğin taşıdığı değerin anlamsız olmasıydı; kimliği anlama işi hep sunucudaydı.
Bu ders bunun tersini kurar. Asılın kim olduğu ve neye yetkili olduğu, isteğin taşıdığı değerin içine yazılır; değerin değiştirilmediğinden emin olmak için de imzalanır. Sunucu doğrulama sırasında hiçbir kayda bakmaz, yalnız imzayı hesaplar. Kazanç ölçülebilir olduğu gibi kayıp da ölçülebilir: kayıt yoksa çıkış neyi siler?
Belirtecin İki Türü
Donuk belirteç (opaque token), taşıdığı değerin kendisi hiçbir anlam içermeyen bir dizgedir. Önceki dersteki oturum kimliği bu türdendir: değer bir kayda işaret eder ve o kayıt olmadan hiçbir şey söylemez. Belirteci alan bir servis, onu üreten servise sorarak — buna belirteç incelemesi (token introspection) denir — geçerli olup olmadığını ve arkasındaki asılı öğrenir.
Kendi kendine yeten belirteç (self-contained token), asıl hakkındaki bilgiyi kendi içinde taşır ve bir imzayla korunur. Doğrulayan taraf imzayı hesaplayabiliyorsa hiçbir yere sormaz. Bu türün kazancı doğrulamanın yerel olmasıdır: ödünç servisi, kataloğu ve ceza servisi aynı belirteci ayrı ayrı doğrulayabilir, aralarında paylaşılan bir oturum deposu gerekmez.
Ayrım bir ödünleşimdir. Donuk belirteçte geçerlilik kararı üretenin elindedir ve her an değiştirilebilir; karşılığında her doğrulama bir sorgu ister. Kendi kendine yeten belirteçte doğrulama ucuzdur; karşılığında karar, belirteç üretildiği anda dondurulmuştur.
İmzalı Belirtecin Üretimi ve Doğrulanması
Aşağıdaki sunucu, gövdeyi base64url ile kodlayıp HMAC-SHA256 imzasını yanına ekler. Bu biçim bu derse özgüdür; sonraki ders aynı işi yapan standart biçimi kurar.
// belirtec.mjs — kendi kendine yeten, imzali belirtecle durumsuz kimlik dogrulama // Kullanim: node belirtec.mjs <port> [iptal] // iptal: cikista belirtec kimligi iptal listesine yazilir // Ortam: OMUR_MS (belirtec omru, ontanimli 6000) import { createServer } from "node:http"; import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; const PORT = Number(process.argv[2] ?? 8531); const IPTAL_LISTESI_VAR = process.argv.includes("iptal"); const OMUR_MS = Number(process.env.OMUR_MS ?? 6000); const ANAHTAR = randomBytes(32); // imzalama anahtari; sunucuda kalir const HESAPLAR = new Map([["deniz.aral", { parola: "üç-kitap-1876", kod: "U-1001", rol: "uye" }]]); // Iptal listesi: belirtec kimligi -> ne zamana kadar tutulacagi const IPTAL = new Map(); let dogrulamaSayisi = 0, depoOkumaSayisi = 0; const b64 = (v) => Buffer.from(v).toString("base64url"); const imzala = (govde) => createHmac("sha256", ANAHTAR).update(govde).digest("base64url"); const uret = (asil) => { const govde = b64(JSON.stringify({ ...asil, jti: randomBytes(9).toString("base64url"), exp: Date.now() + OMUR_MS })); return `${govde}.${imzala(govde)}`; }; const dogrula = (belirtec) => { dogrulamaSayisi++; const [govde, imza] = (belirtec ?? "").split("."); if (!govde || !imza) return { hata: "bicim" }; const beklenen = Buffer.from(imzala(govde), "utf8"); const gelen = Buffer.from(imza, "utf8"); if (beklenen.length !== gelen.length || !timingSafeEqual(beklenen, gelen)) return { hata: "imza" }; const icerik = JSON.parse(Buffer.from(govde, "base64url").toString("utf8")); if (Date.now() >= icerik.exp) return { hata: "sure" }; if (IPTAL_LISTESI_VAR) { depoOkumaSayisi++; // durumsuzluktan vazgecilen tek nokta if (IPTAL.has(icerik.jti)) return { hata: "iptal" }; } return { icerik }; }; const govdeOku = (istek) => new Promise((coz) => { let v = ""; istek.on("data", (p) => (v += p)); istek.on("end", () => coz(v)); }); createServer(async (istek, yanit) => { yanit.sendDate = false; const yol = istek.url.split("?")[0]; const json = (kod, govde, baslik = {}) => { yanit.writeHead(kod, { "content-type": "application/json; charset=utf-8", ...baslik }); yanit.end(JSON.stringify(govde)); }; if (istek.method === "POST" && yol === "/giris") { const { kullanici, parola } = JSON.parse((await govdeOku(istek)) || "{}"); const hesap = HESAPLAR.get(kullanici ?? ""); if (!hesap || parola !== hesap.parola) return json(401, { hata: "kimlik dogrulanamadi" }); return json(200, { belirtec: uret({ kod: hesap.kod, rol: hesap.rol }) }); } const tasiyici = (istek.headers.authorization ?? "").startsWith("Bearer ") ? istek.headers.authorization.slice(7) : null; if (istek.method === "POST" && yol === "/cikis") { const sonuc = dogrula(tasiyici); if (sonuc.icerik && IPTAL_LISTESI_VAR) IPTAL.set(sonuc.icerik.jti, sonuc.icerik.exp); return json(200, { cikildi: true, iptalListesiVar: IPTAL_LISTESI_VAR }); } if (yol === "/ben") { if (!tasiyici) { return json(401, { hata: "belirtec yok" }, { "www-authenticate": 'Bearer realm="odunc"' }); } const sonuc = dogrula(tasiyici); if (sonuc.hata) { return json(401, { hata: sonuc.hata }, { "www-authenticate": `Bearer realm="odunc", error="invalid_token", error_description="${sonuc.hata}"` }); } return json(200, { kod: sonuc.icerik.kod, rol: sonuc.icerik.rol }); } if (yol === "/tanilama") { // Suresi gecen kayitlar listeden dusulur: liste butun belirtecleri degil, // yalnizca omru dolmamis olanlari tutar. for (const [jti, exp] of IPTAL) if (Date.now() >= exp) IPTAL.delete(jti); return json(200, { dogrulamaSayisi, depoOkumaSayisi, iptalListesiUzunlugu: IPTAL.size }); } json(404, { hata: "kaynak yok" }); }).listen(PORT, "127.0.0.1", () => console.log(`belirtec 127.0.0.1:${PORT} iptal=${IPTAL_LISTESI_VAR}`));
Belirteç, Authorization başlığında taşıyıcı şemasıyla (Bearer scheme) yollanır. Şemanın
adı anlamlıdır: belirteci taşıyan herkes onun sahibidir. Başlığı ele geçiren tarafın ayrıca
bir parolaya ihtiyacı yoktur; bu, temel şemadaki durumla aynı taşıma gereksinimini doğurur
ve şifresiz bağlantıyı yine dışarıda bırakır.
Reddetme yanıtı, sorunun ne olduğunu WWW-Authenticate başlığında bildirir.
OMUR_MS=60000 node belirtec.mjs 8533 > /dev/null & SUNUCU=$! sleep 1 curl -s -D - -o /dev/null -H "Authorization: Bearer bozuk.deger" http://127.0.0.1:8533/ben | grep -i "^www-authenticate" kill $SUNUCU
www-authenticate: Bearer realm="odunc", error="invalid_token", error_description="imza"
Gövdenin Okunabilirliği
İmza, gövdenin sunucudan çıktığı hâlde olduğunu güvenceye alır. Gövdeyi gizlemez. Kodlama base64url’dir ve önceki derste görüldüğü gibi çözmek için anahtar gerekmez.
// icerik.mjs — belirtecin tasidigi govdenin okunabilirligi ve imzanin ne dogruladigi // Kullanim: OMUR_MS=60000 node belirtec.mjs 8533 & calisirken node icerik.mjs 8533 const PORT = Number(process.argv[2] ?? 8533); const TABAN = `http://127.0.0.1:${PORT}`; const giris = await fetch(`${TABAN}/giris`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ kullanici: "deniz.aral", parola: "üç-kitap-1876" }), }); const { belirtec } = await giris.json(); const [govde, imza] = belirtec.split("."); console.log("gövde (base64url):", govde); console.log("imza (base64url):", imza); console.log("gövdenin çözülmüş hâli:", Buffer.from(govde, "base64url").toString("utf8")); const sor = async (deger) => (await fetch(`${TABAN}/ben`, { headers: { authorization: `Bearer ${deger}` } })).status; // Govde degistirilir, imza oldugu gibi birakilir: rolu "personel" yapan bir govde const degisik = Buffer.from(JSON.stringify({ kod: "U-1001", rol: "personel", jti: "x", exp: Date.now() + 60000 })) .toString("base64url"); console.log("olduğu gibi :", await sor(belirtec)); console.log("gövdesi değiştirilmiş :", await sor(`${degisik}.${imza}`)); console.log("imzası düşürülmüş :", await sor(govde));
OMUR_MS=60000 node belirtec.mjs 8533 > /dev/null & SUNUCU=$! sleep 1 node icerik.mjs 8533 kill $SUNUCU
gövde (base64url): eyJrb2QiOiJVLTEwMDEiLCJyb2wiOiJ1eWUiLCJqdGkiOiJ2V0JleEFXcjNPbkQiLCJleHAiOjE3ODUyNzIxODIzNzJ9
imza (base64url): rKXePLI8VZZtAaq1LXbveTx7Q-ncpUX7e4EgiZHlBSo
gövdenin çözülmüş hâli: {"kod":"U-1001","rol":"uye","jti":"vWBexAWr3OnD","exp":1785272182372}
olduğu gibi : 200
gövdesi değiştirilmiş : 401
imzası düşürülmüş : 401
Belirteç kimliği ve zaman damgası her çalıştırmada değişir. Üç satırın söylediği ayrı ayrı
okunmalıdır. Gövde okunabilir: içindeki her alan, belirteci eline geçiren herkese açıktır.
Bu yüzden gövdeye parola, kimlik numarası, dâhilî tablo anahtarı ya da başka bir sır
yazılmaz. Gövdenin değiştirilmesi ise sonuç vermez: rolü personel yapan bir gövdenin eski
imzayla eşleşme olasılığı yoktur ve sunucu 401 döner. İmzanın düşürülmesi de aynı kapıya
çıkar — imzasız gövde biçim denetiminden geçemez.
Kısacası imza bütünlüğü sağlar, gizliliği sağlamaz. İkisinin karıştırılması, gövdeye sır yazan kurulumların kaynağıdır.
İptal Sorunu
Doğrulama hiçbir kayda bakmadığı için, sunucunun “bu belirteç artık geçersiz” diyebileceği
bir yer yoktur. Çıkış isteği belirteci istemciden silebilir ama başka bir kopyası varsa o
kopya çalışmayı sürdürür. Süre ölçülebilir: belirteç ömrü 6 saniyeye çekilir, çıkıştan sonra
/ben sürekli sorulur ve ilk başarısızlığa kadar geçen zaman yazılır.
// iptal-olcumu.mjs — cikistan sonra belirtecin kac saniye gecerli kaldigini olcer // Kullanim: OMUR_MS=6000 node belirtec.mjs 8531 & (iptal listesi yok) // OMUR_MS=6000 node belirtec.mjs 8532 iptal & (iptal listesi var) // node iptal-olcumu.mjs 8531 / node iptal-olcumu.mjs 8532 const PORT = Number(process.argv[2]); const TABAN = `http://127.0.0.1:${PORT}`; const giris = await fetch(`${TABAN}/giris`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ kullanici: "deniz.aral", parola: "üç-kitap-1876" }), }); const { belirtec } = await giris.json(); const bas = { authorization: `Bearer ${belirtec}` }; const durum = async () => (await fetch(`${TABAN}/ben`, { headers: bas })).status; console.log("çıkış öncesi /ben :", await durum()); await fetch(`${TABAN}/cikis`, { method: "POST", headers: bas }); const cikisAni = Date.now(); let gecerliKaldi = 0; for (;;) { const kod = await durum(); if (kod !== 200) break; gecerliKaldi = Date.now() - cikisAni; await new Promise((c) => setTimeout(c, 250)); } console.log("çıkıştan sonra geçerli kaldığı süre:", (gecerliKaldi / 1000).toFixed(1), "sn"); console.log("tanılama:", await (await fetch(`${TABAN}/tanilama`)).json());
OMUR_MS=6000 node belirtec.mjs 8531 > /dev/null & LISTESIZ=$! OMUR_MS=6000 node belirtec.mjs 8532 iptal > /dev/null & LISTELI=$! sleep 1 echo "— iptal listesi yok —" node iptal-olcumu.mjs 8531 echo "— iptal listesi var —" node iptal-olcumu.mjs 8532 kill $LISTESIZ $LISTELI
— iptal listesi yok —
çıkış öncesi /ben : 200
çıkıştan sonra geçerli kaldığı süre: 5.9 sn
tanılama: { dogrulamaSayisi: 27, depoOkumaSayisi: 0, iptalListesiUzunlugu: 0 }
— iptal listesi var —
çıkış öncesi /ben : 200
çıkıştan sonra geçerli kaldığı süre: 0.0 sn
tanılama: { dogrulamaSayisi: 3, depoOkumaSayisi: 3, iptalListesiUzunlugu: 1 }
Ölçülen süreler yüzde birkaç oynar; belirleyici olan büyüklük mertebesidir. Listesiz kurulumda çıkış isteğinin sunucu tarafındaki karşılığı boştur: belirteç, ömrünün sonuna kadar çalışır. Altı saniyelik ömürde bu altı saniyedir; sekiz saatlik ömürde sekiz saattir. Önceki dersin oturum deposunda aynı ölçüm sıfır veriyordu, çünkü karar bir kaydın silinmesiydi.
Aynı çıktı, kazancın nereden geldiğini de gösteriyor. Listesiz kurulumda depoOkumaSayisi
sıfırdır: yirmi yedi doğrulama yapıldı ve hiçbiri hiçbir yere sormadı. Listeli kurulumda
iptalListesiUzunlugu bire çıkmıştır; çıkışta yazılan tek kayıt, ömrü dolana kadar orada
durur.
İptal Listesinin Bedeli
İkinci kurulum, çıkışta belirteç kimliğini bir iptal listesine yazar ve her doğrulamada
o listeye bakar. Ölçüm sıfır saniye verir: karar anında etkilidir. Ama depoOkumaSayisi
artık doğrulama sayısına eşittir — durumsuzluk bu noktada bırakılmıştır.
Yine de iki kurulum arasında nicel bir fark kalır. Oturum deposu açık olan bütün oturumları tutar ve kayıt kullanıcı çıkana ya da ömür dolana kadar durur. İptal listesi yalnızca iptal edilmiş ve henüz ömrü dolmamış belirteçleri tutar; ömrü dolan kayıt listeden düşer, çünkü zaten süre denetimi onu reddedecektir. Belirteç ömrü kısaldıkça bu liste küçülür.
Buradan pratik kural çıkar: durumsuz belirteçte iptalin çözümü listeyi büyütmek değil, ömrü kısaltmaktır. Ömür kısaldığında iptal penceresi de kısalır. Ama kısa ömür, kullanıcıyı dakikada bir yeniden giriş yapmaya zorlar; bu ikilemin çözümü ayrı bir derstir.
Özet
- Donuk belirteç bir kayda işaret eder ve doğrulaması sorgu ister; kendi kendine yeten belirteç bilgiyi kendi içinde taşır ve doğrulaması yereldir.
- Kendi kendine yeten belirteç imzalanır; imza gövdenin değişmediğini güvenceye alır ama gövdeyi gizlemez, bu yüzden gövdeye sır yazılmaz.
- Belirteç taşıyıcı şemasıyla yollanır; taşıyanın sahip sayılması, şifresiz bağlantıyı yine
dışarıda bırakır ve reddetme nedeni
WWW-Authenticatebaşlığında bildirilir. - Sunucuda kayıt olmadığı için çıkış anında etkili olmaz: ölçümde belirteç, ömrü bitene kadar çalışmayı sürdürdü.
- İptal listesi kararı anında etkili kılar ama her doğrulamayı bir okumaya bağlar; liste yalnız ömrü dolmamış iptalleri tuttuğu için oturum deposundan küçüktür.
- Durumsuz kurulumda iptal penceresini belirleyen değişken belirteç ömrüdür; kısa ömür pencereyi daraltır ve asıl çözüm buradadır.
Sonraki Adım
Bu derste kullanılan belirteç biçimi bu derse özgüydü: gövde, nokta, imza. Aynı işi yapan yaygın bir standart vardır ve alanların adlarını, imza algoritmasının nasıl bildirileceğini, sürenin hangi birimle yazılacağını sabitler. Standart olmak, doğrulamanın da standartlaşması demektir — ve doğrulayıcının hangi adımları atlamaması gerektiği, biçimin kendisinden daha önemlidir. Sonraki ders bu biçimi ayrıntısıyla kurar, imzayı elle üretip doğrular ve doğrulayıcının belirtecin kendi söylediğine güvendiği noktada niçin çöktüğünü gösterir.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.