İçeriğe geç
academia.sh

Ders 03 / 23

Oturum Tabanlı Kimlik Doğrulama

Sunucu tarafında tutulan oturum kaydının kurulması ve oturum kimliğinin çerezle taşınması: çerez niteliklerinin yanıt başlığında doğrulanması, girişte kimliğin yenilenmesinin ölçülmesi, çıkışın anında etkili olması ve boşta kalma ile mutlak ömür sınırları.

İçindekiler

Temel şemanın çözmediği sorunların ortak kaynağı, sunucunun istekler arasında hiçbir şey hatırlamamasıydı. Parola her istekte yeniden geçiyordu, çıkış diye bir işlem yoktu ve aynı hesabın farklı istemcileri ayırt edilemiyordu. Sunucu bir kez doğrulayıp sonucu kendi tarafında saklarsa üçü de değişir.

Bu derste kurulan yapı oturumdur (session): girişte sunucuda bir kayıt açılır, kayda rastgele bir oturum kimliği (session identifier) verilir ve istemci sonraki isteklerde yalnız bu kimliği taşır. Parola bir daha ağa çıkmaz. Kimliğin kendisi bir anlam taşımaz; tek işlevi sunucudaki kayda işaret etmektir. Kurulumun doğru olup olmadığını belirleyen iki ayrıntı, kimliğin hangi niteliklerle taşındığı ve girişte yenilenip yenilenmediğidir.

Oturum Deposu

Aşağıdaki sunucu bir oturum deposu tutar, girişte kayıt açar, çerez kurar ve her istekte çerezdeki kimliği kayda çevirir. İki bayrak alır: guvenli, çerezi TLS arkasındaki bir kuruluma göre yollar; sabit, girişte kimliği yenilemeyen yanlış kurulumu üretir ve dersin karşı örneğidir.

// oturum.mjs — sunucu tarafi oturum deposu ve cerezle tasinan oturum kimligi
// Kullanim: node oturum.mjs <port> [guvenli] [sabit]
//   guvenli: cerez __Host- onekiyle, Secure niteligiyle yollanir (TLS arkasi kurulum)
//   sabit  : giriste oturum kimligi YENILENMEZ (yanlis kurulum; dersin karsi ornegi)
// Ortam: BOSTA_MS (bosta kalma siniri), MUTLAK_MS (mutlak omur)
import { createServer } from "node:http";
import { randomBytes, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8521);
const GUVENLI = process.argv.includes("guvenli");
const SABIT = process.argv.includes("sabit");
const BOSTA_MS = Number(process.env.BOSTA_MS ?? 15 * 60_000);
const MUTLAK_MS = Number(process.env.MUTLAK_MS ?? 8 * 3_600_000);

const CEREZ_ADI = GUVENLI ? "__Host-odunc_oturum" : "odunc_oturum";
const HESAPLAR = new Map([["deniz.aral", { parola: "üç-kitap-1876", kod: "U-1001", rol: "uye" }]]);

// Oturum deposu: kimlik -> kayit. Tek surecte Map; birden cok surecte paylasilan bir depo.
const DEPO = new Map();
const yeniKimlik = () => randomBytes(32).toString("base64url");

const esitMi = (a, b) => {
  const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
};

const cerezOku = (baslik, ad) =>
  (baslik ?? "").split(";").map((p) => p.trim().split("="))
    .filter(([a]) => a === ad).map(([, d]) => d)[0];

const cerezYaz = (kimlik, omurSn) => {
  const parcalar = [`${CEREZ_ADI}=${kimlik}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${omurSn}`];
  if (GUVENLI) parcalar.splice(2, 0, "Secure");
  return parcalar.join("; ");
};

