Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/cypress antiforgery utils #22

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/DfE.CoreLibs.Security/Cypress/CypressAntiforgeryExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using DfE.CoreLibs.Security.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;

namespace DfE.CoreLibs.Security.Cypress
{
/// <summary>
/// Provides extension methods for registering Cypress-related AntiForgery handling in the MVC pipeline.
/// </summary>
public static class CypressAntiForgeryExtensions
{
/// <summary>
/// Registers the <see cref="ICypressRequestChecker"/> and <see cref="CypressAwareAntiForgeryFilter"/> services,
/// and inserts the Cypress AntiForgery filter globally into the MVC pipeline.
/// </summary>
/// <remarks>
/// <para>
/// By calling this method, any incoming request recognized as a Cypress request
/// (per <see cref="ICypressRequestChecker"/>) will skip AntiForgery validation.
/// Other requests will require AntiForgery validation as normal.
/// </para>
/// </remarks>
/// <returns>
/// The same <see cref="IMvcBuilder"/> so further MVC configuration can be chained.
/// </returns>
public static IMvcBuilder AddCypressAntiForgeryHandling(this IMvcBuilder mvcBuilder)
{
mvcBuilder.Services.AddScoped<ICypressRequestChecker, CypressRequestChecker>();

mvcBuilder.Services.AddScoped<CypressAwareAntiForgeryFilter>();

mvcBuilder.Services.PostConfigure<MvcOptions>(options =>
{
options.Filters.AddService<CypressAwareAntiForgeryFilter>();
});

return mvcBuilder;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using DfE.CoreLibs.Security.Interfaces;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;

namespace DfE.CoreLibs.Security.Cypress
{
public static class CypressAuthenticationExtensions
{
/// <summary>
/// Adds a PolicyScheme ("MultiAuth" by default) that checks for the Cypress request
/// and either forwards to the "cypressScheme" or the "fallbackScheme".
/// </summary>
/// <param name="builder">The <see cref="AuthenticationBuilder"/> from AddAuthentication().</param>
/// <param name="policyScheme">Name of the policy scheme (default "MultiAuth").</param>
/// <param name="displayName">Display name for the policy scheme in UI.</param>
/// <param name="cypressScheme">The scheme to forward to if it's a valid Cypress request (default "CypressAuth").</param>
/// <param name="fallbackScheme">The scheme to fallback to if not valid (default CookieAuthenticationDefaults.AuthenticationScheme).</param>
public static AuthenticationBuilder AddCypressMultiAuthentication(
this AuthenticationBuilder builder,
string policyScheme = "MultiAuth",
string displayName = "Multi Auth",
string cypressScheme = "CypressAuth",
string fallbackScheme = null)

Check warning on line 24 in src/DfE.CoreLibs.Security/Cypress/CypressAuthenticationExtensions.cs

View workflow job for this annotation

GitHub Actions / build-and-test / build-and-test

Cannot convert null literal to non-nullable reference type.

Check warning on line 24 in src/DfE.CoreLibs.Security/Cypress/CypressAuthenticationExtensions.cs

View workflow job for this annotation

GitHub Actions / build-and-test / build-and-test

Cannot convert null literal to non-nullable reference type.
{
// Default fallback scheme to Cookies if not provided
if (string.IsNullOrEmpty(fallbackScheme))
{
fallbackScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}

// Ensure our CypressRequestChecker is registered
builder.Services.AddScoped<ICypressRequestChecker, CypressRequestChecker>();

builder.AddPolicyScheme(policyScheme, displayName, options =>
{
options.ForwardDefaultSelector = context =>
{
var checker = context.RequestServices.GetRequiredService<ICypressRequestChecker>();

var isCypress = checker.IsCypressRequest(context);

if (isCypress)
{
return cypressScheme;
}

// Otherwise fallback
return fallbackScheme;
};
});

// Add the custom scheme
builder.AddScheme<AuthenticationSchemeOptions, CypressAuthenticationHandler>(cypressScheme, _ => { });

return builder;
}
}
}
59 changes: 59 additions & 0 deletions src/DfE.CoreLibs.Security/Cypress/CypressAuthenticationHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using DfE.CoreLibs.Security.Utils;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;

namespace DfE.CoreLibs.Security.Cypress
{
/// <summary>
/// An authentication handler that builds a claims principal from
/// custom headers, intended for testing or "Cypress" scenarios.
/// </summary>
public class CypressAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IHttpContextAccessor httpContextAccessor)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{

var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
return Task.FromResult(AuthenticateResult.Fail("No HttpContext"));
}

var userId = httpContext.Request.Headers["x-user-context-id"].FirstOrDefault() ?? Guid.NewGuid().ToString();

var headers = httpContext.Request.Headers
.Select(x => new KeyValuePair<string, string>(x.Key, x.Value.First()))

Check warning on line 35 in src/DfE.CoreLibs.Security/Cypress/CypressAuthenticationHandler.cs

View workflow job for this annotation

GitHub Actions / build-and-test / build-and-test

Possible null reference argument for parameter 'value' in 'KeyValuePair<string, string>.KeyValuePair(string key, string value)'.

Check warning on line 35 in src/DfE.CoreLibs.Security/Cypress/CypressAuthenticationHandler.cs

View workflow job for this annotation

GitHub Actions / build-and-test / build-and-test

Indexing at 0 should be used instead of the "Enumerable" extension method "First" (https://rules.sonarsource.com/csharp/RSPEC-6608)

Check warning on line 35 in src/DfE.CoreLibs.Security/Cypress/CypressAuthenticationHandler.cs

View workflow job for this annotation

GitHub Actions / build-and-test / build-and-test

Possible null reference argument for parameter 'value' in 'KeyValuePair<string, string>.KeyValuePair(string key, string value)'.

Check warning on line 35 in src/DfE.CoreLibs.Security/Cypress/CypressAuthenticationHandler.cs

View workflow job for this annotation

GitHub Actions / build-and-test / build-and-test

Indexing at 0 should be used instead of the "Enumerable" extension method "First" (https://rules.sonarsource.com/csharp/RSPEC-6608)
.ToArray();

var userInfo = ParsedUserContext.FromHeaders(headers);
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, userInfo.Name),

Check warning on line 41 in src/DfE.CoreLibs.Security/Cypress/CypressAuthenticationHandler.cs

View workflow job for this annotation

GitHub Actions / build-and-test / build-and-test

Dereference of a possibly null reference.

Check warning on line 41 in src/DfE.CoreLibs.Security/Cypress/CypressAuthenticationHandler.cs

View workflow job for this annotation

GitHub Actions / build-and-test / build-and-test

Dereference of a possibly null reference.
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Authentication, "true")
};

foreach (var claim in userInfo.Roles)
{
claims.Add(new Claim(ClaimTypes.Role, claim));
}

var claimsIdentity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(claimsIdentity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);

return Task.FromResult(AuthenticateResult.Success(ticket));
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Http;

namespace DfE.CoreLibs.Security.Cypress
{
public class CypressAwareAntiForgeryOptions
{
/// <summary>
/// A function that, given the current <see cref="HttpContext"/>,
/// returns <c>true</c> if antiforgery should be skipped, or <c>false</c> otherwise.
/// </summary>
public Func<HttpContext, bool> ShouldSkipAntiforgery { get; set; }
= _ => false; // Default: never skip
}
}
48 changes: 48 additions & 0 deletions src/DfE.CoreLibs.Security/Cypress/CypressAwareAntiforgeryFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using DfE.CoreLibs.Security.Interfaces;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace DfE.CoreLibs.Security.Cypress
{
/// <summary>
/// An authorization filter that enforces AntiForgery validation for all requests,
/// except for those recognized as valid Cypress requests or for which the
/// configured predicate says to skip.
/// </summary>
public class CypressAwareAntiForgeryFilter(
IAntiforgery antiforgery,
ILogger<CypressAwareAntiForgeryFilter> logger,
ICypressRequestChecker cypressChecker,
IOptions<CypressAwareAntiForgeryOptions> optionsAccessor)
: IAsyncAuthorizationFilter
{
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
if (optionsAccessor.Value.ShouldSkipAntiforgery(context.HttpContext))
{
logger.LogInformation("Skipping antiforgery due to ShouldSkipAntiforgery predicate.");
return;
}

var method = context.HttpContext.Request.Method;
if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method) ||
HttpMethods.IsOptions(method) || HttpMethods.IsTrace(method))
{
return;
}

var isCypress = cypressChecker.IsCypressRequest(context.HttpContext);
if (isCypress)
{
logger.LogInformation("Skipping antiforgery for Cypress request.");
return;
}

logger.LogInformation("Enforcing antiforgery for non-Cypress request.");
await antiforgery.ValidateRequestAsync(context.HttpContext);
}
}
}
49 changes: 49 additions & 0 deletions src/DfE.CoreLibs.Security/Cypress/CypressRequestChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using DfE.CoreLibs.Security.Interfaces;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Net.Http.Headers;

namespace DfE.CoreLibs.Security.Cypress
{
/// <inheritdoc />
public class CypressRequestChecker(IHostEnvironment env, IConfiguration config) : ICypressRequestChecker
{
/// <inheritdoc />
public bool IsCypressRequest(HttpContext httpContext)
{
// Read config and environment
var secret = config["CypressTestSecret"];
var environmentName = env.EnvironmentName;

// Read headers
var authHeaderValue = httpContext.Request.Headers[HeaderNames.Authorization]
.ToString()
.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);

var userContextHeaderValue = httpContext.Request.Headers["x-cypress-user"].ToString();

// Must match "cypressUser"
if (!userContextHeaderValue.Equals("cypressUser", StringComparison.OrdinalIgnoreCase))
{
return false;
}

// Only allow Dev/Staging
if (!environmentName.Equals("Development", StringComparison.OrdinalIgnoreCase) &&
!environmentName.Equals("Staging", StringComparison.OrdinalIgnoreCase) &&
!environmentName.Equals("Test", StringComparison.OrdinalIgnoreCase))
{
return false;
}

// Compare secrets
if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(authHeaderValue))
{
return false;
}

return authHeaderValue.Equals(secret, StringComparison.Ordinal);
}
}
}
24 changes: 24 additions & 0 deletions src/DfE.CoreLibs.Security/Interfaces/ICypressRequestChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Http;

namespace DfE.CoreLibs.Security.Interfaces
{
/// <summary>
/// Represents a service that detects whether an incoming HTTP request
/// is a valid "Cypress" request (based on environment, headers, etc.).
/// </summary>
public interface ICypressRequestChecker
{
/// <summary>
/// Determines whether the specified <see cref="HttpContext"/>
/// represents a valid Cypress request.
/// </summary>
/// <param name="httpContext">
/// The current HTTP request context from which to read headers, config values, etc.
/// </param>
/// <returns>
/// <c>true</c> if the request is recognized as a valid Cypress request;
/// otherwise, <c>false</c>.
/// </returns>
bool IsCypressRequest(HttpContext httpContext);
}
}
48 changes: 48 additions & 0 deletions src/DfE.CoreLibs.Security/Utils/ParsedUserContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
namespace DfE.CoreLibs.Security.Utils
{
/// <summary>
/// Represents user context information parsed from HTTP headers, including a username and roles
/// </summary>
/// <param name="Name">
/// The user's name, extracted from the "x-user-context-name" header.
/// </param>
/// <param name="Roles">
/// A collection of roles extracted from headers starting with "x-user-context-role-".
/// </param>
public record ParsedUserContext(
string Name,
IReadOnlyList<string> Roles)
{
public const string NameHeaderKey = "x-user-context-name";
public const string RoleHeaderKeyPrefix = "x-user-context-role-";

/// <summary>
/// Creates a new <see cref="ParsedUserContext"/> by extracting user information
/// from the specified collection of header key/value pairs.
/// </summary>
/// <param name="headers">
/// A collection of key/value headers that may contain user context information.
/// </param>
/// <returns>
/// A new <see cref="ParsedUserContext"/> if valid user info is found (i.e., a name
/// and at least one role); otherwise <c>null</c>.
/// </returns>
public static ParsedUserContext? FromHeaders(IEnumerable<KeyValuePair<string, string>> headers)
{
// Extract name from "x-user-context-name"
var name = headers.FirstOrDefault(x => x.Key.Equals(NameHeaderKey, StringComparison.InvariantCultureIgnoreCase)).Value;

// Extract roles from any header starting with "x-user-context-role-"
var roles = headers
.Where(h => h.Key.StartsWith(RoleHeaderKeyPrefix, StringComparison.OrdinalIgnoreCase))
.Select(h => h.Value)
.ToArray();

// If missing name/roles, return null
if (string.IsNullOrWhiteSpace(name) || roles.Length == 0)
return null;

return new ParsedUserContext(name, roles);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using AutoFixture;
using AutoFixture.AutoNSubstitute;
using DfE.CoreLibs.Security.Cypress;
using DfE.CoreLibs.Security.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;

namespace DfE.CoreLibs.Security.Tests.CypressTests
{
public class CypressAntiForgeryExtensionsTests
{
private readonly IFixture _fixture = new Fixture().Customize(new AutoNSubstituteCustomization());

[Fact]
public void AddCypressAntiForgeryHandling_RegistersRequiredServices()
{
// Arrange
var services = new ServiceCollection();
var mvcBuilder = Substitute.For<IMvcBuilder>();
mvcBuilder.Services.Returns(services);

// Act
var result = CypressAntiForgeryExtensions.AddCypressAntiForgeryHandling(mvcBuilder);

// Assert
Assert.Contains(services, d => d.ServiceType == typeof(ICypressRequestChecker) && d.ImplementationType == typeof(CypressRequestChecker));

Assert.Contains(services, d => d.ServiceType == typeof(CypressAwareAntiForgeryFilter));

Assert.Same(mvcBuilder, result);
}
}
}
Loading
Loading