Manuel Tokenization İle Authentication, WebServisleri Güvenliği ve Otomatik Yenileme Part 1

Selamlar Arkadaşlar,

Bu makalede üzerine bolca konuşulan ve yeni başlıyanların korkulu rüyası tokenization, authentication ve Webservisleri güvenliği konularını masaya yatıracağız. Bu işler için herkesin kullandığı Owen, Oauth 2.0 gibi teknolojiler bulunmaktadır. Ama yüksek performans isteyen yapılarda, ya da belli aşamalarda özel çözümlerin gerektirdiği, yani araya kendi kodlarınız konması gerektiği durumlarda bazen işe yarayamaya bilmektedirler. Peki biz bu makalede ne yapacağız. Tabi ki kendi token’ımızı kendimiz yaratacağız :) Başta işler biraz zor olsa da, sonrasında özel çözümlere gidildiğinde, tüm koda hakim olmanın verdiği dayanılmaz mutluluk paha biçilemez.

Şimdi gelin .Net Core bir Mvc projeyi aşağıdaki gibi oluşturalım.

Program.cs: Projenin 1923 portundan ve https üzerinden yayımlanması için aşağıdaki kod satırı eklenir.

Güveliğin ilk başladığı nokta, hepinizin bildiği gibi Login ekranıdır. Peki bu Login ekranı ne zaman gelecek. Ben 2 farklı yol ile bu makale serisinde size fikir vermek istiyorum. 1. yol yani bu bölümün konusu, Backend’de Session ve Frontend’de Local Storage. Şimdi gelin bu konuyu biraz açalım.

Authentication 101 :) :

Mvc bir projede, ilk default action’a gelindiği zaman kişi login olmamış ise, Login penceresine yönlendirmek gerekmektedir. Bunu Custom bir “IActionFilter” yazarak kolayca yapabiliriz. Geldik 2.soruya bu login olunup olunmamış bilgisini nerede tutacağız. İlk cevap “Session” olsa da, WebServislerinde session’a erişmek her durum için mümkün değildir. Her bir durumu beraberce tartışacağız. Zaten makalenin esas düğüm noktası bu konudur! O zaman gelin Session’da “token” keyi olmayan kullanıcıları Login ekranına yönlendiren ActionFilter’ı yazalım.

Controllers/LoginFilter.cs (Part 1): Aşağıda görüldüğü gibi Loginfilter’ın en basit hali yazılmıştır. İlgili “token“‘a session’da bakan ve yok ise “Login” sayfasına yönlendiren bir yapı kurgulanmıştır. Bu sınıfa ait kodlar ilerde, ihtiyaca göre daha da geliştirilecektir.

HomeController/Login(): Aşağıda görüldüğü gibi [IgnoreAttribute] adında custom bir attribute ile işaretlenmiş Login() action bulunmaktadır. Amacı Login sayfasına gelen clientların, Session kontrolünün yapılmamasıdır. Kısaca Session[“token”]’ı null olan bir client’ın, Login’e yönlendirme durumunda, tekrardan session’a bakılması Dead Trigger’a sebebiyet verecektir. Bunu önlemek için IgnoreAttribute ile işaretli Actionlar, LoginFilter’da göz ardı edilecektir.

Controllers/IgnoreAttribute.cs: Sadece amacı, ilgili Action’ işaretlemek olan custom attribute.

Controllers/LoginFilter.cs(Part 2): Aşağıda görüldüğü gibi [IgnoreAttribute] ile işaretli Actionlar’da “Sesson[“token”]” kontrol’ü yapılmamaktadır. Böylece sürekli “Login” ekranına yönlenilmesi engellenmiştir. Bunun için “HasIgnoreAttribute()” methodu yazılmıştır. “OnActionExecuting()” methodunun daha en başında, çağrılan context’in ilgili “IgnoreAttribute” ile işaretli olup olmadığına bakılmış, işaretli ise herhangi bir işlem yapılmadan geri dönülmüştür.

Login.cshtml: Aşağıda güvenlik amacı ile UserName ve Password’ün girildiği bir ekran bulunmaktadır. İlgili WebApi servisine direk “Post” işlemi yapılmaktadır. Dikkat edilirse post işleminden sonra “result!=”Error”” değilse, yani doğru isim ve şifre girilmiş ise, geri dönen sonuç yani token “localStorage“‘a kaydedilmektedir. Örnek Token : “24c9e9e8-2a9c-486a-a322-634e65087927æ7/27/18 1:21:48 PM

