diff --git a/.env.example b/.env.example deleted file mode 100644 index 4f042dca..00000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -#Payment__PayPal__ClientId=PAYPAL_CLIENT_ID -#Payment__PayPal__Secret=PAYPAL_SECRET diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Roles/KeycloakRoleCreationJob.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Roles/KeycloakRoleCreationJob.cs new file mode 100644 index 00000000..58bdacb3 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Roles/KeycloakRoleCreationJob.cs @@ -0,0 +1,42 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using EShopOnAbp.IdentityService.Keycloak.Service; +using Microsoft.Extensions.Logging; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; + +namespace EShopOnAbp.IdentityService.BackgroundJobs.Roles; + +public class KeycloakRoleCreationJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly IKeycloakService _keycloakService; + private readonly ILogger _logger; + + public KeycloakRoleCreationJob(IKeycloakService keycloakService, ILogger logger) + { + _keycloakService = keycloakService; + _logger = logger; + } + + public override async Task ExecuteAsync(IdentityRoleCreationArgs args) + { + try + { + var existingRole = (await _keycloakService.GetRolesAsync()).FirstOrDefault(q => q.Name == args.Name); + if (existingRole != null) + { + return; + } + + await _keycloakService.CreateRoleAsync(args.Name); + } + catch (Exception e) + { + _logger.LogWarning($"Keycloak role creation with the name:{args.Name} failed!"); + throw; + } + } +} + +public record IdentityRoleCreationArgs(string Name); \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Roles/KeycloakRoleDeletionJob.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Roles/KeycloakRoleDeletionJob.cs new file mode 100644 index 00000000..cba36100 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Roles/KeycloakRoleDeletionJob.cs @@ -0,0 +1,42 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using EShopOnAbp.IdentityService.Keycloak.Service; +using Microsoft.Extensions.Logging; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; + +namespace EShopOnAbp.IdentityService.BackgroundJobs.Roles; + +public class KeycloakRoleDeletionJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly IKeycloakService _keycloakService; + private readonly ILogger _logger; + + public KeycloakRoleDeletionJob(IKeycloakService keycloakService, ILogger logger) + { + _keycloakService = keycloakService; + _logger = logger; + } + + public override async Task ExecuteAsync(IdentityRoleDeletionArgs args) + { + try + { + var existingRole = (await _keycloakService.GetRolesAsync()).FirstOrDefault(q => q.Name == args.Name); + if (existingRole == null) + { + return; + } + + await _keycloakService.DeleteRoleByIdAsync(existingRole.Id); + } + catch (Exception e) + { + _logger.LogWarning($"Could not delete the role with the name:{args.Name} from Keycloak server!"); + throw; + } + } +} + +public record IdentityRoleDeletionArgs(string Name); \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Roles/KeycloakRoleUpdatingJob.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Roles/KeycloakRoleUpdatingJob.cs new file mode 100644 index 00000000..3d4050a9 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Roles/KeycloakRoleUpdatingJob.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using EShopOnAbp.IdentityService.Keycloak.Service; +using Keycloak.Net.Models.Roles; +using Microsoft.Extensions.Logging; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.ObjectMapping; + +namespace EShopOnAbp.IdentityService.BackgroundJobs.Roles; + +public class KeycloakRoleUpdatingJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly IKeycloakService _keycloakService; + private readonly ILogger _logger; + private readonly IObjectMapper _objectMapper; + + public KeycloakRoleUpdatingJob(IKeycloakService keycloakService, ILogger logger, + IObjectMapper objectMapper) + { + _keycloakService = keycloakService; + _logger = logger; + _objectMapper = objectMapper; + } + + public override async Task ExecuteAsync(IdentityRoleUpdatingArgs args) + { + try + { + var existingRole = (await _keycloakService.GetRolesAsync()).FirstOrDefault(q => q.Name == args.OldName); + if (existingRole == null) + { + _logger.LogWarning($"Role with the name:{args.OldName} couldn't be found to update!"); + return; + } + + if (args.OldName != args.NewName) + { + existingRole.Name = args.NewName; + + await _keycloakService.UpdateRoleAsync(existingRole.Id, + _objectMapper.Map(existingRole) + ); + } + } + catch (Exception e) + { + _logger.LogWarning($"Could not update the role with the name:{args.OldName} from Keycloak server!"); + throw; + } + } +} + +public record IdentityRoleUpdatingArgs(string OldName, string NewName); \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Users/KeycloakUserCreationJob.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Users/KeycloakUserCreationJob.cs new file mode 100644 index 00000000..7f3b4400 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Users/KeycloakUserCreationJob.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using EShopOnAbp.IdentityService.Keycloak.Service; +using Keycloak.Net.Models.Roles; +using Keycloak.Net.Models.Users; +using Microsoft.Extensions.Logging; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Identity; +using Volo.Abp.ObjectMapping; + +namespace EShopOnAbp.IdentityService.BackgroundJobs.Users; + +public class KeycloakUserCreationJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly IKeycloakService _keycloakService; + private readonly ILogger _logger; + private readonly IObjectMapper _objectMapper; + + public KeycloakUserCreationJob(IKeycloakService keycloakService, + ILogger logger, IObjectMapper objectMapper) + { + _logger = logger; + _objectMapper = objectMapper; + _keycloakService = keycloakService; + } + + public override async Task ExecuteAsync(IdentityUserCreationArgs args) + { + var keycloakUser = new User + { + Email = args.Email, + UserName = args.UserName, + FirstName = args.Name, + LastName = args.Surname, + Enabled = args.IsActive, + Credentials = new List() + { + new() { Type = "password", Value = args.Password } + } + }; + + try + { + var result = await _keycloakService.CreateUserAsync(keycloakUser); + if (result) + { + if (args.RoleNames.Length != 0) + { + await AddRolesToKeycloakUserAsync(keycloakUser.UserName, args.RoleNames); + } + + _logger.LogInformation($"Keycloak user with the username:{args.UserName} has been created."); + } + } + catch (Exception e) + { + _logger.LogError($"Keycloak user creation with the Username:{args.UserName} has been failed!"); + throw; + } + } + + private async Task AddRolesToKeycloakUserAsync(string userName, string[] roleNames) + { + var user = (await _keycloakService.GetUsersAsync()).FirstOrDefault(q=>q.UserName == userName); + var allTheRoles = await _keycloakService.GetRolesAsync(); + var roles = allTheRoles.Where(q => roleNames.Contains(q.Name)).ToList(); + + await _keycloakService.AddRealmRolesToUserAsync( + user.Id, + _objectMapper.Map,List>(roles) + ); + _logger.LogInformation($"Keycloak roles:{roleNames} has been added to user with the username:{userName}."); + } +} + +public class IdentityUserCreationArgs +{ + public string Email { get; init; } + public string UserName { get; init; } + public string Name { get; init; } + public string Surname { get; init; } + public string Password { get; init; } + public bool IsActive { get; init; } + public string[] RoleNames { get; init; } + + public IdentityUserCreationArgs() // For deserialization + { + + } + + public IdentityUserCreationArgs(IdentityUserCreateDto input) + { + Email = input.Email; + UserName = input.UserName; + Name = input.Name; + Surname = input.Surname; + Password = input.Password; + IsActive = input.IsActive; + RoleNames = input.RoleNames; + } +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Users/KeycloakUserDeletionJob.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Users/KeycloakUserDeletionJob.cs new file mode 100644 index 00000000..1c7d7c57 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Users/KeycloakUserDeletionJob.cs @@ -0,0 +1,61 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using EShopOnAbp.IdentityService.Keycloak.Service; +using Microsoft.Extensions.Logging; +using Volo.Abp; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; + +namespace EShopOnAbp.IdentityService.BackgroundJobs.Users; + +public class KeycloakUserDeletionJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly IKeycloakService _keycloakService; + private readonly ILogger _logger; + + public KeycloakUserDeletionJob(IKeycloakService keycloakService, + ILogger logger) + { + _keycloakService = keycloakService; + _logger = logger; + } + + public override async Task ExecuteAsync(IdentityUserDeletionArgs args) + { + try + { + var keycloakUser = (await _keycloakService.GetUsersAsync()) + .FirstOrDefault(q => q.UserName == args.UserName); + if (keycloakUser == null) + { + _logger.LogError($"Keycloak user could not be found to delete! Username:{args.UserName}"); + throw new UserFriendlyException($"Keycloak user with the username:{args.UserName} could not be found!"); + } + + var result = await _keycloakService.DeleteUserAsync(keycloakUser.Id); + if (result) + { + _logger.LogInformation($"Keycloak user with the username:{args.UserName} has been deleted."); + } + } + catch (Exception e) + { + _logger.LogError($"Keycloak user deletion failed! Username:{args.UserName}"); + } + } +} + +public class IdentityUserDeletionArgs +{ + public string UserName { get; init; } + + public IdentityUserDeletionArgs() + { + } + + public IdentityUserDeletionArgs(string userName) + { + UserName = userName; + } +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Users/KeycloakUserUpdatingJob.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Users/KeycloakUserUpdatingJob.cs new file mode 100644 index 00000000..f3bc74a4 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/BackgroundJobs/Users/KeycloakUserUpdatingJob.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using EShopOnAbp.IdentityService.Keycloak.Service; +using Keycloak.Net.Models.Roles; +using Keycloak.Net.Models.Users; +using Microsoft.Extensions.Logging; +using Volo.Abp; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.ObjectMapping; + +namespace EShopOnAbp.IdentityService.BackgroundJobs.Users; + +public class KeycloakUserUpdatingJob : AsyncBackgroundJob, ITransientDependency +{ + private readonly IKeycloakService _keycloakService; + private readonly ILogger _logger; + private readonly IObjectMapper _objectMapper; + + public KeycloakUserUpdatingJob(IKeycloakService keycloakService, ILogger logger, + IObjectMapper objectMapper) + { + _keycloakService = keycloakService; + _logger = logger; + _objectMapper = objectMapper; + } + + public override async Task ExecuteAsync(IdentityUserUpdatingArgs args) + { + try + { + var keycloakUser = (await _keycloakService.GetUsersAsync()) + .FirstOrDefault(q => q.UserName == args.OldUserName); + if (keycloakUser == null) + { + _logger.LogError($"Keycloak user could not be found to update! Username:{args.OldUserName}"); + throw new UserFriendlyException($"Keycloak user with the username:{args.OldUserName} could not be found!"); + } + + IEnumerable differentFields = args.GetDifferentFields().ToList(); + foreach (var fieldChange in differentFields) + { + if (fieldChange.FieldName == "Email") + keycloakUser.Email = fieldChange.NewValue.ToString(); + if (fieldChange.FieldName == "UserName") // Username update is not working - not updating in keycloak + keycloakUser.UserName = fieldChange.NewValue.ToString(); + if (fieldChange.FieldName == "Name") + keycloakUser.FirstName = fieldChange.NewValue.ToString(); + if (fieldChange.FieldName == "Surname") + keycloakUser.LastName = fieldChange.NewValue.ToString(); + if (fieldChange.FieldName == "IsActive") + keycloakUser.Enabled = (bool)fieldChange.NewValue; + if (fieldChange.FieldName == "RoleNames") + keycloakUser.RealmRoles = (string[])fieldChange.NewValue; + } + + if (differentFields.Count() != 0) + { + var mappedUser = _objectMapper.Map(keycloakUser); + + var result = await _keycloakService.UpdateUserAsync( + keycloakUser.Id, + mappedUser + ); + + // User roles are not being updated - Updating manually + if (differentFields.FirstOrDefault(q => q.FieldName == "RoleNames") != null) + { + var oldRoles = (await _keycloakService.GetRolesAsync()) + .Where(q => args.OldRoleNames.Contains(q.Name)) + .ToList(); + var newRoles = (await _keycloakService.GetRolesAsync()) + .Where(q => args.RoleNames.Contains(q.Name)) + .ToList(); + if (oldRoles.Count > 0) + { + await _keycloakService.RemoveRealmRolesFromUserAsync(keycloakUser.Id, + _objectMapper.Map, List>(oldRoles)); + } + + if (newRoles.Count > 0) + { + await _keycloakService.AddRealmRolesToUserAsync(keycloakUser.Id, + _objectMapper.Map, List>(newRoles)); + } + } + + if (result) + { + _logger.LogInformation($"Keycloak user with the username:{args.UserName} has been updated."); + } + } + } + catch (Exception e) + { + _logger.LogWarning($"Keycloak user updating failed! Username:{args.UserName}"); + throw new UserFriendlyException($"Keycloak user updating failed! Username:{args.UserName}", + innerException: e); + } + } +} + +public class IdentityUserUpdatingArgs +{ + public string Email { get; init; } + public string OldEmail { get; init; } + public string UserName { get; init; } + public string OldUserName { get; init; } + public string Name { get; init; } + public string OldName { get; init; } + public string Surname { get; init; } + public string OldSurname { get; init; } + public bool IsActive { get; init; } + public bool OldIsActive { get; init; } + public string[] RoleNames { get; init; } + public string[] OldRoleNames { get; init; } + + public IEnumerable GetDifferentFields() + { + List fieldChanges = new List(); + + if ((Email, OldEmail) is not (null, null) && Email != OldEmail) + fieldChanges.Add(new FieldChange { FieldName = nameof(Email), NewValue = Email, OldValue = OldEmail }); + + if ((UserName, OldUserName) is not (null, null) && UserName != OldUserName) + fieldChanges.Add(new FieldChange + { FieldName = nameof(UserName), NewValue = UserName, OldValue = OldUserName }); + + if ((Name, OldName) is not (null, null) && Name != OldName) + fieldChanges.Add(new FieldChange { FieldName = nameof(Name), NewValue = Name, OldValue = OldName }); + + if ((Surname, OldSurname) is not (null, null) && Surname != OldSurname) + fieldChanges.Add(new FieldChange + { FieldName = nameof(Surname), NewValue = Surname, OldValue = OldSurname }); + + if (IsActive != OldIsActive) + fieldChanges.Add(new FieldChange + { FieldName = nameof(IsActive), NewValue = IsActive, OldValue = OldIsActive }); + + if (!Enumerable.SequenceEqual(RoleNames ?? Enumerable.Empty(), + OldRoleNames ?? Enumerable.Empty())) + fieldChanges.Add(new FieldChange + { FieldName = nameof(RoleNames), NewValue = RoleNames, OldValue = OldRoleNames }); + + return fieldChanges; + } + + public class FieldChange + { + public string FieldName { get; set; } + public object NewValue { get; set; } + public object OldValue { get; set; } + } +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/EShopOnAbp.IdentityService.Application.csproj b/services/identity/src/EShopOnAbp.IdentityService.Application/EShopOnAbp.IdentityService.Application.csproj index 9a5c929f..bb85acac 100644 --- a/services/identity/src/EShopOnAbp.IdentityService.Application/EShopOnAbp.IdentityService.Application.csproj +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/EShopOnAbp.IdentityService.Application.csproj @@ -11,7 +11,9 @@ + + diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/EShopOnAbpIdentityServiceAutoMapperProfile.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/EShopOnAbpIdentityServiceAutoMapperProfile.cs new file mode 100644 index 00000000..3146db25 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/EShopOnAbpIdentityServiceAutoMapperProfile.cs @@ -0,0 +1,21 @@ +using AutoMapper; +using EShopOnAbp.IdentityService.Keycloak.Service; +using Keycloak.Net.Models.Roles; +using Keycloak.Net.Models.Users; + +namespace EShopOnAbp.IdentityService; + +public class EShopOnAbpIdentityServiceAutoMapperProfile : Profile +{ + public EShopOnAbpIdentityServiceAutoMapperProfile() + { + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + CreateMap(); + + CreateMap().ReverseMap(); + CreateMap().ReverseMap(); + } +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/EventHandlers/Roles/KeycloakRolesEventHandler.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/EventHandlers/Roles/KeycloakRolesEventHandler.cs new file mode 100644 index 00000000..f5ffe8e4 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/EventHandlers/Roles/KeycloakRolesEventHandler.cs @@ -0,0 +1,47 @@ +// using System; +// using System.Linq; +// using System.Threading.Tasks; +// using EShopOnAbp.IdentityService.Keycloak; +// using Microsoft.Extensions.Logging; +// using Volo.Abp.DependencyInjection; +// using Volo.Abp.Domain.Entities.Events.Distributed; +// using Volo.Abp.EventBus.Distributed; +// using Volo.Abp.Identity; +// +// namespace EShopOnAbp.IdentityService.EventHandlers.Roles; +// +// public class KeycloakRolesEventHandler : IDistributedEventHandler>, +// ITransientDependency +// { +// private readonly KeycloakService _keycloakService; +// private readonly ILogger _logger; +// +// public KeycloakRolesEventHandler(KeycloakService keycloakService, ILogger logger) +// { +// _keycloakService = keycloakService; +// _logger = logger; +// } +// +// public async Task HandleEventAsync(EntityCreatedEto eventData) +// { +// try +// { +// var existingRole = (await _keycloakService.GetRolesAsync()).FirstOrDefault(q => q.Name == eventData.Entity.Name); +// if (existingRole != null) +// { +// return; +// } +// +// var isSuccess = await _keycloakService.CreateRoleAsync(eventData.Entity.Name); +// if (isSuccess) +// { +// _logger.LogInformation($"Role created:{eventData.Entity.Name}"); +// } +// } +// catch (Exception e) +// { +// _logger.LogError($"Keycloak role creation with the name:{eventData.Entity.Name} failed!"); +// throw; +// } +// } +// } \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/Identity/EShopIdentityRoleAppService.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/Identity/EShopIdentityRoleAppService.cs new file mode 100644 index 00000000..61a3840e --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/Identity/EShopIdentityRoleAppService.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using EShopOnAbp.IdentityService.BackgroundJobs.Roles; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Identity; + +namespace EShopOnAbp.IdentityService.Identity; + +[ExposeServices(typeof(IdentityRoleAppService), typeof(IIdentityRoleAppService))] +public class EShopIdentityRoleAppService : IdentityRoleAppService +{ + private readonly IBackgroundJobManager _backgroundJobManager; + private readonly IdentityRoleManager _identityRoleManager; + + public EShopIdentityRoleAppService(IdentityRoleManager roleManager, IIdentityRoleRepository roleRepository, + IBackgroundJobManager backgroundJobManager, IdentityRoleManager identityRoleManager) : base( + roleManager, roleRepository) + { + _backgroundJobManager = backgroundJobManager; + _identityRoleManager = identityRoleManager; + } + + public override async Task CreateAsync(IdentityRoleCreateDto input) + { + var result = await base.CreateAsync(input); + await _backgroundJobManager.EnqueueAsync(new IdentityRoleCreationArgs(result.Name)); + + return result; + } + + public override async Task UpdateAsync(Guid id, IdentityRoleUpdateDto input) + { + var role = await _identityRoleManager.GetByIdAsync(id); + var existingRoleName = role.Name; + var result = await base.UpdateAsync(id, input); + + await _backgroundJobManager.EnqueueAsync(new IdentityRoleUpdatingArgs(existingRoleName, input.Name)); + + return result; + } + + public override async Task DeleteAsync(Guid id) + { + var existingRole = await _identityRoleManager.FindByIdAsync(id.ToString()); + await base.DeleteAsync(id); + if (existingRole == null) + { + return; + } + + await _backgroundJobManager.EnqueueAsync(new IdentityRoleDeletionArgs(existingRole.Name)); + } +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/Identity/EShopIdentityUserAppService.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/Identity/EShopIdentityUserAppService.cs new file mode 100644 index 00000000..f56f7d4a --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/Identity/EShopIdentityUserAppService.cs @@ -0,0 +1,92 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using EShopOnAbp.IdentityService.BackgroundJobs.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Identity; + +namespace EShopOnAbp.IdentityService.Identity; + +[ExposeServices(typeof(IdentityUserAppService), typeof(IIdentityUserAppService))] +public class EShopIdentityUserAppService : IdentityUserAppService +{ + private readonly IIdentityUserRepository _userRepository; + private readonly IBackgroundJobManager _backgroundJobManager; + private readonly IIdentityRoleRepository _roleRepository; + + public EShopIdentityUserAppService( + IdentityUserManager userManager, + IIdentityUserRepository userRepository, + IIdentityRoleRepository roleRepository, + IOptions identityOptions, + IBackgroundJobManager backgroundJobManager) : base(userManager, + userRepository, + roleRepository, + identityOptions) + { + _userRepository = userRepository; + _roleRepository = roleRepository; + _backgroundJobManager = backgroundJobManager; + } + + public override async Task CreateAsync(IdentityUserCreateDto input) + { + var createdUser = await base.CreateAsync(input); + await _backgroundJobManager.EnqueueAsync(new IdentityUserCreationArgs(input)); + + return createdUser; + } + + public override async Task UpdateAsync(Guid id, IdentityUserUpdateDto input) + { + var existingUser = await _userRepository.GetAsync(id); + // Disabling username updating. Keycloak service is unavailable to update the username field! + if (input.UserName != existingUser.UserName) + { + input.UserName = existingUser.UserName; + } + var args = await CreateIdentityUserUpdatingArgsAsync(existingUser, input); + var updatedUser = await base.UpdateAsync(id, input); + await _backgroundJobManager.EnqueueAsync(args); + + return updatedUser; + } + + public override async Task DeleteAsync(Guid id) + { + var user = await _userRepository.FindAsync(id); + await base.DeleteAsync(id); + if (user != null) + { + await _backgroundJobManager.EnqueueAsync(new IdentityUserDeletionArgs(user.UserName)); + } + } + + private async Task CreateIdentityUserUpdatingArgsAsync(IdentityUser existingUser, + IdentityUserUpdateDto input) + { + var userRoles = existingUser.Roles.Select(q => q.RoleId).ToList(); + var roles = await _roleRepository.GetListAsync(); + + var args = new IdentityUserUpdatingArgs + { + Email = input.Email, + OldEmail = existingUser.Email, + UserName = input.UserName, + OldUserName = existingUser.UserName, + Name = input.Name, + OldName = existingUser.Name, + Surname = input.Surname, + OldSurname = existingUser.Surname, + IsActive = input.IsActive, + OldIsActive = existingUser.IsActive, + RoleNames = input.RoleNames, + OldRoleNames = roles.Where(q => userRoles.Contains(q.Id)).Select(q => q.Name).ToArray() + }; + + return args; + } +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/IdentityServiceApplicationModule.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/IdentityServiceApplicationModule.cs index 502d3733..7c9e2fd2 100644 --- a/services/identity/src/EShopOnAbp.IdentityService.Application/IdentityServiceApplicationModule.cs +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/IdentityServiceApplicationModule.cs @@ -1,5 +1,8 @@ -using Microsoft.Extensions.DependencyInjection; +using EShopOnAbp.IdentityService.Keycloak; +using EShopOnAbp.IdentityService.Keycloak.Service; +using Microsoft.Extensions.DependencyInjection; using Volo.Abp.AutoMapper; +using Volo.Abp.BackgroundJobs; using Volo.Abp.Identity; using Volo.Abp.Modularity; @@ -8,17 +11,29 @@ namespace EShopOnAbp.IdentityService [DependsOn( typeof(IdentityServiceDomainModule), typeof(IdentityServiceApplicationContractsModule), - typeof(AbpIdentityApplicationModule) - )] + typeof(AbpIdentityApplicationModule), + typeof(AbpBackgroundJobsModule) + )] public class IdentityServiceApplicationModule : AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) { + var configuration = context.Services.GetConfiguration(); + context.Services.AddAutoMapperObjectMapper(); Configure(options => { options.AddMaps(validate: true); }); + + Configure(options => + { + options.Url = configuration["Keycloak:url"]; + options.AdminUserName = configuration["Keycloak:adminUsername"]; + options.AdminPassword = configuration["Keycloak:adminPassword"]; + options.RealmName = configuration["Keycloak:realmName"]; + } + ); } } -} +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/CachedKeycloakRole.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/CachedKeycloakRole.cs new file mode 100644 index 00000000..82e298e6 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/CachedKeycloakRole.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using Volo.Abp.Caching; + +namespace EShopOnAbp.IdentityService.Keycloak.Service; + +[CacheName("KeycloakRole")] +public class CachedKeycloakRole +{ + public string Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public bool? Composite { get; set; } + public CachedRoleComposite Composites { get; set; } + public bool? ClientRole { get; set; } + public string ContainerId { get; set; } + public IDictionary Attributes { get; set; } +} + +public class CachedRoleComposite +{ + public IDictionary Client { get; set; } + public IEnumerable Realm { get; set; } +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/CachedKeycloakUser.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/CachedKeycloakUser.cs new file mode 100644 index 00000000..671cd8f7 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/CachedKeycloakUser.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using Volo.Abp.Caching; + +namespace EShopOnAbp.IdentityService.Keycloak.Service; + +[CacheName("KeycloakUser")] +public class CachedKeycloakUser +{ + public string Id { get; set; } + public long CreatedTimestamp { get; set; } + public string UserName { get; set; } + public bool? Enabled { get; set; } + public bool? Totp { get; set; } + public bool? EmailVerified { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public string Email { get; set; } + public Collection DisableableCredentialTypes { get; set; } + public Collection RequiredActions { get; set; } + public int? NotBefore { get; set; } + public Dictionary> Attributes { get; set; } + public IDictionary ClientRoles { get; set; } + public string FederationLink { get; set; } + public IEnumerable Groups { get; set; } + public string Origin { get; set; } + public string[] RealmRoles { get; set; } + public string Self { get; set; } + public string ServiceAccountClientId { get; set; } + public CachedUserAccess Access { get; set; } + public IEnumerable ClientConsents { get; set; } + public IEnumerable Credentials { get; set; } + public IEnumerable FederatedIdentities { get; set; } +} + +public class CachedUserConsent +{ + public string ClientId { get; set; } + public IEnumerable GrantedClientScopes { get; set; } + public long? CreatedDate { get; set; } + public long? LastUpdatedDate { get; set; } +} + +public class CachedUserAccess +{ + public bool? ManageGroupMembership { get; set; } + public bool? View { get; set; } + public bool? MapRoles { get; set; } + public bool? Impersonate { get; set; } + public bool? Manage { get; set; } +} + +public class CachedCredentials +{ + public string Algorithm { get; set; } + public IDictionary Config { get; set; } + public int? Counter { get; set; } + public long? CreatedDate { get; set; } + public string Device { get; set; } + public int? Digits { get; set; } + public int? HashIterations { get; set; } + public string HashSaltedValue { get; set; } + public int? Period { get; set; } + public string Salt { get; set; } + public bool? Temporary { get; set; } + public string Type { get; set; } + public string Value { get; set; } +} + +public class CachedFederatedIdentity +{ + public string IdentityProvider { get; set; } + public string UserId { get; set; } + public string UserName { get; set; } +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/IKeycloakService.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/IKeycloakService.cs new file mode 100644 index 00000000..aa09d8d3 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/IKeycloakService.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keycloak.Net.Models.Roles; +using Keycloak.Net.Models.Users; +using Volo.Abp.DependencyInjection; + +namespace EShopOnAbp.IdentityService.Keycloak.Service; + +/* + * This will be an external service from the Keycloak package. + * Keeping it under Application layer because the Keycloak.Net.Core package requires .Net6 target framework. + * Application.Contracts targets netstandard2.0 + */ +public interface IKeycloakService : ITransientDependency +{ + Task> GetUsersAsync(string search = null, string username = null, string email = null, CancellationToken cancellationToken = default); + + Task CreateUserAsync(User user, CancellationToken cancellationToken = default); + + Task UpdateUserAsync(string userId, User user, CancellationToken cancellationToken = default); + + Task DeleteUserAsync(string userId, CancellationToken cancellationToken = default); + + Task> GetRolesAsync(CancellationToken cancellationToken = default); + + Task AddRealmRolesToUserAsync(string userId, IEnumerable roles, CancellationToken cancellationToken = default); + + Task RemoveRealmRolesFromUserAsync(string userId, IEnumerable roles, CancellationToken cancellationToken = default); + + Task CreateRoleAsync(string name, CancellationToken cancellationToken = default); + + Task DeleteRoleByIdAsync(string id, CancellationToken cancellationToken = default); + + Task UpdateRoleAsync(string id, Role role, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/KeycloakClientOptions.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/KeycloakClientOptions.cs new file mode 100644 index 00000000..9498cf56 --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/KeycloakClientOptions.cs @@ -0,0 +1,8 @@ +namespace EShopOnAbp.IdentityService.Keycloak.Service; + public class KeycloakClientOptions + { + public string Url { get; set; } + public string AdminUserName { get; set; } + public string AdminPassword { get; set; } + public string RealmName { get; set; } + } \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/KeycloakService.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/KeycloakService.cs new file mode 100644 index 00000000..73f2b41b --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/KeycloakService.cs @@ -0,0 +1,161 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Keycloak.Net; +using Keycloak.Net.Models.Roles; +using Keycloak.Net.Models.Users; +using Microsoft.Extensions.Options; +using Volo.Abp.Caching; +using Volo.Abp.DependencyInjection; +using Volo.Abp.ObjectMapping; + +namespace EShopOnAbp.IdentityService.Keycloak.Service; + +/* + * This will be an external service from the Keycloak package + */ +[ExposeServices(typeof(IKeycloakService), typeof(KeycloakService))] +public class KeycloakService : IKeycloakService +{ + protected const string UsersCacheKey = "KeycloakUsers"; + protected const string RolesCacheKey = "KeycloakRoles"; + private readonly IObjectMapper _objectMapper; + private readonly IDistributedCache, string> _keycloakUsersCache; + private readonly IDistributedCache, string> _keycloakRolesCache; + + private readonly KeycloakClient _keycloakClient; + private readonly KeycloakClientOptions _keycloakOptions; + + public KeycloakService( + IOptions keycloakOptions, + IObjectMapper objectMapper, + IDistributedCache, string> keycloakUsersCache, + IDistributedCache, string> keycloakRolesCache) + { + _objectMapper = objectMapper; + _keycloakUsersCache = keycloakUsersCache; + _keycloakRolesCache = keycloakRolesCache; + + _keycloakOptions = keycloakOptions.Value; + _keycloakClient = new KeycloakClient( + _keycloakOptions.Url, + _keycloakOptions.AdminUserName, + _keycloakOptions.AdminPassword + ); + } + + public async Task> GetUsersAsync(string search = null, string username = null, + string email = null, + CancellationToken cancellationToken = default) + { + var users = await _keycloakUsersCache.GetAsync(UsersCacheKey, token: cancellationToken); + if (users == null) + { + var result = await _keycloakClient.GetUsersAsync(_keycloakOptions.RealmName, search: search, + username: username, + email: email, cancellationToken: cancellationToken); + users = _objectMapper.Map, List>(result.ToList()); + await _keycloakUsersCache.SetAsync(UsersCacheKey, users, token: cancellationToken); + } + + return users; + } + + public async Task CreateUserAsync(User user, CancellationToken cancellationToken = default) + { + var result = await _keycloakClient.CreateUserAsync(_keycloakOptions.RealmName, user, cancellationToken); + if (result) + { + await _keycloakUsersCache.RemoveAsync(UsersCacheKey, token: cancellationToken); + } + + return result; + } + + public async Task UpdateUserAsync(string userId, User user, CancellationToken cancellationToken = default) + { + var result = await _keycloakClient.UpdateUserAsync(_keycloakOptions.RealmName, userId, user, cancellationToken); + if (result) + { + await _keycloakUsersCache.RemoveAsync(UsersCacheKey, token: cancellationToken); + } + + return result; + } + + public async Task DeleteUserAsync(string userId, CancellationToken cancellationToken = default) + { + var result = await _keycloakClient.DeleteUserAsync(_keycloakOptions.RealmName, userId, cancellationToken); + if (result) + { + await _keycloakUsersCache.RemoveAsync(UsersCacheKey, token: cancellationToken); + } + + return result; + } + + public async Task> GetRolesAsync(CancellationToken cancellationToken = default) + { + var roles = await _keycloakRolesCache.GetAsync(RolesCacheKey, token: cancellationToken); + if (roles == null) + { + var result = + (await _keycloakClient.GetRolesAsync(_keycloakOptions.RealmName, cancellationToken: cancellationToken)) + .ToList(); + roles = _objectMapper.Map, List>(result.ToList()); + + await _keycloakRolesCache.SetAsync(RolesCacheKey, roles, token: cancellationToken); + } + + return roles; + } + + public Task AddRealmRolesToUserAsync(string userId, IEnumerable roles, + CancellationToken cancellationToken = default) + { + return _keycloakClient.AddRealmRoleMappingsToUserAsync(_keycloakOptions.RealmName, userId, roles, + cancellationToken); + } + + public Task RemoveRealmRolesFromUserAsync(string userId, IEnumerable roles, + CancellationToken cancellationToken = default) + { + return _keycloakClient.DeleteRealmRoleMappingsFromUserAsync(_keycloakOptions.RealmName, userId, roles, + cancellationToken); + } + + public async Task CreateRoleAsync(string name, CancellationToken cancellationToken = default) + { + var result = await _keycloakClient.CreateRoleAsync(_keycloakOptions.RealmName, new Role() { Name = name }, + cancellationToken); + if (result) + { + await _keycloakRolesCache.RemoveAsync(RolesCacheKey, token: cancellationToken); + } + + return result; + } + + public async Task DeleteRoleByIdAsync(string id, CancellationToken cancellationToken = default) + { + var result = await _keycloakClient.DeleteRoleByIdAsync(_keycloakOptions.RealmName, id, cancellationToken); + if (result) + { + await _keycloakRolesCache.RemoveAsync(RolesCacheKey, token: cancellationToken); + } + + return result; + } + + public async Task UpdateRoleAsync(string id, Role role, CancellationToken cancellationToken = default) + { + var result = await _keycloakClient.UpdateRoleByIdAsync(_keycloakOptions.RealmName, id, role, cancellationToken); + if (result) + { + await _keycloakRolesCache.RemoveAsync(RolesCacheKey, token: cancellationToken); + } + + return result; + } +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/KeycloakServiceExtensions.cs b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/KeycloakServiceExtensions.cs new file mode 100644 index 00000000..21840fbe --- /dev/null +++ b/services/identity/src/EShopOnAbp.IdentityService.Application/Keycloak/Service/KeycloakServiceExtensions.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Keycloak.Net.Models.Roles; +using Keycloak.Net.Models.Users; + +namespace EShopOnAbp.IdentityService.Keycloak.Service; + +/* Extensions to create unique strings based on list values */ +public static class KeycloakServiceExtensions +{ + public static string GenerateCacheKeyBasedOnValues(this IEnumerable roles) + { + return GenerateUniqueCacheKeyBasedOnList(roles); + } + + public static string GenerateCacheKeyBasedOnValues(this IEnumerable users) + { + return GenerateUniqueCacheKeyBasedOnList(users); + } + + private static string GenerateUniqueCacheKeyBasedOnList(IEnumerable list) + { + string serializedList = JsonSerializer.Serialize(list); + byte[] bytes = Encoding.UTF8.GetBytes(serializedList); + byte[] hash = SHA256.Create().ComputeHash(bytes); + string hashString = BitConverter.ToString(hash).Replace("-", ""); + return hashString; + } +} \ No newline at end of file diff --git a/services/identity/src/EShopOnAbp.IdentityService.HttpApi.Host/appsettings.json b/services/identity/src/EShopOnAbp.IdentityService.HttpApi.Host/appsettings.json index 9cdfa28f..f37922ad 100644 --- a/services/identity/src/EShopOnAbp.IdentityService.HttpApi.Host/appsettings.json +++ b/services/identity/src/EShopOnAbp.IdentityService.HttpApi.Host/appsettings.json @@ -9,6 +9,12 @@ "SwaggerClientId": "WebGateway_Swagger", "SwaggerClientSecret": "1q2w3e*" }, + "Keycloak": { + "url": "http://localhost:8080", + "adminUsername": "admin", + "adminPassword": "1q2w3E*", + "realmName": "master" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/shared/EShopOnAbp.Keycloak.DbMigrator/KeycloakDataSeeder.cs b/shared/EShopOnAbp.Keycloak.DbMigrator/KeycloakDataSeeder.cs index 73259647..3f297757 100644 --- a/shared/EShopOnAbp.Keycloak.DbMigrator/KeycloakDataSeeder.cs +++ b/shared/EShopOnAbp.Keycloak.DbMigrator/KeycloakDataSeeder.cs @@ -39,7 +39,7 @@ public class KeyCloakDataSeeder : IDataSeedContributor, ITransientDependency { await UpdateRealmSettingsAsync(); await UpdateAdminUserAsync(); - await CreateRoleMapperAsync(); + await CreateRoleMapperAsync(); // roles scope await CreateClientScopesAsync(); await CreateClientsAsync(); }