Cloudflare Workers ile Güvenli Giriş Sistemi Oluşturma

Geçen ay bir müşterimizin login sayfası brute force saldırısına uğradı. Klasik yöntem: origin server önünde Nginx rate limiting, fail2ban, birkaç iptables kuralı. Ama trafik zaten origin’e geliyordu, sunucu çırpınıyordu. İşte tam o noktada “bunu neden edge’de çözmüyoruz?” sorusunu sorduk kendimize. Cloudflare Workers bu iş için biçilmiş kaftan.

Bu yazıda sıfırdan, production’a hazır bir güvenli giriş sistemi kuracağız. Sadece rate limiting değil; JWT doğrulama, coğrafi kısıtlama, bot tespiti ve şüpheli IP bloklama. Hepsini kullanıcı isteği origin sunucunuza ulaşmadan önce edge’de halledeceğiz.

Neden Edge’de Authentication?

Origin önüne bir WAF koymak artık yeterli değil. Saldırılar daha akıllı, dağıtık ve hedefli hale geldi. Cloudflare’in global edge ağı 300’den fazla şehirde PoP’a sahip. Türkiye’deki bir kullanıcı login isteği attığında, bu istek Istanbul edge node’unda işlenebilir. Origin sunucunuz Ankara’daki bir data center’da olsa bile, kötü niyetli trafik hiç oraya ulaşmaz.

Performans tarafında da ciddi kazanımlar var. Edge’deki bir Worker, cold start dahil genellikle 5ms altında yanıt veriyor. Bunu kendi sunucunuzdaki bir middleware ile kıyasladığınızda fark ortaya çıkıyor.

Ortamı Hazırlama

Wrangler CLI olmadan Workers geliştirmek işkenceye dönüşür. Hemen kuralım:

npm install -g wrangler
wrangler login
wrangler init secure-login-worker --type=javascript
cd secure-login-worker

wrangler.toml dosyamızı düzenleyelim. KV namespace’i rate limiting ve session verisi için kullanacağız:

cat > wrangler.toml << 'EOF'
name = "secure-login-worker"
main = "src/index.js"
compatibility_date = "2024-01-01"

[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "buraya_kv_namespace_id_gelecek"
preview_id = "buraya_preview_id_gelecek"

[vars]
JWT_ALGORITHM = "HS256"
MAX_ATTEMPTS = "5"
LOCKOUT_DURATION = "900"
GEO_RESTRICT = "false"

[[secrets]]
name = "JWT_SECRET"
EOF

KV namespace oluşturalım ve ID’yi alalım:

wrangler kv:namespace create "RATE_LIMIT_KV"
wrangler kv:namespace create "RATE_LIMIT_KV" --preview
# Çıktıdaki ID'leri wrangler.toml'a yapıştır

wrangler secret put JWT_SECRET
# Güçlü bir secret girin, en az 32 karakter

Rate Limiting Katmanı

İlk savunma hattı rate limiting. Cloudflare’in kendi rate limiting özelliği var ama Workers içinde bunu KV ile kendiniz yönettiğinizde çok daha granüler kontrol elde ediyorsunuz. Örneğin “aynı IP’den 5 dakikada 5 başarısız deneme” gibi mantıkları kolayca ifade edebiliyorsunuz:

// src/rateLimit.js
export async function checkRateLimit(ip, kv, maxAttempts, lockoutDuration) {
  const key = `ratelimit:${ip}`;
  const now = Math.floor(Date.now() / 1000);
  
  const existing = await kv.get(key, { type: "json" });
  
  if (!existing) {
    await kv.put(key, JSON.stringify({
      attempts: 1,
      firstAttempt: now,
      lastAttempt: now,
      blocked: false
    }), { expirationTtl: lockoutDuration });
    return { allowed: true, remaining: maxAttempts - 1 };
  }

  // Lockout kontrolü
  if (existing.blocked) {
    const timeLeft = lockoutDuration - (now - existing.lastAttempt);
    return { 
      allowed: false, 
      reason: "ip_blocked",
      retryAfter: Math.max(0, timeLeft)
    };
  }

  // Pencere dışında mı?
  if (now - existing.firstAttempt > 300) {
    // 5 dakikalık pencere geçmiş, sıfırla
    await kv.put(key, JSON.stringify({
      attempts: 1,
      firstAttempt: now,
      lastAttempt: now,
      blocked: false
    }), { expirationTtl: lockoutDuration });
    return { allowed: true, remaining: maxAttempts - 1 };
  }

  const newAttempts = existing.attempts + 1;
  
  if (newAttempts >= maxAttempts) {
    await kv.put(key, JSON.stringify({
      ...existing,
      attempts: newAttempts,
      lastAttempt: now,
      blocked: true
    }), { expirationTtl: lockoutDuration });
    return { 
      allowed: false, 
      reason: "rate_limit_exceeded",
      retryAfter: lockoutDuration 
    };
  }

  await kv.put(key, JSON.stringify({
    ...existing,
    attempts: newAttempts,
    lastAttempt: now
  }), { expirationTtl: lockoutDuration });

  return { allowed: true, remaining: maxAttempts - newAttempts };
}

export async function recordSuccessfulLogin(ip, kv) {
  const key = `ratelimit:${ip}`;
  await kv.delete(key);
}

JWT Üretme ve Doğrulama

Workers ortamında Node.js crypto modülü yok. Web Crypto API kullanıyoruz. Bu kısım birçok insanın tökezlediği nokta; jsonwebtoken paketi Workers’ta çalışmaz:

// src/jwt.js
const encoder = new TextEncoder();

async function importKey(secret) {
  return crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign", "verify"]
  );
}

