diff --git a/build/dependencies.props b/build/dependencies.props index 0d2aabf..36d926a 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -27,7 +27,7 @@ http://github.com/xabaril/Acheve.TestHost Xabaril Contributors Xabaril - 1.4.2 + 2.0.0 Achve.TestHost is a nuget package to improve TestServer experiences. For more information see http://github.com/Xabaril/Acheve.TestHost TestHost;TestServer diff --git a/samples/Sample.IntegrationTests/Specs/ValuesTests.cs b/samples/Sample.IntegrationTests/Specs/ValuesTests.cs index 99c6e9a..6843580 100644 --- a/samples/Sample.IntegrationTests/Specs/ValuesTests.cs +++ b/samples/Sample.IntegrationTests/Specs/ValuesTests.cs @@ -1,4 +1,5 @@ -using System.Net; +using System.Linq; +using System.Net; using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; @@ -20,9 +21,8 @@ public VauesWithDefaultUserTests(TestHostFixture fixture) } [Fact] - public async Task WithRequestBuilder() + public async Task Authorized_User_Should_Get_200() { - // Or you can create a request and assign the identity to the RequestBuilder var response = await _fixture.Server.CreateHttpApiRequest(controller=>controller.Values()) .WithIdentity(Identities.User) .GetAsync(); @@ -31,20 +31,20 @@ public async Task WithRequestBuilder() } [Fact] - public async Task WithEmptyRequestBuilder() + public async Task User_With_No_Claims_Is_Unauthorized() { - // Or you can create a request and assign the identity to the RequestBuilder var response = await _fixture.Server.CreateHttpApiRequest(controller => controller.Values()) .WithIdentity(Identities.Empty) .GetAsync(); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + response.Headers.WwwAuthenticate.Count.Should().Be(1); + response.Headers.WwwAuthenticate.First().Scheme.Should().Be("TestServer"); } [Fact] - public async Task WithRequestBuilderAndSpecificScheme() + public async Task Authorized_User_Should_Get_200_Using_A_Specific_Scheme() { - // Or you can create a request and assign the identity to the RequestBuilder var response = await _fixture.Server.CreateHttpApiRequest(controller => controller.ValuesWithSchema()) .WithIdentity(Identities.User, "Bearer") .GetAsync(); @@ -55,16 +55,17 @@ public async Task WithRequestBuilderAndSpecificScheme() [Fact] public async Task WithRequestBuilderAndSpecificSchemeUnauthorized() { - // Or you can create a request and assign the identity to the RequestBuilder var response = await _fixture.Server.CreateHttpApiRequest(controller => controller.ValuesWithSchema()) - .WithIdentity(Identities.User) + .WithIdentity(Identities.User) // We are not using the expected "Bearer" schema .GetAsync(); response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + response.Headers.WwwAuthenticate.Count.Should().Be(1); + response.Headers.WwwAuthenticate.First().Scheme.Should().Be("Bearer"); } [Fact] - public async Task Anonymous() + public async Task Authentication_Is_Not_Performed_For_Non_Protected_Endpoints() { var response = await _fixture.Server.CreateHttpApiRequest(controller => controller.PublicValues()) .GetAsync(); diff --git a/samples/Sample.IntegrationTests/TestStartup.cs b/samples/Sample.IntegrationTests/TestStartup.cs index a983619..9d21755 100644 --- a/samples/Sample.IntegrationTests/TestStartup.cs +++ b/samples/Sample.IntegrationTests/TestStartup.cs @@ -1,6 +1,5 @@ -using System; -using System.Reflection; -using System.Security.Claims; +using System.Reflection; +using System.Threading.Tasks; using Acheve.AspNetCore.TestHost.Security; using Acheve.TestHost; using Microsoft.AspNetCore.Builder; @@ -14,12 +13,28 @@ public class TestStartup { public void ConfigureServices(IServiceCollection services) { - services.AddAuthentication(TestServerAuthenticationDefaults.AuthenticationScheme) - .AddTestServerAuthentication() - .AddTestServerAuthentication("Bearer", options => + services.AddAuthentication(TestServerDefaults.AuthenticationScheme) + .AddTestServer(options => + { + options.Events = new TestServerEvents + { + OnMessageReceived = context => Task.CompletedTask, + OnTokenValidated = context => Task.CompletedTask, + OnAuthenticationFailed = context => Task.CompletedTask, + OnChallenge = context => Task.CompletedTask + }; + }) + .AddTestServer("Bearer", options => { options.NameClaimType = "name"; options.RoleClaimType = "role"; + options.Events = new TestServerEvents + { + OnMessageReceived = context => Task.CompletedTask, + OnTokenValidated = context => Task.CompletedTask, + OnAuthenticationFailed = context => Task.CompletedTask, + OnChallenge = context => Task.CompletedTask + }; }); var mvcCoreBuilder = services.AddMvcCore() diff --git a/src/Acheve.TestHost/AuthenticationBuilderExtensions.cs b/src/Acheve.TestHost/AuthenticationBuilderExtensions.cs index 5496e12..d81558b 100644 --- a/src/Acheve.TestHost/AuthenticationBuilderExtensions.cs +++ b/src/Acheve.TestHost/AuthenticationBuilderExtensions.cs @@ -6,44 +6,44 @@ namespace Acheve.AspNetCore.TestHost.Security { public static class AuthenticationBuilderExtensions { - public static AuthenticationBuilder AddTestServerAuthentication(this AuthenticationBuilder builder) - => builder.AddTestServerAuthentication( - authenticationScheme: TestServerAuthenticationDefaults.AuthenticationScheme, + public static AuthenticationBuilder AddTestServer(this AuthenticationBuilder builder) + => builder.AddTestServer( + authenticationScheme: TestServerDefaults.AuthenticationScheme, displayName: null, configureOptions: _ => { }); - public static AuthenticationBuilder AddTestServerAuthentication( + public static AuthenticationBuilder AddTestServer( this AuthenticationBuilder builder, string authenticationScheme) - => builder.AddTestServerAuthentication( + => builder.AddTestServer( authenticationScheme: authenticationScheme, displayName: null, configureOptions: _ => { }); - public static AuthenticationBuilder AddTestServerAuthentication( + public static AuthenticationBuilder AddTestServer( this AuthenticationBuilder builder, - Action configureOptions) - => builder.AddTestServerAuthentication( - authenticationScheme: TestServerAuthenticationDefaults.AuthenticationScheme, + Action configureOptions) + => builder.AddTestServer( + authenticationScheme: TestServerDefaults.AuthenticationScheme, displayName: null, configureOptions: configureOptions); - public static AuthenticationBuilder AddTestServerAuthentication( + public static AuthenticationBuilder AddTestServer( this AuthenticationBuilder builder, string authenticationScheme, - Action configureOptions) - => builder.AddTestServerAuthentication( + Action configureOptions) + => builder.AddTestServer( authenticationScheme: authenticationScheme, displayName: null, configureOptions: configureOptions); - public static AuthenticationBuilder AddTestServerAuthentication( + public static AuthenticationBuilder AddTestServer( this AuthenticationBuilder builder, string authenticationScheme, string displayName, - Action configureOptions) + Action configureOptions) { - return builder.AddScheme( + return builder.AddScheme( authenticationScheme: authenticationScheme, displayName: displayName, configureOptions: configureOptions); diff --git a/src/Acheve.TestHost/HttpClientExtensions.cs b/src/Acheve.TestHost/HttpClientExtensions.cs index 447ad92..b702684 100644 --- a/src/Acheve.TestHost/HttpClientExtensions.cs +++ b/src/Acheve.TestHost/HttpClientExtensions.cs @@ -17,11 +17,11 @@ public static class HttpClientExtensions public static HttpClient WithDefaultIdentity(this HttpClient httpClient, IEnumerable claims) { var headerName = - AuthenticationHeaderHelper.GetHeaderName(TestServerAuthenticationDefaults.AuthenticationScheme); + AuthenticationHeaderHelper.GetHeaderName(TestServerDefaults.AuthenticationScheme); httpClient.DefaultRequestHeaders.Add( name: headerName, - value: $"{TestServerAuthenticationDefaults.AuthenticationScheme} {DefautClaimsEncoder.Encode(claims)}"); + value: $"{TestServerDefaults.AuthenticationScheme} {DefautClaimsEncoder.Encode(claims)}"); return httpClient; } diff --git a/src/Acheve.TestHost/RequestBuilderExtensions.cs b/src/Acheve.TestHost/RequestBuilderExtensions.cs index f98c719..894c560 100644 --- a/src/Acheve.TestHost/RequestBuilderExtensions.cs +++ b/src/Acheve.TestHost/RequestBuilderExtensions.cs @@ -17,11 +17,11 @@ public static class RequestBuilderExtensions public static RequestBuilder WithIdentity(this RequestBuilder requestBuilder, IEnumerable claims) { var headerName = - AuthenticationHeaderHelper.GetHeaderName(TestServerAuthenticationDefaults.AuthenticationScheme); + AuthenticationHeaderHelper.GetHeaderName(TestServerDefaults.AuthenticationScheme); requestBuilder.AddHeader( headerName, - $"{TestServerAuthenticationDefaults.AuthenticationScheme} {DefautClaimsEncoder.Encode(claims)}"); + $"{TestServerDefaults.AuthenticationScheme} {DefautClaimsEncoder.Encode(claims)}"); return requestBuilder; } diff --git a/src/Acheve.TestHost/Security/AuthenticationFailedContext.cs b/src/Acheve.TestHost/Security/AuthenticationFailedContext.cs new file mode 100644 index 0000000..7a6b788 --- /dev/null +++ b/src/Acheve.TestHost/Security/AuthenticationFailedContext.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using System; + +namespace Acheve.TestHost +{ + public class AuthenticationFailedContext : ResultContext + { + public AuthenticationFailedContext( + HttpContext context, + AuthenticationScheme scheme, + TestServerOptions options) + : base(context, scheme, options) { } + + public Exception Exception { get; set; } + } +} \ No newline at end of file diff --git a/src/Acheve.TestHost/Security/MessageReceivedContext.cs b/src/Acheve.TestHost/Security/MessageReceivedContext.cs new file mode 100644 index 0000000..c664820 --- /dev/null +++ b/src/Acheve.TestHost/Security/MessageReceivedContext.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Acheve.TestHost +{ + public class MessageReceivedContext : ResultContext + { + public MessageReceivedContext( + HttpContext context, + AuthenticationScheme scheme, + TestServerOptions options) + : base(context, scheme, options) { } + + public string Token { get; set; } + } +} \ No newline at end of file diff --git a/src/Acheve.TestHost/Security/TestServerAuthenticationHandler.cs b/src/Acheve.TestHost/Security/TestServerAuthenticationHandler.cs deleted file mode 100644 index 6509458..0000000 --- a/src/Acheve.TestHost/Security/TestServerAuthenticationHandler.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; -using System.Linq; -using System.Net.Http.Headers; -using System.Security.Claims; -using System.Text.Encodings.Web; -using System.Threading.Tasks; - -namespace Acheve.TestHost -{ - public class TestServerAuthenticationHandler : AuthenticationHandler - { - public TestServerAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder, - ISystemClock clock) - : base(options, logger, encoder, clock) - { - } - - protected override Task HandleAuthenticateAsync() - { - var headerName = AuthenticationHeaderHelper.GetHeaderName(Scheme.Name); - - StringValues authHeaderString; - var existAuthorizationHeader = - Context.Request.Headers.TryGetValue(headerName, out authHeaderString); - - if (existAuthorizationHeader == false) - { - Logger.LogInformation("{Scheme} No {HeaderName} header present", Scheme.Name, headerName); - return Task.FromResult(AuthenticateResult.Fail("No Authorization header present")); - } - - AuthenticationHeaderValue authHeader; - var canParse = AuthenticationHeaderValue.TryParse(authHeaderString[0], out authHeader); - - if (canParse == false) - { - Logger.LogInformation("{Scheme} {HeaderName} header not valid", Scheme.Name, headerName); - return Task.FromResult(AuthenticateResult.Fail("Authorization header not valid")); - } - - var headerClaims = DefautClaimsEncoder.Decode(authHeader.Parameter).ToArray(); - - if (headerClaims.Length == 0) - { - Logger.LogInformation("{Scheme} Invalid claims", Scheme.Name); - return Task.FromResult(AuthenticateResult.Fail("Invalid claims")); - } - - var identity = new ClaimsIdentity( - claims: Options.CommonClaims.Union(headerClaims), - authenticationType: Scheme.Name, - nameType: Options.NameClaimType, - roleType: Options.RoleClaimType); - - var ticket = new AuthenticationTicket( - new ClaimsPrincipal(identity), - new AuthenticationProperties(), - Scheme.Name); - - Logger.LogInformation("{Scheme} Authenticated", Scheme.Name); - return Task.FromResult(AuthenticateResult.Success(ticket)); - } - } -} diff --git a/src/Acheve.TestHost/Security/TestServerChallengeContext.cs b/src/Acheve.TestHost/Security/TestServerChallengeContext.cs new file mode 100644 index 0000000..53540c0 --- /dev/null +++ b/src/Acheve.TestHost/Security/TestServerChallengeContext.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using System; + +namespace Acheve.TestHost +{ + public class TestServerChallengeContext : PropertiesContext + { + public TestServerChallengeContext( + HttpContext context, + AuthenticationScheme scheme, + TestServerOptions options, + AuthenticationProperties properties) + : base(context, scheme, options, properties) { } + + public Exception AuthenticateFailure { get; set; } + + public string Error { get; set; } + + public bool Handled { get; private set; } + + public void HandleResponse() => Handled = true; + } +} \ No newline at end of file diff --git a/src/Acheve.TestHost/Security/TestServerAuthenticationDefaults.cs b/src/Acheve.TestHost/Security/TestServerDefaults.cs similarity index 65% rename from src/Acheve.TestHost/Security/TestServerAuthenticationDefaults.cs rename to src/Acheve.TestHost/Security/TestServerDefaults.cs index 9281210..0ebdd66 100644 --- a/src/Acheve.TestHost/Security/TestServerAuthenticationDefaults.cs +++ b/src/Acheve.TestHost/Security/TestServerDefaults.cs @@ -1,6 +1,6 @@ namespace Acheve.TestHost { - public static class TestServerAuthenticationDefaults + public static class TestServerDefaults { public const string AuthenticationScheme = "TestServer"; } diff --git a/src/Acheve.TestHost/Security/TestServerEvents.cs b/src/Acheve.TestHost/Security/TestServerEvents.cs new file mode 100644 index 0000000..7c87ca6 --- /dev/null +++ b/src/Acheve.TestHost/Security/TestServerEvents.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; + +namespace Acheve.TestHost +{ + public class TestServerEvents + { + public Func OnAuthenticationFailed { get; set; } = context => Task.CompletedTask; + + public Func OnMessageReceived { get; set; } = context => Task.CompletedTask; + + public Func OnTokenValidated { get; set; } = context => Task.CompletedTask; + + public Func OnChallenge { get; set; } = context => Task.CompletedTask; + + public virtual Task AuthenticationFailed(AuthenticationFailedContext context) => OnAuthenticationFailed(context); + + public virtual Task MessageReceived(MessageReceivedContext context) => OnMessageReceived(context); + + public virtual Task TokenValidated(TokenValidatedContext context) => OnTokenValidated(context); + + public virtual Task Challenge(TestServerChallengeContext context) => OnChallenge(context); + } +} \ No newline at end of file diff --git a/src/Acheve.TestHost/Security/TestServerHandler.cs b/src/Acheve.TestHost/Security/TestServerHandler.cs new file mode 100644 index 0000000..719cbd2 --- /dev/null +++ b/src/Acheve.TestHost/Security/TestServerHandler.cs @@ -0,0 +1,144 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; +using System; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; + +namespace Acheve.TestHost +{ + public class TestServerHandler : AuthenticationHandler + { + public TestServerHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) + : base(options, logger, encoder, clock) + { + } + + protected new TestServerEvents Events + { + get => (TestServerEvents)base.Events; + set => base.Events = value; + } + + protected override Task CreateEventsAsync() => Task.FromResult(new TestServerEvents()); + + protected override async Task HandleAuthenticateAsync() + { + string token = null; + try + { + // Give application opportunity to find from a different location, adjust, or reject token + var messageReceivedContext = new MessageReceivedContext(Context, Scheme, Options); + + // event can set the token + await Events.MessageReceived(messageReceivedContext); + if (messageReceivedContext.Result != null) + { + return messageReceivedContext.Result; + } + + // If application retrieved token from somewhere else, use that. + token = messageReceivedContext.Token; + + // If not, use the default location + if (string.IsNullOrEmpty(token)) + { + var headerName = AuthenticationHeaderHelper.GetHeaderName(Scheme.Name); + string authorization = Context.Request.Headers[headerName]; + + // If no authorization header found, nothing to process further + if (string.IsNullOrEmpty(authorization)) + { + return AuthenticateResult.NoResult(); + } + + if (authorization.StartsWith($"{Scheme.Name} ", StringComparison.OrdinalIgnoreCase)) + { + token = authorization.Substring($"{Scheme.Name} ".Length).Trim(); + } + + // If no token found, no further work possible + if (string.IsNullOrEmpty(token)) + { + return AuthenticateResult.NoResult(); + } + } + + var claims = DefautClaimsEncoder.Decode(token).ToArray(); + + var principal = new ClaimsPrincipal( + new ClaimsIdentity( + claims: Options.CommonClaims.Union(claims), + authenticationType: Scheme.Name, + nameType: Options.NameClaimType, + roleType: Options.RoleClaimType)); + + Logger.LogInformation("{Scheme} Authenticated", Scheme.Name); + + var tokenValidatedContext = new TokenValidatedContext(Context, Scheme, Options) + { + Principal = principal + }; + + await Events.TokenValidated(tokenValidatedContext); + if (tokenValidatedContext.Result != null) + { + return tokenValidatedContext.Result; + } + + tokenValidatedContext.Success(); + return tokenValidatedContext.Result; + } + catch (Exception ex) + { + Logger.LogWarning("Authentication failed for schema: {Scheme}. {message}", Scheme.Name, ex.Message); + var authenticationFailedContext = new AuthenticationFailedContext(Context, Scheme, Options) + { + Exception = ex + }; + + await Events.AuthenticationFailed(authenticationFailedContext); + if (authenticationFailedContext.Result != null) + { + return authenticationFailedContext.Result; + } + + throw; + } + } + + protected override async Task HandleChallengeAsync(AuthenticationProperties properties) + { + var authResult = await HandleAuthenticateOnceSafeAsync(); + var eventContext = new TestServerChallengeContext(Context, Scheme, Options, properties) + { + AuthenticateFailure = authResult?.Failure + }; + + // Avoid returning error=invalid_token if the error is not caused by an authentication failure (e.g missing token). + if (eventContext.AuthenticateFailure != null) + { + eventContext.Error = "invalid_token"; + } + + await Events.Challenge(eventContext); + if (eventContext.Handled) + { + return; + } + + Response.StatusCode = 401; + + Response.Headers.Append(HeaderNames.WWWAuthenticate, Scheme.Name); + } + } +} diff --git a/src/Acheve.TestHost/Security/TestServerAuthenticationOptions.cs b/src/Acheve.TestHost/Security/TestServerOptions.cs similarity index 82% rename from src/Acheve.TestHost/Security/TestServerAuthenticationOptions.cs rename to src/Acheve.TestHost/Security/TestServerOptions.cs index c012fa6..dfd4297 100644 --- a/src/Acheve.TestHost/Security/TestServerAuthenticationOptions.cs +++ b/src/Acheve.TestHost/Security/TestServerOptions.cs @@ -4,7 +4,7 @@ namespace Acheve.TestHost { - public class TestServerAuthenticationOptions : AuthenticationSchemeOptions + public class TestServerOptions : AuthenticationSchemeOptions { public IEnumerable CommonClaims { get; set; } = new Claim[0]; diff --git a/src/Acheve.TestHost/Security/TokenValidatedContext.cs b/src/Acheve.TestHost/Security/TokenValidatedContext.cs new file mode 100644 index 0000000..3fc1000 --- /dev/null +++ b/src/Acheve.TestHost/Security/TokenValidatedContext.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Acheve.TestHost +{ + public class TokenValidatedContext : ResultContext + { + public TokenValidatedContext( + HttpContext context, + AuthenticationScheme scheme, + TestServerOptions options) + : base(context, scheme, options) { } + } +} \ No newline at end of file