diff --git a/src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs b/src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs index 428ef09f72..3d4d4dacc3 100644 --- a/src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs +++ b/src/Umbraco.Cms.Api.Delivery/Handlers/RevokeMemberAuthenticationTokensNotificationHandler.cs @@ -73,7 +73,7 @@ internal sealed class RevokeMemberAuthenticationTokensNotificationHandler return; } - _logger.LogInformation("Deleting {count} active tokens for member with ID {id}", tokens.Length, member.Id); + _logger.LogInformation("Revoking {count} active tokens for member with ID {id}", tokens.Length, member.Id); foreach (var token in tokens) { await _tokenManager.DeleteAsync(token); diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs index 5ed72e92b5..3199cfbecc 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs @@ -1,10 +1,12 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Common.DependencyInjection; +using Umbraco.Cms.Api.Management.Handlers; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Api.Management.Middleware; using Umbraco.Cms.Api.Management.Security; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Infrastructure.Security; using Umbraco.Cms.Web.Common.ApplicationBuilder; @@ -17,7 +19,19 @@ public static class BackOfficeAuthBuilderExtensions builder .AddAuthentication() .AddUmbracoOpenIddict() - .AddBackOfficeLogin(); + .AddBackOfficeLogin() + .AddTokenRevocation(); + + return builder; + } + + private static IUmbracoBuilder AddTokenRevocation(this IUmbracoBuilder builder) + { + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); return builder; } diff --git a/src/Umbraco.Cms.Api.Management/Handlers/RevokeUserAuthenticationTokensNotificationHandler.cs b/src/Umbraco.Cms.Api.Management/Handlers/RevokeUserAuthenticationTokensNotificationHandler.cs new file mode 100644 index 0000000000..5749dce5b4 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Handlers/RevokeUserAuthenticationTokensNotificationHandler.cs @@ -0,0 +1,223 @@ +using Microsoft.Extensions.Logging; +using OpenIddict.Abstractions; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Handlers; + +internal sealed class RevokeUserAuthenticationTokensNotificationHandler : + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler +{ + private const string NotificationStateKey = "Umbraco.Cms.Api.Management.Handlers.RevokeUserAuthenticationTokensNotificationHandler"; + + private readonly IUserService _userService; + private readonly IUserGroupService _userGroupService; + private readonly IOpenIddictTokenManager _tokenManager; + private readonly ILogger _logger; + + public RevokeUserAuthenticationTokensNotificationHandler( + IUserService userService, + IUserGroupService userGroupService, + IOpenIddictTokenManager tokenManager, + ILogger logger) + { + _userService = userService; + _userGroupService = userGroupService; + _tokenManager = tokenManager; + _logger = logger; + } + + // We need to know the pre-saving state of the saved users in order to compare if their access has changed + public async Task HandleAsync(UserSavingNotification notification, CancellationToken cancellationToken) + { + var usersAccess = new Dictionary(); + foreach (IUser user in notification.SavedEntities) + { + UserStartNodesAndGroupAccess? priorUserAccess = await GetRelevantUserAccessDataByUserKeyAsync(user.Key); + if (priorUserAccess == null) + { + continue; + } + + usersAccess.Add(user.Key, priorUserAccess); + } + + notification.State[NotificationStateKey] = usersAccess; + } + + public async Task HandleAsync(UserSavedNotification notification, CancellationToken cancellationToken) + { + Dictionary? preSavingUsersState = null; + + if (notification.State.TryGetValue(NotificationStateKey, out var value)) + { + preSavingUsersState = value as Dictionary; + } + + // If we have a new user, there is no token + if (preSavingUsersState is null || preSavingUsersState.Count == 0) + { + return; + } + + foreach (IUser user in notification.SavedEntities) + { + if (user.IsSuper()) + { + continue; + } + + // When a user is locked out and/or un-approved, make sure we revoke all tokens + if (user.IsLockedOut || user.IsApproved is false) + { + await RevokeTokensAsync(user); + continue; + } + + // Don't revoke admin tokens to prevent log out when accidental changes + if (user.IsAdmin()) + { + continue; + } + + // Check if the user access has changed - we also need to revoke all tokens in this case + if (preSavingUsersState.TryGetValue(user.Key, out UserStartNodesAndGroupAccess? preSavingState)) + { + UserStartNodesAndGroupAccess postSavingState = MapToUserStartNodesAndGroupAccess(user); + if (preSavingState.CompareAccess(postSavingState) == false) + { + await RevokeTokensAsync(user); + } + } + } + } + + // We can only delete non-logged in users in Umbraco, meaning that such will not have a token, + // so this is just a precaution in case this workflow changes in the future + public async Task HandleAsync(UserDeletedNotification notification, CancellationToken cancellationToken) + { + foreach (IUser user in notification.DeletedEntities) + { + await RevokeTokensAsync(user); + } + } + + // We need to know the pre-deleting state of the users part of the deleted group to revoke their tokens + public async Task HandleAsync(UserGroupDeletingNotification notification, CancellationToken cancellationToken) + { + var usersInGroups = new Dictionary>(); + foreach (IUserGroup userGroup in notification.DeletedEntities) + { + var users = await GetUsersByGroupKeyAsync(userGroup.Key); + if (users == null) + { + continue; + } + + usersInGroups.Add(userGroup.Key, users); + } + + notification.State[NotificationStateKey] = usersInGroups; + } + + public async Task HandleAsync(UserGroupDeletedNotification notification, CancellationToken cancellationToken) + { + Dictionary>? preDeletingUsersInGroups = null; + + if (notification.State.TryGetValue(NotificationStateKey, out var value)) + { + preDeletingUsersInGroups = value as Dictionary>; + } + + if (preDeletingUsersInGroups is null) + { + return; + } + + // since the user group was deleted, we can only use the information we collected before the deletion + // this means that we will not be able to detect users in any groups that were eventually deleted (due to implementor/3th party supplier interference) + // that were not in the initial to be deleted list + foreach (IUser user in preDeletingUsersInGroups + .Where(group => notification.DeletedEntities.Any(entity => group.Key == entity.Key)) + .SelectMany(group => group.Value)) + { + await RevokeTokensAsync(user); + } + } + + // Get data about the user before saving + private async Task GetRelevantUserAccessDataByUserKeyAsync(Guid userKey) + { + IUser? user = await _userService.GetAsync(userKey); + + return user is null + ? null + : MapToUserStartNodesAndGroupAccess(user); + } + + private UserStartNodesAndGroupAccess MapToUserStartNodesAndGroupAccess(IUser user) + => new(user.Groups.Select(g => g.Key), user.StartContentIds, user.StartMediaIds); + + // Get data about the users part of a group before deleting it + private async Task?> GetUsersByGroupKeyAsync(Guid userGroupKey) + { + IUserGroup? userGroup = await _userGroupService.GetAsync(userGroupKey); + + return userGroup is null + ? null + : _userService.GetAllInGroup(userGroup.Id); + } + + private async Task RevokeTokensAsync(IUser user) + { + var tokens = await _tokenManager.FindBySubjectAsync(user.Key.ToString()).ToArrayAsync(); + if (tokens.Length == 0) + { + return; + } + + _logger.LogInformation("Revoking {count} active tokens for user with ID {id}", tokens.Length, user.Id); + foreach (var token in tokens) + { + await _tokenManager.DeleteAsync(token); + } + } + + private class UserStartNodesAndGroupAccess + { + public IEnumerable GroupKeys { get; } + + public int[]? StartContentIds { get; } + + public int[]? StartMediaIds { get; } + + public UserStartNodesAndGroupAccess(IEnumerable groupKeys, int[]? startContentIds, int[]? startMediaIds) + { + GroupKeys = groupKeys; + StartContentIds = startContentIds; + StartMediaIds = startMediaIds; + } + + public bool CompareAccess(UserStartNodesAndGroupAccess other) + { + var areContentStartNodesEqual = (StartContentIds == null && other.StartContentIds == null) || + (StartContentIds != null && other.StartContentIds != null && + StartContentIds.SequenceEqual(other.StartContentIds)); + + var areMediaStartNodesEqual = (StartMediaIds == null && other.StartMediaIds == null) || + (StartMediaIds != null && other.StartMediaIds != null && + StartMediaIds.SequenceEqual(other.StartMediaIds)); + + return areContentStartNodesEqual && + areMediaStartNodesEqual && + GroupKeys.SequenceEqual(other.GroupKeys); + } + } +}