Asp.Net Core Identity 是 Asp.Net Core 的重要組成部分,他爲 Asp.Net Core 甚至其餘 .Net Core 應用程序提供了一個簡單易用且易於擴展的基礎用戶管理系統框架。它包含了基本的用戶、角色、第三方登陸、Claim等功能,使用 Identity Server 4 能夠爲其輕鬆擴展 OpenId connection 和 Oauth 2.0 相關功能。網上已經有大量相關文章介紹,不過這還不是 Asp.Net Core Identity 的所有,其中一個就是隱私數據保護。html
乍一看,隱私數據保護是個什麼東西,感受好像知道,但又說不清楚。確實這個東西光說很難解釋清楚,那就直接上圖:git
這是用戶表的一部分,有沒有發現問題所在?用戶名和 Email 字段變成了一堆看不懂的東西。仔細看會發現這串亂碼好像還有點規律:guid + 冒號 + 貌似是 base64 編碼的字符串,固然這串字符串去在線解碼結果仍是一堆亂碼,好比 id 爲 1 的 UserName :svBqhhluYZSiPZVUF4baOQ== 在線解碼後是 ²ðjna¢=TÚ9 。github
這就是隱私數據保護,若是沒有這個功能,那麼用戶名是明文存儲的,雖然密碼依然是hash難以破解,但若是被拖庫,用戶數據也會面臨更大的風險。由於不少人喜歡在不一樣的網站使用相同的帳號信息進行註冊,避免遺忘。若是某個網站的密碼被盜,其餘網站被拖庫,黑客就能夠比對是否有相同的用戶名,嘗試撞庫,甚至若是 Email 被盜,黑客還能夠看着 Email 用找回密碼把帳號給 NTR 了。而隱私數據保護就是一層更堅實的後盾,哪怕被拖庫,黑客依然看不懂裏面的東西。數據庫
而後是這個格式,基本能想到,冒號應該是分隔符,前面一個 guid,後面是加密後的內容。那問題就變成了 guid 又是幹嗎的?直接把加密的內容存進去不就完了。這實際上是微軟開發框架注重細節的最佳體現,接下來結合代碼就能一探究竟。緩存
啓用隱私數據保護框架
1 //註冊Identity服務(使用EF存儲,在EF上下文以後註冊) 2 services.AddIdentity<ApplicationUser, ApplicationRole>(options => 3 { 4 //... 5 options.Stores.ProtectPersonalData = true; //在這裏啓用隱私數據保護 6 }) 7 //... 8 .AddPersonalDataProtection<AesProtector, AesProtectorKeyRing>(); //在這裏配置數據加密器,一旦啓用保護,這裏必須配置,不然拋出異常
其中的 AesProtector 和 AesProtectorKeyRing 須要自行實現,微軟並無提供現成的類,至少我沒有找到,估計也是這個功能冷門的緣由吧。.Neter 都被微軟給慣壞了,都是衣來伸手飯來張口。有沒有發現 AesProtectorKeyRing 中有 KeyRing 字樣?鑰匙串,恭喜你猜對了,guid 就是這個鑰匙串中一把鑰匙的編號。也就是說若是加密的鑰匙被盜,但不是所有被盜,那用戶信息還不會所有泄露。微軟這一手可真是狠啊!工具
接下來看看這兩個類是什麼吧。性能
AesProtector 是 ILookupProtector 的實現。接口包含兩個方法,分別用於加密和解密,返回字符串,參數包含字符串數據和上面那個 guid,固然實際只要是字符串就行, guid 是我我的的選擇,生成不重複字符串仍是 guid 方便。網站
AesProtectorKeyRing 則是 ILookupProtectorKeyRing 的實現。接口包含一、獲取當前正在使用的鑰匙編號的只讀屬性,用於提供加密鑰匙;二、根據鑰匙編號獲取字符串的索引器(我這裏就是原樣返回的。。。);三、獲取全部鑰匙編號的方法。ui
AesProtector
1 class AesProtector : ILookupProtector 2 { 3 private readonly object _locker; 4 5 private readonly Dictionary<string, SecurityUtil.AesProtector> _protectors; 6 7 private readonly DirectoryInfo _dirInfo; 8 9 public AesProtector(IWebHostEnvironment environment) 10 { 11 _locker = new object(); 12 13 _protectors = new Dictionary<string, SecurityUtil.AesProtector>(); 14 15 _dirInfo = new DirectoryInfo($@"{environment.ContentRootPath}\App_Data\AesDataProtectionKey"); 16 } 17 18 public string Protect(string keyId, string data) 19 { 20 if (data.IsNullOrEmpty()) 21 { 22 return data; 23 } 24 25 CheckOrCreateProtector(keyId); 26 27 return _protectors[keyId].Protect(Encoding.UTF8.GetBytes(data)).ToBase64String(); 28 } 29 30 public string Unprotect(string keyId, string data) 31 { 32 if (data.IsNullOrEmpty()) 33 { 34 return data; 35 } 36 37 CheckOrCreateProtector(keyId); 38 39 return Encoding.UTF8.GetString(_protectors[keyId].Unprotect(data.ToBytesFromBase64String())); 40 } 41 42 private void CheckOrCreateProtector(string keyId) 43 { 44 if (!_protectors.ContainsKey(keyId)) 45 { 46 lock (_locker) 47 { 48 if (!_protectors.ContainsKey(keyId)) 49 { 50 var fileInfo = _dirInfo.GetFiles().FirstOrDefault(d => d.Name == $@"key-{keyId}.xml") ?? 51 throw new FileNotFoundException(); 52 using (var stream = fileInfo.OpenRead()) 53 { 54 XDocument xmlDoc = XDocument.Load(stream); 55 _protectors.Add(keyId, 56 new SecurityUtil.AesProtector(xmlDoc.Element("key")?.Element("encryption")?.Element("masterKey")?.Value.ToBytesFromBase64String() 57 , xmlDoc.Element("key")?.Element("encryption")?.Element("iv")?.Value.ToBytesFromBase64String() 58 , int.Parse(xmlDoc.Element("key")?.Element("encryption")?.Attribute("BlockSize")?.Value) 59 , int.Parse(xmlDoc.Element("key")?.Element("encryption")?.Attribute("KeySize")?.Value) 60 , int.Parse(xmlDoc.Element("key")?.Element("encryption")?.Attribute("FeedbackSize")?.Value) 61 , Enum.Parse<PaddingMode>(xmlDoc.Element("key")?.Element("encryption")?.Attribute("Padding")?.Value) 62 , Enum.Parse<CipherMode>(xmlDoc.Element("key")?.Element("encryption")?.Attribute("Mode")?.Value))); 63 } 64 } 65 } 66 } 67 } 68 }
AesProtectorKeyRing
1 class AesProtectorKeyRing : ILookupProtectorKeyRing 2 { 3 private readonly object _locker; 4 private readonly Dictionary<string, XDocument> _keyRings; 5 private readonly DirectoryInfo _dirInfo; 6 7 public AesProtectorKeyRing(IWebHostEnvironment environment) 8 { 9 _locker = new object(); 10 _keyRings = new Dictionary<string, XDocument>(); 11 _dirInfo = new DirectoryInfo($@"{environment.ContentRootPath}\App_Data\AesDataProtectionKey"); 12 13 ReadKeys(_dirInfo); 14 } 15 16 public IEnumerable<string> GetAllKeyIds() 17 { 18 return _keyRings.Keys; 19 } 20 21 public string CurrentKeyId => NewestActivationKey(DateTimeOffset.Now)?.Element("key")?.Attribute("id")?.Value ?? GenerateKey(_dirInfo)?.Element("key")?.Attribute("id")?.Value; 22 23 public string this[string keyId] => 24 GetAllKeyIds().FirstOrDefault(id => id == keyId) ?? throw new KeyNotFoundException(); 25 26 private void ReadKeys(DirectoryInfo dirInfo) 27 { 28 foreach (var fileInfo in dirInfo.GetFiles().Where(f => f.Extension == ".xml")) 29 { 30 using (var stream = fileInfo.OpenRead()) 31 { 32 XDocument xmlDoc = XDocument.Load(stream); 33 34 _keyRings.TryAdd(xmlDoc.Element("key")?.Attribute("id")?.Value, xmlDoc); 35 } 36 } 37 } 38 39 private XDocument GenerateKey(DirectoryInfo dirInfo) 40 { 41 var now = DateTimeOffset.Now; 42 if (!_keyRings.Any(item => 43 DateTimeOffset.Parse(item.Value.Element("key")?.Element("activationDate")?.Value) <= now 44 && DateTimeOffset.Parse(item.Value.Element("key")?.Element("expirationDate")?.Value) > now)) 45 { 46 lock (_locker) 47 { 48 if (!_keyRings.Any(item => 49 DateTimeOffset.Parse(item.Value.Element("key")?.Element("activationDate")?.Value) <= now 50 && DateTimeOffset.Parse(item.Value.Element("key")?.Element("expirationDate")?.Value) > now)) 51 { 52 var masterKeyId = Guid.NewGuid().ToString(); 53 54 XDocument xmlDoc = new XDocument(); 55 xmlDoc.Declaration = new XDeclaration("1.0", "utf-8", "yes"); 56 57 XElement key = new XElement("key"); 58 key.SetAttributeValue("id", masterKeyId); 59 key.SetAttributeValue("version", 1); 60 61 XElement creationDate = new XElement("creationDate"); 62 creationDate.SetValue(now); 63 64 XElement activationDate = new XElement("activationDate"); 65 activationDate.SetValue(now); 66 67 XElement expirationDate = new XElement("expirationDate"); 68 expirationDate.SetValue(now.AddDays(90)); 69 70 XElement encryption = new XElement("encryption"); 71 encryption.SetAttributeValue("BlockSize", 128); 72 encryption.SetAttributeValue("KeySize", 256); 73 encryption.SetAttributeValue("FeedbackSize", 128); 74 encryption.SetAttributeValue("Padding", PaddingMode.PKCS7); 75 encryption.SetAttributeValue("Mode", CipherMode.CBC); 76 77 SecurityUtil.AesProtector protector = new SecurityUtil.AesProtector(); 78 XElement masterKey = new XElement("masterKey"); 79 masterKey.SetValue(protector.GenerateKey().ToBase64String()); 80 81 XElement iv = new XElement("iv"); 82 iv.SetValue(protector.GenerateIV().ToBase64String()); 83 84 xmlDoc.Add(key); 85 key.Add(creationDate); 86 key.Add(activationDate); 87 key.Add(expirationDate); 88 key.Add(encryption); 89 encryption.Add(masterKey); 90 encryption.Add(iv); 91 92 xmlDoc.Save( 93 $@"{dirInfo.FullName}\key-{masterKeyId}.xml"); 94 95 _keyRings.Add(masterKeyId, xmlDoc); 96 97 return xmlDoc; 98 } 99 100 return NewestActivationKey(now); 101 } 102 } 103 104 return NewestActivationKey(now); 105 } 106 107 private XDocument NewestActivationKey(DateTimeOffset now) 108 { 109 return _keyRings.Where(item => 110 DateTimeOffset.Parse(item.Value.Element("key")?.Element("activationDate")?.Value) <= now 111 && DateTimeOffset.Parse(item.Value.Element("key")?.Element("expirationDate")?.Value) > now) 112 .OrderByDescending(item => 113 DateTimeOffset.Parse(item.Value.Element("key")?.Element("expirationDate")?.Value)).FirstOrDefault().Value; 114 } 115 }
這兩個類也是註冊到 Asp.Net Core DI 中的服務,全部 DI 的功能都支持。
在其中我還使用了我在其餘地方寫的底層基礎工具類,若是想看完整實現能夠去個人 Github 克隆代碼實際運行並體驗。在這裏大體說一下這兩個類的設計思路。既然微軟設計了鑰匙串功能,那天然是要利用好。我在代碼裏寫死每一個鑰匙有效期90天,過時後會自動生成並使用新的鑰匙,鑰匙的詳細信息使用xml文檔保存在項目文件夾中,具體見下面的截圖。Identity 會使用最新鑰匙進行加密並把鑰匙編號一併存入數據庫,在讀取時會根據編號找到對應的加密器解密數據。這個過程由 EF Core 的值轉換器(EF Core 2.1 增長)完成,也就是說 Identity 向 DbContext 中須要加密的字段註冊了值轉換器。因此我也不清楚早期 Identity 有沒有這個功能,不使用 EF Core 的狀況下這個功能是否可用。
若是但願對自定義用戶數據進行保護,爲對應屬性標註 [PersonalData] 特性便可。Identity 已經對內部的部分屬性進行了標記,好比上面提到的 UserName 。
有幾個要特別注意的點:
一、在有數據的狀況下不要隨便開啓或關閉數據保護功能,不然可能致使嚴重後果。
二、鑰匙必定要保護好,保存好。不然可能泄露用戶數據或者再也沒法解密用戶數據,從刪庫到跑路那種 Shift + Del 的事千萬別幹。
三、被保護的字段沒法在數據庫端執行模糊搜索,只能精確匹配。若是但願進行數據分析,只能先用 Identity 把數據讀取到內存才能繼續作其餘事。
四、鑰匙的有效期不宜太短,由於在用戶登陸時 Identity 並不知道用戶是何時註冊的,應該用哪一個鑰匙,因此 Identity 會用全部鑰匙加密一遍而後查找是否有精確匹配的記錄。鑰匙的有效期越短,隨着網站運行時間的增長,鑰匙數量會增長,要嘗試的鑰匙也會跟着增長,最後對系統性能產生影響。固然這能夠用緩存來緩解。
效果預覽:
轉載請完整保留如下內容,未經受權刪除如下內容進行轉載盜用的,保留追究法律責任的權利!
本文地址:http://www.javashuo.com/article/p-zstjfggj-es.html
完整源代碼:Github
裏面有各類小東西,這只是其中之一,不嫌棄的話能夠Star一下。