İçeriğe geç
academia.sh

Ders 06 / 23

Yenileme Belirteçleri

Kısa ömürlü erişim belirteci ile uzun ömürlü yenileme belirtecinin ayrılması: yenileme belirtecinin yalnız karma olarak saklanması, her kullanımda dönüştürülmesi ve kullanılmış bir belirtecin yeniden sunulmasının aile iptaliyle sonuçlanmasının ölçülmesi.

İçindekiler

Önceki iki ders bir ikilem bıraktı. Durumsuz belirteçte iptal penceresini ömür belirliyordu: ömür kısaldıkça pencere daralıyor, ama kullanıcı sık sık yeniden giriş yapmak zorunda kalıyordu. İkilem, tek belirteç varsayımından doğar. İki belirteç kullanıldığında ömür ile kullanım kolaylığı ayrı ayrı ayarlanabilir.

Erişim belirteci (access token) kısa ömürlüdür ve her isteğe iliştirilir. Yenileme belirteci (refresh token) uzun ömürlüdür, yalnız tek bir uca — yenileme ucuna — gider ve karşılığında taze bir erişim belirteci verir. Bu ayrımın kendisi bir kazanç sağlamaz; kazanç, uzun ömürlü olanın sunucuda kayıtlı olmasından ve her kullanımda değiştirilmesinden gelir.

İki Belirteç, İki Görev

Erişim belirteci her istekte gider; dolayısıyla vekil sunucu, günlük, hata izleme kaydı gibi bütün ara duraklardan geçer. Yenileme belirteci yalnız yenileme ucuna gider; geçtiği yol kısadır ve o yol dışında hiçbir yerde görünmez.

İkincisi durumludur. Erişim belirteci imzasıyla doğrulanır ve hiçbir kayda bakılmaz; yenileme belirteci ise sunucudaki bir satıra karşılık gelir. Bu, önceki dersteki durumsuzluğun terk edilmesi değildir: kayıt okuması dakikada bir olan yenileme isteğinde yapılır, saniyede yüzlerce olan kaynak isteğinde değil.

Belirtecin istemci tarafında nereye konacağı ayrı bir karardır ve Uygulama Mimarisi kursunun Tarayıcıda Kimlik Doğrulama dersinde ele alınmıştır; bu ders sunucu tarafını kurar.

Yenileme Belirtecinin Saklanması

Yenileme belirteci sunucuda düz olarak saklanmaz. Saklanan değer, belirtecin karmasıdır; belirtecin kendisi yalnız istemcidedir. Belirteç iki parçadan oluşur: aramada kullanılan bir kimlik ve doğrulamada kullanılan bir sır. Kimlik parçası, bütün satırları tek tek denemeden doğrudan satıra ulaşmayı sağlar.

