diff --git a/src/Http/Authentication.Core/src/AuthenticationCoreServiceCollectionExtensions.cs b/src/Http/Authentication.Core/src/AuthenticationCoreServiceCollectionExtensions.cs index 9b73616527ba..be04d14e3717 100644 --- a/src/Http/Authentication.Core/src/AuthenticationCoreServiceCollectionExtensions.cs +++ b/src/Http/Authentication.Core/src/AuthenticationCoreServiceCollectionExtensions.cs @@ -20,10 +20,12 @@ public static IServiceCollection AddAuthenticationCore(this IServiceCollection s { ArgumentNullException.ThrowIfNull(services); - services.TryAddScoped(); + services.AddMetrics(); + services.TryAddScoped(); services.TryAddSingleton(); // Can be replaced with scoped ones that use DbContext services.TryAddScoped(); services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/Http/Authentication.Core/src/AuthenticationMetrics.cs b/src/Http/Authentication.Core/src/AuthenticationMetrics.cs new file mode 100644 index 000000000000..12607938a10d --- /dev/null +++ b/src/Http/Authentication.Core/src/AuthenticationMetrics.cs @@ -0,0 +1,204 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Authentication; + +internal sealed class AuthenticationMetrics +{ + public const string MeterName = "Microsoft.AspNetCore.Authentication"; + + private readonly Meter _meter; + private readonly Histogram _authenticatedRequestDuration; + private readonly Counter _challengeCount; + private readonly Counter _forbidCount; + private readonly Counter _signInCount; + private readonly Counter _signOutCount; + + public AuthenticationMetrics(IMeterFactory meterFactory) + { + _meter = meterFactory.Create(MeterName); + + _authenticatedRequestDuration = _meter.CreateHistogram( + "aspnetcore.authentication.authenticate.duration", + unit: "s", + description: "The authentication duration for a request.", + advice: new() { HistogramBucketBoundaries = MetricsConstants.ShortSecondsBucketBoundaries }); + + _challengeCount = _meter.CreateCounter( + "aspnetcore.authentication.challenges", + unit: "{request}", + description: "The total number of times a scheme is challenged."); + + _forbidCount = _meter.CreateCounter( + "aspnetcore.authentication.forbids", + unit: "{request}", + description: "The total number of times an authenticated user attempts to access a resource they are not permitted to access."); + + _signInCount = _meter.CreateCounter( + "aspnetcore.authentication.sign_ins", + unit: "{request}", + description: "The total number of times a principal is signed in."); + + _signOutCount = _meter.CreateCounter( + "aspnetcore.authentication.sign_outs", + unit: "{request}", + description: "The total number of times a scheme is signed out."); + } + + public void AuthenticatedRequestCompleted(string? scheme, AuthenticateResult? result, Exception? exception, long startTimestamp, long currentTimestamp) + { + if (_authenticatedRequestDuration.Enabled) + { + AuthenticatedRequestCompletedCore(scheme, result, exception, startTimestamp, currentTimestamp); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void AuthenticatedRequestCompletedCore(string? scheme, AuthenticateResult? result, Exception? exception, long startTimestamp, long currentTimestamp) + { + var tags = new TagList(); + + if (scheme is not null) + { + AddSchemeTag(ref tags, scheme); + } + + if (result is not null) + { + tags.Add("aspnetcore.authentication.result", result switch + { + { None: true } => "none", + { Succeeded: true } => "success", + { Failure: not null } => "failure", + _ => "_OTHER", // _OTHER is commonly used fallback for an extra or unexpected value. Shouldn't reach here. + }); + } + + if (exception is not null) + { + AddErrorTag(ref tags, exception); + } + + var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); + _authenticatedRequestDuration.Record(duration.TotalSeconds, tags); + } + + public void ChallengeCompleted(string? scheme, Exception? exception) + { + if (_challengeCount.Enabled) + { + ChallengeCompletedCore(scheme, exception); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ChallengeCompletedCore(string? scheme, Exception? exception) + { + var tags = new TagList(); + + if (scheme is not null) + { + AddSchemeTag(ref tags, scheme); + } + + if (exception is not null) + { + AddErrorTag(ref tags, exception); + } + + _challengeCount.Add(1, tags); + } + + public void ForbidCompleted(string? scheme, Exception? exception) + { + if (_forbidCount.Enabled) + { + ForbidCompletedCore(scheme, exception); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ForbidCompletedCore(string? scheme, Exception? exception) + { + var tags = new TagList(); + + if (scheme is not null) + { + AddSchemeTag(ref tags, scheme); + } + + if (exception is not null) + { + AddErrorTag(ref tags, exception); + } + + _forbidCount.Add(1, tags); + } + + public void SignInCompleted(string? scheme, Exception? exception) + { + if (_signInCount.Enabled) + { + SignInCompletedCore(scheme, exception); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void SignInCompletedCore(string? scheme, Exception? exception) + { + var tags = new TagList(); + + if (scheme is not null) + { + AddSchemeTag(ref tags, scheme); + } + + if (exception is not null) + { + AddErrorTag(ref tags, exception); + } + + _signInCount.Add(1, tags); + } + + public void SignOutCompleted(string? scheme, Exception? exception) + { + if (_signOutCount.Enabled) + { + SignOutCompletedCore(scheme, exception); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void SignOutCompletedCore(string? scheme, Exception? exception) + { + var tags = new TagList(); + + if (scheme is not null) + { + AddSchemeTag(ref tags, scheme); + } + + if (exception is not null) + { + AddErrorTag(ref tags, exception); + } + + _signOutCount.Add(1, tags); + } + + private static void AddSchemeTag(ref TagList tags, string scheme) + { + tags.Add("aspnetcore.authentication.scheme", scheme); + } + + private static void AddErrorTag(ref TagList tags, Exception exception) + { + tags.Add("error.type", exception.GetType().FullName); + } +} diff --git a/src/Http/Authentication.Core/src/AuthenticationService.cs b/src/Http/Authentication.Core/src/AuthenticationService.cs index 027886286ef3..3b45ffc56dc6 100644 --- a/src/Http/Authentication.Core/src/AuthenticationService.cs +++ b/src/Http/Authentication.Core/src/AuthenticationService.cs @@ -22,7 +22,11 @@ public class AuthenticationService : IAuthenticationService /// The . /// The . /// The . - public AuthenticationService(IAuthenticationSchemeProvider schemes, IAuthenticationHandlerProvider handlers, IClaimsTransformation transform, IOptions options) + public AuthenticationService( + IAuthenticationSchemeProvider schemes, + IAuthenticationHandlerProvider handlers, + IClaimsTransformation transform, + IOptions options) { Schemes = schemes; Handlers = handlers; @@ -68,11 +72,7 @@ public virtual async Task AuthenticateAsync(HttpContext cont } } - var handler = await Handlers.GetHandlerAsync(context, scheme); - if (handler == null) - { - throw await CreateMissingHandlerException(scheme); - } + var handler = await Handlers.GetHandlerAsync(context, scheme) ?? throw await CreateMissingHandlerException(scheme); // Handlers should not return null, but we'll be tolerant of null values for legacy reasons. var result = (await handler.AuthenticateAsync()) ?? AuthenticateResult.NoResult(); @@ -81,7 +81,7 @@ public virtual async Task AuthenticateAsync(HttpContext cont { var principal = result.Principal!; var doTransform = true; - _transformCache ??= new HashSet(); + _transformCache ??= []; if (_transformCache.Contains(principal)) { doTransform = false; @@ -94,6 +94,7 @@ public virtual async Task AuthenticateAsync(HttpContext cont } return AuthenticateResult.Success(new AuthenticationTicket(principal, result.Properties, result.Ticket!.AuthenticationScheme)); } + return result; } @@ -116,12 +117,7 @@ public virtual async Task ChallengeAsync(HttpContext context, string? scheme, Au } } - var handler = await Handlers.GetHandlerAsync(context, scheme); - if (handler == null) - { - throw await CreateMissingHandlerException(scheme); - } - + var handler = await Handlers.GetHandlerAsync(context, scheme) ?? throw await CreateMissingHandlerException(scheme); await handler.ChallengeAsync(properties); } @@ -144,12 +140,7 @@ public virtual async Task ForbidAsync(HttpContext context, string? scheme, Authe } } - var handler = await Handlers.GetHandlerAsync(context, scheme); - if (handler == null) - { - throw await CreateMissingHandlerException(scheme); - } - + var handler = await Handlers.GetHandlerAsync(context, scheme) ?? throw await CreateMissingHandlerException(scheme); await handler.ForbidAsync(properties); } @@ -187,14 +178,8 @@ public virtual async Task SignInAsync(HttpContext context, string? scheme, Claim } } - var handler = await Handlers.GetHandlerAsync(context, scheme); - if (handler == null) - { - throw await CreateMissingSignInHandlerException(scheme); - } - - var signInHandler = handler as IAuthenticationSignInHandler; - if (signInHandler == null) + var handler = await Handlers.GetHandlerAsync(context, scheme) ?? throw await CreateMissingSignInHandlerException(scheme); + if (handler is not IAuthenticationSignInHandler signInHandler) { throw await CreateMismatchedSignInHandlerException(scheme, handler); } @@ -221,14 +206,8 @@ public virtual async Task SignOutAsync(HttpContext context, string? scheme, Auth } } - var handler = await Handlers.GetHandlerAsync(context, scheme); - if (handler == null) - { - throw await CreateMissingSignOutHandlerException(scheme); - } - - var signOutHandler = handler as IAuthenticationSignOutHandler; - if (signOutHandler == null) + var handler = await Handlers.GetHandlerAsync(context, scheme) ?? throw await CreateMissingSignOutHandlerException(scheme); + if (handler is not IAuthenticationSignOutHandler signOutHandler) { throw await CreateMismatchedSignOutHandlerException(scheme, handler); } diff --git a/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs b/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs new file mode 100644 index 000000000000..8aca027a1af6 --- /dev/null +++ b/src/Http/Authentication.Core/src/AuthenticationServiceImpl.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication; + +internal sealed class AuthenticationServiceImpl( + IAuthenticationSchemeProvider schemes, + IAuthenticationHandlerProvider handlers, + IClaimsTransformation transform, + IOptions options, + AuthenticationMetrics metrics) + : AuthenticationService(schemes, handlers, transform, options) +{ + public override async Task AuthenticateAsync(HttpContext context, string? scheme) + { + AuthenticateResult result; + var startTimestamp = Stopwatch.GetTimestamp(); + try + { + result = await base.AuthenticateAsync(context, scheme); + } + catch (Exception ex) + { + metrics.AuthenticatedRequestCompleted(scheme, result: null, ex, startTimestamp, currentTimestamp: Stopwatch.GetTimestamp()); + throw; + } + + metrics.AuthenticatedRequestCompleted(scheme, result, exception: result.Failure, startTimestamp, currentTimestamp: Stopwatch.GetTimestamp()); + return result; + } + + public override async Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) + { + try + { + await base.ChallengeAsync(context, scheme, properties); + } + catch (Exception ex) + { + metrics.ChallengeCompleted(scheme, ex); + throw; + } + + metrics.ChallengeCompleted(scheme, exception: null); + } + + public override async Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) + { + try + { + await base.ForbidAsync(context, scheme, properties); + } + catch (Exception ex) + { + metrics.ForbidCompleted(scheme, ex); + throw; + } + + metrics.ForbidCompleted(scheme, exception: null); + } + + public override async Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal, AuthenticationProperties? properties) + { + try + { + await base.SignInAsync(context, scheme, principal, properties); + } + catch (Exception ex) + { + metrics.SignInCompleted(scheme, ex); + throw; + } + + metrics.SignInCompleted(scheme, exception: null); + } + + public override async Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) + { + try + { + await base.SignOutAsync(context, scheme, properties); + } + catch (Exception ex) + { + metrics.SignOutCompleted(scheme, ex); + throw; + } + + metrics.SignOutCompleted(scheme, exception: null); + } +} diff --git a/src/Http/Authentication.Core/src/Microsoft.AspNetCore.Authentication.Core.csproj b/src/Http/Authentication.Core/src/Microsoft.AspNetCore.Authentication.Core.csproj index 5786fd9a889a..61b6d3a8f71e 100644 --- a/src/Http/Authentication.Core/src/Microsoft.AspNetCore.Authentication.Core.csproj +++ b/src/Http/Authentication.Core/src/Microsoft.AspNetCore.Authentication.Core.csproj @@ -10,10 +10,19 @@ true + + + + + + + + + diff --git a/src/Http/startvscode.cmd b/src/Http/startvscode.cmd new file mode 100644 index 000000000000..d403f3028231 --- /dev/null +++ b/src/Http/startvscode.cmd @@ -0,0 +1,3 @@ +@ECHO OFF + +%~dp0..\..\startvscode.cmd %~dp0 diff --git a/src/Security/Authentication/test/AuthenticationMetricsTest.cs b/src/Security/Authentication/test/AuthenticationMetricsTest.cs new file mode 100644 index 000000000000..fec3667d4ee6 --- /dev/null +++ b/src/Security/Authentication/test/AuthenticationMetricsTest.cs @@ -0,0 +1,356 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Diagnostics.Metrics.Testing; +using Moq; + +namespace Microsoft.AspNetCore.Authentication; + +public class AuthenticationMetricsTest +{ + [Fact] + public async Task Authenticate_Success() + { + // Arrange + var authenticationHandler = new Mock(); + authenticationHandler.Setup(h => h.AuthenticateAsync()).Returns(Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(), "custom")))); + + var meterFactory = new TestMeterFactory(); + var httpContext = new DefaultHttpContext(); + var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory); + var meter = meterFactory.Meters.Single(); + + using var authenticationRequestsCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.authenticate.duration"); + + // Act + await authenticationService.AuthenticateAsync(httpContext, scheme: "custom"); + + // Assert + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(authenticationRequestsCollector.GetMeasurementSnapshot()); + Assert.True(measurement.Value > 0); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + Assert.Equal("success", (string)measurement.Tags["aspnetcore.authentication.result"]); + Assert.False(measurement.Tags.ContainsKey("error.type")); + } + + [Fact] + public async Task Authenticate_Failure() + { + // Arrange + var authenticationHandler = new Mock(); + authenticationHandler.Setup(h => h.AuthenticateAsync()).Returns(Task.FromResult(AuthenticateResult.Fail("Authentication failed"))); + + var meterFactory = new TestMeterFactory(); + var httpContext = new DefaultHttpContext(); + var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory); + var meter = meterFactory.Meters.Single(); + + using var authenticationRequestsCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.authenticate.duration"); + + // Act + await authenticationService.AuthenticateAsync(httpContext, scheme: "custom"); + + // Assert + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(authenticationRequestsCollector.GetMeasurementSnapshot()); + Assert.True(measurement.Value > 0); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + Assert.Equal("failure", (string)measurement.Tags["aspnetcore.authentication.result"]); + Assert.Equal("Microsoft.AspNetCore.Authentication.AuthenticationFailureException", (string)measurement.Tags["error.type"]); + } + + [Fact] + public async Task Authenticate_NoResult() + { + // Arrange + var authenticationHandler = new Mock(); + authenticationHandler.Setup(h => h.AuthenticateAsync()).Returns(Task.FromResult(AuthenticateResult.NoResult())); + + var meterFactory = new TestMeterFactory(); + var httpContext = new DefaultHttpContext(); + var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory); + var meter = meterFactory.Meters.Single(); + + using var authenticationRequestsCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.authenticate.duration"); + + // Act + await authenticationService.AuthenticateAsync(httpContext, scheme: "custom"); + + // Assert + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(authenticationRequestsCollector.GetMeasurementSnapshot()); + Assert.True(measurement.Value > 0); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + Assert.Equal("none", (string)measurement.Tags["aspnetcore.authentication.result"]); + Assert.False(measurement.Tags.ContainsKey("error.type")); + } + + [Fact] + public async Task Authenticate_ExceptionThrownInHandler() + { + // Arrange + var authenticationHandler = new Mock(); + authenticationHandler.Setup(h => h.AuthenticateAsync()).Throws(new InvalidOperationException("An error occurred during authentication")); + + var meterFactory = new TestMeterFactory(); + var httpContext = new DefaultHttpContext(); + var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory); + var meter = meterFactory.Meters.Single(); + + using var authenticationRequestsCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.authenticate.duration"); + + // Act + var ex = await Assert.ThrowsAsync(() => authenticationService.AuthenticateAsync(httpContext, scheme: "custom")); + + // Assert + Assert.Equal("An error occurred during authentication", ex.Message); + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(authenticationRequestsCollector.GetMeasurementSnapshot()); + Assert.True(measurement.Value > 0); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]); + Assert.False(measurement.Tags.ContainsKey("aspnetcore.authentication.result")); + } + + [Fact] + public async Task Challenge() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var httpContext = new DefaultHttpContext(); + var authenticationService = CreateAuthenticationService(Mock.Of(), meterFactory); + var meter = meterFactory.Meters.Single(); + + using var challengesCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.challenges"); + + // Act + await authenticationService.ChallengeAsync(httpContext, scheme: "custom", properties: null); + + // Assert + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(challengesCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + } + + [Fact] + public async Task Challenge_ExceptionThrownInHandler() + { + // Arrange + var authenticationHandler = new Mock(); + authenticationHandler.Setup(h => h.ChallengeAsync(It.IsAny())).Throws(new InvalidOperationException("An error occurred during challenge")); + + var meterFactory = new TestMeterFactory(); + var httpContext = new DefaultHttpContext(); + var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory); + var meter = meterFactory.Meters.Single(); + + using var challengesCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.challenges"); + + // Act + var ex = await Assert.ThrowsAsync(() => authenticationService.ChallengeAsync(httpContext, scheme: "custom", properties: null)); + + // Assert + Assert.Equal("An error occurred during challenge", ex.Message); + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(challengesCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]); + } + + [Fact] + public async Task Forbid() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var httpContext = new DefaultHttpContext(); + var authenticationService = CreateAuthenticationService(Mock.Of(), meterFactory); + var meter = meterFactory.Meters.Single(); + + using var forbidsCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.forbids"); + + // Act + await authenticationService.ForbidAsync(httpContext, scheme: "custom", properties: null); + + // Assert + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(forbidsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + } + + [Fact] + public async Task Forbid_ExceptionThrownInHandler() + { + // Arrange + var authenticationHandler = new Mock(); + authenticationHandler.Setup(h => h.ForbidAsync(It.IsAny())).Throws(new InvalidOperationException("An error occurred during forbid")); + + var meterFactory = new TestMeterFactory(); + var httpContext = new DefaultHttpContext(); + var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory); + var meter = meterFactory.Meters.Single(); + + using var forbidsCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.forbids"); + + // Act + var ex = await Assert.ThrowsAsync(() => authenticationService.ForbidAsync(httpContext, scheme: "custom", properties: null)); + + // Assert + Assert.Equal("An error occurred during forbid", ex.Message); + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(forbidsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]); + } + + [Fact] + public async Task SignIn() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var httpContext = new DefaultHttpContext(); + var authenticationService = CreateAuthenticationService(Mock.Of(), meterFactory); + var meter = meterFactory.Meters.Single(); + + using var signInsCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.sign_ins"); + + // Act + await authenticationService.SignInAsync(httpContext, scheme: "custom", new ClaimsPrincipal(), properties: null); + + // Assert + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(signInsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + } + + [Fact] + public async Task SignIn_ExceptionThrownInHandler() + { + // Arrange + var authenticationHandler = new Mock(); + authenticationHandler.Setup(h => h.SignInAsync(It.IsAny(), It.IsAny())).Throws(new InvalidOperationException("An error occurred during sign in")); + + var meterFactory = new TestMeterFactory(); + var httpContext = new DefaultHttpContext(); + var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory); + var meter = meterFactory.Meters.Single(); + + using var signInsCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.sign_ins"); + + // Act + var ex = await Assert.ThrowsAsync(() => authenticationService.SignInAsync(httpContext, scheme: "custom", new ClaimsPrincipal(), properties: null)); + + // Assert + Assert.Equal("An error occurred during sign in", ex.Message); + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(signInsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]); + } + + [Fact] + public async Task SignOut() + { + // Arrange + var httpContext = new DefaultHttpContext(); + var meterFactory = new TestMeterFactory(); + var authenticationService = CreateAuthenticationService(Mock.Of(), meterFactory); + var meter = meterFactory.Meters.Single(); + + using var signOutsCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.sign_outs"); + + // Act + await authenticationService.SignOutAsync(httpContext, scheme: "custom", properties: null); + + // Assert + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(signOutsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + } + + [Fact] + public async Task SignOut_ExceptionThrownInHandler() + { + // Arrange + var authenticationHandler = new Mock(); + authenticationHandler.Setup(h => h.SignOutAsync(It.IsAny())).Throws(new InvalidOperationException("An error occurred during sign out")); + + var httpContext = new DefaultHttpContext(); + var meterFactory = new TestMeterFactory(); + var authenticationService = CreateAuthenticationService(authenticationHandler.Object, meterFactory); + var meter = meterFactory.Meters.Single(); + + using var signOutsCollector = new MetricCollector(meterFactory, AuthenticationMetrics.MeterName, "aspnetcore.authentication.sign_outs"); + + // Act + var ex = await Assert.ThrowsAsync(() => authenticationService.SignOutAsync(httpContext, scheme: "custom", properties: null)); + + // Assert + Assert.Equal("An error occurred during sign out", ex.Message); + Assert.Equal(AuthenticationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(signOutsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("custom", (string)measurement.Tags["aspnetcore.authentication.scheme"]); + Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]); + } + + private static AuthenticationServiceImpl CreateAuthenticationService(IAuthenticationHandler authenticationHandler, TestMeterFactory meterFactory) + { + var authenticationHandlerProvider = new Mock(); + authenticationHandlerProvider.Setup(p => p.GetHandlerAsync(It.IsAny(), "custom")).Returns(Task.FromResult(authenticationHandler)); + + var claimsTransform = new Mock(); + claimsTransform.Setup(t => t.TransformAsync(It.IsAny())).Returns((ClaimsPrincipal p) => Task.FromResult(p)); + + var options = Options.Create(new AuthenticationOptions + { + DefaultSignInScheme = "custom", + RequireAuthenticatedSignIn = false, + }); + + var metrics = new AuthenticationMetrics(meterFactory); + var authenticationService = new AuthenticationServiceImpl( + Mock.Of(), + authenticationHandlerProvider.Object, + claimsTransform.Object, + options, + metrics); + + return authenticationService; + } +} diff --git a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj index d94b1c3cbc57..64bcf143aa82 100644 --- a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj +++ b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj @@ -14,6 +14,7 @@ + PreserveNewest @@ -49,6 +50,7 @@ + diff --git a/src/Security/Authorization/Core/src/AuthorizationMetrics.cs b/src/Security/Authorization/Core/src/AuthorizationMetrics.cs new file mode 100644 index 000000000000..eb1d4060c593 --- /dev/null +++ b/src/Security/Authorization/Core/src/AuthorizationMetrics.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Authorization; + +internal sealed class AuthorizationMetrics +{ + public const string MeterName = "Microsoft.AspNetCore.Authorization"; + + private readonly Meter _meter; + private readonly Counter _authorizedRequestCount; + + public AuthorizationMetrics(IMeterFactory meterFactory) + { + _meter = meterFactory.Create(MeterName); + + _authorizedRequestCount = _meter.CreateCounter( + "aspnetcore.authorization.attempts", + unit: "{request}", + description: "The total number of requests for which authorization was attempted."); + } + + public void AuthorizedRequestCompleted(ClaimsPrincipal user, string? policyName, AuthorizationResult? result, Exception? exception) + { + if (_authorizedRequestCount.Enabled) + { + AuthorizedRequestCompletedCore(user, policyName, result, exception); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void AuthorizedRequestCompletedCore(ClaimsPrincipal user, string? policyName, AuthorizationResult? result, Exception? exception) + { + var tags = new TagList([ + new("user.is_authenticated", user.Identity?.IsAuthenticated ?? false) + ]); + + if (policyName is not null) + { + tags.Add("aspnetcore.authorization.policy", policyName); + } + + if (result is not null) + { + var resultTagValue = result.Succeeded ? "success" : "failure"; + tags.Add("aspnetcore.authorization.result", resultTagValue); + } + + if (exception is not null) + { + tags.Add("error.type", exception.GetType().FullName); + } + + _authorizedRequestCount.Add(1, tags); + } +} diff --git a/src/Security/Authorization/Core/src/AuthorizationServiceCollectionExtensions.cs b/src/Security/Authorization/Core/src/AuthorizationServiceCollectionExtensions.cs index 273f3c5ff563..77ad7ff6752b 100644 --- a/src/Security/Authorization/Core/src/AuthorizationServiceCollectionExtensions.cs +++ b/src/Security/Authorization/Core/src/AuthorizationServiceCollectionExtensions.cs @@ -27,7 +27,10 @@ public static IServiceCollection AddAuthorizationCore(this IServiceCollection se // aren't included by default. services.AddOptions(); - services.TryAdd(ServiceDescriptor.Transient()); + services.AddMetrics(); + + services.TryAdd(ServiceDescriptor.Singleton()); + services.TryAdd(ServiceDescriptor.Transient()); services.TryAdd(ServiceDescriptor.Transient()); services.TryAdd(ServiceDescriptor.Transient()); services.TryAdd(ServiceDescriptor.Transient()); diff --git a/src/Security/Authorization/Core/src/DefaultAuthorizationService.cs b/src/Security/Authorization/Core/src/DefaultAuthorizationService.cs index 24073df62676..1a9474634485 100644 --- a/src/Security/Authorization/Core/src/DefaultAuthorizationService.cs +++ b/src/Security/Authorization/Core/src/DefaultAuthorizationService.cs @@ -98,13 +98,14 @@ public virtual async Task AuthorizeAsync(ClaimsPrincipal us /// public virtual async Task AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) { - ArgumentNullThrowHelper.ThrowIfNull(policyName); - - var policy = await _policyProvider.GetPolicyAsync(policyName).ConfigureAwait(false); - if (policy == null) - { - throw new InvalidOperationException($"No policy found: {policyName}."); - } + var policy = await GetPolicyAsync(policyName).ConfigureAwait(false); return await this.AuthorizeAsync(user, resource, policy).ConfigureAwait(false); } + + // For use in DefaultAuthorizationServiceImpl. + private protected async Task GetPolicyAsync(string policyName) + { + ArgumentNullThrowHelper.ThrowIfNull(policyName); + return await _policyProvider.GetPolicyAsync(policyName).ConfigureAwait(false) ?? throw new InvalidOperationException($"No policy found: {policyName}."); + } } diff --git a/src/Security/Authorization/Core/src/DefaultAuthorizationServiceImpl.cs b/src/Security/Authorization/Core/src/DefaultAuthorizationServiceImpl.cs new file mode 100644 index 000000000000..0912acb47792 --- /dev/null +++ b/src/Security/Authorization/Core/src/DefaultAuthorizationServiceImpl.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Shared; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authorization; + +internal sealed class DefaultAuthorizationServiceImpl( + IAuthorizationPolicyProvider policyProvider, + IAuthorizationHandlerProvider handlers, + ILogger logger, + IAuthorizationHandlerContextFactory contextFactory, + IAuthorizationEvaluator evaluator, + IOptions options, + AuthorizationMetrics metrics) + : DefaultAuthorizationService(policyProvider, handlers, logger, contextFactory, evaluator, options) +{ + public override async Task AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable requirements) + { + AuthorizationResult result; + try + { + result = await base.AuthorizeAsync(user, resource, requirements).ConfigureAwait(false); + } + catch (Exception ex) + { + metrics.AuthorizedRequestCompleted(user, policyName: null, result: null, ex); + throw; + } + + metrics.AuthorizedRequestCompleted(user, policyName: null, result, exception: null); + return result; + } + + public override async Task AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) + { + AuthorizationResult result; + try + { + var policy = await GetPolicyAsync(policyName).ConfigureAwait(false); + + // Note that we deliberately call the base method of the other overload here. + // This is because the base implementation for this overload dispatches to the other overload, + // which would cause metrics to be recorded twice. + result = await base.AuthorizeAsync(user, resource, policy.Requirements).ConfigureAwait(false); + } + catch (Exception ex) + { + metrics.AuthorizedRequestCompleted(user, policyName, result: null, ex); + throw; + } + + metrics.AuthorizedRequestCompleted(user, policyName, result, exception: null); + return result; + } +} diff --git a/src/Security/Authorization/Core/src/Microsoft.AspNetCore.Authorization.csproj b/src/Security/Authorization/Core/src/Microsoft.AspNetCore.Authorization.csproj index 44df5654138a..1d2f6d562069 100644 --- a/src/Security/Authorization/Core/src/Microsoft.AspNetCore.Authorization.csproj +++ b/src/Security/Authorization/Core/src/Microsoft.AspNetCore.Authorization.csproj @@ -17,6 +17,7 @@ Microsoft.AspNetCore.Authorization.AuthorizeAttribute + @@ -31,4 +32,8 @@ Microsoft.AspNetCore.Authorization.AuthorizeAttribute + + + + diff --git a/src/Security/Authorization/test/AuthorizationMetricsTest.cs b/src/Security/Authorization/test/AuthorizationMetricsTest.cs new file mode 100644 index 000000000000..affbc91de9da --- /dev/null +++ b/src/Security/Authorization/test/AuthorizationMetricsTest.cs @@ -0,0 +1,206 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.Metrics.Testing; + +namespace Microsoft.AspNetCore.Authorization.Test; + +public class AuthorizationMetricsTest +{ + [Fact] + public async Task Authorize_WithPolicyName_Success() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var authorizationService = BuildAuthorizationService(meterFactory); + var meter = meterFactory.Meters.Single(); + var user = new ClaimsPrincipal(new ClaimsIdentity([new Claim("Permission", "CanViewPage")], authenticationType: "someAuthentication")); + + using var authorizedRequestsCollector = new MetricCollector(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts"); + + // Act + await authorizationService.AuthorizeAsync(user, "Basic"); + + // Assert + Assert.Equal(AuthorizationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("Basic", (string)measurement.Tags["aspnetcore.authorization.policy"]); + Assert.Equal("success", (string)measurement.Tags["aspnetcore.authorization.result"]); + Assert.True((bool)measurement.Tags["user.is_authenticated"]); + } + + [Fact] + public async Task Authorize_WithPolicyName_Failure() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var authorizationService = BuildAuthorizationService(meterFactory); + var meter = meterFactory.Meters.Single(); + var user = new ClaimsPrincipal(new ClaimsIdentity([])); // Will fail due to missing required claim + + using var authorizedRequestsCollector = new MetricCollector(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts"); + + // Act + await authorizationService.AuthorizeAsync(user, "Basic"); + + // Assert + Assert.Equal(AuthorizationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("Basic", (string)measurement.Tags["aspnetcore.authorization.policy"]); + Assert.Equal("failure", (string)measurement.Tags["aspnetcore.authorization.result"]); + Assert.False((bool)measurement.Tags["user.is_authenticated"]); + } + + [Fact] + public async Task Authorize_WithPolicyName_PolicyNotFound() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var authorizationService = BuildAuthorizationService(meterFactory); + var meter = meterFactory.Meters.Single(); + var user = new ClaimsPrincipal(new ClaimsIdentity([])); // Will fail due to missing required claim + + using var authorizedRequestsCollector = new MetricCollector(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts"); + + // Act + await Assert.ThrowsAsync(() => authorizationService.AuthorizeAsync(user, "UnknownPolicy")); + + // Assert + Assert.Equal(AuthorizationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("UnknownPolicy", (string)measurement.Tags["aspnetcore.authorization.policy"]); + Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]); + Assert.False((bool)measurement.Tags["user.is_authenticated"]); + Assert.False(measurement.Tags.ContainsKey("aspnetcore.authorization.result")); + } + + [Fact] + public async Task Authorize_WithoutPolicyName_Success() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var authorizationService = BuildAuthorizationService(meterFactory, services => + { + services.AddSingleton(new AlwaysHandler(succeed: true)); + }); + var meter = meterFactory.Meters.Single(); + var user = new ClaimsPrincipal(new ClaimsIdentity([])); + + using var authorizedRequestsCollector = new MetricCollector(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts"); + + // Act + await authorizationService.AuthorizeAsync(user, resource: null, new TestRequirement()); + + // Assert + Assert.Equal(AuthorizationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("success", (string)measurement.Tags["aspnetcore.authorization.result"]); + Assert.False((bool)measurement.Tags["user.is_authenticated"]); + Assert.False(measurement.Tags.ContainsKey("aspnetcore.authorization.policy")); + } + + [Fact] + public async Task Authorize_WithoutPolicyName_Failure() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var authorizationService = BuildAuthorizationService(meterFactory); // Will fail because there is no handler registered + var meter = meterFactory.Meters.Single(); + var user = new ClaimsPrincipal(new ClaimsIdentity([])); + + using var authorizedRequestsCollector = new MetricCollector(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts"); + + // Act + await authorizationService.AuthorizeAsync(user, resource: null, new TestRequirement()); + + // Assert + Assert.Equal(AuthorizationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("failure", (string)measurement.Tags["aspnetcore.authorization.result"]); + Assert.False((bool)measurement.Tags["user.is_authenticated"]); + Assert.False(measurement.Tags.ContainsKey("aspnetcore.authorization.policy")); + } + + [Fact] + public async Task Authorize_WithoutPolicyName_ExceptionThrownInHandler() + { + // Arrange + var meterFactory = new TestMeterFactory(); + var authorizationService = BuildAuthorizationService(meterFactory, services => + { + services.AddSingleton(new AlwaysThrowHandler()); + }); + var meter = meterFactory.Meters.Single(); + var user = new ClaimsPrincipal(new ClaimsIdentity([])); + + using var authorizedRequestsCollector = new MetricCollector(meterFactory, AuthorizationMetrics.MeterName, "aspnetcore.authorization.attempts"); + + // Act + var ex = await Assert.ThrowsAsync(() => authorizationService.AuthorizeAsync(user, resource: null, new TestRequirement())); + + // Assert + Assert.Equal("An error occurred in the authorization handler", ex.Message); + Assert.Equal(AuthorizationMetrics.MeterName, meter.Name); + Assert.Null(meter.Version); + + var measurement = Assert.Single(authorizedRequestsCollector.GetMeasurementSnapshot()); + Assert.Equal(1, measurement.Value); + Assert.Equal("System.InvalidOperationException", (string)measurement.Tags["error.type"]); + Assert.False((bool)measurement.Tags["user.is_authenticated"]); + Assert.False(measurement.Tags.ContainsKey("aspnetcore.authorization.policy")); + Assert.False(measurement.Tags.ContainsKey("aspnetcore.authorization.result")); + } + + private static IAuthorizationService BuildAuthorizationService(TestMeterFactory meterFactory, Action setupServices = null) + { + var services = new ServiceCollection(); + services.AddSingleton(new AuthorizationMetrics(meterFactory)); + services.AddAuthorizationBuilder() + .AddPolicy("Basic", policy => policy.RequireClaim("Permission", "CanViewPage")); + services.AddLogging(); + services.AddOptions(); + setupServices?.Invoke(services); + return services.BuildServiceProvider().GetRequiredService(); + } + + private sealed class AlwaysHandler(bool succeed) : AuthorizationHandler + { + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, TestRequirement requirement) + { + if (succeed) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } + + private sealed class AlwaysThrowHandler : AuthorizationHandler + { + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, TestRequirement requirement) + { + throw new InvalidOperationException("An error occurred in the authorization handler"); + } + } + + private sealed class TestRequirement : IAuthorizationRequirement; +} diff --git a/src/Security/Authorization/test/Microsoft.AspNetCore.Authorization.Test.csproj b/src/Security/Authorization/test/Microsoft.AspNetCore.Authorization.Test.csproj index 11fe6d5589af..8eeed805c2c9 100644 --- a/src/Security/Authorization/test/Microsoft.AspNetCore.Authorization.Test.csproj +++ b/src/Security/Authorization/test/Microsoft.AspNetCore.Authorization.Test.csproj @@ -10,7 +10,12 @@ + + + + + diff --git a/src/Security/startvscode.cmd b/src/Security/startvscode.cmd new file mode 100644 index 000000000000..d403f3028231 --- /dev/null +++ b/src/Security/startvscode.cmd @@ -0,0 +1,3 @@ +@ECHO OFF + +%~dp0..\..\startvscode.cmd %~dp0