Changes to Basic Auth to support external logins (#12434)

* Fixed issues with basic auth middleware to support Umbraco Cloud usecase

* Fix redirects to return url, now allows website urls

* Strip potential domain part of returnPath

* Fix default value in appsettings schema

* Reintroduce check of basic auth enabled.

* Fix wrong negation introduced in #12349

* Fixed issues with redirects

* Also check external login cookie, while authenticating backoffice
This commit is contained in:
Bjarke Berg
2022-06-02 12:19:22 +02:00
committed by GitHub
parent f4e333c178
commit faf06be618
9 changed files with 102 additions and 19 deletions

View File

@@ -23,5 +23,18 @@ namespace Umbraco.Cms.Core.Configuration.Models
public string[] AllowedIPs { get; set; } = Array.Empty<string>();
public SharedSecret SharedSecret { get; set; } = new SharedSecret();
public bool RedirectToLoginPage { get; set; } = false;
}
public class SharedSecret
{
private const string StaticHeaderName = "X-Authentication-Shared-Secret";
[DefaultValue(StaticHeaderName)]
public string? HeaderName { get; set; } = StaticHeaderName;
public string? Value { get; set; }
}
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Web.Common.DependencyInjection;
@@ -31,6 +32,7 @@ namespace Umbraco.Cms.Core.Services.Implement
}
public bool IsBasicAuthEnabled() => _basicAuthSettings.Enabled;
public bool IsRedirectToLoginPageEnabled() => _basicAuthSettings.RedirectToLoginPage;
public bool IsIpAllowListed(IPAddress clientIpAddress)
{
@@ -44,5 +46,18 @@ namespace Umbraco.Cms.Core.Services.Implement
return false;
}
public bool HasCorrectSharedSecret(IDictionary<string, StringValues> headers)
{
var headerName = _basicAuthSettings.SharedSecret.HeaderName;
var sharedSecret = _basicAuthSettings.SharedSecret.Value;
if (string.IsNullOrWhiteSpace(headerName) || string.IsNullOrWhiteSpace(sharedSecret))
{
return false;
}
return headers.TryGetValue(headerName, out var value) && value.Equals(sharedSecret);
}
}
}

View File

@@ -1,4 +1,5 @@
using System.Net;
using Microsoft.Extensions.Primitives;
namespace Umbraco.Cms.Core.Services
{
@@ -6,5 +7,8 @@ namespace Umbraco.Cms.Core.Services
{
bool IsBasicAuthEnabled();
bool IsIpAllowListed(IPAddress clientIpAddress);
bool HasCorrectSharedSecret(IDictionary<string, StringValues> headers) => false;
bool IsRedirectToLoginPageEnabled() => false;
}
}

View File

@@ -322,7 +322,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
[AllowAnonymous]
public ActionResult ExternalLogin(string provider, string? redirectUrl = null)
{
if (redirectUrl == null)
if (redirectUrl == null || Uri.TryCreate(redirectUrl, UriKind.Absolute, out _))
{
redirectUrl = Url.Action(nameof(Default), this.GetControllerName());
}

View File

@@ -52,6 +52,13 @@ public static class HttpContextExtensions
AuthenticateResult result =
await httpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType);
if (!result.Succeeded)
{
result =
await httpContext.AuthenticateAsync(Constants.Security.BackOfficeExternalAuthenticationType);
}
return result;
}

View File

