İçeriğe geç
XDD.baby Yazılım Öğrenme
Sözlük Hata rehberi 0/56

Blok 4 · Ders 31

Kimlik doğrulama ve güvenlik temelleri

Şifre saklamanın doğru yolu (hash), oturum çerezi ile token farkı, yetkilendirme ve en sık güvenlik açıkları (XSS, SQL injection, CSRF).

Bu dersin sonunda

  • Kimlik doğrulama (kimsin?) ile yetkilendirmeyi (ne yapabilirsin?) ayırıp korunan route'ları girisGerekli ve yoneticiGerekli ara katmanlarıyla doğru sırada kapatabilir, doğru yerde 401, doğru yerde 403 döndürebilirsin
  • Şifreyi node:crypto'nun scrypt'iyle tuzlu ve yavaş hashleyip timingSafeEqual ile güvenle doğrulayabilirsin
  • Bellekte bir oturum tablosu tutan, HttpOnly, Secure, SameSite işaretli çerez kuran bir giriş sistemi yazabilirsin
  • Giriş denemelerine hız sınırı koyabilir, güçlü şifre kuralı uygulayabilir ve şifre değişince bütün oturumları kapatabilirsin
  • XSS, CSRF ve SQL injection saldırılarının akışını anlatıp her birine karşı ilk savunmayı uygulayabilirsin
  • Token (JWT) ile oturum çerezini karşılaştırıp hazır kimlik çözümlerinin (OAuth) ne zaman doğru seçim olduğunu söyleyebilirsin
Bu sayfada
  1. İki ayrı soru: kimsin ve ne yapabilirsin
  2. Şifreyi asla düz saklama
  3. Kayıt ve giriş uç noktaları
  4. Oturum: sunucu seni nasıl hatırlar
  5. Çerezin koruma işaretleri
  6. Korunan route: ara katman
  7. Hız sınırı ve güçlü şifre
  8. Gerçek bir olaydan ders: şifre değişince oturumları kapat
  9. Token (JWT) fikri
  10. Üç yaygın açık ve ilk savunma
  11. Hazır çözümler: kendi sistemini sıfırdan yazma
  12. Görevler
  13. Kendini sına
  14. Kayıt noktası

Şimdiye kadar yazdığın sunucular herkese açıktı. Gerçek bir uygulamada ise bazı şeyler yalnız sana, bazıları yalnız yöneticiye açıktır. Bu derste o kapıyı kuruyorsun: kayıt, giriş, oturum ve korunan adresler. Sonra saldırganın gözünden bakıp en sık üç açığı ve ilk savunmayı göreceksin.

Baştan bir uyarı: güvenlik tek dersle bitmez. Amaç, nelerin neden yapıldığını anlaman. Gerçek bir üründe kendi şifre sistemini sıfırdan yazmak yerine denenmiş bir çözüm kullanacaksın; bunu dersin sonunda konuşacağız. Önce altında ne olduğunu görelim.

İki ayrı soru: kimsin ve ne yapabilirsin

Güvenliğin temelinde iki ayrı soru var ve bunları karıştırmak çok sık yapılan bir hata.

1. Kimlik doğrulama Kimsin? kullanıcı adı + şifre ile kendini kanıtlarsın sonuç: kim olduğun bilinir 2. Yetkilendirme Ne yapabilirsin? rolüne bakılır (oyuncu / yönetici) sonuç: izin ya da ret başarısız: 401 başarısız: 403
Şekil 31.1 Önce kimsin sorusu (kimlik doğrulama), sonra ne yapabilirsin sorusu (yetkilendirme).

Bu ayrım, Ders 26'da gördüğün iki durum koduyla birebir eşleşir:

  • 401 Unauthorized: "Seni tanımıyorum." Giriş yapmadın ya da kimliğini kanıtlayamadın.
  • 403 Forbidden: "Seni tanıyorum ama bu işe iznin yok."

Adı yanıltıcı: 401'in İngilizce adı "Unauthorized" (yetkisiz) olsa da aslında kimlik doğrulamayla ilgilidir. İstemci ikisine farklı davranır: 401 görünce giriş sayfasını açar, 403 görünce "bu sayfa sana kapalı" der.

Şifreyi asla düz saklama

İlk akla gelen, şifreyi olduğu gibi saklamak. Peki kullanıcı listen bir gün çalınırsa? Herkesin şifresi saldırganın eline geçer; insanlar aynı şifreyi başka sitelerde de kullandığı için zarar senin sitende kalmaz.

Çözüm: şifreyi saklamadan önce geri döndürülemez bir işlemden geçirmek. Bu işleme hash denir.

Bu dersin projesi için yeni bir klasör aç: terminalde cd C:\kod, mkdir giris-sistemi ve cd giris-sistemi. Klasörü VS Code'da aç, Ders 27'deki gibi npm init -y çalıştır ve package.json'da "type": "commonjs" satırını "type": "module" yap.

İlk akla gelen, sha256 gibi hazır bir hash. Node'un içindeki node:crypto modülüyle dene:

hash-dene.js
import { createHash } from "node:crypto" function sha256(metin) {    return createHash("sha256").update(metin).digest("hex")} console.log(sha256("sihirdar123"))console.log(sha256("sihirdar123"))console.log(sha256("sihirdar124"))
Çıktı
debc36df3a62863d08c6e51a2e853b5fb1df1cf131e2f1bea6c228df26a99b92debc36df3a62863d08c6e51a2e853b5fb1df1cf131e2f1bea6c228df26a99b92a35b5ea8922f68281c63f9e1f0c624b20c560669ef3ff645dbd33f080bb7b96b

createHash("sha256") bir hash makinesi kurar, update(metin) metni içine atar, digest("hex") sonucu 0–9 ve a–f harfleriyle yazılmış bir metin olarak verir (zincirleme). Aynı şifre hep aynı özeti veriyor (karşılaştırabilirsin), tek harf değişince özet bambaşka oluyor. Ama sha256'nın şifre için iki büyük sorunu var.