const oturumCoz = (istek) => {
  const kimlik = cerezOku(istek.headers.cookie, CEREZ_ADI);
  if (!kimlik) return { kimlik: null, kayit: null };
  const kayit = DEPO.get(kimlik);
  if (!kayit) return { kimlik, kayit: null };
  const simdi = Date.now();
  if (simdi - kayit.sonErisim > BOSTA_MS || simdi - kayit.acilis > MUTLAK_MS) {
    DEPO.delete(kimlik);                       // suresi gecen kayit depodan dusulur
    return { kimlik, kayit: null };
  }
  kayit.sonErisim = simdi;
  return { kimlik, kayit };
};

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 || !esitMi(parola ?? "", hesap.parola)) return json(401, { hata: "kimlik dogrulanamadi" });

    const { kimlik: oncekiKimlik } = oturumCoz(istek);
    let kimlik;
    if (SABIT && oncekiKimlik) {
      kimlik = oncekiKimlik;                   // yanlis: giris oncesi kimlik korunuyor
    } else {
      if (oncekiKimlik) DEPO.delete(oncekiKimlik);   // dogru: eski kayit silinir
      kimlik = yeniKimlik();                         // dogru: yeni kimlik uretilir
    }
    DEPO.set(kimlik, { asil: { kod: hesap.kod, rol: hesap.rol }, acilis: Date.now(), sonErisim: Date.now() });
    return json(200, { girdi: hesap.kod }, { "set-cookie": cerezYaz(kimlik, Math.floor(MUTLAK_MS / 1000)) });
  }

  if (istek.method === "POST" && yol === "/cikis") {
    const { kimlik } = oturumCoz(istek);
    if (kimlik) DEPO.delete(kimlik);           // kayit silinir: karar bir sonraki istekte etkilidir
    return json(200, { cikildi: true }, { "set-cookie": cerezYaz("", 0) });
  }

  if (yol === "/ben") {
    const { kayit } = oturumCoz(istek);
    if (!kayit) return json(401, { hata: "oturum yok" });
    return json(200, { kod: kayit.asil.kod, rol: kayit.asil.rol });
  }

  // Tanilama ucu: yalniz bu derste, depoyu gozlemek icin. Gercek serviste bulunmaz.
  if (yol === "/tanilama/depo") {
    const { kimlik } = oturumCoz(istek);
    return json(200, {
      depodakiKayitSayisi: DEPO.size,
      cerezdekiKimlikOnEki: kimlik ? kimlik.slice(0, 12) : null,
      cerezdekiKimlikDepoda: kimlik ? DEPO.has(kimlik) : null,
    });
  }
  json(404, { hata: "kaynak yok" });
}).listen(PORT, "127.0.0.1", () => console.log(`oturum 127.0.0.1:${PORT} guvenli=${GUVENLI} sabit=${SABIT}`));

Oturum kimliği 32 baytlık rastgele veriden üretilir. Bu değerin tahmin edilememesi, kaydın tek koruması olduğu için pazarlık konusu değildir: sayaçtan, kullanıcı kodundan ya da zaman damgasından türetilen bir kimlik, kaydın kendisini herkese açar.

Çerez Nitelikleri

Oturum kimliği çerezle taşınır ve çerezin nitelikleri kimliğin nereye, ne zaman ve kim tarafından gönderileceğini belirler. Aynı sunucunun iki kurulumu, yanıt başlığında yan yana görülebilir.

node oturum.mjs 8521 > /dev/null & YEREL=$!
node oturum.mjs 8522 guvenli > /dev/null & TLS=$!
sleep 1
echo "--- yerel kurulum (TLS yok): Set-Cookie"
curl -s -D - -o /dev/null -X POST -H "content-type: application/json" \
  -d "{\"kullanici\":\"deniz.aral\",\"parola\":\"üç-kitap-1876\"}" \
  http://127.0.0.1:8521/giris | grep -i "^set-cookie"
echo "--- TLS arkasi kurulum: Set-Cookie"
curl -s -D - -o /dev/null -X POST -H "content-type: application/json" \
  -d "{\"kullanici\":\"deniz.aral\",\"parola\":\"üç-kitap-1876\"}" \
  http://127.0.0.1:8522/giris | grep -i "^set-cookie"
kill $YEREL $TLS
--- yerel kurulum (TLS yok): Set-Cookie
set-cookie: odunc_oturum=ThJZuB7-8PQvlgmrsRE-F9akNm_i_Rp3KIhauu9VLpc; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800
--- TLS arkasi kurulum: Set-Cookie
set-cookie: __Host-odunc_oturum=ehLOT_FJv58aS-OxyMFUTrWNGvkZNewoIirc7qnhRsc; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800

Kimlik değerleri her çalıştırmada değişir; sabit olan niteliklerdir.

