From 8e4dadc0dd69b462bd0e562863f8414aceb1dca1 Mon Sep 17 00:00:00 2001 From: Hao Kung Date: Fri, 5 Jun 2020 00:58:47 -0700 Subject: [PATCH] Add certificate validation cache (#21847) --- ....Authentication.Abstractions.netcoreapp.cs | 3 + .../src/AuthenticateResult.cs | 22 ++++ .../src/AuthenticationProperties.cs | 8 ++ .../src/AuthenticationTicket.cs | 15 +++ .../test/AuthenticationPropertiesTests.cs | 29 ++++- .../test/AuthenticationTicketTests.cs | 44 +++++++ .../samples/Certificate.Sample/Startup.cs | 3 +- .../CertificateAuthenticationExtensions.cs | 18 +++ .../src/CertificateAuthenticationHandler.cs | 117 +++++++++++------- .../src/CertificateValidationCache.cs | 48 +++++++ .../src/CertificateValidationCacheOptions.cs | 27 ++++ .../src/ICertificateValidationCache.cs | 30 +++++ .../Certificate/src/LoggingExtensions.cs | 11 ++ ...pNetCore.Authentication.Certificate.csproj | 1 + .../Authentication/test/CertificateTests.cs | 88 ++++++++++++- 15 files changed, 414 insertions(+), 50 deletions(-) create mode 100644 src/Http/Authentication.Core/test/AuthenticationTicketTests.cs create mode 100644 src/Security/Authentication/Certificate/src/CertificateValidationCache.cs create mode 100644 src/Security/Authentication/Certificate/src/CertificateValidationCacheOptions.cs create mode 100644 src/Security/Authentication/Certificate/src/ICertificateValidationCache.cs diff --git a/src/Http/Authentication.Abstractions/ref/Microsoft.AspNetCore.Authentication.Abstractions.netcoreapp.cs b/src/Http/Authentication.Abstractions/ref/Microsoft.AspNetCore.Authentication.Abstractions.netcoreapp.cs index 4027259f90..58949cc877 100644 --- a/src/Http/Authentication.Abstractions/ref/Microsoft.AspNetCore.Authentication.Abstractions.netcoreapp.cs +++ b/src/Http/Authentication.Abstractions/ref/Microsoft.AspNetCore.Authentication.Abstractions.netcoreapp.cs @@ -12,6 +12,7 @@ namespace Microsoft.AspNetCore.Authentication public Microsoft.AspNetCore.Authentication.AuthenticationProperties Properties { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] protected set { } } public bool Succeeded { get { throw null; } } public Microsoft.AspNetCore.Authentication.AuthenticationTicket Ticket { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] protected set { } } + public Microsoft.AspNetCore.Authentication.AuthenticateResult Clone() { throw null; } public static Microsoft.AspNetCore.Authentication.AuthenticateResult Fail(System.Exception failure) { throw null; } public static Microsoft.AspNetCore.Authentication.AuthenticateResult Fail(System.Exception failure, Microsoft.AspNetCore.Authentication.AuthenticationProperties properties) { throw null; } public static Microsoft.AspNetCore.Authentication.AuthenticateResult Fail(string failureMessage) { throw null; } @@ -69,6 +70,7 @@ namespace Microsoft.AspNetCore.Authentication public System.Collections.Generic.IDictionary Items { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } public System.Collections.Generic.IDictionary Parameters { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } public string RedirectUri { get { throw null; } set { } } + public Microsoft.AspNetCore.Authentication.AuthenticationProperties Clone() { throw null; } protected bool? GetBool(string key) { throw null; } protected System.DateTimeOffset? GetDateTimeOffset(string key) { throw null; } public T GetParameter(string key) { throw null; } @@ -100,6 +102,7 @@ namespace Microsoft.AspNetCore.Authentication public string AuthenticationScheme { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } public System.Security.Claims.ClaimsPrincipal Principal { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } public Microsoft.AspNetCore.Authentication.AuthenticationProperties Properties { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.AspNetCore.Authentication.AuthenticationTicket Clone() { throw null; } } public partial class AuthenticationToken { diff --git a/src/Http/Authentication.Abstractions/src/AuthenticateResult.cs b/src/Http/Authentication.Abstractions/src/AuthenticateResult.cs index fe31859ed4..6d250b0507 100644 --- a/src/Http/Authentication.Abstractions/src/AuthenticateResult.cs +++ b/src/Http/Authentication.Abstractions/src/AuthenticateResult.cs @@ -46,6 +46,28 @@ namespace Microsoft.AspNetCore.Authentication /// public bool None { get; protected set; } + /// + /// Create a new deep copy of the result + /// + /// A copy of the result + public AuthenticateResult Clone() + { + if (None) + { + return NoResult(); + } + if (Failure != null) + { + return Fail(Failure, Properties.Clone()); + } + if (Succeeded) + { + return Success(Ticket.Clone()); + } + // This shouldn't happen + throw new NotImplementedException(); + } + /// /// Indicates that authentication was successful. /// diff --git a/src/Http/Authentication.Abstractions/src/AuthenticationProperties.cs b/src/Http/Authentication.Abstractions/src/AuthenticationProperties.cs index ebb3995472..491e2552e8 100644 --- a/src/Http/Authentication.Abstractions/src/AuthenticationProperties.cs +++ b/src/Http/Authentication.Abstractions/src/AuthenticationProperties.cs @@ -45,6 +45,14 @@ namespace Microsoft.AspNetCore.Authentication Parameters = parameters ?? new Dictionary(StringComparer.Ordinal); } + /// + /// Return a copy. + /// + /// A copy. + public AuthenticationProperties Clone() + => new AuthenticationProperties(new Dictionary(Items, StringComparer.Ordinal), + new Dictionary(Parameters, StringComparer.Ordinal)); + /// /// State values about the authentication session. /// diff --git a/src/Http/Authentication.Abstractions/src/AuthenticationTicket.cs b/src/Http/Authentication.Abstractions/src/AuthenticationTicket.cs index c31f15ec01..97b93afdde 100644 --- a/src/Http/Authentication.Abstractions/src/AuthenticationTicket.cs +++ b/src/Http/Authentication.Abstractions/src/AuthenticationTicket.cs @@ -52,5 +52,20 @@ namespace Microsoft.AspNetCore.Authentication /// Additional state values for the authentication session. /// public AuthenticationProperties Properties { get; private set; } + + /// + /// Returns a copy of the ticket. + /// Note: the claims principal will be cloned by calling Clone() on each of the Identities. + /// + /// A copy of the ticket + public AuthenticationTicket Clone() + { + var principal = new ClaimsPrincipal(); + foreach (var identity in Principal.Identities) + { + principal.AddIdentity(identity.Clone()); + } + return new AuthenticationTicket(principal, Properties.Clone(), AuthenticationScheme); + } } } diff --git a/src/Http/Authentication.Core/test/AuthenticationPropertiesTests.cs b/src/Http/Authentication.Core/test/AuthenticationPropertiesTests.cs index c8a8056077..cb7dbeb95f 100644 --- a/src/Http/Authentication.Core/test/AuthenticationPropertiesTests.cs +++ b/src/Http/Authentication.Core/test/AuthenticationPropertiesTests.cs @@ -8,6 +8,33 @@ namespace Microsoft.AspNetCore.Authentication.Core.Test { public class AuthenticationPropertiesTests { + [Fact] + public void Clone_Copies() + { + var items = new Dictionary + { + ["foo"] = "bar", + }; + var value = "value"; + var parameters = new Dictionary + { + ["foo2"] = value, + }; + var props = new AuthenticationProperties(items, parameters); + Assert.Same(items, props.Items); + Assert.Same(parameters, props.Parameters); + var copy = props.Clone(); + Assert.NotSame(props.Items, copy.Items); + Assert.NotSame(props.Parameters, copy.Parameters); + // Objects in the dictionaries will still be the same + Assert.Equal(props.Items, copy.Items); + Assert.Equal(props.Parameters, copy.Parameters); + props.Items["change"] = "good"; + props.Parameters["something"] = "bad"; + Assert.NotEqual(props.Items, copy.Items); + Assert.NotEqual(props.Parameters, copy.Parameters); + } + [Fact] public void DefaultConstructor_EmptyCollections() { @@ -298,4 +325,4 @@ namespace Microsoft.AspNetCore.Authentication.Core.Test } } } -} \ No newline at end of file +} diff --git a/src/Http/Authentication.Core/test/AuthenticationTicketTests.cs b/src/Http/Authentication.Core/test/AuthenticationTicketTests.cs new file mode 100644 index 0000000000..9023735fac --- /dev/null +++ b/src/Http/Authentication.Core/test/AuthenticationTicketTests.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Security.Claims; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.Core.Test +{ + public class AuthenticationTicketTests + { + [Fact] + public void Clone_Copies() + { + var items = new Dictionary + { + ["foo"] = "bar", + }; + var value = "value"; + var parameters = new Dictionary + { + ["foo2"] = value, + }; + var props = new AuthenticationProperties(items, parameters); + var identity = new ClaimsIdentity(); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, props, "scheme"); + + Assert.Same(items, ticket.Properties.Items); + Assert.Same(parameters, ticket.Properties.Parameters); + var copy = ticket.Clone(); + Assert.NotSame(ticket.Principal, copy.Principal); + Assert.NotSame(ticket.Properties.Items, copy.Properties.Items); + Assert.NotSame(ticket.Properties.Parameters, copy.Properties.Parameters); + // Objects in the dictionaries will still be the same + Assert.Equal(ticket.Properties.Items, copy.Properties.Items); + Assert.Equal(ticket.Properties.Parameters, copy.Properties.Parameters); + props.Items["change"] = "good"; + props.Parameters["something"] = "bad"; + Assert.NotEqual(ticket.Properties.Items, copy.Properties.Items); + Assert.NotEqual(ticket.Properties.Parameters, copy.Properties.Parameters); + identity.AddClaim(new Claim("name", "value")); + Assert.True(ticket.Principal.HasClaim("name", "value")); + Assert.False(copy.Principal.HasClaim("name", "value")); + } + } +} diff --git a/src/Security/Authentication/Certificate/samples/Certificate.Sample/Startup.cs b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Startup.cs index a45bcb93cf..1406351a61 100644 --- a/src/Security/Authentication/Certificate/samples/Certificate.Sample/Startup.cs +++ b/src/Security/Authentication/Certificate/samples/Certificate.Sample/Startup.cs @@ -35,7 +35,8 @@ namespace Certificate.Sample return Task.CompletedTask; } }; - }); + // Adding a ICertificateValidationCache will result in certificate auth caching the results, the default implementation uses a memory cache + }).AddCertificateCache(); services.AddAuthorization(); } diff --git a/src/Security/Authentication/Certificate/src/CertificateAuthenticationExtensions.cs b/src/Security/Authentication/Certificate/src/CertificateAuthenticationExtensions.cs index 4926a21382..8224eb16ce 100644 --- a/src/Security/Authentication/Certificate/src/CertificateAuthenticationExtensions.cs +++ b/src/Security/Authentication/Certificate/src/CertificateAuthenticationExtensions.cs @@ -87,5 +87,23 @@ namespace Microsoft.Extensions.DependencyInjection string authenticationScheme, Action configureOptions) where TService : class => builder.AddScheme(authenticationScheme, configureOptions); + + /// + /// Adds certificate authentication. + /// + /// The . + /// + /// The . + public static AuthenticationBuilder AddCertificateCache( + this AuthenticationBuilder builder, + Action configureOptions = null) + { + builder.Services.AddSingleton(); + if (configureOptions != null) + { + builder.Services.Configure(configureOptions); + } + return builder; + } } } diff --git a/src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs b/src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs index e8f053d7ed..caf77f982a 100644 --- a/src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs +++ b/src/Security/Authentication/Certificate/src/CertificateAuthenticationHandler.cs @@ -8,6 +8,7 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.Encodings.Web; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,6 +17,7 @@ namespace Microsoft.AspNetCore.Authentication.Certificate internal class CertificateAuthenticationHandler : AuthenticationHandler { private static readonly Oid ClientCertificateOid = new Oid("1.3.6.1.5.5.7.3.2"); + private ICertificateValidationCache _cache; public CertificateAuthenticationHandler( IOptionsMonitor options, @@ -41,11 +43,18 @@ namespace Microsoft.AspNetCore.Authentication.Certificate /// A new instance of the events instance. protected override Task CreateEventsAsync() => Task.FromResult(new CertificateAuthenticationEvents()); + protected override Task InitializeHandlerAsync() + { + _cache = Context.RequestServices.GetService(); + return base.InitializeHandlerAsync(); + } + protected override async Task HandleAuthenticateAsync() { // You only get client certificates over HTTPS if (!Context.Request.IsHttps) { + Logger.NotHttps(); return AuthenticateResult.NoResult(); } @@ -60,57 +69,21 @@ namespace Microsoft.AspNetCore.Authentication.Certificate return AuthenticateResult.NoResult(); } - // If we have a self signed cert, and they're not allowed, exit early and not bother with - // any other validations. - if (clientCertificate.IsSelfSigned() && - !Options.AllowedCertificateTypes.HasFlag(CertificateTypes.SelfSigned)) + if (_cache != null) { - Logger.CertificateRejected("Self signed", clientCertificate.Subject); - return AuthenticateResult.Fail("Options do not allow self signed certificates."); - } - - // If we have a chained cert, and they're not allowed, exit early and not bother with - // any other validations. - if (!clientCertificate.IsSelfSigned() && - !Options.AllowedCertificateTypes.HasFlag(CertificateTypes.Chained)) - { - Logger.CertificateRejected("Chained", clientCertificate.Subject); - return AuthenticateResult.Fail("Options do not allow chained certificates."); - } - - var chainPolicy = BuildChainPolicy(clientCertificate); - var chain = new X509Chain - { - ChainPolicy = chainPolicy - }; - - var certificateIsValid = chain.Build(clientCertificate); - if (!certificateIsValid) - { - var chainErrors = new List(); - foreach (var validationFailure in chain.ChainStatus) + var cacheHit = _cache.Get(Context, clientCertificate); + if (cacheHit != null) { - chainErrors.Add($"{validationFailure.Status} {validationFailure.StatusInformation}"); + return cacheHit; } - Logger.CertificateFailedValidation(clientCertificate.Subject, chainErrors); - return AuthenticateResult.Fail("Client certificate failed validation."); } - var certificateValidatedContext = new CertificateValidatedContext(Context, Scheme, Options) + var result = await ValidateCertificateAsync(clientCertificate); + if (_cache != null) { - ClientCertificate = clientCertificate, - Principal = CreatePrincipal(clientCertificate) - }; - - await Events.CertificateValidated(certificateValidatedContext); - - if (certificateValidatedContext.Result != null) - { - return certificateValidatedContext.Result; + _cache.Put(Context, clientCertificate, result); } - - certificateValidatedContext.Success(); - return certificateValidatedContext.Result; + return result; } catch (Exception ex) { @@ -120,7 +93,6 @@ namespace Microsoft.AspNetCore.Authentication.Certificate }; await Events.AuthenticationFailed(authenticationFailedContext); - if (authenticationFailedContext.Result != null) { return authenticationFailedContext.Result; @@ -130,6 +102,61 @@ namespace Microsoft.AspNetCore.Authentication.Certificate } } + private async Task ValidateCertificateAsync(X509Certificate2 clientCertificate) + { + // If we have a self signed cert, and they're not allowed, exit early and not bother with + // any other validations. + if (clientCertificate.IsSelfSigned() && + !Options.AllowedCertificateTypes.HasFlag(CertificateTypes.SelfSigned)) + { + Logger.CertificateRejected("Self signed", clientCertificate.Subject); + return AuthenticateResult.Fail("Options do not allow self signed certificates."); + } + + // If we have a chained cert, and they're not allowed, exit early and not bother with + // any other validations. + if (!clientCertificate.IsSelfSigned() && + !Options.AllowedCertificateTypes.HasFlag(CertificateTypes.Chained)) + { + Logger.CertificateRejected("Chained", clientCertificate.Subject); + return AuthenticateResult.Fail("Options do not allow chained certificates."); + } + + var chainPolicy = BuildChainPolicy(clientCertificate); + var chain = new X509Chain + { + ChainPolicy = chainPolicy + }; + + var certificateIsValid = chain.Build(clientCertificate); + if (!certificateIsValid) + { + var chainErrors = new List(); + foreach (var validationFailure in chain.ChainStatus) + { + chainErrors.Add($"{validationFailure.Status} {validationFailure.StatusInformation}"); + } + Logger.CertificateFailedValidation(clientCertificate.Subject, chainErrors); + return AuthenticateResult.Fail("Client certificate failed validation."); + } + + var certificateValidatedContext = new CertificateValidatedContext(Context, Scheme, Options) + { + ClientCertificate = clientCertificate, + Principal = CreatePrincipal(clientCertificate) + }; + + await Events.CertificateValidated(certificateValidatedContext); + + if (certificateValidatedContext.Result != null) + { + return certificateValidatedContext.Result; + } + + certificateValidatedContext.Success(); + return certificateValidatedContext.Result; + } + protected override async Task HandleChallengeAsync(AuthenticationProperties properties) { var authenticationChallengedContext = new CertificateChallengeContext(Context, Scheme, Options, properties); diff --git a/src/Security/Authentication/Certificate/src/CertificateValidationCache.cs b/src/Security/Authentication/Certificate/src/CertificateValidationCache.cs new file mode 100644 index 0000000000..df65030435 --- /dev/null +++ b/src/Security/Authentication/Certificate/src/CertificateValidationCache.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.Certificate +{ + /// + /// MemoryCache based implementation used to store results after the certificate has been validated + /// + public class CertificateValidationCache : ICertificateValidationCache + { + private MemoryCache _cache; + private CertificateValidationCacheOptions _options; + + public CertificateValidationCache(IOptions options) + { + _options = options.Value; + _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = _options.CacheSize }); + } + + /// + /// Get the for the connection and certificate. + /// + /// The HttpContext. + /// The certificate. + /// the + public AuthenticateResult Get(HttpContext context, X509Certificate2 certificate) + => _cache.Get(ComputeKey(certificate))?.Clone(); + + /// + /// Store a for the connection and certificate + /// + /// The HttpContext. + /// The certificate. + /// the + public void Put(HttpContext context, X509Certificate2 certificate, AuthenticateResult result) + => _cache.Set(ComputeKey(certificate), result.Clone(), new MemoryCacheEntryOptions() + .SetSize(1).SetSlidingExpiration(_options.CacheEntryExpiration).SetAbsoluteExpiration(certificate.NotAfter)); + + private string ComputeKey(X509Certificate2 certificate) + => certificate.GetCertHashString(HashAlgorithmName.SHA256); + } +} diff --git a/src/Security/Authentication/Certificate/src/CertificateValidationCacheOptions.cs b/src/Security/Authentication/Certificate/src/CertificateValidationCacheOptions.cs new file mode 100644 index 0000000000..e8f443c4f5 --- /dev/null +++ b/src/Security/Authentication/Certificate/src/CertificateValidationCacheOptions.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Authentication.Certificate +{ + /// + /// Configuration options for + /// + public class CertificateValidationCacheOptions + { + /// + /// The expiration that should be used for entries in the MemoryCache, defaults to 2 minutes. + /// This is a sliding expiration that will extend each time the certificate is used, so long as the certificate is valid (see X509Certificate2.NotAfter). + /// + public TimeSpan CacheEntryExpiration { get; set; } = TimeSpan.FromMinutes(2); + + /// + /// How many validated certificate results to store in the cache, defaults to 1024. + /// + public int CacheSize { get; set; } = 1024; + } +} diff --git a/src/Security/Authentication/Certificate/src/ICertificateValidationCache.cs b/src/Security/Authentication/Certificate/src/ICertificateValidationCache.cs new file mode 100644 index 0000000000..41ef9a8f56 --- /dev/null +++ b/src/Security/Authentication/Certificate/src/ICertificateValidationCache.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Authentication.Certificate +{ + /// + /// Cache used to store results after the certificate has been validated + /// + public interface ICertificateValidationCache + { + /// + /// Get the for the connection and certificate. + /// + /// The HttpContext. + /// The certificate. + /// the + AuthenticateResult Get(HttpContext context, X509Certificate2 certificate); + + /// + /// Store a for the connection and certificate + /// + /// The HttpContext. + /// The certificate. + /// the + void Put(HttpContext context, X509Certificate2 certificate, AuthenticateResult result); + } +} diff --git a/src/Security/Authentication/Certificate/src/LoggingExtensions.cs b/src/Security/Authentication/Certificate/src/LoggingExtensions.cs index 2219a349b6..b3bd1946a3 100644 --- a/src/Security/Authentication/Certificate/src/LoggingExtensions.cs +++ b/src/Security/Authentication/Certificate/src/LoggingExtensions.cs @@ -9,6 +9,7 @@ namespace Microsoft.Extensions.Logging internal static class LoggingExtensions { private static Action _noCertificate; + private static Action _notHttps; private static Action _certRejected; private static Action _certFailedValidation; @@ -28,6 +29,11 @@ namespace Microsoft.Extensions.Logging eventId: new EventId(2, "CertificateFailedValidation"), logLevel: LogLevel.Warning, formatString: "Certificate validation failed, subject was {Subject}." + Environment.NewLine + "{ChainErrors}"); + + _notHttps = LoggerMessage.Define( + eventId: new EventId(3, "NotHttps"), + logLevel: LogLevel.Debug, + formatString: "Not https, skipping certificate authentication."); } public static void NoCertificate(this ILogger logger) @@ -35,6 +41,11 @@ namespace Microsoft.Extensions.Logging _noCertificate(logger, null); } + public static void NotHttps(this ILogger logger) + { + _notHttps(logger, null); + } + public static void CertificateRejected(this ILogger logger, string certificateType, string subject) { _certRejected(logger, certificateType, subject, null); diff --git a/src/Security/Authentication/Certificate/src/Microsoft.AspNetCore.Authentication.Certificate.csproj b/src/Security/Authentication/Certificate/src/Microsoft.AspNetCore.Authentication.Certificate.csproj index 86af316bad..4bc3a361ec 100644 --- a/src/Security/Authentication/Certificate/src/Microsoft.AspNetCore.Authentication.Certificate.csproj +++ b/src/Security/Authentication/Certificate/src/Microsoft.AspNetCore.Authentication.Certificate.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Security/Authentication/test/CertificateTests.cs b/src/Security/Authentication/test/CertificateTests.cs index bd395c33ff..ca0e92024e 100644 --- a/src/Security/Authentication/test/CertificateTests.cs +++ b/src/Security/Authentication/test/CertificateTests.cs @@ -473,6 +473,82 @@ namespace Microsoft.AspNetCore.Authentication.Certificate.Test } } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task VerifyValidationResultCanBeCached(bool cache) + { + const string Expected = "John Doe"; + var validationCount = 0; + + var server = CreateServer( + new CertificateAuthenticationOptions + { + AllowedCertificateTypes = CertificateTypes.SelfSigned, + Events = new CertificateAuthenticationEvents + { + OnCertificateValidated = context => + { + validationCount++; + + // Make sure we get the validated principal + Assert.NotNull(context.Principal); + + var claims = new[] + { + new Claim(ClaimTypes.Name, Expected, ClaimValueTypes.String, context.Options.ClaimsIssuer), + new Claim("ValidationCount", validationCount.ToString(), ClaimValueTypes.String, context.Options.ClaimsIssuer) + }; + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name)); + context.Success(); + return Task.CompletedTask; + } + } + }, + Certificates.SelfSignedValidWithNoEku, null, null, false, "", cache); + + var response = await server.CreateClient().GetAsync("https://example.com/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + XElement responseAsXml = null; + if (response.Content != null && + response.Content.Headers.ContentType != null && + response.Content.Headers.ContentType.MediaType == "text/xml") + { + var responseContent = await response.Content.ReadAsStringAsync(); + responseAsXml = XElement.Parse(responseContent); + } + + Assert.NotNull(responseAsXml); + var name = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == ClaimTypes.Name); + Assert.Single(name); + Assert.Equal(Expected, name.First().Value); + var count = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == "ValidationCount"); + Assert.Single(count); + Assert.Equal("1", count.First().Value); + + // Second request should not trigger validation if caching + response = await server.CreateClient().GetAsync("https://example.com/"); + responseAsXml = null; + if (response.Content != null && + response.Content.Headers.ContentType != null && + response.Content.Headers.ContentType.MediaType == "text/xml") + { + var responseContent = await response.Content.ReadAsStringAsync(); + responseAsXml = XElement.Parse(responseContent); + } + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + name = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == ClaimTypes.Name); + Assert.Single(name); + Assert.Equal(Expected, name.First().Value); + count = responseAsXml.Elements("claim").Where(claim => claim.Attribute("Type").Value == "ValidationCount"); + Assert.Single(count); + var expected = cache ? "1" : "2"; + Assert.Equal(expected, count.First().Value); + } + [Fact] public async Task VerifyValidationEventPrincipalIsPropogated() { @@ -526,7 +602,8 @@ namespace Microsoft.AspNetCore.Authentication.Certificate.Test Func handler = null, Uri baseAddress = null, bool wireUpHeaderMiddleware = false, - string headerName = "") + string headerName = "", + bool useCache = false) { var builder = new WebHostBuilder() .Configure(app => @@ -575,9 +652,10 @@ namespace Microsoft.AspNetCore.Authentication.Certificate.Test }) .ConfigureServices(services => { + AuthenticationBuilder authBuilder; if (configureOptions != null) { - services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).AddCertificate(options => + authBuilder = services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).AddCertificate(options => { options.CustomTrustStore = configureOptions.CustomTrustStore; options.ChainTrustValidationMode = configureOptions.ChainTrustValidationMode; @@ -591,7 +669,11 @@ namespace Microsoft.AspNetCore.Authentication.Certificate.Test } else { - services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).AddCertificate(); + authBuilder = services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).AddCertificate(); + } + if (useCache) + { + authBuilder.AddCertificateCache(); } if (wireUpHeaderMiddleware && !string.IsNullOrEmpty(headerName))