@@ -45,7 +45,10 @@
vm.allowPasswordReset = Umbraco.Sys.ServerVariables.umbracoSettings.canSendRequiredEmail && Umbraco.Sys.ServerVariables.umbracoSettings.allowPasswordReset;
vm.errorMsg = "";
vm.externalLoginFormAction = Umbraco.Sys.ServerVariables.umbracoUrls.externalLoginsUrl;
const tempUrl = new URL(Umbraco.Sys.ServerVariables.umbracoUrls.externalLoginsUrl, window.location.origin);
tempUrl.searchParams.append("redirectUrl", $location.search().returnPath ?? "")
vm.externalLoginFormAction = tempUrl.pathname + tempUrl.search;
vm.externalLoginProviders = externalLoginInfoService.getLoginProviders();
vm.externalLoginProviders.forEach(x => {
x.customView = externalLoginInfoService.getLoginProviderView(x);
@@ -224,8 +227,8 @@
if (formHelper.submitForm({ scope: $scope, formCtrl: vm.loginForm })) {
//if the login and password are not empty we need to automatically
// validate them - this is because if there are validation errors on the server
// then the user has to change both username & password to resubmit which isn't ideal,
// validate them - this is because if there are validation errors on the server
// then the user has to change both username & password to resubmit which isn't ideal,
// so if they're not empty, we'll just make sure to set them to valid.
if (vm.login && vm.password && vm.login.length > 0 && vm.password.length > 0) {
vm.loginForm.username.$setValidity('auth', true);

View File

@@ -8,7 +8,7 @@ app.run(['$rootScope', '$route', '$location', '$cookies', 'urlHelper', 'appState
$.ajaxSetup({
beforeSend: function (xhr) {
xhr.setRequestHeader("X-UMB-XSRF-TOKEN", $cookies["UMB-XSRF-TOKEN"]);
// This is a standard header that should be sent for all ajax requests and is required for
// This is a standard header that should be sent for all ajax requests and is required for
// how the server handles auth rejections, etc... see https://github.com/dotnet/aspnetcore/blob/a2568cbe1e8dd92d8a7976469100e564362f778e/src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs#L106-L107
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
var queryStrings = urlHelper.getQueryStringParams();
@@ -120,7 +120,7 @@ app.run(['$rootScope', '$route', '$location', '$cookies', 'urlHelper', 'appState
var returnPath = null;
if (rejection.path == "/login" || rejection.path.startsWith("/login/")) {
//Set the current path before redirecting so we know where to redirect back to
returnPath = encodeURIComponent($location.url());
returnPath = encodeURIComponent(window.location.href.replace(window.location.origin,''));
}
$location.path(rejection.path)
if (returnPath) {

View File

@@ -1,8 +1,8 @@
/** This controller is simply here to launch the login dialog when the route is explicitly changed to /login */
angular.module('umbraco').controller("Umbraco.LoginController", function (eventsService, $scope, userService, $location, $rootScope) {
angular.module('umbraco').controller("Umbraco.LoginController", function (eventsService, $scope, userService, $location, $rootScope, $window) {
userService._showLoginDialog();
userService._showLoginDialog();
var evtOn = eventsService.on("app.ready", function(evt, data){
$scope.avatar = "assets/img/application/logo.png";
@@ -14,7 +14,10 @@ angular.module('umbraco').controller("Umbraco.LoginController", function (events
path = decodeURIComponent(locationObj.returnPath);
}
$location.url(path);
// Ensure path is not absolute
path = path.replace(/^.*\/\/[^\/]+/, '')
window.location.href = path;
});
$scope.$on('$destroy', function () {

View File

@@ -1,11 +1,16 @@
using System.Net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
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.BackOffice.Security;
using Umbraco.Cms.Web.Common.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Web.Common.Middleware;
@@ -18,20 +23,41 @@ public class BasicAuthenticationMiddleware : IMiddleware
{
private readonly IBasicAuthService _basicAuthService;
private readonly IRuntimeState _runtimeState;
private readonly string _backOfficePath;
public BasicAuthenticationMiddleware(
IRuntimeState runtimeState,
IBasicAuthService basicAuthService)
IBasicAuthService basicAuthService,
IOptionsMonitor<GlobalSettings> globalSettings,
IHostingEnvironment hostingEnvironment)
{
_runtimeState = runtimeState;
_basicAuthService = basicAuthService;
_backOfficePath = globalSettings.CurrentValue.GetBackOfficePath(hostingEnvironment);
}
[Obsolete("Use Ctor with all methods. This will be removed in Umbraco 12")]
public BasicAuthenticationMiddleware(
IRuntimeState runtimeState,
IBasicAuthService basicAuthService) : this(
runtimeState,
basicAuthService,
StaticServiceProvider.Instance.GetRequiredService<IOptionsMonitor<GlobalSettings>>(),
StaticServiceProvider.Instance.GetRequiredService<IHostingEnvironment>()
)
{
}
/// <inheritdoc />
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (_runtimeState.Level < RuntimeLevel.Run || context.Request.IsBackOfficeRequest() ||
!_basicAuthService.IsBasicAuthEnabled())
if (_runtimeState.Level < RuntimeLevel.Run
|| !_basicAuthService.IsBasicAuthEnabled()
|| context.Request.IsBackOfficeRequest()
|| AllowedClientRequest(context)
|| _basicAuthService.HasCorrectSharedSecret(context.Request.Headers))
{
await next(context);
return;
@@ -67,24 +93,36 @@ public class BasicAuthenticationMiddleware : IMiddleware
}
else
{
SetUnauthorizedHeader(context);
HandleUnauthorized(context);
}
}
else
{
SetUnauthorizedHeader(context);
HandleUnauthorized(context);
}
}
else
{
// no authorization header
SetUnauthorizedHeader(context);
HandleUnauthorized(context);
}
}
private static void SetUnauthorizedHeader(HttpContext context)
private bool AllowedClientRequest(HttpContext context)
{
context.Response.StatusCode = 401;
context.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"Umbraco login\"");
return context.Request.IsClientSideRequest() && _basicAuthService.IsRedirectToLoginPageEnabled();
}
private void HandleUnauthorized(HttpContext context)
{
if (_basicAuthService.IsRedirectToLoginPageEnabled())
{
context.Response.Redirect($"{_backOfficePath}#/login/false?returnPath={WebUtility.UrlEncode(context.Request.GetEncodedPathAndQuery())}" , false);
}
else
{
context.Response.StatusCode = 401;
context.Response.Headers.Add("WWW-Authenticate", "Basic realm=\"Umbraco login\"");
}
}
}