HttpOnly, çerezi betikten okunamaz kılar. Etkisi tarayıcıda ortaya çıkar: document.cookie bu çerezi görmez. Frontend Kalitesi kursundaki Siteler Arası Betik Çalıştırma dersinde gösterilen sızıntı yolu, oturum kimliği için bu nitelikle kapatılır. Sunucu tarafında görülebilecek tek şey niteliğin yollanmış olmasıdır; uygulanması istemcinin işidir.

Secure, çerezin yalnız TLS bağlantısında gönderilmesini şart koşar. Yukarıdaki iki çıktının tek anlamlı farkı budur ve TLS arkasındaki kurulum için isteğe bağlı değildir: niteliksiz çerez, şifresiz bir isteğe düşen istemcide açık biçimde ağa çıkar. Yerel kurulumda niteliğin verilmemesinin nedeni, tarayıcının şifresiz bir kökenden gelen Secure çerezi zaten saklamamasıdır; bu, geliştirme ortamının TLS ile çalışması için ayrıca bir gerekçedir.

SameSite=Lax, çerezin başka bir siteden başlatılan isteklere iliştirilmesini sınırlar. Sunucunun ortam yetkisiyle çalışması — yani çerezin isteğe kim başlatırsa başlatsın eklenmesi — siteler arası istek sahteciliğinin kaynağıdır; bu nitelik yüzeyi daraltır ama tek başına yeterli savunma değildir.

Path=/ çerezin hangi yollara gönderileceğini, Max-Age ise istemcide ne kadar saklanacağını söyler. __Host- öneki, konak öneki kuralı gereği çerezin Secure olmasını, Path=/ taşımasını ve Domain belirtmemesini şart koşar; alt alan adlarından çerez yazılmasını engeller.

Bu niteliklerin hiçbiri sunucudaki kaydın ömrünü belirlemez. Max-Age istemcinin çerezi ne zaman unutacağını söyler; kaydın ne zaman geçersizleşeceğine sunucu karar verir. İkisi karıştırıldığında ortaya “istemci sildi ama sunucuda hâlâ geçerli” durumu çıkar.

Girişte Kimliğin Yenilenmesi

Giriş öncesinde istemcinin üzerinde bir oturum kimliği bulunabilir. Sunucu girişte bu kimliği korursa, giriş öncesinde bilinen bir değer giriş sonrasında yetkili bir kayda işaret etmeye başlar. Bu duruma oturum sabitlemesi (session fixation) denir; değerin istemciye nasıl yerleştiği ayrı bir konudur, sunucunun ödevi değeri devralmamaktır.

Aşağıdaki ölçüm, giriş öncesi ve sonrası kimlikleri karşılaştırır.

// sabitleme.mjs — giriste oturum kimliginin yenilenip yenilenmediginin olculmesi
// Kullanim: node oturum.mjs 8523 sabit &  ve  node oturum.mjs 8524 &  calisirken
//           node sabitleme.mjs 8523   /   node sabitleme.mjs 8524
const PORT = Number(process.argv[2]);
const TABAN = `http://127.0.0.1:${PORT}`;
const CEREZ_ADI = "odunc_oturum";

// Giris oncesi istemcinin uzerinde bulunan, disaridan belirlenmis bir deger.
const ONCEDEN_BELIRLENMIS = "onceden-belirlenmis-deger-0001";

const girisYap = async (cerezDegeri) => {
  const cevap = await fetch(`${TABAN}/giris`, {
    method: "POST",
    headers: { "content-type": "application/json", cookie: `${CEREZ_ADI}=${cerezDegeri}` },
    body: JSON.stringify({ kullanici: "deniz.aral", parola: "üç-kitap-1876" }),
  });
  const kurulan = (cevap.headers.get("set-cookie") ?? "").split(";")[0].split("=").slice(1).join("=");
  return { kod: cevap.status, kurulan };
};

const benKim = async (cerezDegeri) => {
  const cevap = await fetch(`${TABAN}/ben`, { headers: { cookie: `${CEREZ_ADI}=${cerezDegeri}` } });
  return `${cevap.status} ${await cevap.text()}`;
};

