diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs index 634c3c358f35..ca7fe99cd386 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs @@ -1,6 +1,4 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using OpenIddict.Server; using Umbraco.Cms.Api.Common.DependencyInjection; @@ -9,9 +7,9 @@ using Umbraco.Cms.Api.Management.Middleware; using Umbraco.Cms.Api.Management.Security; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Infrastructure.Security; using Umbraco.Cms.Web.Common.ApplicationBuilder; @@ -27,12 +25,12 @@ public static class BackOfficeAuthBuilderExtensions /// /// The to which back office authentication services will be added. /// The same instance with back office authentication configured. + [Obsolete("Use AddBackOffice() or AddBackOfficeSignIn() instead. Scheduled for removal in Umbraco 19.")] public static IUmbracoBuilder AddBackOfficeAuthentication(this IUmbracoBuilder builder) { builder - .AddAuthentication() - .AddUmbracoOpenIddict() - .AddBackOfficeLogin(); + .AddBackOfficeCookieAuthentication() + .AddBackOfficeOpenIddictServices(); return builder; } @@ -52,22 +50,13 @@ public static IUmbracoBuilder AddTokenRevocation(this IUmbracoBuilder builder) return builder; } - private static IUmbracoBuilder AddAuthentication(this IUmbracoBuilder builder) - { - builder.Services.AddAuthentication(); - builder.AddAuthorizationPolicies(); - - builder.Services.AddTransient(); - builder.Services.AddSingleton(); - builder.Services.Configure(options => options.AddFilter(new BackofficePipelineFilter("Backoffice"))); - - return builder; - } - - private static IUmbracoBuilder AddBackOfficeLogin(this IUmbracoBuilder builder) + /// + /// Registers backoffice cookie authentication schemes, cookie configuration, and authorization policies. + /// Does NOT register OpenIddict or the backoffice SPA infrastructure. + /// + internal static IUmbracoBuilder AddBackOfficeCookieAuthentication(this IUmbracoBuilder builder) { - builder.Services - .AddAuthentication() + builder.Services.AddAuthentication() // Add our custom schemes which are cookie handlers .AddCookie(Constants.Security.BackOfficeAuthenticationType) @@ -93,7 +82,30 @@ private static IUmbracoBuilder AddBackOfficeLogin(this IUmbracoBuilder builder) o.ExpireTimeSpan = TimeSpan.FromMinutes(5); }); - // Add OpnIddict server event handler to refresh the cookie that exposes the backoffice authentication outside the scope of the backoffice. + builder.Services.AddScoped(); + builder.Services.ConfigureOptions(); + builder.Services.ConfigureOptions(); + builder.Services.ConfigureOptions(); + + builder.AddAuthorizationPolicies(); + + return builder; + } + + /// + /// Registers OpenIddict services, the backoffice application manager, authorization initialization middleware, + /// and OpenIddict event handlers. These are only needed for the full backoffice SPA flow. + /// + internal static IUmbracoBuilder AddBackOfficeOpenIddictServices(this IUmbracoBuilder builder) + { + builder.AddUmbracoOpenIddict(); + + builder.Services.AddTransient(); + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.Configure(options => options.AddFilter(new BackofficePipelineFilter("Backoffice"))); + + // Add OpenIddict server event handler to refresh the cookie that exposes the backoffice authentication outside the scope of the backoffice. builder.Services.AddSingleton(); builder.Services.Configure(options => { @@ -109,11 +121,6 @@ private static IUmbracoBuilder AddBackOfficeLogin(this IUmbracoBuilder builder) .Build()); }); - builder.Services.AddScoped(); - builder.Services.ConfigureOptions(); - builder.Services.ConfigureOptions(); - builder.Services.ConfigureOptions(); - return builder; } } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs index cc80e64eee4a..0c4b0a55cab6 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs @@ -17,20 +17,45 @@ public static partial class UmbracoBuilderExtensions /// Adds all required components to run the Umbraco back office. /// /// - /// This method calls AddCore() internally to register all core services, - /// then adds backoffice-specific services on top. + /// This method calls AddCore() and internally + /// to register core services, identity, and cookie authentication, then adds backoffice-specific + /// services on top (OpenIddict, backoffice SPA infrastructure, token management). + /// + /// For frontend-only deployments that only need basic authentication with backoffice credentials + /// (no backoffice UI), use instead. + /// /// /// The Umbraco builder. /// Optional action to configure the MVC builder. /// The Umbraco builder. public static IUmbracoBuilder AddBackOffice(this IUmbracoBuilder builder, Action? configureMvc = null) => builder - .AddCore(configureMvc) // All core services - .AddBackOfficeCore() // Backoffice-specific: IBackOfficePathGenerator - .AddBackOfficeIdentity() // Backoffice user identity - .AddBackOfficeAuthentication() // OpenIddict, authorization policies - .AddTokenRevocation() // Token cleanup handlers - .AddMembersIdentity(); // Member identity (also needed for backoffice admin) + .AddCore(configureMvc) // All core services + .AddBackOfficeSignIn() // Identity + Cookie authentication + .AddBackOfficeCore() // IBackOfficePathGenerator, IBackOfficeEnabledMarker + .AddBackOfficeOpenIddictServices() // OpenIddict, application manager, middleware + .AddTokenRevocation() // Token cleanup handlers + .AddMembersIdentity(); // Member identity (also needed for backoffice admin) + + /// + /// Adds backoffice identity and cookie authentication without the full backoffice UI or OpenIddict. + /// Use this for frontend-only deployments that need basic authentication with backoffice credentials. + /// + /// + /// This registers the backoffice identity system (user manager, sign-in manager) and cookie authentication + /// schemes, but does NOT register OpenIddict, the Management API, or the backoffice SPA. It enables + /// BasicAuthenticationMiddleware to authenticate users via a standalone server-rendered login page. + /// + /// Requires AddCore() to have been called first. + /// For full backoffice support, use instead. + /// + /// + /// The to configure. + /// The same instance. + public static IUmbracoBuilder AddBackOfficeSignIn(this IUmbracoBuilder builder) => + builder + .AddBackOfficeIdentity() + .AddBackOfficeCookieAuthentication(); /// /// Registers the essential services required for the Umbraco back office, including the back office path generator and the physical file system implementation. diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs index fec2bc0c9f75..ea1e42d4f40a 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOfficeIdentity.cs @@ -67,7 +67,6 @@ public static IUmbracoBuilder AddBackOfficeIdentity(this IUmbracoBuilder builder services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddSingleton(); diff --git a/src/Umbraco.Cms.StaticAssets/umbraco/BasicAuthLogin/Login.cshtml b/src/Umbraco.Cms.StaticAssets/umbraco/BasicAuthLogin/Login.cshtml new file mode 100644 index 000000000000..e5c691923db2 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/umbraco/BasicAuthLogin/Login.cshtml @@ -0,0 +1,169 @@ +@model Umbraco.Cms.Web.Website.Models.BasicAuthLoginModel +@{ + Layout = null; + var hasError = !string.IsNullOrEmpty(Model?.ErrorMessage); + var externalProviders = Model?.ExternalLoginProviders?.ToList() ?? []; +} + + + + + + Umbraco - Sign In + + + +
+

Umbraco

+

Sign in with your backoffice account