// saklama.mjs — yenileme belirtecinin sunucuda hangi bicimde durdugu
// Kullanim: node saklama.mjs
import { randomBytes, createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE yenileme (kimlik TEXT PRIMARY KEY, aile TEXT NOT NULL, karma TEXT NOT NULL,
                                uretim INTEGER NOT NULL, kullanim INTEGER)`);

const karma = (sir) => createHash("sha256").update(sir).digest("base64url");

const kimlik = randomBytes(9).toString("base64url");
const sir = randomBytes(32).toString("base64url");
const istemciyeVerilen = `${kimlik}.${sir}`;
db.prepare("INSERT INTO yenileme (kimlik, aile, karma, uretim) VALUES (?, ?, ?, ?)")
  .run(kimlik, "A-1", karma(sir), 1785000000000);

const satir = db.prepare("SELECT * FROM yenileme WHERE kimlik = ?").get(kimlik);
console.log("istemciye verilen :", istemciyeVerilen);
console.log("tablodaki satır   :", JSON.stringify(satir));
console.log("satırda sır var mı:", JSON.stringify(satir).includes(sir));
console.log("sunulan değer doğrulanabiliyor mu:", karma(sir) === satir.karma);
istemciye verilen : eYxdnXk2HLZc.1wAt5gNZ-E91wUNx_xjYpsdxA7eS7AxYLHqWuQefbCY
tablodaki satır   : {"kimlik":"eYxdnXk2HLZc","aile":"A-1","karma":"SeGl3OEK1MKA2ifVK51meydjt7F-1iABmT01wPg0C3o","uretim":1785000000000,"kullanim":null}
satırda sır var mı: false
sunulan değer doğrulanabiliyor mu: true

Kimlik ve sır her çalıştırmada değişir. Satırda sır bulunmuyor; buna karşılık istemcinin sunduğu değer doğrulanabiliyor. Veritabanının okunması, geçerli yenileme belirteçlerini vermez.

Burada kullanılan tek geçişli özet, parola saklamada yeterli olmaz. Fark girdinin öngörülebilirliğindedir: sır 32 bayt rastgele veriden üretilmiştir ve denenerek bulunması söz konusu değildir. Parolalar için bu koşul sağlanmadığından ayrı bir yöntem gerekir; o konu ayrıca ele alınacaktır.

Yenileme Ucu

Aşağıdaki sunucu iki belirteci de üretir, yenileme ucunu kurar ve bir bayrakla dönüşümsüz kurulumu da üretebilir. Yenileme belirteçleri bir aileye (family) bağlıdır: bir girişte açılan aile, o girişten türeyen bütün yenileme belirteçlerini kapsar.

// yenileme.mjs — kisa omurlu erisim belirteci + uzun omurlu yenileme belirteci
// Kullanim: node yenileme.mjs <port> [dondurmesiz]
//   dondurmesiz: yenilemede ayni yenileme belirteci gecerli kalir (karsi ornek)
// Ortam: ERISIM_MS (erisim belirteci omru, ontanimli 3000)
import { createServer } from "node:http";
import { createHmac, randomBytes, timingSafeEqual, createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

const PORT = Number(process.argv[2] ?? 8541);
const DONDURMESIZ = process.argv.includes("dondurmesiz");
const ERISIM_MS = Number(process.env.ERISIM_MS ?? 3000);
const AILE_OMRU_MS = 30 * 24 * 3_600_000;              // ailenin mutlak omru

const ANAHTAR = randomBytes(32);
const HESAPLAR = new Map([["deniz.aral", { parola: "üç-kitap-1876", kod: "U-1001", rol: "uye" }]]);

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE aile (kod TEXT PRIMARY KEY, uye TEXT NOT NULL, acilis INTEGER NOT NULL,
                     iptal INTEGER NOT NULL DEFAULT 0, iptalNedeni TEXT);
  CREATE TABLE yenileme (kimlik TEXT PRIMARY KEY, aile TEXT NOT NULL, karma TEXT NOT NULL,
                         uretim INTEGER NOT NULL, kullanim INTEGER);
`);

// Yenileme belirteci: "<kimlik>.<sir>". Sunucuda yalniz sirrin karmasi durur.
const karma = (sir) => createHash("sha256").update(sir).digest("base64url");
const yenilemeUret = (aile) => {
  const kimlik = randomBytes(9).toString("base64url");
  const sir = randomBytes(32).toString("base64url");
  db.prepare("INSERT INTO yenileme (kimlik, aile, karma, uretim) VALUES (?, ?, ?, ?)")
    .run(kimlik, aile, karma(sir), Date.now());
  return `${kimlik}.${sir}`;
};