const { kurulan } = await girisYap(ONCEDEN_BELIRLENMIS);
console.log("giriş öncesi değer      :", ONCEDEN_BELIRLENMIS);
console.log("giriş sonrası kurulan   :", kurulan.slice(0, 30) + (kurulan.length > 30 ? "…" : ""));
console.log("kimlik yenilendi mi     :", kurulan !== ONCEDEN_BELIRLENMIS);
console.log("eski değerle /ben       :", await benKim(ONCEDEN_BELIRLENMIS));
console.log("yeni değerle /ben       :", await benKim(kurulan));
node oturum.mjs 8523 sabit > /dev/null & SABIT=$!
node oturum.mjs 8524 > /dev/null & DOGRU=$!
sleep 1
echo "— giriste kimlik yenilenmiyor (yanlis kurulum) —"
node sabitleme.mjs 8523
echo "— giriste kimlik yenileniyor (dogru kurulum) —"
node sabitleme.mjs 8524
kill $SABIT $DOGRU
— giriste kimlik yenilenmiyor (yanlis kurulum) —
giriş öncesi değer      : onceden-belirlenmis-deger-0001
giriş sonrası kurulan   : onceden-belirlenmis-deger-0001
kimlik yenilendi mi     : false
eski değerle /ben       : 200 {"kod":"U-1001","rol":"uye"}
yeni değerle /ben       : 200 {"kod":"U-1001","rol":"uye"}
— giriste kimlik yenileniyor (dogru kurulum) —
giriş öncesi değer      : onceden-belirlenmis-deger-0001
giriş sonrası kurulan   : Qdk_zPWOapHWgrDAvPOPnKvzzhQA-z…
kimlik yenilendi mi     : true
eski değerle /ben       : 401 {"hata":"oturum yok"}
yeni değerle /ben       : 200 {"kod":"U-1001","rol":"uye"}

İlk kurulumda giriş öncesinde bilinen değer, giriş sonrasında U-1001 üyesinin kaydını döndürüyor. İkinci kurulumda aynı değer 401 alıyor; kayıt yeni ve tahmin edilemez bir kimliğe taşınmış, eskisi depodan silinmiştir. Kural tek cümledir: yetki düzeyinin değiştiği her noktada — giriş, hesap değiştirme, ikinci etmen doğrulaması, yetki yükseltme — oturum kimliği yenilenir.

Çıkışın Etkisi ve Ömür Sınırları

Oturum kaydı sunucuda durduğu için silme işlemi doğrudan bir sonuç üretir. Kayıt yoksa bir sonraki istek 401 alır; arada bekleme yoktur.

Kayıt kendiliğinden de düşer. Boşta kalma sınırı (idle timeout), son istekten bu yana geçen süreyi sınırlar; her istek sayacı sıfırlar. Mutlak ömür (absolute lifetime), kaydın açılışından itibaren toplam süreyi sınırlar ve istekle uzamaz. Aşağıdaki ölçümde boşta kalma sınırı 1,5 saniyeye çekilmiştir.

// omur.mjs — cikisin ve bosta kalma sinirinin ne zaman etkili oldugu
// Kullanim: BOSTA_MS=1500 node oturum.mjs 8525 &  calisirken  node omur.mjs 8525
const PORT = Number(process.argv[2] ?? 8525);
const TABAN = `http://127.0.0.1:${PORT}`;
const CEREZ_ADI = "odunc_oturum";

const giris = await fetch(`${TABAN}/giris`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ kullanici: "deniz.aral", parola: "üç-kitap-1876" }),
});
const cerez = (giris.headers.get("set-cookie") ?? "").split(";")[0];
const bas = { cookie: cerez };
const durum = async (yol) => (await fetch(TABAN + yol, { headers: bas })).status;
const depo = async () => await (await fetch(`${TABAN}/tanilama/depo`, { headers: bas })).json();

console.log("giriş sonrası /ben      :", await durum("/ben"), "| depo:", (await depo()).depodakiKayitSayisi);
await fetch(`${TABAN}/cikis`, { method: "POST", headers: bas });
console.log("çıkış sonrası /ben      :", await durum("/ben"), "| depo:", (await depo()).depodakiKayitSayisi);

