diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs index 03504a5099..a368334954 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs @@ -185,9 +185,12 @@ public class BackOfficeController : SecurityControllerBase [MapToApiVersion("1.0")] public async Task Signout(CancellationToken cancellationToken) { - var userName = await GetUserNameFromAuthCookie(); + AuthenticateResult cookieAuthResult = await HttpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType); + var userName = cookieAuthResult.Principal?.Identity?.Name; + var userId = cookieAuthResult.Principal?.Identity?.GetUserId(); await _backOfficeSignInManager.SignOutAsync(); + _backOfficeUserManager.NotifyLogoutSuccess(cookieAuthResult.Principal ?? User, userId); _logger.LogInformation( "User {UserName} from IP address {RemoteIpAddress} has logged out", diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/AuditLogBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/AuditLogBuilderExtensions.cs index a962ba558e..edca89e56b 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/AuditLogBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/AuditLogBuilderExtensions.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Security; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Notifications; namespace Umbraco.Cms.Api.Management.DependencyInjection; @@ -9,6 +11,13 @@ internal static class AuditLogBuilderExtensions internal static IUmbracoBuilder AddAuditLogs(this IUmbracoBuilder builder) { builder.Services.AddTransient(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); return builder; } diff --git a/src/Umbraco.Cms.Api.Management/Security/BackOfficeUserManagerAuditer.cs b/src/Umbraco.Cms.Api.Management/Security/BackOfficeUserManagerAuditer.cs new file mode 100644 index 0000000000..561e67bd38 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Security/BackOfficeUserManagerAuditer.cs @@ -0,0 +1,142 @@ +using System.Globalization; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Security; + +/// +/// Binds to notifications to write audit logs for the +/// +internal sealed class BackOfficeUserManagerAuditer : + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler +{ + private readonly IAuditService _auditService; + private readonly IUserService _userService; + + public BackOfficeUserManagerAuditer(IAuditService auditService, IUserService userService) + { + _auditService = auditService; + _userService = userService; + } + + public void Handle(UserForgotPasswordChangedNotification notification) => + WriteAudit( + notification.PerformingUserId, + notification.AffectedUserId, + notification.IpAddress, + "umbraco/user/password/forgot/change", + "password forgot/change"); + + public void Handle(UserForgotPasswordRequestedNotification notification) => + WriteAudit( + notification.PerformingUserId, + notification.AffectedUserId, + notification.IpAddress, + "umbraco/user/password/forgot/request", + "password forgot/request"); + + public void Handle(UserLoginFailedNotification notification) => + WriteAudit( + notification.PerformingUserId, + null, + notification.IpAddress, + "umbraco/user/sign-in/failed", + "login failed"); + + public void Handle(UserLoginSuccessNotification notification) + => WriteAudit( + notification.PerformingUserId, + notification.AffectedUserId, + notification.IpAddress, + "umbraco/user/sign-in/login", + "login success"); + + public void Handle(UserLogoutSuccessNotification notification) + => WriteAudit( + notification.PerformingUserId, + notification.AffectedUserId, + notification.IpAddress, + "umbraco/user/sign-in/logout", + "logout success"); + + public void Handle(UserPasswordChangedNotification notification) => + WriteAudit( + notification.PerformingUserId, + notification.AffectedUserId, + notification.IpAddress, + "umbraco/user/password/change", + "password change"); + + public void Handle(UserPasswordResetNotification notification) => + WriteAudit( + notification.PerformingUserId, + notification.AffectedUserId, + notification.IpAddress, + "umbraco/user/password/reset", + "password reset"); + + private static string FormatEmail(IMembershipUser? user) => + user is null ? string.Empty : user.Email.IsNullOrWhiteSpace() ? string.Empty : $"<{user.Email}>"; + + private void WriteAudit( + string performingId, + string? affectedId, + string ipAddress, + string eventType, + string eventDetails) + { + int? performingIdAsInt = ParseUserId(performingId); + int? affectedIdAsInt = ParseUserId(affectedId); + + WriteAudit(performingIdAsInt, affectedIdAsInt, ipAddress, eventType, eventDetails); + } + + private static int? ParseUserId(string? id) + => int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var isAsInt) ? isAsInt : null; + + private void WriteAudit( + int? performingId, + int? affectedId, + string ipAddress, + string eventType, + string eventDetails) + { + var performingDetails = "User UNKNOWN:0"; + if (performingId.HasValue) + { + IUser? performingUser = _userService.GetUserById(performingId.Value); + performingDetails = performingUser is null + ? $"User UNKNOWN:{performingId.Value}" + : $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}"; + } + + var affectedDetails = "User UNKNOWN:0"; + if (affectedId.HasValue) + { + IUser? affectedUser = _userService.GetUserById(affectedId.Value); + affectedDetails = affectedUser is null + ? $"User UNKNOWN:{affectedId.Value}" + : $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}"; + } + + _auditService.Write( + performingId ?? 0, + performingDetails, + ipAddress, + DateTime.UtcNow, + affectedId ?? 0, + affectedDetails, + eventType, + eventDetails); + } +}