導航
在HTTP認證之摘要認證——Digest(一)中介紹了Digest認證的工作原理和流程,接下來就趕緊通過代碼來實踐一下,以下教程使用默認的MD5摘要算法、auth策略,基於
ASP.NET Core WebApi
框架。如有興趣,可查看源碼
一、准備工作
在開始之前,先把最基本的業務邏輯准備好,只有一個根據用戶名獲取密碼的方法:
public class UserService
{
public static string GetPassword(string userName) => userName;
}
還有MD5加密的一些擴展方法
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();
}
}
二、編碼
以下代碼書寫在自定義授權過濾器中,繼承自Attribute, IAuthorizationFilter
1.首先,先確定使用的認證方案為Digest
,並指定Realm
,設置Qop
的策略為auth
,這里我們采用的預處理方式為在一定時間段內可以重用nonce
,指定過期時間為10s
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是否過期)
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
。
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.好,接下來就來進行摘要計算吧,其實就是套用公式,如果不記得了,可以重溫一下第一節。
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
上加上自定義過濾器特性,大功告成!自己測試一下吧!
三、封裝為中間件
照例,接下來我們將摘要認證封裝為
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]
特性,否則前面做的一切都是徒勞+_+