Local Storage: Client-Side’da browser’ın Local Storage’a kaydettiği token değeri, aşağıdaki gibidir. [Guid + DateTime.Now()].

[HttpPost] HomeController/Login() : Aşağıda görüldüğü gibi isim ve şifre doğrulaması, örnek amaçlı static olarak yapılmıştır. Gene bu method’da “IgnoreAttribute” ile işretlenmiştir. Eğer isim ve şifre doğru ise, Guid ve o anki Zamanæ” işaretli ile birleştirilip geçerli token oluşturulmuştur. İlk yöntem olarak ilgili token bir Session’da saklanmıştır. İlgili token “Content(token)” şeklinde Login sayfasına result olarak dönülmektedir. Bu şekilde oluşan bu token, Client Side tarafta da Local Storage’da saklanmaktadır.

.Net Core bir projede Session kullanabilmek için Startup dosyasında, aşağıdaki config’in yapılması gerekmektedir.

Startup.cs/ConfigureServices: Session Timeout süresi olarak bu örnekte 1 dakika olarak belirlenmiştir. Ayrıca güvenlik amaçlı Cookiye sadece Http ile kayıt atılabilmektedir.

Startup.cs/Configure: Session’ın .Net Core’da kullanılması için bir de aşağıdaki tanımlamanın yapılması gerekmektedir.

Şimdi sıra geldi token’ı test edeceğimiz örnek bir sayfaya. Bu amaç doğrultusunda elektronik bir gazetede, seçilen kategorilere göre sıralanacak bir haber listesi, ve bu haber listesinden seçilen bir habere ait yorumların gösterileceği bir sayfa yapılacaktır.

İlgili DateModeller aşağıdaki gibidir:

Not: Tüm modellerde “RefreshToken“,  [NotMapped] ile işaretlenmiştir. Çünkü DB’de olmayan bir kolondur. Amacı süresi dolmaya yakın bir token’ın, yenisinin ilgili model ile taşınmasıdır. Bu şekilde çalışan client’ın, sürekli Login Ekranına düşürülmemesi ve belli bir sürede yeni bir tokenın üretilerek, güvenliği arttırılması sağlanmaktadır.

Models/Habers.cs:

Models/Kategoris:

Models/Yorums:

DAL/GazeteContext.cs: Bu projede CodeFirst kullanılmıştır. ilgili DBContext aşağıdaki gibidir.

HomeController/Gazete(): Gazete link’ine tıklanınca önce “LoginFilter”‘ın “OnActionExecuting()” methoduna gidilir. Gazete Action’ına gelen “token”, makalenin devamında detaylıca anlatılacaktır.

Not: Sayfanın ilk yüklenme durumunda, View Model’i doldurmak için Action tarafında ilgili web servisine gidilip kategoriler çekilmek istense idi, WebApi servisinde ilgili session’a ve doğal olarak token’a erişilemiyecekti. Bunun için tüm WebServisi işlemleri, Client Side tarafda Jquery Post ile yapılmıştır. Maklenin 2.bölümünde “token” session yerine Redis’de tutulacak ve Mvc Application’da ilgili data server side taraftan çekilecektir.

https://localhost:1923/Home/Gazete:

