V14: Invalidate user tokens (#15651)
* Adding revoke user auth token handler and registering it * Maintain method clarity by grouping new calls into its own method * Rename functions to what they do * Suggested linq function of tripple nesting * Reduce nesting by early loop continuation * Fix PR suggestion async typo * Review suggestions * Log msg alignment between members and users --------- Co-authored-by: Sven Geusens <sge@umbraco.dk>
This commit is contained in:
committed by
GitHub
parent
71b3076de9
commit
8c6e03d346
@@ -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);
|
||||
|
||||
@@ -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<UserSavingNotification, RevokeUserAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<UserSavedNotification, RevokeUserAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<UserDeletedNotification, RevokeUserAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<UserGroupDeletingNotification, RevokeUserAuthenticationTokensNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<UserGroupDeletedNotification, RevokeUserAuthenticationTokensNotificationHandler>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -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<UserSavingNotification>,
|
||||
INotificationAsyncHandler<UserSavedNotification>,
|
||||
INotificationAsyncHandler<UserDeletedNotification>,
|
||||
INotificationAsyncHandler<UserGroupDeletingNotification>,
|
||||
INotificationAsyncHandler<UserGroupDeletedNotification>
|
||||
{
|
||||
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<RevokeUserAuthenticationTokensNotificationHandler> _logger;
|
||||
|
||||
public RevokeUserAuthenticationTokensNotificationHandler(
|
||||
IUserService userService,
|
||||
IUserGroupService userGroupService,
|
||||
IOpenIddictTokenManager tokenManager,
|
||||
ILogger<RevokeUserAuthenticationTokensNotificationHandler> 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<Guid, UserStartNodesAndGroupAccess>();
|
||||
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<Guid, UserStartNodesAndGroupAccess>? preSavingUsersState = null;
|
||||
|
||||
if (notification.State.TryGetValue(NotificationStateKey, out var value))
|
||||
{
|
||||
preSavingUsersState = value as Dictionary<Guid, UserStartNodesAndGroupAccess>;
|
||||
}
|
||||
|
||||
// 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<Guid, IEnumerable<IUser>>();
|
||||
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<Guid, IEnumerable<IUser>>? preDeletingUsersInGroups = null;
|
||||
|
||||
if (notification.State.TryGetValue(NotificationStateKey, out var value))
|
||||
{
|
||||
preDeletingUsersInGroups = value as Dictionary<Guid, IEnumerable<IUser>>;
|
||||
}
|
||||
|
||||
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<UserStartNodesAndGroupAccess?> 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<IEnumerable<IUser>?> 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<Guid> GroupKeys { get; }
|
||||
|
||||
public int[]? StartContentIds { get; }
|
||||
|
||||
public int[]? StartMediaIds { get; }
|
||||
|
||||
public UserStartNodesAndGroupAccess(IEnumerable<Guid> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user