const b64 = (v) => Buffer.from(v).toString("base64url");
const erisimUret = (asil) => {
  const govde = b64(JSON.stringify({ ...asil, exp: Date.now() + ERISIM_MS }));
  return `${govde}.${createHmac("sha256", ANAHTAR).update(govde).digest("base64url")}`;
};
const erisimDogrula = (belirtec) => {
  const [govde, imza] = (belirtec ?? "").split(".");
  if (!govde || !imza) return null;
  const beklenen = createHmac("sha256", ANAHTAR).update(govde).digest("base64url");
  const a = Buffer.from(beklenen, "utf8"), b = Buffer.from(imza, "utf8");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
  const icerik = JSON.parse(Buffer.from(govde, "base64url").toString("utf8"));
  return Date.now() < icerik.exp ? icerik : null;
};

const aileIptal = (kod, neden) =>
  db.prepare("UPDATE aile SET iptal = 1, iptalNedeni = ? WHERE kod = ?").run(neden, kod);

// Yenileme belirtecinin degerlendirilmesi: dort ayri ret nedeni ayirt edilir.
const yenilemeCoz = (deger) => {
  const [kimlik, sir] = (deger ?? "").split(".");
  const satir = kimlik ? db.prepare("SELECT * FROM yenileme WHERE kimlik = ?").get(kimlik) : undefined;
  if (!satir) return { hata: "taninmiyor" };

  const a = Buffer.from(karma(sir ?? ""), "utf8"), b = Buffer.from(satir.karma, "utf8");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return { hata: "karma" };

  const aile = db.prepare("SELECT * FROM aile WHERE kod = ?").get(satir.aile);
  if (aile.iptal) return { hata: "aile iptal", aile };
  if (Date.now() - aile.acilis > AILE_OMRU_MS) return { hata: "aile omru doldu", aile };

  // Kullanilmis bir belirtec yeniden sunuldu: kopyanin dolasimda oldugunun isareti.
  if (satir.kullanim !== null && !DONDURMESIZ) {
    aileIptal(aile.kod, "yeniden kullanim saptandi");
    return { hata: "yeniden kullanim", aile };
  }
  return { satir, aile };
};

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) => {
    yanit.writeHead(kod, { "content-type": "application/json; charset=utf-8" });
    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" });
    const aile = randomBytes(6).toString("base64url");
    db.prepare("INSERT INTO aile (kod, uye, acilis) VALUES (?, ?, ?)").run(aile, hesap.kod, Date.now());
    return json(200, { erisim: erisimUret({ kod: hesap.kod, rol: hesap.rol }), yenileme: yenilemeUret(aile) });
  }

  if (istek.method === "POST" && yol === "/yenile") {
    const { yenileme } = JSON.parse((await govdeOku(istek)) || "{}");
    const sonuc = yenilemeCoz(yenileme);
    if (sonuc.hata) return json(401, { hata: sonuc.hata });

    const uye = db.prepare("SELECT uye FROM aile WHERE kod = ?").get(sonuc.aile.kod).uye;
    const govde = { erisim: erisimUret({ kod: uye, rol: "uye" }) };
    if (DONDURMESIZ) {
      govde.yenileme = yenileme;                       // ayni belirtec gecerli kalir
    } else {
      db.prepare("UPDATE yenileme SET kullanim = ? WHERE kimlik = ?").run(Date.now(), sonuc.satir.kimlik);
      govde.yenileme = yenilemeUret(sonuc.aile.kod);   // dönüşüm: yeni belirtec
    }
    return json(200, govde);
  }

  if (yol === "/ben") {
    const tasiyici = (istek.headers.authorization ?? "").startsWith("Bearer ")
      ? istek.headers.authorization.slice(7) : null;
    const icerik = erisimDogrula(tasiyici);
    return icerik ? json(200, { kod: icerik.kod }) : json(401, { hata: "erisim belirteci gecersiz" });
  }

  if (yol === "/tanilama") {
    return json(200, {
      aileler: db.prepare("SELECT kod, iptal, iptalNedeni FROM aile").all(),
      yenilemeSayisi: db.prepare("SELECT COUNT(*) n FROM yenileme").get().n,
      kullanilmisSayisi: db.prepare("SELECT COUNT(*) n FROM yenileme WHERE kullanim IS NOT NULL").get().n,
    });
  }
  json(404, { hata: "kaynak yok" });
}).listen(PORT, "127.0.0.1", () => console.log(`yenileme 127.0.0.1:${PORT} dondurmesiz=${DONDURMESIZ}`));