function base64UrlEncode(data) {
  return btoa(String.fromCharCode(...new Uint8Array(data)))
    .replace(/+/g, "-")
    .replace(///g, "_")
    .replace(/=/g, "");
}

function base64UrlDecode(str) {
  str = str.replace(/-/g, "+").replace(/_/g, "/");
  while (str.length % 4) str += "=";
  return atob(str);
}

export async function createJWT(payload, secret, expiresIn = 3600) {
  const header = { alg: "HS256", typ: "JWT" };
  const now = Math.floor(Date.now() / 1000);
  
  const fullPayload = {
    ...payload,
    iat: now,
    exp: now + expiresIn,
    jti: crypto.randomUUID()
  };

  const headerEncoded = base64UrlEncode(encoder.encode(JSON.stringify(header)));
  const payloadEncoded = base64UrlEncode(encoder.encode(JSON.stringify(fullPayload)));
  const signingInput = `${headerEncoded}.${payloadEncoded}`;

  const key = await importKey(secret);
  const signature = await crypto.subtle.sign(
    "HMAC",
    key,
    encoder.encode(signingInput)
  );

  return `${signingInput}.${base64UrlEncode(signature)}`;
}

export async function verifyJWT(token, secret) {
  try {
    const parts = token.split(".");
    if (parts.length !== 3) throw new Error("Geçersiz token formatı");

    const [headerEncoded, payloadEncoded, signatureEncoded] = parts;
    const signingInput = `${headerEncoded}.${payloadEncoded}`;

    const key = await importKey(secret);
    const signatureBytes = Uint8Array.from(
      base64UrlDecode(signatureEncoded), 
      c => c.charCodeAt(0)
    );

    const valid = await crypto.subtle.verify(
      "HMAC",
      key,
      signatureBytes,
      encoder.encode(signingInput)
    );

    if (!valid) throw new Error("İmza doğrulanamadı");

    const payload = JSON.parse(base64UrlDecode(payloadEncoded));
    const now = Math.floor(Date.now() / 1000);

    if (payload.exp && payload.exp < now) {
      throw new Error("Token süresi dolmuş");
    }

    return { valid: true, payload };
  } catch (error) {
    return { valid: false, error: error.message };
  }
}

Bot Tespiti

Cloudflare, her request’e cf objesi ekler. Bu obje içinde Türkiye’ye özgü senaryolarda çok işe yarayan bot skoru, ASN bilgisi ve tehdit skoru bulunur. Bir e-ticaret müşterisinde ASN bazlı filtreleme yaparak Türkiye kaynaklı trafiği hosting/VPN ASN’lerinden ayırdık; false positive’i minimize ettik:

// src/botDetection.js
// Bilinen kötü ASN'ler - düzenli güncellenmeli
const SUSPICIOUS_ASNS = [
  "AS209588", // Örnek: belirli bir VPN sağlayıcısı
  "AS396356", // Örnek: hosting bloğu
];

export function analyzeBotSignals(request) {
  const cf = request.cf || {};
  const signals = [];
  let riskScore = 0;

  // Cloudflare bot skoru (düşük = daha bot)
  if (cf.botManagement) {
    const botScore = cf.botManagement.score;
    if (botScore < 30) {
      signals.push("low_bot_score");
      riskScore += 40;
    } else if (botScore < 60) {
      riskScore += 15;
    }
  }

  // Threat score kontrolü
  if (cf.threatScore && cf.threatScore > 10) {
    signals.push("high_threat_score");
    riskScore += cf.threatScore;
  }

  // ASN kontrolü
  if (cf.asn && SUSPICIOUS_ASNS.includes(`AS${cf.asn}`)) {
    signals.push("suspicious_asn");
    riskScore += 30;
  }

  // User-Agent analizi
  const ua = request.headers.get("user-agent") || "";
  if (!ua || ua.length < 10) {
    signals.push("missing_or_short_ua");
    riskScore += 25;
  }

  // Tor exit node kontrolü
  if (cf.isEUCountry === false && cf.country === "T1") {
    signals.push("tor_exit_node");
    riskScore += 50;
  }

  // Headers tutarlılığı
  const acceptHeader = request.headers.get("accept");
  const acceptLang = request.headers.get("accept-language");
  if (!acceptHeader || !acceptLang) {
    signals.push("missing_browser_headers");
    riskScore += 20;
  }

  return {
    riskScore,
    signals,
    blocked: riskScore >= 70,
    challenged: riskScore >= 40 && riskScore < 70
  };
}

Ana Worker Mantığı

Şimdi her şeyi bir araya getirelim. Login endpoint’ine gelen POST isteklerini edge’de yakalıyoruz; diğer her şeyi origin’e geçiriyoruz:

// src/index.js
import { checkRateLimit, recordSuccessfulLogin } from "./rateLimit.js";
import { createJWT, verifyJWT } from "./jwt.js";
import { analyzeBotSignals } from "./botDetection.js";

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const ip = request.headers.get("CF-Connecting-IP") || "unknown";

    // Sadece login endpoint'ini handle et
    if (url.pathname === "/api/auth/login" && request.method === "POST") {
      return handleLogin(request, env, ip);
    }

    // Korumalı route'ları kontrol et
    if (url.pathname.startsWith("/api/") && url.pathname !== "/api/auth/login") {
      const authResult = await handleAuthCheck(request, env);
      if (!authResult.authenticated) {
        return new Response(JSON.stringify({ 
          error: "Yetkisiz erişim",
          code: "UNAUTHORIZED"
        }), {
          status: 401,
          headers: { "Content-Type": "application/json" }
        });
      }
    }

    // Origin'e geçir
    return fetch(request);
  }
};

