Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Management.NotificationHandlers;
using Umbraco.Cms.Api.Management.Security;
using Umbraco.Cms.Api.Management.Services;
using Umbraco.Cms.Api.Management.Telemetry;
Expand All @@ -12,6 +13,7 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Net;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Security;
Expand Down Expand Up @@ -74,6 +76,9 @@ public static IUmbracoBuilder AddBackOfficeIdentity(this IUmbracoBuilder builder

services.AddScoped<IBackOfficeExternalLoginService, BackOfficeExternalLoginService>();

// Register a notification handler to interrogate the registered external login providers at startup.
builder.AddNotificationHandler<UmbracoApplicationStartingNotification, ExternalLoginProviderStartupHandler>();

return builder;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using Umbraco.Cms.Api.Management.Security;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;

namespace Umbraco.Cms.Api.Management.NotificationHandlers;

/// <summary>
/// Invalidates backoffice sessions and clears external logins for removed providers if the external login
/// provider setup has changed.
/// </summary>
internal sealed class ExternalLoginProviderStartupHandler : INotificationHandler<UmbracoApplicationStartingNotification>
{
private readonly IBackOfficeExternalLoginProviders _backOfficeExternalLoginProviders;
private readonly IRuntimeState _runtimeState;
private readonly IServerRoleAccessor _serverRoleAccessor;

/// <summary>
/// Initializes a new instance of the <see cref="ExternalLoginProviderStartupHandler"/> class.
/// </summary>
public ExternalLoginProviderStartupHandler(
IBackOfficeExternalLoginProviders backOfficeExternalLoginProviders,
IRuntimeState runtimeState,
IServerRoleAccessor serverRoleAccessor)
{
_backOfficeExternalLoginProviders = backOfficeExternalLoginProviders;
_runtimeState = runtimeState;
_serverRoleAccessor = serverRoleAccessor;
}

/// <inheritdoc/>
public void Handle(UmbracoApplicationStartingNotification notification)
{
if (_runtimeState.Level != RuntimeLevel.Run ||
_serverRoleAccessor.CurrentServerRole == ServerRole.Subscriber)
{
return;
}

_backOfficeExternalLoginProviders.InvalidateSessionsIfExternalLoginProvidersChanged();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using Microsoft.AspNetCore.Authentication;
using Umbraco.Cms.Core.Security;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Services;

namespace Umbraco.Cms.Api.Management.Security;

Expand All @@ -8,13 +12,37 @@ public class BackOfficeExternalLoginProviders : IBackOfficeExternalLoginProvider
{
private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider;
private readonly Dictionary<string, BackOfficeExternalLoginProvider> _externalLogins;
private readonly IKeyValueService _keyValueService;
private readonly IExternalLoginWithKeyService _externalLoginWithKeyService;
private readonly ILogger<BackOfficeExternalLoginProviders> _logger;

private const string ExternalLoginProvidersKey = "Umbraco.Cms.Web.BackOffice.Security.BackOfficeExternalLoginProviders";

[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public BackOfficeExternalLoginProviders(
IEnumerable<BackOfficeExternalLoginProvider> externalLogins,
IAuthenticationSchemeProvider authenticationSchemeProvider)
: this(
externalLogins,
authenticationSchemeProvider,
StaticServiceProvider.Instance.GetRequiredService<IKeyValueService>(),
StaticServiceProvider.Instance.GetRequiredService<IExternalLoginWithKeyService>(),
StaticServiceProvider.Instance.GetRequiredService<ILogger<BackOfficeExternalLoginProviders>>())
{
}

public BackOfficeExternalLoginProviders(
IEnumerable<BackOfficeExternalLoginProvider> externalLogins,
IAuthenticationSchemeProvider authenticationSchemeProvider,
IKeyValueService keyValueService,
IExternalLoginWithKeyService externalLoginWithKeyService,
ILogger<BackOfficeExternalLoginProviders> logger)
{
_externalLogins = externalLogins.ToDictionary(x => x.AuthenticationType);
_authenticationSchemeProvider = authenticationSchemeProvider;
_keyValueService = keyValueService;
_externalLoginWithKeyService = externalLoginWithKeyService;
_logger = logger;
}

/// <inheritdoc />
Expand Down Expand Up @@ -60,4 +88,25 @@ public bool HasDenyLocalLogin()
var found = _externalLogins.Values.Where(x => x.Options.DenyLocalLogin).ToList();
return found.Count > 0;
}

/// <inheritdoc />
public void InvalidateSessionsIfExternalLoginProvidersChanged()
{
var previousExternalLoginProvidersValue = _keyValueService.GetValue(ExternalLoginProvidersKey);
var currentExternalLoginProvidersValue = string.Join("|", _externalLogins.Keys.OrderBy(key => key));

if ((previousExternalLoginProvidersValue ?? string.Empty) != currentExternalLoginProvidersValue)
{
_logger.LogWarning(
"The configured external login providers have changed. Existing backoffice sessions using the removed providers will be invalidated and external login data removed.");

_externalLoginWithKeyService.PurgeLoginsForRemovedProviders(_externalLogins.Keys);

_keyValueService.SetValue(ExternalLoginProvidersKey, currentExternalLoginProvidersValue);
}
else if (previousExternalLoginProvidersValue is null)
{
_keyValueService.SetValue(ExternalLoginProvidersKey, string.Empty);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@ public interface IBackOfficeExternalLoginProviders
/// </summary>
/// <returns></returns>
bool HasDenyLocalLogin();

/// <summary>
/// Used during startup to see if the configured external login providers is different from the persisted information.
/// If they are different, this will invalidate backoffice sessions and clear external logins for removed providers
/// if the external login provider setup has changed.
/// </summary>
void InvalidateSessionsIfExternalLoginProvidersChanged() { }
}
4 changes: 2 additions & 2 deletions src/Umbraco.Core/Configuration/Models/SecuritySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ public class SecuritySettings

internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60;
internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60;
private const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000;
private const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250;
internal const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000;
internal const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250;
internal const string StaticAuthorizeCallbackPathName = "/umbraco/oauth_complete";
internal const string StaticAuthorizeCallbackLogoutPathName = "/umbraco/logout";
internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error";
Expand Down
35 changes: 29 additions & 6 deletions src/Umbraco.Core/Extensions/IntExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.

using System.Diagnostics.CodeAnalysis;

namespace Umbraco.Extensions;

public static class IntExtensions
{
/// <summary>
/// Does something 'x' amount of times
/// Does something 'x' amount of times.
/// </summary>
/// <param name="n"></param>
/// <param name="action"></param>
/// <param name="n">Number of times to execute the action.</param>
/// <param name="action">The action to execute.</param>
public static void Times(this int n, Action<int> action)
{
for (var i = 0; i < n; i++)
Expand All @@ -19,16 +21,37 @@ public static void Times(this int n, Action<int> action)
}

/// <summary>
/// Creates a Guid based on an integer value
/// Creates a Guid based on an integer value.
/// </summary>
/// <param name="value"><see cref="int" /> value to convert</param>
/// <param name="value">The <see cref="int" /> value to convert.</param>
/// <returns>
/// <see cref="Guid" />
/// The converted <see cref="Guid" />.
/// </returns>
public static Guid ToGuid(this int value)
{
Span<byte> bytes = stackalloc byte[16];
BitConverter.GetBytes(value).CopyTo(bytes);
return new Guid(bytes);
}

/// <summary>
/// Restores a GUID previously created from an integer value using <see cref="ToGuid" />.
/// </summary>
/// <param name="value">The <see cref="Guid" /> value to convert.</param>
/// <param name="result">The converted <see cref="int" />.</param>
/// <returns>
/// True if the <see cref="int" /> value could be created, otherwise false.
/// </returns>
public static bool TryParseFromGuid(Guid value, [NotNullWhen(true)] out int? result)
{
if (value.ToString().EndsWith("-0000-0000-0000-000000000000") is false)
{
// We have a proper GUID, not one converted from an integer.
result = null;
return false;
Comment thread
AndyButland marked this conversation as resolved.
}

result = BitConverter.ToInt32(value.ToByteArray());
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,29 @@
namespace Umbraco.Cms.Core.Persistence.Repositories;

/// <summary>
/// Repository for external logins with Guid as key, so it can be shared for members and users
/// Repository for external logins with Guid as key, so it can be shared for members and users.
/// </summary>
public interface IExternalLoginWithKeyRepository : IReadWriteQueryRepository<int, IIdentityUserLogin>,
IQueryRepository<IIdentityUserToken>
{
/// <summary>
/// Replaces all external login providers for the user/member key
/// Replaces all external login providers for the user/member key.
/// </summary>
void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins);

/// <summary>
/// Replaces all external login provider tokens for the providers specified for the user/member key
/// Replaces all external login provider tokens for the providers specified for the user/member key.
/// </summary>
void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens);

/// <summary>
/// Deletes all external logins for the specified the user/member key
/// Deletes all external logins for the specified the user/member key.
/// </summary>
void DeleteUserLogins(Guid userOrMemberKey);

/// <summary>
/// Deletes external logins that aren't associated with the current collection of providers.
/// </summary>
/// <param name="currentLoginProviders">The names of the currently configured providers.</param>
void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
}
8 changes: 7 additions & 1 deletion src/Umbraco.Core/Persistence/Repositories/IUserRepository.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Linq.Expressions;
using System.Linq.Expressions;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Persistence.Querying;

Expand Down Expand Up @@ -160,4 +160,10 @@ IEnumerable<IUser> GetPagedResultsByQuery(
bool RemoveClientId(int id, string clientId);

IUser? GetByClientId(string clientId);

/// <summary>
/// Invalidates sessions for users that aren't associated with the current collection of providers.
/// </summary>
/// <param name="currentLoginProviders">The names of the currently configured providers.</param>
void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
}
34 changes: 32 additions & 2 deletions src/Umbraco.Core/Services/ExternalLoginService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,40 @@
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Security;
using Umbraco.Extensions;

namespace Umbraco.Cms.Core.Services;

public class ExternalLoginService : RepositoryService, IExternalLoginWithKeyService
{
private readonly IExternalLoginWithKeyRepository _externalLoginRepository;
private readonly IUserRepository _userRepository;

[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public ExternalLoginService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
IEventMessagesFactory eventMessagesFactory,
IExternalLoginWithKeyRepository externalLoginRepository)
: base(provider, loggerFactory, eventMessagesFactory) =>
: this(
provider,
loggerFactory,
eventMessagesFactory,
externalLoginRepository,
StaticServiceProvider.Instance.GetRequiredService<IUserRepository>())
{
}

public ExternalLoginService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
IEventMessagesFactory eventMessagesFactory,
IExternalLoginWithKeyRepository externalLoginRepository,
IUserRepository userRepository)
: base(provider, loggerFactory, eventMessagesFactory)
{
_externalLoginRepository = externalLoginRepository;
_userRepository = userRepository;
}

public IEnumerable<IIdentityUserLogin> Find(string loginProvider, string providerKey)
{
Expand Down Expand Up @@ -80,4 +99,15 @@ public void DeleteUserLogins(Guid userOrMemberKey)
scope.Complete();
}
}

/// <inheritdoc />
public void PurgeLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
_userRepository.InvalidateSessionsForRemovedProviders(currentLoginProviders);
_externalLoginRepository.DeleteUserLoginsForRemovedProviders(currentLoginProviders);
scope.Complete();
}
}
}
Loading