Erişim Belirtecinin Ömrü

Erişim belirtecinin ömrü üç saniyeye çekildiğinde akış gözlenebilir hâle gelir: belirteç süresi dolunca 401 alınır, yenileme ucundan taze bir belirteç alınır ve istek yeniden çalışır.

// erisim-omru.mjs — erisim belirtecinin omru ve yenilemeyle tazelenmesi
// Kullanim: ERISIM_MS=3000 node yenileme.mjs 8543 &  calisirken  node erisim-omru.mjs 8543
const PORT = Number(process.argv[2] ?? 8543);
const TABAN = `http://127.0.0.1:${PORT}`;
const gonder = async (yol, govde) =>
  (await fetch(TABAN + yol, {
    method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(govde),
  })).json();
const durum = async (erisim) =>
  (await fetch(`${TABAN}/ben`, { headers: { authorization: `Bearer ${erisim}` } })).status;

const giris = await gonder("/giris", { kullanici: "deniz.aral", parola: "üç-kitap-1876" });
const bas = Date.now();
console.log("giriş anında /ben        :", await durum(giris.erisim));
await new Promise((c) => setTimeout(c, 3200));
console.log("3,2 sn sonra /ben        :", await durum(giris.erisim),
  `(erişim ömrü 3 sn, geçen ${((Date.now() - bas) / 1000).toFixed(1)} sn)`);

const taze = await gonder("/yenile", { yenileme: giris.yenileme });
console.log("yenilemeden sonra /ben   :", await durum(taze.erisim));
console.log("eski erişim belirteciyle :", await durum(giris.erisim));
ERISIM_MS=3000 node yenileme.mjs 8543 > /dev/null 2>&1 & S=$!
sleep 1.2
node erisim-omru.mjs 8543
kill $S
giriş anında /ben        : 200
3,2 sn sonra /ben        : 401 (erişim ömrü 3 sn, geçen 3.2 sn)
yenilemeden sonra /ben   : 200
eski erişim belirteciyle : 401

Son satır önemlidir: yenileme, eski erişim belirtecini geçerli kılmaz. Her erişim belirteci kendi exp değerine bağlıdır ve süresi dolduğunda geri gelmez. Yenilemenin yaptığı tek şey yeni bir belirteç üretmektir.

Dönüşümlü Kullanım

Dönüşüm (rotation), yenileme belirtecinin her kullanımda değiştirilmesidir: sunucu eski satırı kullanılmış olarak işaretler ve yeni bir belirteç üretir. Bunun sonucu, her yenileme belirtecinin tek kullanımlık olmasıdır.

Tek kullanımlık olmanın ölçülebilir kazancı, kullanılmış bir belirtecin yeniden sunulduğunda bir işaret taşımasıdır. Normal bir istemci bunu yapmaz; çünkü elindeki belirteci kullandığında yenisini almıştır. Aynı belirtecin ikinci kez sunulması, o belirtecin bir kopyasının dolaşımda olduğunu söyler.

// donusum.mjs — donusumlu kullanim ve yeniden kullanim saptamasinin olculmesi
// Kullanim: ERISIM_MS=3000 node yenileme.mjs 8541 &              (donusum var)
//           ERISIM_MS=3000 node yenileme.mjs 8542 dondurmesiz &  (donusum yok)
//           node donusum.mjs 8541   /   node donusum.mjs 8542
const PORT = Number(process.argv[2]);
const TABAN = `http://127.0.0.1:${PORT}`;
const gonder = async (yol, govde) =>
  (await fetch(TABAN + yol, {
    method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(govde),
  })).json();