async function handleLogin(request, env, ip) {
  // Bot analizi
  const botAnalysis = analyzeBotSignals(request);
  
  if (botAnalysis.blocked) {
    return new Response(JSON.stringify({ 
      error: "Erişim reddedildi",
      code: "BOT_DETECTED"
    }), {
      status: 403,
      headers: { "Content-Type": "application/json" }
    });
  }

  // Rate limit kontrolü
  const maxAttempts = parseInt(env.MAX_ATTEMPTS || "5");
  const lockoutDuration = parseInt(env.LOCKOUT_DURATION || "900");
  
  const rateLimitResult = await checkRateLimit(
    ip, 
    env.RATE_LIMIT_KV, 
    maxAttempts, 
    lockoutDuration
  );

  if (!rateLimitResult.allowed) {
    return new Response(JSON.stringify({
      error: "Çok fazla deneme yapıldı. Lütfen bekleyin.",
      retryAfter: rateLimitResult.retryAfter,
      code: rateLimitResult.reason
    }), {
      status: 429,
      headers: {
        "Content-Type": "application/json",
        "Retry-After": String(rateLimitResult.retryAfter || 900),
        "X-RateLimit-Remaining": "0"
      }
    });
  }

  // İsteği origin'e ilet, credential doğrulaması orada
  let body;
  try {
    body = await request.json();
  } catch {
    return new Response(JSON.stringify({ error: "Geçersiz istek gövdesi" }), {
      status: 400,
      headers: { "Content-Type": "application/json" }
    });
  }

  // Origin'den auth sonucunu al
  const originResponse = await fetch(request.url.replace("/api/auth/login", "/internal/auth/verify"), {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Internal-Secret": env.INTERNAL_SECRET,
      "X-Original-IP": ip
    },
    body: JSON.stringify(body)
  });

  const originData = await originResponse.json();

  if (!originResponse.ok || !originData.success) {
    // Başarısız deneme kaydedildi (KV'de otomatik sayıldı)
    return new Response(JSON.stringify({
      error: "Kullanıcı adı veya şifre hatalı",
      remaining: rateLimitResult.remaining - 1
    }), {
      status: 401,
      headers: { "Content-Type": "application/json" }
    });
  }

  // Başarılı giriş - rate limit sıfırla, JWT üret
  await recordSuccessfulLogin(ip, env.RATE_LIMIT_KV);

  const token = await createJWT({
    sub: originData.userId,
    email: originData.email,
    role: originData.role,
    ip: ip
  }, env.JWT_SECRET, 3600);

  return new Response(JSON.stringify({
    success: true,
    token,
    expiresIn: 3600
  }), {
    status: 200,
    headers: {
      "Content-Type": "application/json",
      "Set-Cookie": `auth_token=${token}; HttpOnly; Secure; SameSite=Strict; Max-Age=3600; Path=/`
    }
  });
}

