HTTP Digest认证

一、概述

1、理解Http的无状态特性

HTTP是一个无状态的协议,WEB服务器在处理所有传入HTTP请求时,根本就不知道某个请求是否是一个用户的第一次请求与后续请求,或者是另一个用户的请求。 WEB服务器每次在处理请求时,都会按照用户所访问的资源所对应的处理代码,从头到尾执行一遍,然后输出响应内容,WEB服务器根本不会记住已处理了哪些用户的请求,因此,我们通常说HTTP协议是无状态的。

2、为什么需要认证

虽然HTTP协议与WEB服务器是无状态,但我们的业务需求却要求有状态,典型的就是用户登录, 在这种业务需求中,要求WEB服务器端能区分某个请求是不是一个已登录用户发起的,或者当前请求是哪个用户发出的。 在开发WEB应用程序时,我们通常会使用Cookie来保存一些简单的数据供服务端维持必要的状态。总的来说,加入认证的根本原因就是确保请求的合法性以及资源的安全性,如下图:

HTTP Digest认证

二、HTTP Digest认证

http认证根据凭证协议的不同,划分为不同的方式。常用的方式有:

  • HTTP基本认证
  • HTTP摘要认证
  • HTTP Bearer认证

本篇文章介绍HTTP摘要认证。

1、原理解析

下面通过图详细的了解下HTTP 摘要认证过程:

HTTP Digest认证

上图中涉及的名词释义:

?qop:算法,默认auth
?nonce:16进制随机数,用于客户端生成密码摘要,避免了重放攻击
?nc:nonce计数器,表示同一nonce下客户端发送出请求的数量,以便检测重复的请求
?cnonce:客户端随机数
?response:密码摘
?rspauth:响应摘要,用于客户端对服务端进行认证
?stale:当nonce过期时,服务器可以返回一个附带有新随机数的401响应,并指定stale=true,表示服务器在告知客户端用新的随机数来重试,此过程不再要求用户重新输入用户名和密码

上图步骤2,认证失败,服务端返回401,并且附带了参数qop和nonce。Qop是密码摘要的算法策略,默认是auth,nonce是16进制的随机数。这两个参数都和生成密码摘要有关。下图是fiddler抓包情况:

HTTP Digest认证

浏览器根据WWW-Authenticate响应头会弹出一个登录验证的对话框,要求客户端提供用户名和密码进行认证。如下图: 

HTTP Digest认证

浏览器根据输入的用户名和密码会生成密码摘要以及其他信息发送给服务端,抓包结果如下:

HTTP Digest认证服务端验证成功后,返回200,如下抓包图:

HTTP Digest认证

其中rspauth是响应摘要,用于浏览器对服务端进行认证;nc是nonce计数器,表示同一nonce下客户端发送出请求的数量,以便检测重复的请求。

从上边整个流程来看,和http basic认证的流程差不多,不同的是认证的凭证协议要复杂的多。 http摘要认证的凭证协议如下图简单示例:

HTTP Digest认证

具体和凭证相关的算法如下:

HTTP Digest认证

2、优缺点

优点:
  • 有效的保护了用户密码。

  • 使用nonce避免了重放攻击。

缺点:
  • 一般数据库存放的密码使用MD5加密的,而digest密码摘要算法中,密码是明文,所以digest标准认证方式并不适合所有场景。

  • 服务端每次验证都需要查询数据库,获取用户密码。

三、HTTP Digest认证示例

1、新建netcore mvc项目

HTTP Digest认证

HTTP Digest认证

2、新建AuthorizationHeader.cs

封装认证涉及的参数: 

namespace HttpDigestAuthentication.Middleware
{
    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; }
    }
}

3、新建AuthenticateHeaderNames.cs

指定参数的名称:

namespace HttpDigestAuthentication.Middleware
{
    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";
    }
}

4、新建DigestDefaults.cs

指定默认的认证方案:

namespace HttpDigestAuthentication.Middleware
{
    public static class DigestDefaults
    {
        public const string AuthenticationScheme = "Digest";
    }
}

5、新建DigestOptions .cs

封装摘要认证的Options:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using System;
using System.Threading.Tasks;

namespace HttpDigestAuthentication.Middleware
{
    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;
    }

}

6、新建DigestHandler.cs

封装服务端质询、生成随机数等逻辑:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;

namespace HttpDigestAuthentication.Middleware
{
    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(AuthenticateHeaderNames.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>(AuthenticateHeaderNames.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 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(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>>()
    {
        (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 GetChallengeValue(bool stale)
        {
            var partList = new List<ValueTuple<string, string, bool>>()
    {
        (AuthenticateHeaderNames.Realm, Options.Realm, true),
        (AuthenticateHeaderNames.Qop, Options.Qop, true),
        (AuthenticateHeaderNames.Nonce, GetNonce(), true),
    };

            var value = $"{DigestDefaults.AuthenticationScheme} {string.Join(", ", partList.Select(part => FormatHeaderPart(part)))}";
            if (stale)
            {
                value += $", {FormatHeaderPart((AuthenticateHeaderNames.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}";

    }
}

7、新建DigestExtensions.cs

将接口暴露:

using Microsoft.AspNetCore.Authentication;
using System;

namespace HttpDigestAuthentication.Middleware
{
    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);
    }

}

8、新建MD5HashExtensions.cs

生成凭证需要的帮助类:

using System.Security.Cryptography;
using System.Text;

namespace HttpDigestAuthentication.Middleware
{
    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();
        }
    }
}

9、Startup.cs中配置中间件

在 ConfigureServices 中配置认证中间件

public void ConfigureServices(IServiceCollection services)
{
  services.AddControllersWithViews();
  services.AddAuthentication(DigestDefaults.AuthenticationScheme)
  .AddDigest(options =>
  {
    options.Realm = "http://localhost:44378";
    options.PrivateKey = "private key";
    options.Events = new DigestEvents(context => Task.FromResult(context.UserName));
  });
}

在 Configure 中启用认证中间件

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
  app.UseAuthentication();
  app.UseAuthorization();
}

10、Action加入认证

在HomeController的action上加入[Authorize]。

using HttpDigestAuthentication.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System.Diagnostics;

namespace HttpDigestAuthentication.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;

        public HomeController(ILogger<HomeController> logger)
        {
            _logger = logger;
        }
        [Authorize]
        public IActionResult Index()
        {
            return View();
        }
        [Authorize]
        public IActionResult Privacy()
        {
            return View();
        }
        [Authorize]
        [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
        public IActionResult Error()
        {
            return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
        }
    }
}

11、最终项目目录及运行效果

HTTP Digest认证

运行项目,效果如下:

HTTP Digest认证

输入用户名和密码(用户名和密码一致即可),点击登录,认证成功,记录用户信息并进行重定向:

HTTP Digest认证

四、源码下载

源码:https://github.com/qiuxianhu/AuthenticationAndAuthorization

 

HTTP Digest认证

上一篇:【译】.NET 5 中的诊断改进


下一篇:Android JNI 异常定位(2) ——ndk-stack