HTTP認證之摘要認證——Digest(二)

導航

HTTP認證之摘要認證——Digest(一)中介紹了Digest認證的工做原理和流程,接下來就趕忙經過代碼來實踐一下,如下教程使用默認的MD5摘要算法、auth策略,基於ASP.NET Core WebApi框架。若有興趣,可查看源碼html

1、準備工做

在開始以前,先把最基本的業務邏輯準備好,只有一個根據用戶名獲取密碼的方法:git

public class UserService
{
    public static string GetPassword(string userName) => userName;
}

還有MD5加密的一些擴展方法github

public static class MD5HashExtensions
{
    public static string ToMD5Hash(this string input) => MD5Helper.Encrypt(input);
}

public class MD5Helper
{
    public static string Encrypt(string plainText) => Encrypt(plainText, Encoding.UTF8);

    public static string Encrypt(string plainText, Encoding encoding)
    {
        var bytes = encoding.GetBytes(plainText);
        return Encrypt(bytes);
    }

    public static string Encrypt(byte[] bytes)
    {
        using (var md5 = MD5.Create())
        {
            var hash = md5.ComputeHash(bytes);
            return FromHash(hash);
        }
    }

    private static string FromHash(byte[] hash)
    {
        var sb = new StringBuilder();
        foreach (var t in hash)
        {
            sb.Append(t.ToString("x2"));
        }

        return sb.ToString();
    }
}

2、編碼

如下代碼書寫在自定義受權過濾器中,繼承自Attribute, IAuthorizationFilter算法

1.首先,先肯定使用的認證方案爲Digest,並指定Realm,設置Qop的策略爲auth,這裏咱們採用的預處理方式爲在必定時間段內能夠重用nonce,指定過時時間爲10sapp

public const string AuthenticationScheme = "Digest";
public const string AuthenticationRealm = "http://localhost:32435";
public const string Qop = "auth";
//設置 nonce 過時時間爲10s
public const int MaxNonceAgeSeconds = 10;

2.接着,咱們再把經常使用的常量封裝一下框架

public static class AuthenticateHeaderNames
{
    public const string UserName = "username";
    public const string Realm = "realm";
    public const string Nonce = "nonce";
    public const string ClientNonce = "cnonce";
    public const string NonceCounter = "nc";
    public const string Qop = "qop";
    public const string Response = "response";
    public const string Uri = "uri";
    public const string RspAuth = "rspauth";
    public const string Stale = "stale";
}

public static class QopValues
{
    public const string Auth = "auth";
    public const string AuthInt = "auth-int";
}

3.在沒有進行認證或認證失敗時,服務端須要返回401 Unauthorized,並對客戶端發出質詢,一下是質詢須要包含的內容(「stale」參數指示nonce是否過時)async

private void AddChallenge(HttpResponse response, bool stale)
{
    var partList = new List<ValueTuple<string, string, bool>>()
    {
        (AuthenticateHeaderNames.Realm, AuthenticationRealm, true),
        (AuthenticateHeaderNames.Qop, Qop, true),
        (AuthenticateHeaderNames.Nonce, GetNonce(), true),
    };

    var value = $"{AuthenticationScheme} {string.Join(", ", partList.Select(part => FormatHeaderPart(part)))}";
    if (stale)
    {
        value += $", {FormatHeaderPart((AuthenticateHeaderNames.Stale, "true", false))}";
    }
    response.Headers.Append(HeaderNames.WWWAuthenticate, value);
}

private string GetNonce(DateTimeOffset? timestamp = null)
{
    var privateKey = "test private key";
    var timestampStr = timestamp?.ToString() ?? DateTimeOffset.UtcNow.ToString();
    return Convert.ToBase64String(_encoding.GetBytes($"{ timestampStr } {$"{timestampStr} : {privateKey}".ToMD5Hash()}"));
}

private string FormatHeaderPart((string Name, string Value, bool ShouldQuote) part)
    => part.ShouldQuote ? $"{part.Name}=\"{part.Value}\"" : $"{part.Name}={part.Value}";

4.客戶端請求認證後,服務端須要使用HTTP Request中Authorization標頭的參數進行摘要計算,因此咱們須要將這些參數解析出來並封裝成一個類對象AuthorizationHeaderide

private AuthorizationHeader GetAuthenticationHeader(HttpRequest request)
{
    try
    {
        var credentials = GetCredentials(request);
        if (credentials != null)
        {
            var authorizationHeader = new AuthorizationHeader()
            {
                RequestMethod = request.Method,
            };
            var nameValueStrs = credentials.Replace("\"", string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim());
            foreach (var nameValueStr in nameValueStrs)
            {
                var index = nameValueStr.IndexOf('=');
                var name = nameValueStr.Substring(0, index);
                var value = nameValueStr.Substring(index + 1);

                switch (name)
                {
                    case AuthenticateHeaderNames.UserName:
                        authorizationHeader.UserName = value;
                        break;
                    case AuthenticateHeaderNames.Realm:
                        authorizationHeader.Realm = value;
                        break;
                    case AuthenticateHeaderNames.Nonce:
                        authorizationHeader.Nonce = value;
                        break;
                    case AuthenticateHeaderNames.ClientNonce:
                        authorizationHeader.ClientNonce = value;
                        break;
                    case AuthenticateHeaderNames.NonceCounter:
                        authorizationHeader.NonceCounter = value;
                        break;
                    case AuthenticateHeaderNames.Qop:
                        authorizationHeader.Qop = value;
                        break;
                    case AuthenticateHeaderNames.Response:
                        authorizationHeader.Response = value;
                        break;
                    case AuthenticateHeaderNames.Uri:
                        authorizationHeader.Uri = value;
                        break;
                }
            }

            return authorizationHeader;
        }
    }
    catch { }

    return null;
}

private string GetCredentials(HttpRequest request)
{
    string credentials = null;

    string authorization = request.Headers[HeaderNames.Authorization];
    //請求中存在 Authorization 標頭且認證方式爲 Digest
    if (authorization?.StartsWith(AuthenticationScheme, StringComparison.OrdinalIgnoreCase) == true)
    {
        credentials = authorization.Substring(AuthenticationScheme.Length).Trim();
    }

    return credentials;
}

public class AuthorizationHeader
{
    public string UserName { get; set; }
    public string Realm { get; set; }
    public string Nonce { get; set; }
    public string ClientNonce { get; set; }
    public string NonceCounter { get; set; }
    public string Qop { get; set; }
    public string Response { get; set; }
    public string RequestMethod { get; set; }
    public string Uri { get; set; }
}

5.進行摘要計算的參數信息已經齊備了,不過彆着急,先來校驗一下nonce的有效性。測試

/// <summary>
/// 驗證Nonce是否有效
/// </summary>
/// <param name="nonce"></param>
/// <returns>true:驗證經過;false:驗證失敗;null:隨機數過時</returns>
private bool? ValidateNonce(string nonce)
{
    try
    {
        var plainNonce = _encoding.GetString(Convert.FromBase64String(nonce));
        var timestamp = DateTimeOffset.Parse(plainNonce.Substring(0, plainNonce.LastIndexOf(' ')));
        //驗證Nonce是否被篡改
        var isValid = nonce == GetNonce(timestamp);

        //驗證是否過時
        if (Math.Abs((timestamp - DateTimeOffset.UtcNow).TotalSeconds) > MaxNonceAgeSeconds)
        {
            return isValid ? (bool?)null : false;
        }

        return isValid;
    }
    catch
    {
        return false;
    }
}

6.好,接下來就來進行摘要計算吧,其實就是套用公式,若是不記得了,能夠重溫一下第一節。ui

private static string GetComputedResponse(AuthorizationHeader authorizationHeader, string password)
{
    var a1Hash = $"{authorizationHeader.UserName}:{authorizationHeader.Realm}:{password}".ToMD5Hash();
    var a2Hash = $"{authorizationHeader.RequestMethod}:{authorizationHeader.Uri}".ToMD5Hash();
    return $"{a1Hash}:{authorizationHeader.Nonce}:{authorizationHeader.NonceCounter}:{authorizationHeader.ClientNonce}:{authorizationHeader.Qop}:{a2Hash}".ToMD5Hash();
}

7.若是認證經過,咱們經過Authorization-Info返回一些受權會話的信息。

private void AddAuthorizationInfo(HttpResponse response, AuthorizationHeader authorizationHeader, string password)
{
    var partList = new List<ValueTuple<string, string, bool>>()
    {
        (AuthenticateHeaderNames.Qop, authorizationHeader.Qop, true),
        (AuthenticateHeaderNames.RspAuth, GetRspAuth(authorizationHeader, password), true),
        (AuthenticateHeaderNames.ClientNonce, authorizationHeader.ClientNonce, true),
        (AuthenticateHeaderNames.NonceCounter, authorizationHeader.NonceCounter, false)
    };
    response.Headers.Append("Authorization-Info", string.Join(", ", partList.Select(part => FormatHeaderPart(part))));
}

private string GetRspAuth(AuthorizationHeader authorizationHeader, string password)
{
    var a1Hash = $"{authorizationHeader.UserName}:{authorizationHeader.Realm}:{password}".ToMD5Hash();
    var a2Hash = $":{authorizationHeader.Uri}".ToMD5Hash();
    return $"{a1Hash}:{authorizationHeader.Nonce}:{authorizationHeader.NonceCounter}:{authorizationHeader.ClientNonce}:{authorizationHeader.Qop}:{a2Hash}".ToMD5Hash();
}

8.咱們把整個認證流程整理一下

public void OnAuthorization(AuthorizationFilterContext context)
{
    //請求容許匿名訪問
    if (context.Filters.Any(item => item is IAllowAnonymousFilter)) return;

    var authorizationHeader = GetAuthenticationHeader(context.HttpContext.Request);
    var stale = false;
    if(authorizationHeader != null)
    {
        var isValid = ValidateNonce(authorizationHeader.Nonce);
        //隨機數過時
        if(isValid == null)
        {
            stale = true;
        }
        else if(isValid == true)
        {
            var password = UserService.GetPassword(authorizationHeader.UserName);
            string computedResponse = null;
            switch (authorizationHeader.Qop)
            {
                case QopValues.Auth:
                    computedResponse = GetComputedResponse(authorizationHeader, password);
                    break;
                default:
                    context.Result = new BadRequestObjectResult($"qop指定策略必須爲\"{QopValues.Auth}\"");
                    break;
            }

            if (computedResponse == authorizationHeader.Response)
            {
                AddAuthorizationInfo(context.HttpContext.Response, authorizationHeader, password);
                return;
            }
        }
    }

    context.Result = new UnauthorizedResult();
    AddChallenge(context.HttpContext.Response, stale);
}

9.最後,在須要認證的Action上加上自定義過濾器特性,大功告成!本身測試一下吧!

3、封裝爲中間件

照例,接下來咱們將摘要認證封裝爲ASP.NET Core中間件,便於使用和擴展。如下封裝採用Jwt Bearer封裝規範。如下代碼較長,推薦直接去看源碼。

  1. 首先封裝常量(以前提到過的就不說了)
public static class DigestDefaults
{
    public const string AuthenticationScheme = "Digest";
}

2.而後封裝Basic認證的Options,包括Realm、Qop、Private key和事件,繼承自Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions。在事件內部,咱們定義了獲取密碼行爲和質詢行爲,分別用來根據用戶名獲取密碼和在HTTP Response中添加質詢信息。要注意的是,獲取密碼行爲要求必須由用戶實現,畢竟咱們內部是不知道密碼的。

public class DigestOptions : AuthenticationSchemeOptions
{
    public const string DefaultQop = QopValues.Auth;
    public const int DefaultMaxNonceAgeSeconds = 10;

    public string Realm { get; set; }
    public string Qop { get; set; } = DefaultQop;
    public int MaxNonceAgeSeconds { get; set; } = DefaultMaxNonceAgeSeconds;
    public string PrivateKey { get; set; }

    public new DigestEvents Events
    {
        get => (DigestEvents)base.Events;
        set => base.Events = value;
    }
}

public class DigestEvents
{
    public DigestEvents(Func<GetPasswordContext, Task<string>> onGetPassword)
    {
        OnGetPassword = onGetPassword;
    }

    public Func<GetPasswordContext, Task<string>> OnGetPassword { get; set; } = context => throw new NotImplementedException($"{nameof(OnGetPassword)} must be implemented!");

    public Func<DigestChallengeContext, Task> OnChallenge { get; set; } = context => Task.CompletedTask;

    public virtual Task<string> GetPassword(GetPasswordContext context) => OnGetPassword(context);

    public virtual Task Challenge(DigestChallengeContext context) => OnChallenge(context);
}

public class GetPasswordContext : ResultContext<DigestOptions>
{
    public GetPasswordContext(
        HttpContext context, 
        AuthenticationScheme scheme, 
        DigestOptions options) 
        : base(context, scheme, options)
    {
    }

    public string UserName { get; set; }
}

public class DigestChallengeContext : PropertiesContext<DigestOptions>
{
    public DigestChallengeContext(
        HttpContext context, 
        AuthenticationScheme scheme, 
        DigestOptions options, 
        AuthenticationProperties properties) 
        : base(context, scheme, options, properties)
    {
    }

    /// <summary>
    /// 在認證期間出現的異常
    /// </summary>
    public Exception AuthenticateFailure { get; set; }

    public bool Stale { get; set; }

    /// <summary>
    /// 指定是否已被處理,若是已處理,則跳過默認認證邏輯
    /// </summary>
    public bool Handled { get; private set; }

    /// <summary>
    /// 跳過默認認證邏輯
    /// </summary>
    public void HandleResponse() => Handled = true;
}

3.接下來,就是對認證過程處理的封裝了,須要繼承自Microsoft.AspNetCore.Authentication.AuthenticationHandler

public class DigestHandler : AuthenticationHandler<DigestOptions>
{
private static readonly Encoding _encoding = Encoding.UTF8;

public DigestHandler(
    IOptionsMonitor<DigestOptions> options,
    ILoggerFactory logger,
    UrlEncoder encoder,
    ISystemClock clock)
    : base(options, logger, encoder, clock)
{
}

protected new DigestEvents Events
{
    get => (DigestEvents)base.Events;
    set => base.Events = value;
}

/// <summary>
/// 確保建立的 Event 類型是 DigestEvents
/// </summary>
/// <returns></returns>
protected override Task<object> CreateEventsAsync() => throw new NotImplementedException($"{nameof(Events)} must be created");

protected async override Task<AuthenticateResult> HandleAuthenticateAsync()
{
    var authorizationHeader = GetAuthenticationHeader(Context.Request);
    if (authorizationHeader == null)
    {
        return AuthenticateResult.NoResult();
    }

    try
    {
        var isValid = ValidateNonce(authorizationHeader.Nonce);
        //隨機數過時
        if (isValid == null)
        {
            var properties = new AuthenticationProperties();
            properties.SetParameter(AuthenticationHeaderNames.Stale, true);
            return AuthenticateResult.Fail(string.Empty, properties);
        }
        else if (isValid == true)
        {
            var getPasswordContext = new GetPasswordContext(Context, Scheme, Options)
            {
                UserName = authorizationHeader.UserName
            };
            var password = await Events.GetPassword(getPasswordContext);
            string computedResponse = null;
            switch (authorizationHeader.Qop)
            {
                case QopValues.Auth:
                    computedResponse = GetComputedResponse(authorizationHeader, password);
                    break;
                default:
                    return AuthenticateResult.Fail($"qop指定策略必須爲\"{QopValues.Auth}\"");
            }

            if (computedResponse == authorizationHeader.Response)
            {
                var claim = new Claim(ClaimTypes.Name, getPasswordContext.UserName);
                var identity = new ClaimsIdentity(DigestDefaults.AuthenticationScheme);
                identity.AddClaim(claim);

                var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
                AddAuthorizationInfo(Context.Response, authorizationHeader, password);
                return AuthenticateResult.Success(ticket);
            }
        }

        return AuthenticateResult.NoResult();
    }
    catch (Exception ex)
    {
        return AuthenticateResult.Fail(ex.Message);
    }

}

protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
    var authResult = await HandleAuthenticateOnceSafeAsync();
    var challengeContext = new DigestChallengeContext(Context, Scheme, Options, properties)
    {
        AuthenticateFailure = authResult.Failure,
        Stale = authResult.Properties?.GetParameter<bool>(AuthenticationHeaderNames.Stale) ?? false
    };
    await Events.Challenge(challengeContext);
    //質詢已處理
    if (challengeContext.Handled) return;

    var challengeValue = GetChallengeValue(challengeContext.Stale);
    var error = challengeContext.AuthenticateFailure?.Message;
    if (!string.IsNullOrWhiteSpace(error))
    {
        //將錯誤信息封裝到內部
        challengeValue += $", error=\"{ error }\"";
    }

    Response.StatusCode = (int)HttpStatusCode.Unauthorized;
    Response.Headers.Append(HeaderNames.WWWAuthenticate, challengeValue);
}

private AuthorizationHeader GetAuthenticationHeader(HttpRequest request)
{
    try
    {
        var credentials = GetCredentials(request);
        if (credentials != null)
        {
            var authorizationHeader = new AuthorizationHeader()
            {
                RequestMethod = request.Method,
            };
            var nameValueStrs = credentials.Replace("\"", string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim());
            foreach (var nameValueStr in nameValueStrs)
            {
                var index = nameValueStr.IndexOf('=');
                var name = nameValueStr.Substring(0, index);
                var value = nameValueStr.Substring(index + 1);

                switch (name)
                {
                    case AuthenticationHeaderNames.UserName:
                        authorizationHeader.UserName = value;
                        break;
                    case AuthenticationHeaderNames.Realm:
                        authorizationHeader.Realm = value;
                        break;
                    case AuthenticationHeaderNames.Nonce:
                        authorizationHeader.Nonce = value;
                        break;
                    case AuthenticationHeaderNames.ClientNonce:
                        authorizationHeader.ClientNonce = value;
                        break;
                    case AuthenticationHeaderNames.NonceCounter:
                        authorizationHeader.NonceCounter = value;
                        break;
                    case AuthenticationHeaderNames.Qop:
                        authorizationHeader.Qop = value;
                        break;
                    case AuthenticationHeaderNames.Response:
                        authorizationHeader.Response = value;
                        break;
                    case AuthenticationHeaderNames.Uri:
                        authorizationHeader.Uri = value;
                        break;
                }
            }

            return authorizationHeader;
        }
    }
    catch { }

    return null;
}

private string GetCredentials(HttpRequest request)
{
    string credentials = null;

    string authorization = request.Headers[HeaderNames.Authorization];
    //請求中存在 Authorization 標頭且認證方式爲 Digest
    if (authorization?.StartsWith(DigestDefaults.AuthenticationScheme, StringComparison.OrdinalIgnoreCase) == true)
    {
        credentials = authorization.Substring(DigestDefaults.AuthenticationScheme.Length).Trim();
    }

    return credentials;
}

/// <summary>
/// 驗證Nonce是否有效
/// </summary>
/// <param name="nonce"></param>
/// <returns>true:驗證經過;false:驗證失敗;null:隨機數過時</returns>
private bool? ValidateNonce(string nonce)
{
    try
    {
        var plainNonce = _encoding.GetString(Convert.FromBase64String(nonce));
        var timestamp = DateTimeOffset.Parse(plainNonce.Substring(0, plainNonce.LastIndexOf(' ')));
        //驗證Nonce是否被篡改
        var isValid = nonce == GetNonce(timestamp);

        //驗證是否過時
        if (Math.Abs((timestamp - DateTimeOffset.UtcNow).TotalSeconds) > Options.MaxNonceAgeSeconds)
        {
            return isValid ? (bool?)null : false;
        }

        return isValid;
    }
    catch
    {
        return false;
    }
}

private static string GetComputedResponse(AuthorizationHeader authorizationHeader, string password)
{
    var a1Hash = $"{authorizationHeader.UserName}:{authorizationHeader.Realm}:{password}".ToMD5Hash();
    var a2Hash = $"{authorizationHeader.RequestMethod}:{authorizationHeader.Uri}".ToMD5Hash();
    return $"{a1Hash}:{authorizationHeader.Nonce}:{authorizationHeader.NonceCounter}:{authorizationHeader.ClientNonce}:{authorizationHeader.Qop}:{a2Hash}".ToMD5Hash();
}


private void AddAuthorizationInfo(HttpResponse response, AuthorizationHeader authorizationHeader, string password)
{
    var partList = new List<ValueTuple<string, string, bool>>()
    {
        (AuthenticationHeaderNames.Qop, authorizationHeader.Qop, true),
        (AuthenticationHeaderNames.RspAuth, GetRspAuth(authorizationHeader, password), true),
        (AuthenticationHeaderNames.ClientNonce, authorizationHeader.ClientNonce, true),
        (AuthenticationHeaderNames.NonceCounter, authorizationHeader.NonceCounter, false)
    };
    response.Headers.Append("Authorization-Info", string.Join(", ", partList.Select(part => FormatHeaderPart(part))));
}

private string GetChallengeValue(bool stale)
{
    var partList = new List<ValueTuple<string, string, bool>>()
    {
        (AuthenticationHeaderNames.Realm, Options.Realm, true),
        (AuthenticationHeaderNames.Qop, Options.Qop, true),
        (AuthenticationHeaderNames.Nonce, GetNonce(), true),
    };

    var value = $"{DigestDefaults.AuthenticationScheme} {string.Join(", ", partList.Select(part => FormatHeaderPart(part)))}";
    if (stale)
    {
        value += $", {FormatHeaderPart((AuthenticationHeaderNames.Stale, "true", false))}";
    }
    return value;
}

private string GetRspAuth(AuthorizationHeader authorizationHeader, string password)
{
    var a1Hash = $"{authorizationHeader.UserName}:{authorizationHeader.Realm}:{password}".ToMD5Hash();
    var a2Hash = $":{authorizationHeader.Uri}".ToMD5Hash();
    return $"{a1Hash}:{authorizationHeader.Nonce}:{authorizationHeader.NonceCounter}:{authorizationHeader.ClientNonce}:{authorizationHeader.Qop}:{a2Hash}".ToMD5Hash();
}

private string GetNonce(DateTimeOffset? timestamp = null)
{
    var privateKey = Options.PrivateKey;
    var timestampStr = timestamp?.ToString() ?? DateTimeOffset.UtcNow.ToString();
    return Convert.ToBase64String(_encoding.GetBytes($"{ timestampStr } {$"{timestampStr} : {privateKey}".ToMD5Hash()}"));
}

private string FormatHeaderPart((string Name, string Value, bool ShouldQuote) part)
    => part.ShouldQuote ? $"{part.Name}=\"{part.Value}\"" : $"{part.Name}={part.Value}";

4.最後,就是要把封裝的接口暴露給用戶了。

public static class DigestExtensions
{
    public static AuthenticationBuilder AddDigest(this AuthenticationBuilder builder)
       => builder.AddDigest(DigestDefaults.AuthenticationScheme, _ => { });

    public static AuthenticationBuilder AddDigest(this AuthenticationBuilder builder, Action<DigestOptions> configureOptions)
        => builder.AddDigest(DigestDefaults.AuthenticationScheme, configureOptions);

    public static AuthenticationBuilder AddDigest(this AuthenticationBuilder builder, string authenticationScheme, Action<DigestOptions> configureOptions)
        => builder.AddDigest(authenticationScheme, displayName: null, configureOptions: configureOptions);

    public static AuthenticationBuilder AddDigest(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<DigestOptions> configureOptions)
        => builder.AddScheme<DigestOptions, DigestHandler>(authenticationScheme, displayName, configureOptions);
}

5.Digest認證庫已經封裝好了,咱們建立一個ASP.NET Core WebApi程序來測試一下吧。

//在 ConfigureServices 中配置認證中間件
public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(DigestDefaults.AuthenticationScheme)
        .AddDigest(options =>
        {
            options.Realm = "http://localhost:44550";
            options.PrivateKey = "test private key";
            options.Events = new DigestEvents(context => Task.FromResult(context.UserName));
        });
}

//在 Configure 中啓用認證中間件
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseAuthentication();
}

最後,必定要記得爲須要認證的Action添加[Authorize]特性,不然前面作的一切都是徒勞+_+

查看源碼

相關文章
相關標籤/搜索