async function handleAuthCheck(request, env) {
  const authHeader = request.headers.get("Authorization");
  const cookieHeader = request.headers.get("Cookie");
  
  let token = null;

  if (authHeader && authHeader.startsWith("Bearer ")) {
    token = authHeader.substring(7);
  } else if (cookieHeader) {
    const match = cookieHeader.match(/auth_token=([^;]+)/);
    if (match) token = match[1];
  }

  if (!token) {
    return { authenticated: false, reason: "no_token" };
  }

  const result = await verifyJWT(token, env.JWT_SECRET);
  return { 
    authenticated: result.valid,
    payload: result.payload,
    reason: result.error
  };
}

Deploy ve Test

# Geliştirme ortamında test
wrangler dev --local

# KV'yi test verisiyle doldur
wrangler kv:key put --binding=RATE_LIMIT_KV "test_key" "test_value"

# Production'a deploy
wrangler deploy

# Başarılı deploy sonrası log takibi
wrangler tail --format=pretty

Lokal test için birkaç curl komutu:

# Normal login denemesi
curl -X POST http://localhost:8787/api/auth/login 
  -H "Content-Type: application/json" 
  -H "CF-Connecting-IP: 1.2.3.4" 
  -d '{"username":"[email protected]","password":"test123"}'

# Rate limit testini simüle et (aynı IP'den 6 istek)
for i in {1..6}; do
  echo "Deneme $i:"
  curl -s -X POST http://localhost:8787/api/auth/login 
    -H "Content-Type: application/json" 
    -H "CF-Connecting-IP: 10.0.0.1" 
    -d '{"username":"[email protected]","password":"yanlis"}' 
    | python3 -m json.tool
  sleep 0.5
done

# JWT ile korumalı endpoint erişimi
TOKEN="eyJ..."  # login'den alınan token
curl http://localhost:8787/api/user/profile 
  -H "Authorization: Bearer $TOKEN"

Coğrafi Kısıtlama (Opsiyonel)

Eğer sisteminiz sadece Türkiye’ye hizmet veriyorsa ve uluslararası erişim istemiyorsanız, cf.country kullanarak bu kontrolü kolayca ekleyebilirsiniz. Dikkat: bu kısıtlamayı sadece gerçekten ihtiyaç duyduğunuzda kullanın, VPN kullanan meşru kullanıcıları engelleyebilirsiniz:

// src/geoRestriction.js
const ALLOWED_COUNTRIES = ["TR", "CY", "AZ", "DE"]; // Hedef pazarınıza göre

export function checkGeoRestriction(request, enabled) {
  if (!enabled) return { allowed: true };

  const country = request.cf?.country || "XX";
  
  if (!ALLOWED_COUNTRIES.includes(country)) {
    return {
      allowed: false,
      country,
      reason: "geo_restricted"
    };
  }

  return { allowed: true, country };
}

