1
0

Added new middleware to protect paths by authorization (also static files)

This commit is contained in:
2023-06-27 11:01:54 +02:00
parent 371283e653
commit 28377a89eb
11 changed files with 249 additions and 8 deletions

View File

@@ -0,0 +1,76 @@
using System;
using System.Linq;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace AMWD.Common.AspNetCore.Security.BasicAuthentication
{
/// <summary>
/// Implements the <see cref="AuthenticationHandler{TOptions}"/> for Basic Authentication.
/// </summary>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly ILogger logger;
private readonly IBasicAuthenticationValidator validator;
/// <summary>
/// Initializes a new instance of the <see cref="BasicAuthenticationHandler"/> class.
/// </summary>
/// <param name="options">The authentication scheme options.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="encoder">The URL encoder.</param>
/// <param name="clock">The system clock.</param>
/// <param name="validator">An basic autentication validator implementation.</param>
public BasicAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory loggerFactory, UrlEncoder encoder, ISystemClock clock, IBasicAuthenticationValidator validator)
: base(options, loggerFactory, encoder, clock)
{
logger = loggerFactory.CreateLogger<BasicAuthenticationHandler>();
this.validator = validator;
}
/// <inheritdoc/>
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var endpoint = Context.GetEndpoint();
if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null)
return AuthenticateResult.NoResult();
if (!Request.Headers.ContainsKey("Authorization"))
return AuthenticateResult.Fail("Authorization header missing");
ClaimsPrincipal principal;
try
{
var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]);
string plain = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader.Parameter));
// See: https://www.rfc-editor.org/rfc/rfc2617, page 6
string username = plain.Split(':').First();
string password = plain[(username.Length + 1)..];
var ipAddress = Context.GetRemoteIpAddress();
principal = await validator.ValidateAsync(username, password, ipAddress, Context.RequestAborted);
}
catch (Exception ex)
{
logger.LogError(ex, $"Handling the Basic Authentication failed: {ex.Message}");
return AuthenticateResult.Fail("Authorization header invalid");
}
if (principal == null)
return AuthenticateResult.Fail("Invalid credentials");
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Linq;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace AMWD.Common.AspNetCore.Security.BasicAuthentication
{
/// <summary>
/// Implements a basic authentication.
/// </summary>
public class BasicAuthenticationMiddleware
{
private readonly RequestDelegate next;
private readonly IBasicAuthenticationValidator validator;
/// <summary>
/// Initializes a new instance of the <see cref="BasicAuthenticationMiddleware"/> class.
/// </summary>
/// <param name="next">The following delegate in the process chain.</param>
/// <param name="validator">A basic authentication validator.</param>
public BasicAuthenticationMiddleware(RequestDelegate next, IBasicAuthenticationValidator validator)
{
this.next = next;
this.validator = validator;
}
/// <summary>
/// The delegate invokation.
/// Performs the authentication check.
/// </summary>
/// <param name="httpContext">The corresponding HTTP context.</param>
/// <returns>An awaitable task.</returns>
public async Task InvokeAsync(HttpContext httpContext)
{
if (!httpContext.Request.Headers.ContainsKey("Authorization"))
{
SetAuthenticateRequest(httpContext, validator.Realm);
return;
}
try
{
var authHeader = AuthenticationHeaderValue.Parse(httpContext.Request.Headers["Authorization"]);
byte[] decoded = Convert.FromBase64String(authHeader.Parameter);
string plain = Encoding.UTF8.GetString(decoded);
// See: https://www.rfc-editor.org/rfc/rfc2617, page 6
string username = plain.Split(':').First();
string password = plain[(username.Length + 1)..];
var principal = await validator.ValidateAsync(username, password, httpContext.GetRemoteIpAddress(), httpContext.RequestAborted);
if (principal == null)
{
SetAuthenticateRequest(httpContext, validator.Realm);
return;
}
await next.Invoke(httpContext);
}
catch (Exception ex)
{
var logger = (ILogger<BasicAuthenticationMiddleware>)httpContext.RequestServices.GetService(typeof(ILogger<BasicAuthenticationMiddleware>));
logger?.LogError(ex, $"Falied to execute basic authentication middleware: {ex.InnerException?.Message ?? ex.Message}");
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
}
}
private static void SetAuthenticateRequest(HttpContext httpContext, string realm)
{
httpContext.Response.Headers["WWW-Authenticate"] = "Basic";
if (!string.IsNullOrWhiteSpace(realm))
httpContext.Response.Headers["WWW-Authenticate"] = $"Basic realm=\"{realm.Replace("\"", "")}\"";
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
}
}
}

View File

@@ -0,0 +1,28 @@
using System.Net;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
namespace AMWD.Common.AspNetCore.Security.BasicAuthentication
{
/// <summary>
/// Interface representing the validation of a basic authentication.
/// </summary>
public interface IBasicAuthenticationValidator
{
/// <summary>
/// Gets the realm to use when requesting authentication.
/// </summary>
string Realm { get; }
/// <summary>
/// Validates a username and password for Basic Authentication.
/// </summary>
/// <param name="username">A username.</param>
/// <param name="password">A password.</param>
/// <param name="remoteAddress">The remote client's IP address.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The validated user principal or <c>null</c>.</returns>
Task<ClaimsPrincipal> ValidateAsync(string username, string password, IPAddress remoteAddress, CancellationToken cancellationToken = default);
}
}

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Builder;
namespace AMWD.Common.AspNetCore.Security.PathProtection
{
/// <summary>
/// Extnsion for <see cref="IApplicationBuilder"/> to enable folder protection.
/// </summary>
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public static class ProtectedPathExtensions
{
/// <summary>
/// Provide protected paths even for static files.
/// </summary>
/// <param name="app">The <see cref="IApplicationBuilder"/>.</param>
/// <param name="options">The <see cref="ProtectedPathOptions"/> with path and policy name.</param>
public static IApplicationBuilder UseProtectedPath(this IApplicationBuilder app, ProtectedPathOptions options)
=> app.UseMiddleware<ProtectedPathMiddleware>(options);
}
}

View File

@@ -0,0 +1,50 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
namespace AMWD.Common.AspNetCore.Security.PathProtection
{
/// <summary>
/// Implements a check to provide protected paths.
/// </summary>
public class ProtectedPathMiddleware
{
private readonly RequestDelegate next;
private readonly PathString path;
private readonly string policyName;
/// <summary>
/// Initializes a new instance of the <see cref="ProtectedPathExtensions"/> class.
/// </summary>
/// <param name="next">The following delegate in the process chain.</param>
/// <param name="options">The options to configure the middleware.</param>
public ProtectedPathMiddleware(RequestDelegate next, ProtectedPathOptions options)
{
this.next = next;
path = options.Path;
policyName = options.PolicyName;
}
/// <summary>
/// The delegate invokation.
/// Performs the protection check.
/// </summary>
/// <param name="httpContext">The corresponding HTTP context.</param>
/// <param name="authorizationService">The <see cref="IAuthorizationService"/>.</param>
/// <returns>An awaitable task.</returns>
public async Task InvokeAsync(HttpContext httpContext, IAuthorizationService authorizationService)
{
if (httpContext.Request.Path.StartsWithSegments(path))
{
var result = await authorizationService.AuthorizeAsync(httpContext.User, null, policyName);
if (!result.Succeeded)
{
await httpContext.ChallengeAsync();
return;
}
}
await next.Invoke(httpContext);
}
}
}

View File

@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Http;
namespace AMWD.Common.AspNetCore.Security.PathProtection
{
/// <summary>
/// Options to define which folder should be protected.
/// </summary>
public class ProtectedPathOptions
{
/// <summary>
/// Gets or sets the path to the protected folder.
/// </summary>
public PathString Path { get; set; }
/// <summary>
/// Gets or sets the policy name to use.
/// </summary>
public string PolicyName { get; set; }
}
}