İleri
guvenlik/guvenli-dosya-yukleme
Güvenli Dosya Yükleme Sistemi
- PHP 8
- PDO
- MySQL
- finfo
- +3
195 inceleme 75 canlı açılış 1 beğeni
Güvenlik İleri
PHP ile sıfır bağımlılık JWT REST API: HS256 imza kütüphanesiz yazılmış, API anahtarı hash'li saklanıyor, kapsam (scope) tabanlı yetkilendirme ve anahtar başına kayan pencereli hız sınırı var. alg:none saldırısı, imza kurcalama ve süresi dolmuş jeton senaryoları demoda tek tıkla denenebiliyor.
Veritabanı şeması projedeki cy_api_jwt.sql dosyasında.
Bir mobil uygulama, bir SPA ya da başka bir servis, sizin veritabanınızdaki veriye erişecek. Oturum çerezi kullanamıyorsunuz — çerez tarayıcıya aittir, mobil uygulamaya değil. Cevap bilinir: jeton tabanlı API.
Sorun, jetonun kendisini yazmaktır. İnternetteki çoğu örnek şunu yapar:
// Kütüphaneden gelen "kolay" kullanım
$claims = JWT::decode($token, $key, ['HS256', 'RS256', 'none']);
if ($claims->user_id) { /* içeri al */ }none algoritmasına izin verilmiş → saldırgan imzayı silip payload'ı istediği gibi yazarexp hiç kontrol edilmemiş → çalınan jeton sonsuza dek geçerliBu proje o üç soruyu ve bir API'nin diğer beş zor sorusunu cevaplıyor — hepsi kütüphanesiz, yaklaşık 60 satırlık bir jwt_encode / jwt_decode çiftiyle:
hash_equals() ile sabit zamanlı HMAC doğrulamasıalg: none gelirse? → algoritma jetondan okunmaz, sabittirJWT_TTL) + iss/aud kontrolüpassword_hash)error.code ile tutarlı JSON zarfıWHERE'indeKimler için uygun?
401 ile 403 arasındaki farkı doğru kurmak isteyenlerKlonla, cy_api_jwt.sql'i içe aktar, çalıştır. Başka hiçbir kurulum adımı yok. Composer yok, npm yok, internet bağlantısı bile gerekmiyor — tüm kütüphaneler proje içinde.
Bu proje, Çılgın Yazılım Kütüphanesi altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.
// TİPİK HATALI KOD — algoritmayı saldırgan seçiyor
$header = json_decode(base64_decode($h64), true);
$alg = $header['alg']; // "none" gelebilir!
if ($alg === 'none') { /* imza yok, geç */ }
// BU PROJEDE — algoritma SABİT
if (!is_array($header) || ($header['alg'] ?? '') !== 'HS256') {
return [null, 'bad_alg']; // imza HİÇ hesaplanmadan reddedilir
}alg: none saldırısı gerçek kütüphanelerde yıllarca yaşadı ve bugün hâlâ kopyalanan örneklerde duruyor. Saldırgan alg değerini none yapar, imza bölümünü siler ve payload'a istediği sub ile scopes değerlerini yazar. Kod algoritmayı jetonun kendisinden okuyorsa, saldırganın kimliğini kendi kendine onaylamış olur.
Doğrusu, sunucunun hangi algoritmayı kabul ettiğini önceden bilmesidir. Demodaki "alg: none saldırısı" senaryosu bu jetonu tarayıcıda üretir ve reddedilişini gösterir.
hash_equals ile kıyaslanır// TİPİK HATALI KOD
if ($expectedSig === $gelenSig) { /* ... */ }
// BU PROJEDE
if (!hash_equals($expectedSig, b64url_decode($s64))) {
return [null, 'bad_signature'];
}=== operatörü dizeleri bayt bayt kıyaslar ve ilk farklı baytta çıkar. Kıyas süresi, kaç baytın tuttuğuna bağlı olarak değişir; bu fark ağ üzerinden bile ölçülebilir. Saldırgan imzayı bayt bayt tahmin ederek doğru olanı bulabilir.
hash_equals() uzunluktan bağımsız olarak hep aynı süreyi harcar. Bu, jeton doğrulamasının pazarlık edilemez parçasıdır.
Authorization: ••••••••
│
├── require_auth() → jeton geçerli mi? değilse 401
│ (imza, exp, nbf, iss, aud)
│
└── require_scope() → bu işlemi yapabilir mi? değilse 403
(scopes claim'i)Bu projede dört demo anahtarı bu ayrımı somutlaştırır: demo_writer yazabilir ama okuyamaz. "Yazabiliyorsa okuyabilir" diye bir kural yoktur.
-- TİPİK HATALI ŞEMA
`secret` VARCHAR(64) NOT NULL, -- ham secret; sızan döküm = ele geçen API
-- BU PROJEDE
`secret_hash` VARCHAR(255) NOT NULL, -- password_hash(); geri üretilemezDoğrulama password_verify() ile yapılır ve anahtar bulunamasa bile çalıştırılır: yoksa "bu key_id var ama secret yanlış" ile "bu key_id hiç yok" arasındaki süre farkı ölçülebilir hâle gelirdi.
// TİPİK HATALI KOD — bir yerde unutulur
$not = $db->query("SELECT * FROM api_notes WHERE id = $id")->fetch();
if ($not['owner_key'] !== $claims['sub']) { /* 403 */ }
// BU PROJEDE — unutmak İMKÂNSIZ
$stmt = $db->prepare('SELECT … FROM api_notes WHERE id = :id AND owner_key = :k');WHERE'e gömülünce kontrol atlanabilir bir adım olmaktan çıkar.
Bir ayrıntı daha: yetkisiz kayıt için 403 değil 404 dönüyoruz. 403, "böyle bir kayıt var ama senin değil" bilgisini verirdi; o bilgi, başkasının kaç kaydı olduğunu saymaya yarar.
JWT çekirdeği (kütüphanesiz)
hash_hmac + hash_equalsalg sabit — none ve algoritma karışıklığı kapalıexp, nbf kontrolü JWT_LEEWAY toleransıylaexpi olmayan jeton reddediliriss / aud doğrulamasıjti (jeton kimliği) baştan bulunurKimlik ve yetki
secret password_hash() ile saklanıractive = 0 ile anahtarı silmeden kapatmaAPI tasarımı
data / meta / errorerror.code + insan için error.messageAllow, Location, WWW-Authenticate, Retry-After başlıkları?q=), sıralama (beyaz listeli)GET / ile uç listesi (discovery)OPTIONS → 204)Konsol ve tasarım
#dene-…, #detay-…)localStorage'a yazılmaz — yalnızca bellekte| Açık | Tipik hatalı kod | Bu projede |
|---|---|---|
alg: none saldırısı | $alg = $header['alg']; — algoritma jetondan okunur | alg !== 'HS256' ise imza hiç hesaplanmadan reddedilir |
| Zamanlama saldırısı (imza) | if ($sig === $beklenen) | hash_equals() — sabit zamanlı kıyas |
| Zamanlama saldırısı (secret) | Anahtar yoksa hemen return | Sahte bir hash'le password_verify() yine de çalıştırılır |
| Süresiz jeton | exp hiç kontrol edilmez | exp yoksa reddedilir; varsa JWT_LEEWAY toleransıyla kontrol edilir |
| Servisler arası jeton geçişi | iss/aud kontrolü yok | İkisi de beklenen değerlerle karşılaştırılır |
| Yetki = kimlik varsayımı | Jetonu olan her şeyi yapabilir | require_scope() — eksikse 403 ve details ile hangi kapsamın gerektiği |
| Anahtar sayımı (enumeration) | "Anahtar bulunamadı" / "secret hatalı" ayrı mesajlar | İkisi de tek tip 401 invalid_client |
| Ham secret saklama | secret VARCHAR(64) | secret_hash — password_hash(), geri üretilemez |
| SQL Injection | Dize birleştirme ile sorgu | Tüm sorgular prepared statement, EMULATE_PREPARES = false |
ORDER BY enjeksiyonu | ORDER BY $_GET['sort'] | Beyaz liste; tanınmayan değer id'ye düşer |
LIKE joker istismarı | LIKE '%$q%' | % ve _ kaçışlanır (ESCAPE '!') |
| Başkasının kaydına erişim | Sahiplik PHP'de kontrol edilir | owner_key her sorgunun WHERE'inde; yetkisiz kayıt 404 |
| Kaba kuvvet (secret deneme) | Sınırsız POST /auth/token | Anahtar/IP başına 12 istek / 60 sn + Retry-After |
| Bilgi sızdıran hatalar | Canlıda SQL metni ekrana basılır | APP_DEBUG sunucu adından türetilir; canlıda false |
| Jeton hırsızlığı (XSS) | Jeton localStorage'a yazılır | Konsolda jeton yalnızca bellekte tutulur |
| Yapılandırma sızıntısı | config.php doğrudan indirilebilir | system/ klasörü tümüyle kapalı + dosya içi CY_APP kontrolü |
| Şema/veri sızıntısı | /cy_api_jwt.sql → HTTP 200 | .sql, .md, .json, .log, .ini, .bak, .example kapalı (README*.md bilinçli istisna) |
| Clickjacking | Başlık yok | X-Frame-Options: SAMEORIGIN |
| MIME sniffing | Başlık yok | X-Content-Type-Options: nosniff |
CSRF neden yok? Bu API çerez kullanmaz; kimlik Authorization başlığıyla taşınır. Tarayıcının kendiliğinden gönderdiği bir kimlik olmadığı için saldırganın sayfası isteği atabilir ama jetonu ekleyemez. CSRF, çerezle taşınan oturumların sorunudur.
Tüm ayarlar system/config.php içindedir. Sırlar oraya yazılmaz — system/config.local.php dosyasına yazılır; o dosya .gitignore içindedir, depoya gitmez ve deploy sırasında silinmez.
| Sabit | Varsayılan | Ne işe yarar |
|---|---|---|
JWT_SECRET | (geliştirme değeri) | HS256 imza anahtarı. Üretimde mutlaka değiştirin. |
JWT_TTL | 900 | Jeton ömrü (saniye). Kısa tutun. |
JWT_ISS / JWT_AUD | cy-rest-api / cy-clients | Jetonu üreten ve jetonun geçerli olduğu servis. |
JWT_LEEWAY | 30 | Sunucu saatleri arası kayma toleransı (saniye). |
RATE_LIMIT_TOKEN | [12, 60] | Jeton üretimi: 12 istek / 60 sn. |
RATE_LIMIT_API | [180, 60] | Normal trafik: 180 istek / 60 sn. |
KNOWN_SCOPES | 3 kapsam | Tanınan kapsamlar. Listede olmayan kapsam yok sayılır. |
DEMO_TOKENS | true | Bilerek bozuk jeton üreten uç. Üretimde false yapın. |
APP_DEBUG | (otomatik) | Sunucu adından türetilir; canlı alan adında kendiliğinden kapanır. |
NOTE_TITLE_MAX / NOTE_BODY_MAX | 150 / 10000 | Doğrulama sınırları. |
NOTES_PAGE_MAX | 100 | Sayfa başına en çok kayıt. |
php -r "echo bin2hex(random_bytes(32));"php -r "echo password_hash('secretiniz', PASSWORD_DEFAULT), PHP_EOL;"INSERT INTO api_keys (name, key_id, secret_hash, scopes)
VALUES ('Mobil uygulama', 'mobil_v1', '$2y$10$…', 'notes:read notes:write profile:read');
Bu depodan kendi projenize taşınacak üç dosya vardır:
| Dosya | Ne taşır |
|---|---|
system/function.php | JWT çekirdeği, JSON zarfı, hız sınırı, kimlik/kapsam yardımcıları |
system/config.php | Yapılandırma kalıbı ve config.local.php mekanizması |
api/index.php | Yönlendirme ve uç noktalar — kendi kaynaklarınızla değiştirin |
index.php ile assets/js/console.js API'nin parçası değildir; silebilirsiniz.
// api/index.php içinde, $claims = require_auth(); satırından SONRA
if ($path === '/urunler' && $method === 'GET') {
require_scope($claims, 'urunler:read'); // yetki kontrolü
$stmt = $db->prepare('SELECT id, ad, fiyat FROM urunler WHERE owner_key = :k');
$stmt->execute([':k' => $claims['sub']]);
api_ok($stmt->fetchAll()); // { "data": [...] }
}KNOWN_SCOPES'a eklemeyi unutmayın — beyaz listede olmayan kapsam, veritabanındaki anahtara yazılsa bile yok sayılır.
require 'system/function.php';
$jwt = jwt_encode(['sub' => 'kullanici-42', 'scopes' => ['notes:read']]);
[$claims, $hata] = jwt_decode($jwt);
if ($hata !== null) {
echo jwt_error_message($hata); // "Jetonun süresi doldu." vb.
}
Arayüz, tüm Çılgın Yazılım örneklerinde ortak olan tasarım kalıbını kullanır:
| Dosya | Kapsam | Değiştirilir mi? |
|---|---|---|
assets/css/cilginyazilim.css | Marka kalıbı — kartlar, butonlar, tablolar, rozetler, modal | Hayır. Projeler arası ortaktır. |
assets/css/style.css | Yalnızca bu sayfaya özgü parçalar (anahtar kartları, JWT gösterimi, senaryo ızgarası) | Evet |
Yükleme sırası: bootstrap → cilginyazilim → style. Renkler doğrudan yazılmaz, CSS değişkenlerinden okunur (--cy-brand-600, --cy-danger …).
Aynı kalıpla hazırlanmış diğer örnekler: cilginyazilim.com/kutuphane
.
├── api/
│ ├── .htaccess → temiz URL yönlendirmesi + Authorization başlığı taşıma
│ └── index.php → API ÖN DENETLEYİCİ: yönlendirme ve tüm uç noktalar
├── system/
│ ├── .htaccess → klasör TÜMÜYLE kapalı (Require all denied)
│ ├── config.php → yapılandırma + PDO bağlantısı
│ ├── config.local.php → (siz oluşturursunuz; .gitignore içinde)
│ ├── config.local.php.example
│ └── function.php → JWT, JSON zarfı, hız sınırı, kimlik/kapsam
├── assets/
│ ├── css/ → bootstrap.min · cilginyazilim (marka) · style
│ ├── js/ → jquery · bootstrap.bundle · console.js
│ └── images/logo.png
├── docs/screenshots/
├── .htaccess → dizin listeleme kapalı, dosya türü kuralları, güvenlik başlıkları
├── .env.example → Veritabanı bilgileri (isteğe bağlı) — .gitignore içinde
├── cy_api_jwt.sql → şema + 4 anahtar + 19 not (zamanlar NOW() - INTERVAL ile)
├── index.php → API KONSOLU (API'nin parçası değildir)
├── CHANGELOG.md
├── LICENSE
├── README.md
└── README.en.md
İSTEMCİ API VERİTABANI
│ │ │
│ POST /auth/token │ │
│ { key_id, secret } │ │
├─────────────────────────────>│ │
│ │ hız sınırı (12/60 sn) │
│ │ SELECT … WHERE key_id = ? │
│ ├─────────────────────────────────>│
│ │ password_verify(secret, hash) │
│ │ active = 1 ? │
│ │ │
│ │ jwt_encode({sub, scopes, exp}) │
│ { data: { token, … } } │ │
│<─────────────────────────────┤ │
│ │
│ GET /notes │
│ Authorization: Bearer <jwt> │
├─────────────────────────────>│ │
│ │ 1) jwt_decode() │
│ │ alg = HS256 ? ─┐ │
│ │ hash_equals(imza) │ 401 │
│ │ exp / nbf │ │
│ │ iss / aud ─┘ │
│ │ │
│ │ 2) hız sınırı (anahtar başına) │
│ │ │
│ │ 3) require_scope('notes:read') │
│ │ eksikse ────────────> 403 │
│ │ │
│ │ 4) SELECT … WHERE owner_key = ? │
│ ├─────────────────────────────────>│
│ { data: [...], meta: {...} }│ │
│<─────────────────────────────┤ │Kapsam kontrolü ise sorgudan önce gelir: yetkisi olmayan bir istek için veritabanına hiç gidilmez.
Tüm yanıtlar aynı zarfı kullanır:
// Başarılı
{ "data": … , "meta": { … } } // meta yalnızca gerektiğinde
// Hatalı
{ "error": { "code": "…", "message": "…", "details": { … } } }error.code makine içindir ve değişmez; error.message insan içindir ve değişebilir. İstemci koşullarını message metnine bağlamamalıdır.
GET / — uç listesi (jeton gerektirmez)
curl -s "$BASE/"{
"data": {
"name": "Çılgın Yazılım · JWT REST API",
"version": "1.0.0",
"auth": { "type": "Bearer JWT (HS256)", "token_url": "/auth/token", "expires_in": 900, "scopes": { … } },
"endpoints": { "GET /notes": "Not listesi (notes:read)", … },
"rate_limits": { "token": "••••••••", "api": "180/60s" }
}
}POST /auth/token — jeton al
curl -s -X POST "$BASE/auth/token" -H 'Content-Type: application/json' \
-d '{"key_id":"demo_full","secret":"••••••••"}'{
"data": {
"token": "••••••••",
"token_type": "Bearer",
"expires_in": 900,
"expires_at": "2026-08-31T09:15:00+00:00",
"scopes": ["notes:read", "notes:write", "profile:read"],
"key_name": "Mobil uygulama (tam yetki)"
}
}
| Durum | Kod | Ne zaman |
|---|---|---|
422 | invalid_request | key_id ya da secret boş |
401 | invalid_client | Secret hatalı ya da anahtar pasif (ayırt edilmez) |
429 | rate_limited | 60 saniyede 12'den fazla deneme |
POST /auth/demo-token — bilerek bozuk jeton (yalnızca DEMO_TOKENS açıkken)
curl -s -X POST "$BASE/auth/demo-token" -H 'Content-Type: application/json' \
-d '{"fault":"expired"}'
fault | Üretilen jeton | Beklenen sonuç |
|---|---|---|
expired | exp geçmişte | 401 invalid_token / expired |
future | nbf ileri tarihli | 401 invalid_token / not_yet_valid |
bad_audience | aud başka servis | 401 invalid_token / bad_audience |
no_expiry | exp alanı yok | 401 invalid_token / no_expiry |
no_scopes | Geçerli jeton, kapsam yok | 403 insufficient_scope |
Bu uç bir öğrenme aracıdır. Bozuk ama imzası geçerli bir jeton üretmek için sırra ihtiyaç vardır; sır da istemcide olmamalı. DEMO_TOKENS = false iken uç 404 döner. Ürettiği jetonların hepsi zaten geçersizdir; hiçbiri bir yetki taşımaz.
GET /me — anahtar künyesi (profile:read)
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"{
"data": {
"name": "Mobil uygulama (tam yetki)",
"key_id": "demo_full",
"active": true,
"scopes": ["notes:read", "notes:write", "profile:read"],
"created_at": "2026-05-27 00:40:43",
"last_used_at": "2026-08-31 00:41:40",
"token": { "jti": "985a34024773adbf", "iat": 1788126100, "exp": 1788127000, "kalan_saniye": 900 }
}
}
GET /stats — sayaçlar (profile:read)
{ "data": { "notlarim": 13, "son_gun": 4, "anahtar_toplam": 4, "anahtar_aktif": 3,
"kapsamlarim": ["notes:read","notes:write","profile:read"], "kalan_saniye": 812 } }GET /notes — liste (notes:read)
| Parametre | Varsayılan | Not | |||
|---|---|---|---|---|---|
page | 1 | ||||
limit | 10 | En çok 100 | |||
q | — | Başlık ve gövdede arama; % ve _ kaçışlanır | |||
sort | id | id \ | title \ | created_at \ | updated_at (beyaz liste) |
dir | desc | asc \ | desc |
curl -s "$BASE/notes?q=jeton&sort=title&dir=asc&limit=5" -H "Authorization: Bearer $TOKEN"{ "data": [ { "id": 5, "title": "…", "body": "…", "created_at": "…", "updated_at": "…" } ],
"meta": { "page": 1, "limit": 5, "total": 3, "pages": 1, "sort": "title", "dir": "asc", "q": "jeton" } }
POST /notes — oluştur (notes:write)
curl -i -s -X POST "$BASE/notes" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"title":"Yeni not","body":"içerik"}'201 Created + Location: /notes/138. Yeni kaynağın adresi başlıkta durur — gövdeye gömülü bir alan değil, standart başlık.
| Durum | Kod | Ne zaman |
|---|---|---|
400 | invalid_json | Gövde geçerli JSON değil |
422 | validation_failed | details alanı hangi alanın neden reddedildiğini söyler |
403 | insufficient_scope | Jetonda notes:write yok |
GET / PUT / DELETE /notes/{id} — tek kayıt
curl -s "$BASE/notes/12" -H "Authorization: Bearer $TOKEN"
curl -s -X PUT "$BASE/notes/12" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"title":"Yeni başlık"}'
curl -i -s -X DELETE "$BASE/notes/12" -H "Authorization: Bearer $TOKEN"
204 No Content döner — gövdesiz. 204 gövde taşıyamaz; "null" bile yazılmaz./notes) PUT/DELETE atmak 405 + Allow: GET, POST verir.Hata kodları — tam liste
| HTTP | error.code | Anlamı |
|---|---|---|
| 400 | invalid_json | Gövde geçerli JSON değil |
| 401 | unauthorized | Authorization başlığı yok |
| 401 | invalid_token | Jeton geçersiz — details.reason: malformed, bad_alg, bad_signature, bad_payload, expired, not_yet_valid, no_expiry, bad_audience |
| 401 | invalid_client | key_id/secret hatalı ya da anahtar pasif |
| 403 | insufficient_scope | Kimlik geçerli, kapsam eksik. details: required, granted |
| 404 | not_found | Kaynak yok ya da başkasına ait |
| 405 | method_not_allowed | Allow başlığı izin verilenleri söyler |
| 422 | invalid_request / validation_failed | Girdi eksik ya da doğrulamadan geçmedi |
| 429 | rate_limited | Retry-After başlığı kaç saniye bekleneceğini söyler |
| 500 | server_error | Ayrıntı yalnızca APP_DEBUG açıkken döner |
api_keys
├── id INT UNSIGNED AUTO_INCREMENT
├── name VARCHAR(120) insan için ad
├── key_id VARCHAR(64) UNIQUE — herkese açık tanımlayıcı
├── secret_hash VARCHAR(255) password_hash(); ham secret SAKLANMAZ
├── scopes VARCHAR(255) boşlukla ayrılmış izinler
├── active TINYINT(1) 0 = jeton üretilmez
├── created_at TIMESTAMP
└── last_used_at DATETIME son jeton üretim anı
api_notes
├── id INT UNSIGNED AUTO_INCREMENT (137'den başlar)
├── owner_key VARCHAR(64) jetondaki 'sub' ile eşleşir
├── title VARCHAR(150)
├── body TEXT
├── created_at TIMESTAMP
├── updated_at TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
└── KEY idx_notes_owner_id (owner_key, id)
| Karar | Neden |
|---|---|
secret_hash, secret değil | Sızan bir veritabanı dökümü API'yi ele geçirmeye yetmesin. |
scopes tek sütun, ayrı tablo değil | Kapsam sayısı sabit ve az; üçüncü tablo öğretici değil, gürültü olurdu. Kapsamlar dinamikleşirse api_key_scopes doğru cevaptır. |
active, DELETE değil | Anahtarı kapatmak geri alınabilir olmalı; hangi anahtarın ne zaman kapatıldığı korunur. |
owner_key, api_keys.id değil | Jetonun sub claim'i key_id taşır. id kullansaydık her istekte anahtar tablosuna gitmek gerekirdi — jetonun varlık sebebi tam olarak bu turu ortadan kaldırmaktır. |
idx_notes_owner_id (owner_key, id) | Liste sorgusu hem owner_key ile filtreler hem id ile sıralar; tek indeks ikisini birden karşılar. |
AUTO_INCREMENT = 137 | Demoda not silinir, numaralar boşalır. Yeni bir kayıt silinmiş bir numarayı devralırsa eski bir bağlantı yanlış kayda gider. |
Neden bir JWT kütüphanesi kullanmıyorsunuz?
Üretimde kullanabilirsiniz — firebase/php-jwt iyi bir kütüphanedir. Ama bu bir öğretici örnektir: JWT'nin ne olduğunu anlamanın en hızlı yolu, 60 satırlık jwt_encode/jwt_decode çiftini okumaktır.
Ayrıca kütüphane kullanmak açıkları kendiliğinden kapatmaz. alg: none saldırısı yıllarca kütüphanelerin içinde yaşadı; bugün de kütüphaneyi yanlış çağıran kodlarda yaşıyor (decode($t, $k, ['HS256','none'])). Neyi neden yaptığınızı bilmek, kütüphanenin yerini tutmaz ama onsuz kütüphane de sizi korumaz.
Refresh token neden yok?
Bilinçli bir kapsam kararı. Refresh token, kendi başına bir konudur: saklama (HttpOnly çerez), döndürme (rotation), yeniden kullanım tespiti, iptal listesi. Hepsini eklemek bu örneğin anlattığı tek şeyi — erişim jetonunun doğrulanmasını — gölgede bırakırdı.
Pratikte deseni şudur: kısa ömürlü erişim jetonu bellekte, uzun ömürlü refresh token JavaScript'in okuyamadığı bir HttpOnly çerezde durur; erişim jetonu bitince refresh ile yenisi alınır.
Jetonu nasıl iptal ederim?
Doğrudan edemezsiniz — ve bu JWT'nin doğasıdır. Jeton sunucuda saklanmaz; doğrulama yalnızca imzaya bakar. "Çıkış yap" düğmesi jetonu geçersiz kılamaz.
Üç seçeneğiniz var:
JWT_SECRET'i değiştirmek — dolaşımdaki tüm jetonları anında keseractive = 0 — yeni jeton üretilmesini engeller, mevcut jeton expe kadar yaşarTek tek iptal gerekiyorsa jti claim'i için bir kara liste tablosu eklenir — ama o noktada her istekte veritabanına gidilir ve JWT'nin durum tutmama avantajı kaybolur. Alan (jti) baştan bulunuyor ki bu adım dolaşımdaki jetonları kırmadan atılabilsin.
Jetonu localStorage'a koysam olmaz mı?
Olur ama riski bilerek alın: sayfadaki herhangi bir XSS açığı localStorage'ı okuyup jetonu dışarı sızdırabilir. Saldırgan o jetonla, ömrü boyunca kullanıcı adına istek atar.
Bu konsolda jeton sıradan bir JavaScript değişkeninde durur; sayfa yenilenince kaybolur. Bu bir eksiklik değil, bilinçli bir ödünleşimdir — bir demo aracının kalıcı oturum tutması gerekmez.
Hız sınırı dosyaya yazıyor, bu ölçeklenir mi?
Tek sunucuda evet. Birden çok sunucunuz varsa hayır: her sunucu kendi payını sayar ve gerçek sınır N katına çıkar. O durumda sayaç ortak bir yerde tutulmalıdır (Redis, Memcached).
Dosya bilerek seçildi: bu örneğin bağımlılığı yok, Redis şart koşmadan çalışsın istiyoruz. Kayan pencere kullanılıyor — sabit pencere (dakikanın başında sıfırlanan sayaç) sınırın iki katına izin verir: 59. saniyede 180, 61. saniyede 180 daha.
Her istekte 401 alıyorum, jeton doğru olmasına rağmen
Büyük olasılıkla Authorization başlığı PHP'ye ulaşmıyor. Bazı Apache/CGI kurulumlarında (mod_php dışındaki SAPI'ler) bu başlık düşürülür ve kod onu gerçekten boş görür.
api/.htaccess başlığı iki ayrı yolla taşır (SetEnvIf ve mod_rewrite), bearer_token() de üç ayrı sunucu değişkenini dener. Yine de olmuyorsa .htaccess dosyalarının okunduğundan emin olun (AllowOverride All).
CORS'u nasıl sınırlarım?
api/index.php başında Access-Control-Allow-Origin: * yazar. Bu, herkese açık bir demo için bilinçlidir. Kendi projenizde origin'i sınırlayın:
$izinli = ['https://uygulamam.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $izinli, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
}Allow-Credentials açacaksanız * kullanmak zaten yasaktır.
system/config.local.php oluşturuldu; canlı JWT_SECRET ve veritabanı künyesi oradaJWT_SECRET en az 32 rastgele bayt (bin2hex(random_bytes(32)))DEMO_TOKENS falsedemo_full, demo_readonly, demo_writer, demo_pasif) silindi ya da active = 0 yapıldıpassword_hash() ileAPP_DEBUG kapalı (canlı alan adında kendiliğinden kapanır — yine de doğrulayın)Access-Control-Allow-Origin sınırlandıJWT_TTL sizin için doğru mu? (kısa = güvenli, uzun = az istek)RATE_LIMIT_* değerleri trafiğinize göre ayarlandı/cy_api_jwt.sql, /system/config.php, /CHANGELOG.md adresleri 403 dönüyorindex.php ve assets/js/console.js üretimde gerekli mi? Gerekmiyorsa silin| Belirti | Sebep | Çözüm |
|---|---|---|
Her istek 401, jeton doğru | Authorization başlığı PHP'ye ulaşmıyor | AllowOverride All; api/.htaccess okunuyor mu? |
/api/notes → 404, /api/index.php?path=/notes çalışıyor | mod_rewrite kapalı | Modülü açın ya da ?path= biçimini kullanın |
403 alıyorum ama jetonum yeni | Kapsam eksik | details.required alanına bakın; yeni jeton almak çözmez |
| Türkçe karakterler bozuk | .sql dosyası yanlış karakter setiyle içe aktarıldı | mysql --default-character-set=utf8mb4 < cy_api_jwt.sql |
SQLSTATE[HY093] | Aynı adlı yer tutucu iki kez kullanılmış | EMULATE_PREPARES = false iken ad tekrar edemez; :q1, :q2 gibi ayırın |
429 sürekli geliyor | Hız sınırı sayaç dosyaları | sys_get_temp_dir()/cy_api_jwt_rate klasörünü silin |
| Konsolda "Demo jetonu üretilemedi" | DEMO_TOKENS = false | Beklenen davranış; üretimde kapalıdır |
db_unavailable | Veritabanı künyesi yanlış | system/config.local.php içindeki DB_* değerlerini kontrol edin |
jti kara listesi ile tek tek jeton iptaliKatkılar memnuniyetle karşılanır.
git checkout -b ozellik/harika-seygit commit -m 'Harika şey eklendi'git push origin ozellik/harika-seyHata bildirimi ve öneriler için Issues bölümünü kullanabilirsiniz.
MIT — bkz. LICENSE. Ticari projelerde de özgürce kullanabilirsiniz.
Çılgın Yazılım · Kütüphane · GitHub
Bu örneği faydalı bulduysanız ⭐ vermeyi unutmayın.
İncelemek istediğiniz dosyayı ağaçtan seçin.
Güvenlik gereği kaynak dosyalardaki parola, API anahtarı ve benzeri gizli değerler gösterilmeden önce maskelenir (••••••••).
Üretimde kullanabilirsiniz; firebase/php-jwt iyi bir kütüphanedir. Ama bu öğretici bir örnek: JWT'nin ne olduğunu anlamanın en hızlı yolu 60 satırlık encode/decode çiftini okumaktır. Ayrıca kütüphane kullanmak açıkları kendiliğinden kapatmaz — alg:none saldırısı yıllarca kütüphanelerin içinde yaşadı ve bugün de onları yanlış çağıran kodlarda yaşıyor.
Saldırgan jetonun header bölümündeki alg değerini "none" yapar, imza bölümünü siler ve payload'a istediği kimliği ve yetkileri yazar. Algoritmayı jetonun kendisinden okuyan kod, saldırganın kimliğini kendi kendine onaylamış olur. Bu projede alg HS256 değilse jeton imza hiç hesaplanmadan reddedilir; demoda bu senaryo tek tıkla denenebilir.
401 "kim olduğunu bilmiyorum" demektir: jeton yok, süresi dolmuş ya da imzası bozuk. 403 ise "kim olduğunu biliyorum ama bunu yapamazsın" demektir: jeton kusursuz, eksik olan yetki. İkisini karıştıran bir istemci 403 aldığında yeni jeton almaya çalışır, aynı 403'ü alır ve sonsuz döngüye girer. Bu örnekte dört demo anahtarı bu ayrımı somutlaştırır.
Doğrudan edemezsiniz; bu JWT'nin doğasıdır. Jeton sunucuda saklanmaz, doğrulama yalnızca imzaya bakar. Üç seçenek vardır: kısa ömür (bu örnekte 900 saniye), JWT_SECRET'i değiştirmek (dolaşımdaki tüm jetonları anında keser) ve anahtarı active = 0 yapmak (yeni jeton üretilmesini engeller). Tek tek iptal gerekiyorsa jti claim'i için bir kara liste tablosu eklenir — ama o noktada her istekte veritabanına gidilir ve JWT'nin durum tutmama avantajı kaybolur.
Bilinçli bir kapsam kararı. Refresh token kendi başına bir konudur: HttpOnly çerezde saklama, döndürme (rotation), yeniden kullanım tespiti, iptal listesi. Hepsini eklemek bu örneğin anlattığı tek şeyi — erişim jetonunun doğrulanmasını — gölgede bırakırdı. Yol haritasında duruyor.
Koyabilirsiniz ama riski bilerek alın: sayfadaki herhangi bir XSS açığı localStorage'ı okuyup jetonu dışarı sızdırabilir ve saldırgan o jetonla ömrü boyunca kullanıcı adına istek atar. Bu örnekteki konsolda jeton sıradan bir JavaScript değişkeninde durur ve sayfa yenilenince kaybolur. Kalıcılık gerekiyorsa doğru desen, JavaScript'in okuyamadığı HttpOnly bir çerezle taşınan refresh token'dır.
Tek sunucuda evet. Birden çok sunucuda hayır: her sunucu kendi payını sayar ve gerçek sınır kat kat artar; o durumda sayaç Redis gibi ortak bir yerde tutulmalıdır. Dosya bilerek seçildi, çünkü bu örneğin hiçbir bağımlılığı yok. Kayan pencere kullanılıyor — sabit pencere, dakikanın başında sıfırlandığı için sınırın iki katına izin verir.
Bu örneğin daha gelişmişi projenizde mi lazım?
İhtiyacınızı anlatın; bu yapıyı sizin sisteminize uyarlayalım ya da size özel geliştirelim.
Henüz yorum yok. Takıldığınız bir yer varsa ilk soruyu siz sorun.
Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.
İleri
guvenlik/guvenli-dosya-yukleme
195 inceleme 75 canlı açılış 1 beğeni
İleri
guvenlik/rol-tabanli-yetkilendirme-paneli
180 inceleme 70 canlı açılış 1 beğeni
İleri
guvenlik/php-rest-api-panel
172 inceleme 60 canlı açılış 1 beğeni