From 7f9afe43e12088633519ced644680c886e53acb2 Mon Sep 17 00:00:00 2001 From: "Cynthia Z E MacLeod (C3D)" Date: Tue, 12 Sep 2023 18:58:22 +0100 Subject: [PATCH 1/5] Add Admin page to sample web app. --- samples/Sample.WebApp/Pages/Admin.cshtml | 8 ++++++++ samples/Sample.WebApp/Pages/Admin.cshtml.cs | 12 ++++++++++++ .../Sample.WebApp/Pages/Shared/_Layout.cshtml | 3 +++ samples/Sample.WebApp/Program.cs | 9 +++++++-- samples/Sample.WebApp/Security.cs | 18 ++++++++++++++++++ test/Sample.WebApp.Tests/PageTests.cs | 4 ++-- 6 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 samples/Sample.WebApp/Pages/Admin.cshtml create mode 100644 samples/Sample.WebApp/Pages/Admin.cshtml.cs create mode 100644 samples/Sample.WebApp/Security.cs diff --git a/samples/Sample.WebApp/Pages/Admin.cshtml b/samples/Sample.WebApp/Pages/Admin.cshtml new file mode 100644 index 0000000..0ad9101 --- /dev/null +++ b/samples/Sample.WebApp/Pages/Admin.cshtml @@ -0,0 +1,8 @@ +@page +@model Sample.WebApp.Pages.AdminModel +@{ + ViewData["Title"] = "Administration"; +} +

@ViewData["Title"]

+ +

This page is protected by authentication.

diff --git a/samples/Sample.WebApp/Pages/Admin.cshtml.cs b/samples/Sample.WebApp/Pages/Admin.cshtml.cs new file mode 100644 index 0000000..2fde8a8 --- /dev/null +++ b/samples/Sample.WebApp/Pages/Admin.cshtml.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Sample.WebApp.Pages; + +[Authorize(Security.Policy.AdminPolicy)] +public class AdminModel : PageModel +{ + public void OnGet() + { + } +} diff --git a/samples/Sample.WebApp/Pages/Shared/_Layout.cshtml b/samples/Sample.WebApp/Pages/Shared/_Layout.cshtml index 614f8d0..2a2f14c 100644 --- a/samples/Sample.WebApp/Pages/Shared/_Layout.cshtml +++ b/samples/Sample.WebApp/Pages/Shared/_Layout.cshtml @@ -21,6 +21,9 @@ + diff --git a/samples/Sample.WebApp/Program.cs b/samples/Sample.WebApp/Program.cs index d64718b..d42ce93 100644 --- a/samples/Sample.WebApp/Program.cs +++ b/samples/Sample.WebApp/Program.cs @@ -12,6 +12,13 @@ public static void Main(string[] args) // Add services to the container. builder.Services.AddRazorPages(); + builder.Services.AddAuthorization(config => + { + config.AddPolicy(Security.Policy.AdminPolicy, policy => { policy.RequireRole(Security.Role.Admin); }); + }); + + builder.Services.AddSingleton(builder.Configuration); + var app = builder.Build(); var logger = app.Services.GetRequiredService>(); @@ -40,8 +47,6 @@ public static void Main(string[] args) app.UseAuthorization(); - - app.MapRazorPages(); app.MapGet("/BadRequest", ctx => throw new BadHttpRequestException("Bad Request")); diff --git a/samples/Sample.WebApp/Security.cs b/samples/Sample.WebApp/Security.cs new file mode 100644 index 0000000..0135270 --- /dev/null +++ b/samples/Sample.WebApp/Security.cs @@ -0,0 +1,18 @@ +namespace Sample.WebApp; + +public static class Security +{ + + public static class Role + { + public const string Admin = "Admin"; + } + + public static class Policy + { + + public const string AdminPolicy = "AdminPolicy"; + + } + +} \ No newline at end of file diff --git a/test/Sample.WebApp.Tests/PageTests.cs b/test/Sample.WebApp.Tests/PageTests.cs index 91c1738..4939afe 100644 --- a/test/Sample.WebApp.Tests/PageTests.cs +++ b/test/Sample.WebApp.Tests/PageTests.cs @@ -49,9 +49,9 @@ public async Task NavigatePrivacyPage() Assert.NotNull(navItems); - Assert.Equal(2, await navItems.CountAsync()); + Assert.Equal(3, await navItems.CountAsync()); - var link = navItems.Nth(1); + var link = navItems.Nth(2); Assert.NotNull(link); From 467b0daa7a84cf0648ecf21e5787203854abd3ed Mon Sep 17 00:00:00 2001 From: "Cynthia Z E MacLeod (C3D)" Date: Wed, 13 Sep 2023 02:20:27 +0100 Subject: [PATCH 2/5] Add C3D.Extensions.Playwright.AspNetCore.Authentication and tests --- C3D.Extensions.Playwright.AspNetCore.sln | 7 +- samples/Sample.WebApp/Program.cs | 1 + ...laywright.AspNetCore.Authentication.csproj | 35 +++++ .../CredentialValidationExtensions.cs | 12 ++ .../Handlers/BasicAuthHandler.cs | 19 +++ ...ostBuilderBasicAuthenticationExtensions.cs | 24 ++++ ...cationFactoryAuthenticatedClientOptions.cs | 54 ++++++++ .../AspNetCore.Authentication/README.md | 92 +++++++++++++ ...CollectionBasicAuthenticationExtensions.cs | 89 +++++++++++++ .../Utilities/BasicAuthHeaderUtilities.cs | 30 +++++ .../WebApplicationFactoryExtensions.cs | 48 +++++++ .../AspNetCore.Authentication/version.json | 18 +++ .../PlaywrightWebApplicationFactory.cs | 2 +- .../AuthenticationTests.cs | 125 ++++++++++++++++++ .../Sample.WebApp.Tests.csproj | 1 + 15 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 src/C3D/Extensions/Playwright/AspNetCore.Authentication/C3D.Extensions.Playwright.AspNetCore.Authentication.csproj create mode 100644 src/C3D/Extensions/Playwright/AspNetCore.Authentication/CredentialValidationExtensions.cs create mode 100644 src/C3D/Extensions/Playwright/AspNetCore.Authentication/Handlers/BasicAuthHandler.cs create mode 100644 src/C3D/Extensions/Playwright/AspNetCore.Authentication/HostBuilderBasicAuthenticationExtensions.cs create mode 100644 src/C3D/Extensions/Playwright/AspNetCore.Authentication/Options/WebApplicationFactoryAuthenticatedClientOptions.cs create mode 100644 src/C3D/Extensions/Playwright/AspNetCore.Authentication/README.md create mode 100644 src/C3D/Extensions/Playwright/AspNetCore.Authentication/ServiceCollectionBasicAuthenticationExtensions.cs create mode 100644 src/C3D/Extensions/Playwright/AspNetCore.Authentication/Utilities/BasicAuthHeaderUtilities.cs create mode 100644 src/C3D/Extensions/Playwright/AspNetCore.Authentication/WebApplicationFactoryExtensions.cs create mode 100644 src/C3D/Extensions/Playwright/AspNetCore.Authentication/version.json create mode 100644 test/Sample.WebApp.Tests/AuthenticationTests.cs diff --git a/C3D.Extensions.Playwright.AspNetCore.sln b/C3D.Extensions.Playwright.AspNetCore.sln index 9817465..57fbc93 100644 --- a/C3D.Extensions.Playwright.AspNetCore.sln +++ b/C3D.Extensions.Playwright.AspNetCore.sln @@ -60,6 +60,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "github", "github", "{AC8C5C .github\workflows\dotnet.yml = .github\workflows\dotnet.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "C3D.Extensions.Playwright.AspNetCore.Authentication", "src\C3D\Extensions\Playwright\AspNetCore.Authentication\C3D.Extensions.Playwright.AspNetCore.Authentication.csproj", "{A865812B-765F-4B0A-89DB-E5F5FBDDD920}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -82,6 +84,10 @@ Global {DBF13B24-28DF-4B97-8040-2832108C0209}.Debug|Any CPU.Build.0 = Debug|Any CPU {DBF13B24-28DF-4B97-8040-2832108C0209}.Release|Any CPU.ActiveCfg = Release|Any CPU {DBF13B24-28DF-4B97-8040-2832108C0209}.Release|Any CPU.Build.0 = Release|Any CPU + {A865812B-765F-4B0A-89DB-E5F5FBDDD920}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A865812B-765F-4B0A-89DB-E5F5FBDDD920}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A865812B-765F-4B0A-89DB-E5F5FBDDD920}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A865812B-765F-4B0A-89DB-E5F5FBDDD920}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -92,7 +98,6 @@ Global {BBA1AE89-9A65-4482-AF8F-9A491B93A3B2} = {79DBA4F1-9703-4A06-A219-C0E03D99633F} {C9D19B9D-B61C-435A-8E6C-B32A56A76F27} = {87AD0A87-358B-4C6B-832B-04269C7D9AB0} {DBF13B24-28DF-4B97-8040-2832108C0209} = {7257F2A8-EE70-4224-9D5D-1EE29EAA0338} - {AC8C5C5D-E914-4919-AB9F-02BA2FE67EC5} = {DA726315-C6FB-4BCB-A766-4BD32A9643A2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F8A03877-9554-4F94-B4B5-0513AAB4A1B8} diff --git a/samples/Sample.WebApp/Program.cs b/samples/Sample.WebApp/Program.cs index d42ce93..a4b732d 100644 --- a/samples/Sample.WebApp/Program.cs +++ b/samples/Sample.WebApp/Program.cs @@ -45,6 +45,7 @@ public static void Main(string[] args) app.UseRouting(); + app.UseAuthentication(); app.UseAuthorization(); app.MapRazorPages(); diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/C3D.Extensions.Playwright.AspNetCore.Authentication.csproj b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/C3D.Extensions.Playwright.AspNetCore.Authentication.csproj new file mode 100644 index 0000000..2c7149c --- /dev/null +++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/C3D.Extensions.Playwright.AspNetCore.Authentication.csproj @@ -0,0 +1,35 @@ + + + + net6.0;net7.0 + enable + enable + $(AssemblyTitle) Authentication + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/CredentialValidationExtensions.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/CredentialValidationExtensions.cs new file mode 100644 index 0000000..e7687bb --- /dev/null +++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/CredentialValidationExtensions.cs @@ -0,0 +1,12 @@ +using System.Security.Claims; + +namespace Microsoft.AspNetCore.Authentication; + +public static class CredentialValidationExtensions { + public static Claim DefaultRoleClaim(this ResultContext context, string roleName) + where TOptions : AuthenticationSchemeOptions + => new(ClaimTypes.Role, + roleName, + ClaimValueTypes.String, + context.Options.ClaimsIssuer); +} \ No newline at end of file diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Handlers/BasicAuthHandler.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Handlers/BasicAuthHandler.cs new file mode 100644 index 0000000..70429ec --- /dev/null +++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Handlers/BasicAuthHandler.cs @@ -0,0 +1,19 @@ +namespace C3D.Extensions.Playwright.AspNetCore.Authentication.Handlers; + +public class BasicAuthHandler : DelegatingHandler +{ + private readonly string? username; + private readonly string? password; + + public BasicAuthHandler(string? username, string? password) + { + this.username = username; + this.password = password; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Authorization = BasicAuthHeaderUtilities.BasicAuthHeader(username, password); + return base.SendAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/HostBuilderBasicAuthenticationExtensions.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/HostBuilderBasicAuthenticationExtensions.cs new file mode 100644 index 0000000..4b2ac9e --- /dev/null +++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/HostBuilderBasicAuthenticationExtensions.cs @@ -0,0 +1,24 @@ +using idunno.Authentication.Basic; +using Microsoft.Extensions.DependencyInjection; +using System.Security.Claims; + +namespace Microsoft.Extensions.Hosting; + +public static class HostBuilderBasicAuthenticationExtensions +{ + /// + /// Registers a basic authentication scheme that succeeds for password==username and assigns the role of the username + /// + public static IHostBuilder AddBasicAuthentication(this IHostBuilder builder, + Func?>>? roleClaimsFunc = null) => + builder.ConfigureServices(services => services.AddBasicAuthentication(roleClaimsFunc)); + + /// + /// Uses a registered RoleManager from Microsoft.AspNetCore.Identity to lookup the role and add any role specific claims. + /// + /// Class used for the Role + /// The main service collection + /// + public static IHostBuilder AddBasicAuthentication(this IHostBuilder builder) + where TRole : class => builder.ConfigureServices(services => services.AddBasicAuthentication()); +} diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Options/WebApplicationFactoryAuthenticatedClientOptions.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Options/WebApplicationFactoryAuthenticatedClientOptions.cs new file mode 100644 index 0000000..4bf57ce --- /dev/null +++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Options/WebApplicationFactoryAuthenticatedClientOptions.cs @@ -0,0 +1,54 @@ +using C3D.Extensions.Playwright.AspNetCore.Authentication.Handlers; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Mvc.Testing.Handlers; + +namespace C3D.Extensions.Playwright.AspNetCore.Authentication.Options; + +public class WebApplicationFactoryAuthenticatedClientOptions : WebApplicationFactoryClientOptions +{ + public WebApplicationFactoryAuthenticatedClientOptions() + { + } + + // Copy constructor + internal WebApplicationFactoryAuthenticatedClientOptions(WebApplicationFactoryClientOptions clientOptions) + { + BaseAddress = clientOptions.BaseAddress; + AllowAutoRedirect = clientOptions.AllowAutoRedirect; + MaxAutomaticRedirections = clientOptions.MaxAutomaticRedirections; + HandleCookies = clientOptions.HandleCookies; + + if (clientOptions is WebApplicationFactoryAuthenticatedClientOptions authOptions) + { + UserName = authOptions.UserName; + Password = authOptions.Password; + Handlers = authOptions.Handlers; + } + } + + public string? UserName { get; set; } + public string? Password { get; set; } + + public IEnumerable Handlers { get; set; } = Enumerable.Empty(); + + internal protected virtual DelegatingHandler[] CreateHandlers() + { + return CreateHandlersCore().Concat(Handlers).ToArray(); + + IEnumerable CreateHandlersCore() + { + if (!string.IsNullOrEmpty(UserName) || !string.IsNullOrEmpty(Password)) + { + yield return new BasicAuthHandler(UserName, Password); + } + if (AllowAutoRedirect) + { + yield return new RedirectHandler(MaxAutomaticRedirections); + } + if (HandleCookies) + { + yield return new CookieContainerHandler(); + } + } + } +} diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/README.md b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/README.md new file mode 100644 index 0000000..67064d5 --- /dev/null +++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/README.md @@ -0,0 +1,92 @@ +# C3D.Extensions.Playwright.AspNetCore.Authentication + +An extension to `Microsoft.AspNetCore.Mvc.Testing` and `C3D.Extensions.Playwright.AspNetCore` which adds authentication support to the `WebApplicationFactory`. + +This allows you to write Playwright browser based tests that use and test authentication. + +The authentication uses the [`idunno.Authentication.Basic`](https://github.com/blowdart/idunno.Authentication) package to provide 'Basic Authentication'. +This should not (normally) be used in a production environement, but provides an easy to use mechansim to generate authentication tokens on the server side, +and matching credentials on the client side. + +## Setup + +When creating a 'test' host using `IHostBuilder`, you can use the `AddBasicAuthentication` extension method to enable the embedded `idunno.Authentication.Basic` authentication system. +```cs + builder.AddBasicAuthentication(); +``` + +This will generate a claims user when the username == the password. +The claims will include the username, displayname and role (which will all be the same). + +You can add an optional function to add additional claims as a parameter to the `AddBasicAuthentication` call. +The function takes `ValidateCredentialsContext` and string parameters representing the context and the username/role (which are equal). +It is an async function that returns `Task?>`. This allows you to not return any additional claims. + +There is an overload that takes the `TRole` type of the registered RoleManager from Microsoft.AspNetCore.Identity to lookup the role and add any role specific claims. +This can be called as + +```cs + builder.AddBasicAuthentication(); +``` + +Obviously this is not secure in any way, and should only be used in a test scenario, e.g. during Playwright testing. + +## Usage + +When you have a host that is setup to support BasicAuthention, you can then create a Playwright browser context (effectively an in-private isolated session), which will include the appropriate authentication header. +There is an extension method to the `PlaywrightFixture` called `CreateAuthorisedPlaywrightContextPageAsync` which takes the rolename to use. +This creates a new context and page (which should be disposed at the end of the test), with a Basic Authentication header with the username and password equal to the passed in role. + + +### Sample + +An example of using this with `XUnit` is available in the github repository. + +```cs +public class PlaywrightAuthenticationFixture : PlaywrightFixture +{ + public PlaywrightAuthenticationFixture(IMessageSink output) : base(output) { } + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.AddBasicAuthentication(); + return base.CreateHost(builder); + } +} +``` + +```cs +public class AuthenticationTests : IClassFixture +{ + private readonly PlaywrightFixture webApplication; + + public AuthenticationTests(PlaywrightAuthenticationFixture webApplication, ITestOutputHelper outputHelper) + { + this.webApplication = webApplication; + } + + [Fact] + public async Task RandomTest() + { + await using var context = await webApplication.CreateAuthorisedPlaywrightContextPageAsync("SomeRole"); + var page = context.Page; + + await page.GotoAsync("/Somewhere"); + } +} +``` + +## HttpClient + +While this package is primarliy designed for use with Playwright, you may also require to use the `HttpClient` features of `Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`. + +This package provides a number of overloads of the basic `CreateClient` method on `WebApplicationFactory`. +The first takes a function to configure a `WebApplicationFactoryAuthenticatedClientOptions` which is an augmented version of `WebApplicationFactoryClientOptions` and defaults to (a copy of) the settings from `WebApplicationFactory.ClientOptions`. +The additional properties `UserName`, `Password` and `Handlers` are available. Setting either (or both) of the authentication properties results in an `AuthenticationHeaderValue` being added to each request made. + +`Handlers` allows you to add additional middleware handlers into the configuration. +This allows you to use the Authentication, Redirection, and Cookie handlers at the same time as custom ones without having to manually add them all. + +There are 2 additional overloads of `CreateClient`, one which takes `username` and `password` as parameters, and another which takes a single string `role` which is used as both `username` and `password`. +These are syntactic sugar over the configuration method mentioned previously. + diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/ServiceCollectionBasicAuthenticationExtensions.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/ServiceCollectionBasicAuthenticationExtensions.cs new file mode 100644 index 0000000..2470c71 --- /dev/null +++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/ServiceCollectionBasicAuthenticationExtensions.cs @@ -0,0 +1,89 @@ +using idunno.Authentication.Basic; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using System.Security.Claims; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class ServiceCollectionBasicAuthenticationExtensions +{ + /// + /// Registers a basic authentication scheme that succeeds for password==username and assigns the role of the username + /// + public static IServiceCollection AddBasicAuthentication(this IServiceCollection services, + Func?>>? roleClaimsFunc = null) + => services + .AddAuthentication(BasicAuthenticationDefaults.AuthenticationScheme) + .AddBasic(options => + { + options.Realm = "Test Realm"; + options.AllowInsecureProtocol = true; + options.Events = new BasicAuthenticationEvents + { + OnAuthenticationFailed = context => + { + var loggerFactory = context.HttpContext.RequestServices.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + logger.LogError(context.Exception, "Authentication failed"); + return Task.CompletedTask; + }, + OnValidateCredentials = async context => + { + if (context.Username == context.Password) + { + + var userClaims = new[] + { + // Set UserName + new Claim( + ClaimTypes.NameIdentifier, + context.Username, + ClaimValueTypes.String, + context.Options.ClaimsIssuer), + // Set DisplayName + new Claim( + ClaimTypes.Name, + context.Username, + ClaimValueTypes.String, + context.Options.ClaimsIssuer) + }; + + + var roleClaims = roleClaimsFunc is null ? + Enumerable.Repeat(context.DefaultRoleClaim(context.Username), 1) : + (await roleClaimsFunc.Invoke(context, context.Username) ?? Enumerable.Empty()); + + context.Principal = new ClaimsPrincipal( + new ClaimsIdentity(userClaims.Concat(roleClaims), context.Scheme.Name)); + context.Success(); + } + } + }; + }) + .Services; + + /// + /// Uses a registered RoleManager from Microsoft.AspNetCore.Identity to lookup the role and add any role specific claims. + /// + /// Class used for the Role + /// The main service collection + /// + public static IServiceCollection AddBasicAuthentication(this IServiceCollection services) + where TRole : class => services.AddBasicAuthentication(async (context, roleName) => + { + // This bit is probably overkill for most testing needs. + // Simply adding the role, regardless of whether it exists, to the claim is enough for most scenarios. + // But, in case there is anything custom added to the Role under RoleManager, we lookup the role and any custom claims. + var roleManager = context.HttpContext.RequestServices.GetRequiredService>(); + var role = await roleManager.FindByNameAsync(roleName); + IList roleClaims = (role is not null ? await roleManager.GetClaimsAsync(role) : null) ?? Enumerable.Empty().ToList(); + if (role is not null) + { + roleClaims.Add(context.DefaultRoleClaim(roleName)); + } + return roleClaims; + }); + +} diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Utilities/BasicAuthHeaderUtilities.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Utilities/BasicAuthHeaderUtilities.cs new file mode 100644 index 0000000..3a2e5f3 --- /dev/null +++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Utilities/BasicAuthHeaderUtilities.cs @@ -0,0 +1,30 @@ +using idunno.Authentication.Basic; +using System.Net.Http.Headers; +using System.Text; + +namespace C3D.Extensions.Playwright.AspNetCore.Authentication; + +public static class BasicAuthHeaderUtilities +{ + public static AuthenticationHeaderValue BasicAuthHeader(string? username, string? password) + { + var rawUserPassword = Encoding.UTF8.GetBytes($"{username ?? string.Empty}:{password ?? string.Empty}"); + var base64UserPassword = Convert.ToBase64String(rawUserPassword); + return new AuthenticationHeaderValue(scheme: BasicAuthenticationDefaults.AuthenticationScheme, base64UserPassword); + } + + private const string Authorization = "Authorization"; + private static KeyValuePair BasicAuthHeaderPair(string? username, string? password) => + new(Authorization, BasicAuthHeader(username, password).ToString()); + + public static IEnumerable> BasicAuthHeaders(IEnumerable> headers, + string username, string password) + => headers + .Where(x => x.Key != Authorization) + .Append(BasicAuthHeaderPair(username, password)); + + public static IEnumerable> BasicAuthHeaders(string username, string password) + { + yield return BasicAuthHeaderPair(username, password); + } +} diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/WebApplicationFactoryExtensions.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/WebApplicationFactoryExtensions.cs new file mode 100644 index 0000000..c0d3600 --- /dev/null +++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/WebApplicationFactoryExtensions.cs @@ -0,0 +1,48 @@ +using C3D.Extensions.Playwright.AspNetCore.Authentication; +using C3D.Extensions.Playwright.AspNetCore.Authentication.Options; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Playwright; +using System.Reflection.PortableExecutable; + +namespace C3D.Extensions.Playwright.AspNetCore; + +public static class WebApplicationFactoryExtensions +{ + public static BrowserNewContextOptions WithBasicAuthentication(this BrowserNewContextOptions options, string username, string password) + { + options.ExtraHTTPHeaders = options.ExtraHTTPHeaders is null + ? BasicAuthHeaderUtilities.BasicAuthHeaders(username, password) + : BasicAuthHeaderUtilities.BasicAuthHeaders(options.ExtraHTTPHeaders, username, password); + return options; + } + + public static async Task CreateAuthorisedPlaywrightContextPageAsync( + this PlaywrightWebApplicationFactory fixture, string username, string password, Action? contextOptions = null) + where TProgram : class => + await fixture.CreatePlaywrightContextPageAsync(contextOptions: options => { + contextOptions?.Invoke(options); + options.WithBasicAuthentication(username,password); + }); + + public static Task CreateAuthorisedPlaywrightContextPageAsync( + this PlaywrightWebApplicationFactory fixture, string role, Action? contextOptions = null) + where TProgram : class => fixture.CreateAuthorisedPlaywrightContextPageAsync(role, role, contextOptions); + + public static HttpClient CreateClient(this WebApplicationFactory fixture, Action options) + where TProgram : class + { + var clientOptions = new WebApplicationFactoryAuthenticatedClientOptions(fixture.ClientOptions); + options?.Invoke(clientOptions); + return fixture.CreateDefaultClient(clientOptions.BaseAddress, clientOptions.CreateHandlers()); + } + + public static HttpClient CreateClient(this WebApplicationFactory fixture, string username, string password) + where TProgram : class => fixture.CreateClient(options => + { + options.UserName = username; + options.Password = password; + }); + + public static HttpClient CreateClient(this WebApplicationFactory fixture, string role) + where TProgram : class => fixture.CreateClient(role, role); +} diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/version.json b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/version.json new file mode 100644 index 0000000..1b9d97f --- /dev/null +++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/version.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/v3.3.37/src/NerdBank.GitVersioning/version.schema.json", + "version": "0.1", + "pathFilters": [ ".", "../Directory.Build.props", "../Directory.Build.targets" ], + "publicReleaseRefSpec": [ + "^refs/heads/main$", // we release out of main + "^refs/heads/rel/v\\d+\\.\\d+" // we also release tags starting with rel/N.N + ], + "nugetPackageVersion": { + "semVer": 2 + }, + "cloudBuild": { + "buildNumber": { + "enabled": true + } + }, + "buildNumberOffset": 1 +} \ No newline at end of file diff --git a/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs b/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs index e4589f4..e21c565 100644 --- a/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs +++ b/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs @@ -17,7 +17,7 @@ public class PlaywrightWebApplicationFactory : WebApplicationFactory +{ + public PlaywrightAuthenticationFixture(IMessageSink output) : base(output) { } + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.AddBasicAuthentication(); + return base.CreateHost(builder); + } +} + +public class AuthenticationTests : IClassFixture +{ + private readonly PlaywrightFixture webApplication; + private readonly ITestOutputHelper outputHelper; + + public AuthenticationTests(PlaywrightAuthenticationFixture webApplication, ITestOutputHelper outputHelper) + { + this.webApplication = webApplication; + this.outputHelper = outputHelper; + } + + private void WriteFunctionName([CallerMemberName] string? caller = null) => outputHelper.WriteLine(caller); + + [Fact] + public async Task AnonymousCantAccessAdmin() + { + WriteFunctionName(); + + await using var context = await webApplication.CreatePlaywrightContextPageAsync(); + var page = context.Page; + + // N.B. These tests work differently if the browser is not headless! + IResponse? response = await page.GotoAsync("/Admin"); + + Assert.NotNull(response); + Assert.Equal((int)HttpStatusCode.Unauthorized, response.Status); + } + + [Fact] + public async Task NonAdminCantAdmin() + { + WriteFunctionName(); + + await using var context = await webApplication.CreateAuthorisedPlaywrightContextPageAsync("user"); + var page = context.Page; + + IResponse? response = await page.GotoAsync("/Admin"); + + Assert.NotNull(response); + Assert.Equal((int)HttpStatusCode.Forbidden, response.Status); + } + + [Fact] + public async Task AdminCanAdmin() + { + WriteFunctionName(); + + await using var context = await webApplication.CreateAuthorisedPlaywrightContextPageAsync(Security.Role.Admin); + var page = context.Page; + + await page.GotoAsync("/Admin"); + + Assert.Equal("Administration", await page.TitleAsync()); + Assert.Equal(webApplication.Uri + "/Admin", page.Url); + } + + [Fact] + public async Task AnonymousClientCantAccessAdmin() + { + WriteFunctionName(); + + using var client = webApplication.CreateClient(); + + var response = await client.GetAsync("/Admin"); + + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task NonAdminClientCantAdmin() + { + WriteFunctionName(); + + using var client = webApplication.CreateClient("user"); + + var response = await client.GetAsync("/Admin"); + + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task AdminClientCanAdmin() + { + WriteFunctionName(); + + using var client = webApplication.CreateClient(Security.Role.Admin); + + var response = await client.GetAsync("/Admin"); + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var ct = response.Content.Headers.ContentType; + Assert.NotNull(ct); + Assert.Equal("text/html", ct.MediaType); + Assert.Equal("utf-8", ct.CharSet); + + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Contains("Administration", body); + + } +} diff --git a/test/Sample.WebApp.Tests/Sample.WebApp.Tests.csproj b/test/Sample.WebApp.Tests/Sample.WebApp.Tests.csproj index 9d3da80..f93dd90 100644 --- a/test/Sample.WebApp.Tests/Sample.WebApp.Tests.csproj +++ b/test/Sample.WebApp.Tests/Sample.WebApp.Tests.csproj @@ -38,6 +38,7 @@ + From 2e45299c387fee9930cc8ffebfe003635749170a Mon Sep 17 00:00:00 2001 From: "Cynthia Z E MacLeod (C3D)" Date: Wed, 13 Sep 2023 02:31:27 +0100 Subject: [PATCH 3/5] Add C3D.Extensions.Playwright.AspNetCore.Authentication to readme --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index d2c352e..ce2dfe6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,13 @@ A set of Playwright related packages designed to help unit testing of AspNetCore An extension to `Microsoft.AspNetCore.Mvc.Testing` which adds `Microsoft.Playwright` support to the `WebApplicationFactory` (and keeps the existing HttpClient infrastucture). +## C3D.Extensions.Playwright.AspNetCore.Authentication +[![NuGet package](https://img.shields.io/nuget/v/C3D.Extensions.Playwright.AspNetCore.Authentication.svg)](https://nuget.org/packages/C3D.Extensions.Playwright.AspNetCore.Authentication) +[![NuGet downloads](https://img.shields.io/nuget/dt/C3D.Extensions.Playwright.AspNetCore.Authentication.svg)](https://nuget.org/packages/C3D.Extensions.Playwright.AspNetCore.Authentication) + +Adds basic authentication support to `C3D.Extensions.Playwright.AspNetCore` to allow easy unit testing of secure AspNetCore web applications. + + ## C3D.Extensions.Playwright.AspNetCore.Xunit [![NuGet package](https://img.shields.io/nuget/v/C3D.Extensions.Playwright.AspNetCore.Xunit.svg)](https://nuget.org/packages/C3D.Extensions.Playwright.AspNetCore.Xunit) [![NuGet downloads](https://img.shields.io/nuget/dt/C3D.Extensions.Playwright.AspNetCore.Xunit.svg)](https://nuget.org/packages/C3D.Extensions.Playwright.AspNetCore.Xunit) From 5506c1441cbf3f7d4b8dd8a20476f55c3bcb307d Mon Sep 17 00:00:00 2001 From: "Cynthia Z E MacLeod (C3D)" Date: Sat, 16 Sep 2023 21:41:03 +0100 Subject: [PATCH 4/5] Fix solution tree --- C3D.Extensions.Playwright.AspNetCore.sln | 2 ++ 1 file changed, 2 insertions(+) diff --git a/C3D.Extensions.Playwright.AspNetCore.sln b/C3D.Extensions.Playwright.AspNetCore.sln index 57fbc93..f459765 100644 --- a/C3D.Extensions.Playwright.AspNetCore.sln +++ b/C3D.Extensions.Playwright.AspNetCore.sln @@ -98,6 +98,8 @@ Global {BBA1AE89-9A65-4482-AF8F-9A491B93A3B2} = {79DBA4F1-9703-4A06-A219-C0E03D99633F} {C9D19B9D-B61C-435A-8E6C-B32A56A76F27} = {87AD0A87-358B-4C6B-832B-04269C7D9AB0} {DBF13B24-28DF-4B97-8040-2832108C0209} = {7257F2A8-EE70-4224-9D5D-1EE29EAA0338} + {AC8C5C5D-E914-4919-AB9F-02BA2FE67EC5} = {DA726315-C6FB-4BCB-A766-4BD32A9643A2} + {A865812B-765F-4B0A-89DB-E5F5FBDDD920} = {79DBA4F1-9703-4A06-A219-C0E03D99633F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F8A03877-9554-4F94-B4B5-0513AAB4A1B8} From 8ee16abe57e2dcb4450f1eeca04148ef2d51b4eb Mon Sep 17 00:00:00 2001 From: "Cynthia Z E MacLeod (C3D)" Date: Mon, 18 Sep 2023 22:40:14 +0100 Subject: [PATCH 5/5] Add net 8 to authentication --- ...C3D.Extensions.Playwright.AspNetCore.Authentication.csproj | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/C3D.Extensions.Playwright.AspNetCore.Authentication.csproj b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/C3D.Extensions.Playwright.AspNetCore.Authentication.csproj index 2c7149c..b786d71 100644 --- a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/C3D.Extensions.Playwright.AspNetCore.Authentication.csproj +++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/C3D.Extensions.Playwright.AspNetCore.Authentication.csproj @@ -1,7 +1,7 @@  - net6.0;net7.0 + net6.0;net7.0;net8.0 enable enable $(AssemblyTitle) Authentication @@ -26,8 +26,6 @@ - -