Birincisi: çok hızlı. sha256 dosya denetimi gibi işler için, hızlı olsun diye tasarlandı. Şifrede ise hız saldırganın dostudur. Ölç (ikinci döngüdeki scryptSync'i birazdan tanıyacaksın, şimdilik yalnız sürelere bak):

hiz-dene.js
import { createHash, scryptSync, randomBytes } from "node:crypto" const tuz = randomBytes(16) let basla = Date.now()for (let i = 0; i < 100000; i++) {    createHash("sha256").update("deneme" + i).digest("hex")}console.log(`100000 sha256: ${Date.now() - basla} ms`) basla = Date.now()for (let i = 0; i < 10; i++) {    scryptSync("deneme" + i, tuz, 32, { N: 16384, r: 8, p: 5 })}console.log(`10 scrypt: ${Date.now() - basla} ms`)
Çıktıörnek, sende farklı çıkabilir
100000 sha256: 149 ms10 scrypt: 1478 ms

Yüz bin sha256 yaklaşık 150 ms sürdü. Çalınmış özetleri eline geçiren saldırgan, milyonlarca şifreyi saniyeler içinde dener. scrypt ise tanesi için yaklaşık 150 ms harcadı. Bu yavaşlık kasıtlı: kullanıcı girişte bir kez 150 ms bekler, fark etmez; saldırgan ise saniyede milyonlarca değil, birkaç tahmin yapabilir.

İkincisi: tuz yok. Aynı şifre hep aynı özeti verdiği için yaygın şifrelerin özetleri önceden hesaplanıp dev tablolara konmuştur ("gökkuşağı tablosu"); özeti bulan, şifreyi bir aramayla bulur. Çaresi tuz.

"sihirdar123" rastgele tuz scrypt yavaş + tek yön tuz:özet veritabanında bu durur ✗ geri hesaplanamaz Aynı şifre, farklı tuz, farklı özet: "123456" + tuz 1f9a0c → 5092…91c "123456" + tuz 7be241 → 1d15…7f6
Şekil 31.2 Şifre, rastgele bir tuzla birlikte tek yönlü scrypt'ten geçer. Özetten şifreye geri dönüş yoktur; tuz özetin yanında saklanır.

Node'un içinde tam bu iş için yapılmış, yavaş ve tuzlu bir fonksiyon var: scrypt. Proje klasöründe sifre.js dosyasını aç:

sifre.js — giris-sistemi — Visual Studio Code
sifre.js
import { scrypt, randomBytes, timingSafeEqual } from "node:crypto"import { promisify } from "node:util" const scryptBekle = promisify(scrypt)const AYAR = { N: 16384, r: 8, p: 5 } export async function sifreyiHashle(sifre) {    const tuz = randomBytes(16)    const hash = await scryptBekle(sifre, tuz, 32, AYAR)    return `${tuz.toString("hex")}:${hash.toString("hex")}`} export async function sifreDogruMu(sifre, kayit) {    const [tuzHex, hashHex] = kayit.split(":")    const beklenen = Buffer.from(hashHex, "hex")    const gelen = await scryptBekle(sifre, Buffer.from(tuzHex, "hex"), beklenen.length, AYAR)    return timingSafeEqual(gelen, beklenen)}
Ln 1, Col 1Spaces: 4UTF-8JavaScript

İki fonksiyon: biri kayıtta şifreyi tuz:özet biçiminde bir metne çevirir, öteki girişte gelen şifreyi aynı tuzla hashleyip saklananla karşılaştırır.

Parça parça:

  • scrypt eski tarz, geri çağırmalı (Ders 10) bir fonksiyon; promisify onu await edilebilen bir Promise'e çevirir. scryptSync de var, ama o çalışırken bütün sunucu bekler.
  • randomBytes(16) her şifreye yeni, tahmin edilemez bir tuz üretir. Sonuç bir Buffer; toString("hex") onu metne çevirir.
  • Tuz ve özet tek metinde, iki noktayla ayrılmış durur. Girişte split(":") ve parçalama ikisini ayırır. Tuz olmadan aynı özeti üretemezsin, saklamak şart.
  • AYAR, scrypt'in ne kadar yavaş çalışacağını ve ne kadar bellek isteyeceğini belirler. Değerler OWASP Password Storage sayfasının scrypt seçeneklerinden biri (N=2^14, r=8, p=5). Donanım hızlandıkça öneriler artar; gerçek projede güncel değeri o sayfadan al. Aynı sayfanın ilk önerisi Argon2id; o da Node 24.7'den beri node:crypto'da var (argon2). İkisi de doğru seçim, biz yıllardır Node'da bulunan scrypt'le ilerliyoruz.

Dene:

sifre-dene.js
import { sifreyiHashle, sifreDogruMu } from "./sifre.js" const kayit = await sifreyiHashle("sihirdar123")console.log(kayit)console.log(await sifreDogruMu("sihirdar123", kayit))console.log(await sifreDogruMu("sihirdar124", kayit))console.log(await sifreyiHashle("sihirdar123"))
Çıktıörnek, sende farklı çıkabilir
4f1a3d7e0f8bd7b558f3696eeabe7196:3128cc3c80e3857e3a52b87bf3617c1016db66e74fac9a253e29a91d56d9b847truefalsed50719acaa85e36128f17961c4aa55f9:9ff3a85d8ae8b9cbed474d9e31b25c4de5f1908e268af68ab82f5074e8d90224

İlk satır saklanacak kayıt: 16 baytlık tuz, iki nokta, 32 baytlık özet. Doğru şifre true, tek harfi farklı şifre false. Son satırda aynı sihirdar123 bambaşka bir kayıt verdi, çünkü tuz yeniden üretildi. Sende bütün sayılar farklı çıkacak.

Sık hataRangeError: Invalid scrypt params: ... memory limit exceeded

Ne zaman çıkar? OWASP'taki ilk seçeneği (N=2^17) denediğinde. O ayar 128 MiB bellek ister; Node ise scrypt'e varsayılan olarak 32 MiB izin verir.

bellek-hata.js
import { scryptSync } from "node:crypto" const hash = scryptSync("mavi-ejder-ucuyor", "tuz", 32, { N: 131072, r: 8, p: 1 })console.log(hash.toString("hex"))
Hata çıktısı
node:internal/crypto/scrypt:69  const job = new ScryptJob(              ^ RangeError: Invalid scrypt params: error:030000AC:digital envelope routines::memory limit exceeded    at scryptSync (node:internal/crypto/scrypt:69:15)    at file:///C:/kod/giris-sistemi/bellek-hata.js:3:14    ...  code: 'ERR_CRYPTO_INVALID_SCRYPT_PARAMS'} Node.js v24.20.0

Nasıl düzelir? Bellek sınırını da yaz: { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 } (denediğimiz bilgisayarda 263 ms sürdü). Ama her giriş 128 MiB tutar; aynı anda yüz kişi girerse sunucu zorlanır. OWASP'ın seçenekleri aynı güvenliği farklı bellek ve süre dengeleriyle verir, sunucuna uyanı seç.

Karşılaştırmanın tuzağı: == değil, timingSafeEqual

scrypt'in verdiği şey bir metin değil, bir Buffer (ham bayt dizisi). Buffer'ları == ya da === ile karşılaştırmak işe yaramaz:

esit-dene.js
import { scryptSync, timingSafeEqual } from "node:crypto" const tuz = Buffer.from("a3f1c09b7e2d44589f10b2c3d4e5f607", "hex")const kayitli = scryptSync("mavi-ejder-ucuyor", tuz, 32, { N: 16384, r: 8, p: 5 })const gelen = scryptSync("mavi-ejder-ucuyor", tuz, 32, { N: 16384, r: 8, p: 5 }) console.log(gelen == kayitli)console.log(gelen === kayitli)console.log(timingSafeEqual(gelen, kayitli))
Çıktı
falsefalsetrue

Aynı şifre, aynı tuz, ama == ve === false diyor. Bu işaretler iki Buffer'ın aynı kutu olup olmadığına bakar (referans), içindeki baytlara bakmaz. timingSafeEqual içeriğe bakar.

Adındaki "timing safe" (zamanı ele vermeyen) de önemli. Sıradan bir karşılaştırma ilk farklı baytta durur, yani kaç baytın tuttuğuna göre biraz erken ya da geç biter. Sabırlı bir saldırgan bu süreyi ölçerek doğru cevaba bayt bayt yaklaşabilir. timingSafeEqual hep aynı sürede biter.

Sık hataRangeError: Input buffers must have the same byte length

Ne zaman çıkar? timingSafeEqual'a farklı uzunlukta iki Buffer verince: kayıtta 32, girişte 64 bayt istemek gibi.

uzunluk-hata.js
import { scryptSync, timingSafeEqual } from "node:crypto" const tuz = Buffer.from("a3f1c09b7e2d44589f10b2c3d4e5f607", "hex")const kayitli = scryptSync("mavi-ejder-ucuyor", tuz, 32, { N: 16384, r: 8, p: 5 })const gelen = scryptSync("mavi-ejder-ucuyor", tuz, 64, { N: 16384, r: 8, p: 5 }) console.log(timingSafeEqual(gelen, kayitli))
Hata çıktısı
file:///C:/kod/giris-sistemi/uzunluk-hata.js:7console.log(timingSafeEqual(gelen, kayitli))            ^ RangeError: Input buffers must have the same byte length    at file:///C:/kod/giris-sistemi/uzunluk-hata.js:7:13    ...  code: 'ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH'} Node.js v24.20.0

Nasıl düzelir? Girişte saklanan özetin uzunluğu kadar bayt iste; sifre.js bunu beklenen.length ile yapıyor.

Kayıt ve giriş uç noktaları

Şimdi Express (Ders 28) ile giriş sistemini kuralım. Çerezleri okumak için yanına cookie-parser gerekiyor:

Terminal — PowerShell
PS C:\kod\giris-sistemi> npm install express cookie-parseradded 67 packages, and audited 68 packages in 1s 28 packages are looking for funding  run `npm fund` for details found 0 vulnerabilities

Express 5.3.0 ve cookie-parser 1.4.7 ile denendi; seninkiler daha yeni olabilir. "scripts" kısmını Ders 30'daki gibi düzenle; dev sunucuyu her kayıtta yeniden başlatır ve .env'i okur:

package.json
{  "name": "giris-sistemi",  "version": "1.0.0",  "description": "",  "main": "index.js",  "scripts": {    "dev": "node --watch --env-file=.env sunucu.js"  },  "keywords": [],  "author": "",  "license": "ISC",  "type": "module",  "dependencies": {    "cookie-parser": "^1.4.7",    "express": "^5.3.0"  }}

Boş bir .env dosyası da oluştur; JWT bölümünde içine anahtar yazacaksın. Anahtar ve çerez dosyaları Git'e girmemeli, işe .gitignore ile başla:

.gitignore
node_modules.env*.txt

Üçüncü satır, curl'ün yazacağı çerez dosyaları için: içlerindeki canlı oturum kimlikleri de birer anahtar (Ders 26).

sunucu.js'in ilk hâli: kayıt ve giriş. Kullanıcılar şimdilik bellekte, bir dizide (veritabanı Blok 5'te):

sunucu.js
import express from "express"import { sifreyiHashle, sifreDogruMu } from "./sifre.js" const app = express()const port = 3000 const kullanicilar = []const yoneticiler = ["deniz"] app.use(express.json()) app.post("/kayit", async (req, res) => {    const ad = req.body?.ad    const sifre = req.body?.sifre    if (typeof ad !== "string" || typeof sifre !== "string") {        return res.status(400).json({ hata: "ad ve sifre metin olarak gelmeli" })    }    if (kullanicilar.some((k) => k.ad === ad)) {        return res.status(409).json({ hata: "Bu ad alınmış" })    }    const sifreHash = await sifreyiHashle(sifre)    const rol = yoneticiler.includes(ad) ? "yonetici" : "oyuncu"    kullanicilar.push({ ad, sifreHash, rol })    res.status(201).json({ ad, rol })}) app.post("/giris", async (req, res) => {    const ad = req.body?.ad    const sifre = req.body?.sifre    const kullanici = kullanicilar.find((k) => k.ad === ad)    if (!kullanici || typeof sifre !== "string") {        return res.status(401).json({ hata: "Ad ya da şifre yanlış" })    }    const dogruMu = await sifreDogruMu(sifre, kullanici.sifreHash)    if (!dogruMu) {        return res.status(401).json({ hata: "Ad ya da şifre yanlış" })    }    res.json({ mesaj: `Hoş geldin, ${kullanici.ad}` })}) app.listen(port, () => {    console.log(`Sunucu çalışıyor: http://localhost:${port}`)})

Üç noktaya dikkat:

  • Kayıtta şifre hemen hashleniyor; düz şifre hiçbir yere yazılmıyor. typeof denetimi metin olmayan gövdeyi 400 ile çevirir. Gövde hiç gelmezse req.body undefined olur; req.body?.ad'daki ?. o durumda çökmeyi önler.
  • Kullanıcı yoksa da, şifre yanlışsa da aynı mesaj dönüyor. "Bu ad var ama şifre yanlış" demek, saldırgana kayıtlı adları söylerdi.
  • Adı yoneticiler listesindeki kişi yonetici olur. Bu kestirme de bir açık: deniz adını ilk kim kaydederse yönetici o. Gerçek sistemde rolü bir yönetici elle verir.

Sunucuyu bir terminalde npm run dev ile başlat. İstek gövdelerini Ders 26'daki gibi dosyalara koy:

  • deniz.json: { "ad": "deniz", "sifre": "gece-yarisi-ejder-uyanir" }
  • deniz-yanlis.json: { "ad": "deniz", "sifre": "gece-yarisi-ejder-uyuyor" }
  • mert.json: { "ad": "mert", "sifre": "kuzey-ruzgari-sert-esiyor" }

Bu şifreler uydurma; gerçek bir şifreyi böyle bir dosyaya yazma. İkinci bir terminal aç (paneldeki + düğmesi) ve dene:

İkinci terminal
PS C:\kod\giris-sistemi> curl.exe --json "@deniz.json" http://localhost:3000/kayit{"ad":"deniz","rol":"yonetici"}PS C:\kod\giris-sistemi> curl.exe --json "@deniz.json" http://localhost:3000/kayit{"hata":"Bu ad alınmış"}PS C:\kod\giris-sistemi> curl.exe --json "@deniz.json" http://localhost:3000/giris{"mesaj":"Hoş geldin, deniz"}PS C:\kod\giris-sistemi> curl.exe -i --json "@deniz-yanlis.json" http://localhost:3000/girisHTTP/1.1 401 UnauthorizedX-Powered-By: ExpressContent-Type: application/json; charset=utf-8... {"hata":"Ad ya da şifre yanlış"}
Sık hataDoğru şifreyle bile giriş olmuyor (== ile karşılaştırmak)

Ne zaman çıkar? sifreDogruMu'nun son satırını == ile yazınca. Hata mesajı yok, ama doğru şifreye de "yanlış" deniyor.

sifre.js✗ Yanlış
    return gelen == beklenen
Terminal — PowerShell
PS C:\kod\giris-sistemi> curl.exe --json "@deniz.json" http://localhost:3000/kayit{"ad":"deniz","rol":"yonetici"}PS C:\kod\giris-sistemi> curl.exe --json "@deniz.json" http://localhost:3000/giris{"hata":"Ad ya da şifre yanlış"}

Nasıl düzelir? return timingSafeEqual(gelen, beklenen). Özetleri metne çevirip === ile karşılaştırmak çalışır gibi görünür ama zamanı ele verir; o kestirmeyi kullanma.

Giriş çalışıyor, ama korunan her istekte şifreyi yeniden göndermek zorundasın. Sunucunun seni hatırlaması gerekiyor: oturum ve çerez.

Oturum: sunucu seni nasıl hatırlar

Ders 26'da akışı görmüştün: giriş başarılı olunca sunucu rastgele bir oturum kimliği üretir, defterine yazar ve Set-Cookie ile tarayıcıya verir. Tarayıcı sonraki her isteğe o çerezi ekler. Oradaki turnuva bilekliğini hatırla: kapıda bir kez bilet gösterirsin, sonra hep bilekliği. Şimdi bileklikleri dağıtan ve deftere yazan görevli sensin.

Defter için bir Map kullanacağız:

sunucu.js'in baş tarafını şöyle genişlet. Yeni olan satırlar vurgulu:

sunucu.js
import express from "express"import cookieParser from "cookie-parser"import { randomBytes } from "node:crypto"import { sifreyiHashle, sifreDogruMu } from "./sifre.js" const app = express()const port = 3000 const kullanicilar = []const yoneticiler = ["deniz"]const oturumlar = new Map()const OTURUM_SURESI = 60 * 60 * 1000const CEREZ_AYARI = { httpOnly: true, secure: true, sameSite: "lax" } app.use(express.json())app.use(cookieParser()) function oturumAc(res, ad) {    const kimlik = randomBytes(32).toString("hex")    oturumlar.set(kimlik, { ad, bitis: Date.now() + OTURUM_SURESI })    res.cookie("oturum", kimlik, { ...CEREZ_AYARI, maxAge: OTURUM_SURESI })}

oturumAc, giriş başarılı olunca çağrılacak:

  • randomBytes(32) tahmin edilemez bir kimlik üretir. 1, 2, 3 gibi sıralı bir sayı olsaydı saldırgan başkasının kimliğini tahmin ederdi.
  • Defterde kimliğin karşısında kullanıcının adı ve oturumun biteceği an durur. OTURUM_SURESI bir saat, milisaniye cinsinden.
  • res.cookie(...) Express'in metodu: çerezi Set-Cookie başlığına koyar. { ...CEREZ_AYARI, maxAge: ... } ayarları yayıp süreyi ekler.
  • cookieParser() gelen isteklerdeki Cookie başlığını okuyup req.cookies objesine çevirir.

Son olarak /giris'te res.json(...) satırının hemen üstüne oturumu açan satırı ekle:

JavaScript
    oturumAc(res, kullanici.ad)    res.json({ mesaj: `Hoş geldin, ${kullanici.ad}` })

Çerezin koruma işaretleri

CEREZ_AYARI'ndaki üç işaret, bir oturum çerezinin olmazsa olmazı. Giriş yanıtına curl.exe -i ile bakınca hepsini görürsün:

İkinci terminal
PS C:\kod\giris-sistemi> curl.exe --json "@deniz.json" http://localhost:3000/kayit{"ad":"deniz","rol":"yonetici"}PS C:\kod\giris-sistemi> curl.exe -i --json "@deniz.json" http://localhost:3000/girisHTTP/1.1 200 OKX-Powered-By: ExpressSet-Cookie: oturum=43517600d768ebf41a8b5ac8bdd78688f9f4b9dea07509a421b85f7a901fffd4; Max-Age=3600; Path=/; Expires=Mon, 12 Oct 2026 01:09:58 GMT; HttpOnly; Secure; SameSite=LaxContent-Type: application/json; charset=utf-8... {"mesaj":"Hoş geldin, deniz"}

oturum='dan sonraki 64 karakter, randomBytes(32)'nin ürettiği kimlik. Max-Age=3600 bir saat; Express milisaniyeyi saniyeye kendisi çevirdi.

Set-Cookie: oturum=…; HttpOnly; Secure; SameSite=Lax HttpOnly JavaScript çerezi okuyamaz XSS'e karşı Secure yalnız HTTPS bağlantıda gider dinlemeye karşı SameSite=Lax başka siteden gelen POST'larda gitmez CSRF'e karşı
Şekil 31.3 Oturum çerezinin üç koruma işareti ve her birinin kapattığı kapı.
  • HttpOnly: Sayfadaki JavaScript çereze (document.cookie) erişemez. Sitende bir XSS açığı olsa bile saldırganın kodu oturum çerezini çalamaz; birazdan gerçek bir tarayıcıda göreceksin.
  • Secure: Çerez yalnız https:// bağlantılarda gönderilir; araya giren biri onu okuyamaz. Chrome, Edge, Firefox ve curl, localhost'u bu kuralın istisnası sayar; geliştirirken düz http ile çalışabilmen bu yüzden.
  • SameSite=Lax: Çerez, başka bir siteden tetiklenen POST gibi isteklere eklenmez; başka siteden gelen bir bağlantıya tıklayınca (üst seviye GET) eklenir. Bunun önemini CSRF bölümünde göreceksin.
Sık hataÇerez geliyor ama sonraki isteklerde geri gönderilmiyor

Ne zaman çıkar? Secure çerezi https de localhost da olmayan bir adreste kullanınca. En sık hâli: siteyi telefondan denemek için bilgisayarın ağ adresiyle açmak (http://192.168.1.108:3000). Giriş 200 döner, sonraki her istek 401 alır. curl.exe -v (ayrıntılı) nedenini söyler:

İkinci terminal
PS C:\kod\giris-sistemi> curl.exe -v -c ag.txt --json "@deniz.json" http://192.168.1.108:3000/giris...< HTTP/1.1 200 OK...* skipped cookie because not 'secure'< Set-Cookie: oturum=35dd9e1f8731d08fe67ceec45b3c0c664817c2f4abe008ea1d894b1eca980ef6; Max-Age=3600; Path=/; Expires=Mon, 12 Oct 2026 01:11:00 GMT; HttpOnly; Secure; SameSite=Lax...{"mesaj":"Hoş geldin, deniz"}PS C:\kod\giris-sistemi> curl.exe -b ag.txt http://192.168.1.108:3000/ben{"hata":"Önce giriş yap"}

"Secure olmadığı için çerez atlandı." Tarayıcı da aynısını yapar: geliştirici araçlarında Network sekmesinde giris isteğini, sonra Cookies sekmesini açarsan satırın yanında sarı bir uyarı görürsün: This attempt to set a cookie via a Set-Cookie header was blocked because it had the "Secure" attribute but was not received over a secure connection. (Arayüz sürümle değişebilir.) Benzeri sameSite: "none" ile olur: SameSite=None çerez Secure değilse tarayıcı onu localhost'ta bile reddeder.

Nasıl düzelir? Geliştirirken localhost'tan çalış. Ağ adresiyle denemen gerekiyorsa secure'u ortama göre ver: secure: process.env.NODE_ENV === "production" (Ders 29). Canlıda site https olacağı için Secure orada hep açık kalır. sameSite: "none" gerekmedikçe kullanma; "lax" çoğu site için doğru değer.

Korunan route: ara katman

Bir adresi "önce giriş yap" diye korumak için route'un önüne bir ara katman koyacağız. İki tane: biri giriş var mı diye bakar, öteki rol yetiyor mu diye. İkisini oturumAc'ın altına yaz:

sunucu.js
function girisGerekli(req, res, next) {    const kimlik = req.cookies.oturum    const oturum = oturumlar.get(kimlik)    if (!oturum) {        return res.status(401).json({ hata: "Önce giriş yap" })    }    if (Date.now() > oturum.bitis) {        oturumlar.delete(kimlik)        return res.status(401).json({ hata: "Oturumun süresi doldu, yeniden giriş yap" })    }    req.kullanici = kullanicilar.find((k) => k.ad === oturum.ad)    next()} function yoneticiGerekli(req, res, next) {    if (req.kullanici.rol !== "yonetici") {        return res.status(403).json({ hata: "Bu iş için yönetici olmalısın" })    }    next()}

girisGerekli çerezdeki kimliği defterde arar. Yoksa 401; süresi dolmuşsa kaydı silip yine 401. Varsa kullanıcıyı req.kullanici'ye koyar ve next() ile isteği sıradakine verir. yoneticiGerekli ise rolüne bakar; yetmezse 403.

İstek + çerez girisGerekli oturum var mı? next() yoneticiGerekli rol yönetici mi? next() route 200 hayır 401 giriş yok hayır 403 yetki yok
Şekil 31.4 İstek ara katman zincirinden geçer. Her kapı ya isteği yanıt yazıp durdurur ya da next() ile sıradakine verir.

yoneticiGerekli, girisGerekli'nin koyduğu req.kullanici'ye güvenir; bu yüzden sıra önemli: önce kimlik, sonra yetki. Şimdi /giris'in altına üç route ekle. Bundan sonraki parçalarda satır numaraları dosyanın ders sonundaki hâline göre; seninki şimdilik daha kısa olduğu için numaralar tutmaz, yeri tarif eden cümleye bak:

sunucu.js
app.get("/ben", girisGerekli, (req, res) => {    res.json({ ad: req.kullanici.ad, rol: req.kullanici.rol })}) app.get("/yonetim/kullanicilar", girisGerekli, yoneticiGerekli, (req, res) => {    res.json(kullanicilar.map((k) => ({ ad: k.ad, rol: k.rol })))}) app.post("/cikis", girisGerekli, (req, res) => {    oturumlar.delete(req.cookies.oturum)    res.clearCookie("oturum", CEREZ_AYARI)    res.status(204).end()})

Yönetim listesi map ile (Ders 11) yalnız ad ve rol gönderiyor; sifreHash dışarı çıkmaz. Çıkışta oturum silinir, res.clearCookie tarayıcıya "bu çerezi sil" der (kurarkenki ayarları ister). Dene; -c çerezi dosyaya yazar, -b isteğe ekler (Ders 26):

İkinci terminal
PS C:\kod\giris-sistemi> curl.exe --json "@deniz.json" http://localhost:3000/kayit{"ad":"deniz","rol":"yonetici"}PS C:\kod\giris-sistemi> curl.exe --json "@mert.json" http://localhost:3000/kayit{"ad":"mert","rol":"oyuncu"}PS C:\kod\giris-sistemi> curl.exe -c deniz.txt --json "@deniz.json" http://localhost:3000/giris{"mesaj":"Hoş geldin, deniz"}PS C:\kod\giris-sistemi> curl.exe -c mert.txt --json "@mert.json" http://localhost:3000/giris{"mesaj":"Hoş geldin, mert"}PS C:\kod\giris-sistemi> curl.exe -b deniz.txt http://localhost:3000/ben{"ad":"deniz","rol":"yonetici"}PS C:\kod\giris-sistemi> curl.exe -i http://localhost:3000/yonetim/kullanicilarHTTP/1.1 401 Unauthorized... {"hata":"Önce giriş yap"}PS C:\kod\giris-sistemi> curl.exe -i -b mert.txt http://localhost:3000/yonetim/kullanicilarHTTP/1.1 403 Forbidden... {"hata":"Bu iş için yönetici olmalısın"}PS C:\kod\giris-sistemi> curl.exe -b deniz.txt http://localhost:3000/yonetim/kullanicilar[{"ad":"deniz","rol":"yonetici"},{"ad":"mert","rol":"oyuncu"}]PS C:\kod\giris-sistemi> curl.exe -i -X POST -b deniz.txt http://localhost:3000/cikisHTTP/1.1 204 No ContentX-Powered-By: ExpressSet-Cookie: oturum=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax...PS C:\kod\giris-sistemi> curl.exe -b deniz.txt http://localhost:3000/ben{"hata":"Önce giriş yap"}

Çerezsiz istek 401, oyuncu mert 403, yönetici deniz 200. Çıkıştan sonra deniz.txt'deki kimlik artık defterde yok: 401.

Sık hataTypeError: Cannot read properties of undefined (reading 'oturum')

Ne zaman çıkar? app.use(cookieParser()) satırını unutunca: req.cookies hiç oluşmaz. Express isteğe 500 Internal Server Error döner ve sunucunun terminaline şunu yazar:

Hata çıktısı
TypeError: Cannot read properties of undefined (reading 'oturum')    at girisGerekli (file:///C:/kod/giris-sistemi/sunucu.js:24:32)    ...

24:32, girisGerekli'deki req.cookies.oturum yazısını gösteriyor: req.cookies undefined, onun oturum'u okunamıyor. (Eksik satır yüzünden dosyanın geri kalanı bir satır yukarı kaydığı için numara 25 değil 24.)

Nasıl düzelir? import cookieParser ... ve app.use(cookieParser()) satırlarını route'lardan önce yaz.

Sık hataTypeError: Cannot read properties of undefined (reading 'rol')

Ne zaman çıkar? Ara katmanları ters sıraya koyunca: yoneticiGerekli, girisGerekli. yoneticiGerekli çalıştığında req.kullanici henüz yok; herkes 500 alır, yönetici bile:

Hata çıktısı
TypeError: Cannot read properties of undefined (reading 'rol')    at yoneticiGerekli (file:///C:/kod/giris-sistemi/sunucu.js:39:23)    ...

Nasıl düzelir? Önce kimlik, sonra yetki: girisGerekli, yoneticiGerekli. Zinciri şekildeki gibi oku: soldaki kapı sağdakine malzeme hazırlar.

Sık hataGiriş yapmamış kullanıcıya 403 dönmek

Ne zaman çıkar? girisGerekli'de 401 yerine 403 yazınca. Sunucu çalışır, mesaj bile doğrudur, ama durum kodu yanlış şeyi söyler:

sunucu.js✗ Yanlış
    if (!oturum) {        return res.status(403).json({ hata: "Önce giriş yap" })    }
İkinci terminal
PS C:\kod\giris-sistemi> curl.exe -i http://localhost:3000/benHTTP/1.1 403 Forbidden... {"hata":"Önce giriş yap"}

İstemci 401 görünce giriş ekranını açar; 403 görünce "yetkin yok" deyip bekler, kullanıcı giriş yapması gerektiğini hiç öğrenmez. Tersi de sorun: yetkisiz oyuncuya 401 dönersen istemci onu boş yere giriş ekranına yollar.

Nasıl düzelir? "Bu kişi giriş yaparsa sorun çözülür mü?" diye sor: evetse 401, hayırsa 403.

Hız sınırı ve güçlü şifre

scrypt'in yavaşlığı saldırganı durdurmaz: şifreleri doğrudan /giris'e de deneyebilir. İkinci savunma, bir hesaba üst üste çok deneme yapılmasını engellemek: Ders 30'da sana konan hız sınırını bu kez sen koyuyorsun. Sayılar yine bir Map'te, adres ve ad ikilisine göre tutuluyor. yoneticiGerekli'nin altına ekle:

sunucu.js
const denemeler = new Map()const DENEME_SINIRI = 5const DENEME_PENCERESI = 15 * 60 * 1000 function girisSiniri(req, res, next) {    const anahtar = `${req.ip}|${req.body?.ad}`    let kayit = denemeler.get(anahtar)    if (!kayit || Date.now() - kayit.ilk >= DENEME_PENCERESI) {        kayit = { sayi: 0, ilk: Date.now() }        denemeler.set(anahtar, kayit)    }    if (kayit.sayi >= DENEME_SINIRI) {        const kalan = Math.ceil((kayit.ilk + DENEME_PENCERESI - Date.now()) / 1000)        res.set("Retry-After", String(kalan))        return res.status(429).json({ hata: `Çok fazla deneme. ${kalan} saniye sonra yeniden dene` })    }    kayit.sayi = kayit.sayi + 1    req.denemeAnahtari = anahtar    next()}
  • req.ip isteği atan bilgisayarın adresi (kendi bilgisayarında ::1 ya da 127.0.0.1). Anahtar ::1|deniz gibi bir metin olur.
  • İlk denemede ya da 15 dakikalık pencere dolunca sayaç sıfırdan başlar.
  • 5 deneme dolunca 429 Too Many Requests döner. res.set yanıta başlık ekler; Retry-After kaç saniye beklemek gerektiğini söyler.

Şimdi /giris'i bu kapının arkasına al ve başarılı girişte sayacı sil (vurgulu iki satır):

sunucu.js
app.post("/giris", girisSiniri, async (req, res) => {    const ad = req.body?.ad    const sifre = req.body?.sifre    const kullanici = kullanicilar.find((k) => k.ad === ad)    if (!kullanici || typeof sifre !== "string") {        return res.status(401).json({ hata: "Ad ya da şifre yanlış" })    }    const dogruMu = await sifreDogruMu(sifre, kullanici.sifreHash)    if (!dogruMu) {        return res.status(401).json({ hata: "Ad ya da şifre yanlış" })    }    denemeler.delete(req.denemeAnahtari)    oturumAc(res, kullanici.ad)    res.json({ mesaj: `Hoş geldin, ${kullanici.ad}` })})

girisSiniri, express.json()'dan sonra çalıştığı için req.body?.ad'ı okuyabiliyor. Yanlış şifreyi beş kez gönder (↑ son komutu geri getirir), altıncıya bak:

İkinci terminal
PS C:\kod\giris-sistemi> curl.exe --json "@deniz.json" http://localhost:3000/kayit{"ad":"deniz","rol":"yonetici"}PS C:\kod\giris-sistemi> curl.exe --json "@deniz-yanlis.json" http://localhost:3000/giris{"hata":"Ad ya da şifre yanlış"}# aynı komutu dört kez daha çalıştır, hep aynı cevap gelirPS C:\kod\giris-sistemi> curl.exe -i --json "@deniz-yanlis.json" http://localhost:3000/girisHTTP/1.1 429 Too Many RequestsX-Powered-By: ExpressRetry-After: 896Content-Type: application/json; charset=utf-8... {"hata":"Çok fazla deneme. 896 saniye sonra yeniden dene"}PS C:\kod\giris-sistemi> curl.exe --json "@deniz.json" http://localhost:3000/giris{"hata":"Çok fazla deneme. 896 saniye sonra yeniden dene"}PS C:\kod\giris-sistemi> curl.exe --json "@mert.json" http://localhost:3000/kayit{"ad":"mert","rol":"oyuncu"}PS C:\kod\giris-sistemi> curl.exe --json "@mert.json" http://localhost:3000/giris{"mesaj":"Hoş geldin, mert"}

Altıncı denemede 429 geldi; doğru şifreyle bile 15 dakika giremezsin. 896, pencerenin kalan saniyesi: ilk denemeden bu yana geçen süre 900'den düşülür, sende sayı biraz farklı çıkar. Tek adresten şifre listesi deneyen bir bot, bir hesap için saatte yirmi tahmini geçemez. mert etkilenmedi, çünkü sayaç adres ve ad başına. Binlerce adresten deneyeni bu sayaç tek başına yakalayamaz, ama ilk adım budur.

Üçüncü savunma, en baştan güçlü bir şifre. ABD'nin standart kurumu NIST'in güncel kılavuzu (SP 800-63B) şaşırtıcı gelebilir: önemli olan uzunluk. Yalnız şifreyle girilen sistemde şifre en az 15 karakter olmalı ve sızmış, sık kullanılan şifreler listesine karşı denetlenmeli. "Büyük harf, rakam, işaret koy" kuralları ve süreli zorunlu değişiklik ise dayatılmamalı; insanları Sihirdar1! gibi şifrelere iter. girisSiniri'nin altına bir denetçi yaz:

sunucu.js
const SITE_ADI = "sihirdar"const YAYGIN = ["123456789012345", "qwertyuiop12345", "1q2w3e4r5t6y7u8"] function sifreSorunu(ad, sifre) {    if (typeof sifre !== "string" || sifre.length < 15) {        return "Şifre en az 15 karakter olmalı"    }    const kucuk = sifre.toLocaleLowerCase("tr")    if (YAYGIN.includes(kucuk)) {        return "Bu şifre çok sık kullanılıyor, başka bir tane seç"    }    if (kucuk.includes(ad.toLocaleLowerCase("tr")) || kucuk.includes(SITE_ADI)) {        return "Şifre kullanıcı adını ya da site adını içermemeli"    }    return null}

Sorun varsa mesajını, yoksa null döndürür. İki taraf da küçültülüyor; "tr" yerel ayarı I ve İ'yi doğru küçültür. Gerçek sitede YAYGIN milyonlarca sızmış şifredir; burada üçü yeter. Denetçiyi /kayit'ta, hashlemeden önce çağır:

sunucu.js
app.post("/kayit", async (req, res) => {    const ad = req.body?.ad    const sifre = req.body?.sifre    if (typeof ad !== "string" || typeof sifre !== "string") {        return res.status(400).json({ hata: "ad ve sifre metin olarak gelmeli" })    }    const sorun = sifreSorunu(ad, sifre)    if (sorun) {        return res.status(400).json({ hata: sorun })    }    if (kullanicilar.some((k) => k.ad === ad)) {        return res.status(409).json({ hata: "Bu ad alınmış" })    }

Dene. ece.json dosyasına { "ad": "ece", "sifre": "sihirdar123" } yaz; her denemeden sonra şifreyi sıradakiyle değiştir:

İkinci terminal
PS C:\kod\giris-sistemi> curl.exe --json "@ece.json" http://localhost:3000/kayit{"hata":"Şifre en az 15 karakter olmalı"}# şifre: sihirdar-arenasi-2026PS C:\kod\giris-sistemi> curl.exe --json "@ece.json" http://localhost:3000/kayit{"hata":"Şifre kullanıcı adını ya da site adını içermemeli"}# şifre: qwertyuiop12345PS C:\kod\giris-sistemi> curl.exe --json "@ece.json" http://localhost:3000/kayit{"hata":"Bu şifre çok sık kullanılıyor, başka bir tane seç"}# şifre: kirmizi-balon-yukseliyorPS C:\kod\giris-sistemi> curl.exe --json "@ece.json" http://localhost:3000/kayit{"ad":"ece","rol":"oyuncu"}

Gerçek bir olaydan ders: şifre değişince oturumları kapat

Gerçekten yaşanmış bir olay (adını vermeyeceğim). Bir sitenin yönetici şifresi sitenin adından türetilmişti: siteadi123 gibi. Saldırgan onu saatler içinde buldu ve panele girdi. Fark edilince şifre hemen güçlü bir yenisiyle değiştirildi, ama saldırgan içeride kaldı. Neden? Şifre değişti, saldırganın açık oturumu kapanmadı; elindeki çerez şifreden bağımsız olarak geçerliydi.

İki ders çıkar. Birincisi: baştan güçlü bir şifre (denetçin sihirdar123'ü zaten reddediyor). İkincisi: şifre değişince o kullanıcının bütün oturumlarını geçersiz kılmak. Fonksiyonu sifreSorunu'nun altına, route'u app.listen'ın üstüne ekle:

sunucu.js
function oturumlariKapat(ad) {    for (const [kimlik, oturum] of oturumlar) {        if (oturum.ad === ad) {            oturumlar.delete(kimlik)        }    }}
sunucu.js
app.post("/sifre-degistir", girisGerekli, async (req, res) => {    const eskiSifre = req.body?.eskiSifre    const yeniSifre = req.body?.yeniSifre    if (typeof eskiSifre !== "string" || typeof yeniSifre !== "string") {        return res.status(400).json({ hata: "eskiSifre ve yeniSifre metin olarak gelmeli" })    }    const dogruMu = await sifreDogruMu(eskiSifre, req.kullanici.sifreHash)    if (!dogruMu) {        return res.status(403).json({ hata: "Şu anki şifren yanlış" })    }    const sorun = sifreSorunu(req.kullanici.ad, yeniSifre)    if (sorun) {        return res.status(400).json({ hata: sorun })    }    req.kullanici.sifreHash = await sifreyiHashle(yeniSifre)    oturumlariKapat(req.kullanici.ad)    oturumAc(res, req.kullanici.ad)    res.json({ mesaj: "Şifre değişti, öteki bütün oturumlar kapatıldı" })})

for (const [kimlik, oturum] of oturumlar) defteri çift çift gezer; Map'i gezerken içinden silmek güvenlidir. Route önce mevcut şifreyi doğruluyor: çerezi çalan biri şifreyi bilmeden değiştiremesin. Şifre yanlışsa 403, çünkü kişi zaten giriş yapmış; yeniden giriş yapması sorunu çözmez, 401 dönseydin istemci onu boşuna giriş ekranına yollardı. Sonra yeni hashi yazıp bütün oturumları siliyor, değişikliği yapana oturumAc ile taze bir oturum veriyor.

İki cihazdan girmişsin gibi dene. degistir.json: { "eskiSifre": "gece-yarisi-ejder-uyanir", "yeniSifre": "yedi-kule-sabaha-kadar-yanar" }.

İkinci terminal
PS C:\kod\giris-sistemi> curl.exe --json "@deniz.json" http://localhost:3000/kayit{"ad":"deniz","rol":"yonetici"}PS C:\kod\giris-sistemi> curl.exe -c laptop.txt --json "@deniz.json" http://localhost:3000/giris{"mesaj":"Hoş geldin, deniz"}PS C:\kod\giris-sistemi> curl.exe -c telefon.txt --json "@deniz.json" http://localhost:3000/giris{"mesaj":"Hoş geldin, deniz"}PS C:\kod\giris-sistemi> curl.exe -b laptop.txt -c laptop.txt --json "@degistir.json" http://localhost:3000/sifre-degistir{"mesaj":"Şifre değişti, öteki bütün oturumlar kapatıldı"}PS C:\kod\giris-sistemi> curl.exe -b laptop.txt http://localhost:3000/ben{"ad":"deniz","rol":"yonetici"}PS C:\kod\giris-sistemi> curl.exe -b telefon.txt http://localhost:3000/ben{"hata":"Önce giriş yap"}PS C:\kod\giris-sistemi> curl.exe --json "@deniz.json" http://localhost:3000/giris{"hata":"Ad ya da şifre yanlış"}

Şifreyi değiştiren laptop içeride kaldı (-c laptop.txt taze çerezi dosyaya yazdı). Öteki oturum, yani senaryodaki saldırgan, anında dışarı atıldı; eski şifre de artık geçmiyor. O olay tek bir fonksiyonla kapanabilirdi.

Kontrol: sunucu.js'in son hâli

Parçaları yerleştirirken kaybolduysan dosyanı bununla karşılaştır:

sunucu.js
import express from "express"import cookieParser from "cookie-parser"import { randomBytes } from "node:crypto"import { sifreyiHashle, sifreDogruMu } from "./sifre.js" const app = express()const port = 3000 const kullanicilar = []const yoneticiler = ["deniz"]const oturumlar = new Map()const OTURUM_SURESI = 60 * 60 * 1000const CEREZ_AYARI = { httpOnly: true, secure: true, sameSite: "lax" } app.use(express.json())app.use(cookieParser()) function oturumAc(res, ad) {    const kimlik = randomBytes(32).toString("hex")    oturumlar.set(kimlik, { ad, bitis: Date.now() + OTURUM_SURESI })    res.cookie("oturum", kimlik, { ...CEREZ_AYARI, maxAge: OTURUM_SURESI })} function girisGerekli(req, res, next) {    const kimlik = req.cookies.oturum    const oturum = oturumlar.get(kimlik)    if (!oturum) {        return res.status(401).json({ hata: "Önce giriş yap" })    }    if (Date.now() > oturum.bitis) {        oturumlar.delete(kimlik)        return res.status(401).json({ hata: "Oturumun süresi doldu, yeniden giriş yap" })    }    req.kullanici = kullanicilar.find((k) => k.ad === oturum.ad)    next()} function yoneticiGerekli(req, res, next) {    if (req.kullanici.rol !== "yonetici") {        return res.status(403).json({ hata: "Bu iş için yönetici olmalısın" })    }    next()} const denemeler = new Map()const DENEME_SINIRI = 5const DENEME_PENCERESI = 15 * 60 * 1000 function girisSiniri(req, res, next) {    const anahtar = `${req.ip}|${req.body?.ad}`    let kayit = denemeler.get(anahtar)    if (!kayit || Date.now() - kayit.ilk >= DENEME_PENCERESI) {        kayit = { sayi: 0, ilk: Date.now() }        denemeler.set(anahtar, kayit)    }    if (kayit.sayi >= DENEME_SINIRI) {        const kalan = Math.ceil((kayit.ilk + DENEME_PENCERESI - Date.now()) / 1000)        res.set("Retry-After", String(kalan))        return res.status(429).json({ hata: `Çok fazla deneme. ${kalan} saniye sonra yeniden dene` })    }    kayit.sayi = kayit.sayi + 1    req.denemeAnahtari = anahtar    next()} const SITE_ADI = "sihirdar"const YAYGIN = ["123456789012345", "qwertyuiop12345", "1q2w3e4r5t6y7u8"] function sifreSorunu(ad, sifre) {    if (typeof sifre !== "string" || sifre.length < 15) {        return "Şifre en az 15 karakter olmalı"    }    const kucuk = sifre.toLocaleLowerCase("tr")    if (YAYGIN.includes(kucuk)) {        return "Bu şifre çok sık kullanılıyor, başka bir tane seç"    }    if (kucuk.includes(ad.toLocaleLowerCase("tr")) || kucuk.includes(SITE_ADI)) {        return "Şifre kullanıcı adını ya da site adını içermemeli"    }    return null} function oturumlariKapat(ad) {    for (const [kimlik, oturum] of oturumlar) {        if (oturum.ad === ad) {            oturumlar.delete(kimlik)        }    }} app.post("/kayit", async (req, res) => {    const ad = req.body?.ad    const sifre = req.body?.sifre    if (typeof ad !== "string" || typeof sifre !== "string") {        return res.status(400).json({ hata: "ad ve sifre metin olarak gelmeli" })    }    const sorun = sifreSorunu(ad, sifre)    if (sorun) {        return res.status(400).json({ hata: sorun })    }    if (kullanicilar.some((k) => k.ad === ad)) {        return res.status(409).json({ hata: "Bu ad alınmış" })    }    const sifreHash = await sifreyiHashle(sifre)    const rol = yoneticiler.includes(ad) ? "yonetici" : "oyuncu"    kullanicilar.push({ ad, sifreHash, rol })    res.status(201).json({ ad, rol })}) app.post("/giris", girisSiniri, async (req, res) => {    const ad = req.body?.ad    const sifre = req.body?.sifre    const kullanici = kullanicilar.find((k) => k.ad === ad)    if (!kullanici || typeof sifre !== "string") {        return res.status(401).json({ hata: "Ad ya da şifre yanlış" })    }    const dogruMu = await sifreDogruMu(sifre, kullanici.sifreHash)    if (!dogruMu) {        return res.status(401).json({ hata: "Ad ya da şifre yanlış" })    }    denemeler.delete(req.denemeAnahtari)    oturumAc(res, kullanici.ad)    res.json({ mesaj: `Hoş geldin, ${kullanici.ad}` })}) app.get("/ben", girisGerekli, (req, res) => {    res.json({ ad: req.kullanici.ad, rol: req.kullanici.rol })}) app.get("/yonetim/kullanicilar", girisGerekli, yoneticiGerekli, (req, res) => {    res.json(kullanicilar.map((k) => ({ ad: k.ad, rol: k.rol })))}) app.post("/cikis", girisGerekli, (req, res) => {    oturumlar.delete(req.cookies.oturum)    res.clearCookie("oturum", CEREZ_AYARI)    res.status(204).end()}) app.post("/sifre-degistir", girisGerekli, async (req, res) => {    const eskiSifre = req.body?.eskiSifre    const yeniSifre = req.body?.yeniSifre    if (typeof eskiSifre !== "string" || typeof yeniSifre !== "string") {        return res.status(400).json({ hata: "eskiSifre ve yeniSifre metin olarak gelmeli" })    }    const dogruMu = await sifreDogruMu(eskiSifre, req.kullanici.sifreHash)    if (!dogruMu) {        return res.status(403).json({ hata: "Şu anki şifren yanlış" })    }    const sorun = sifreSorunu(req.kullanici.ad, yeniSifre)    if (sorun) {        return res.status(400).json({ hata: sorun })    }    req.kullanici.sifreHash = await sifreyiHashle(yeniSifre)    oturumlariKapat(req.kullanici.ad)    oturumAc(res, req.kullanici.ad)    res.json({ mesaj: "Şifre değişti, öteki bütün oturumlar kapatıldı" })}) app.listen(port, () => {    console.log(`Sunucu çalışıyor: http://localhost:${port}`)})

Token (JWT) fikri

Oturum defterimiz sunucunun belleğinde. Aynı anda beş sunucun çalışsaydı, istek hangisine düşerse düşsün hepsi aynı defteri görmeliydi. Bir yol, defteri veritabanında tutmak. Öteki yol: defter tutmamak, kullanıcının bilgisini kurcalanamayacak biçimde imzalayıp istemciye vermek. Bu imzalı bilgiye token denir, en yaygın biçimi JWT.

JWT'yi elle değil, bir kütüphaneyle üretirsin. jose yaygın olanlardan biri (6.2.12 ile denendi):

Terminal — PowerShell
PS C:\kod\giris-sistemi> npm install joseadded 1 package, and audited 69 packages in 1s 29 packages are looking for funding  run `npm fund` for details found 0 vulnerabilities

İmza için tahmin edilemez bir gizli anahtar gerekir. Onu da randomBytes ile üret:

sir-uret.js
import { randomBytes } from "node:crypto" console.log(randomBytes(32).toString("hex"))
Çıktıörnek, sende farklı çıkabilir
ff3a228077f94d6864be18cdb23a2052627662b8de2d203141fc214f4eb803de

Çıkan metni .env dosyasına yaz (kendi ürettiğini, buradakini değil):

.env
JWT_SIRRI=ff3a228077f94d6864be18cdb23a2052627662b8de2d203141fc214f4eb803de

Şimdi bir token üretip doğrula. Anahtar Ders 29'daki gibi process.env'den geliyor; jose onu bayt dizisi olarak istediği için TextEncoder çeviriyor:

jwt-dene.js
import { SignJWT, jwtVerify } from "jose" const sir = new TextEncoder().encode(process.env.JWT_SIRRI) const jeton = await new SignJWT({ ad: "deniz", rol: "yonetici" })    .setProtectedHeader({ alg: "HS256" })    .setIssuedAt()    .setExpirationTime("1h")    .sign(sir) console.log(jeton) const { payload } = await jwtVerify(jeton, sir)console.log(payload)
Terminal — PowerShell
PS C:\kod\giris-sistemi> node --env-file=.env jwt-dene.jseyJhbGciOiJIUzI1NiJ9.eyJhZCI6ImRlbml6Iiwicm9sIjoieW9uZXRpY2kiLCJpYXQiOjE3OTE3NjQyNjIsImV4cCI6MTc5MTc2Nzg2Mn0.-EnLY8N9vkEkJ-NAcuB9G8GCKIBH_8IR4nOsmUUpq-M{ ad: 'deniz', rol: 'yonetici', iat: 1791764262, exp: 1791767862 }

.setIssuedAt() üretim zamanını (iat), .setExpirationTime("1h") bir saat sonrasını (exp) ekledi, 1970'ten bu yana geçen saniye olarak. jwtVerify imzayı ve süreyi denetleyip veriyi verdi. Sende sayılar farklı çıkacak.

Token'ın ortasındaki parça şifreli değil, yalnızca kodlanmış; herkes okuyabilir:

jwt-oku.js
const jeton = "eyJhbGciOiJIUzI1NiJ9.eyJhZCI6ImRlbml6Iiwicm9sIjoieW9uZXRpY2kiLCJpYXQiOjE3OTE3NjQyNjIsImV4cCI6MTc5MTc2Nzg2Mn0.-EnLY8N9vkEkJ-NAcuB9G8GCKIBH_8IR4nOsmUUpq-M" const [baslik, veri, imza] = jeton.split(".")console.log(Buffer.from(baslik, "base64url").toString())console.log(Buffer.from(veri, "base64url").toString())console.log(imza)
Çıktı
{"alg":"HS256"}{"ad":"deniz","rol":"yonetici","iat":1791764262,"exp":1791767862}-EnLY8N9vkEkJ-NAcuB9G8GCKIBH_8IR4nOsmUUpq-M

base64url, baytları harf ve rakamlarla yazmanın bir yolu; gizlemez. Token imzalıdır ama gizli değildir: içine şifre gibi bir şey koyma. Saldırgan rol'ü değiştirip yeniden kodlayabilir, ama imzayı tutturamaz; bunun için sırrı bilmesi gerekir.

Sık hataDOMException [DataError]: Zero-length key is not supported

Ne zaman çıkar? Ortam değişkenini vermeden çalıştırınca: node jwt-dene.js. process.env.JWT_SIRRI undefined olur, TextEncoder boş bir bayt dizisi üretir ve imzalama çöker.

Hata çıktısı
node:internal/crypto/mac:48    throw lazyDOMException('Zero-length key is not supported', 'DataError');          ^ DOMException [DataError]: Zero-length key is not supported    at normalizeKeyLength (node:internal/crypto/mac:48:11)    ... Node.js v24.20.0

Nasıl düzelir? node --env-file=.env jwt-dene.js ile çalıştır (npm run dev bunu zaten yapıyor). Çare, anahtarı koda gömmek değil; nedeni bir sonraki kartta.

Sık hataGizli anahtarı koda gömmek

Ne zaman çıkar? ".env ile uğraşmayayım" deyip anahtarı koda yazınca. Hata mesajı çıkmaz; sorun, kod GitHub'a gidince başlar. Sırrı bilmeyen saldırganın sahte token'ı reddedilir:

Hata çıktısı
JWSSignatureVerificationFailed: signature verification failed    at verifySignature (file:///C:/kod/giris-sistemi/node_modules/jose/dist/webapi/lib/jws_verify.js:67:11)    ...  code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED'}

Ama sır koddaysa, kodu okuyan herkes onu bilir ve kendine yönetici token'ı imzalar:

jwt-gomulu.js✗ Yanlış
import { SignJWT, jwtVerify } from "jose" const sir = new TextEncoder().encode("sihirdar-gizli-anahtar") const sahte = await new SignJWT({ ad: "saldirgan", rol: "yonetici" })    .setProtectedHeader({ alg: "HS256" })    .setExpirationTime("1h")    .sign(sir) const { payload } = await jwtVerify(sahte, sir)console.log(payload)
Çıktıörnek, sende farklı çıkabilir
{ ad: 'saldirgan', rol: 'yonetici', exp: 1791767531 }

Sunucu bu token'ı gerçeğinden ayıramaz: imza tutuyor.

Nasıl düzelir? Sır her zaman .env'de, .env her zaman .gitignore'da. Sır sızdıysa onu yenile (Ders 29): eski sırla imzalanmış bütün token'lar, saldırganınkiler de, o anda geçersiz olur.

Hangisini seçmeli? İkisinin de bedeli var:

Oturum defteri (çerez)Token (JWT)
Bilgi nerede?Sunucuda; çerezde yalnız kimlikToken'ın içinde, istemcide
Bir kişiyi hemen atmakKolay: defterden silZor: süre dolmasını bekle ya da iptal listesi tut
Herkesi birden atmakDefteri boşaltİmza sırrını değiştir
Çok sunucuOrtak defter gerekirKendiliğinden çalışır

Tek sunuculu bir uygulamada sunucu tarafı oturum çoğu zaman daha basit ve daha güvenlidir, çünkü iptali kolaydır. JWT'yi tarayıcıda tutacaksan localStorage'a değil yine HttpOnly bir çereze koy: localStorage'ı sayfadaki her betik okur. Token, birçok servisin aynı kimliği paylaştığı büyük sistemlerde parlar.

Üç yaygın açık ve ilk savunma

Şimdi saldırganın gözünden üç klasik açığa bakalım. XSS ile SQL injection'ın kökü aynı: veriyle komutu karıştırmak. Ders 22'deki sohbet benzetmesini hatırla: düz mesaj sanılan /ff komut olarak çalışınca teslim oylaması başlar. Kullanıcıdan gelen metin bir yerde komut gibi çalışmaya başladığında açık doğar. Üçüncüsü, CSRF, başka bir yerden gelir: tarayıcının çerezi her isteğe kendiliğinden eklemesinden.

XSS: kullanıcının metni senin sayfanda kod olur

Ders 22'de XSS'i tarayıcı tarafında görmüştün: innerHTML'e verilen metindeki etiketler gerçek öğeye dönüşüyordu. Aynı tehlike sunucuda da var: kullanıcının yazdığını HTML'e olduğu gibi gömersen saldırgan oraya <script> yazar.

1. Saldırgan profil adına <script>… 2. Sunucu metni sayfaya olduğu gibi koyar 3. Kurban sayfayı açar 4. Kod kurbanın tarayıcısında çalışır
Şekil 31.5 XSS dört adımda: saldırgan kod yazar, sunucu onu sayfaya olduğu gibi koyar, kurban sayfayı açar, kod kurbanın tarayıcısında çalışır.

Küçük bir deneme sunucusuyla gör (sunucu.js 3000'de çalışmayı sürdürsün diye bu 3001'de):

xss-dene.js
import express from "express" const app = express()const port = 3001 function kacir(metin) {    return String(metin)        .replaceAll("&", "&amp;")        .replaceAll("<", "&lt;")        .replaceAll(">", "&gt;")        .replaceAll('"', "&quot;")        .replaceAll("'", "&#39;")} app.get("/", (req, res) => {    res.cookie("tema", "koyu")    res.cookie("oturum", "a1b2c3d4e5f6", { httpOnly: true })    res.send("<p>Çerezler kuruldu.</p>")}) app.get("/selam-tehlikeli", (req, res) => {    res.send(`<h1>Hoş geldin, ${req.query.ad}</h1>`)}) app.get("/selam-guvenli", (req, res) => {    res.send(`<h1>Hoş geldin, ${kacir(req.query.ad)}</h1>`)}) app.listen(port, () => {    console.log(`Sunucu çalışıyor: http://localhost:${port}`)})

/selam-tehlikeli, ad sorgusunu (Ders 28'deki req.query) olduğu gibi gömüyor. /selam-guvenli önce kaçırıyor: <, >, &, " ve ' işaretlerini HTML'in "bu işaret değil, yazı" dediği karşılıklarına çeviriyor. & en başta değişmeli, yoksa az önce koyduğun &lt;'yi de bozarsın. / adresi iki çerez kuruyor: biri sıradan, biri HttpOnly. Üçüncü bir terminal aç (+), node xss-dene.js ile başlat; istekleri yine ikinci terminalden at:

İkinci terminal
PS C:\kod\giris-sistemi> curl.exe "http://localhost:3001/selam-tehlikeli?ad=Deniz"<h1>Hoş geldin, Deniz</h1>PS C:\kod\giris-sistemi> curl.exe "http://localhost:3001/selam-tehlikeli?ad=<script>alert(1)</script>"<h1>Hoş geldin, <script>alert(1)</script></h1>PS C:\kod\giris-sistemi> curl.exe "http://localhost:3001/selam-guvenli?ad=<script>alert(1)</script>"<h1>Hoş geldin, &lt;script&gt;alert(1)&lt;/script&gt;</h1>

İkinci yanıttaki <script> gerçek bir etiket; tarayıcıda uyarı penceresi açar. Daha kötüsünü dene: tarayıcıda önce http://localhost:3001/ adresini aç (çerezler kurulsun), sonra http://localhost:3001/selam-tehlikeli?ad=<script>document.write(document.cookie)</script> adresine git. document.cookie sayfadaki JavaScript'in görebildiği çerezleri verir, document.write onları sayfaya yazar. Sonra aynısını selam-guvenli ile yap. Gerçek Chromium'da sonuç:

localhost:3001/selam-tehlikeli?ad=<script>document.write(document.cookie)</script>
Beyaz sayfada kalın başlık: Hoş geldin, tema=koyu. Betik çalışmış, document.cookie yalnız tema çerezini döndürmüş.
Kaçırmasız sayfa: betik çalıştı ve çerezleri yazdı, ama yalnız tema=koyu görünüyor. HttpOnly olan oturum çerezine ulaşamadı.
localhost:3001/selam-guvenli?ad=<script>document.write(document.cookie)</script>
Beyaz sayfada kalın başlık üç satıra bölünmüş: Hoş geldin, <script>document.write(document.cookie)</script>. Etiket düz yazı olarak görünüyor.
Kaçıran sayfa: aynı metin ekranda düz yazı olarak duruyor, hiçbir şey çalışmadı.

Gerçek saldırıda bu adresi saldırgan hazırlar ve kurbana bir bağlantı olarak yollar (bir Discord mesajı gibi). Kurban tıklayınca betik kurbanın tarayıcısında, kurbanın çerezleriyle çalışır. Dört adımlı şekildeki tür daha da tehlikeli: metin bir profil adı gibi sunucuda saklanır, sayfayı açan herkesi etkiler. İkisinin de çaresi aynı.

Kaçırmak saldırıyı durdurdu. Kaçırmanın unutulduğu sayfada HttpOnly oturum çerezini korudu, ama betik yine çalıştı ve kurbanın tarayıcısından onun adına istek atabilirdi; HttpOnly yalnız ikinci kat. Tarayıcıda aynı fikrin adı textContent (Ders 22): kullanıcıdan geleni asla innerHTML'e verme. Gerçek projelerde bunu kendiliğinden yapan araçlar kullanırsın (React gibi, Blok 6'da).

CSRF: başka bir site senin adına istek attırır

Diyelim ki sitende hesabı silen bir POST /hesabi-sil adresi var ve giriş yapmışsın; oturum çerezin tarayıcında. Başka bir sekmede kötü niyetli bir sayfa açtın. O sayfa gizli bir formla senin sitene POST /hesabi-sil gönderebilir. Çerez bu isteğe kendiliğinden eklenirse sunucu isteği senden geldi sanır ve hesabını siler. Buna CSRF denir.

Kurbanın tarayıcısı oturum çerezi var Kötü sayfa gizli form gönderir POST /hesabi-sil + çerez eklenirse… Sunucu "kurbandan geldi" sanır SameSite=Lax: çerez bu isteğe eklenmez
Şekil 31.6 CSRF: kurban giriş yapmışken kötü sayfayı açar; kötü sayfa siteye istek attırır. SameSite=Lax olmasaydı tarayıcı çerezi isteğe kendiliğinden eklerdi.

İlk savunmayı zaten koydun: SameSite=Lax çerezi başka siteden tetiklenen POST isteklerine eklemez; saldırı başlamadan biter. Bazı tarayıcılar işaretsiz çereze de Lax davranır, ama hepsi değil; açıkça yaz. Üç kural daha:

  • Durum değiştiren işlemleri GET ile yapma. Lax, başka siteden gelen bağlantıya tıklamada (üst seviye GET) çerezi gönderir. Aynı iş GET /hesabi-sil ile yapılsaydı kötü sayfadaki tek bir bağlantı onu kurbanın oturumuyla çalıştırır. Silme, güncelleme, şifre değiştirme hep POST, PUT, PATCH, DELETE olsun (Ders 26).
  • Önemli işlemde şifreyi yeniden iste. /sifre-degistir'in eski şifreyi istemesi bu yüzden de iyi: kötü sayfa senin şifreni bilmez, isteği tamamlayamaz.
  • Büyük projelerde bir adım daha: sunucu her forma tahmin edilemez bir "CSRF token" koyar ve gelen istekte onu arar; kötü sayfa bu değeri bilemez. Ayrıntısı OWASP CSRF sayfasında.

SQL injection: kullanıcının metni veritabanı komutu olur

Bunu Blok 5'te tam göreceksin, fikri şimdiden tanı. Veritabanına "şu adlı kullanıcıyı getir" gibi komutlar metin olarak gönderilir; bu dilin adı SQL (Ders 33). Kullanıcının yazdığı ad komut metnine + ile yapıştırılırsa ne olur?

sql-dene.js
const ad = "' OR '1'='1"const komut = "SELECT * FROM kullanicilar WHERE ad = '" + ad + "'"console.log(komut)
Çıktı
SELECT * FROM kullanicilar WHERE ad = '' OR '1'='1'

"Adı şu olanı getir" komutu, araya giren tırnak ve OR yüzünden "adı boş olanı ya da 1'in 1'e eşit olduğu her satırı getir" oldu. '1'='1' her satır için doğru: veritabanı bütün kullanıcıları verir.

Kullanıcı ad kutusuna yazar: ' OR '1'='1 komuta olduğu gibi yapıştırılır Oluşan veritabanı komutu: … WHERE ad = '' OR '1'='1' sonuç: herkes gelir Savunma: veriyi komuta yapıştırma, ayrı gönder (parametreli sorgu).
Şekil 31.7 Kullanıcının adı komuta yapıştırılınca komutun anlamı değişir: "şu kişiyi bul" yerine "herkesi bul".

Çaresi XSS'teki gibi veriyle komutu karıştırmamak. Veritabanında adı parametreli sorgu: ad komut metnine yapıştırılmaz, ayrı bir kanaldan gider; veritabanı onu hep veri sayar. Ders 37'de gerçek bir veritabanıyla yapacaksın. Şimdilik aklında kalsın: dışarıdan gelen hiçbir metne komut gibi davranma.

Hazır çözümler: kendi sistemini sıfırdan yazma

Artık bir giriş sisteminin altında ne olduğunu biliyorsun: hash, tuz, oturum, çerez işaretleri, yetki, hız sınırı. Tam da bu yüzden: gerçek bir üründe bunu sıfırdan kendin yazma. Güvenlikte gözden kaçan tek ayrıntı her şeyi açar; şifre sıfırlama, iki adımlı doğrulama, hesap kurtarma gibi daha görmediğin parçalar da var. Binlerce kişinin kullandığı, açıkları yıllardır kapatılan çözümler ilk denemenden çok daha güvenlidir.

İki yaygın yol var. Birincisi, girişi tümüyle başka bir servise bırakmak: OAuth ile "Discord ile giriş" ya da "Google ile giriş".

Akış kabaca şöyle (bu sitedeki Discord ile giriş de böyle çalışıyor):

  1. Kullanıcı "Discord ile giriş"e basar. Sunucun onu Discord'un izin sayfasına (https://discord.com/oauth2/authorize) yollar; adreste uygulamanın kimliği, dönüş adresi ve rastgele bir state değeri vardır.
  2. Kullanıcı Discord'da "izin ver"e basar. Discord onu senin dönüş adresine, tek kullanımlık bir code ile yollar.
  3. Sunucun state'in kendi verdiğiyle aynı olduğunu denetler (OAuth'un CSRF koruması), sonra code'u uygulamanın gizli anahtarıyla Discord'un token adresine gönderir.
  4. Aldığı erişim token'ıyla Discord'a "bu kim?" diye sorar (/users/@me) ve kendi oturumunu açar, bu dersteki oturumAc gibi.

Kullanıcının Discord şifresi sana hiç uğramaz. Uygulamanın gizli anahtarı Ders 30'daki API anahtarı gibi .env'de durur. Ayrıntılar Discord OAuth2 belgesinde; adresler değişebilir, kod yazmadan önce oraya bak.

İkinci yol: şifre sistemini sen tutarsın ama yazmazsın. Olgun bir kimlik kütüphanesi hash'ten oturuma, OAuth'tan şifre sıfırlamaya kadar hepsini hazır verir: Express için yıllardır kullanılan Passport.js ya da daha yeni Better Auth gibi. Auth0, Clerk, Supabase Auth gibi servisler ise bütün işi kendi sunucularında yapar. Alan hızlı değişiyor (2025'te Auth.js, Better Auth'a katıldı; Lucia bir öğrenme kaynağına dönüştü); seçmeden önce Ders 14'teki ölçütlerle güncel durumu araştır.

Peki bu dersi niye yaptık? Hazır bir çözümü doğru kurmak için Secure, SameSite, hız sınırı gibi ayarların ne işe yaradığını bilmen gerekir. Artık biliyorsun.

Görevler

Görevleri giris-sistemi klasöründe ayrı dosyalarda yap; hiçbiri sunucu ya da paket gerektirmiyor. Önce kendin dene, takılırsan ipuçlarını sırayla aç.

Görev 1

Doğru kapı, doğru kod

erisimKodu(kullanici, gerekenRol) fonksiyonunu yaz. kullanici giriş yapanın objesi ({ ad, rol }) ya da giriş yoksa null. gerekenRol sayfanın istediği rol; null ise giriş yeter. Giriş yoksa 401, rol tutmuyorsa 403, yoksa 200 döndürsün.

JavaScript
const mert = { ad: "mert", rol: "oyuncu" }const deniz = { ad: "deniz", rol: "yonetici" } console.log(erisimKodu(null, "yonetici"))console.log(erisimKodu(null, null))console.log(erisimKodu(mert, "yonetici"))console.log(erisimKodu(mert, null))console.log(erisimKodu(deniz, "yonetici"))
Metin
401401403200200
İpucu 1

Önce kimlik, sonra yetki. İlk if kullanici yoksa 401 döndürüp çıksın (Ders 6'daki erken çıkış).

İpucu 2

İkinci denetim yalnız bir rol isteniyorsa anlamlı: if (gerekenRol && kullanici.rol !== gerekenRol).

Çözümü göster
erisim.js
function erisimKodu(kullanici, gerekenRol) {    if (!kullanici) {        return 401    }    if (gerekenRol && kullanici.rol !== gerekenRol) {        return 403    }    return 200} const mert = { ad: "mert", rol: "oyuncu" }const deniz = { ad: "deniz", rol: "yonetici" } console.log(erisimKodu(null, "yonetici"))console.log(erisimKodu(null, null))console.log(erisimKodu(mert, "yonetici"))console.log(erisimKodu(mert, null))console.log(erisimKodu(deniz, "yonetici"))
Çıktı
401401403200200

Sınır: erisimKodu(null, null) rol istemeyen sayfada bile 401; giriş yoksa yetki sorulmaz. İki if'in yerini değiştirirsen null'ın rol'ünü okumaya çalışıp çökersin.

Görev 2

Güvenli oyuncu listesi

listeYap(adlar) bir ad dizisinden <ul><li>…</li></ul> biçiminde HTML üretsin; her ad kaçırılmış olsun (&, <, >, ", '). Boş dizi <ul></ul> versin.

Metin
<ul><li>Teemo</li><li>&lt;b&gt;Jinx&lt;/b&gt;</li><li>Kai&#39;Sa &amp; Lux</li></ul><ul></ul>
İpucu 1

Önce kaçırma fonksiyonunu yaz, sonra map ile her adı <li>…</li>'ye çevir, join("") ile birleştir (Ders 11).

İpucu 2

&'i en başta değiştir; sonra yaparsan &amp;lt; gibi bozuk çıktılar alırsın.

Çözümü göster
liste.js
function kacir(metin) {    return String(metin)        .replaceAll("&", "&amp;")        .replaceAll("<", "&lt;")        .replaceAll(">", "&gt;")        .replaceAll('"', "&quot;")        .replaceAll("'", "&#39;")} function listeYap(adlar) {    const satirlar = adlar.map((ad) => `<li>${kacir(ad)}</li>`)    return `<ul>${satirlar.join("")}</ul>`} console.log(listeYap(["Teemo", "<b>Jinx</b>", "Kai'Sa & Lux"]))console.log(listeYap([]))
Çıktı
<ul><li>Teemo</li><li>&lt;b&gt;Jinx&lt;/b&gt;</li><li>Kai&#39;Sa &amp; Lux</li></ul><ul></ul>

Sınır: boş dizide join("") boş metin verir, çıktı <ul></ul>. Etiketleri sen yazdığın için kaçırmıyorsun; yalnız dışarıdan gelen adları kaçırıyorsun.

Görev 3

Kayıtlı şifreyi doğrula

Bir arkadaşının sunucusu scrypt ayarlarını da kaydın içine yazıyor. Böylece ileride N'yi artırsa bile eski kayıtlar kendi ayarlarıyla doğrulanabiliyor. Kayıt $ ile ayrılmış altı parça: algoritma adı, N, r, p, tuz ve özet (ikisi de hex, özet 32 bayt). Şu kayıt ejderin-gozu-acik şifresine ait:

Metin
scrypt$16384$8$5$9d2a61f0c4b87e135a0f6c2d8e4b7a19$134b34a15a3e85f7760650767ff76b165e3cdfbb8d23efe2fa96b6c408daefdb

dogrula(sifre, kayit) fonksiyonunu scryptSync ve timingSafeEqual ile yaz: doğru şifrede true, yanlışta false. Kayıt bozuksa çökmesin, false dönsün: parça sayısı altı değilse, algoritma scrypt değilse ya da özet 32 bayt değilse. Şu beş deneme (doğru şifre, büyük E'li şifre, son iki karakteri silinmiş kayıt, scrypt yerine md5 yazan kayıt, yarım kalmış scrypt$16384$8$5 kaydı) şunu yazmalı:

Metin
truefalsefalsefalsefalse
İpucu 1

kayit.split("$") bir dizi verir; önce length'ine bak, sonra parçalama ile adlandır: const [algoritma, n, r, p, tuzHex, ozetHex] = parcalar. n, r, p metin olarak gelir; scrypt sayı ister, Number() ile çevir (Ders 9).

İpucu 2

Buffer.from(ozetHex, "hex") metni baytlara çevirir, .length kaç bayt olduğunu söyler. Bozuk kayıtta timingSafeEqual hangi hatayı fırlatıyordu? Ona gelmeden erken false döndür.

Çözümü göster
dogrula.js
import { scryptSync, timingSafeEqual } from "node:crypto" const kayit = "scrypt$16384$8$5$9d2a61f0c4b87e135a0f6c2d8e4b7a19$134b34a15a3e85f7760650767ff76b165e3cdfbb8d23efe2fa96b6c408daefdb" function dogrula(sifre, kayit) {    const parcalar = kayit.split("$")    if (parcalar.length !== 6) {        return false    }    const [algoritma, n, r, p, tuzHex, ozetHex] = parcalar    const beklenen = Buffer.from(ozetHex, "hex")    if (algoritma !== "scrypt" || beklenen.length !== 32) {        return false    }    const ayar = { N: Number(n), r: Number(r), p: Number(p) }    const gelen = scryptSync(sifre, Buffer.from(tuzHex, "hex"), 32, ayar)    return timingSafeEqual(gelen, beklenen)} console.log(dogrula("ejderin-gozu-acik", kayit))console.log(dogrula("Ejderin-gozu-acik", kayit))console.log(dogrula("ejderin-gozu-acik", kayit.slice(0, -2)))console.log(dogrula("ejderin-gozu-acik", kayit.replace("scrypt", "md5")))console.log(dogrula("ejderin-gozu-acik", "scrypt$16384$8$5"))
Çıktı
truefalsefalsefalsefalse

İkinci denemede tek fark büyük E; şifrede büyük-küçük harf önemlidir. Üçüncüde özet 31 bayta düştü; uzunluk denetimi olmasaydı RangeError: Input buffers must have the same byte length ile çökerdin. Beşincide yalnız dört parça var; parça sayısına bakmasaydın ozetHex undefined olur, Buffer.from TypeError fırlatırdı. scryptSync burada kolaylık için; sunucuda asenkron sürümü kullan.

Görev 4

Yeni şifre kuralı

Şifre değiştirme için yeniSifreSorunu(eski, yeni) yaz. Sırayla denetleyip ilk sorunun mesajını, sorun yoksa null döndürsün:

  1. 15 karakterden kısaysa "en az 15 karakter olmalı"
  2. Eskisiyle aynıysa (büyük-küçük harf fark etmeden) "eski şifreyle aynı olamaz"
  3. Tek bir karakterin tekrarıysa ("aaaaaaaaaaaaaaa" gibi) "tek bir karakterin tekrarı olamaz"

Eski şifre gece-yarisi-ejder iken yeni şifre sırayla kara-kule-yedi (14 karakter), Gece-Yarisi-Ejder, aaaaaaaaaaaaaaa (15 tane a) ve kara-kule-sekiz (15 karakter) olunca şu çıkmalı:

Metin
en az 15 karakter olmalıeski şifreyle aynı olamaztek bir karakterin tekrarı olamaznull
İpucu 1

Büyük-küçük harfi yok saymak için ikisini de toLocaleLowerCase("tr") ile küçült, sonra === ile karşılaştır.

İpucu 2

Tek karakter tekrarını bulmanın bir yolu: ilk karakteri (yeni[0]) replaceAll (Ders 9) ile her yerden sil. Geriye "" kalıyorsa bütün karakterler aynıymış.

Çözümü göster
yeni-sifre.js
function yeniSifreSorunu(eski, yeni) {    if (yeni.length < 15) {        return "en az 15 karakter olmalı"    }    if (yeni.toLocaleLowerCase("tr") === eski.toLocaleLowerCase("tr")) {        return "eski şifreyle aynı olamaz"    }    if (yeni.replaceAll(yeni[0], "") === "") {        return "tek bir karakterin tekrarı olamaz"    }    return null} console.log(yeniSifreSorunu("gece-yarisi-ejder", "kara-kule-yedi"))console.log(yeniSifreSorunu("gece-yarisi-ejder", "Gece-Yarisi-Ejder"))console.log(yeniSifreSorunu("gece-yarisi-ejder", "aaaaaaaaaaaaaaa"))console.log(yeniSifreSorunu("gece-yarisi-ejder", "kara-kule-sekiz"))
Çıktı
en az 15 karakter olmalıeski şifreyle aynı olamaztek bir karakterin tekrarı olamaznull

Sınır: 14 karakterlik kara-kule-yedi reddedildi, tam 15 olan kara-kule-sekiz geçti (< kullandığın için). aaaaaaaaaaaaaaa uzunluğu geçiyor ama üçüncü kurala takılıyor; kuralların sırası hangi mesajın görüneceğini belirler.

Görev 5

Öteki oturumları kapat

Dersteki oturumlariKapat şifreyi değiştirenin oturumunu da siliyordu, sonra ona yenisini açıyorduk. Daha kibar bir sürüm yaz: digerOturumlariKapat(oturumlar, ad, mevcutKimlik) o kullanıcının mevcut oturum dışındaki bütün oturumlarını silsin ve kaç tane sildiğini döndürsün; başkalarınınkine dokunmasın.

Defterde a1, b2, d4 (deniz) ve c3 (mert) varken deniz için b2'yi koruyarak iki kez çağırınca şu çıkmalı:

Metin
22true true0

İkinci satır kalan oturum sayısı, üçüncüsü b2 ve c3'ün hâlâ defterde olup olmadığı.

İpucu 1

Dersteki for (const [kimlik, oturum] of oturumlar) döngüsünü kullan. Silme koşulu iki parçalı: ad tutuyor ve kimlik mevcut kimlik değil.

İpucu 2

Döngüden önce let kapanan = 0, her silmede bir artır, en sonda döndür. Varlığa oturumlar.has("b2") ile bak.

Çözümü göster
oturum-kapat.js
function digerOturumlariKapat(oturumlar, ad, mevcutKimlik) {    let kapanan = 0    for (const [kimlik, oturum] of oturumlar) {        if (oturum.ad === ad && kimlik !== mevcutKimlik) {            oturumlar.delete(kimlik)            kapanan = kapanan + 1        }    }    return kapanan} const oturumlar = new Map()oturumlar.set("a1", { ad: "deniz" })oturumlar.set("b2", { ad: "deniz" })oturumlar.set("c3", { ad: "mert" })oturumlar.set("d4", { ad: "deniz" }) console.log(digerOturumlariKapat(oturumlar, "deniz", "b2"))console.log(oturumlar.size)console.log(oturumlar.has("b2"), oturumlar.has("c3"))console.log(digerOturumlariKapat(oturumlar, "deniz", "b2"))
Çıktı
22true true0

Sınır: ikinci çağrıda deniz'in yalnız mevcut oturumu kaldı, 0. kimlik !== mevcutKimlik'i unutursan ilk çağrı 3 döndürür ve şifreyi değiştiren de dışarı atılır.

Görev 6

Sohbet hız sınırı

Dersteki girisSiniri sabit pencere kullanıyor: sayaç ilk denemeyle başlar, süre dolunca birden sıfırlanır. Açığı şu: denemelerini pencerenin son saniyelerine yığan biri, pencere sıfırlanır sıfırlanmaz beş deneme daha yapar; birkaç saniyeye sınırın neredeyse iki katı sığar. Bu kez kayan pencere yaz: her mesajda geriye dönüp yalnız son 10 saniyeye bakılır.

Oyun içi sohbette bir oyuncu son 10 saniye içinde en fazla 3 mesaj atabilsin. mesajIzni(oyuncu, simdi) izin varsa mesajın zamanını kaydedip true, yoksa false döndürsün; reddedilen mesaj kaydedilmez. simdi milisaniye cinsinden zaman; Date.now() yerine elle verdiğin için beklemeden test edersin. simdi - zaman farkı 10000'den küçük olan mesajlar son 10 saniyeye girer. Her oyuncunun kaydı ayrı.

JavaScript
console.log(mesajIzni("jinx", 0))console.log(mesajIzni("jinx", 4000))console.log(mesajIzni("jinx", 8000))console.log(mesajIzni("jinx", 9000))console.log(mesajIzni("lux", 9000))console.log(mesajIzni("jinx", 10000))console.log(mesajIzni("jinx", 11000))console.log(mesajIzni("jinx", 13999))console.log(mesajIzni("jinx", 14000))
Metin
truetruetruefalsetruetruefalsefalsetrue
İpucu 1

Sayaç yetmez; her oyuncu için bir Map'te mesaj zamanlarının dizisini tut. Kayıt yoksa boş diziyle başla: gecmis.get(oyuncu) ?? [] (Ders 12).

İpucu 2

Her çağrıda önce eskileri at: filter ile yalnız simdi - zaman < 10000 olanları bırak (Ders 11). Kalanların sayısı 3'ten azsa izin var; o zaman simdi'yi diziye ekle. Diziyi her durumda Map'e geri yaz.

İpucu 3

Sınırlar: 10000'de 0'daki mesaj tam 10000 ms eskidi, düşer; geriye iki mesaj kalır, izin. 11000'de son 10 saniyede 4000, 8000 ve 10000 var, ret. Sabit pencerede 10000'de sayaç sıfırlanacağı için 11000 ve 13999'da izin çıkardı; fark tam burada. 14000'de 4000'deki mesaj da düşer.

Çözümü göster
sohbet.js
const SINIR = 3const PENCERE = 10000const gecmis = new Map() function mesajIzni(oyuncu, simdi) {    const eski = gecmis.get(oyuncu) ?? []    const son = eski.filter((zaman) => simdi - zaman < PENCERE)    const izin = son.length < SINIR    if (izin) {        son.push(simdi)    }    gecmis.set(oyuncu, son)    return izin} console.log(mesajIzni("jinx", 0))console.log(mesajIzni("jinx", 4000))console.log(mesajIzni("jinx", 8000))console.log(mesajIzni("jinx", 9000))console.log(mesajIzni("lux", 9000))console.log(mesajIzni("jinx", 10000))console.log(mesajIzni("jinx", 11000))console.log(mesajIzni("jinx", 13999))console.log(mesajIzni("jinx", 14000))
Çıktı
truetruetruefalsetruetruefalsefalsetrue

jinx'in dördüncü mesajı sınıra takıldı; lux serbest, çünkü dizisi ayrı. < yerine <= yazarsan 10000'deki mesaj reddedilir: 0'daki mesaj "tam 10 saniye önce" sayılıp pencerede kalır. Bedeli bellek: sabit pencere oyuncu başına iki sayı tutar, kayan pencere son mesajların hepsini.

Kendini sına

Soru 1

Giriş yapmamış biri yönetici sayfasını isterse hangi kodu alır, 401 mi 403 mü? Giriş yapmış bir oyuncu isterse?

Cevabı göster

Giriş yapmamış kişi 401 alır: kim olduğu bilinmiyor. Giriş yapmış oyuncu 403 alır: kim olduğu biliniyor ama rolü yetmiyor. Akıl kuralı: "giriş yaparsa sorun çözülür mü?" Evetse 401.

Soru 2

Şifreyi neden sha256 ile değil scrypt ile saklıyoruz? İki sebep.

Cevabı göster

Hız: sha256 çok hızlıdır, saldırgan çalınmış özetlere karşı saniyede milyonlarca şifre dener; scrypt kasıtlı olarak yavaştır. Tuz: tuzsuz sha256'da aynı şifre hep aynı özeti verir ve hazır tablolarla çözülür; her şifreye rastgele tuz verince aynı iki şifre bile farklı özet verir.

Soru 3

Bu kod iki özeti karşılaştırıyor ama doğru şifrede bile hep yanlış sonuç veriyor. Neden?

JavaScript
if (gelenHash == kayitliHash) {    girisBasarili()}
Cevabı göster

İkisi de Buffer. == aynı kutu olup olmadıklarına bakar (Ders 12), baytlara bakmaz; iki ayrı Buffer, içerikleri aynı olsa bile false verir. Doğrusu timingSafeEqual(gelenHash, kayitliHash): içeriği karşılaştırır, süreyi ele vermez.

Soru 4

Oturum çerezindeki HttpOnly ve SameSite=Lax işaretleri hangi saldırılara karşı işe yarar?

Cevabı göster

HttpOnly sayfadaki JavaScript'in çerezi okumasını engeller; XSS açığında saldırganın betiği oturum çerezini çalamaz (ekran görüntüsünde yalnız tema görünüyordu). SameSite=Lax çerezin başka siteden tetiklenen POST isteklerine eklenmesini engeller; CSRF'e karşı ilk savunma. Secure ise çerezi yalnız HTTPS'te gönderir.

Soru 5

Bu route'u giriş yapmış bir yönetici istese bile ne olur? Neden?

JavaScript
app.get("/yonetim", yoneticiGerekli, girisGerekli, (req, res) => {    res.json({ gizli: "veri" })})
Cevabı göster

Herkes 500 alır, yönetici bile. yoneticiGerekli önce çalışıp req.kullanici.rol'ü okuyor, ama req.kullanici'yi henüz çalışmamış girisGerekli koyuyor. Sonuç: TypeError: Cannot read properties of undefined (reading 'rol'). Doğru sıra: girisGerekli, yoneticiGerekli.

Soru 6

Bir hesabın şifresinin çalındığını fark ettin ve güçlü bir yenisiyle değiştirdin. Saldırgan hâlâ içeride olabilir mi? Ne yapmalısın?

Cevabı göster

Evet. Oturum çerezi şifreden bağımsızdır; şifre değişince açık oturumlar kendiliğinden kapanmaz. O kullanıcının bütün oturumlarını silmelisin (oturumlariKapat). Oturumlar JWT ise imza sırrını değiştirmek bütün token'ları geçersiz kılar. Derste anlatılan olayda bu adım atlandığı için saldırgan içeride kalmıştı.

Soru 7

Token (JWT) imzalıysa, içindeki rol'ü kimse okuyamaz mı?

Cevabı göster

Okuyabilir. Veri parçası yalnızca base64url ile kodlanmış; jwt-oku.js onu Buffer.from(veri, "base64url") ile açtı. İmza verinin değiştirilmediğini kanıtlar, gizlemez. Token'a gizli bir şey konmaz; değiştirilmiş token'ın imzası ise tutmaz ve jwtVerify onu reddeder.

Soru 8

Bu iki satırdan hangisi SQL injection'a açık, neden?

JavaScript
const komut1 = "SELECT * FROM maclar WHERE oyuncu = '" + oyuncuAdi + "'"const komut2 = "SELECT * FROM maclar WHERE oyuncu = 'Ahri'"
Cevabı göster

komut1. Dışarıdan gelen oyuncuAdi komuta yapıştırılıyor; içine tırnak ve OR yazan biri komutun anlamını değiştirir. komut2 sabit, dışarıdan bir şey almıyor. komut1'in doğrusu parametreli sorgu (Ders 37).

Kayıt noktası

Giriş sistemini Git'e kaydet. Yeni bir proje klasörü olduğu için önce git init. Commit'ten önce git status ile bak: listede .env, node_modules ya da .txt çerez dosyaları görürsen dur ve .gitignore'u düzelt.

Bir sonraki derste bu API'yi internete açacaksın: barındırma seçenekleri, ortam değişkenlerini canlıya taşımak, loglar ve alan adı. Blok 4'ün proje dersi, Ders 32: yayına alma ve takım API'si.

İşaret yalnızca bu tarayıcıda saklanır, kimseye gönderilmez.