const giris = await gonder("/giris", { kullanici: "deniz.aral", parola: "üç-kitap-1876" });
const ilkYenileme = giris.yenileme;

// Uc kez yenile; her seferinde eldeki yenileme belirtecini kullan.
let elde = ilkYenileme;
for (let i = 1; i <= 3; i++) {
  const c = await gonder("/yenile", { yenileme: elde });
  console.log(`${i}. yenileme:`, c.hata ? `ret (${c.hata})` : "kabul",
    "| yeni yenileme belirteci mi:", !c.hata && c.yenileme !== elde);
  if (!c.hata) elde = c.yenileme;
}

// Ilk yenileme belirteci yeniden sunuluyor (bir kopyanin dolasimda oldugu durum).
const tekrar = await gonder("/yenile", { yenileme: ilkYenileme });
console.log("ilk belirteç yeniden  :", tekrar.hata ? `ret (${tekrar.hata})` : "kabul");

// En son gecerli belirtecle yeniden denenir.
const sonrasi = await gonder("/yenile", { yenileme: elde });
console.log("güncel belirteçle     :", sonrasi.hata ? `ret (${sonrasi.hata})` : "kabul");
console.log("tanılama:", JSON.stringify(await (await fetch(`${TABAN}/tanilama`)).json()));
ERISIM_MS=3000 node yenileme.mjs 8541 > /dev/null 2>&1 & DONUSUMLU=$!
ERISIM_MS=3000 node yenileme.mjs 8542 dondurmesiz > /dev/null 2>&1 & DONUSUMSUZ=$!
sleep 1.2
echo "— dönüşümlü kullanım —"
node donusum.mjs 8541
echo "— dönüşüm yok —"
node donusum.mjs 8542
kill $DONUSUMLU $DONUSUMSUZ
— dönüşümlü kullanım —
1. yenileme: kabul | yeni yenileme belirteci mi: true
2. yenileme: kabul | yeni yenileme belirteci mi: true
3. yenileme: kabul | yeni yenileme belirteci mi: true
ilk belirteç yeniden  : ret (yeniden kullanim)
güncel belirteçle     : ret (aile iptal)
tanılama: {"aileler":[{"kod":"hwmd0rUj","iptal":1,"iptalNedeni":"yeniden kullanim saptandi"}],"yenilemeSayisi":4,"kullanilmisSayisi":3}
— dönüşüm yok —
1. yenileme: kabul | yeni yenileme belirteci mi: false
2. yenileme: kabul | yeni yenileme belirteci mi: false
3. yenileme: kabul | yeni yenileme belirteci mi: false
ilk belirteç yeniden  : kabul
güncel belirteçle     : kabul
tanılama: {"aileler":[{"kod":"0hYwgfbk","iptal":0,"iptalNedeni":null}],"yenilemeSayisi":1,"kullanilmisSayisi":0}

Aile kodları her çalıştırmada değişir. İki blok arasındaki fark, dönüşümün ne kazandırdığını tek başına anlatıyor.

Dönüşümlü kurulumda üç yenilemenin ardından tabloda dört satır vardır ve üçü kullanılmıştır. İlk belirteç yeniden sunulduğunda sunucu bunu saptar: satırın kullanim alanı doludur. Kararın sonucu yalnız o isteği reddetmek değildir — aile iptal edilir ve bir sonraki satırda görüldüğü gibi güncel belirteç de artık çalışmaz. Bunun nedeni, hangi tarafın kopya olduğunun bilinememesidir: yeniden sunan taraf da, elinde güncel belirteci tutan taraf da aynı aileye aittir. Güvenli karar, ikisini de düşürüp yeniden giriş istemektir.

Dönüşümsüz kurulumda aynı belirteç dört kez kullanıldı, hepsi kabul edildi ve tabloda tek satır var; kullanilmisSayisi sıfır. Bir kopyanın dolaşımda olup olmadığını gösterecek hiçbir iz yoktur. Sunucunun saptayamadığı bir durum, sunucunun yanıt veremediği bir durumdur.

Yanlış Alarm ve Ailenin Ömrü

Saptamanın bir kusuru vardır: aynı yenileme belirteciyle eşzamanlı iki istek de aynı işareti üretir. Uygulamanın iki sekmesi ya da iki isteği aynı anda 401 alıp aynı belirteçle yenilemeye giderse, ikincisi kullanılmış bir belirteç sunmuş olur ve aile boşuna iptal edilir. Çözüm istemci tarafındadır ve Uygulama Mimarisi kursunun Oturum Yenileme dersinde tek uçuş adıyla kurulmuştur: aynı anda yalnız bir yenileme isteği çıkar, diğerleri onun sonucunu bekler. Sunucu tarafında ise küçük bir hoşgörü penceresi tanınabilir — dönüşümden sonraki birkaç saniye içinde gelen aynı istek, aynı yeni belirteçle yanıtlanır.

Ailenin bir de mutlak ömrü vardır. Dönüşüm her kullanımda yeni belirteç ürettiği için, düzenli kullanılan bir aile kendiliğinden hiç bitmez. AILE_OMRU_MS bunu sınırlar: ilk girişten belirli bir süre sonra aile, ne kadar kullanılırsa kullanılsın kapanır ve yeniden giriş gerekir. Aile kaydı ayrıca çıkışın karşılığıdır: çıkış isteği aileyi iptal eder, böylece o girişten türeyen bütün yenileme belirteçleri tek işlemle düşer.

Böylece iptal penceresi iki farklı süreye ayrılmış olur. Erişim belirteci için pencere onun kısa ömrüdür — üç saniye, on beş dakika, ne seçilmişse. Yenileme belirteci içinse pencere sıfırdır, çünkü karar veritabanındaki bir satırdır.

Özet

  • Erişim belirteci kısa ömürlüdür ve her isteğe iliştirilir; yenileme belirteci uzun ömürlüdür ve yalnız yenileme ucuna gider.
  • Yenileme belirteci sunucuda karma olarak saklanır; belirteç, aramada kullanılan bir kimlik ile doğrulamada kullanılan bir sırdan oluşur ve veritabanının okunması geçerli belirteç vermez.
  • Dönüşüm, yenileme belirtecini her kullanımda değiştirir ve onu tek kullanımlık kılar; kullanılmış bir belirtecin yeniden sunulması bir kopyanın dolaşımda olduğunun işaretidir.
  • Saptama üzerine aile iptal edilir; hangi tarafın kopya olduğu bilinemediği için güncel belirteç de düşer.
  • Dönüşümsüz kurulumda aynı belirteç sınırsız kullanılır ve yeniden kullanımı gösterecek hiçbir iz kalmaz.
  • Eşzamanlı yenileme yanlış alarm üretebilir; istemcide tek uçuş, sunucuda kısa bir hoşgörü penceresi bunu karşılar. Ailenin mutlak ömrü ve çıkışta iptali, oturumun sonunu belirler.

Sonraki Adım

Buraya kadar kimlik doğrulamanın bütün adımları tek bir tarafta geçti: kullanıcı parolasını ödünç servisine verdi, ödünç servisi belirteci üretti ve yine kendisi doğruladı. Kütüphanenin ilçe şubelerinin ortak bir üye kaydı kullandığı ya da bir üyenin ödünç geçmişini üçüncü taraf bir okuma uygulamasına açmak istediği durumda bu düzen çalışmaz: parolanın o uygulamaya verilmesi gerekirdi. Sonraki ders bu sorunu çözen yetki devri protokolünü kurar — kaynak sahibi parolasını yalnız kendi kimlik sağlayıcısına verir, uygulamaya ise sınırlı bir yetki çıkar. Yetki kodu akışı, state parametresi ve doğrulayıcı, kendi yerel sunucularınızla uçtan uca çalıştırılacak.

İ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