Gazete.cshtml: Sayfanın çalışma mantığı, ilk yüklendiğinde “$(document).ready()” methodunda ilgili WebApi servisine gidilmiş ve Category combosu(comboCategory) doldurulmuştur.

  • “$(“#comboCategory”).change(function(){” : Combo Categor’den 1 eleman seçilince, “fillnews()” methodu ile Haber Combosu doldurulur.
  • “$(“#comboNews”).change(function(){“: Combo News yani Haber Combosundan 1 eleman seçilince, “fillNewsDetail()” methodu ile haber detay ve “fillCommands()” methodu ile de, ilgili habere ait Yorumlar doldurulmaktadır.
  • Combo Category Doldurulması:
    • “var token=localStorage.getItem(‘token’).split(“æ”)[0]” : İlgili token Local Storage’dan alınır. Split ile CreatedDate kısmından ayrılır.
    • “$.getJSON(“/api/news/”+token)” : İlgili WebServisine güncel token ile request yapılır. Güvenlik amaçlı WebServisi her request için güncel token’ı parametre olarak istemektedir.
    • “if(data.length==0” : Eğer hiçbir data dönmez ise, bu gönderilen token’ın süresinin geçtiğini ya da doğru bir token olmadığını göstermektedir. Bu nedenle client, ana sayfaya yönlendirilmekte ve ordan da LoginFilter’a takılıp Login ekranına gidilmektedir.
    • “for (i = 0; i < data.length; i++) {“: Eğer herhangi bir data dönülmüş ise, ilgili data gezilerek “item[]” dizisine, “option” değerler push edilmektedir.
    • “items.push(“<option value=” + data[i].id + “>” + data[i].ad + “</option>”);”: Her bir “option” items[] dizisine atılmaktadır.
    • ” $(“#comboCategory”).html(items.join(‘ ‘));” : Cataregory Combosunun Html’ine,  ilgili items[] dizisi atanmaktadır.
  • function fillNews(categoryID):
    • “var token=localStorage.getItem(‘token’).split(“æ”)[0];” :  İlgili token Local Storage’dan alınır. Split ile CreatedDate kısmından ayrılır.
    • “$.getJSON(“/api/news/”+categoryID+”/”+token).done(function (data) { ” : İlgili WebServisinden, güncel token ile seçilen kategory’e ait haberler çekilir.
    • “if(data[0].refreshToken!=null)” : Çekilen token’ın süresinin dolmasına az kalan bir zamanda yapılan request’de, bu örnekte 40sn ile 60sn arasında yeni bir token üretilmektedir.
      • “localStorage.setItem(“token”, data[0].refreshToken); “: Zamanı dolan token yerine üretilen yeni token, Local Storage’da güncellenir.
    • “if(data.length==0)” : Eğer hiç data dönmez ise, bu gönderilen token’ın süresinin geçtiğini ya da doğru bir token olmadığını göstermektedir. Bu nedenle client, ana sayfaya yönlendirilmekte ve ordan da LoginFilter’a takılıp Login ekranına gidilmektedir.
    • “for (i = 0; i < data.length; i++) {” : Eğer data dönülmüş ise, ilgili News datası gezilerek bir diziye “option” değerler push edilmekte, ordan da  Haber Combos’u “$(“#comboNews”)” doldurulmaktadır.
  • “function fillNewsDetail(detail){” : Haber Combosunda bir kayıt seçilince, ilgili haber detay “$(“#newsDetail”).html(detail)” bir textarea’ya doldurulmaktadır.
  • “function fillCommands(newsID)” : Son olarak geldi sıra, seçilen Haber’e göre ilgili Yorumların WebApi servisinden çekilmesine.
    • “var token=localStorage.getItem(‘token’).split(“æ”)[0];” : Güncel token Local Storage’dan çekilir.
    • ” $.getJSON(“/api/news/command/”+newsID+”/”+token).done(function (data) {” : Seçilen Kategory ve Habere göre Yorumların çekildiği servisidir.
      • “if(data[0].refreshToken!=null)” : Çekilen token’ın süresinin dolmasına az kalan bir zamanda yapılan request’de, bu örnekte 40sn ile 60sn arasında yeni bir token üretilmektedir.
      • “localStorage.setItem(“token”, data[0].refreshToken)” : Zamanı dolan token yerine üretilen yeni token, Local Storage’da güncellenir.
      • “if(data.length==0)”: Eğer hiçbir kayıt dönmez ise, bu örnekte mutlaka yorum yapılacağı düşünülmüştür, ya token’ın süresi dolmuştur. Ya da yanlış token girilmiştir.
        • “window.location.href = ‘https://localhost:1923′”: Bu nedenle ana sayfaya yönlenilmekte ordan da LoginFilter’a takılıp Login ekranına gidilmektedir.
      • ” for (i = 0; i < data.length; i++) { ” : İlgili yorum datası çekilir ve gezilerek items[] dizisine doldurulur.
      • “$(“#tableCommand”).html(items.join(‘ ‘));” :  Son olarak WebApi servisinden dönen data, Command yani Yorum combosuna atanır.
      • “@if(@ViewBag.Token!=null)” : Bu kısım henüz LoginFilter’da bahsedilmeyen yer ile alakalıdır. Kısaca Client-Side tarafta Local Storage’da token süresi, 40sn ile 60sn arasında ise, yeni bir RefreshToken üretilir. Bu yeni token, ilgili View’a ait Action’a parametre olarak gönderilir. Eğer parametre var ise, ilgili Action’da token ViewBag’e şu şekilde atanır ==>”if (token != null){ViewBag.Token=token;}
        • “localStorage.setItem(“token”, ‘@Html.Raw(@ViewBag.Token)’)” : Eğer yeni bir token gelmiş ise Local Storage güncellenir.

Gazete.cshtml: Haber ve yorumların gösterildiği sayfadır.

Refresh Token

Controllers/LoginFilter.cs(Part 3) Tam : Aşağıda önceden anlatılmayan kısım “IsRefreshToken()” function’ı dır.

  • “string actionName = (string)context.RouteData.Values[“action”]” : LoginFilter’a gelinen Action’ın ismi alınır.
  • “string controllerName = (string)context.RouteData.Values[“controller”]” : LoginFilter’a gelinen Controller’ın ismi alınır.
  • “var controller = (HomeController)context.Controller” : HomeController çekilir.
  • “string refreshToken=IsRefreshToken(result, context)” : Varsa Session’daki token değeri ==> result ve context değerleri parametre olarak geçilerek, RefreshToken değeri aranır.
  • “if(!String.IsNullOrWhiteSpace(refreshToken))”: Eğer güncellenmesi gereken bir RefreshToken var ise şeklinde bir koşula bakılır.
    • “context.Result = controller.RedirectToAction(actionName, controllerName,new { token = refreshToken })” : Gelinilen Controller’a ait Action’a yeni çekilen RefreshToken değeri ile geri dönülür. Amaç Client-Side tarafta da, yeni token ile Local Storage’ı güncellemektir.
  • IsRefreshToken(): Session’dan gelen Token’ın süresi 40sn’den fazla ise, yeni bir token’ın üretilir ve session’a atılıp geri dönmesi sağlanır.
    • “string tokenSession = System.Text.Encoding.UTF8.GetString(sessionToken).Split(‘æ’)[1]”: Session’dan gelen token’ın, Created Date’i alınır.
    • “TimeSpan remainingTime = DateTime.Now – sessionCreateTime”: Token’ın, ne kadar süredir yayında olduğu hesaplanır.
    • “if (remainingTime.TotalSeconds >= 40)” : 40sn’den fazla ise koşuluna bakılır.(Bu değer bir config dosyadan da alınabilirdi.) Böylece session time out süreleri, derlenme işlemi olmadan, istendiği zaman kolayca değiştirilebilirdi.
      • “string token = Guid.NewGuid().ToString()+ “æ” + DateTime.Now” : Yeni token, Guid ve o anki zaman bilgisinin birleşiminden üretilir.
      • “context.HttpContext.Session.Set(“token”, System.Text.Encoding.UTF8.GetBytes(token))” : Session güncellenir. Ve yeni token geri dönülür.

Son olarak geldik en önemli konuya, yani WebApi servisine:

WebApi/NewsController.cs: DB’den çekilecek tüm data WebApi servisleri ve token kontrolü ile sisteme aktarılır.

Not: Bir WebServices’inde Session ile işlem yapmak, onaylanan bir yöntem değildir. WebApi servisleri stateless’dır. Örneğin webapi servisine Server-Side taraftan bir request yapılması durumunda, burdaki örneğe göre Gazete() actionın’dan, ilgili session’a webapi tarafından erişilemiyecekti. Ama Client-Side taraftan yapılan requestlerde, ilgili session’a webapi üzerinden erişilebilir.

  • public List<Kategoris> Category(string token)” : Amaç haberlere ait kategorilerin listesinin dönülmesidir.
    • “[HttpGet(“{token}”)] ” : Mvc routing bu şekilde yapılmıştır. Sadece string token bekleyen bir methoddur.
    • “var session = HttpContext.Session;” : Session var mı diye bakılır.
    • “HttpContext.Session.TryGetValue(“token”, out var result);”: Session’daki token alınır.
    • “string tokenSession = System.Text.Encoding.UTF8.GetString(result).Split(‘æ’)[0]”: Session’daki token’da ‘æ‘ karakteri ile zaman bilgisi ayıklanır. (24c9e9e8-2a9c-486a-a322-634e65087927æ7/27/18 1:21:48 PM)
      • “if (tokenSession == token)” : Gönderilen Token ile Session’daki token aynı mı diye bakılır.
      • “return context.Kategoris.ToList()” : Aynı ise kategory listesi geri dönülür.
      • “return new List<Kategoris>()” : Tokenlar aynı değil ise boş kayıt dönülür.
    • “return new List<Kategoris>()” : Session hiç yok ise, ya da Session[“token”] null ise, boş kayıt dönülür.
  • public List<Habers> CategoryNews(int id, string token)” : Seçilen kategoriye göre Haberleri getiren methoddur.
    • “[HttpGet(“{id}/{token}”)]” : Mvc routing ile seçilen CategoryID’yi ve string token’ı bekleyen bir methoddur.
    • “var session = HttpContext.Session” : Session var mı diye bakılır.
    • “HttpContext.Session.TryGetValue(“token”, out var result)” : Var ise ilgili token session’dan çekilir.
    • string refreshToken = IsRefreshToken(result)” : Burada çekilen token’ın süresine bakılır. Eğer 40sn’den fazla ise yenisi ile değiştirilir.
    • “string tokenSession = System.Text.Encoding.UTF8.GetString(result).Split(‘æ’)[0]” : Session’daki token’da ‘æ‘ karakteri ile zaman bilgisi ayıklanır.
    • “if (tokenSession == token)” : Gönderilen token ve sessiondaki token eşit ise ilgili haberler geri dönülür.
    • “var listNews = context.Habers.Where(ha => ha.Kategori_id == id).ToList()”: İlgili kategoriye ait tüm haberler geri dönülür.
    • “if (!String.IsNullOrWhiteSpace(refreshToken))” : Eğer refreshToken var ise, geri dönülecek tüm haber listesine “refreshToken” bilgisi eklenir.
    • “listNews.ForEach(command => { command.RefreshToken = refreshToken; })” : Tüm Haber Listesi gezilerek “refreshToken” kolonu set edilir.
    • “return new List<Habers>()”: Eğer 1-)Session yok ise 2-) Session[“token”] null ise 3-) Gelen parameter token ile sessiondaki token aynı değil ise boş haber listesi geri dönülür.
  • public string IsRefreshToken(byte[] token)” : Süresi bitmesine yakın olan token’ın, yenisi ile değiştirilmesi amacı ile yazılmış bir methoddur.
    • “string tokenSession = System.Text.Encoding.UTF8.GetString(token).Split(‘æ’)[1]” : Session’daki token’in oluşturulduğu süre alınır. [24c9e9e8-2a9c-486a-a322-634e65087927æ7/27/18 1:21:48 PM]
    • “DateTime sessionCreateTime = DateTime.Parse(tokenSession)” : Şu anki zaman alınır.
    • “TimeSpan remainingTime = DateTime.Now – sessionCreateTime” : Toplam geçen zaman bulunur. Yani Session[“token”]’ın yaşı bulunur :)
    • “if (remainingTime.TotalSeconds >= 40)”: Geçen süre 40sn’den fazla ise yenisi ile değiştirilir.
    • “string newToken = Guid.NewGuid().ToString()+ “æ” + DateTime.Now” : Yeni token Guid + Now(Şimdiki zaman) şeklinde oluşturulur.
    • “HttpContext.Session.Set(“token”, System.Text.Encoding.UTF8.GetBytes(newToken))”: Session[“token”]’a atanır. Ve geri dönülür.
  • public List<Yorums> CommandNews(int id, string token)” : Seçilen habere göre yapılan yorumların çekildiği methoddur.
    • “[HttpGet(“command/{id}/{token}”)]” : Mvc Routing ile başında “command/” text’i ile beraber parametre olarak seçilen HaberID ve güvenlik amaçlı token beklenmektedir.
    • “var session = HttpContext.Session” : Session var mı diye bakılır. Yok ise boş Yorums listesi dönülür.
    • “HttpContext.Session.TryGetValue(“token”, out var result)” : Session’dan token alınır. Yok ise yine boş Yorums listesi dönülür.
    • “string refreshToken = IsRefreshToken(result)” : Eğer token’ın süresi dolmak üzere ise, bu örnekte >40sn ise yeni RefreshToken alınır.
    • “if (tokenSession == token)” : Gelen token ile session’daki token karşılaştırılır. Farklı ise yine boş Yorums listesi dönülür.
    • “var listCommand = context.Yorums.Where(yor => yor.Haber_id == id).ToList()” : Token doğru ise seçilen Haber’e ait tüm yorum listesi çekilir.
    • “if (!String.IsNullOrWhiteSpace(refreshToken)) { listCommand.ForEach(command => { command.RefreshToken = refreshToken; }); }” : Eğer RefreshToken null değil ise, çekilen tüm yorumlar gezilerek,  RefreshToken kolonuna yeni oluşturulan token değeri atanarak geri dönülür.

Geldik bir makale serisinin daha sonuna. Bu bölümde güvenlik amaçlı bir WebApi servisi için gerekli olan token’ı, manuel olarak ürettik. Session süresinin sonlanmasına az bir zaman kala, token’ı yine manuel olarak üretilen RefeshToken ile yeniledik. Client-Side tarafta Local Storage’da, Server-Side tarafda session’da tutuğumuz tokenları, hem belli actionlara girerken ,hem de WebServislerine yaptığımız requestler’de eşleştirerek kontrol ettik. “$.Post” ile WebApi servisine yaptığımız requestlerde, session süresi bitmiş tokenler olur ise, Login sayfasına yönlendirdik. Session süresi bitmeye yakın tokenları da, yeni Refresh token oluşturup, hem Server-Side’da Sessionda, hem de Client-Side’da Local Storage’da yeniledik.

Bir sonraki bölümde ServerSide’da oluşturduğumuz tokenları, Session’da değil de Redis’de sakladığımız zaman nasıl avantajlar sağlayabileceğimizi hep beraber inceleyeceğiz.Yeni bir makalede görüşmek üzere hepinize hoşçakalın.

Source Code: https://github.com/borakasmer/CustomTokenization

Herkes Görsün:

Bunlar da hoşunuza gidebilir...

5 Cevaplar

  1. senol dedi ki:

    ben,m anlamadıgım şu bu Authentication işi net core membersihp ile olmuyormu bi dünya code yazıyoruz ?
    yoksa amaç başkamı

    • borsoft dedi ki:

      Amaç başka :) Videoda da anlattığım gibi amaç gereksiz koddan kurtulmak, kodun heryerine hakim olmak ve arasına istediğimiz kodu sıkıştırmak :)

  2. yusuf dedi ki:

    Merhaba abi bir önce ki mesajım da sertifika hatasının kaynağından bahsetmiştim. Çözümü buldum. Sorun sürümden çünkü .2.0.9 dan sonra sslport açıyor. portu kapatınca sorunsuz şekilde bağlanıyor. \Properties\launchSettings içinde “sslPort”: 44348 ve “ASPNETCORE_HTTPS_PORT”: “44348” var.
    Ben “sslPort” iptal ettim. Microsoft.AspNetCore.App 2.1.1 “ASPNETCORE_HTTPS_PORT” oluşturmadı yani senin derlemende var bence sen de onu da iptal edip deneyebilirsin. Çünkü boş proje oluşturduğumda da aynı hatayı tarayıcılar veriyordu. Şuan kaldırınca sorunsuz.

  3. serdar dedi ki:

    merhaba token cookide saklanıyorsa ve buna bakarak oturum açıyorsak ben arkadaşımın bilgisayarından cookie alıp kendi bilgisayarıma kopyalarsam ve oturum açmak istediğimde benide onaylayacaktır ? o zaman token neden kullanıyoruz client a ait harddisk numarası alıp bunu kullansak o zaman cookie alınsa bile başkası kullanamaz elinde çünkü disk numarası yoktur.Ama client kullanıcının disk numarasını alamaz o zaman bu dediğim güvenlik açığı değilmi ?

    • borsoft dedi ki:

      Baska bir Client’in Browser’indaki Token’i almak, arkadasinin evinin anahtarını almak gibidir, bu nedenle Token’a kısa sureli Expire suresi atanmalı ve yenilemek icin 2cil yani RefreshToken’a ihtiyac duyulmalıdır. Expire suresi ne kadar kısa tutulur ise, maalesef Performans da o kadar düşer. Özellikle bu etki, yüksek trafik alan cok sayıda unique client içeren yapılarda gozlemlenmektedir.

Bir cevap yazın

E-posta hesabınız yayımlanmayacak.