// Ikinci oturum: bosta kalma siniri
const giris2 = await fetch(`${TABAN}/giris`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ kullanici: "deniz.aral", parola: "üç-kitap-1876" }),
});
const bas2 = { cookie: (giris2.headers.get("set-cookie") ?? "").split(";")[0] };
const durum2 = async () => (await fetch(`${TABAN}/ben`, { headers: bas2 })).status;
console.log("yeni oturum /ben        :", await durum2());
await new Promise((c) => setTimeout(c, 800));
console.log("0,8 sn sonra /ben       :", await durum2(), "(sınır 1,5 sn; her istek sayacı sıfırlar)");
await new Promise((c) => setTimeout(c, 1800));
console.log("1,8 sn boşta sonra /ben :", await durum2());
BOSTA_MS=1500 node oturum.mjs 8525 > /dev/null & SUNUCU=$!
sleep 1
node omur.mjs 8525
kill $SUNUCU
giriş sonrası /ben      : 200 | depo: 1
çıkış sonrası /ben      : 401 | depo: 0
yeni oturum /ben        : 200
0,8 sn sonra /ben       : 200 (sınır 1,5 sn; her istek sayacı sıfırlar)
1,8 sn boşta sonra /ben : 401

Çıkış isteğinden sonra depoda kayıt kalmadı ve aynı çerezle yapılan istek 401 aldı. Bu, oturum tabanlı kurulumun ayırt edici özelliğidir ve sonraki dersin karşılaştırma noktasıdır.

Deponun Bedeli

Depo sunucuda durduğu için bir bedeli vardır. Her istek deponun okunmasını gerektirir; tek süreçte bu bir Map erişimidir, birden çok süreçte paylaşılan bir depoya yapılan ağ çağrısıdır. Uygulama yatay ölçeklendiğinde kayıt bütün süreçlerin ulaşabildiği bir yerde durmalıdır; her sürecin kendi belleğinde tuttuğu depo, isteği başka sürece düşen kullanıcıyı oturumsuz bırakır. Bu sorunu isteği hep aynı sürece yönlendirerek çözmek, süreç düştüğünde o sürece bağlı bütün oturumları düşürür.

Buna karşılık depo, ölçülebilir bir yetenek verir: hangi hesabın kaç açık oturumu olduğu, hangi oturumun ne zaman kullanıldığı ve hangi oturumun tek bir istekle kapatılacağı sunucunun bildiği bilgilerdir.

Özet

  • Oturum, girişte sunucuda açılan bir kayıttır; istemci yalnız o kayda işaret eden rastgele bir oturum kimliği taşır ve parola bir daha ağa çıkmaz.
  • Çerez nitelikleri kimliğin nereye ve ne zaman gönderileceğini belirler: HttpOnly betikten okumayı, Secure şifresiz gönderimi, SameSite başka siteden başlatılan isteklere iliştirilmesini sınırlar; __Host- öneki bu kuralları zorunlu kılar.
  • Max-Age istemcinin çerezi ne kadar saklayacağını söyler; kaydın geçerliliğine sunucu karar verir ve ikisi ayrı ömürlerdir.
  • Yetki düzeyinin değiştiği her noktada oturum kimliği yenilenir; yenilenmeyen kurulumda giriş öncesinde bilinen bir değer giriş sonrasında yetkili kayda işaret eder.
  • Kayıt sunucuda durduğu için çıkış anında etkilidir; boşta kalma sınırı son istekten, mutlak ömür ise kaydın açılışından ölçülür.
  • Deponun bedeli her istekte bir okuma ve yatay ölçeklemede paylaşılan bir depo gereksinimidir; karşılığında açık oturumların sayılabilmesi ve tek tek kapatılabilmesidir.

Sonraki Adım

Deponun bedeli, onu ortadan kaldırma isteğini doğurur. Sunucu kimliği bir kayda çevirmek yerine, asılın kendisini isteğin taşıdığı değerin içine yazabilir ve o değeri imzalayarak değiştirilmediğinden emin olabilir. Böyle bir kurulumda doğrulama yerel bir hesaba iner, paylaşılan depo gereksinimi ortadan kalkar ve servisler birbirinin oturum deposuna bağlanmadan aynı kimliği tanıyabilir. Sonraki ders bu kurulumu yazar ve bedelini ölçer: sunucuda kayıt yoksa çıkış neyi siler ve silinmeyen bir değer ne kadar süre geçerli kalır?

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat