導航
在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
標頭的參數進行摘要計算,因此咱們須要將這些參數解析出來並封裝成一個類對象AuthorizationHeader
。ide
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
封裝規範。如下代碼較長,推薦直接去看源碼。
- 首先封裝常量(以前提到過的就不說了)
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]
特性,不然前面作的一切都是徒勞+_+