Token Yenilenme Durumunda Aynı Clienti’ın Birden Fazla İstekte Bulunduğunda, Aldığı Geçersiz Token Sorunu
Selamlar, bu makalede Token ve Refresh Token’ın kullanıldığı senaryolarda Expire durumunda, karşılaşılabilecek sorunlardan ve çözümlerinden bahsedilecektir.
Aslında projelerinde, Token kullanmayan nerede ise hiç yoktur. Ama genelde bu tokenları, expire olmadan ve kullanıcıya çaktırmadan bir Refresh Token ile yenileyen maalesef pek azdır. Çalışan cilient’ı madur etmemek adına genelde, bu Token Expire süreleri uzun tutulur. Ve bu da bazı güvenlik açıklarına neden olmaktadır. Hadi şimdi gelin bu Refresh işleminde hemen karşımıza çıkmayacak, ama kullanılmaya başlandığında karşılaşılabilecek çok sinsi bir sorundan bahsedelim. Bu makalede sıfırdan bir kod yazılmayacak, proje içinde geçen kod parçaları örnek amaçlı paylaşılacaktır.
Yukarıdaki çizime bakıldığında, bir client’ın 4 farklı request’i eş zamanlı olarak asenkron gerçekleştirdiği görülmektedir. Controller veya Actionlara gelmeden, bir “[LoginAttribute]” ile Token ve yetki kontrolünün yapıldığını düşünelim.
1-) Token Validation:
Gelen tüm Requestlerin ilk karşılandığı nokta. Bu senaryoda, ilk Threadin Token kontrolünden başarı ile geçtiğini ama diğer Threradlerin daha henüz bu kontrole girmediğini düşünelim.
2-) Token Expire Süresinin Kontrol Edilmesi:
Bu aşamada, doğrulanmış Token’ın ömrüne bakılır. Eğer sonlanmasına bu senaryo gereği 15 dakikadan az bir süre kalmış ise, Refresh Token’ın da olup olmadığına ve RefreshToken’ın doğruluğuna bakıldıktan sonra yenileme işlemi yapılarak, client-side’a bu yeni tokenlar geri dönülür. Böylece sürekli çalışan client, madur edilmemiş ve logout olmadan bir 60 dakika daha kazandırılmış olunur.
*3-) İlk Thread 1 ile Değiştirilen Tokenlar, Thread 2-3 ve 4 için Artık Geçersiz olacaktır:
Bu durum aslında çok kısa bir süre içinde gerçekleşebilmektedir. Testlerde yakalanması da çok zor olabilmektedir :) Çünkü daha çok, aynı client’ın anlık asenkron olarak birden fazla istekte bulunması durumunda, bu sorun ile karşılaşılmaktadır. İlk istekte yenilenen Tokenın, artık diğer isteklerin elindeki Token’ı geçersiz hale getirmesi ile karşımıza çıkan 401 Unauthorized hatasıdır. Çözüm makalenin devamında, akış tamamlanınca anlatılacaktır.
4-) Double Check Lock Mutex:
Token’ın yenilenmesi sırasında, bunun sadece ilk istek için yapılması diğer 3 istek için de tekrarlanmaması istenir. Yani anlık 4 request’de Token’ın herbiri için ayrı ayrı yenilenmesi, sunucu için 4X yüktür. Bu nedenle, ilk Requestiden sonra Lock objesi ile diğer requestler kitlenmekte ve ilk Request’in ilgili Token ve RefreshToken’ı değiştirmesi beklenmektedir. Daha sonrasında, Lock objesi sadece 2.Thread için kaldıralacak ve 2. kere Token’ın Expire olup olmadığı kontrol edilecektir. Doğal olarak, ilk Request’de ilgili Token güncellendiği için bu koşul sağlanmayacak ve 2.kere Token güncellenmeyecektir. Aynı durum 3. ve 4. Requestler için de geçerli olacaktır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
if ((remainingTime.TotalMinutes >= _coreContext.TokenExpireTime - 15 && remainingTime.TotalMinutes <= _coreContext.TokenExpireTime)) //Token'ın Expire süresine bakıldığı ilk kontrol. { lock (lockObject) // Diğer Requestler bu lock objesinin dışında bekler. { //Double Check Lock With Redis Key! //İlgili UserID'ye ait Token Redis'den alınır. cacheRedistoken = _redisCacheService.Get(_redisCacheService.GetTokenKey(userId, isMobile, false, unqDeviceId)); var redisToken = cacheRedistoken.Split('ß')[2]; var redisTokenCreateTime = DateTime.Parse(redisToken); var redisTokenTime = DateTime.Now - redisTokenCreateTime; //2.KONTROL Check Timeout 2. Time for Backend Redis! if (redisTokenTime.TotalMinutes >= _coreContext.TokenExpireTime - 15) //Token'ın 2.kere Expire süresine bakılır. Amaç ilk Request Token'ı yenilemiş ise, diğer requestlerin aynı işlemi tekrarlamamasıdır. { CreateTokensByCheckRefreshToken(context); //Tüm koşullar sağlanmış ise yeni Token ve RefreshToken yenilenecektir. } } } |
Yukarıdaki kod parçasında, öncelikle Tokenın expire süresinin 15 dakkadan az kaldığı ve 60 dakikayı geçmediği koşulu kontrol edilmiştir. Daha sonra ilk request geçtikten sonra, diğer requstler için “Lock” işlemi yapılmıştır. Sonrasında ilgili Tokenlar tekrardan Redis’den çekilmiş ve 2.kere Token Expire süresine bakılmıştır. Amaç, demin de bahsedildiği gibi lock objeye ilk Requestden sonra giren diğer Requestler için yeniden Token süresini kontrol ederek, 2. , 3. ve 4. kere yeni Token ve RefreshToken’ın yaratılmasına engel olmaktır.
Şimdi Gelelim İlk Request’de Değişen Token’ın, Eş Zamanlı Gelen Diğer Requestleri Geçersiz Kılması Sorununa:
1-) Eğer Tokenin yaşı “45 < x < 60 ” arası ise, yani en fazla 15 dakika içinde Expire olacak ise aşağıda görüldüğü gibi değişimi için öncelikle, güvenlik amaçlı RefreshToken beklenir. RefreshToken yok ise, zaten güncelleme işlemi yapılmaz. Daha sonra ilerde, Redisden geçerli Token ve Referesh Tokenların çekilmesi için “userID”, mobil ise “isMobile” ve mobile cihazın UniqueID’si “UniqueDeviceID”‘i alınmaktadır. Eğer Header’dan geçerli bir “userId” gelmez ise, “UnAuthorized” hatası geri dönülmektedir.
2-)Aşağıda görüldüğü gibi Client ve BackEnd tarafından gelen RefreshTokenlar karşılaştırılmıştır. ClientSide taraftan gelen encrypted RefreshToken, decrypt edilir. Ayrıca ilgili userID’ye ait Redis’de bir RefreshToken yok ise, ya Expire olmuştur ya da client hiç LOGİN olmamıştır.
* 3-) Aşağıdaki kodda görüldüğü gibi, Expire’a uğrayacak user’a ait eski Token 5 dakka süre ile “_Old” keyword’ü ile Redis’de saklanırlar. Peki neden ? Makalenin sebebi olan cevap :) Çünkü diğer Requestler için oluşabilecek geçersiz Token durumunda, bu eski 5 dakkalık Tokenların geçerliğine bakılacak ve buna göre izin verilecektir.
4-) Aşağıdaki kod parçasında, artık yeni bir Token üretilmiş ve Redis’e 1 saatlik bir expire süresi ile konmuştur. Böylece, makina başındaki client, logout olmadan ya da bölünmeden 1 saat daha işine devam edebilecektir. Daha sonra aynı adımlar, yani eski Token’in “_Old” uzantı ile 5 dakka süre ile kaydedilip, yeni Tokenın 1 saatlik süre için yaratılıp Redis’de saklanması, RefreshToken için de yapılır.
5-) Generate Token, geriye Tuple olarak şifreli ve şifresiz hali ile iki yeni oken dönmektedir. Şifreli hali, “context.HttpContext.Items[“token”] = encToken;” ile Headerda client’a dönülür. Şifresiz hali, Backend’de Redis’de saklanır. Client’a Token’ın şifreli hali, tamamen güvenlik amacı ile dönülmektedir. Bir Token’ın açık halinin görünmesi, bir sonraki Token tahmini veya algoritmanın çözülmesi için önemli bir ip ucu olmaktadır.
6-) Aşağıdaki kod parçasında, Token üretimi için yapılan işlemlerin bir benzeri, RefreshToken için de yapılmaktadır. Buradaki tek fark, RefreshToken’ın TimeOut süresi, işi sağlama almak adına 90 dakikadır. Kısacası Refresh Token süresini, Token’ın süresinden biraz daha fazla tutmakda fayda vardır.
Tam Kod:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
public void CreateTokensByCheckRefreshToken(ActionExecutingContext context, bool returnResult = false) { if (context.HttpContext.Request.Headers["RefreshToken"].FirstOrDefault() != null) // client refresh token göndermiş. { bool.TryParse(context.HttpContext.Request.Headers["IsMobile"].FirstOrDefault(), out var isMobile); int.TryParse(context.HttpContext.Request.Headers["UserId"].FirstOrDefault(), out var userId); var unqDeviceId = context.HttpContext.Request.Headers["UnqDeviceId"].FirstOrDefault(); if (userId == 0) { context.Result = new UnauthorizedResult(); return; } var clientRefreshToken = context.HttpContext.Request.Headers["RefreshToken"].FirstOrDefault(); var redisRefreshToken = _redisCacheService.Get(_redisCacheService.GetTokenKey(userId, isMobile, true, unqDeviceId)); if (string.IsNullOrEmpty(redisRefreshToken))//rediste refresh token yok { context.Result = new UnauthorizedResult(); return; } var decClientRefreshToken = _encryption.DecryptText(clientRefreshToken); if (decClientRefreshToken == redisRefreshToken)//Refresh Token doğru. Yeni token ve refresh token üretip dönelim. { UserModel user = _userService.GetById(userId).Entity; var (encToken, decToken) = _encryption.GenerateToken(user.Email); //Oluşturulan Token Redis'e atılır. //Get Current Old Token var redisToken = _redisCacheService.Get(_redisCacheService.GetTokenKey(userId, isMobile, false, unqDeviceId)); //Set Current Old Token for 5 minutes. DateTime tokenOldExpireTime = DateTime.Now.AddMinutes(5);// Eski Token 5 dakka süre ile -Old olarak atanır. _redisCacheService.Set(_redisCacheService.GetTokenKey(userId, isMobile, false, unqDeviceId) + "-Old", redisToken, tokenOldExpireTime); //BİTTİ Token-Old Atandı-------------------------------------------------- var createTime = DateTime.Now; DateTime tokenExpireTime = createTime.AddMinutes(_coreContext.TokenExpireTime); _redisCacheService.Set(_redisCacheService.GetTokenKey(userId, isMobile, false, unqDeviceId), decToken, tokenExpireTime); //Geri dönülecek Encrypt Token ve Yaratılma zamanı Client'ın Header'ına atanır context.HttpContext.Items["token"] = encToken; context.HttpContext.Items["createdTokenTime"] = createTime.GetTotalMilliSeconds(); //Set Current Old RefreshToken for 5 minutes. _redisCacheService.Set(_redisCacheService.GetTokenKey(userId, isMobile, true, unqDeviceId) + "-Old", redisRefreshToken, tokenOldExpireTime); //BİTTİ Token-Old Atandı--------------------------------------------------------- //RefreshToken Oluşturulur. //Refresh Token Mobilde 1 Yıl Web'de 1.5 saattir. appsettings.json'a bakınız. var refreshToken = GenerateRefreshToken(user, context, unqDeviceId, isMobile); if (!string.IsNullOrWhiteSpace(refreshToken)) { //Oluşturulan RefreshToken Client'a dönülür. context.HttpContext.Items["refreshToken"] = refreshToken; } } else if (returnResult) { context.Result = new UnauthorizedResult(); return; } } else if (returnResult) { context.Result = new UnauthorizedResult(); return; } } //------------------------------------------------------- public string GenerateRefreshToken(UserModel user, ActionExecutingContext context, string unqDeviceId, bool isMobile) { var createTime = DateTime.Now; DateTime tokenExpireTime = createTime.AddMinutes(_coreContext.RefreshTokenExpireTime); if (isMobile) { tokenExpireTime = createTime.AddMinutes(_coreContext.MobileRefreshTokenExpireTime); } var (encToken, decToken) = _encryption.GenerateToken(user.Email); _redisCacheService.Set(_redisCacheService.GetTokenKey(user.IdUser, isMobile, true, unqDeviceId), decToken, tokenExpireTime); return encToken; } //------------------------------------------------------- public (string encToken, string decToken) GenerateToken(string emailUser) { var token = new StringBuilder(); var guid = Guid.NewGuid(); var time = DateTime.Now; var email = emailUser; token.Append(email); token.Append("ß"); token.Append(guid); token.Append("ß"); token.Append(time); var encryptToken = EncryptText(token.ToString()); return (encryptToken, token.ToString()); } |
Şimdi Gelin Authentication Filterımızı Bu “_Old” Tokenlara Göre Tekrardan Düzenleyelim:
Aşağıdaki kod parçasında, ilk request ile değişen Token’ın, diğer Requestleri nasıl 401 Unauthorized’a düşürmeyeceği anlatılmıştır.
1-) Backend tarafinda Redis’den çekilen Token null ise, ya Expired olmuştur ya da hiç login olunmamıştır. Ama bazen Mobile uygulamalarda Token yok ise, 1 yıl Expire süresi verilmiş RefreshToken ile tekrardan yeni Token aldırılabilir. Ancak bu da tabi ki kullanıcıya bir kullanım kolaylığı getirse de, ayrıca bir güvenlik açığı oluşturmaktadır.
*2-) İşte esas çözüm aşağıda görüldüğü gibi Backend ile Client Side tarafdan gelen Tokenların eşit olmadığı durumlarda gerçekleştirilmektedir. Güncel Tokenların eşit olmadığı durumlarda, bir de Old Tokenlara yani 5 dakkalık ömrü olan Tokenlara bakılmaktadır. Eğer onlar da doğru değil ise, 401 Unauthorized hatası geri dönülmektedir. Böylece multithread anlık birden çok requestde Expire düşme sorunu çözülmüş olmaktadır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
var cacheRedistoken = _redisCacheService.Get(_redisCacheService.GetTokenKey(userId, isMobile, false, unqDeviceId)); string cacheOldRedistoken = null; if (string.IsNullOrEmpty(cacheRedistoken)) // Redis'de Token Key yok ise. { context.Result = new UnauthorizedResult(); return; } else if ((string.IsNullOrEmpty(cacheRedistoken)) || (!string.IsNullOrEmpty(cacheRedistoken) && cacheRedistoken.Trim() != decryptToken.Trim())) //Redis'de Token Yok Ya da Redis'de Token Var ama tokenlar eşit değil , geçerli bir oturum isteği değil. { //30.03.2021 Check Token-Old------------------------------------------------- //İlgili UserID'ye ait Token-Old Redis'den alınır. cacheOldRedistoken = _redisCacheService.Get(_redisCacheService.GetTokenKey(userId, isMobile, false, unqDeviceId) + "-Old"); //Token-Old ile aynı mı diye bakılır. if ((string.IsNullOrEmpty(cacheOldRedistoken)) || (!string.IsNullOrEmpty(cacheOldRedistoken) && cacheOldRedistoken.Trim() != decryptToken.Trim())) { context.Result = new UnauthorizedResult(); return; } } |
Geldik bir makalenin daha sonuna. Bu makalede, bazı güvenlik durumlarında karşılaşabileceğimiz sorunlardan bahsedilmiştir. Dikkat ederseniz, yazılımda güvenlik arttırıldıkça, üzerinde düşünülmesi gereken koşul ve durumlar da aynı oranda artmaktadır. Unutlmamalıdır ki güvenlik ile performans ters orantılıdır. Bu yüzden yazılımlarınızda güvenlik ilkelerini, ihtiyacınız doğrultusunda belirleyiniz. Aklınızdan çıkarmamanız gereken bir durum da, aynı satrançta olduğu gibi her yaptığınız hamlenin, başka bir alanda size açık olarak geri dönebilecek olmasıdır. Hamlelerinizi akıllıca ve ihtiyacınız olduğu yönde yapınız. Örneğin bir banka uygulaması ya da ödeme sistemi yazmıyorsanız, belki de bu kadar fazla önleme ihtiyacınız yoktur.
Yeni bir makalede görüşmek üzere hepinize hoşçakalın.
İçeriğinizi çok beğendim!
Tesekkurler..
Anlamadığım nokta şu, refresh işlemi neden token validation sırasında yapılıyor? Client tarafı refresh işlemini ayrı bir request ile belli aralıklarla yapsa? Token expire olmadan, elindeki tokenı refresh ettiği tokenla değiştirip requestleri o token ile atsa?
Selamlar Koray,
Bu durumda anlık 10K client olsa, her 1 dakkikada 10K requested BackEnd’e yok bindirecektir. Bu da hiç istenmeyen bir durumdur.
Hocam emeğinize, kaleminize sağlık. Tecrübelerinizi, bilgilerinizi paylaştığınız için çok sağolunuz
Ben tesekkur ederim Sefa..
Elinize emeğinize sağlık çok bilgilendirici bir yazı olmuş. Microservislerle ilgili bazı merak ettiklerim var size sorabileceğim bir yer var mıdır yoksa direkt buradan mı sormalıyım ?