+ + @if (hasError) + { + + } + +
+ @Html.AntiForgeryToken() + + +
+ + +
+ +
+ + +
+ + +
+ + @if (externalProviders.Count > 0) + { +
or
+ + @foreach (var provider in externalProviders) + { +
+ @Html.AntiForgeryToken() + + + +
+ } + } +
+ + + diff --git a/src/Umbraco.Cms.StaticAssets/umbraco/BasicAuthLogin/TwoFactor.cshtml b/src/Umbraco.Cms.StaticAssets/umbraco/BasicAuthLogin/TwoFactor.cshtml new file mode 100644 index 000000000000..f700c0bca615 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/umbraco/BasicAuthLogin/TwoFactor.cshtml @@ -0,0 +1,142 @@ +@model Umbraco.Cms.Web.Website.Models.BasicAuthTwoFactorModel +@{ + Layout = null; + var providers = Model?.ProviderNames?.ToList() ?? []; + var singleProvider = providers.Count == 1; + var hasError = !string.IsNullOrEmpty(Model?.ErrorMessage); +} + + + + + + Umbraco - Two-Factor Authentication + + + +
+

Umbraco

+

Enter your two-factor authentication code

+ + @if (hasError) + { + + } + +
+ @Html.AntiForgeryToken() + + + @if (singleProvider) + { + + } + else if (providers.Count > 1) + { +
+ + +
+ } + +
+ + +
+ + +
+
+ + + diff --git a/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/basic-auth/login.js b/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/basic-auth/login.js new file mode 100644 index 000000000000..4b789ca610ac --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/basic-auth/login.js @@ -0,0 +1,11 @@ +document.addEventListener("DOMContentLoaded", function () { + document.querySelectorAll("form").forEach(function (form) { + form.addEventListener("submit", function () { + var btn = form.querySelector(".btn"); + if (btn && btn.dataset.submittingText) { + btn.disabled = true; + btn.textContent = btn.dataset.submittingText; + } + }); + }); +}); diff --git a/src/Umbraco.Core/Configuration/Models/BasicAuthSettings.cs b/src/Umbraco.Core/Configuration/Models/BasicAuthSettings.cs index 4944a84fae43..8793454e3178 100644 --- a/src/Umbraco.Core/Configuration/Models/BasicAuthSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/BasicAuthSettings.cs @@ -12,6 +12,8 @@ namespace Umbraco.Cms.Core.Configuration.Models; public class BasicAuthSettings { private const bool StaticEnabled = false; + private const string StaticLoginViewPath = "/umbraco/BasicAuthLogin/Login.cshtml"; + private const string StaticTwoFactorViewPath = "/umbraco/BasicAuthLogin/TwoFactor.cshtml"; /// /// Gets or sets a value indicating whether Basic Auth Middleware is enabled. @@ -33,6 +35,18 @@ public class BasicAuthSettings /// Gets or sets a value indicating whether to redirect to the login page instead of showing basic auth prompt. /// public bool RedirectToLoginPage { get; set; } = false; + + /// + /// Gets or sets a value for the path to the login view. + /// + [DefaultValue(StaticLoginViewPath)] + public string LoginViewPath { get; set; } = StaticLoginViewPath; + + /// + /// Gets or sets a value for the path to the two-factor view. + /// + [DefaultValue(StaticTwoFactorViewPath)] + public string TwoFactorViewPath { get; set; } = StaticTwoFactorViewPath; } /// diff --git a/src/Umbraco.Web.Website/Controllers/BasicAuthLoginController.cs b/src/Umbraco.Web.Website/Controllers/BasicAuthLoginController.cs new file mode 100644 index 000000000000..de206a138c05 --- /dev/null +++ b/src/Umbraco.Web.Website/Controllers/BasicAuthLoginController.cs @@ -0,0 +1,339 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Cms.Web.Website.Models; +using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; + +namespace Umbraco.Cms.Web.Website.Controllers; + +/// +/// Provides a standalone server-rendered login page for basic authentication +/// when the backoffice SPA is not available (frontend-only deployments). +/// +/// +/// This controller is used by BasicAuthenticationMiddleware when RedirectToLoginPage is enabled. +/// It supports username/password login and two-factor authentication via . +/// Dependencies are resolved from request services rather than constructor injection so that the controller +/// can be activated even when AddBackOfficeSignIn() has not been called. +/// All actions return 404 when basic authentication is not enabled, preventing the login endpoint +/// from being used as a backdoor sign-in mechanism. +/// +[AllowAnonymous] +public class BasicAuthLoginController : Controller +{ + private readonly IOptions _basicAuthSettings; + + /// + /// Initializes a new instance of the class. + /// + /// The basic authentication settings. + public BasicAuthLoginController(IOptions basicAuthSettings) => _basicAuthSettings = basicAuthSettings; + + /// + /// Renders the login form. + /// + /// The local URL to redirect to after successful login. + /// The login view, or 404 if basic auth is not enabled. + [HttpGet] + public async Task Login(string? returnPath) + { + if (IsBasicAuthEnabled() is false) + { + return NotFound(); + } + + return await LoginView(returnPath); + } + + /// + /// Processes a username/password login attempt. + /// + /// The backoffice username. + /// The backoffice password. + /// The local URL to redirect to after successful login. + /// A redirect on success, or the login view with an error message on failure. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Login(string? username, string? password, string? returnPath) + { + if (IsBasicAuthEnabled() is false) + { + return NotFound(); + } + + IBackOfficeSignInManager? signInManager = + HttpContext.RequestServices.GetService(); + + if (signInManager is null) + { + return await LoginView(returnPath, "Backoffice sign-in is not available. Ensure AddBackOfficeSignIn() is called at startup."); + } + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + return await LoginView(returnPath, "Please enter a username and password."); + } + + SignInResult signInResult = await signInManager.PasswordSignInAsync(username, password, isPersistent: false, lockoutOnFailure: true); + + if (signInResult.Succeeded) + { + return LocalRedirectOrHome(returnPath); + } + + if (signInResult.IsLockedOut) + { + return await LoginView(returnPath, "Your account has been locked out. Please try again later."); + } + + if (signInResult.IsNotAllowed) + { + return await LoginView(returnPath, "Your account is not allowed to sign in."); + } + + if (signInResult.RequiresTwoFactor) + { + return RedirectToAction(nameof(TwoFactor), new { returnPath }); + } + + return await LoginView(returnPath, "Invalid username or password."); + } + + /// + /// Renders the two-factor authentication code entry form. + /// + /// The local URL to redirect to after successful verification. + /// The 2FA view, or a redirect to login if no user is in the 2FA flow. + [HttpGet] + public async Task TwoFactor(string? returnPath) + { + if (IsBasicAuthEnabled() is false) + { + return NotFound(); + } + + IBackOfficeSignInManager? signInManager = + HttpContext.RequestServices.GetService(); + + if (signInManager is null) + { + return await LoginView(returnPath, "Backoffice sign-in is not available. Ensure AddBackOfficeSignIn() is called at startup."); + } + + BackOfficeIdentityUser? user = await signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user is null) + { + // No user in 2FA flow — session may have expired, redirect back to login. + return RedirectToAction(nameof(Login), new { returnPath }); + } + + ITwoFactorLoginService twoFactorService = + HttpContext.RequestServices.GetRequiredService(); + + IEnumerable providerNames = await twoFactorService.GetEnabledTwoFactorProviderNamesAsync(user.Key); + + var model = new BasicAuthTwoFactorModel + { + ReturnPath = returnPath, + ProviderNames = providerNames, + }; + return View("/umbraco/BasicAuthLogin/TwoFactor.cshtml", model); + } + + /// + /// Processes a two-factor authentication code submission. + /// + /// The 2FA provider name (e.g. "UmbracoUserAppAuthenticator"). + /// The verification code from the authenticator app. + /// The local URL to redirect to after successful verification. + /// A redirect on success, or the 2FA view with an error message on failure. + [HttpPost] + [ValidateAntiForgeryToken] + public async Task TwoFactor(string? provider, string? code, string? returnPath) + { + if (IsBasicAuthEnabled() is false) + { + return NotFound(); + } + + IBackOfficeSignInManager? signInManager = + HttpContext.RequestServices.GetService(); + + if (signInManager is null) + { + return await LoginView(returnPath, "Backoffice sign-in is not available. Ensure AddBackOfficeSignIn() is called at startup."); + } + + if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(code)) + { + return await TwoFactorView(signInManager, returnPath, "Please enter a verification code."); + } + + SignInResult signInResult = await signInManager.TwoFactorSignInAsync(provider, code, isPersistent: false, rememberClient: false); + + if (signInResult.Succeeded) + { + return LocalRedirectOrHome(returnPath); + } + + if (signInResult.IsLockedOut) + { + return await LoginView(returnPath, "Your account has been locked out. Please try again later."); + } + + return await TwoFactorView(signInManager, returnPath, "Invalid verification code. Please try again."); + } + + /// + /// Initiates an external login challenge (e.g. Google, Microsoft) by redirecting to the provider. + /// + /// The external authentication provider name. + /// The local URL to redirect to after successful login. + /// A challenge result that redirects to the external provider, or 404 if basic auth is disabled. + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult ExternalLogin(string provider, string? returnPath) + { + if (IsBasicAuthEnabled() is false) + { + return NotFound(); + } + + IBackOfficeSignInManager? signInManager = + HttpContext.RequestServices.GetService(); + + if (signInManager is null) + { + return NotFound(); + } + + var callbackUrl = Url.Action(nameof(ExternalLoginCallback), "BasicAuthLogin", new { returnPath }); + AuthenticationProperties properties = signInManager.ConfigureExternalAuthenticationProperties(provider, callbackUrl); + return Challenge(properties, provider); + } + + /// + /// Handles the callback from an external login provider after authentication. + /// + /// The local URL to redirect to after successful login. + /// A redirect on success, or the login view with an error message on failure. + [HttpGet] + public async Task ExternalLoginCallback(string? returnPath) + { + if (IsBasicAuthEnabled() is false) + { + return NotFound(); + } + + IBackOfficeSignInManager? signInManager = + HttpContext.RequestServices.GetService(); + + if (signInManager is null) + { + return await LoginView(returnPath, "Backoffice sign-in is not available. Ensure AddBackOfficeSignIn() is called at startup."); + } + + ExternalLoginInfo? loginInfo = await signInManager.GetExternalLoginInfoAsync(); + if (loginInfo is null) + { + return await LoginView(returnPath, "Invalid response from the external login provider."); + } + + SignInResult signInResult = await signInManager.ExternalLoginSignInAsync(loginInfo, isPersistent: false); + + if (signInResult.Succeeded) + { + await signInManager.UpdateExternalAuthenticationTokensAsync(loginInfo); + return LocalRedirectOrHome(returnPath); + } + + if (signInResult.RequiresTwoFactor) + { + return RedirectToAction(nameof(TwoFactor), new { returnPath }); + } + + if (signInResult.IsLockedOut) + { + return await LoginView(returnPath, "Your account has been locked out. Please try again later."); + } + + return await LoginView(returnPath, $"Unable to sign in with {loginInfo.ProviderDisplayName}."); + } + + /// + /// Builds the 2FA view with the user's enabled provider names and an optional error message. + /// Redirects to login if no user is currently in the 2FA flow. + /// + private async Task TwoFactorView(IBackOfficeSignInManager signInManager, string? returnPath, string? errorMessage) + { + BackOfficeIdentityUser? user = await signInManager.GetTwoFactorAuthenticationUserAsync(); + if (user is null) + { + return RedirectToAction(nameof(Login), new { returnPath }); + } + + ITwoFactorLoginService twoFactorService = + HttpContext.RequestServices.GetRequiredService(); + + IEnumerable providerNames = await twoFactorService.GetEnabledTwoFactorProviderNamesAsync(user.Key); + + var model = new BasicAuthTwoFactorModel + { + ReturnPath = returnPath, + ErrorMessage = errorMessage, + ProviderNames = providerNames, + }; + return View(_basicAuthSettings.Value.TwoFactorViewPath, model); + } + + /// + /// Builds the login view with an optional error message and available external login providers. + /// + private async Task LoginView(string? returnPath, string? errorMessage = null) + { + IBackOfficeSignInManager? signInManager = + HttpContext.RequestServices.GetService(); + + IEnumerable externalProviders = signInManager is not null + ? await signInManager.GetExternalAuthenticationSchemesAsync() + : []; + + var model = new BasicAuthLoginModel + { + ReturnPath = returnPath, + ErrorMessage = errorMessage, + ExternalLoginProviders = externalProviders, + }; + return View(_basicAuthSettings.Value.LoginViewPath, model); + } + + /// + /// Redirects to the return path if it is a valid local URL, otherwise redirects to the site root. + /// + private IActionResult LocalRedirectOrHome(string? returnPath) + { + if (!string.IsNullOrWhiteSpace(returnPath) && Url.IsLocalUrl(returnPath)) + { + return LocalRedirect(returnPath); + } + + return Redirect("/"); + } + + /// + /// Checks whether basic authentication is enabled via . + /// Returns false if the service is not registered or basic auth is disabled. + /// + private bool IsBasicAuthEnabled() + { + IBasicAuthService? basicAuthService = HttpContext.RequestServices.GetService(); + return basicAuthService?.IsBasicAuthEnabled() == true; + } +} diff --git a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs index 70999b7b03f1..f375955edebb 100644 --- a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs @@ -76,6 +76,7 @@ public static IUmbracoBuilder AddWebsite(this IUmbracoBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilderExtensions.cs b/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilderExtensions.cs index 4a5aaabd49a6..eab60bfda8a1 100644 --- a/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilderExtensions.cs +++ b/src/Umbraco.Web.Website/Extensions/UmbracoApplicationBuilderExtensions.cs @@ -42,6 +42,10 @@ public static IUmbracoEndpointBuilderContext UseWebsiteEndpoints(this IUmbracoEn FrontEndRoutes surfaceRoutes = builder.ApplicationServices.GetRequiredService(); surfaceRoutes.CreateRoutes(builder.EndpointRouteBuilder); + + BasicAuthLoginRoutes basicAuthLoginRoutes = builder.ApplicationServices.GetRequiredService(); + basicAuthLoginRoutes.CreateRoutes(builder.EndpointRouteBuilder); + builder.EndpointRouteBuilder.MapDynamicControllerRoute(Constants.Web.Routing.DynamicRoutePattern); return builder; diff --git a/src/Umbraco.Web.Website/Middleware/BasicAuthenticationMiddleware.cs b/src/Umbraco.Web.Website/Middleware/BasicAuthenticationMiddleware.cs index 348be9e5bf0e..73c952ad03a7 100644 --- a/src/Umbraco.Web.Website/Middleware/BasicAuthenticationMiddleware.cs +++ b/src/Umbraco.Web.Website/Middleware/BasicAuthenticationMiddleware.cs @@ -4,9 +4,7 @@ using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Security; @@ -24,6 +22,12 @@ public class BasicAuthenticationMiddleware : IMiddleware private readonly IRuntimeState _runtimeState; private readonly string _backOfficePath; + /// + /// Initializes a new instance of the class. + /// + /// The runtime state used to determine if the application is running. + /// The service providing basic authentication configuration and validation. + /// The hosting environment used to resolve the backoffice path. public BasicAuthenticationMiddleware( IRuntimeState runtimeState, IBasicAuthService basicAuthService, @@ -38,9 +42,10 @@ public BasicAuthenticationMiddleware( public async Task InvokeAsync(HttpContext context, RequestDelegate next) { if (_runtimeState.Level < RuntimeLevel.Run - || !_basicAuthService.IsBasicAuthEnabled() + || _basicAuthService.IsBasicAuthEnabled() is false || context.Request.IsBackOfficeRequest() - || AllowedClientRequest(context) + || context.Request.Path.StartsWithSegments($"{_backOfficePath}/basic-auth") + || IsAllowedClientRequest(context) || _basicAuthService.HasCorrectSharedSecret(context.Request.Headers)) { await next(context); @@ -54,54 +59,83 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) return; } - AuthenticateResult authenticateResult = await context.AuthenticateBackOfficeAsync(); - if (authenticateResult.Succeeded) + if (await IsAuthenticatedBackOfficeRequestAsync(context)) { await next(context); return; } - if (context.TryGetBasicAuthCredentials(out var username, out var password)) + if (context.TryGetBasicAuthCredentials(out var username, out var password) is false) + { + // No authorization header. + HandleUnauthorized(context); + return; + } + + IBackOfficeSignInManager? backOfficeSignInManager = + context.RequestServices.GetService(); + + if (backOfficeSignInManager is null || username is null || password is null) + { + HandleUnauthorized(context); + return; + } + + SignInResult signInResult = + await backOfficeSignInManager.PasswordSignInAsync(username, password, false, true); + + if (signInResult.Succeeded) + { + await next.Invoke(context); + } + else if (signInResult.RequiresTwoFactor) { - IBackOfficeSignInManager? backOfficeSignInManager = - context.RequestServices.GetService(); - - if (backOfficeSignInManager is not null && username is not null && password is not null) - { - SignInResult signInResult = - await backOfficeSignInManager.PasswordSignInAsync(username, password, false, true); - - if (signInResult.Succeeded) - { - await next.Invoke(context); - } - else - { - HandleUnauthorized(context); - } - } - else - { - HandleUnauthorized(context); - } + // Always redirect to the 2FA page, even when RedirectToLoginPage is false. + // The browser's Basic auth popup cannot complete a 2FA flow. + var returnPath = WebUtility.UrlEncode(context.Request.GetEncodedPathAndQuery()); + context.Response.Redirect($"{_backOfficePath}/basic-auth/2fa?returnPath={returnPath}", false); } else { - // no authorization header HandleUnauthorized(context); } } - private bool AllowedClientRequest(HttpContext context) + private bool IsAllowedClientRequest(HttpContext context) => + context.Request.IsClientSideRequest() && _basicAuthService.IsRedirectToLoginPageEnabled(); + + /// + /// Checks if the request is already authenticated via the backoffice cookie scheme. + /// Returns false when backoffice auth services are not registered (e.g. AddCore()-only deployments). + /// + private static async Task IsAuthenticatedBackOfficeRequestAsync(HttpContext context) { - return context.Request.IsClientSideRequest() && _basicAuthService.IsRedirectToLoginPageEnabled(); + IAuthenticationSchemeProvider? schemeProvider = context.RequestServices.GetService(); + if (schemeProvider is null) + { + return false; + } + + AuthenticationScheme? backOfficeScheme = await schemeProvider.GetSchemeAsync(Cms.Core.Constants.Security.BackOfficeAuthenticationType); + if (backOfficeScheme is null) + { + return false; + } + + AuthenticateResult authenticateResult = await context.AuthenticateBackOfficeAsync(); + return authenticateResult.Succeeded; } private void HandleUnauthorized(HttpContext context) { if (_basicAuthService.IsRedirectToLoginPageEnabled()) { - context.Response.Redirect($"{_backOfficePath}/?status=false&returnPath={WebUtility.UrlEncode(context.Request.GetEncodedPathAndQuery())}", false); + var returnPath = WebUtility.UrlEncode(context.Request.GetEncodedPathAndQuery()); + + // Always use the standalone server-rendered login page for basic auth. + // This is purpose-built for the "authenticate and return to the frontend" flow, + // avoiding the heavier backoffice SPA + OpenIddict token flow. + context.Response.Redirect($"{_backOfficePath}/basic-auth/login?returnPath={returnPath}", false); } else { diff --git a/src/Umbraco.Web.Website/Models/BasicAuthLoginModel.cs b/src/Umbraco.Web.Website/Models/BasicAuthLoginModel.cs new file mode 100644 index 000000000000..748addd80a9d --- /dev/null +++ b/src/Umbraco.Web.Website/Models/BasicAuthLoginModel.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Authentication; + +namespace Umbraco.Cms.Web.Website.Models; + +/// +/// View model for the standalone basic auth login page. +/// +public class BasicAuthLoginModel +{ + /// + /// Gets or sets the local URL to redirect to after successful login. + /// + public string? ReturnPath { get; set; } + + /// + /// Gets or sets an error message to display on the login form. + /// + public string? ErrorMessage { get; set; } + + /// + /// Gets or sets the available external authentication providers (e.g. Google, Microsoft). + /// + public IEnumerable ExternalLoginProviders { get; set; } = []; +} diff --git a/src/Umbraco.Web.Website/Models/BasicAuthTwoFactorModel.cs b/src/Umbraco.Web.Website/Models/BasicAuthTwoFactorModel.cs new file mode 100644 index 000000000000..eef482bdc21a --- /dev/null +++ b/src/Umbraco.Web.Website/Models/BasicAuthTwoFactorModel.cs @@ -0,0 +1,22 @@ +namespace Umbraco.Cms.Web.Website.Models; + +/// +/// View model for the standalone basic auth two-factor authentication page. +/// +public class BasicAuthTwoFactorModel +{ + /// + /// Gets or sets the local URL to redirect to after successful 2FA verification. + /// + public string? ReturnPath { get; set; } + + /// + /// Gets or sets an error message to display on the 2FA form. + /// + public string? ErrorMessage { get; set; } + + /// + /// Gets or sets the enabled 2FA provider names for the user. + /// + public IEnumerable ProviderNames { get; set; } = []; +} diff --git a/src/Umbraco.Web.Website/Routing/BasicAuthLoginRoutes.cs b/src/Umbraco.Web.Website/Routing/BasicAuthLoginRoutes.cs new file mode 100644 index 000000000000..19af9e79fce2 --- /dev/null +++ b/src/Umbraco.Web.Website/Routing/BasicAuthLoginRoutes.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Routing; +using Umbraco.Cms.Web.Website.Controllers; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Website.Routing; + +/// +/// Creates routes for the standalone basic auth login controller. +/// Routes are always registered when the runtime level is ; +/// access control is enforced at the controller level, which returns 404 when basic auth is disabled. +/// +internal sealed class BasicAuthLoginRoutes : IAreaRoutes +{ + private readonly IRuntimeState _runtimeState; + private readonly string _backOfficePath; + + public BasicAuthLoginRoutes( + IRuntimeState runtimeState, + IHostingEnvironment hostingEnvironment) + { + _runtimeState = runtimeState; + _backOfficePath = hostingEnvironment.GetBackOfficePath(); + } + + /// + public void CreateRoutes(IEndpointRouteBuilder endpoints) + { + if (_runtimeState.Level != RuntimeLevel.Run) + { + return; + } + + var controllerName = nameof(BasicAuthLoginController) + .Replace("Controller", string.Empty, StringComparison.Ordinal); + + var pathPrefix = _backOfficePath.TrimStart('/') + "/basic-auth"; + + // Map /basic-auth/2fa to the TwoFactor action (friendly URL for middleware redirects) + endpoints.MapControllerRoute( + name: "BasicAuth2fa", + pattern: pathPrefix + "/2fa", + defaults: new { controller = controllerName, action = "TwoFactor" }); + + // Map /basic-auth/{action} with Login as default + endpoints.MapControllerRoute( + name: "BasicAuthLogin", + pattern: pathPrefix + "/{action=Login}", + defaults: new { controller = controllerName }); + } +} diff --git a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs index 4e466e2a1f8b..77ab82fa8d9d 100644 --- a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs +++ b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs @@ -287,7 +287,8 @@ protected void ConfigureServices(IServiceCollection services) .AddWebComponents() .AddUmbracoHybridCache() .AddBackOfficeCore() - .AddBackOfficeAuthentication() + .AddBackOfficeCookieAuthentication() + .AddBackOfficeOpenIddictServices() .AddBackOfficeIdentity() .AddMembersIdentity() // .AddBackOfficeAuthorizationPolicies(TestAuthHandler.TestAuthenticationScheme) diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs index 271d76038c86..6e2689c07535 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTest.cs @@ -162,7 +162,8 @@ protected void ConfigureServices(IServiceCollection services) builder.AddConfiguration() .AddUmbracoCore() .AddWebComponents() - .AddBackOfficeAuthentication() + .AddBackOfficeCookieAuthentication() + .AddBackOfficeOpenIddictServices() .AddBackOfficeIdentity() .AddMembersIdentity() .AddExamine() diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Controllers/BasicAuthLoginControllerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Controllers/BasicAuthLoginControllerTests.cs new file mode 100644 index 000000000000..b9e5774faa99 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Controllers/BasicAuthLoginControllerTests.cs @@ -0,0 +1,482 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Cms.Web.Website.Controllers; +using Umbraco.Cms.Web.Website.Models; +using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Website.Controllers; + +[TestFixture] +public class BasicAuthLoginControllerTests +{ + private Mock _signInManagerMock = null!; + private Mock _twoFactorServiceMock = null!; + private Mock _basicAuthServiceMock = null!; + private BasicAuthLoginController _controller = null!; + + [SetUp] + public void SetUp() + { + _signInManagerMock = new Mock(); + _twoFactorServiceMock = new Mock(); + _basicAuthServiceMock = new Mock(); + _basicAuthServiceMock.Setup(x => x.IsBasicAuthEnabled()).Returns(true); + + var services = new ServiceCollection(); + services.AddSingleton(_signInManagerMock.Object); + services.AddSingleton(_twoFactorServiceMock.Object); + services.AddSingleton(_basicAuthServiceMock.Object); + services.AddAntiforgery(); + services.AddLogging(); + services.AddControllersWithViews(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + var httpContext = new DefaultHttpContext { RequestServices = serviceProvider }; + + _controller = new BasicAuthLoginController(Options.Create(new BasicAuthSettings())) + { + ControllerContext = new ControllerContext + { + HttpContext = httpContext, + }, + + // UrlHelper needs an action context to resolve Url.IsLocalUrl. + Url = new UrlHelper(new ActionContext(httpContext, new RouteData(), new ActionDescriptor())), + }; + } + + /// + /// Verifies that GET Login returns the login view with the return path set. + /// + [Test] + public async Task Login_Get_ReturnsView() + { + IActionResult result = await _controller.Login("/some-page"); + + var viewResult = result as ViewResult; + Assert.IsNotNull(viewResult); + Assert.That(viewResult!.ViewName, Is.EqualTo("/umbraco/BasicAuthLogin/Login.cshtml")); + + var model = viewResult.Model as BasicAuthLoginModel; + Assert.IsNotNull(model); + Assert.That(model!.ReturnPath, Is.EqualTo("/some-page")); + Assert.IsNull(model.ErrorMessage); + } + + /// + /// Verifies that GET Login accepts a null return path without error. + /// + [Test] + public async Task Login_Get_NullReturnPath_ReturnsView() + { + IActionResult result = await _controller.Login(null); + + var viewResult = result as ViewResult; + Assert.IsNotNull(viewResult); + + var model = viewResult!.Model as BasicAuthLoginModel; + Assert.IsNotNull(model); + Assert.IsNull(model!.ReturnPath); + } + + /// + /// Verifies that successful login redirects to the provided local return path. + /// + [Test] + public async Task Login_Post_Success_RedirectsToReturnPath() + { + _signInManagerMock + .Setup(x => x.PasswordSignInAsync("admin", "pass", false, true)) + .ReturnsAsync(SignInResult.Success); + + IActionResult result = await _controller.Login("admin", "pass", "/protected-page"); + + var redirectResult = result as LocalRedirectResult; + Assert.IsNotNull(redirectResult); + Assert.That(redirectResult!.Url, Is.EqualTo("/protected-page")); + } + + /// + /// Verifies that successful login with no return path redirects to the site root. + /// + [Test] + public async Task Login_Post_Success_NullReturnPath_RedirectsToRoot() + { + _signInManagerMock + .Setup(x => x.PasswordSignInAsync("admin", "pass", false, true)) + .ReturnsAsync(SignInResult.Success); + + IActionResult result = await _controller.Login("admin", "pass", null); + + var redirectResult = result as RedirectResult; + Assert.IsNotNull(redirectResult); + Assert.That(redirectResult!.Url, Is.EqualTo("/")); + } + + /// + /// Verifies that invalid credentials return the login view with an error message. + /// + [Test] + public async Task Login_Post_InvalidCredentials_ReturnsLoginViewWithError() + { + _signInManagerMock + .Setup(x => x.PasswordSignInAsync("admin", "wrong", false, true)) + .ReturnsAsync(SignInResult.Failed); + + IActionResult result = await _controller.Login("admin", "wrong", "/page"); + + AssertLoginViewWithError(result, "Invalid username or password."); + } + + /// + /// Verifies that a locked-out account returns the login view with a lockout message. + /// + [Test] + public async Task Login_Post_LockedOut_ReturnsLoginViewWithError() + { + _signInManagerMock + .Setup(x => x.PasswordSignInAsync("admin", "pass", false, true)) + .ReturnsAsync(SignInResult.LockedOut); + + IActionResult result = await _controller.Login("admin", "pass", "/page"); + + AssertLoginViewWithError(result, "Your account has been locked out. Please try again later."); + } + + /// + /// Verifies that a disallowed account returns the login view with an appropriate message. + /// + [Test] + public async Task Login_Post_NotAllowed_ReturnsLoginViewWithError() + { + _signInManagerMock + .Setup(x => x.PasswordSignInAsync("admin", "pass", false, true)) + .ReturnsAsync(SignInResult.NotAllowed); + + IActionResult result = await _controller.Login("admin", "pass", "/page"); + + AssertLoginViewWithError(result, "Your account is not allowed to sign in."); + } + + /// + /// Verifies that a RequiresTwoFactor result redirects to the TwoFactor action with the return path. + /// + [Test] + public async Task Login_Post_RequiresTwoFactor_RedirectsToTwoFactorAction() + { + _signInManagerMock + .Setup(x => x.PasswordSignInAsync("admin", "pass", false, true)) + .ReturnsAsync(SignInResult.TwoFactorRequired); + + IActionResult result = await _controller.Login("admin", "pass", "/page"); + + var redirectResult = result as RedirectToActionResult; + Assert.IsNotNull(redirectResult); + Assert.That(redirectResult!.ActionName, Is.EqualTo("TwoFactor")); + Assert.That(redirectResult.RouteValues!["returnPath"], Is.EqualTo("/page")); + } + + /// + /// Verifies that an empty username returns the login view with a validation error + /// and does not call the sign-in manager. + /// + [Test] + public async Task Login_Post_EmptyUsername_ReturnsLoginViewWithError() + { + IActionResult result = await _controller.Login(string.Empty, "pass", "/page"); + + AssertLoginViewWithError(result, "Please enter a username and password."); + _signInManagerMock.Verify( + x => x.PasswordSignInAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Verifies that an empty password returns the login view with a validation error. + /// + [Test] + public async Task Login_Post_EmptyPassword_ReturnsLoginViewWithError() + { + IActionResult result = await _controller.Login("admin", string.Empty, "/page"); + + AssertLoginViewWithError(result, "Please enter a username and password."); + } + + /// + /// Verifies that a non-local return path (open redirect attempt) redirects to the site root instead. + /// + [Test] + public async Task Login_Post_NonLocalReturnPath_RedirectsToRoot() + { + _signInManagerMock + .Setup(x => x.PasswordSignInAsync("admin", "pass", false, true)) + .ReturnsAsync(SignInResult.Success); + + IActionResult result = await _controller.Login("admin", "pass", "https://evil.com"); + + var redirectResult = result as RedirectResult; + Assert.IsNotNull(redirectResult); + Assert.That(redirectResult!.Url, Is.EqualTo("/")); + } + + /// + /// Verifies that when IBackOfficeSignInManager is not registered, the login view shows + /// an appropriate configuration error. + /// + [Test] + public async Task Login_Post_NoSignInManager_ReturnsLoginViewWithError() + { + // Create controller without IBackOfficeSignInManager registered but with basic auth enabled + var basicAuthMock = new Mock(); + basicAuthMock.Setup(x => x.IsBasicAuthEnabled()).Returns(true); + + var services = new ServiceCollection(); + services.AddSingleton(basicAuthMock.Object); + services.AddLogging(); + services.AddControllersWithViews(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + var httpContext = new DefaultHttpContext { RequestServices = serviceProvider }; + + var controller = new BasicAuthLoginController(Options.Create(new BasicAuthSettings())) + { + ControllerContext = new ControllerContext { HttpContext = httpContext }, + }; + + IActionResult result = await controller.Login("admin", "pass", "/page"); + + AssertLoginViewWithError(result, "Backoffice sign-in is not available. Ensure AddBackOfficeSignIn() is called at startup."); + } + + /// + /// Verifies that GET TwoFactor returns the 2FA view with the user's enabled provider names. + /// + [Test] + public async Task TwoFactor_Get_ReturnsViewWithProviders() + { + var user = CreateBackOfficeUser(); + _signInManagerMock + .Setup(x => x.GetTwoFactorAuthenticationUserAsync()) + .ReturnsAsync(user); + _twoFactorServiceMock + .Setup(x => x.GetEnabledTwoFactorProviderNamesAsync(user.Key)) + .ReturnsAsync(["UmbracoUserAppAuthenticator"]); + + IActionResult result = await _controller.TwoFactor("/page"); + + var viewResult = result as ViewResult; + Assert.IsNotNull(viewResult); + Assert.That(viewResult!.ViewName, Is.EqualTo("/umbraco/BasicAuthLogin/TwoFactor.cshtml")); + + var model = viewResult.Model as BasicAuthTwoFactorModel; + Assert.IsNotNull(model); + Assert.That(model!.ReturnPath, Is.EqualTo("/page")); + Assert.IsNull(model.ErrorMessage); + Assert.That(model.ProviderNames, Is.EquivalentTo(new[] { "UmbracoUserAppAuthenticator" })); + } + + /// + /// Verifies that GET TwoFactor redirects to login when no user is in the 2FA flow + /// (e.g. session expired). + /// + [Test] + public async Task TwoFactor_Get_NoUserInFlow_RedirectsToLogin() + { + _signInManagerMock + .Setup(x => x.GetTwoFactorAuthenticationUserAsync()) + .ReturnsAsync((BackOfficeIdentityUser?)null); + + IActionResult result = await _controller.TwoFactor("/page"); + + var redirectResult = result as RedirectToActionResult; + Assert.IsNotNull(redirectResult); + Assert.That(redirectResult!.ActionName, Is.EqualTo("Login")); + Assert.That(redirectResult.RouteValues!["returnPath"], Is.EqualTo("/page")); + } + + /// + /// Verifies that a valid 2FA code redirects to the provided local return path. + /// + [Test] + public async Task TwoFactor_Post_Success_RedirectsToReturnPath() + { + _signInManagerMock + .Setup(x => x.TwoFactorSignInAsync("UmbracoUserAppAuthenticator", "123456", false, false)) + .ReturnsAsync(SignInResult.Success); + + IActionResult result = await _controller.TwoFactor("UmbracoUserAppAuthenticator", "123456", "/page"); + + var redirectResult = result as LocalRedirectResult; + Assert.IsNotNull(redirectResult); + Assert.That(redirectResult!.Url, Is.EqualTo("/page")); + } + + /// + /// Verifies that an invalid 2FA code returns the 2FA view with an error message + /// and preserves the user's provider list. + /// + [Test] + public async Task TwoFactor_Post_InvalidCode_ReturnsTwoFactorViewWithError() + { + var user = CreateBackOfficeUser(); + _signInManagerMock + .Setup(x => x.TwoFactorSignInAsync("UmbracoUserAppAuthenticator", "000000", false, false)) + .ReturnsAsync(SignInResult.Failed); + _signInManagerMock + .Setup(x => x.GetTwoFactorAuthenticationUserAsync()) + .ReturnsAsync(user); + _twoFactorServiceMock + .Setup(x => x.GetEnabledTwoFactorProviderNamesAsync(user.Key)) + .ReturnsAsync(["UmbracoUserAppAuthenticator"]); + + IActionResult result = await _controller.TwoFactor("UmbracoUserAppAuthenticator", "000000", "/page"); + + var viewResult = result as ViewResult; + Assert.IsNotNull(viewResult); + Assert.That(viewResult!.ViewName, Is.EqualTo("/umbraco/BasicAuthLogin/TwoFactor.cshtml")); + + var model = viewResult.Model as BasicAuthTwoFactorModel; + Assert.IsNotNull(model); + Assert.That(model!.ErrorMessage, Is.EqualTo("Invalid verification code. Please try again.")); + } + + /// + /// Verifies that a locked-out account during 2FA returns the login view with a lockout message. + /// + [Test] + public async Task TwoFactor_Post_LockedOut_ReturnsLoginViewWithError() + { + _signInManagerMock + .Setup(x => x.TwoFactorSignInAsync("UmbracoUserAppAuthenticator", "000000", false, false)) + .ReturnsAsync(SignInResult.LockedOut); + + IActionResult result = await _controller.TwoFactor("UmbracoUserAppAuthenticator", "000000", "/page"); + + AssertLoginViewWithError(result, "Your account has been locked out. Please try again later."); + } + + /// + /// Verifies that an empty verification code returns the 2FA view with a validation error. + /// + [Test] + public async Task TwoFactor_Post_EmptyCode_ReturnsTwoFactorViewWithError() + { + var user = CreateBackOfficeUser(); + _signInManagerMock + .Setup(x => x.GetTwoFactorAuthenticationUserAsync()) + .ReturnsAsync(user); + _twoFactorServiceMock + .Setup(x => x.GetEnabledTwoFactorProviderNamesAsync(user.Key)) + .ReturnsAsync(["UmbracoUserAppAuthenticator"]); + + IActionResult result = await _controller.TwoFactor("UmbracoUserAppAuthenticator", string.Empty, "/page"); + + var viewResult = result as ViewResult; + Assert.IsNotNull(viewResult); + + var model = viewResult!.Model as BasicAuthTwoFactorModel; + Assert.IsNotNull(model); + Assert.That(model!.ErrorMessage, Is.EqualTo("Please enter a verification code.")); + } + + /// + /// Verifies that a non-local return path after 2FA success redirects to the site root + /// to prevent open redirect attacks. + /// + [Test] + public async Task TwoFactor_Post_NonLocalReturnPath_RedirectsToRoot() + { + _signInManagerMock + .Setup(x => x.TwoFactorSignInAsync("UmbracoUserAppAuthenticator", "123456", false, false)) + .ReturnsAsync(SignInResult.Success); + + IActionResult result = await _controller.TwoFactor("UmbracoUserAppAuthenticator", "123456", "https://evil.com"); + + var redirectResult = result as RedirectResult; + Assert.IsNotNull(redirectResult); + Assert.That(redirectResult.Url, Is.EqualTo("/")); + } + + /// + /// Verifies that GET Login returns 404 when basic auth is disabled. + /// + [Test] + public async Task Login_Get_BasicAuthDisabled_ReturnsNotFound() + { + _basicAuthServiceMock.Setup(x => x.IsBasicAuthEnabled()).Returns(false); + + IActionResult result = await _controller.Login("/page"); + + Assert.IsInstanceOf(result); + } + + /// + /// Verifies that POST Login returns 404 when basic auth is disabled, + /// preventing the endpoint from being used as a backdoor sign-in. + /// + [Test] + public async Task Login_Post_BasicAuthDisabled_ReturnsNotFound() + { + _basicAuthServiceMock.Setup(x => x.IsBasicAuthEnabled()).Returns(false); + + IActionResult result = await _controller.Login("admin", "pass", "/page"); + + Assert.IsInstanceOf(result); + _signInManagerMock.Verify( + x => x.PasswordSignInAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Verifies that GET TwoFactor returns 404 when basic auth is disabled. + /// + [Test] + public async Task TwoFactor_Get_BasicAuthDisabled_ReturnsNotFound() + { + _basicAuthServiceMock.Setup(x => x.IsBasicAuthEnabled()).Returns(false); + + IActionResult result = await _controller.TwoFactor("/page"); + + Assert.IsInstanceOf(result); + } + + /// + /// Verifies that POST TwoFactor returns 404 when basic auth is disabled. + /// + [Test] + public async Task TwoFactor_Post_BasicAuthDisabled_ReturnsNotFound() + { + _basicAuthServiceMock.Setup(x => x.IsBasicAuthEnabled()).Returns(false); + + IActionResult result = await _controller.TwoFactor("UmbracoUserAppAuthenticator", "123456", "/page"); + + Assert.IsInstanceOf(result); + } + + private static void AssertLoginViewWithError(IActionResult result, string expectedError) + { + var viewResult = result as ViewResult; + Assert.IsNotNull(viewResult); + Assert.That(viewResult.ViewName, Is.EqualTo("/umbraco/BasicAuthLogin/Login.cshtml")); + + var model = viewResult.Model as BasicAuthLoginModel; + Assert.IsNotNull(model); + Assert.That(model.ErrorMessage, Is.EqualTo(expectedError)); + } + + private static BackOfficeIdentityUser CreateBackOfficeUser() + { + var globalSettings = new global::Umbraco.Cms.Core.Configuration.Models.GlobalSettings(); + return BackOfficeIdentityUser.CreateNew(globalSettings, "admin", "admin@example.com", "en-US"); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Middleware/BasicAuthenticationMiddlewareTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Middleware/BasicAuthenticationMiddlewareTests.cs new file mode 100644 index 000000000000..f6e6f920c4ed --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Middleware/BasicAuthenticationMiddlewareTests.cs @@ -0,0 +1,363 @@ +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Middleware; +using Umbraco.Cms.Web.Common.Security; +using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Website.Middleware; + +[TestFixture] +public class BasicAuthenticationMiddlewareTests +{ + private Mock _runtimeStateMock = null!; + private Mock _basicAuthServiceMock = null!; + private Mock _signInManagerMock = null!; + private BasicAuthenticationMiddleware _middleware = null!; + private bool _nextCalled; + + [SetUp] + public void SetUp() + { + _runtimeStateMock = new Mock(); + _runtimeStateMock.Setup(x => x.Level).Returns(RuntimeLevel.Run); + + _basicAuthServiceMock = new Mock(); + _basicAuthServiceMock.Setup(x => x.IsBasicAuthEnabled()).Returns(true); + + var hostingEnvironmentMock = new Mock(); + hostingEnvironmentMock.Setup(x => x.ApplicationVirtualPath).Returns("/"); + hostingEnvironmentMock.Setup(x => x.ToAbsolute(It.IsAny())).Returns(path => path.TrimStart('~')); + + _signInManagerMock = new Mock(); + _nextCalled = false; + + _middleware = new BasicAuthenticationMiddleware( + _runtimeStateMock.Object, + _basicAuthServiceMock.Object, + hostingEnvironmentMock.Object); + } + + /// + /// Verifies that the middleware passes through when the runtime level is below Run. + /// + [Test] + public async Task InvokeAsync_RuntimeLevelBelowRun_PassesThrough() + { + _runtimeStateMock.Setup(x => x.Level).Returns(RuntimeLevel.Boot); + HttpContext context = CreateHttpContext("/some-page"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsTrue(_nextCalled); + } + + /// + /// Verifies that the middleware passes through when basic auth is disabled. + /// + [Test] + public async Task InvokeAsync_BasicAuthDisabled_PassesThrough() + { + _basicAuthServiceMock.Setup(x => x.IsBasicAuthEnabled()).Returns(false); + HttpContext context = CreateHttpContext("/some-page"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsTrue(_nextCalled); + } + + /// + /// Verifies that the middleware passes through for requests to the standalone login page, + /// preventing an infinite redirect loop. + /// + [Test] + public async Task InvokeAsync_BasicAuthLoginRoute_PassesThrough() + { + HttpContext context = CreateHttpContext("/umbraco/basic-auth/login"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsTrue(_nextCalled); + } + + /// + /// Verifies that the middleware passes through for requests to the 2FA page. + /// + [Test] + public async Task InvokeAsync_BasicAuth2faRoute_PassesThrough() + { + HttpContext context = CreateHttpContext("/umbraco/basic-auth/2fa"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsTrue(_nextCalled); + } + + /// + /// Verifies that the middleware passes through when the client has a correct shared secret header. + /// + [Test] + public async Task InvokeAsync_CorrectSharedSecret_PassesThrough() + { + _basicAuthServiceMock.Setup(x => x.HasCorrectSharedSecret(It.IsAny())).Returns(true); + HttpContext context = CreateHttpContext("/some-page"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsTrue(_nextCalled); + } + + /// + /// Verifies that the middleware passes through when the client IP is allow-listed. + /// + [Test] + public async Task InvokeAsync_AllowListedIp_PassesThrough() + { + _basicAuthServiceMock.Setup(x => x.IsIpAllowListed(It.IsAny())).Returns(true); + HttpContext context = CreateHttpContext("/some-page"); + context.Connection.RemoteIpAddress = IPAddress.Parse("192.168.1.1"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsTrue(_nextCalled); + } + + /// + /// Verifies that when RedirectToLoginPage is enabled and no credentials are provided, + /// the middleware redirects to the standalone login page. + /// + [Test] + public async Task InvokeAsync_NoCredentials_RedirectToLoginEnabled_RedirectsToStandaloneLogin() + { + _basicAuthServiceMock.Setup(x => x.IsRedirectToLoginPageEnabled()).Returns(true); + HttpContext context = CreateHttpContext("/protected-page"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsFalse(_nextCalled); + Assert.That(context.Response.StatusCode, Is.EqualTo(302)); + Assert.That( + context.Response.Headers.Location.ToString(), + Does.Contain("basic-auth/login?returnPath=")); + } + + /// + /// Verifies that when RedirectToLoginPage is disabled and no credentials are provided, + /// the middleware returns 401 with a WWW-Authenticate header for the browser Basic popup. + /// + [Test] + public async Task InvokeAsync_NoCredentials_RedirectToLoginDisabled_Returns401WithWwwAuthenticate() + { + _basicAuthServiceMock.Setup(x => x.IsRedirectToLoginPageEnabled()).Returns(false); + HttpContext context = CreateHttpContext("/protected-page"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsFalse(_nextCalled); + Assert.That(context.Response.StatusCode, Is.EqualTo(401)); + Assert.That(context.Response.Headers["WWW-Authenticate"].ToString(), Is.EqualTo("Basic realm=\"Umbraco login\"")); + } + + /// + /// Verifies that valid Basic credentials in the Authorization header allow the request through. + /// + [Test] + public async Task InvokeAsync_ValidBasicCredentials_PassesThrough() + { + _signInManagerMock + .Setup(x => x.PasswordSignInAsync("admin", "pass", false, true)) + .ReturnsAsync(SignInResult.Success); + + HttpContext context = CreateHttpContext("/protected-page", withSignInManager: true); + AddBasicAuthHeader(context, "admin", "pass"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsTrue(_nextCalled); + } + + /// + /// Verifies that invalid Basic credentials result in an unauthorized response. + /// + [Test] + public async Task InvokeAsync_InvalidBasicCredentials_HandleUnauthorized() + { + _signInManagerMock + .Setup(x => x.PasswordSignInAsync("admin", "wrong", false, true)) + .ReturnsAsync(SignInResult.Failed); + _basicAuthServiceMock.Setup(x => x.IsRedirectToLoginPageEnabled()).Returns(false); + + HttpContext context = CreateHttpContext("/protected-page", withSignInManager: true); + AddBasicAuthHeader(context, "admin", "wrong"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsFalse(_nextCalled); + Assert.That(context.Response.StatusCode, Is.EqualTo(401)); + } + + /// + /// Verifies that when Basic credentials require 2FA, the middleware redirects to the 2FA page + /// even when RedirectToLoginPage is disabled. The browser's Basic popup cannot complete a 2FA flow. + /// + [Test] + public async Task InvokeAsync_RequiresTwoFactor_RedirectToLoginDisabled_StillRedirectsTo2fa() + { + _signInManagerMock + .Setup(x => x.PasswordSignInAsync("admin", "pass", false, true)) + .ReturnsAsync(SignInResult.TwoFactorRequired); + _basicAuthServiceMock.Setup(x => x.IsRedirectToLoginPageEnabled()).Returns(false); + + HttpContext context = CreateHttpContext("/protected-page", withSignInManager: true); + AddBasicAuthHeader(context, "admin", "pass"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsFalse(_nextCalled); + Assert.That(context.Response.StatusCode, Is.EqualTo(302)); + Assert.That( + context.Response.Headers.Location.ToString(), + Does.Contain("basic-auth/2fa?returnPath=")); + } + + /// + /// Verifies that when Basic credentials require 2FA and RedirectToLoginPage is enabled, + /// the middleware redirects to the 2FA page. + /// + [Test] + public async Task InvokeAsync_RequiresTwoFactor_RedirectToLoginEnabled_RedirectsTo2fa() + { + _signInManagerMock + .Setup(x => x.PasswordSignInAsync("admin", "pass", false, true)) + .ReturnsAsync(SignInResult.TwoFactorRequired); + _basicAuthServiceMock.Setup(x => x.IsRedirectToLoginPageEnabled()).Returns(true); + + HttpContext context = CreateHttpContext("/protected-page", withSignInManager: true); + AddBasicAuthHeader(context, "admin", "pass"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsFalse(_nextCalled); + Assert.That(context.Response.StatusCode, Is.EqualTo(302)); + Assert.That( + context.Response.Headers.Location.ToString(), + Does.Contain("basic-auth/2fa?returnPath=")); + } + + /// + /// Verifies that when the backoffice auth scheme is not registered (AddCore() only), + /// the middleware does not throw and proceeds to handle the request. + /// + [Test] + public async Task InvokeAsync_NoBackOfficeAuthScheme_DoesNotThrow_HandleUnauthorized() + { + _basicAuthServiceMock.Setup(x => x.IsRedirectToLoginPageEnabled()).Returns(false); + HttpContext context = CreateHttpContext("/protected-page"); + + // No auth scheme registered, no Basic header — should return 401 without throwing + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsFalse(_nextCalled); + Assert.That(context.Response.StatusCode, Is.EqualTo(401)); + } + + /// + /// Verifies that when IBackOfficeSignInManager is not registered and Basic credentials + /// are provided, the middleware treats it as unauthorized. + /// + [Test] + public async Task InvokeAsync_BasicCredentials_NoSignInManager_HandleUnauthorized() + { + _basicAuthServiceMock.Setup(x => x.IsRedirectToLoginPageEnabled()).Returns(false); + + // Create context without sign-in manager + HttpContext context = CreateHttpContext("/protected-page", withSignInManager: false); + AddBasicAuthHeader(context, "admin", "pass"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + Assert.IsFalse(_nextCalled); + Assert.That(context.Response.StatusCode, Is.EqualTo(401)); + } + + /// + /// Verifies that when no authentication services are registered (AddCore().AddWebsite() only), + /// the middleware does not throw and falls through to HandleUnauthorized gracefully. + /// + [Test] + public async Task InvokeAsync_NoAuthenticationServicesRegistered_DoesNotThrow_HandleUnauthorized() + { + _basicAuthServiceMock.Setup(x => x.IsRedirectToLoginPageEnabled()).Returns(false); + + // Create context without authentication services (no IAuthenticationSchemeProvider) + var services = new ServiceCollection(); + services.AddLogging(); + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + var httpContext = new DefaultHttpContext { RequestServices = serviceProvider }; + httpContext.Request.Path = "/protected-page"; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("localhost"); + + await _middleware.InvokeAsync(httpContext, NextDelegate()); + + Assert.IsFalse(_nextCalled); + Assert.That(httpContext.Response.StatusCode, Is.EqualTo(401)); + } + + /// + /// Verifies that the return path in the login redirect is URL-encoded. + /// + [Test] + public async Task InvokeAsync_RedirectToLogin_ReturnPathIsUrlEncoded() + { + _basicAuthServiceMock.Setup(x => x.IsRedirectToLoginPageEnabled()).Returns(true); + HttpContext context = CreateHttpContext("/page"); + context.Request.QueryString = new QueryString("?foo=bar&baz=1"); + + await _middleware.InvokeAsync(context, NextDelegate()); + + var location = context.Response.Headers.Location.ToString(); + Assert.That(location, Does.Contain("returnPath=%2Fpage%3Ffoo%3Dbar%26baz%3D1")); + } + + private HttpContext CreateHttpContext(string path, bool withSignInManager = false) + { + var services = new ServiceCollection(); + services.AddAuthentication(); + services.AddLogging(); + + if (withSignInManager) + { + services.AddSingleton(_signInManagerMock.Object); + } + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + var httpContext = new DefaultHttpContext { RequestServices = serviceProvider }; + httpContext.Request.Path = path; + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("localhost"); + + return httpContext; + } + + private static void AddBasicAuthHeader(HttpContext context, string username, string password) + { + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + context.Request.Headers.Authorization = $"Basic {credentials}"; + } + + private RequestDelegate NextDelegate() => + _ => + { + _nextCalled = true; + return Task.CompletedTask; + }; +}