Indie Valley'de istemciye güvenmeyen bir API kurmak
Toggle yerine durumu bildiren uçlar, tekrar kullanılınca bütün aileyi iptal eden refresh token'lar, baştan kurulabilen bir Redis indeksi ve yayına çıkmadan önce kendini kanıtlaması gereken dosyalar. Dört kararın da derdi aynı: aynı istek iki kez gelirse ne olur?
Indie Valley, ortaklarımla kurduğum ve teknik tarafını yönettiğim bir platform: Türkiye'de oyun yapan, oyun oynayan ve oyunlara müzik yapan insanları aynı yerde buluşturuyor. Next.js ile yazılmış web istemcisi de iOS ve Android'deki Flutter uygulaması da aynı NestJS API'sine bağlanıyor.
Mobil bir istemcinin arkasında her şey olabilir: asansörde kopan bir bağlantı, kendini yeniden deneyen bir istek, iki kez dokunulan bir düğme, çalınmış bir token ya da tam o sırada düşen bir Redis. Aşağıdaki dört kararın hepsi aynı soruya cevap arıyor: istemci ya da ağ yanlış davrandığında sistem yine doğru kalabiliyor mu?
1. Durumu bildiren uçlar
Beğen düğmesi için akla gelen en kolay uç nokta bir "aç/kapa": POST /posts/:id/like/toggle. Sunucu o anki durumu okuyor, tersine çeviriyor ve sonucu dönüyor. Dert, bu istek iki kez geldiğinde başlıyor. Kullanıcı düğmeye iki kez dokunabilir ya da ağ katmanı zaman aşımına uğrayan isteği tekrar gönderebilir. O zaman iki toggle birbirini sıfırlıyor: kullanıcı beğendiğini sanıyor ama sunucuda beğeni yok.
Bu yüzden Indie Valley'de beğenme ve kaydetme uçları istenen durumu açıkça söylüyor. İstemci "beğenildi" demek için PUT, "beğenilmedi" demek için DELETE gönderiyor. HTTP bu iki metodu idempotent olarak tanımlıyor1, yani aynı istek kaç kez gelirse gelsin sunucudaki son durum değişmiyor.
// Desen taslağı: durum bildiren bir beğeni ucu.
@Put("posts/:id/like")
async like(@Param("id") postId: string, @User() user: AuthUser) {
// Zaten varsa hiçbir şey olmaz; ikinci istek ilkini bozamaz.
await this.likes.upsert({ postId, userId: user.id });
return { liked: true };
}
@Delete("posts/:id/like")
async unlike(@Param("id") postId: string, @User() user: AuthUser) {
await this.likes.deleteIfExists({ postId, userId: user.id });
return { liked: false };
}Arayüz de bu sözleşmeye dayanıyor. Düğmeye bastığın anda ekran iyimser biçimde güncelleniyor, istek hata dönerse eski durum geri geliyor. İstek durumu taşıdığı için geri almak da kolay: istemci ne istediğini biliyor, sunucunun cevabı da bunu ya onaylıyor ya reddediyor. Toggle'da istemci ne istediğini bile bilmiyor, sadece "değiştir" demiş oluyor.
2. Refresh token'lar aile halinde yaşıyor
Her oturumda kısa ömürlü bir erişim token'ı ve uzun ömürlü bir refresh token var. Refresh token her kullanıldığında yenisiyle değiştiriliyor. Kullanılmış bir refresh token ikinci kez gelirse sunucu bunu hırsızlık işareti sayıyor ve o token'dan türeyen bütün aileyi iptal ediyor2.
Mantığı şu: token çalındıysa aynı aileyi artık iki kişi kullanıyor, gerçek kullanıcı ve saldırgan. Hangisi önce yenilerse yenilesin, öbürü bir sonraki denemesinde eski token'la gelecek. Sunucu hangisinin meşru olduğunu bilemiyor ama bir şeylerin ters gittiğini anlıyor. Bütün aileyi kapatınca ikisi birden dışarıda kalıyor: gerçek kullanıcı yeniden giriş yapıyor, saldırganın elindeki token ise artık hiçbir kapıyı açmıyor.
// Desen taslağı: tekrar kullanımda ailenin tamamını iptal etmek.
async refresh(presented: string) {
const token = await this.tokens.findByHash(hash(presented));
if (!token) throw new UnauthorizedException();
if (token.usedAt) {
// Daha önce değiştirilmiş bir token yeniden geldi: aile ele geçmiş olabilir.
await this.tokens.revokeFamily(token.familyId);
throw new UnauthorizedException();
}
await this.tokens.markUsed(token.id);
return this.issuePair({ userId: token.userId, familyId: token.familyId });
}Erişim token'ı da kendi haline bırakılmıyor: her istekte, WebSocket bağlantılarında da 30 saniyede bir yeniden doğrulanıyor. Bu yüzden bir hesap engellendiğinde, açık kalan sohbet bağlantısı da en geç yarım dakika içinde kapanıyor. Google ve Steam ile girişte PKCE var3. Yetkilendirme kodu yolda yakalansa bile, kodu başlatan istemcinin elindeki doğrulayıcı olmadan token'a çevrilemiyor.
3. Asıl veri PostgreSQL'de, Redis bir indeks
Eşleşme tarafında Redis var ama asıl veri PostgreSQL'de duruyor. Redis'teki her şey, Postgres'teki veriden baştan kurulabilen bir indeks.
Bu ayrım iki durumu kolaylaştırıyor. Redis'in içeriği bozulursa ya da silinirse hiçbir şey kaybolmuyor, indeks yeniden kuruluyor. Redis tamamen düşerse de sistem eksik bir indeksle eşleşme üretmeye çalışmıyor, güvenli tarafta kalıp kapanıyor. Bunun sebebi basit: bir süre hiç eşleşme yapamamak, eksik bir indeksle yanlış eşleşme yapmaktan iyidir.
Eşleşme oturumları da sürümlü tutuluyor. Her güncelleme, yazmadan önce okuduğu sürümün hâlâ geçerli olup olmadığına bakıyor (compare-and-swap). Arada başka bir işlem oturumu değiştirdiyse yazma reddediliyor. Bu yüzden iki cihazdan aynı anda gelen iki kaydırma, kimse fark etmeden birbirinin sonucunu ezemiyor. Bir şeyler ters giderse eşleşmeyi tamamen durduran kill switch'ler de var.
4. Dosya, yayından önce kendini kanıtlıyor
Medya yüklemeleri API sunucusuna hiç uğramıyor. İstemci önce kısa ömürlü, imzalı bir yükleme adresi alıyor ve dosyayı doğrudan nesne deposuna (MinIO) koyuyor. Dosya bu aşamada henüz yayında değil.
Yükleme bitince gelen geri çağrıda üç şeye bakılıyor: dosyayı yükleyen kişi gerçekten sahibi mi, boyutu sınırın içinde mi, ilk baytlarındaki imza beyan edilen türle uyuşuyor mu. Üçüncüsünün sebebi şu: uzantıyı ve Content-Type başlığını istemci söylüyor, ilk baytlar ise dosyanın gerçekte ne olduğunu gösteriyor.
// Desen taslağı: dosyanın başındaki imzayı beyan edilen türle karşılaştırmak.
const SIGNATURES: Record<string, number[]> = {
"image/png": [0x89, 0x50, 0x4e, 0x47],
"image/jpeg": [0xff, 0xd8, 0xff],
"image/gif": [0x47, 0x49, 0x46, 0x38],
};
function matchesSignature(head: Uint8Array, declared: string) {
const signature = SIGNATURES[declared];
return Boolean(signature) && signature.every((byte, i) => head[i] === byte);
}Yayına yalnızca bu kontrollerden geçen dosyalar çıkıyor. Uzantısı .png olup içinde başka bir şey taşıyan bir dosya, daha kimse görmeden eleniyor.
Ortak nokta
Bu dört karar sistemin farklı köşelerinde duruyor ama hepsi aynı şeyi söylüyor: istemciye ve ağa güvenme, durumu sunucuda tut, tekrarı zararsız hale getir. Sohbetteki mesajlar da aynı fikirle çalışıyor: her mesaj idempotent gönderildiği için, ağ yüzünden iki kez yollanan bir mesaj sohbete iki kez düşmüyor.
Bu kararların hiçbiri büyük bir altyapı istemiyor. Her uç noktayı yazarken kendine "bu istek iki kez gelirse ne olur?" diye sormak yetiyor.
Dipnotlar
-
RFC 9110, HTTP Semantics, bölüm 9.2.2: Idempotent Methods.
PUTveDELETEidempotent,POSTdeğil. ↩ -
RFC 9700, Best Current Practice for OAuth 2.0 Security, bölüm 4.14: refresh token rotation ve geçersiz kılınmış bir token'ın tekrar kullanılmasının saldırıyı ele vermesi. ↩