Bu fonksiyonu handleLogin içinde şu şekilde çağırırsınız:

const geoRestrict = env.GEO_RESTRICT === "true";
const geoResult = checkGeoRestriction(request, geoRestrict);

if (!geoResult.allowed) {
  return new Response(JSON.stringify({
    error: "Bu hizmet bulunduğunuz bölgede kullanılamamaktadır.",
    code: "GEO_RESTRICTED"
  }), { status: 403, headers: { "Content-Type": "application/json" } });
}

Monitoring ve Alerting

Production’da şüpheli aktiviteyi izlemek için Workers Analytics Engine kullanabilirsiniz ya da basitçe console.log ile Wrangler tail üzerinden takip edebilirsiniz. Daha ciddi bir monitoring için:

// src/analytics.js
export async function logSecurityEvent(env, eventType, data) {
  const event = {
    timestamp: new Date().toISOString(),
    type: eventType,
    ...data
  };

  // Opsiyonel: harici log servise gönder (Datadog, Grafana Loki vb.)
  if (env.LOG_ENDPOINT) {
    try {
      await fetch(env.LOG_ENDPOINT, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer ${env.LOG_API_KEY}`
        },
        body: JSON.stringify(event)
      });
    } catch {
      // Log hataları asla ana akışı engellememelidir
      console.error("Log gönderilemedi:", eventType);
    }
  }

  console.log(JSON.stringify(event));
}

Security event’leri şöyle kategorize edebilirsiniz:

  • login_blocked_ratelimit: IP kilitlendiğinde
  • login_blocked_bot: Bot skoru yüksek olduğunda
  • login_failed: Hatalı credential
  • login_success: Başarılı giriş
  • auth_token_expired: Süresi dolmuş token erişimi
  • auth_token_invalid: Bozuk/manipüle edilmiş token

Bilinen Sınırlılıklar ve Dikkat Edilmesi Gerekenler

Workers’ın bazı kısıtlamaları var ve bunları baştan bilmek, sonradan sürprizle karşılaşmaktan kurtarıyor:

  • CPU süresi: Tek bir Worker isteği maksimum 50ms CPU süresi kullanabilir (paid plan’da 30 saniye). Karmaşık şifreleme işlemleri dikkat gerektirir.
  • KV gecikme: KV strongly consistent değil, eventually consistent. Aynı veri merkezinden okuma genelde güncel, farklı bölgeler arasında kısa bir lag olabilir. Rate limiting için bu kabul edilebilir.
  • Gizli değişkenler: env.JWT_SECRET gibi değerleri kesinlikle wrangler.toml‘a yazmayın, her zaman wrangler secret put kullanın.
  • IP manipülasyonu: CF-Connecting-IP headerı Cloudflare tarafından ekleniyor, güvenilir. Ama origin sunucunuz direkt erişime açıksa bu header manipüle edilebilir. Origin’i sadece Cloudflare IP’lerine açın.
  • Token blacklisting: Bu implementasyonda logout sonrası token invalidation yok. Bunu yapmak için KV’de bir revocation list tutmanız gerekiyor; trade-off olarak her auth check’te KV’ye ek bir lookup yapılır.

Sonuç

Bu yapıyla elde ettiğimiz şey şu: kötü niyetli trafik Türkiye’deki bir Cloudflare edge node’unda ölüyor, origin sunucunuz sadece meşru trafikle uğraşıyor. Müşterimizin sunucu yükü brute force saldırısı sırasında bu yapıya geçtikten sonra yüzde seksen düştü. Load balancer loglarında “syn flood” satırları kayboldu.

Tabii bu tek başına yeterli değil. Origin’de de kendi auth validasyonunuzu tutun, X-Internal-Secret ile Worker’dan gelen istekleri verify edin, HTTPS her yerde zorunlu olsun. Güvenlik katmanlı bir yaklaşım gerektiriyor; Workers burada giriş kapısındaki akıllı muhafız rolünü üstleniyor, asıl sorumluluk hala sizde.

Wrangler CLI ve Workers ekosistemi hızla olgunlaşıyor. Durable Objects ile daha güçlü state yönetimi, D1 ile SQLite tabanlı session storage gibi seçenekler artık production’a hazır. Bu temel yapının üstüne bunları da inşa edebilirsiniz.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir