Merge pull request #10803 from umbraco/v9/feature/basic_auth_middleware

V9: Add basic auth middleware
This commit is contained in:
Bjarke Berg
2021-08-06 13:28:39 +02:00
committed by GitHub
24 changed files with 336 additions and 20 deletions

View File

@@ -46,6 +46,7 @@ namespace JsonSchema
public UnattendedSettings Unattended { get; set; }
public RichTextEditorSettings RichTextEditor { get; set; }
public RuntimeMinificationSettings RuntimeMinification { get; set; }
public BasicAuthSettings BasicAuth { get; set; }
}
/// <summary>

View File

@@ -0,0 +1,27 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System;
using System.ComponentModel;
using System.Net;
namespace Umbraco.Cms.Core.Configuration.Models
{
/// <summary>
/// Typed configuration options for basic authentication settings.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigBasicAuth)]
public class BasicAuthSettings
{
private const bool StaticEnabled = false;
/// <summary>
/// Gets or sets a value indicating whether to keep the user logged in.
/// </summary>
[DefaultValue(StaticEnabled)]
public bool Enabled { get; set; } = StaticEnabled;
public string[] AllowedIPs { get; set; } = Array.Empty<string>();
}
}

View File

@@ -46,6 +46,7 @@
public const string ConfigRuntimeMinification = ConfigPrefix + "RuntimeMinification";
public const string ConfigRuntimeMinificationVersion = ConfigRuntimeMinification + ":Version";
public const string ConfigSecurity = ConfigPrefix + "Security";
public const string ConfigBasicAuth = ConfigPrefix + "BasicAuth";
public const string ConfigTours = ConfigPrefix + "Tours";
public const string ConfigTypeFinder = ConfigPrefix + "TypeFinder";
public const string ConfigWebRouting = ConfigPrefix + "WebRouting";

View File

@@ -71,6 +71,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
.AddUmbracoOptions<UmbracoPluginSettings>()
.AddUmbracoOptions<UnattendedSettings>()
.AddUmbracoOptions<RichTextEditorSettings>()
.AddUmbracoOptions<BasicAuthSettings>()
.AddUmbracoOptions<RuntimeMinificationSettings>();
return builder;

View File

@@ -0,0 +1,10 @@
using System.Net;
namespace Umbraco.Cms.Core.Services
{
public interface IBasicAuthService
{
bool IsBasicAuthEnabled();
bool IsIpAllowListed(IPAddress clientIpAddress);
}
}

View File

@@ -40,6 +40,7 @@ namespace Umbraco.Cms.Infrastructure.DependencyInjection
builder.Services.AddUnique<IDomainService, DomainService>();
builder.Services.AddUnique<IAuditService, AuditService>();
builder.Services.AddUnique<ICacheInstructionService, CacheInstructionService>();
builder.Services.AddUnique<IBasicAuthService, BasicAuthService>();
builder.Services.AddUnique<ITagService, TagService>();
builder.Services.AddUnique<IContentService, ContentService>();
builder.Services.AddUnique<IUserService, UserService>();

View File

@@ -0,0 +1,33 @@
using System.Net;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
namespace Umbraco.Cms.Core.Services.Implement
{
public class BasicAuthService : IBasicAuthService
{
private BasicAuthSettings _basicAuthSettings;
public BasicAuthService(IOptionsMonitor<BasicAuthSettings> optionsMonitor)
{
_basicAuthSettings = optionsMonitor.CurrentValue;
optionsMonitor.OnChange(basicAuthSettings => _basicAuthSettings = basicAuthSettings);
}
public bool IsBasicAuthEnabled() => _basicAuthSettings.Enabled;
public bool IsIpAllowListed(IPAddress clientIpAddress)
{
foreach (var allowedIpString in _basicAuthSettings.AllowedIPs)
{
if (IPNetwork.TryParse(allowedIpString, out IPNetwork allowedIp) && allowedIp.Contains(clientIpAddress))
{
return true;
}
}
return false;
}
}
}

View File

@@ -19,6 +19,7 @@
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.34" />
<PackageReference Include="IPNetwork2" Version="2.5.329" />
<PackageReference Include="MailKit" Version="2.13.0" />
<PackageReference Include="Markdown" Version="2.2.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" />

View File

@@ -0,0 +1,37 @@
using System.Linq;
using System.Net;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services.Implement;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services
{
[TestFixture]
public class BasicAuthServiceTests
{
[TestCase(true, ExpectedResult = true)]
[TestCase(false, ExpectedResult = false)]
public bool IsBasicAuthEnabled(bool enabled)
{
var sut = new BasicAuthService(Mock.Of<IOptionsMonitor<BasicAuthSettings>>(_ => _.CurrentValue == new BasicAuthSettings() {Enabled = enabled}));
return sut.IsBasicAuthEnabled();
}
[TestCase("::1", "1.1.1.1", ExpectedResult = false)]
[TestCase("::1", "1.1.1.1, ::1", ExpectedResult = true)]
[TestCase("127.0.0.1", "127.0.0.1, ::1", ExpectedResult = true)]
[TestCase("127.0.0.1", "", ExpectedResult = false)]
[TestCase("125.125.125.1", "125.125.125.0/24", ExpectedResult = true)]
[TestCase("125.125.124.1", "125.125.125.0/24", ExpectedResult = false)]
public bool IsIpAllowListed(string clientIpAddress, string commaSeperatedAllowlist)
{
var allowedIPs = commaSeperatedAllowlist.Split(",").Select(x=>x.Trim()).ToArray();
var sut = new BasicAuthService(Mock.Of<IOptionsMonitor<BasicAuthSettings>>(_ => _.CurrentValue == new BasicAuthSettings() {AllowedIPs = allowedIPs}));
return sut.IsIpAllowListed(IPAddress.Parse(clientIpAddress));
}
}
}

View File

@@ -30,7 +30,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.BackOffice.Security
var mgr = new BackOfficeCookieManager(
Mock.Of<IUmbracoContextAccessor>(),
runtime,
new UmbracoRequestPaths(Options.Create(globalSettings), TestHelper.GetHostingEnvironment()));
new UmbracoRequestPaths(Options.Create(globalSettings), TestHelper.GetHostingEnvironment()),
Mock.Of<IBasicAuthService>());
var result = mgr.ShouldAuthenticateRequest("/umbraco");
@@ -48,7 +49,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.BackOffice.Security
runtime,
new UmbracoRequestPaths(
Options.Create(globalSettings),
Mock.Of<IHostingEnvironment>(x => x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco")));
Mock.Of<IHostingEnvironment>(x => x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco")),
Mock.Of<IBasicAuthService>());
var result = mgr.ShouldAuthenticateRequest("/umbraco");
@@ -69,7 +71,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.BackOffice.Security
runtime,
new UmbracoRequestPaths(
Options.Create(globalSettings),
Mock.Of<IHostingEnvironment>(x => x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco" && x.ToAbsolute(Constants.SystemDirectories.Install) == "/install")));
Mock.Of<IHostingEnvironment>(x => x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco" && x.ToAbsolute(Constants.SystemDirectories.Install) == "/install")),
Mock.Of<IBasicAuthService>());
var result = mgr.ShouldAuthenticateRequest(remainingTimeoutSecondsPath);
Assert.IsTrue(result);
@@ -90,7 +93,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.BackOffice.Security
runtime,
new UmbracoRequestPaths(
Options.Create(globalSettings),
Mock.Of<IHostingEnvironment>(x => x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco" && x.ToAbsolute(Constants.SystemDirectories.Install) == "/install")));
Mock.Of<IHostingEnvironment>(x => x.ApplicationVirtualPath == "/" && x.ToAbsolute(globalSettings.UmbracoPath) == "/umbraco" && x.ToAbsolute(Constants.SystemDirectories.Install) == "/install")),
Mock.Of<IBasicAuthService>());
var result = mgr.ShouldAuthenticateRequest("/notbackoffice");
Assert.IsFalse(result);

View File

@@ -0,0 +1,42 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System;
using System.Text;
using Microsoft.AspNetCore.Http;
using NUnit.Framework;
using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions
{
[TestFixture]
public class HttpContextExtensionTests
{
[Test]
public void TryGetBasicAuthCredentials_WithoutHeader_ReturnsFalse()
{
var httpContext = new DefaultHttpContext();
var result = httpContext.TryGetBasicAuthCredentials(out string _, out string _);
Assert.IsFalse(result);
}
[Test]
public void TryGetBasicAuthCredentials_WithHeader_ReturnsTrueWithCredentials()
{
const string Username = "fred";
const string Password = "test";
var httpContext = new DefaultHttpContext();
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{Username}:{Password}"));
httpContext.Request.Headers.Add("Authorization", $"Basic {credentials}");
bool result = httpContext.TryGetBasicAuthCredentials(out string username, out string password);
Assert.IsTrue(result);
Assert.AreEqual(Username, username);
Assert.AreEqual(Password, password);
}
}
}

View File

@@ -11,6 +11,7 @@ using Umbraco.Cms.Web.Common.Security;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Web.BackOffice.Authorization;
using Umbraco.Cms.Web.Common.Middleware;
namespace Umbraco.Extensions

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.Security;
namespace Umbraco.Extensions
@@ -10,5 +12,6 @@ namespace Umbraco.Extensions
public static BackOfficeExternalLoginProviderErrors GetExternalLoginProviderErrors(this HttpContext httpContext)
=> httpContext.Items[nameof(BackOfficeExternalLoginProviderErrors)] as BackOfficeExternalLoginProviderErrors;
}
}

View File

@@ -8,6 +8,7 @@ using Umbraco.Cms.Web.BackOffice.Middleware;
using Umbraco.Cms.Web.BackOffice.Routing;
using Umbraco.Cms.Web.Common.ApplicationBuilder;
using Umbraco.Cms.Web.Common.Extensions;
using Umbraco.Cms.Web.Common.Middleware;
namespace Umbraco.Extensions
{

View File

@@ -2,7 +2,9 @@ using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
@@ -23,6 +25,7 @@ namespace Umbraco.Cms.Web.BackOffice.Security
private readonly IRuntimeState _runtime;
private readonly string[] _explicitPaths;
private readonly UmbracoRequestPaths _umbracoRequestPaths;
private readonly IBasicAuthService _basicAuthService;
/// <summary>
/// Initializes a new instance of the <see cref="BackOfficeCookieManager"/> class.
@@ -30,8 +33,9 @@ namespace Umbraco.Cms.Web.BackOffice.Security
public BackOfficeCookieManager(
IUmbracoContextAccessor umbracoContextAccessor,
IRuntimeState runtime,
UmbracoRequestPaths umbracoRequestPaths)
: this(umbracoContextAccessor, runtime, null, umbracoRequestPaths)
UmbracoRequestPaths umbracoRequestPaths,
IBasicAuthService basicAuthService)
: this(umbracoContextAccessor, runtime, null, umbracoRequestPaths, basicAuthService)
{
}
@@ -42,12 +46,14 @@ namespace Umbraco.Cms.Web.BackOffice.Security
IUmbracoContextAccessor umbracoContextAccessor,
IRuntimeState runtime,
IEnumerable<string> explicitPaths,
UmbracoRequestPaths umbracoRequestPaths)
UmbracoRequestPaths umbracoRequestPaths,
IBasicAuthService basicAuthService)
{
_umbracoContextAccessor = umbracoContextAccessor;
_runtime = runtime;
_explicitPaths = explicitPaths?.ToArray();
_umbracoRequestPaths = umbracoRequestPaths;
_basicAuthService = basicAuthService;
}
/// <summary>
@@ -88,6 +94,11 @@ namespace Umbraco.Cms.Web.BackOffice.Security
return true;
}
if (_basicAuthService.IsBasicAuthEnabled())
{
return true;
}
return false;
}

View File

@@ -34,6 +34,8 @@ namespace Umbraco.Cms.Web.BackOffice.Security
private readonly IIpResolver _ipResolver;
private readonly ISystemClock _systemClock;
private readonly UmbracoRequestPaths _umbracoRequestPaths;
private readonly IBasicAuthService _basicAuthService;
private readonly IOptionsMonitor<BasicAuthSettings> _optionsSnapshot;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigureBackOfficeCookieOptions"/> class.
@@ -59,7 +61,8 @@ namespace Umbraco.Cms.Web.BackOffice.Security
IUserService userService,
IIpResolver ipResolver,
ISystemClock systemClock,
UmbracoRequestPaths umbracoRequestPaths)
UmbracoRequestPaths umbracoRequestPaths,
IBasicAuthService basicAuthService)
{
_serviceProvider = serviceProvider;
_umbracoContextAccessor = umbracoContextAccessor;
@@ -72,6 +75,7 @@ namespace Umbraco.Cms.Web.BackOffice.Security
_ipResolver = ipResolver;
_systemClock = systemClock;
_umbracoRequestPaths = umbracoRequestPaths;
_basicAuthService = basicAuthService;
}
/// <inheritdoc />
@@ -115,7 +119,9 @@ namespace Umbraco.Cms.Web.BackOffice.Security
options.CookieManager = new BackOfficeCookieManager(
_umbracoContextAccessor,
_runtimeState,
_umbracoRequestPaths);
_umbracoRequestPaths,
_basicAuthService
);
options.Events = new CookieAuthenticationEvents
{

View File

@@ -101,10 +101,10 @@ namespace Umbraco.Extensions
services.AddSingleton(httpContextAccessor);
var requestCache = new HttpContextRequestAppCache(httpContextAccessor);
var appCaches = AppCaches.Create(requestCache);
var appCaches = AppCaches.Create(requestCache);
IProfiler profiler = GetWebProfiler(config);
ILoggerFactory loggerFactory = LoggerFactory.Create(cfg => cfg.AddSerilog(Log.Logger, false));
TypeLoader typeLoader = services.AddTypeLoader(

View File

@@ -37,7 +37,7 @@ namespace Umbraco.Extensions
IOptions<UmbracoPipelineOptions> startupOptions = app.ApplicationServices.GetRequiredService<IOptions<UmbracoPipelineOptions>>();
app.RunPrePipeline(startupOptions.Value);
app.UseUmbracoCore();
app.UseUmbracoRequestLogging();

View File

@@ -14,13 +14,7 @@ namespace Umbraco.Extensions
/// <returns></returns>
public static async Task<AuthenticateResult> AuthenticateBackOfficeAsync(this ControllerBase controller)
{
if (controller.HttpContext == null)
{
return AuthenticateResult.NoResult();
}
var result = await controller.HttpContext.AuthenticateAsync(Cms.Core.Constants.Security.BackOfficeAuthenticationType);
return result;
return await controller.HttpContext.AuthenticateBackOfficeAsync();
}
/// <summary>

View File

@@ -1,12 +1,60 @@
using System;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Primitives;
namespace Umbraco.Extensions
{
public static class HttpContextExtensions
{
/// <summary>
/// Try to get the basic auth username and password from the http context.
/// </summary>
public static bool TryGetBasicAuthCredentials(this HttpContext httpContext, out string username, out string password)
{
username = null;
password = null;
if (httpContext.Request.Headers.TryGetValue("Authorization", out StringValues authHeaders))
{
var authHeader = authHeaders.ToString();
if (authHeader is not null && authHeader.StartsWith("Basic"))
{
// Extract credentials.
var encodedUsernamePassword = authHeader.Substring(6).Trim();
Encoding encoding = Encoding.UTF8;
var usernamePassword = encoding.GetString(Convert.FromBase64String(encodedUsernamePassword));
var seperatorIndex = usernamePassword.IndexOf(':');
username = usernamePassword.Substring(0, seperatorIndex);
password = usernamePassword.Substring(seperatorIndex + 1);
}
return true;
}
return false;
}
/// <summary>
/// Runs the authentication process
/// </summary>
public static async Task<AuthenticateResult> AuthenticateBackOfficeAsync(this HttpContext httpContext)
{
if (httpContext == null)
{
return AuthenticateResult.NoResult();
}
var result = await httpContext.AuthenticateAsync(Cms.Core.Constants.Security.BackOfficeAuthenticationType);
return result;
}
/// <summary>
/// Get the value in the request form or query string for the key
/// </summary>

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Infrastructure.DependencyInjection;
using Umbraco.Cms.Web.Common.Middleware;
using Umbraco.Cms.Web.Common.Routing;
using Umbraco.Cms.Web.Website.Collections;
using Umbraco.Cms.Web.Website.Controllers;
@@ -47,6 +48,7 @@ namespace Umbraco.Extensions
builder.Services.AddSingleton<MemberModelBuilderFactory>();
builder.Services.AddSingleton<PublicAccessMiddleware>();
builder.Services.AddSingleton<BasicAuthenticationMiddleware>();
builder
.AddDistributedCache()

View File

@@ -2,6 +2,7 @@ using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Web.Common.ApplicationBuilder;
using Umbraco.Cms.Web.Common.Middleware;
using Umbraco.Cms.Web.Website.Middleware;
using Umbraco.Cms.Web.Website.Routing;
@@ -20,6 +21,7 @@ namespace Umbraco.Extensions
public static IUmbracoMiddlewareBuilder WithWebsite(this IUmbracoMiddlewareBuilder builder)
{
builder.AppBuilder.UseMiddleware<PublicAccessMiddleware>();
builder.AppBuilder.UseMiddleware<BasicAuthenticationMiddleware>();
return builder;
}

View File

@@ -0,0 +1,90 @@
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.BackOffice.Security;
using Umbraco.Extensions;
namespace Umbraco.Cms.Web.Common.Middleware
{
/// <summary>
/// Provides basic authentication via back-office credentials for public website access if configured for use and the client IP is not allow listed.
/// </summary>
public class BasicAuthenticationMiddleware : IMiddleware
{
private readonly IRuntimeState _runtimeState;
private readonly IBasicAuthService _basicAuthService;
public BasicAuthenticationMiddleware(
IRuntimeState runtimeState,
IBasicAuthService basicAuthService)
{
_runtimeState = runtimeState;
_basicAuthService = basicAuthService;
}
/// <inheritdoc />
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (_runtimeState.Level < RuntimeLevel.Run || context.Request.IsBackOfficeRequest() || !_basicAuthService.IsBasicAuthEnabled())
{
await next(context);
return;
}
IPAddress clientIPAddress = context.Connection.RemoteIpAddress;
if (_basicAuthService.IsIpAllowListed(clientIPAddress))
{
await next(context);
return;
}
AuthenticateResult authenticateResult = await context.AuthenticateBackOfficeAsync();
if (authenticateResult.Succeeded)
{
await next(context);
return;
}
if (context.TryGetBasicAuthCredentials(out var username, out var password))
{
IBackOfficeSignInManager backOfficeSignInManager =
context.RequestServices.GetService<IBackOfficeSignInManager>();
if (backOfficeSignInManager is not null)
{
SignInResult signInResult =
await backOfficeSignInManager.PasswordSignInAsync(username, password, false, true);
if (signInResult.Succeeded)
{
await next.Invoke(context);
}
else
{
SetUnauthorizedHeader(context);
}
}
else
{
SetUnauthorizedHeader(context);
}
}
else
{
// no authorization header
SetUnauthorizedHeader(context);
}
}
private static void SetUnauthorizedHeader(HttpContext context)
{
context.Response.StatusCode = 401;
context.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"Umbraco login\"");
}
}
}