1. 程式人生 > >.NET Core驗證ASP.NET密碼

.NET Core驗證ASP.NET密碼

.NET Core驗證ASP.NET密碼

隨著.NET Core的持續更新和完善,越來越多的機構已經選擇或者升級為.NET Core。但由於技術不完全相同,不可能所有應用/資料庫都能無縫遷移,因此ASP.NET Core和傳統ASP.NET之間多少會存在一些挑戰,需要更多的漸進升級方法和互動。

其中,密碼共享就是升級到ASP.NET Core一個很容易想到的漸進升級方式,也是一個需要解決的問題。

啥?什麼都不用做?

其實如果堅持走ASP.NET Core Identity這一套,程式碼是寫了一個if/else,能相容老ASP.NET Identity生成的密碼的,Github連結:https://github.com/dotnet/aspnetcore/blob/8b7f6621695d93b2a55fb8a5b1be99c4af867ae4/src/Identity/Extensions.Core/src/PasswordHasher.cs#L185-L207,核心程式碼如下:

/// <summary>
/// Returns a <see cref="PasswordVerificationResult"/> indicating the result of a password hash comparison.
/// </summary>
/// <param name="user">The user whose password should be verified.</param>
/// <param name="hashedPassword">The hash value for a user's stored password.</param>
/// <param name="providedPassword">The password supplied for comparison.</param>
/// <returns>A <see cref="PasswordVerificationResult"/> indicating the result of a password hash comparison.</returns>
/// <remarks>Implementations of this method should be time consistent.</remarks>
public virtual PasswordVerificationResult VerifyHashedPassword(TUser user, string hashedPassword, string providedPassword)
{
    if (hashedPassword == null)
    {
        throw new ArgumentNullException(nameof(hashedPassword));
    }
    if (providedPassword == null)
    {
        throw new ArgumentNullException(nameof(providedPassword));
    }

    byte[] decodedHashedPassword = Convert.FromBase64String(hashedPassword);

    // read the format marker from the hashed password
    if (decodedHashedPassword.Length == 0)
    {
        return PasswordVerificationResult.Failed;
    }
    switch (decodedHashedPassword[0])
    {
        case 0x00:
            if (VerifyHashedPasswordV2(decodedHashedPassword, providedPassword))
            {
                // This is an old password hash format - the caller needs to rehash if we're not running in an older compat mode.
                return (_compatibilityMode == PasswordHasherCompatibilityMode.IdentityV3)
                    ? PasswordVerificationResult.SuccessRehashNeeded
                    : PasswordVerificationResult.Success;
            }
            else
            {
                return PasswordVerificationResult.Failed;
            }

        case 0x01:
            int embeddedIterCount;
            if (VerifyHashedPasswordV3(decodedHashedPassword, providedPassword, out embeddedIterCount))
            {
                // If this hasher was configured with a higher iteration count, change the entry now.
                return (embeddedIterCount < _iterCount)
                    ? PasswordVerificationResult.SuccessRehashNeeded
                    : PasswordVerificationResult.Success;
            }
            else
            {
                return PasswordVerificationResult.Failed;
            }

        default:
            return PasswordVerificationResult.Failed; // unknown format marker
    }
}

它根據Base64解碼後的第一個位元組,來判斷是老版本還是新版本,然後呼叫各自不同的驗證函式。

但時代在變化,很多人已經不用官方提供的這一套Identity驗證密碼,那麼有什麼“騷”操作,可以解密嗎?

和密碼剛正面

傳統的ASP.NET MVC模板專案是通過ASP.NET Identity管理的密碼,它由Rfc2898DeriveBytes實現,該演算法進行了1000次迴圈SHA1雜湊、並加鹽,以確保難以通過傳統的雜湊碰撞來確保密碼的安全性,其核心程式碼如下(Github連結:https://github.com/aspnet/AspNetIdentity/blob/9c48993a446288032f9824633e6dae81257da06e/src/Microsoft.AspNet.Identity.Core/Crypto.cs#L26-L46):

private const int PBKDF2IterCount = 1000; // default for Rfc2898DeriveBytes
private const int PBKDF2SubkeyLength = 256/8; // 256 bits
private const int SaltSize = 128/8; // 128 bits

/* =======================
*  HASHED PASSWORD FORMATS
*  =======================
*  
*  Version 0:
*  PBKDF2 with HMAC-SHA1, 128-bit salt, 256-bit subkey, 1000 iterations.
*  (See also: SDL crypto guidelines v5.1, Part III)
*  Format: { 0x00, salt, subkey }
*/

public static string HashPassword(string password)
{
    if (password == null)
    {
        throw new ArgumentNullException("password");
    }

    // Produce a version 0 (see comment above) text hash.
    byte[] salt;
    byte[] subkey;
    using (var deriveBytes = new Rfc2898DeriveBytes(password, SaltSize, PBKDF2IterCount))
    {
        salt = deriveBytes.Salt;
        subkey = deriveBytes.GetBytes(PBKDF2SubkeyLength);
    }

    var outputBytes = new byte[1 + SaltSize + PBKDF2SubkeyLength];
    Buffer.BlockCopy(salt, 0, outputBytes, 1, SaltSize);
    Buffer.BlockCopy(subkey, 0, outputBytes, 1 + SaltSize, PBKDF2SubkeyLength);
    return Convert.ToBase64String(outputBytes);
}

值得一提的是,程式碼用到了Rfc2898DeriveBytes,經常讀我的部落格的知道,這個類是老朋友了,通過傳入明文密碼、鹽、迭代次數和演算法,可以在不依賴於雜湊演算法安全性的前提下做單向加密,確保了密碼的不可(難以)破解性。

可見,密碼的原始位元組由{0, salt, subkey}三部分組成,其中鹽為128位,即16位元組,subkey256位,即32位元組,總共1+16+32=49位元組,密文需要轉換為Base64,根據Base64的資訊量計算公式

Math.Log(64, 256)=0.75

可知,Base64編碼相對原始位元組的比例為0.75:1,因此計算可得轉換為Base64之後,其字串長度為

Math.Ceiling(49 / 0.75 / 4) * 4 = 68

我們來從資料庫隨便查詢一個由ASP.NET Identity建立的密碼:

SELECT TOP 1 
    PasswordHash, 
    LEN(PasswordHash) AS Len 
FROM [User]

結果如下,可見結果長度真的為68

所以,說了這麼多,怎麼把密碼遷移到ASP.NET Core呢?

注意看,上面的程式碼沒什麼特別,就是依賴於Rfc2898DeriveBytes這個類。

.NET Core內建了Rfc2898DeriveBytes這個類,可以直接使用,不需要安裝任何NuGet包,因此……直接複製貼上上文中的【核心程式碼】即可。

ASP.NET Core中的Identity有什麼區別?

Github核心程式碼在此:https://github.com/dotnet/aspnetcore/blob/8b7f6621695d93b2a55fb8a5b1be99c4af867ae4/src/Identity/Extensions.Core/src/PasswordHasher.cs#L113-L156

我就從簡引用一下關鍵註釋:

/* =======================
*  HASHED PASSWORD FORMATS
*  =======================
* 
*  Version 2:
*  PBKDF2 with HMAC-SHA1, 128-bit salt, 256-bit subkey, 1000 iterations.
*  (See also: SDL crypto guidelines v5.1, Part III)
*  Format: { 0x00, salt, subkey }
* 
*  Version 3:
*  PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations.
*  Format: { 0x01, prf (UInt32), iter count (UInt32), salt length (UInt32), salt, subkey }
*  (All UInt32s are stored big-endian.)
*/

原來,老ASP.NET Identity中的密碼版本為V2,當前ASP.NET Core Identity中的密碼版本為V3,首位元組(版本號)從0x00改成了0x01,演算法從HMACSHA1升級為了HMACSHA256,另外V3版本還將迭代次數從1000升級為10000,另外還將演算法名、迭代次數、鹽的長度資訊儲存在了密碼中。

另外需要注意的是,新ASP.NET Core Identity的實現很有彈性,它通過依賴注入IOptions<T>的形式,使得所有選項都是可以配置的。

解密示例

假如我有一個密碼:myF&TB9vhTx7,我使用傳統的ASP.NET MVC建立專案,然後用這個密碼註冊一個帳號:

然後在資料庫中將雜湊值取出來:

SELECT PasswordHash
FROM [AspNetUsers]
WHERE [Email] = N'[email protected]'

查得結果如下,結果為AJRvdJ1/Ii+58zU6yBrPJH4hCkMagxqK/W6oejAuG1hIrNPEQMAAyYynsXWwat9Huw==

我將上述程式碼稍作簡化,用最簡單的程式碼表達驗證密碼的過程,除去兩行註釋程式碼(用於做斷言),整個過程只需7行程式碼,程式碼如下:

string pwdHashText = "AJRvdJ1/Ii+58zU6yBrPJH4hCkMagxqK/W6oejAuG1hIrNPEQMAAyYynsXWwat9Huw==";
string pwd = "myF&TB9vhTx7";
byte[] pwdHash = Convert.FromBase64String(pwdHashText);
// Debug.Assert(pwdHash.Length == 49);
// Debug.Assert(pwdHash[0] == 0);
byte[] salt = pwdHash[1..17];
byte[] hash = pwdHash[17..49];
using var r = new Rfc2898DeriveBytes(pwd, salt, 1000);
Console.WriteLine(r.GetBytes(32).SequenceEqual(hash));

此時,執行結果如下,執行顯示True,表示驗證解碼成功:

總結

我總結一個表格如下所示:

ASP.NET Identity ASP.NET Core Identity
版本 V2 V3
首位元組 0x00 0x01
預設演算法 HMACSHA1 HMACSHA256
迭代次數 1000 10000
密碼資訊 版本+鹽+雜湊值 版本+演算法+迭代次數+鹽長度+鹽+雜湊值
原始長度 1+16+32=49 1+4+4+4+16+32=61
Base64長度 68 84
可配置性 不可配置 可自由配置
安全性 非常好

密碼解密其實是前往ASP.NET Core上較為輕鬆的一步,核心程式碼也就7行。

其實更有挑戰、也更有意思的是如何解傳統ASP.NET Identity中的Cookie,我將在下一篇中詳情分析這個主題,敬請期待!

喜歡的朋友請關注我的微信公眾號:【DotNet騷操作】

新年快到了,祝大家闔家歡樂,鼠年大吉