From 72a6ea8cba8eb71492eb88c93d22eff7082ac0cc Mon Sep 17 00:00:00 2001 From: Jeff Stirn Date: Fri, 25 Jul 2025 19:55:20 +0200 Subject: [PATCH 1/3] UserAccess module based on Microsoft Identity --- .gitignore | 3 + .../CompanyName.MyMeetings.API.csproj | 5 +- .../HasPermissionAuthorizationHandler.cs | 48 --- .../ExecutionContextAccessor.cs | 13 +- .../Extensions/SwaggerExtensions.cs | 2 +- .../Configuration/UserAccessModuleSelector.cs | 34 ++ .../MeetingGroupProposalsController.cs | 2 +- .../Meetings/Countries/CountriesController.cs | 2 +- ...eetingCommentingConfigurationController.cs | 2 +- .../MeetingCommentsController.cs | 2 +- .../MeetingGroupProposalsController.cs | 2 +- .../MeetingGroups/MeetingGroupsController.cs | 2 +- .../Meetings/Meetings/MeetingsController.cs | 2 +- .../MeetingFeePaymentsController.cs | 2 +- .../Payments/Payers/PayersController.cs | 2 +- .../PriceListItemsController.cs | 2 +- .../SubscriptionRenewalsController.cs | 2 +- .../SubscriptionPaymentsController.cs | 2 +- .../Subscriptions/SubscriptionsController.cs | 2 +- .../RegisterNewUserRequest.cs | 2 +- .../RegistrationsAutofacModule.cs | 15 + .../UserRegistrationsController.cs | 5 +- .../UserAccess/UserAccessAutofacModule.cs | 16 - src/API/CompanyName.MyMeetings.API/Program.cs | 16 + src/API/CompanyName.MyMeetings.API/Startup.cs | 67 ++-- .../appsettings.json | 18 ++ src/API/RequestExamples/Users.http | 5 +- .../Application/Security}/PasswordManager.cs | 2 +- src/BuildingBlocks/Domain/Entity.cs | 2 +- src/BuildingBlocks/Domain/IEntity.cs | 12 + .../AttributeAuthorizationHandler.cs | 8 +- .../Authorization/AuthorizationChecker.cs | 5 +- .../Authorization/HasPermissionAttribute.cs | 6 +- .../HasPermissionAuthorizationRequirement.cs | 2 +- .../NoPermissionRequiredAttribute.cs | 2 +- .../ModuleHosting/HostServices.cs | 37 +++ .../Infrastructure/ModuleHosting/IModule.cs | 39 +++ .../ModuleHosting/ModuleBase.cs | 55 ++++ .../ModuleHosting/ModuleLoader.cs | 74 +++++ src/CompanyName.MyMeetings.sln | 126 +++++++- .../CompanyName.MyMeetings.Database.sqlproj | 58 ++-- .../Scripts/CreateStructure.sql | 5 +- .../1_0_0_0/0001_initial_structure.sql | 5 +- .../0015_add_users_microsoft_identity.sql | 215 +++++++++++++ .../Scripts/Seeds/0002_SeedPermissions.sql | 85 +++++ .../Scripts/Seeds/0003_SeedRoles.sql | 60 ++++ .../Scripts/Seeds/0004_SeedUsers.sql | 48 +++ .../Structure/Security/Schemas.sql | 3 + .../Tables/InboxMessages.sql | 0 .../Tables/InternalCommands.sql | 0 .../Tables/OutboxMessages.sql | 0 .../Tables/Permissions.sql | 0 .../Tables/RolesToPermissions.sql | 0 .../{ => IdentityServer}/Tables/UserRoles.sql | 0 .../{ => IdentityServer}/Tables/Users.sql | 0 .../Views/v_UserPermissions.sql | 0 .../Views/v_UserRoles.sql | 0 .../{ => IdentityServer}/Views/v_Users.sql | 0 .../Tables/InboxMessages.sql | 10 + .../Tables/InternalCommands.sql | 11 + .../Tables/OutboxMessages.sql | 10 + .../MicrosoftIdentity/Tables/Permission.sql | 8 + .../users/MicrosoftIdentity/Tables/Role.sql | 11 + .../MicrosoftIdentity/Tables/RoleClaim.sql | 10 + .../users/MicrosoftIdentity/Tables/User.sql | 25 ++ .../MicrosoftIdentity/Tables/UserClaim.sql | 10 + .../MicrosoftIdentity/Tables/UserLogin.sql | 10 + .../Tables/UserRefreshToken.sql | 13 + .../MicrosoftIdentity/Tables/UserRole.sql | 9 + .../MicrosoftIdentity/Tables/UserToken.sql | 10 + .../Views/v_UserPermissions.sql | 10 + .../MicrosoftIdentity/Views/v_UserRoles.sql | 8 + .../users/MicrosoftIdentity/Views/v_Users.sql | 11 + .../DatabaseMigrator/DatabaseMigrator.csproj | 19 +- src/Directory.Build.targets | 89 +++--- src/Directory.Packages.props | 10 +- ...etings.Modules.Meetings.Application.csproj | 2 +- .../UserRegistrationConfirmedNotification.cs | 2 + ...egistrationConfirmedNotificationHandler.cs | 2 +- ...egistrationConfirmedPublishEventHandler.cs | 40 +++ .../RegisterNewUserCommandHandler.cs | 3 +- .../Registrations/IntegrationEvents/Class1.cs | 5 - ...erRegistrationConfirmedIntegrationEvent.cs | 33 ++ ...yMeetings.Modules.UserAccess.WebApi.csproj | 12 + .../Endpoints}/AuthenticatedUserController.cs | 5 +- .../Api/WebApi/Endpoints}/EmailsController.cs | 6 +- ...ings.Modules.UserAccess.Application.csproj | 1 + ...s.Modules.UserAccess.Infrastructure.csproj | 4 +- .../HasPermissionAuthorizationHandler.cs | 48 +++ .../ModuleHosting/UserAccessModule.cs | 50 +++ ...yMeetings.Modules.UsersMI.Contracts.csproj | 8 + .../Api/Contracts/Results/ErrorMessage.cs | 3 + .../Api/Contracts/Results/IResult.cs | 6 + .../Api/Contracts/Results/Result.cs | 215 +++++++++++++ .../Api/Contracts/Results/ResultStatus.cs | 55 ++++ .../Authentication/AuthenticationRequest.cs | 11 + .../Authentication/AuthenticationResponse.cs | 16 + .../V1/Authentication/ResetPasswordRequest.cs | 17 + .../V1/Authentication/TokenRequest.cs | 8 + .../V1/Authentication/TokenResponse.cs | 8 + .../V1/Authorization/PermissionResponse.cs | 10 + .../V1/Authorization/PermissionsResponse.cs | 6 + .../V1/Me/ChangeEmailAddressRequest.cs | 8 + .../Contracts/V1/Me/ChangePasswordRequest.cs | 12 + .../V1/Me/ConfirmEmailAddressRequest.cs | 6 + .../V1/Me/RegisterAuthenticatorRequest.cs | 6 + .../RequestChangeEmailAddressTokenRequest.cs | 6 + .../Contracts/V1/Me/UpdateProfileRequest.cs | 12 + .../Contracts/V1/Me/UserAccountResponse.cs | 16 + .../Api/Contracts/V1/Roles/AddRoleRequest.cs | 8 + .../Contracts/V1/Roles/PermissionResponse.cs | 10 + .../Contracts/V1/Roles/PermissionsResponse.cs | 6 + .../Contracts/V1/Roles/RenameRoleRequest.cs | 6 + .../Api/Contracts/V1/Roles/RoleResponse.cs | 8 + .../Api/Contracts/V1/Roles/RolesResponse.cs | 6 + .../V1/Roles/SetRolePermissionsRequest.cs | 6 + .../V1/Users/ChangeUserEmailAddressRequest.cs | 6 + .../Contracts/V1/Users/PermissionResponse.cs | 10 + .../Contracts/V1/Users/PermissionsResponse.cs | 6 + .../Api/Contracts/V1/Users/RoleResponse.cs | 8 + .../Api/Contracts/V1/Users/RolesResponse.cs | 6 + .../V1/Users/SetUserPermissionsRequest.cs | 6 + .../Contracts/V1/Users/SetUserRolesRequest.cs | 6 + .../V1/Users/UpdateUserAccountRequest.cs | 19 ++ .../Contracts/V1/Users/UserAccountResponse.cs | 71 +++++ .../V1/Users/UserAccountsResponse.cs | 6 + .../MicrosoftIdentity/Api/Sdk/ApiEndpoints.cs | 69 +++++ ...Name.MyMeetings.Modules.UsersMI.Sdk.csproj | 16 + .../Api/Sdk/IAuthenticationApi.cs | 23 ++ .../Api/Sdk/IAuthorizationApi.cs | 11 + .../Users/MicrosoftIdentity/Api/Sdk/IMeApi.cs | 35 +++ .../MicrosoftIdentity/Api/Sdk/IRoleApi.cs | 29 ++ .../MicrosoftIdentity/Api/Sdk/IUserApi.cs | 35 +++ ...e.MyMeetings.Modules.UsersMI.WebApi.csproj | 17 + .../WebApi/Endpoints/ApplicationController.cs | 64 ++++ .../AuthenticationController.cs | 241 +++++++++++++++ .../AuthenticationRequestValidator.cs | 14 + .../ResetPasswordRequestValidator.cs | 18 ++ .../Authorization/AuthorizationController.cs | 45 +++ .../Api/WebApi/Endpoints/Me/MeController.cs | 161 ++++++++++ .../ChangeEmailAddressRequestValidator.cs | 14 + .../ConfirmEmailAddressRequestValidator.cs | 13 + ...ChangeEmailAddressTokenRequestValidator.cs | 13 + .../UpdateProfileRequestValidator.cs | 14 + .../WebApi/Endpoints/Roles/RolesController.cs | 148 +++++++++ .../Validators/AddRoleRequestValidator.cs | 14 + .../Validators/RenameRoleRequestValidator.cs | 13 + .../SetRolePermissionsRequestValidator.cs | 13 + .../WebApi/Endpoints/Users/UsersController.cs | 218 +++++++++++++ .../ConfirmEmailAddressRequestValidator.cs | 13 + .../SetUserPermissionsRequestValidator.cs | 13 + .../SetUserRolesRequestValidator.cs | 13 + .../Api/WebApi/Endpoints/UsersPermissions.cs | 28 ++ .../Api/WebApi/ErrorMapper.cs | 89 ++++++ .../Api/WebApi/ResultToApiResultExtensions.cs | 290 ++++++++++++++++++ .../Login/AccountLoginCommand.cs | 16 + .../Login/AccountLoginCommandHandler.cs | 161 ++++++++++ .../Login/AccountLoginCommandValidator.cs | 12 + .../Login/AccountTwoFactorLoginCommand.cs | 19 ++ .../AccountTwoFactorLoginCommandHandler.cs | 56 ++++ .../Login/AuthenticationResult.cs | 62 ++++ .../External/ExternalAccountLoginCommand.cs | 22 ++ .../ExternalAccountLoginCommandHandler.cs | 82 +++++ .../Authentication/Login/UserDto.cs | 16 + .../RefreshToken/RefreshTokenCommand.cs | 17 + .../RefreshTokenCommandHandler.cs | 21 ++ .../Authentication/RefreshToken/TokenDto.cs | 3 + .../RequestResetPasswordTokenCommand.cs | 14 + ...RequestResetPasswordTokenCommandHandler.cs | 42 +++ .../ResetPasswordTokenResponse.cs | 8 + .../ResetPassword/ResetPasswordCommand.cs | 20 ++ .../ResetPasswordCommandHandler.cs | 39 +++ .../GetPermissions/ContractMapping.cs | 12 + .../GetPermissions/GetPermissionsQuery.cs | 14 + .../GetUserPermissionsQueryHandler.cs | 78 +++++ .../GetPermissions/PermissionDto.cs | 17 + ...eetings.Modules.UsersMI.Application.csproj | 11 + .../Configuration/Commands/ICommandHandler.cs | 15 + .../Commands/ICommandsScheduler.cs | 10 + .../Commands/InternalCommandBase.cs | 28 ++ .../Configuration/Queries/IQueryHandler.cs | 10 + .../Contracts/ApplicationPermissions.cs | 6 + .../Application/Contracts/CommandBase.cs | 31 ++ .../Application/Contracts/CustomClaimTypes.cs | 15 + .../Application/Contracts/ICommand.cs | 13 + .../Application/Contracts/IQuery.cs | 7 + .../Contracts/IRecurringCommand.cs | 5 + .../Contracts/ITokenClaimsService.cs | 19 ++ .../Contracts/IUserAccessModule.cs | 10 + .../Application/Contracts/QueryBase.cs | 16 + .../Application/Contracts/Results/IResult.cs | 19 ++ .../Application/Contracts/Results/Result.cs | 199 ++++++++++++ .../Contracts/Results/ResultStatus.cs | 49 +++ .../Application/Contracts/Roles.cs | 8 + .../Application/CustomValidators.cs | 110 +++++++ .../Application/FluentValidationExtensions.cs | 40 +++ .../Application/IdentityHelpers.cs | 12 + .../GetAuthenticatorKeyQuery.cs | 14 + .../GetAuthenticatorKeyQueryHandler.cs | 53 ++++ .../RegisterAuthenticatorCommand.cs | 17 + .../RegisterAuthenticatorCommandHandler.cs | 42 +++ .../ChangeEmailAddressCommand.cs | 20 ++ .../ChangeEmailAddressCommandHandler.cs | 64 ++++ .../ChangePassword/ChangePasswordCommand.cs | 20 ++ .../ChangePasswordCommandHandler.cs | 41 +++ .../ConfirmEmailAddressCommand.cs | 17 + .../ConfirmEmailAddressCommandHandler.cs | 41 +++ .../Me/GetUserAccount/GetUserAccountQuery.cs | 14 + .../GetUserAccountQueryHandler.cs | 44 +++ .../Me/GetUserAccount/UserAccountDto.cs | 18 ++ .../RequestChangeEmailAddressTokenCommand.cs | 18 ++ ...stChangeEmailAddressTokenCommandHandler.cs | 49 +++ .../RequestConfirmEmailAddressTokenCommand.cs | 14 + ...tConfirmEmailAddressTokenCommandHandler.cs | 49 +++ .../Me/UpdateProfile/UpdateProfileCommand.cs | 26 ++ .../UpdateProfileCommandHandler.cs | 54 ++++ .../Roles/CreateRole/CreateRoleCommand.cs | 17 + .../CreateRole/CreateRoleCommandHandler.cs | 38 +++ .../Roles/DeleteRole/DeleteRoleCommand.cs | 14 + .../DeleteRole/DeleteRoleCommandHandler.cs | 28 ++ .../GetRolePermissions/ContractMapping.cs | 12 + .../GetRolePermissionsQuery.cs | 14 + .../GetRolePermissionsQueryHandler.cs | 55 ++++ .../Roles/GetRolePermissions/PermissionDto.cs | 17 + .../Roles/GetRoles/ById/GetRolesQuery.cs | 14 + .../GetRoles/ById/GetRolesQueryHandler.cs | 34 ++ .../Roles/GetRoles/Directory/GetRolesQuery.cs | 8 + .../Directory/GetRolesQueryHandler.cs | 28 ++ .../Application/Roles/GetRoles/RoleDto.cs | 8 + .../Roles/RenameRole/RenameRoleCommand.cs | 17 + .../RenameRole/RenameRoleCommandHandler.cs | 50 +++ .../SetRolePermissionsCommand.cs | 17 + .../SetRolePermissionsCommandHandler.cs | 60 ++++ .../ChangeUserEmailAddressCommand.cs | 17 + .../ChangeUserEmailAddressCommandHandler.cs | 44 +++ .../CreateUserAccountCommand.cs | 35 +++ .../CreateUserAccountCommandHandler.cs | 77 +++++ ...trationConfirmedIntegrationEventHandler.cs | 30 ++ .../ById/GetUserAccountsQuery.cs | 14 + .../ById/GetUserAccountsQueryHandler.cs | 44 +++ .../Directory/GetUserAccountsQuery.cs | 8 + .../Directory/GetUserAccountsQueryHandler.cs | 41 +++ .../GetUserAccounts/UserAccountDto.cs | 83 +++++ .../GetUserRoles/GetUserRolesQuery.cs | 14 + .../GetUserRoles/GetUserRolesQueryHandler.cs | 43 +++ .../UserAccounts/GetUserRoles/RoleDto.cs | 8 + .../SetUserPermissionsCommand.cs | 17 + .../SetUserPermissionsCommandHandler.cs | 47 +++ .../SetUserRoles/SetUserRolesCommand.cs | 17 + .../SetUserRolesCommandHandler.cs | 70 +++++ .../UnlockUserAccountCommand.cs | 14 + .../UnlockUserAccountCommandHandler.cs | 33 ++ .../UpdateUserAccountCommand.cs | 23 ++ .../UpdateUserAccountCommandHandler.cs | 42 +++ .../Domain/ApplicationUser.cs | 79 +++++ ...e.MyMeetings.Modules.UsersMI.Domain.csproj | 11 + .../Users/MicrosoftIdentity/Domain/Email.cs | 67 ++++ .../MicrosoftIdentity/Domain/Errors/Error.cs | 106 +++++++ .../Domain/Errors/ErrorExtensions.cs | 60 ++++ .../MicrosoftIdentity/Domain/Errors/Errors.cs | 129 ++++++++ .../MicrosoftIdentity/Domain/Permission.cs | 10 + .../IReadOnlyPermissionRepository.cs | 8 + .../IUserRefreshTokenRepository.cs | 11 + .../Users/MicrosoftIdentity/Domain/Role.cs | 17 + .../Users/MicrosoftIdentity/Domain/UserId.cs | 12 + .../Domain/UserRefreshToken.cs | 43 +++ .../Domain/UserRefreshTokenId.cs | 12 + .../MicrosoftIdentity/Domain/UserRole.cs | 23 ++ ...ings.Modules.UsersMI.Infrastructure.csproj | 12 + .../Configuration/AllConstructorFinder.cs | 20 ++ .../Configuration/Assemblies.cs | 20 ++ .../Configuration/ConfigurationExtensions.cs | 34 ++ .../DataAccess/DataAccessModule.cs | 41 +++ .../DataAccess/DatabaseConfiguration.cs | 12 + .../DataAccess/IDatabaseConfiguration.cs | 7 + .../ApplicationUserEntityTypeConfiguration.cs | 17 + ...dentityRoleClaimEntityTypeConfiguration.cs | 13 + ...dentityUserClaimEntityTypeConfiguration.cs | 13 + ...dentityUserLoginEntityTypeConfiguration.cs | 13 + ...IdentityUserRoleEntityTypeConfiguration.cs | 13 + ...dentityUserTokenEntityTypeConfiguration.cs | 13 + .../RoleEntityTypeConfiguration.cs | 13 + ...UserRefreshTokenEntityTypeConfiguration.cs | 22 ++ .../Configuration/Email/EmailModule.cs | 34 ++ .../EventsBus/EventsBusModule.cs | 28 ++ .../EventsBus/EventsBusStartup.cs | 30 ++ .../IntegrationEventGenericHandler.cs | 38 +++ .../Identity/CustomPasswordHasher.cs | 21 ++ .../DoesNotContainPasswordValidator.cs | 29 ++ .../EmailConfirmationTokenProvider.cs | 21 ++ .../EmailConfirmationTokenProviderOptions.cs | 7 + .../HasPermissionAuthorizationHandler.cs | 64 ++++ .../Identity/IdentityConfiguration.cs | 73 +++++ .../Configuration/Identity/IdentityModule.cs | 49 +++ .../Configuration/Logging/LoggingModule.cs | 21 ++ .../Configuration/Mediation/MediatorModule.cs | 92 ++++++ .../ModuleHosting/UserAccessModule.cs | 70 +++++ .../Processing/CommandsExecutor.cs | 26 ++ .../Processing/IRecurringCommand.cs | 5 + .../Processing/Inbox/InboxMessageDto.cs | 10 + .../Processing/Inbox/ProcessInboxCommand.cs | 5 + .../Inbox/ProcessInboxCommandHandler.cs | 62 ++++ .../Processing/Inbox/ProcessInboxJob.cs | 12 + .../InternalCommands/CommandsScheduler.cs | 56 ++++ .../ProcessInternalCommandsCommand.cs | 5 + .../ProcessInternalCommandsCommandHandler.cs | 82 +++++ .../ProcessInternalCommandsJob.cs | 12 + .../LoggingCommandHandlerDecorator.cs | 89 ++++++ ...oggingCommandHandlerWithResultDecorator.cs | 87 ++++++ .../Processing/Outbox/OutboxMessageDto.cs | 10 + .../Processing/Outbox/OutboxModule.cs | 59 ++++ .../Processing/Outbox/ProcessOutboxCommand.cs | 5 + .../Outbox/ProcessOutboxCommandHandler.cs | 84 +++++ .../Processing/Outbox/ProcessOutboxJob.cs | 12 + .../Processing/ProcessingModule.cs | 68 ++++ .../UnitOfWorkCommandHandlerDecorator.cs | 41 +++ ...OfWorkCommandHandlerWithResultDecorator.cs | 43 +++ .../ValidationCommandHandlerDecorator.cs | 37 +++ ...dationCommandHandlerWithResultDecorator.cs | 38 +++ .../Configuration/Quartz/QuartzModule.cs | 13 + .../Configuration/Quartz/QuartzStartup.cs | 111 +++++++ .../Quartz/SerilogLogProvider.cs | 67 ++++ .../Configuration/Services/ServicesModule.cs | 25 ++ .../UserAccessCompositionRoot.cs | 23 ++ .../Configuration/UserAccessConfiguration.cs | 17 + .../Configuration/UserAccessStartup.cs | 87 ++++++ .../Repositories/PermissionRepository.cs | 47 +++ .../UserRefreshTokenRepository.cs | 32 ++ .../InternalCommandEntityTypeConfiguration.cs | 16 + .../Infrastructure/Outbox/OutboxAccessor.cs | 23 ++ .../OutboxMessageEntityTypeConfiguration.cs | 16 + .../IdentityTokenService/CustomClaimTypes.cs | 12 + .../IdentityTokenClaimService.cs | 185 +++++++++++ .../Infrastructure/UserAccessContext.cs | 91 ++++++ .../Infrastructure/UserAccessModule.cs | 30 ++ .../ArchTests/Application/ApplicationTests.cs | 200 ++++++++++++ ...yMeetings.Modules.UsersMI.ArchTests.csproj | 1 + .../Tests/ArchTests/Domain/DomainTests.cs | 228 ++++++++++++++ .../Tests/ArchTests/Module/LayersTests.cs | 43 +++ .../Tests/ArchTests/SeedWork/TestBase.cs | 43 +++ .../Tests/IntegrationTests/AssemblyInfo.cs | 11 + .../Authentication/AuthenticationTests.cs | 38 +++ ...gs.Modules.UsersMI.IntegrationTests.csproj | 11 + .../Me/ChangeEmailAddressTests.cs | 101 ++++++ .../Me/ChangePasswordTests.cs | 100 ++++++ .../Me/ConfirmEmailAddressTests.cs | 97 ++++++ .../Me/GetAuthenticatorKeyTests.cs | 70 +++++ .../Me/GetUserAccountTests.cs | 69 +++++ .../Me/RegisterAuthenticatorTests.cs | 74 +++++ .../Me/RequestChangeEmailAddressTokenTests.cs | 87 ++++++ .../RequestConfirmEmailAddressTokenTests.cs | 83 +++++ .../IntegrationTests/Me/UpdateProfileTests.cs | 85 +++++ .../Roles/0002_SeedPermissions.sql | 86 ++++++ .../IntegrationTests/Roles/CreateRoleTests.cs | 80 +++++ .../IntegrationTests/Roles/DeleteRoleTests.cs | 51 +++ .../Roles/GetRolePermissionsTests.cs | 55 ++++ .../IntegrationTests/Roles/GetRoleTests.cs | 53 ++++ .../IntegrationTests/Roles/GetRolesTests.cs | 46 +++ .../IntegrationTests/Roles/RenameRoleTests.cs | 84 +++++ .../Roles/SetRolePermissionsTests.cs | 81 +++++ .../SeedWork/Authenticator.cs | 12 + .../IntegrationTests/SeedWork/EmailSender.cs | 17 + .../SeedWork/ExecutionContextMock.cs | 23 ++ .../SeedWork/OutboxMessagesHelper.cs | 35 +++ .../IntegrationTests/SeedWork/TestBase.cs | 144 +++++++++ .../ChangeUserEmailAddressTests.cs | 61 ++++ .../UserAccounts/CreateUserTests.cs | 56 ++++ .../UserAccounts/GetUserAccountTests.cs | 59 ++++ .../UserAccounts/GetUserAccountsTests.cs | 37 +++ .../UserAccounts/GetUserRolesTests.cs | 78 +++++ .../UserAccounts/SetUserPermissionsTests.cs | 69 +++++ .../UserAccounts/SetUserRolesTests.cs | 88 ++++++ .../UserAccounts/UnlockUserAccountTests.cs | 52 ++++ .../UserAccounts/UpdateUserAccountTests.cs | 69 +++++ ...yMeetings.Modules.UsersMI.UnitTests.csproj | 1 + .../SeedWork/DomainEventsTestHelper.cs | 48 +++ .../Tests/UnitTests/SeedWork/TestBase.cs | 32 ++ 377 files changed, 12645 insertions(+), 243 deletions(-) delete mode 100644 src/API/CompanyName.MyMeetings.API/Configuration/Authorization/HasPermissionAuthorizationHandler.cs create mode 100644 src/API/CompanyName.MyMeetings.API/Configuration/UserAccessModuleSelector.cs rename src/API/CompanyName.MyMeetings.API/Modules/{UserAccess => Registrations}/RegisterNewUserRequest.cs (84%) create mode 100644 src/API/CompanyName.MyMeetings.API/Modules/Registrations/RegistrationsAutofacModule.cs rename src/API/CompanyName.MyMeetings.API/Modules/{UserAccess => Registrations}/UserRegistrationsController.cs (89%) delete mode 100644 src/API/CompanyName.MyMeetings.API/Modules/UserAccess/UserAccessAutofacModule.cs rename src/{Modules/Registrations/Application/UserRegistrations/RegisterNewUser => BuildingBlocks/Application/Security}/PasswordManager.cs (95%) create mode 100644 src/BuildingBlocks/Domain/IEntity.cs rename src/{API/CompanyName.MyMeetings.API/Configuration => BuildingBlocks/Infrastructure}/Authorization/AttributeAuthorizationHandler.cs (74%) rename src/{API/CompanyName.MyMeetings.API/Configuration => BuildingBlocks/Infrastructure}/Authorization/AuthorizationChecker.cs (92%) rename src/{API/CompanyName.MyMeetings.API/Configuration => BuildingBlocks/Infrastructure}/Authorization/HasPermissionAttribute.cs (61%) rename src/{API/CompanyName.MyMeetings.API/Configuration => BuildingBlocks/Infrastructure}/Authorization/HasPermissionAuthorizationRequirement.cs (65%) rename src/{API/CompanyName.MyMeetings.API/Configuration => BuildingBlocks/Infrastructure}/Authorization/NoPermissionRequiredAttribute.cs (63%) create mode 100644 src/BuildingBlocks/Infrastructure/ModuleHosting/HostServices.cs create mode 100644 src/BuildingBlocks/Infrastructure/ModuleHosting/IModule.cs create mode 100644 src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs create mode 100644 src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleLoader.cs create mode 100644 src/Database/CompanyName.MyMeetings.Database/Scripts/Migrations/1_0_0_0/0015_add_users_microsoft_identity.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0002_SeedPermissions.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0003_SeedRoles.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0004_SeedUsers.sql rename src/Database/CompanyName.MyMeetings.Database/Structure/users/{ => IdentityServer}/Tables/InboxMessages.sql (100%) rename src/Database/CompanyName.MyMeetings.Database/Structure/users/{ => IdentityServer}/Tables/InternalCommands.sql (100%) rename src/Database/CompanyName.MyMeetings.Database/Structure/users/{ => IdentityServer}/Tables/OutboxMessages.sql (100%) rename src/Database/CompanyName.MyMeetings.Database/Structure/users/{ => IdentityServer}/Tables/Permissions.sql (100%) rename src/Database/CompanyName.MyMeetings.Database/Structure/users/{ => IdentityServer}/Tables/RolesToPermissions.sql (100%) rename src/Database/CompanyName.MyMeetings.Database/Structure/users/{ => IdentityServer}/Tables/UserRoles.sql (100%) rename src/Database/CompanyName.MyMeetings.Database/Structure/users/{ => IdentityServer}/Tables/Users.sql (100%) rename src/Database/CompanyName.MyMeetings.Database/Structure/users/{ => IdentityServer}/Views/v_UserPermissions.sql (100%) rename src/Database/CompanyName.MyMeetings.Database/Structure/users/{ => IdentityServer}/Views/v_UserRoles.sql (100%) rename src/Database/CompanyName.MyMeetings.Database/Structure/users/{ => IdentityServer}/Views/v_Users.sql (100%) create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/InboxMessages.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/InternalCommands.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/OutboxMessages.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/Permission.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/Role.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/RoleClaim.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/User.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserClaim.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserLogin.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserRefreshToken.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserRole.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserToken.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_UserPermissions.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_UserRoles.sql create mode 100644 src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_Users.sql create mode 100644 src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedPublishEventHandler.cs delete mode 100644 src/Modules/Registrations/IntegrationEvents/Class1.cs create mode 100644 src/Modules/Registrations/IntegrationEvents/UserRegistrationConfirmedIntegrationEvent.cs create mode 100644 src/Modules/UserAccess/Api/WebApi/CompanyName.MyMeetings.Modules.UserAccess.WebApi.csproj rename src/{API/CompanyName.MyMeetings.API/Modules/UserAccess => Modules/UserAccess/Api/WebApi/Endpoints}/AuthenticatedUserController.cs (89%) rename src/{API/CompanyName.MyMeetings.API/Modules/UserAccess => Modules/UserAccess/Api/WebApi/Endpoints}/EmailsController.cs (84%) create mode 100644 src/Modules/UserAccess/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs create mode 100644 src/Modules/UserAccess/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/CompanyName.MyMeetings.Modules.UsersMI.Contracts.csproj create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/ErrorMessage.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/IResult.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/Result.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/ResultStatus.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/AuthenticationRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/AuthenticationResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/ResetPasswordRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/TokenRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/TokenResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authorization/PermissionResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authorization/PermissionsResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ChangeEmailAddressRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ChangePasswordRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ConfirmEmailAddressRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/RegisterAuthenticatorRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/RequestChangeEmailAddressTokenRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/UpdateProfileRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/UserAccountResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/AddRoleRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/PermissionResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/PermissionsResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RenameRoleRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RoleResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RolesResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/SetRolePermissionsRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/ChangeUserEmailAddressRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/PermissionResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/PermissionsResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/RoleResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/RolesResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/SetUserPermissionsRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/SetUserRolesRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UpdateUserAccountRequest.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UserAccountResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UserAccountsResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Sdk/ApiEndpoints.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Sdk/CompanyName.MyMeetings.Modules.UsersMI.Sdk.csproj create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Sdk/IAuthenticationApi.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Sdk/IAuthorizationApi.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Sdk/IMeApi.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Sdk/IRoleApi.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/Sdk/IUserApi.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/CompanyName.MyMeetings.Modules.UsersMI.WebApi.csproj create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/ApplicationController.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/AuthenticationController.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/Validators/AuthenticationRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/Validators/ResetPasswordRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authorization/AuthorizationController.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/ChangeEmailAddressRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/ConfirmEmailAddressRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/RequestChangeEmailAddressTokenRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/UpdateProfileRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/RolesController.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/AddRoleRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/RenameRoleRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/SetRolePermissionsRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/UsersController.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/ConfirmEmailAddressRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/SetUserPermissionsRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/SetUserRolesRequestValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/UsersPermissions.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/ErrorMapper.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/ResultToApiResultExtensions.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommandValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountTwoFactorLoginCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountTwoFactorLoginCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AuthenticationResult.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/External/ExternalAccountLoginCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/External/ExternalAccountLoginCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/UserDto.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/RefreshTokenCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/RefreshTokenCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/TokenDto.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/RequestResetPasswordTokenCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/RequestResetPasswordTokenCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/ResetPasswordTokenResponse.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/ContractMapping.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/GetPermissionsQuery.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/GetUserPermissionsQueryHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/PermissionDto.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/CompanyName.MyMeetings.Modules.UsersMI.Application.csproj create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/ICommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/ICommandsScheduler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/InternalCommandBase.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Configuration/Queries/IQueryHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/ApplicationPermissions.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/CommandBase.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/CustomClaimTypes.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/ICommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/IQuery.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/IRecurringCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/ITokenClaimsService.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/IUserAccessModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/QueryBase.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/IResult.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/Result.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/ResultStatus.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/Roles.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/CustomValidators.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/FluentValidationExtensions.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/IdentityHelpers.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/GetAuthenticatorKey/GetAuthenticatorKeyQuery.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/GetAuthenticatorKey/GetAuthenticatorKeyQueryHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/RegisterAuthenticator/RegisterAuthenticatorCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/RegisterAuthenticator/RegisterAuthenticatorCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/ChangeEmailAddress/ChangeEmailAddressCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/ChangeEmailAddress/ChangeEmailAddressCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/ConfirmEmailAddress/ConfirmEmailAddressCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/ConfirmEmailAddress/ConfirmEmailAddressCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/GetUserAccountQuery.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/GetUserAccountQueryHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/UserAccountDto.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/RequestChangeEmailAddressToken/RequestChangeEmailAddressTokenCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/RequestChangeEmailAddressToken/RequestChangeEmailAddressTokenCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/RequestConfirmEmailAddressToken/RequestConfirmEmailAddressTokenCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/RequestConfirmEmailAddressToken/RequestConfirmEmailAddressTokenCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/UpdateProfile/UpdateProfileCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Me/UpdateProfile/UpdateProfileCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/CreateRole/CreateRoleCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/CreateRole/CreateRoleCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/DeleteRole/DeleteRoleCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/DeleteRole/DeleteRoleCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/ContractMapping.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/GetRolePermissionsQuery.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/GetRolePermissionsQueryHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/PermissionDto.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/ById/GetRolesQuery.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/ById/GetRolesQueryHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/Directory/GetRolesQuery.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/Directory/GetRolesQueryHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/RoleDto.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/RenameRole/RenameRoleCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/RenameRole/RenameRoleCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/SetRolePermissions/SetRolePermissionsCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Roles/SetRolePermissions/SetRolePermissionsCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/ChangeUserEmailAddress/ChangeUserEmailAddressCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/ChangeUserEmailAddress/ChangeUserEmailAddressCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/CreateUserAccountCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/CreateUserAccountCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/UserRegistrationConfirmedIntegrationEventHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/ById/GetUserAccountsQuery.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/ById/GetUserAccountsQueryHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/Directory/GetUserAccountsQuery.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/Directory/GetUserAccountsQueryHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/UserAccountDto.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/GetUserRolesQuery.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/GetUserRolesQueryHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/RoleDto.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserPermissions/SetUserPermissionsCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserPermissions/SetUserPermissionsCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserRoles/SetUserRolesCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserRoles/SetUserRolesCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UnlockUserAccount/UnlockUserAccountCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UnlockUserAccount/UnlockUserAccountCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UpdateUserAccount/UpdateUserAccountCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UpdateUserAccount/UpdateUserAccountCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/ApplicationUser.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/CompanyName.MyMeetings.Modules.UsersMI.Domain.csproj create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/Email.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/Errors/Error.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/Errors/ErrorExtensions.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/Errors/Errors.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/Permission.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/Repositories/IReadOnlyPermissionRepository.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/Repositories/IUserRefreshTokenRepository.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/Role.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/UserId.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/UserRefreshToken.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/UserRefreshTokenId.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Domain/UserRole.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.csproj create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/AllConstructorFinder.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Assemblies.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ConfigurationExtensions.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/DataAccessModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/DatabaseConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/IDatabaseConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/ApplicationUserEntityTypeConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityRoleClaimEntityTypeConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserClaimEntityTypeConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserLoginEntityTypeConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserRoleEntityTypeConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserTokenEntityTypeConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/RoleEntityTypeConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/UserRefreshTokenEntityTypeConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Email/EmailModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/EventsBusModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/EventsBusStartup.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/IntegrationEventGenericHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/CustomPasswordHasher.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/DoesNotContainPasswordValidator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/EmailConfirmationTokenProvider.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/EmailConfirmationTokenProviderOptions.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/IdentityConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/IdentityModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Logging/LoggingModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Mediation/MediatorModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/CommandsExecutor.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/IRecurringCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/InboxMessageDto.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxJob.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/CommandsScheduler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsJob.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/LoggingCommandHandlerDecorator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/LoggingCommandHandlerWithResultDecorator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/OutboxMessageDto.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/OutboxModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxCommand.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxCommandHandler.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxJob.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ProcessingModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/UnitOfWorkCommandHandlerDecorator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/UnitOfWorkCommandHandlerWithResultDecorator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ValidationCommandHandlerDecorator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ValidationCommandHandlerWithResultDecorator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/QuartzModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/QuartzStartup.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/SerilogLogProvider.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Services/ServicesModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessCompositionRoot.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessStartup.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Domain/Repositories/PermissionRepository.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Domain/Repositories/UserRefreshTokenRepository.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/InternalCommands/InternalCommandEntityTypeConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Outbox/OutboxAccessor.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Outbox/OutboxMessageEntityTypeConfiguration.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Services/IdentityTokenService/CustomClaimTypes.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/Services/IdentityTokenService/IdentityTokenClaimService.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/UserAccessContext.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Infrastructure/UserAccessModule.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Application/ApplicationTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/CompanyName.MyMeetings.Modules.UsersMI.ArchTests.csproj create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Domain/DomainTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Module/LayersTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/SeedWork/TestBase.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/AssemblyInfo.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Authentication/AuthenticationTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.csproj create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ChangeEmailAddressTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ChangePasswordTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ConfirmEmailAddressTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/GetAuthenticatorKeyTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/GetUserAccountTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RegisterAuthenticatorTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RequestChangeEmailAddressTokenTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RequestConfirmEmailAddressTokenTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/UpdateProfileTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/0002_SeedPermissions.sql create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/CreateRoleTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/DeleteRoleTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRolePermissionsTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRoleTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRolesTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/RenameRoleTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/SetRolePermissionsTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/Authenticator.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/EmailSender.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/ExecutionContextMock.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/OutboxMessagesHelper.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/TestBase.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/ChangeUserEmailAddressTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/CreateUserTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserAccountTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserAccountsTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserRolesTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/SetUserPermissionsTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/SetUserRolesTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/UnlockUserAccountTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/UpdateUserAccountTests.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/CompanyName.MyMeetings.Modules.UsersMI.UnitTests.csproj create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/SeedWork/DomainEventsTestHelper.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/SeedWork/TestBase.cs diff --git a/.gitignore b/.gitignore index 0a74300d6..4fba5cce7 100644 --- a/.gitignore +++ b/.gitignore @@ -275,3 +275,6 @@ UploadedFiles #Nuke working directory .nuke-working-directory /src/API/CompanyName.MyMeetings.API/tempkey.jwk + +# CodeRush personal settings +*/.cr/personal \ No newline at end of file diff --git a/src/API/CompanyName.MyMeetings.API/CompanyName.MyMeetings.API.csproj b/src/API/CompanyName.MyMeetings.API/CompanyName.MyMeetings.API.csproj index 8dde11cbc..e3c3fade6 100644 --- a/src/API/CompanyName.MyMeetings.API/CompanyName.MyMeetings.API.csproj +++ b/src/API/CompanyName.MyMeetings.API/CompanyName.MyMeetings.API.csproj @@ -9,7 +9,4 @@ bin\Debug\CompanyName.MyMeetings.API.xml - - - - + \ No newline at end of file diff --git a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/HasPermissionAuthorizationHandler.cs b/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/HasPermissionAuthorizationHandler.cs deleted file mode 100644 index 82b1de0b3..000000000 --- a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/HasPermissionAuthorizationHandler.cs +++ /dev/null @@ -1,48 +0,0 @@ -using CompanyName.MyMeetings.BuildingBlocks.Application; -using CompanyName.MyMeetings.Modules.UserAccess.Application.Authorization.GetUserPermissions; -using CompanyName.MyMeetings.Modules.UserAccess.Application.Contracts; -using Microsoft.AspNetCore.Authorization; - -namespace CompanyName.MyMeetings.API.Configuration.Authorization -{ - internal class HasPermissionAuthorizationHandler : AttributeAuthorizationHandler< - HasPermissionAuthorizationRequirement, HasPermissionAttribute> - { - private readonly IExecutionContextAccessor _executionContextAccessor; - private readonly IUserAccessModule _userAccessModule; - - public HasPermissionAuthorizationHandler( - IExecutionContextAccessor executionContextAccessor, - IUserAccessModule userAccessModule) - { - _executionContextAccessor = executionContextAccessor; - _userAccessModule = userAccessModule; - } - - protected override async Task HandleRequirementAsync( - AuthorizationHandlerContext context, - HasPermissionAuthorizationRequirement requirement, - HasPermissionAttribute attribute) - { - var permissions = await _userAccessModule.ExecuteQueryAsync(new GetUserPermissionsQuery(_executionContextAccessor.UserId)); - - if (!await AuthorizeAsync(attribute.Name, permissions)) - { - context.Fail(); - return; - } - - context.Succeed(requirement); - } - - private Task AuthorizeAsync(string permission, List permissions) - { -#if !DEBUG - return Task.FromResult(true); -#endif -#pragma warning disable CS0162 // Unreachable code detected - return Task.FromResult(permissions.Any(x => x.Code == permission)); -#pragma warning restore CS0162 // Unreachable code detected - } - } -} \ No newline at end of file diff --git a/src/API/CompanyName.MyMeetings.API/Configuration/ExecutionContext/ExecutionContextAccessor.cs b/src/API/CompanyName.MyMeetings.API/Configuration/ExecutionContext/ExecutionContextAccessor.cs index 207dbdeac..a4329d4d1 100644 --- a/src/API/CompanyName.MyMeetings.API/Configuration/ExecutionContext/ExecutionContextAccessor.cs +++ b/src/API/CompanyName.MyMeetings.API/Configuration/ExecutionContext/ExecutionContextAccessor.cs @@ -1,8 +1,9 @@ -using CompanyName.MyMeetings.BuildingBlocks.Application; +using System.Security.Claims; +using CompanyName.MyMeetings.BuildingBlocks.Application; namespace CompanyName.MyMeetings.API.Configuration.ExecutionContext { - public class ExecutionContextAccessor : IExecutionContextAccessor + internal class ExecutionContextAccessor : IExecutionContextAccessor { private readonly IHttpContextAccessor _httpContextAccessor; @@ -19,11 +20,11 @@ public Guid UserId .HttpContext? .User? .Claims? - .SingleOrDefault(x => x.Type == "sub")? + .SingleOrDefault(x => x.Type == ClaimTypes.NameIdentifier)? .Value != null) { return Guid.Parse(_httpContextAccessor.HttpContext.User.Claims.Single( - x => x.Type == "sub").Value); + x => x.Type == ClaimTypes.NameIdentifier).Value); } throw new ApplicationException("User context is not available"); @@ -34,11 +35,11 @@ public Guid CorrelationId { get { - if (IsAvailable && _httpContextAccessor.HttpContext.Request.Headers.Keys.Any( + if (IsAvailable && _httpContextAccessor.HttpContext!.Request.Headers.Keys.Any( x => x == CorrelationMiddleware.CorrelationHeaderKey)) { return Guid.Parse( - _httpContextAccessor.HttpContext.Request.Headers[CorrelationMiddleware.CorrelationHeaderKey]); + _httpContextAccessor.HttpContext!.Request.Headers[CorrelationMiddleware.CorrelationHeaderKey]!); } throw new ApplicationException("Http context and correlation id is not available"); diff --git a/src/API/CompanyName.MyMeetings.API/Configuration/Extensions/SwaggerExtensions.cs b/src/API/CompanyName.MyMeetings.API/Configuration/Extensions/SwaggerExtensions.cs index 17dbce681..e6bfc73c8 100644 --- a/src/API/CompanyName.MyMeetings.API/Configuration/Extensions/SwaggerExtensions.cs +++ b/src/API/CompanyName.MyMeetings.API/Configuration/Extensions/SwaggerExtensions.cs @@ -62,4 +62,4 @@ internal static IApplicationBuilder UseSwaggerDocumentation(this IApplicationBui return app; } } -} +} \ No newline at end of file diff --git a/src/API/CompanyName.MyMeetings.API/Configuration/UserAccessModuleSelector.cs b/src/API/CompanyName.MyMeetings.API/Configuration/UserAccessModuleSelector.cs new file mode 100644 index 000000000..6bcc49a7d --- /dev/null +++ b/src/API/CompanyName.MyMeetings.API/Configuration/UserAccessModuleSelector.cs @@ -0,0 +1,34 @@ +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; +using IdentityServerUserAccess = CompanyName.MyMeetings.Modules.UserAccess.Infrastructure.Configuration.ModuleHosting; +using MicrosoftIdentityUserAccess = CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.ModuleHosting; + +namespace CompanyName.MyMeetings.API.Configuration; + +internal static class UserAccessModuleSelector +{ + private const string IdentityServerModuleType = "IdentityServer"; + private const string MicrosoftIdentityModuleType = "MicrosoftIdentity"; + + private static readonly string[] AcceptedModuleTypes = [IdentityServerModuleType, MicrosoftIdentityModuleType]; + + public static void AddUserAccessModule(ModuleLoader moduleLoader, IConfiguration configuration) + { + var userModule = configuration["Modules:UserModule"]; + if (string.IsNullOrWhiteSpace(userModule) || !AcceptedModuleTypes.Contains(userModule)) + { + throw new InvalidOperationException($"Invalid user module configuration. Accepted values are: {string.Join(", ", AcceptedModuleTypes)}"); + } + + if (userModule == IdentityServerModuleType) + { + moduleLoader.AddModule(new IdentityServerUserAccess.UserAccessModule(configuration)); + return; + } + + if (userModule == MicrosoftIdentityModuleType) + { + moduleLoader.AddModule(new MicrosoftIdentityUserAccess.UserAccessModule(configuration)); + return; + } + } +} \ No newline at end of file diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Administration/MeetingGroupProposals/MeetingGroupProposalsController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Administration/MeetingGroupProposals/MeetingGroupProposalsController.cs index f6cbbd674..97c88f138 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Administration/MeetingGroupProposals/MeetingGroupProposalsController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Administration/MeetingGroupProposals/MeetingGroupProposalsController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Administration.Application.Contracts; using CompanyName.MyMeetings.Modules.Administration.Application.MeetingGroupProposals.AcceptMeetingGroupProposal; using CompanyName.MyMeetings.Modules.Administration.Application.MeetingGroupProposals.GetMeetingGroupProposal; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/Countries/CountriesController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/Countries/CountriesController.cs index a73f62412..3509b2d5b 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/Countries/CountriesController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/Countries/CountriesController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; using CompanyName.MyMeetings.Modules.Meetings.Application.Countries; using Microsoft.AspNetCore.Mvc; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingCommentingConfiguration/MeetingCommentingConfigurationController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingCommentingConfiguration/MeetingCommentingConfigurationController.cs index 0ea152f27..d1434551c 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingCommentingConfiguration/MeetingCommentingConfigurationController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingCommentingConfiguration/MeetingCommentingConfigurationController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; using CompanyName.MyMeetings.Modules.Meetings.Application.MeetingCommentingConfigurations.DisableMeetingCommentingConfiguration; using CompanyName.MyMeetings.Modules.Meetings.Application.MeetingCommentingConfigurations.EnableMeetingCommentingConfiguration; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingComments/MeetingCommentsController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingComments/MeetingCommentsController.cs index dc7c7b043..8087f926a 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingComments/MeetingCommentsController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingComments/MeetingCommentsController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; using CompanyName.MyMeetings.Modules.Meetings.Application.MeetingComments.AddMeetingComment; using CompanyName.MyMeetings.Modules.Meetings.Application.MeetingComments.AddMeetingCommentLike; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingGroupProposals/MeetingGroupProposalsController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingGroupProposals/MeetingGroupProposalsController.cs index aa55676e8..85e80c703 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingGroupProposals/MeetingGroupProposalsController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingGroupProposals/MeetingGroupProposalsController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; using CompanyName.MyMeetings.Modules.Meetings.Application.MeetingGroupProposals.GetAllMeetingGroupProposals; using CompanyName.MyMeetings.Modules.Meetings.Application.MeetingGroupProposals.GetMeetingGroupProposal; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingGroups/MeetingGroupsController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingGroups/MeetingGroupsController.cs index 614f30ad7..dfb3cef49 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingGroups/MeetingGroupsController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/MeetingGroups/MeetingGroupsController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; using CompanyName.MyMeetings.Modules.Meetings.Application.MeetingGroups.EditMeetingGroupGeneralAttributes; using CompanyName.MyMeetings.Modules.Meetings.Application.MeetingGroups.GetAllMeetingGroups; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/Meetings/MeetingsController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/Meetings/MeetingsController.cs index 57abb82d4..76e1d8cfc 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Meetings/Meetings/MeetingsController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Meetings/Meetings/MeetingsController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Meetings.Application.Contracts; using CompanyName.MyMeetings.Modules.Meetings.Application.Meetings.AddMeetingAttendee; using CompanyName.MyMeetings.Modules.Meetings.Application.Meetings.AddMeetingNotAttendee; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Payments/MeetingFees/MeetingFeePaymentsController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Payments/MeetingFees/MeetingFeePaymentsController.cs index eef74d69a..9f005c857 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Payments/MeetingFees/MeetingFeePaymentsController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Payments/MeetingFees/MeetingFeePaymentsController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Payments.Application.Contracts; using CompanyName.MyMeetings.Modules.Payments.Application.MeetingFees.CreateMeetingFeePayment; using CompanyName.MyMeetings.Modules.Payments.Application.MeetingFees.MarkMeetingFeePaymentAsPaid; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Payments/Payers/PayersController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Payments/Payers/PayersController.cs index e6a42ad0b..b747d726c 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Payments/Payers/PayersController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Payments/Payers/PayersController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Payments.Application.Contracts; using CompanyName.MyMeetings.Modules.Payments.Application.Subscriptions.GetPayerSubscription; using CompanyName.MyMeetings.Modules.Payments.Application.Subscriptions.GetSubscriptionDetails; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Payments/PriceListItems/PriceListItemsController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Payments/PriceListItems/PriceListItemsController.cs index 1ebb287f9..827c57aa0 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Payments/PriceListItems/PriceListItemsController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Payments/PriceListItems/PriceListItemsController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Payments.Application.Contracts; using CompanyName.MyMeetings.Modules.Payments.Application.PriceListItems.ActivatePriceListItem; using CompanyName.MyMeetings.Modules.Payments.Application.PriceListItems.ChangePriceListItemAttributes; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Payments/SubscriptionRenewalsController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Payments/SubscriptionRenewalsController.cs index 9ff1d3960..93462ef63 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Payments/SubscriptionRenewalsController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Payments/SubscriptionRenewalsController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Payments.Application.Contracts; using CompanyName.MyMeetings.Modules.Payments.Application.Subscriptions.MarkSubscriptionRenewalPaymentAsPaid; using Microsoft.AspNetCore.Mvc; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Payments/Subscriptions/SubscriptionPaymentsController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Payments/Subscriptions/SubscriptionPaymentsController.cs index 4ecde2444..10d1fa1f1 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Payments/Subscriptions/SubscriptionPaymentsController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Payments/Subscriptions/SubscriptionPaymentsController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Payments.Application.Contracts; using CompanyName.MyMeetings.Modules.Payments.Application.Subscriptions.MarkSubscriptionPaymentAsPaid; using Microsoft.AspNetCore.Mvc; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Payments/Subscriptions/SubscriptionsController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Payments/Subscriptions/SubscriptionsController.cs index b61b445fb..77b7c5824 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/Payments/Subscriptions/SubscriptionsController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Payments/Subscriptions/SubscriptionsController.cs @@ -1,4 +1,4 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Payments.Application.Contracts; using CompanyName.MyMeetings.Modules.Payments.Application.Subscriptions.BuySubscription; using CompanyName.MyMeetings.Modules.Payments.Application.Subscriptions.BuySubscriptionRenewal; diff --git a/src/API/CompanyName.MyMeetings.API/Modules/UserAccess/RegisterNewUserRequest.cs b/src/API/CompanyName.MyMeetings.API/Modules/Registrations/RegisterNewUserRequest.cs similarity index 84% rename from src/API/CompanyName.MyMeetings.API/Modules/UserAccess/RegisterNewUserRequest.cs rename to src/API/CompanyName.MyMeetings.API/Modules/Registrations/RegisterNewUserRequest.cs index 53fd0d6d1..9e555aa44 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/UserAccess/RegisterNewUserRequest.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Registrations/RegisterNewUserRequest.cs @@ -1,4 +1,4 @@ -namespace CompanyName.MyMeetings.API.Modules.UserAccess +namespace CompanyName.MyMeetings.API.Modules.Registrations { public class RegisterNewUserRequest { diff --git a/src/API/CompanyName.MyMeetings.API/Modules/Registrations/RegistrationsAutofacModule.cs b/src/API/CompanyName.MyMeetings.API/Modules/Registrations/RegistrationsAutofacModule.cs new file mode 100644 index 000000000..df041ceb4 --- /dev/null +++ b/src/API/CompanyName.MyMeetings.API/Modules/Registrations/RegistrationsAutofacModule.cs @@ -0,0 +1,15 @@ +using Autofac; +using CompanyName.MyMeetings.Modules.Registrations.Application.Contracts; +using CompanyName.MyMeetings.Modules.Registrations.Infrastructure; + +namespace CompanyName.MyMeetings.API.Modules.Registrations; + +internal class RegistrationsAutofacModule : Module +{ + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType() + .As() + .InstancePerLifetimeScope(); + } +} \ No newline at end of file diff --git a/src/API/CompanyName.MyMeetings.API/Modules/UserAccess/UserRegistrationsController.cs b/src/API/CompanyName.MyMeetings.API/Modules/Registrations/UserRegistrationsController.cs similarity index 89% rename from src/API/CompanyName.MyMeetings.API/Modules/UserAccess/UserRegistrationsController.cs rename to src/API/CompanyName.MyMeetings.API/Modules/Registrations/UserRegistrationsController.cs index 5b05df11c..15b4849e4 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/UserAccess/UserRegistrationsController.cs +++ b/src/API/CompanyName.MyMeetings.API/Modules/Registrations/UserRegistrationsController.cs @@ -1,12 +1,11 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.Registrations.Application.Contracts; using CompanyName.MyMeetings.Modules.Registrations.Application.UserRegistrations.ConfirmUserRegistration; using CompanyName.MyMeetings.Modules.Registrations.Application.UserRegistrations.RegisterNewUser; -using CompanyName.MyMeetings.Modules.UserAccess.Application.Contracts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace CompanyName.MyMeetings.API.Modules.UserAccess +namespace CompanyName.MyMeetings.API.Modules.Registrations { [Route("userAccess/[controller]")] [ApiController] diff --git a/src/API/CompanyName.MyMeetings.API/Modules/UserAccess/UserAccessAutofacModule.cs b/src/API/CompanyName.MyMeetings.API/Modules/UserAccess/UserAccessAutofacModule.cs deleted file mode 100644 index 4f846e7da..000000000 --- a/src/API/CompanyName.MyMeetings.API/Modules/UserAccess/UserAccessAutofacModule.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Autofac; -using CompanyName.MyMeetings.Modules.UserAccess.Application.Contracts; -using CompanyName.MyMeetings.Modules.UserAccess.Infrastructure; - -namespace CompanyName.MyMeetings.API.Modules.UserAccess -{ - public class UserAccessAutofacModule : Module - { - protected override void Load(ContainerBuilder builder) - { - builder.RegisterType() - .As() - .InstancePerLifetimeScope(); - } - } -} \ No newline at end of file diff --git a/src/API/CompanyName.MyMeetings.API/Program.cs b/src/API/CompanyName.MyMeetings.API/Program.cs index 6505e6a88..fccb72d0f 100644 --- a/src/API/CompanyName.MyMeetings.API/Program.cs +++ b/src/API/CompanyName.MyMeetings.API/Program.cs @@ -1,4 +1,6 @@ using Autofac.Extensions.DependencyInjection; +using Serilog; +using Serilog.Formatting.Compact; namespace CompanyName.MyMeetings.API { @@ -12,9 +14,23 @@ public static void Main(string[] args) public static IHostBuilder CreateWebHostBuilder(string[] args) { return Host.CreateDefaultBuilder(args) + .UseSerilog(ConfigureLogger()) .UseServiceProviderFactory(new AutofacServiceProviderFactory()) .ConfigureWebHostDefaults( webBuilder => { webBuilder.UseStartup(); }); } + + private static Serilog.ILogger ConfigureLogger() + { + var logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .WriteTo.Console( + outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] [{Module}] [{Context}] {Message:lj}{NewLine}{Exception}") + .WriteTo.File(new CompactJsonFormatter(), "logs/logs") + .CreateLogger(); + + return logger.ForContext("Module", "Host"); + } } } \ No newline at end of file diff --git a/src/API/CompanyName.MyMeetings.API/Startup.cs b/src/API/CompanyName.MyMeetings.API/Startup.cs index e7a3b3108..832a33023 100644 --- a/src/API/CompanyName.MyMeetings.API/Startup.cs +++ b/src/API/CompanyName.MyMeetings.API/Startup.cs @@ -1,26 +1,24 @@ using Autofac; using Autofac.Extensions.DependencyInjection; -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.API.Configuration; using CompanyName.MyMeetings.API.Configuration.ExecutionContext; using CompanyName.MyMeetings.API.Configuration.Extensions; using CompanyName.MyMeetings.API.Configuration.Validation; using CompanyName.MyMeetings.API.Modules.Administration; using CompanyName.MyMeetings.API.Modules.Meetings; using CompanyName.MyMeetings.API.Modules.Payments; -using CompanyName.MyMeetings.API.Modules.UserAccess; +using CompanyName.MyMeetings.API.Modules.Registrations; using CompanyName.MyMeetings.BuildingBlocks.Application; using CompanyName.MyMeetings.BuildingBlocks.Domain; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Emails; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; using CompanyName.MyMeetings.Modules.Administration.Infrastructure.Configuration; using CompanyName.MyMeetings.Modules.Meetings.Infrastructure.Configuration; using CompanyName.MyMeetings.Modules.Payments.Infrastructure.Configuration; using CompanyName.MyMeetings.Modules.Registrations.Infrastructure.Configuration; -using CompanyName.MyMeetings.Modules.UserAccess.Infrastructure.Configuration; -using CompanyName.MyMeetings.Modules.UserAccess.Infrastructure.Configuration.Identity; using Hellang.Middleware.ProblemDetails; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Server.HttpSys; +using Microsoft.AspNetCore.Mvc.ApplicationParts; using Serilog; using Serilog.Formatting.Compact; using ILogger = Serilog.ILogger; @@ -33,6 +31,7 @@ public class Startup private static ILogger _logger; private static ILogger _loggerForApi; private readonly IConfiguration _configuration; + private readonly ModuleLoader _moduleLoader = new(); public Startup(IWebHostEnvironment env) { @@ -47,17 +46,18 @@ public Startup(IWebHostEnvironment env) _loggerForApi.Information("Connection string:" + _configuration.GetConnectionString(MeetingsConnectionString)); - AuthorizationChecker.CheckAllEndpoints(); + AuthorizationChecker.CheckAllEndpoints(typeof(Startup).Assembly); + RegisterModules(_moduleLoader); } public void ConfigureServices(IServiceCollection services) { - services.AddControllers(); + var builder = services.AddControllers(); + builder.PartManager.ApplicationParts.Clear(); + builder.PartManager.ApplicationParts.Add(new AssemblyPart(typeof(Startup).Assembly)); services.AddSwaggerDocumentation(); - services.ConfigureIdentityService(); - services.AddSingleton(); services.AddSingleton(); @@ -67,42 +67,30 @@ public void ConfigureServices(IServiceCollection services) x.Map(ex => new BusinessRuleValidationExceptionProblemDetails(ex)); }); - services.AddAuthorization(options => - { - options.AddPolicy(HasPermissionAttribute.HasPermissionPolicyName, policyBuilder => - { - policyBuilder.Requirements.Add(new HasPermissionAuthorizationRequirement()); - policyBuilder.AddAuthenticationSchemes("Bearer"); - }); - }); - - services.AddScoped(); + _moduleLoader.AddHostServices(services, builder.PartManager); } public void ConfigureContainer(ContainerBuilder containerBuilder) { containerBuilder.RegisterModule(new MeetingsAutofacModule()); containerBuilder.RegisterModule(new AdministrationAutofacModule()); - containerBuilder.RegisterModule(new UserAccessAutofacModule()); containerBuilder.RegisterModule(new PaymentsAutofacModule()); + containerBuilder.RegisterModule(new RegistrationsAutofacModule()); + _moduleLoader.RegisterModules(containerBuilder); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IServiceProvider serviceProvider) { - var container = app.ApplicationServices.GetAutofacRoot(); - app.UseCors(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); - InitializeModules(container); + InitializeModules(app); app.UseMiddleware(); app.UseSwaggerDocumentation(); - app.AddIdentityService(); - if (env.IsDevelopment()) { app.UseProblemDetails(); @@ -138,33 +126,40 @@ private static void ConfigureLogger() _loggerForApi.Information("Logger configured"); } - private void InitializeModules(ILifetimeScope container) + private void RegisterModules(ModuleLoader moduleLoader) { + UserAccessModuleSelector.AddUserAccessModule(moduleLoader, _configuration); + } + + private void InitializeModules(IApplicationBuilder applicationBuilder) + { + var container = applicationBuilder.ApplicationServices.GetAutofacRoot(); var httpContextAccessor = container.Resolve(); var executionContextAccessor = new ExecutionContextAccessor(httpContextAccessor); var emailsConfiguration = new EmailsConfiguration(_configuration["EmailsConfiguration:FromEmail"]); - MeetingsStartup.Initialize( + var hostServices = new HostServices( + _logger, _configuration.GetConnectionString(MeetingsConnectionString), + _configuration["Security:TextEncryptionKey"], + applicationBuilder, executionContextAccessor, - _logger, emailsConfiguration, - null); + eventsBus: null); + _moduleLoader.InitializeModules(hostServices); - AdministrationStartup.Initialize( + MeetingsStartup.Initialize( _configuration.GetConnectionString(MeetingsConnectionString), executionContextAccessor, _logger, + emailsConfiguration, null); - UserAccessStartup.Initialize( + AdministrationStartup.Initialize( _configuration.GetConnectionString(MeetingsConnectionString), executionContextAccessor, _logger, - emailsConfiguration, - _configuration["Security:TextEncryptionKey"], - null, null); PaymentsStartup.Initialize( diff --git a/src/API/CompanyName.MyMeetings.API/appsettings.json b/src/API/CompanyName.MyMeetings.API/appsettings.json index 4a2889513..5b82b900a 100644 --- a/src/API/CompanyName.MyMeetings.API/appsettings.json +++ b/src/API/CompanyName.MyMeetings.API/appsettings.json @@ -18,5 +18,23 @@ }, "ConnectionStrings": { "MeetingsConnectionString": "YourConnectioString" + }, + + "Modules": { + // Specify which module is used for user management and authentication + // Possible values: "IdentityServer", "MicrosoftIdentity" + "UserModule": "IdentityServer", + + "UserAccess": { + "Security": { + /* NOTE! This is sensitive data and should be stored in secure way (not here). Added only for demo purpose. */ + "JwtSecretKey": "eiURlJpBCvFkZXzQrWKVhAWhHcbfefCPcqUrrbKOTDrkJOxLEOjuAmKiRMcKNKC", + "JwtIssuer": "api://mymeetings.com/api", + "JwtAudience": "api://mymeetings.com/api", + // JWT tokens are not supposed to be long-lived, quite the opposite. + // They're designed to be short-lived (5 to 10 minutes) with refresh capabilities. + "JwtTokenLifetimeInMinutes": 7 + } + } } } diff --git a/src/API/RequestExamples/Users.http b/src/API/RequestExamples/Users.http index 44babb5ef..09bf15c84 100644 --- a/src/API/RequestExamples/Users.http +++ b/src/API/RequestExamples/Users.http @@ -13,4 +13,7 @@ Content-Type: application/json ### User registration confirmation -PATCH {{baseUrl}}/userAccess/UserRegistrations/e80985c5-bf97-4bb3-b178-9423d70ef87b/confirm \ No newline at end of file +PATCH {{baseUrl}}/userAccess/UserRegistrations/e80985c5-bf97-4bb3-b178-9423d70ef87b/confirm + +### Get user accounts +GET http://localhost:5000/api/users/accounts \ No newline at end of file diff --git a/src/Modules/Registrations/Application/UserRegistrations/RegisterNewUser/PasswordManager.cs b/src/BuildingBlocks/Application/Security/PasswordManager.cs similarity index 95% rename from src/Modules/Registrations/Application/UserRegistrations/RegisterNewUser/PasswordManager.cs rename to src/BuildingBlocks/Application/Security/PasswordManager.cs index aef993fc6..dd8461e08 100644 --- a/src/Modules/Registrations/Application/UserRegistrations/RegisterNewUser/PasswordManager.cs +++ b/src/BuildingBlocks/Application/Security/PasswordManager.cs @@ -1,7 +1,7 @@ using System.Runtime.CompilerServices; using System.Security.Cryptography; -namespace CompanyName.MyMeetings.Modules.Registrations.Application.UserRegistrations.RegisterNewUser +namespace CompanyName.MyMeetings.BuildingBlocks.Application.Security { public class PasswordManager { diff --git a/src/BuildingBlocks/Domain/Entity.cs b/src/BuildingBlocks/Domain/Entity.cs index a0158fa3d..b4e430091 100644 --- a/src/BuildingBlocks/Domain/Entity.cs +++ b/src/BuildingBlocks/Domain/Entity.cs @@ -1,6 +1,6 @@ namespace CompanyName.MyMeetings.BuildingBlocks.Domain { - public abstract class Entity + public abstract class Entity : IEntity { private List _domainEvents; diff --git a/src/BuildingBlocks/Domain/IEntity.cs b/src/BuildingBlocks/Domain/IEntity.cs new file mode 100644 index 000000000..aa0e009cb --- /dev/null +++ b/src/BuildingBlocks/Domain/IEntity.cs @@ -0,0 +1,12 @@ +namespace CompanyName.MyMeetings.BuildingBlocks.Domain +{ + public interface IEntity + { + void ClearDomainEvents(); + + /// + /// Domain events occurred. + /// + IReadOnlyCollection DomainEvents { get; } + } +} diff --git a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/AttributeAuthorizationHandler.cs b/src/BuildingBlocks/Infrastructure/Authorization/AttributeAuthorizationHandler.cs similarity index 74% rename from src/API/CompanyName.MyMeetings.API/Configuration/Authorization/AttributeAuthorizationHandler.cs rename to src/BuildingBlocks/Infrastructure/Authorization/AttributeAuthorizationHandler.cs index 02fe21d7c..3374e2c5a 100644 --- a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/AttributeAuthorizationHandler.cs +++ b/src/BuildingBlocks/Infrastructure/Authorization/AttributeAuthorizationHandler.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; -namespace CompanyName.MyMeetings.API.Configuration.Authorization +namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization { public abstract class AttributeAuthorizationHandler : AuthorizationHandler @@ -9,10 +11,10 @@ public abstract class AttributeAuthorizationHandler { protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, TRequirement requirement) { - var endpoint = (context.Resource as HttpContext).GetEndpoint() as RouteEndpoint; + var endpoint = (context.Resource as HttpContext)!.GetEndpoint() as RouteEndpoint; var attribute = endpoint?.Metadata.GetMetadata(); - return HandleRequirementAsync(context, requirement, attribute); + return HandleRequirementAsync(context, requirement, attribute!); } protected abstract Task HandleRequirementAsync( diff --git a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/AuthorizationChecker.cs b/src/BuildingBlocks/Infrastructure/Authorization/AuthorizationChecker.cs similarity index 92% rename from src/API/CompanyName.MyMeetings.API/Configuration/Authorization/AuthorizationChecker.cs rename to src/BuildingBlocks/Infrastructure/Authorization/AuthorizationChecker.cs index 0f4d559d1..0c570b9bb 100644 --- a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/AuthorizationChecker.cs +++ b/src/BuildingBlocks/Infrastructure/Authorization/AuthorizationChecker.cs @@ -2,13 +2,12 @@ using System.Text; using Microsoft.AspNetCore.Mvc; -namespace CompanyName.MyMeetings.API.Configuration.Authorization +namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization { public static class AuthorizationChecker { - public static void CheckAllEndpoints() + public static void CheckAllEndpoints(Assembly assembly) { - var assembly = typeof(Startup).Assembly; var allControllerTypes = assembly.GetTypes().Where(x => x.IsSubclassOf(typeof(ControllerBase))); List notProtectedActionMethods = []; diff --git a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/HasPermissionAttribute.cs b/src/BuildingBlocks/Infrastructure/Authorization/HasPermissionAttribute.cs similarity index 61% rename from src/API/CompanyName.MyMeetings.API/Configuration/Authorization/HasPermissionAttribute.cs rename to src/BuildingBlocks/Infrastructure/Authorization/HasPermissionAttribute.cs index 2678b3894..deb4a94c5 100644 --- a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/HasPermissionAttribute.cs +++ b/src/BuildingBlocks/Infrastructure/Authorization/HasPermissionAttribute.cs @@ -1,11 +1,11 @@ using Microsoft.AspNetCore.Authorization; -namespace CompanyName.MyMeetings.API.Configuration.Authorization +namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization { [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] - internal class HasPermissionAttribute : AuthorizeAttribute + public class HasPermissionAttribute : AuthorizeAttribute { - internal const string HasPermissionPolicyName = "HasPermission"; + public const string HasPermissionPolicyName = "HasPermission"; public HasPermissionAttribute(string name) : base(HasPermissionPolicyName) diff --git a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/HasPermissionAuthorizationRequirement.cs b/src/BuildingBlocks/Infrastructure/Authorization/HasPermissionAuthorizationRequirement.cs similarity index 65% rename from src/API/CompanyName.MyMeetings.API/Configuration/Authorization/HasPermissionAuthorizationRequirement.cs rename to src/BuildingBlocks/Infrastructure/Authorization/HasPermissionAuthorizationRequirement.cs index 6054994c6..d93105f2b 100644 --- a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/HasPermissionAuthorizationRequirement.cs +++ b/src/BuildingBlocks/Infrastructure/Authorization/HasPermissionAuthorizationRequirement.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authorization; -namespace CompanyName.MyMeetings.API.Configuration.Authorization +namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization { public class HasPermissionAuthorizationRequirement : IAuthorizationRequirement { diff --git a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/NoPermissionRequiredAttribute.cs b/src/BuildingBlocks/Infrastructure/Authorization/NoPermissionRequiredAttribute.cs similarity index 63% rename from src/API/CompanyName.MyMeetings.API/Configuration/Authorization/NoPermissionRequiredAttribute.cs rename to src/BuildingBlocks/Infrastructure/Authorization/NoPermissionRequiredAttribute.cs index ee6fd98b8..b93c0f943 100644 --- a/src/API/CompanyName.MyMeetings.API/Configuration/Authorization/NoPermissionRequiredAttribute.cs +++ b/src/BuildingBlocks/Infrastructure/Authorization/NoPermissionRequiredAttribute.cs @@ -1,4 +1,4 @@ -namespace CompanyName.MyMeetings.API.Configuration.Authorization +namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization { [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public class NoPermissionRequiredAttribute : Attribute diff --git a/src/BuildingBlocks/Infrastructure/ModuleHosting/HostServices.cs b/src/BuildingBlocks/Infrastructure/ModuleHosting/HostServices.cs new file mode 100644 index 000000000..0208997f4 --- /dev/null +++ b/src/BuildingBlocks/Infrastructure/ModuleHosting/HostServices.cs @@ -0,0 +1,37 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Emails; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.EventBus; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Serilog; +#nullable enable + +namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; + +public class HostServices +{ + public HostServices(ILogger logger, string connectionString, string textEncryptionKey, IApplicationBuilder applicationBuilder, IExecutionContextAccessor executionContextAccessor, EmailsConfiguration emailsConfiguration, IEventsBus? eventsBus) + { + Logger = logger; + ConnectionString = connectionString; + TextEncryptionKey = textEncryptionKey; + ApplicationBuilder = applicationBuilder; + ExecutionContextAccessor = executionContextAccessor; + EventsBus = eventsBus; + EmailsConfiguration = emailsConfiguration; + } + + public ILogger Logger { get; } + + public string ConnectionString { get; } + + public string TextEncryptionKey { get; } + + public IApplicationBuilder ApplicationBuilder { get; } + + public IExecutionContextAccessor ExecutionContextAccessor { get; } + + public IEventsBus? EventsBus { get; } + + public EmailsConfiguration EmailsConfiguration { get; } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Infrastructure/ModuleHosting/IModule.cs b/src/BuildingBlocks/Infrastructure/ModuleHosting/IModule.cs new file mode 100644 index 000000000..b84035c48 --- /dev/null +++ b/src/BuildingBlocks/Infrastructure/ModuleHosting/IModule.cs @@ -0,0 +1,39 @@ +using Autofac; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +#nullable enable + +namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; + +public interface IModule +{ + /// + /// Gets the search pattern used to locate Web API assembly within the module. + /// + string WebApiAssemblySearchPattern { get; } + + /// + /// Gets the host application's configuration. + /// + IConfiguration HostConfiguration { get; } + + /// + /// Registers the module within the host application's dependency injection container. + /// + /// The host's DI container builder. + void RegisterModule(ContainerBuilder containerBuilder); + + /// + /// Adds the services provided by the module to the host application's service collection. + /// + /// The host's service collection. + /// The application part manager to register MVC components. + void AddHostServices(IServiceCollection services, ApplicationPartManager applicationPartManager); + + /// + /// Initializes the module using the specified host services. + /// + /// The services initialized by the host that can be used for module initialization. + void InitializeModule(HostServices hostServices); +} \ No newline at end of file diff --git a/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs b/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs new file mode 100644 index 000000000..ab353d010 --- /dev/null +++ b/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs @@ -0,0 +1,55 @@ +using System.Reflection; +using Autofac; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; + +public abstract class ModuleBase(IConfiguration hostConfiguration) : IModule +{ + public abstract string WebApiAssemblySearchPattern { get; } + + public IConfiguration HostConfiguration { get; } = hostConfiguration; + + public abstract void RegisterModule(ContainerBuilder containerBuilder); + + public abstract void InitializeModule(HostServices hostServices); + + public void AddHostServices(IServiceCollection services, ApplicationPartManager applicationPartManager) + { + RegisterModuleParts(applicationPartManager); + AddHostServices(services); + } + + /// + /// Adds application-specific services to the provided service collection for host configuration. + /// + /// Implementations should register any services required for the host's operation. This method + /// is typically called during application startup to configure dependency injection. + /// The service collection to which host services will be added. + protected abstract void AddHostServices(IServiceCollection services); + + /// + /// Registers the Web API assembly as an application part with the specified + /// instance, enabling discovery of controllers and other MVC features from that assembly. + /// + /// This method searches for the Web API assembly in the application's base directory using a + /// predefined search pattern. If the assembly is found, it is loaded and added to the . This allows ASP.NET Core to discover MVC controllers and related features + /// defined in the Web API assembly. + /// The to which the Web API assembly will be added as an application part. + /// Cannot be null. + private void RegisterModuleParts(ApplicationPartManager applicationPartManager) + { + var webApiAssembly = Directory + .GetFiles(AppContext.BaseDirectory, WebApiAssemblySearchPattern) + .Select(Assembly.LoadFrom) + .SingleOrDefault(); + + if (webApiAssembly != null) + { + applicationPartManager.ApplicationParts.Add(new AssemblyPart(webApiAssembly)); + } + } +} \ No newline at end of file diff --git a/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleLoader.cs b/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleLoader.cs new file mode 100644 index 000000000..8bf308125 --- /dev/null +++ b/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleLoader.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Autofac; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; + +public class ModuleLoader +{ + private readonly List _modules = new(); + + public IReadOnlyCollection Modules => _modules.AsReadOnly(); + + public ModuleLoader AddModule(IModule module) + { + AddModules([module]); + return this; + } + + public ModuleLoader AddModules(IEnumerable modules) + { + _modules.AddRange(modules); + return this; + } + + public ModuleLoader AddModules(params IModule[] modules) + { + _modules.AddRange(modules); + return this; + } + + public ModuleLoader AddModules(IConfiguration config, params Assembly[] assemblies) + { + foreach (var assembly in assemblies) + { + var moduleTypes = assembly.GetTypes() + .Where(t => typeof(IModule).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract); + foreach (var moduleType in moduleTypes) + { + if (Activator.CreateInstance(moduleType, config) is IModule moduleInstance) + { + _modules.Add(moduleInstance); + } + } + } + + return this; + } + + public void RegisterModules(ContainerBuilder containerBuilder) + { + foreach (var module in _modules) + { + module.RegisterModule(containerBuilder); + } + } + + public void AddHostServices(IServiceCollection services, ApplicationPartManager applicationPartManager) + { + foreach (var module in _modules) + { + module.AddHostServices(services, applicationPartManager); + } + } + + public void InitializeModules(HostServices hostServices) + { + foreach (var module in _modules) + { + module.InitializeModule(hostServices); + } + } +} \ No newline at end of file diff --git a/src/CompanyName.MyMeetings.sln b/src/CompanyName.MyMeetings.sln index 0266c55cc..3458ff6bb 100644 --- a/src/CompanyName.MyMeetings.sln +++ b/src/CompanyName.MyMeetings.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.8.34330.188 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11121.172 MinimumVisualStudioVersion = 15.0.26124.0 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CompanyName.MyMeetings.API", "API\CompanyName.MyMeetings.API\CompanyName.MyMeetings.API.csproj", "{49D08B64-AC8E-4607-820F-8A0B989CFD33}" EndProject @@ -11,8 +11,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{BCE1 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Meetings", "Meetings", "{9CD43CAC-C149-41E1-9654-157D578143B7}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UserAccess", "UserAccess", "{AE6D0618-60E1-40B2-A46F-664A19C9503C}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "API", "API", "{BC9DDFD1-FB81-4996-812A-68BEBCA33A97}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CompanyName.MyMeetings.Modules.UserAccess.Application", "Modules\UserAccess\Application\CompanyName.MyMeetings.Modules.UserAccess.Application.csproj", "{F34C6504-590B-480A-A239-F230CDFF8CED}" @@ -113,6 +111,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{8172C84D-A01E-4B56-8A41-498E5DB9E395}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + ..\.gitignore = ..\.gitignore Directory.Build.props = Directory.Build.props Directory.Build.targets = Directory.Build.targets Directory.Packages.props = Directory.Packages.props @@ -154,6 +153,38 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modu EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modules.Registrations.IntegrationEvents", "Modules\Registrations\IntegrationEvents\CompanyName.MyMeetings.Modules.Registrations.IntegrationEvents.csproj", "{2E71D2B2-516D-4B0D-8DE6-B9F3105B9C95}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UserAccess", "UserAccess", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MicrosoftIdentity", "MicrosoftIdentity", "{1E6ABBCD-5F00-4682-9561-8A6270327B75}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Api", "Api", "{94D76666-FA9F-44D9-874D-96492A363678}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{1F6FFCF9-B7ED-4A74-9865-047D5B28F9FF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modules.UsersMI.Domain", "Modules\Users\MicrosoftIdentity\Domain\CompanyName.MyMeetings.Modules.UsersMI.Domain.csproj", "{8E9C2C95-133B-F06A-4387-5367FDCE9645}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modules.UsersMI.Infrastructure", "Modules\Users\MicrosoftIdentity\Infrastructure\CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.csproj", "{BC06BE1C-DC2B-2E09-2D28-BD19F746B9FB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modules.UsersMI.Application", "Modules\Users\MicrosoftIdentity\Application\CompanyName.MyMeetings.Modules.UsersMI.Application.csproj", "{3EBDF0F1-C491-2779-E0D8-00B8597A7D89}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modules.UsersMI.Contracts", "Modules\Users\MicrosoftIdentity\Api\Contracts\CompanyName.MyMeetings.Modules.UsersMI.Contracts.csproj", "{1B5E35AD-CCD3-0628-1582-E6C8179596EC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modules.UsersMI.Sdk", "Modules\Users\MicrosoftIdentity\Api\Sdk\CompanyName.MyMeetings.Modules.UsersMI.Sdk.csproj", "{D26A8127-FA34-ADB3-5D91-543166A85237}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modules.UsersMI.WebApi", "Modules\Users\MicrosoftIdentity\Api\WebApi\CompanyName.MyMeetings.Modules.UsersMI.WebApi.csproj", "{F091D812-6F09-E7EA-D9B3-963126E0A2FD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modules.UsersMI.ArchTests", "Modules\Users\MicrosoftIdentity\Tests\ArchTests\CompanyName.MyMeetings.Modules.UsersMI.ArchTests.csproj", "{6AE0A077-04AC-B244-12AA-4072D1A8E87E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests", "Modules\Users\MicrosoftIdentity\Tests\IntegrationTests\CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.csproj", "{DB3CC4F8-66D3-E688-7B40-A349557DF6A8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modules.UsersMI.UnitTests", "Modules\Users\MicrosoftIdentity\Tests\UnitTests\CompanyName.MyMeetings.Modules.UsersMI.UnitTests.csproj", "{50A9A239-423B-C70D-8D46-839C76130A88}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "IdentityServer", "IdentityServer", "{556AC016-1A29-4FA3-A78C-11463F340D00}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Api", "Api", "{4BA6D6D2-5252-4207-A724-F368A5C53B99}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CompanyName.MyMeetings.Modules.UserAccess.WebApi", "Modules\UserAccess\Api\WebApi\CompanyName.MyMeetings.Modules.UserAccess.WebApi.csproj", "{1E0E0736-1A25-470D-73E4-7D08AEDAC27B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -445,6 +476,66 @@ Global {2E71D2B2-516D-4B0D-8DE6-B9F3105B9C95}.Production|Any CPU.Build.0 = Production|Any CPU {2E71D2B2-516D-4B0D-8DE6-B9F3105B9C95}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E71D2B2-516D-4B0D-8DE6-B9F3105B9C95}.Release|Any CPU.Build.0 = Release|Any CPU + {8E9C2C95-133B-F06A-4387-5367FDCE9645}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E9C2C95-133B-F06A-4387-5367FDCE9645}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E9C2C95-133B-F06A-4387-5367FDCE9645}.Production|Any CPU.ActiveCfg = Production|Any CPU + {8E9C2C95-133B-F06A-4387-5367FDCE9645}.Production|Any CPU.Build.0 = Production|Any CPU + {8E9C2C95-133B-F06A-4387-5367FDCE9645}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E9C2C95-133B-F06A-4387-5367FDCE9645}.Release|Any CPU.Build.0 = Release|Any CPU + {BC06BE1C-DC2B-2E09-2D28-BD19F746B9FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC06BE1C-DC2B-2E09-2D28-BD19F746B9FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC06BE1C-DC2B-2E09-2D28-BD19F746B9FB}.Production|Any CPU.ActiveCfg = Production|Any CPU + {BC06BE1C-DC2B-2E09-2D28-BD19F746B9FB}.Production|Any CPU.Build.0 = Production|Any CPU + {BC06BE1C-DC2B-2E09-2D28-BD19F746B9FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC06BE1C-DC2B-2E09-2D28-BD19F746B9FB}.Release|Any CPU.Build.0 = Release|Any CPU + {3EBDF0F1-C491-2779-E0D8-00B8597A7D89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3EBDF0F1-C491-2779-E0D8-00B8597A7D89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3EBDF0F1-C491-2779-E0D8-00B8597A7D89}.Production|Any CPU.ActiveCfg = Production|Any CPU + {3EBDF0F1-C491-2779-E0D8-00B8597A7D89}.Production|Any CPU.Build.0 = Production|Any CPU + {3EBDF0F1-C491-2779-E0D8-00B8597A7D89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3EBDF0F1-C491-2779-E0D8-00B8597A7D89}.Release|Any CPU.Build.0 = Release|Any CPU + {1B5E35AD-CCD3-0628-1582-E6C8179596EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B5E35AD-CCD3-0628-1582-E6C8179596EC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B5E35AD-CCD3-0628-1582-E6C8179596EC}.Production|Any CPU.ActiveCfg = Production|Any CPU + {1B5E35AD-CCD3-0628-1582-E6C8179596EC}.Production|Any CPU.Build.0 = Production|Any CPU + {1B5E35AD-CCD3-0628-1582-E6C8179596EC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B5E35AD-CCD3-0628-1582-E6C8179596EC}.Release|Any CPU.Build.0 = Release|Any CPU + {D26A8127-FA34-ADB3-5D91-543166A85237}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D26A8127-FA34-ADB3-5D91-543166A85237}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D26A8127-FA34-ADB3-5D91-543166A85237}.Production|Any CPU.ActiveCfg = Production|Any CPU + {D26A8127-FA34-ADB3-5D91-543166A85237}.Production|Any CPU.Build.0 = Production|Any CPU + {D26A8127-FA34-ADB3-5D91-543166A85237}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D26A8127-FA34-ADB3-5D91-543166A85237}.Release|Any CPU.Build.0 = Release|Any CPU + {F091D812-6F09-E7EA-D9B3-963126E0A2FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F091D812-6F09-E7EA-D9B3-963126E0A2FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F091D812-6F09-E7EA-D9B3-963126E0A2FD}.Production|Any CPU.ActiveCfg = Production|Any CPU + {F091D812-6F09-E7EA-D9B3-963126E0A2FD}.Production|Any CPU.Build.0 = Production|Any CPU + {F091D812-6F09-E7EA-D9B3-963126E0A2FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F091D812-6F09-E7EA-D9B3-963126E0A2FD}.Release|Any CPU.Build.0 = Release|Any CPU + {6AE0A077-04AC-B244-12AA-4072D1A8E87E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AE0A077-04AC-B244-12AA-4072D1A8E87E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AE0A077-04AC-B244-12AA-4072D1A8E87E}.Production|Any CPU.ActiveCfg = Production|Any CPU + {6AE0A077-04AC-B244-12AA-4072D1A8E87E}.Production|Any CPU.Build.0 = Production|Any CPU + {6AE0A077-04AC-B244-12AA-4072D1A8E87E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AE0A077-04AC-B244-12AA-4072D1A8E87E}.Release|Any CPU.Build.0 = Release|Any CPU + {DB3CC4F8-66D3-E688-7B40-A349557DF6A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB3CC4F8-66D3-E688-7B40-A349557DF6A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB3CC4F8-66D3-E688-7B40-A349557DF6A8}.Production|Any CPU.ActiveCfg = Production|Any CPU + {DB3CC4F8-66D3-E688-7B40-A349557DF6A8}.Production|Any CPU.Build.0 = Production|Any CPU + {DB3CC4F8-66D3-E688-7B40-A349557DF6A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB3CC4F8-66D3-E688-7B40-A349557DF6A8}.Release|Any CPU.Build.0 = Release|Any CPU + {50A9A239-423B-C70D-8D46-839C76130A88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50A9A239-423B-C70D-8D46-839C76130A88}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50A9A239-423B-C70D-8D46-839C76130A88}.Production|Any CPU.ActiveCfg = Production|Any CPU + {50A9A239-423B-C70D-8D46-839C76130A88}.Production|Any CPU.Build.0 = Production|Any CPU + {50A9A239-423B-C70D-8D46-839C76130A88}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50A9A239-423B-C70D-8D46-839C76130A88}.Release|Any CPU.Build.0 = Release|Any CPU + {1E0E0736-1A25-470D-73E4-7D08AEDAC27B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E0E0736-1A25-470D-73E4-7D08AEDAC27B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E0E0736-1A25-470D-73E4-7D08AEDAC27B}.Production|Any CPU.ActiveCfg = Production|Any CPU + {1E0E0736-1A25-470D-73E4-7D08AEDAC27B}.Production|Any CPU.Build.0 = Production|Any CPU + {1E0E0736-1A25-470D-73E4-7D08AEDAC27B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E0E0736-1A25-470D-73E4-7D08AEDAC27B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -452,8 +543,7 @@ Global GlobalSection(NestedProjects) = preSolution {49D08B64-AC8E-4607-820F-8A0B989CFD33} = {BC9DDFD1-FB81-4996-812A-68BEBCA33A97} {9CD43CAC-C149-41E1-9654-157D578143B7} = {BCE1EE3C-ADB1-48CC-9FD1-C7324D886964} - {AE6D0618-60E1-40B2-A46F-664A19C9503C} = {BCE1EE3C-ADB1-48CC-9FD1-C7324D886964} - {F34C6504-590B-480A-A239-F230CDFF8CED} = {AE6D0618-60E1-40B2-A46F-664A19C9503C} + {F34C6504-590B-480A-A239-F230CDFF8CED} = {556AC016-1A29-4FA3-A78C-11463F340D00} {5C2C1630-8A7A-451F-81A8-8547C939F5FD} = {9CD43CAC-C149-41E1-9654-157D578143B7} {F71CFDA8-0770-4761-896A-FB0098113F87} = {9CD43CAC-C149-41E1-9654-157D578143B7} {93F3D023-37BD-4C5C-9442-DE2631CD4954} = {9CD43CAC-C149-41E1-9654-157D578143B7} @@ -465,9 +555,9 @@ Global {CD765A37-EB16-4E35-AA03-6E706D70E8A0} = {5F398170-87FD-4368-9930-FAAAD2D9FDCC} {3ED61776-83A0-426C-9B3A-3AB755DA01EF} = {9CD43CAC-C149-41E1-9654-157D578143B7} {396817BD-94A7-4559-A7F1-2FAB8009BCF8} = {5F398170-87FD-4368-9930-FAAAD2D9FDCC} - {55E1C531-2B9B-49B1-BDFE-9DE322E7FE00} = {AE6D0618-60E1-40B2-A46F-664A19C9503C} - {F364B0C4-1882-46A1-9B08-22587BEF05A2} = {AE6D0618-60E1-40B2-A46F-664A19C9503C} - {0C31EA31-6A10-47D0-82E5-6D224E1CE532} = {AE6D0618-60E1-40B2-A46F-664A19C9503C} + {55E1C531-2B9B-49B1-BDFE-9DE322E7FE00} = {556AC016-1A29-4FA3-A78C-11463F340D00} + {F364B0C4-1882-46A1-9B08-22587BEF05A2} = {556AC016-1A29-4FA3-A78C-11463F340D00} + {0C31EA31-6A10-47D0-82E5-6D224E1CE532} = {556AC016-1A29-4FA3-A78C-11463F340D00} {13E4D721-2E96-497E-9657-503D09468F9F} = {BCE1EE3C-ADB1-48CC-9FD1-C7324D886964} {6E013582-9E44-4D7E-8CFE-5F6FB6757B03} = {13E4D721-2E96-497E-9657-503D09468F9F} {C457929B-91BE-48CC-A714-217D9CABD3CC} = {13E4D721-2E96-497E-9657-503D09468F9F} @@ -475,7 +565,7 @@ Global {8DF88C85-594D-45D7-B12E-6B641D497853} = {13E4D721-2E96-497E-9657-503D09468F9F} {9EAA687B-951E-4D89-8857-99151FF1BCD7} = {E91D4BE3-61FE-441C-A227-29850D414216} {43DBBB02-CA43-42AD-BE21-04AC867BA168} = {C733D087-7051-4E35-BCDB-081252A108E5} - {0FF699EF-8156-43CB-8D18-8EA28F30E9EE} = {AE6D0618-60E1-40B2-A46F-664A19C9503C} + {0FF699EF-8156-43CB-8D18-8EA28F30E9EE} = {556AC016-1A29-4FA3-A78C-11463F340D00} {0BC12804-A858-427B-88E2-F9CDE9E97986} = {0FF699EF-8156-43CB-8D18-8EA28F30E9EE} {53E4F002-E708-45F7-8444-19EB8977B5C9} = {13E4D721-2E96-497E-9657-503D09468F9F} {602768DD-F063-469D-9CD3-95CB02F6E441} = {53E4F002-E708-45F7-8444-19EB8977B5C9} @@ -509,6 +599,22 @@ Global {9AB969B5-4215-4ACF-8D48-EC0A6F35BC46} = {646E463D-F0E2-4BA4-9B5E-434ABE26EC07} {0535D1F2-FA8B-4093-9987-7533F8D07605} = {646E463D-F0E2-4BA4-9B5E-434ABE26EC07} {2E71D2B2-516D-4B0D-8DE6-B9F3105B9C95} = {8F0598A5-2F0C-4FA6-82F6-938F1830ADB7} + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {BCE1EE3C-ADB1-48CC-9FD1-C7324D886964} + {1E6ABBCD-5F00-4682-9561-8A6270327B75} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {94D76666-FA9F-44D9-874D-96492A363678} = {1E6ABBCD-5F00-4682-9561-8A6270327B75} + {1F6FFCF9-B7ED-4A74-9865-047D5B28F9FF} = {1E6ABBCD-5F00-4682-9561-8A6270327B75} + {8E9C2C95-133B-F06A-4387-5367FDCE9645} = {1E6ABBCD-5F00-4682-9561-8A6270327B75} + {BC06BE1C-DC2B-2E09-2D28-BD19F746B9FB} = {1E6ABBCD-5F00-4682-9561-8A6270327B75} + {3EBDF0F1-C491-2779-E0D8-00B8597A7D89} = {1E6ABBCD-5F00-4682-9561-8A6270327B75} + {1B5E35AD-CCD3-0628-1582-E6C8179596EC} = {94D76666-FA9F-44D9-874D-96492A363678} + {D26A8127-FA34-ADB3-5D91-543166A85237} = {94D76666-FA9F-44D9-874D-96492A363678} + {F091D812-6F09-E7EA-D9B3-963126E0A2FD} = {94D76666-FA9F-44D9-874D-96492A363678} + {6AE0A077-04AC-B244-12AA-4072D1A8E87E} = {1F6FFCF9-B7ED-4A74-9865-047D5B28F9FF} + {DB3CC4F8-66D3-E688-7B40-A349557DF6A8} = {1F6FFCF9-B7ED-4A74-9865-047D5B28F9FF} + {50A9A239-423B-C70D-8D46-839C76130A88} = {1F6FFCF9-B7ED-4A74-9865-047D5B28F9FF} + {556AC016-1A29-4FA3-A78C-11463F340D00} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {4BA6D6D2-5252-4207-A724-F368A5C53B99} = {556AC016-1A29-4FA3-A78C-11463F340D00} + {1E0E0736-1A25-470D-73E4-7D08AEDAC27B} = {4BA6D6D2-5252-4207-A724-F368A5C53B99} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6B94C21A-AA6D-4D82-963E-C69C0353B938} diff --git a/src/Database/CompanyName.MyMeetings.Database/CompanyName.MyMeetings.Database.sqlproj b/src/Database/CompanyName.MyMeetings.Database/CompanyName.MyMeetings.Database.sqlproj index 7a3327f7f..8adb20d5f 100644 --- a/src/Database/CompanyName.MyMeetings.Database/CompanyName.MyMeetings.Database.sqlproj +++ b/src/Database/CompanyName.MyMeetings.Database/CompanyName.MyMeetings.Database.sqlproj @@ -86,12 +86,16 @@ - - + + + + + + @@ -148,23 +152,9 @@ - - - - - - - - - - - - - - DoNotCopy @@ -179,8 +169,40 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Scripts/CreateStructure.sql b/src/Database/CompanyName.MyMeetings.Database/Scripts/CreateStructure.sql index 09b30353b..8f4c211c7 100644 --- a/src/Database/CompanyName.MyMeetings.Database/Scripts/CreateStructure.sql +++ b/src/Database/CompanyName.MyMeetings.Database/Scripts/CreateStructure.sql @@ -940,7 +940,7 @@ PRINT N'Creating [registrations].[v_UserRegistrations]...'; GO -CREATE VIEW [users].[v_UserRegistrations] +CREATE VIEW [registrations].[v_UserRegistrations] AS SELECT [UserRegistration].[Id], @@ -949,7 +949,8 @@ SELECT [UserRegistration].[FirstName], [UserRegistration].[LastName], [UserRegistration].[Name], - [UserRegistration].[StatusCode] + [UserRegistration].[StatusCode], + [UserRegistration].[Password] FROM [registrations].[UserRegistrations] AS [UserRegistration] GO PRINT N'Creating [registrations].[v_UserPermissions]...'; diff --git a/src/Database/CompanyName.MyMeetings.Database/Scripts/Migrations/1_0_0_0/0001_initial_structure.sql b/src/Database/CompanyName.MyMeetings.Database/Scripts/Migrations/1_0_0_0/0001_initial_structure.sql index 562db7b92..51e4442fa 100644 --- a/src/Database/CompanyName.MyMeetings.Database/Scripts/Migrations/1_0_0_0/0001_initial_structure.sql +++ b/src/Database/CompanyName.MyMeetings.Database/Scripts/Migrations/1_0_0_0/0001_initial_structure.sql @@ -882,7 +882,7 @@ PRINT N'Creating [registrations].[v_UserRegistrations]...'; GO -CREATE VIEW [users].[v_UserRegistrations] +CREATE VIEW [registrations].[v_UserRegistrations] AS SELECT [UserRegistration].[Id], @@ -891,7 +891,8 @@ SELECT [UserRegistration].[FirstName], [UserRegistration].[LastName], [UserRegistration].[Name], - [UserRegistration].[StatusCode] + [UserRegistration].[StatusCode], + [UserRegistration].[Password] FROM [registrations].[UserRegistrations] AS [UserRegistration] GO PRINT N'Creating [users].[v_UserPermissions]...'; diff --git a/src/Database/CompanyName.MyMeetings.Database/Scripts/Migrations/1_0_0_0/0015_add_users_microsoft_identity.sql b/src/Database/CompanyName.MyMeetings.Database/Scripts/Migrations/1_0_0_0/0015_add_users_microsoft_identity.sql new file mode 100644 index 000000000..dca09ab76 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Scripts/Migrations/1_0_0_0/0015_add_users_microsoft_identity.sql @@ -0,0 +1,215 @@ +CREATE SCHEMA [usersmi] + AUTHORIZATION [dbo]; + +GO +CREATE TABLE [usersmi].[UserTokens] ( + [UserId] UNIQUEIDENTIFIER NOT NULL, + [LoginProvider] NVARCHAR (450) NOT NULL, + [Name] NVARCHAR (450) NOT NULL, + [Value] NVARCHAR (MAX) NULL, + CONSTRAINT [PK_UserToken_UserId_LoginProvider_Name] PRIMARY KEY CLUSTERED ([UserId] ASC, [LoginProvider] ASC, [Name] ASC) +); + +GO +CREATE TABLE [usersmi].[UserRoles] ( + [UserId] UNIQUEIDENTIFIER NOT NULL, + [RoleId] UNIQUEIDENTIFIER NOT NULL, + CONSTRAINT [PK_UserRole_UserId_RoleId] PRIMARY KEY CLUSTERED ([UserId] ASC, [RoleId] ASC) +); + +GO +CREATE TABLE [usersmi].[UserRefreshTokens] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NOT NULL, + [Token] NVARCHAR (MAX) NOT NULL, + [JwtId] NVARCHAR (MAX) NOT NULL, + [IsRevoked] BIT NOT NULL, + [AddedDate] DATETIME2 (7) NOT NULL, + [ExpiryDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_UserRefreshToken_Id] PRIMARY KEY CLUSTERED ([Id] ASC) +); + +GO +CREATE TABLE [usersmi].[UserLogins] ( + [LoginProvider] NVARCHAR (450) NOT NULL, + [ProviderKey] NVARCHAR (450) NOT NULL, + [ProviderDisplayName] NVARCHAR (MAX) NULL, + [UserId] UNIQUEIDENTIFIER NOT NULL, + CONSTRAINT [PK_UserLogin_LoginProvider_ProviderKey] PRIMARY KEY CLUSTERED ([LoginProvider] ASC, [ProviderKey] ASC) +); + +GO +CREATE TABLE [usersmi].[UserClaims] ( + [Id] INT IDENTITY (1, 1) NOT NULL, + [UserId] UNIQUEIDENTIFIER NOT NULL, + [ClaimType] NVARCHAR (MAX) NULL, + [ClaimValue] NVARCHAR (MAX) NULL, + CONSTRAINT [PK_UserClaim_Id] PRIMARY KEY CLUSTERED ([Id] ASC) +); + +GO +CREATE TABLE [usersmi].[Users] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR (255) NULL, + [FirstName] NVARCHAR (100) NULL, + [LastName] NVARCHAR (100) NULL, + [UserName] NVARCHAR (256) NULL, + [NormalizedUserName] NVARCHAR (256) NULL, + [Email] NVARCHAR (256) NULL, + [NormalizedEmail] NVARCHAR (256) NULL, + [EmailConfirmed] BIT NOT NULL, + [PasswordHash] NVARCHAR (MAX) NULL, + [SecurityStamp] NVARCHAR (MAX) NULL, + [ConcurrencyStamp] NVARCHAR (MAX) NULL, + [PhoneNumber] NVARCHAR (MAX) NULL, + [PhoneNumberConfirmed] BIT NOT NULL, + [TwoFactorEnabled] BIT NOT NULL, + [LockoutEnd] DATETIMEOFFSET (7) NULL, + [LockoutEnabled] BIT NOT NULL, + [AccessFailedCount] INT NOT NULL, + CONSTRAINT [PK_User_Id] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [UQ_User_NormalizedUserName] UNIQUE NONCLUSTERED ([NormalizedUserName] ASC), + CONSTRAINT [UQ_User_UserName] UNIQUE NONCLUSTERED ([UserName] ASC) +); + +GO +CREATE TABLE [usersmi].[RoleClaims] ( + [Id] INT IDENTITY (1, 1) NOT NULL, + [RoleId] UNIQUEIDENTIFIER NOT NULL, + [ClaimType] NVARCHAR (MAX) NULL, + [ClaimValue] NVARCHAR (MAX) NULL, + CONSTRAINT [PK_RoleClaim_Id] PRIMARY KEY CLUSTERED ([Id] ASC) +); + +GO +CREATE TABLE [usersmi].[Roles] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR (256) NULL, + [NormalizedName] NVARCHAR (256) NULL, + [ConcurrencyStamp] NVARCHAR (MAX) NULL, + CONSTRAINT [PK_Role_Id] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [UQ_Role_Name] UNIQUE NONCLUSTERED ([Name] ASC), + CONSTRAINT [UQ_Role_NormalizedName] UNIQUE NONCLUSTERED ([NormalizedName] ASC) +); + +GO +CREATE TABLE [usersmi].[Permissions] ( + [Code] VARCHAR (100) NOT NULL, + [Name] VARCHAR (100) NOT NULL, + [Description] VARCHAR (255) NULL, + CONSTRAINT [PK_Permission_Code] PRIMARY KEY CLUSTERED ([Code] ASC) +); + +GO +CREATE TABLE [usersmi].[OutboxMessages] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OccurredOn] DATETIME2 (7) NOT NULL, + [Type] VARCHAR (255) NOT NULL, + [Data] VARCHAR (MAX) NOT NULL, + [ProcessedDate] DATETIME2 (7) NULL, + CONSTRAINT [PK_OutboxMessages_Id] PRIMARY KEY CLUSTERED ([Id] ASC) +); + +GO +CREATE TABLE [usersmi].[InternalCommands] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [EnqueueDate] DATETIME2 (7) NOT NULL, + [Type] VARCHAR (255) NOT NULL, + [Data] VARCHAR (MAX) NOT NULL, + [ProcessedDate] DATETIME2 (7) NULL, + [Error] NVARCHAR (MAX) NULL, + CONSTRAINT [PK_InternalCommands_Id] PRIMARY KEY CLUSTERED ([Id] ASC) +); + +GO +CREATE TABLE [usersmi].[InboxMessages] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OccurredOn] DATETIME2 (7) NOT NULL, + [Type] VARCHAR (255) NOT NULL, + [Data] VARCHAR (MAX) NOT NULL, + [ProcessedDate] DATETIME2 (7) NULL, + CONSTRAINT [PK_InboxMessages_Id] PRIMARY KEY CLUSTERED ([Id] ASC) +); + +GO +ALTER TABLE [usersmi].[UserTokens] WITH NOCHECK + ADD CONSTRAINT [FK_UserToken_UserId_User_Id] FOREIGN KEY ([UserId]) REFERENCES [usersmi].[Users] ([Id]) ON DELETE CASCADE; + + +GO +ALTER TABLE [usersmi].[UserRoles] WITH NOCHECK + ADD CONSTRAINT [FK_UserRole_RoleId_Role_Id] FOREIGN KEY ([RoleId]) REFERENCES [usersmi].[Roles] ([Id]) ON DELETE CASCADE; + + +GO +ALTER TABLE [usersmi].[UserRoles] WITH NOCHECK + ADD CONSTRAINT [FK_UserRole_UserId_User_Id] FOREIGN KEY ([UserId]) REFERENCES [usersmi].[Users] ([Id]) ON DELETE CASCADE; + + +GO +ALTER TABLE [usersmi].[UserRefreshTokens] WITH NOCHECK + ADD CONSTRAINT [FK_UserRefreshToken_UserId_User_Id] FOREIGN KEY ([UserId]) REFERENCES [usersmi].[Users] ([Id]) ON DELETE CASCADE; + +GO +ALTER TABLE [usersmi].[UserLogins] WITH NOCHECK + ADD CONSTRAINT [FK_UserLogin_UserId_User_Id] FOREIGN KEY ([UserId]) REFERENCES [usersmi].[Users] ([Id]) ON DELETE CASCADE; + + +GO +ALTER TABLE [usersmi].[UserClaims] WITH NOCHECK + ADD CONSTRAINT [FK_UserClaim_UserId_User_Id] FOREIGN KEY ([UserId]) REFERENCES [usersmi].[Users] ([Id]) ON DELETE CASCADE; + + +GO +ALTER TABLE [usersmi].[RoleClaims] WITH NOCHECK + ADD CONSTRAINT [FK_RoleClaim_RoleId_Role_Id] FOREIGN KEY ([RoleId]) REFERENCES [usersmi].[Roles] ([Id]) ON DELETE CASCADE; + + +GO +CREATE VIEW [usersmi].[v_UserRoles] +AS +SELECT [UserRole].[UserId] AS [UserId], + [Role].[Name] AS [RoleCode] + FROM [usersmi].[UserRoles] AS [UserRole] + INNER JOIN [usersmi].[Roles] AS [Role] + ON [UserRole].[RoleId] = [Role].[Id] +GO + +CREATE VIEW [usersmi].[v_UserPermissions] +AS +SELECT + DISTINCT + [UserRole].[UserId] AS [UserId], + [RoleClaim].[ClaimValue] AS [PermissionCode] +FROM [usersmi].UserRoles AS [UserRole] + INNER JOIN [usersmi].[RoleClaims] AS [RoleClaim] + ON [UserRole].[RoleId] = [RoleClaim].[RoleId] +GO + +CREATE VIEW [usersmi].[v_Users] +AS +SELECT + [User].[Id], + IIF([User].[LockoutEnabled] = 1, 0, 1) AS [IsActive], + [User].[UserName] AS [Login], + [User].[PasswordHash] AS [Password], + [User].[Email], + [User].[Name] +FROM [usersmi].[Users] AS [User] + +GO +ALTER TABLE [usersmi].[UserTokens] WITH CHECK CHECK CONSTRAINT [FK_UserToken_UserId_User_Id]; + +ALTER TABLE [usersmi].[UserRoles] WITH CHECK CHECK CONSTRAINT [FK_UserRole_RoleId_Role_Id]; + +ALTER TABLE [usersmi].[UserRoles] WITH CHECK CHECK CONSTRAINT [FK_UserRole_UserId_User_Id]; + +ALTER TABLE [usersmi].[UserRefreshTokens] WITH CHECK CHECK CONSTRAINT [FK_UserRefreshToken_UserId_User_Id]; + +ALTER TABLE [usersmi].[UserLogins] WITH CHECK CHECK CONSTRAINT [FK_UserLogin_UserId_User_Id]; + +ALTER TABLE [usersmi].[UserClaims] WITH CHECK CHECK CONSTRAINT [FK_UserClaim_UserId_User_Id]; + +ALTER TABLE [usersmi].[RoleClaims] WITH CHECK CHECK CONSTRAINT [FK_RoleClaim_RoleId_Role_Id]; + +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0002_SeedPermissions.sql b/src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0002_SeedPermissions.sql new file mode 100644 index 000000000..36b020992 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0002_SeedPermissions.sql @@ -0,0 +1,85 @@ +INSERT INTO [usersmi].[Permissions] ([Code], [Name], [Description]) + VALUES + + -- ############################################################################################################## + -- # *** APPLICATION *** # + -- ############################################################################################################## + ('Application.Administrator', 'Administrator', NULL), + + + -- ############################################################################################################## + -- # *** USERS *** # + -- ############################################################################################################## + + -- Users + ('Users.GetUsers', 'GetUsers', NULL), + ('Users.UpdateUserAccount', 'UpdateUserAccount', NULL), + ('Users.UnlockUserAccount', 'UnlockUserAccount', NULL), + ('Users.ChangeUserEmailAddress', 'ChangeUserEmailAddress', NULL), + ('Users.GetUserRoles', 'GetUserRoles', NULL), + ('Users.SetUserRoles', 'SetUserRoles', NULL), + ('Users.GetUserPermissions', 'GetUserPermissions', NULL), + ('Users.SetUserPermissions', 'SetUserPermissions', NULL), + + -- User roles + ('Users.GetRoles', 'GetRoles', NULL), + ('Users.AddRole', 'AddRole', NULL), + ('Users.RenameRole', 'RenameRole', NULL), + ('Users.DeleteRole', 'DeleteRole', NULL), + ('Users.GetRolePermissions', 'GetRolePermissions', NULL), + ('Users.SetRolePermissions', 'SetRolePermissions', NULL), + + -- ############################################################################################################## + -- # *** MEETINGS *** # + -- ############################################################################################################## + ('GetMeetingGroupProposals', 'GetMeetingGroupProposals', NULL), + ('ProposeMeetingGroup', 'ProposeMeetingGroup', NULL), + ('CreateNewMeeting', 'CreateNewMeeting', NULL), + ('EditMeeting', 'EditMeeting', NULL), + ('AddMeetingAttendee', 'AddMeetingAttendee', NULL), + ('RemoveMeetingAttendee', 'RemoveMeetingAttendee', NULL), + ('AddNotAttendee', 'AddNotAttendee', NULL), + ('ChangeNotAttendeeDecision', 'ChangeNotAttendeeDecision', NULL), + ('SignUpMemberToWaitlist', 'SignUpMemberToWaitlist', NULL), + ('SignOffMemberFromWaitlist', 'SignOffMemberFromWaitlist', NULL), + ('SetMeetingHostRole', 'SetMeetingHostRole', NULL), + ('SetMeetingAttendeeRole', 'SetMeetingAttendeeRole', NULL), + ('CancelMeeting', 'CancelMeeting', NULL), + ('GetAllMeetingGroups', 'GetAllMeetingGroups', NULL), + ('EditMeetingGroupGeneralAttributes', 'EditMeetingGroupGeneralAttributes', NULL), + ('JoinToGroup', 'JoinToGroup', NULL), + ('LeaveMeetingGroup', 'LeaveMeetingGroup', NULL), + ('AddMeetingComment', 'AddMeetingComment', NULL), + ('EditMeetingComment', 'EditMeetingComment', NULL), + ('RemoveMeetingComment', 'RemoveMeetingComment', NULL), + ('AddMeetingCommentReply', 'AddMeetingCommentReply', NULL), + ('LikeMeetingComment', 'LikeMeetingComment', NULL), + ('UnlikeMeetingComment', 'UnlikeMeetingComment', NULL), + ('EnableMeetingCommenting', 'EnableMeetingCommenting', NULL), + ('DisableMeetingCommenting', 'DisableMeetingCommenting', NULL), + ('MyMeetingGroupsView', 'MyMeetingGroupsView', NULL), + ('AllMeetingGroupsView', 'AllMeetingGroupsView', NULL), + ('SubscriptionView', 'SubscriptionView', NULL), + ('EmailsView', 'EmailsView', NULL), + ('MyMeetingsView', 'MyMeetingsView', NULL), + ('GetAuthenticatedMemberMeetings', 'GetAuthenticatedMemberMeetings', NULL), + + -- ############################################################################################################## + -- # *** ADMINISTRATION *** # + -- ############################################################################################################## + -- + ('AcceptMeetingGroupProposal', 'AcceptMeetingGroupProposal', NULL), + ('AdministrationsView', 'AdministrationsView', NULL), + + -- ############################################################################################################## + -- # *** PAYMENTS *** # + -- ############################################################################################################## + ('RegisterPayment', 'RegisterPayment', NULL), + ('BuySubscription', 'BuySubscription', NULL), + ('RenewSubscription', 'RenewSubscription', NULL), + ('CreatePriceListItem', 'CreatePriceListItem', NULL), + ('ActivatePriceListItem', 'ActivatePriceListItem', NULL), + ('DeactivatePriceListItem', 'DeactivatePriceListItem', NULL), + ('ChangePriceListItemAttributes', 'ChangePriceListItemAttributes', NULL), + ('GetAuthenticatedPayerSubscription', 'GetAuthenticatedPayerSubscription', NULL), + ('GetPriceListItem', 'GetPriceListItem', NULL); \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0003_SeedRoles.sql b/src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0003_SeedRoles.sql new file mode 100644 index 000000000..796f4c2d8 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0003_SeedRoles.sql @@ -0,0 +1,60 @@ +INSERT INTO [usersmi].[Roles] + ([Id], [Name], [NormalizedName], [ConcurrencyStamp]) + VALUES + ('1E5A7F47-A258-4127-42A1-08DC00AA0515', 'Administrator', 'ADMINISTRATOR', 'ff5d947e-723d-4bf6-b6b9-0c1487c72f73'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Member', 'MEMBER', 'ad88d3f5-37ef-473e-addc-9ee10184e7d0'); + + +INSERT INTO [usersmi].[RoleClaims] + ([RoleId], [ClaimType], [ClaimValue]) + VALUES + -- Administator + ('1E5A7F47-A258-4127-42A1-08DC00AA0515', 'Application.Permission', 'AcceptMeetingGroupProposal'), + ('1E5A7F47-A258-4127-42A1-08DC00AA0515', 'Application.Permission', 'AdministrationsView'), + ('1E5A7F47-A258-4127-42A1-08DC00AA0515', 'Application.Permission', 'CreatePriceListItem'), + ('1E5A7F47-A258-4127-42A1-08DC00AA0515', 'Application.Permission', 'ActivatePriceListItem'), + ('1E5A7F47-A258-4127-42A1-08DC00AA0515', 'Application.Permission', 'DeactivatePriceListItem'), + ('1E5A7F47-A258-4127-42A1-08DC00AA0515', 'Application.Permission', 'ChangePriceListItemAttributes'), + ('1E5A7F47-A258-4127-42A1-08DC00AA0515', 'Application.Permission', 'GetPriceListItem'), + ('1E5A7F47-A258-4127-42A1-08DC00AA0515', 'Application.Permission', 'Users.GetUserAccounts'), + + -- Member + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'GetMeetingGroupProposals'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'ProposeMeetingGroup'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'CreateNewMeeting'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'EditMeeting'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'AddMeetingAttendee'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'RemoveMeetingAttendee'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'AddNotAttendee'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'ChangeNotAttendeeDecision'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'SignUpMemberToWaitlist'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'SignOffMemberFromWaitlist'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'SetMeetingHostRole'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'SetMeetingAttendeeRole'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'CancelMeeting'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'GetAllMeetingGroups'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'EditMeetingGroupGeneralAttributes'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'JoinToGroup'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'LeaveMeetingGroup'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'AddMeetingComment'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'EditMeetingComment'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'RemoveMeetingComment'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'AddMeetingCommentReply'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'LikeMeetingComment'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'UnlikeMeetingComment'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'GetAuthenticatedMemberMeetingGroups'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'GetMeetingGroupDetails'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'GetMeetingDetails'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'GetMeetingAttendees'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'MyMeetingsGroupsView'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'SubscriptionView'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'EmailsView'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'AllMeetingGroupsView'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'MyMeetingsView'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'GetAuthenticatedMemberMeetings'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'RegisterPayment'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'BuySubscription'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'RenewSubscription'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'GetAuthenticatedPayerSubscription'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'GetPriceListItem'), + ('9CE09287-D003-439F-42A0-08DC00AA0515', 'Application.Permission', 'Users.GetUserAccounts'); \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0004_SeedUsers.sql b/src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0004_SeedUsers.sql new file mode 100644 index 000000000..6ec7c27b2 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Scripts/Seeds/0004_SeedUsers.sql @@ -0,0 +1,48 @@ +-- Global administrator +INSERT INTO [usersmi].[Users] ([Id], [Name], [FirstName], [LastName], [UserName], [NormalizedUserName], [Email], [NormalizedEmail], [EmailConfirmed], [PasswordHash], [SecurityStamp], [ConcurrencyStamp], [PhoneNumber], [PhoneNumberConfirmed], [TwoFactorEnabled], [LockoutEnd], [LockoutEnabled], [AccessFailedCount]) + VALUES + ('39C63EC6-9AFF-473D-010F-08DBFFF6E9DF', + 'Administrator', + NULL, + NULL, + 'administrator', + 'ADMINISTRATOR', + 'administrator@mymeetings.com', + 'ADMINISTRATOR@MYMEETINGS.COM', + 1, + 'AMpZBLMVK4vr+ic0DKMsm6DpRhylYFPRQnt5RKnKbg1Yp3X1a9d+7UlFRfMQB/KKrQ==', -- AdminP@$$123. + 'CELIHCK47VKN4IL3ZGTYRVL4WX7AKTS6', + '60c49c98-2932-43b0-9363-099906901875', + NULL, + 0, + 0, + NULL, + 0, + 0); + +-- Assign permission to administrator +INSERT INTO [usersmi].[UserClaims] + ([ClaimType], [ClaimValue], [UserId]) + VALUES ('Application.Permission', 'Application.Administrator', '39C63EC6-9AFF-473D-010F-08DBFFF6E9DF'); + + +-- Test users +--INSERT INTO [usersmi].[UserRegistrations] +-- ([Id], [UserName], [Email], [Password], [FirstName], [LastName], [Name], [StatusCode], [RegisterDate], [ConfirmedDate]) +-- VALUES +-- ('2EBFECFC-ED13-43B8-B516-6AC89D51C510', 'testMember@mail.com', 'testMember@mail.com', 'testMemberPass', 'John', 'Doe', 'John Doe', 'Confirmed', GETDATE(), GETDATE()), +-- ('4065630E-4A4C-4F01-9142-0BACF6B8C64D', 'testAdmin@mail.com', 'testAdmin@mail.com', 'testAdminPass', 'Jane', 'Doe', 'Jane Doe', 'Confirmed', GETDATE(), GETDATE()); + +INSERT INTO [usersmi].[Users] + ([Id], [Name], [FirstName], [LastName], [UserName], [NormalizedUserName], [Email], [NormalizedEmail], [EmailConfirmed], [PasswordHash], [SecurityStamp], [ConcurrencyStamp], [PhoneNumber], [PhoneNumberConfirmed], [TwoFactorEnabled], [LockoutEnd], [LockoutEnabled], [AccessFailedCount]) + VALUES + ('2EBFECFC-ED13-43B8-B516-6AC89D51C510', 'John Doe', 'John', 'Doe', 'testMember@mail.com', 'TESTMEMBER@MAIL.COM', 'testMember@mail.com', 'TESTMEMBER@MAIL.COM', 0, 'AQAAAAIAAYagAAAAEBRXigjUOJjPf/7wwJV1YlCOk2MzBOne1zLXXQoHzZNVqTnl5UuQWwM94ORZqOTR3g==', 'NMMJRCDE4JDHMJB32D43US57X5WALBPV', 'b7eb06fb-a904-492e-9717-6771cb6ad4af', NULL, 0, 0, NULL, 1, 0), + ('4065630E-4A4C-4F01-9142-0BACF6B8C64D', 'Jane Doe', 'Jane', 'Doe', 'testAdmin@mail.com', 'TESTADMIN@MAIL.COM', 'testAdmin@mail.com', 'TESTADMIN@MAIL.COM', 0, 'AQAAAAIAAYagAAAAEFjQQOh8ii3LbgV0+0C69GCmygp0I8Z9FV0n6SQCFRK+bN/uFkd5ksBjbWNSiz9/Ag==', 'FTD34O4LXMPZFBMJ4DGS5LN2VVUNCHU5', '29467032-0908-4bd8-9ef5-62676a4e5009', NULL, 0, 0, NULL, 1, 0); + + +-- Assign Roles to Users +INSERT INTO [usersmi].[UserRoles] + ([UserId], [RoleId]) + VALUES + ('2EBFECFC-ED13-43B8-B516-6AC89D51C510', '9CE09287-D003-439F-42A0-08DC00AA0515'), -- John Doe / Member + ('4065630E-4A4C-4F01-9142-0BACF6B8C64D', '1E5A7F47-A258-4127-42A1-08DC00AA0515'); -- Jane Doe / Administrator \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/Security/Schemas.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/Security/Schemas.sql index 6f8668bcb..3555e543c 100644 --- a/src/Database/CompanyName.MyMeetings.Database/Structure/Security/Schemas.sql +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/Security/Schemas.sql @@ -10,6 +10,9 @@ GO CREATE SCHEMA users AUTHORIZATION dbo GO +CREATE SCHEMA usersmi AUTHORIZATION dbo +GO + CREATE SCHEMA registrations AUTHORIZATION dbo GO diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/InboxMessages.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/InboxMessages.sql similarity index 100% rename from src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/InboxMessages.sql rename to src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/InboxMessages.sql diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/InternalCommands.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/InternalCommands.sql similarity index 100% rename from src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/InternalCommands.sql rename to src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/InternalCommands.sql diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/OutboxMessages.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/OutboxMessages.sql similarity index 100% rename from src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/OutboxMessages.sql rename to src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/OutboxMessages.sql diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/Permissions.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/Permissions.sql similarity index 100% rename from src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/Permissions.sql rename to src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/Permissions.sql diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/RolesToPermissions.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/RolesToPermissions.sql similarity index 100% rename from src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/RolesToPermissions.sql rename to src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/RolesToPermissions.sql diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/UserRoles.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/UserRoles.sql similarity index 100% rename from src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/UserRoles.sql rename to src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/UserRoles.sql diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/Users.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/Users.sql similarity index 100% rename from src/Database/CompanyName.MyMeetings.Database/Structure/users/Tables/Users.sql rename to src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Tables/Users.sql diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/Views/v_UserPermissions.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Views/v_UserPermissions.sql similarity index 100% rename from src/Database/CompanyName.MyMeetings.Database/Structure/users/Views/v_UserPermissions.sql rename to src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Views/v_UserPermissions.sql diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/Views/v_UserRoles.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Views/v_UserRoles.sql similarity index 100% rename from src/Database/CompanyName.MyMeetings.Database/Structure/users/Views/v_UserRoles.sql rename to src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Views/v_UserRoles.sql diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/Views/v_Users.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Views/v_Users.sql similarity index 100% rename from src/Database/CompanyName.MyMeetings.Database/Structure/users/Views/v_Users.sql rename to src/Database/CompanyName.MyMeetings.Database/Structure/users/IdentityServer/Views/v_Users.sql diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/InboxMessages.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/InboxMessages.sql new file mode 100644 index 000000000..b4611c485 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/InboxMessages.sql @@ -0,0 +1,10 @@ +CREATE TABLE [usersmi].InboxMessages +( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OccurredOn] DATETIME2 NOT NULL, + [Type] VARCHAR(255) NOT NULL, + [Data] VARCHAR(MAX) NOT NULL, + [ProcessedDate] DATETIME2 NULL, + CONSTRAINT [PK_InboxMessages_Id] PRIMARY KEY ([Id] ASC) +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/InternalCommands.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/InternalCommands.sql new file mode 100644 index 000000000..84aa2240a --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/InternalCommands.sql @@ -0,0 +1,11 @@ +CREATE TABLE [usersmi].InternalCommands +( + [Id] UNIQUEIDENTIFIER NOT NULL, + [EnqueueDate] DATETIME2 NOT NULL, + [Type] VARCHAR(255) NOT NULL, + [Data] VARCHAR(MAX) NOT NULL, + [ProcessedDate] DATETIME2 NULL, + [Error] NVARCHAR(MAX) NULL, + CONSTRAINT [PK_InternalCommands_Id] PRIMARY KEY ([Id] ASC) +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/OutboxMessages.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/OutboxMessages.sql new file mode 100644 index 000000000..06526d471 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/OutboxMessages.sql @@ -0,0 +1,10 @@ +CREATE TABLE [usersmi].OutboxMessages +( + [Id] UNIQUEIDENTIFIER NOT NULL, + [OccurredOn] DATETIME2 NOT NULL, + [Type] VARCHAR(255) NOT NULL, + [Data] VARCHAR(MAX) NOT NULL, + [ProcessedDate] DATETIME2 NULL, + CONSTRAINT [PK_OutboxMessages_Id] PRIMARY KEY ([Id] ASC) +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/Permission.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/Permission.sql new file mode 100644 index 000000000..ae8820002 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/Permission.sql @@ -0,0 +1,8 @@ +CREATE TABLE [usersmi].[Permissions] +( + [Code] VARCHAR(100) NOT NULL, + [Name] VARCHAR(100) NOT NULL, + [Description] VARCHAR(255) NULL, + CONSTRAINT [PK_Permission_Code] PRIMARY KEY ([Code]) +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/Role.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/Role.sql new file mode 100644 index 000000000..64041e627 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/Role.sql @@ -0,0 +1,11 @@ +CREATE TABLE [usersmi].[Roles] +( + [Id] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR(256) NULL, + [NormalizedName] NVARCHAR(256) NULL, + [ConcurrencyStamp] NVARCHAR(max) NULL, + CONSTRAINT [PK_Role_Id] PRIMARY KEY ([Id]), + CONSTRAINT [UQ_Role_Name] UNIQUE([Name]), + CONSTRAINT [UQ_Role_NormalizedName] UNIQUE([NormalizedName]) +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/RoleClaim.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/RoleClaim.sql new file mode 100644 index 000000000..9fa263ff1 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/RoleClaim.sql @@ -0,0 +1,10 @@ +CREATE TABLE [usersmi].[RoleClaims] +( + [Id] int NOT NULL IDENTITY, + [RoleId] UNIQUEIDENTIFIER NOT NULL, + [ClaimType] NVARCHAR(max) NULL, + [ClaimValue] NVARCHAR(max) NULL, + CONSTRAINT [PK_RoleClaim_Id] PRIMARY KEY ([Id]), + CONSTRAINT [FK_RoleClaim_RoleId_Role_Id] FOREIGN KEY ([RoleId]) REFERENCES [usersmi].[Roles] ([Id]) ON DELETE CASCADE +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/User.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/User.sql new file mode 100644 index 000000000..d01496f83 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/User.sql @@ -0,0 +1,25 @@ +CREATE TABLE [usersmi].[Users] +( + [Id] UNIQUEIDENTIFIER NOT NULL, + [Name] NVARCHAR(255) NULL, + [FirstName] NVARCHAR(100) NULL, + [LastName] NVARCHAR(100) NULL, + [UserName] NVARCHAR(256) NULL, + [NormalizedUserName] NVARCHAR(256) NULL, + [Email] NVARCHAR(256) NULL, + [NormalizedEmail] NVARCHAR(256) NULL, + [EmailConfirmed] BIT NOT NULL, + [PasswordHash] NVARCHAR(max) NULL, + [SecurityStamp] NVARCHAR(max) NULL, + [ConcurrencyStamp] NVARCHAR(max) NULL, + [PhoneNumber] NVARCHAR(max) NULL, + [PhoneNumberConfirmed] BIT NOT NULL, + [TwoFactorEnabled] BIT NOT NULL, + [LockoutEnd] DATETIMEOFFSET NULL, + [LockoutEnabled] BIT NOT NULL, + [AccessFailedCount] INT NOT NULL, + CONSTRAINT [PK_User_Id] PRIMARY KEY ([Id]), + CONSTRAINT [UQ_User_UserName] UNIQUE([UserName]), + CONSTRAINT [UQ_User_NormalizedUserName] UNIQUE([NormalizedUserName]) +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserClaim.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserClaim.sql new file mode 100644 index 000000000..a050db775 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserClaim.sql @@ -0,0 +1,10 @@ +CREATE TABLE [usersmi].[UserClaims] +( + [Id] INT NOT NULL IDENTITY, + [UserId] UNIQUEIDENTIFIER NOT NULL, + [ClaimType] NVARCHAR(max) NULL, + [ClaimValue] NVARCHAR(max) NULL, + CONSTRAINT [PK_UserClaim_Id] PRIMARY KEY ([Id]), + CONSTRAINT [FK_UserClaim_UserId_User_Id] FOREIGN KEY ([UserId]) REFERENCES [usersmi].[Users] ([Id]) ON DELETE CASCADE +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserLogin.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserLogin.sql new file mode 100644 index 000000000..3e630bfcb --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserLogin.sql @@ -0,0 +1,10 @@ +CREATE TABLE [usersmi].[UserLogins] +( + [LoginProvider] NVARCHAR(450) NOT NULL, + [ProviderKey] NVARCHAR(450) NOT NULL, + [ProviderDisplayName] NVARCHAR(max) NULL, + [UserId] UNIQUEIDENTIFIER NOT NULL, + CONSTRAINT [PK_UserLogin_LoginProvider_ProviderKey] PRIMARY KEY ([LoginProvider], [ProviderKey]), + CONSTRAINT [FK_UserLogin_UserId_User_Id] FOREIGN KEY ([UserId]) REFERENCES [usersmi].[Users] ([Id]) ON DELETE CASCADE +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserRefreshToken.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserRefreshToken.sql new file mode 100644 index 000000000..7d1832d24 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserRefreshToken.sql @@ -0,0 +1,13 @@ +CREATE TABLE [usersmi].[UserRefreshTokens] +( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NOT NULL, + [Token] NVARCHAR(max) NOT NULL, + [JwtId] NVARCHAR(max) NOT NULL, + [IsRevoked] BIT NOT NULL, + [AddedDate] DATETIME2 NOT NULL, + [ExpiryDate] DATETIME2 NOT NULL, + CONSTRAINT [PK_UserRefreshToken_Id] PRIMARY KEY ([Id]), + CONSTRAINT [FK_UserRefreshToken_UserId_User_Id] FOREIGN KEY ([UserId]) REFERENCES [usersmi].[Users] ([Id]) ON DELETE CASCADE +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserRole.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserRole.sql new file mode 100644 index 000000000..58a502bdb --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserRole.sql @@ -0,0 +1,9 @@ +CREATE TABLE [usersmi].[UserRoles] +( + [UserId] UNIQUEIDENTIFIER NOT NULL, + [RoleId] UNIQUEIDENTIFIER NOT NULL, + CONSTRAINT [PK_UserRole_UserId_RoleId] PRIMARY KEY ([UserId], [RoleId]), + CONSTRAINT [FK_UserRole_RoleId_Role_Id] FOREIGN KEY ([RoleId]) REFERENCES [usersmi].[Roles] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_UserRole_UserId_User_Id] FOREIGN KEY ([UserId]) REFERENCES [usersmi].[Users] ([Id]) ON DELETE CASCADE +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserToken.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserToken.sql new file mode 100644 index 000000000..0fa2c2232 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Tables/UserToken.sql @@ -0,0 +1,10 @@ +CREATE TABLE [usersmi].[UserTokens] +( + [UserId] UNIQUEIDENTIFIER NOT NULL, + [LoginProvider] NVARCHAR(450) NOT NULL, + [Name] NVARCHAR(450) NOT NULL, + [Value] NVARCHAR(max) NULL, + CONSTRAINT [PK_UserToken_UserId_LoginProvider_Name] PRIMARY KEY ([UserId], [LoginProvider], [Name]), + CONSTRAINT [FK_UserToken_UserId_User_Id] FOREIGN KEY ([UserId]) REFERENCES [usersmi].[Users] ([Id]) ON DELETE CASCADE +) +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_UserPermissions.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_UserPermissions.sql new file mode 100644 index 000000000..847a4a571 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_UserPermissions.sql @@ -0,0 +1,10 @@ +CREATE VIEW [usersmi].[v_UserPermissions] +AS +SELECT + DISTINCT + [UserRole].[UserId] AS [UserId], + [RoleClaim].[ClaimValue] AS [PermissionCode] +FROM [usersmi].UserRoles AS [UserRole] + INNER JOIN [usersmi].[RoleClaims] AS [RoleClaim] + ON [UserRole].[RoleId] = [RoleClaim].[RoleId] +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_UserRoles.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_UserRoles.sql new file mode 100644 index 000000000..558a5dc98 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_UserRoles.sql @@ -0,0 +1,8 @@ +CREATE VIEW [usersmi].[v_UserRoles] +AS +SELECT [UserRole].[UserId] AS [UserId], + [Role].[Name] AS [RoleCode] + FROM [usersmi].[UserRoles] AS [UserRole] + INNER JOIN [usersmi].[Roles] AS [Role] + ON [UserRole].[RoleId] = [Role].[Id] +GO \ No newline at end of file diff --git a/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_Users.sql b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_Users.sql new file mode 100644 index 000000000..f603e1826 --- /dev/null +++ b/src/Database/CompanyName.MyMeetings.Database/Structure/users/MicrosoftIdentity/Views/v_Users.sql @@ -0,0 +1,11 @@ +CREATE VIEW [usersmi].[v_Users] +AS +SELECT + [User].[Id], + IIF([User].[LockoutEnabled] = 1, 0, 1) AS [IsActive], + [User].[UserName] AS [Login], + [User].[PasswordHash] AS [Password], + [User].[Email], + [User].[Name] +FROM [usersmi].[Users] AS [User] +GO \ No newline at end of file diff --git a/src/Database/DatabaseMigrator/DatabaseMigrator.csproj b/src/Database/DatabaseMigrator/DatabaseMigrator.csproj index 677ff4d25..88c8fead7 100644 --- a/src/Database/DatabaseMigrator/DatabaseMigrator.csproj +++ b/src/Database/DatabaseMigrator/DatabaseMigrator.csproj @@ -1,5 +1,18 @@  - - Exe - + + Exe + + + + + + + PreserveNewest + PreserveNewest + + + PreserveNewest + PreserveNewest + + \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index a98909f23..91a9f6ef9 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,54 +1,55 @@  - - - - - + + + + + - - False - + + False + - - $(MSBuildThisFileDirectory)\BuildingBlocks\Domain\*.csproj - $(MSBuildThisFileDirectory)\BuildingBlocks\Application\*.csproj - $(MSBuildThisFileDirectory)\BuildingBlocks\Infrastructure\*.csproj - $(MSBuildThisFileDirectory)\BuildingBlocks\Tests\IntegrationTests\*.csproj - + + $(MSBuildThisFileDirectory)\BuildingBlocks\Domain\*.csproj + $(MSBuildThisFileDirectory)\BuildingBlocks\Application\*.csproj + $(MSBuildThisFileDirectory)\BuildingBlocks\Infrastructure\*.csproj + $(MSBuildThisFileDirectory)\BuildingBlocks\Tests\IntegrationTests\*.csproj + - - - + + + + - - - - - - + + + + + + - - - - + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - + + + + \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index c7044f5e1..a1b45af1e 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -2,24 +2,21 @@ - - + - - @@ -27,13 +24,12 @@ - - + + - diff --git a/src/Modules/Meetings/Application/CompanyName.MyMeetings.Modules.Meetings.Application.csproj b/src/Modules/Meetings/Application/CompanyName.MyMeetings.Modules.Meetings.Application.csproj index c7ae8e6bb..24993e116 100644 --- a/src/Modules/Meetings/Application/CompanyName.MyMeetings.Modules.Meetings.Application.csproj +++ b/src/Modules/Meetings/Application/CompanyName.MyMeetings.Modules.Meetings.Application.csproj @@ -1,7 +1,7 @@  - + diff --git a/src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedNotification.cs b/src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedNotification.cs index 493513ff5..2de8aaa27 100644 --- a/src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedNotification.cs +++ b/src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedNotification.cs @@ -1,10 +1,12 @@ using CompanyName.MyMeetings.BuildingBlocks.Application.Events; using CompanyName.MyMeetings.Modules.Registrations.Domain.UserRegistrations.Events; +using Newtonsoft.Json; namespace CompanyName.MyMeetings.Modules.Registrations.Application.UserRegistrations.ConfirmUserRegistration; public class UserRegistrationConfirmedNotification : DomainNotificationBase { + [JsonConstructor] public UserRegistrationConfirmedNotification(UserRegistrationConfirmedDomainEvent domainEvent, Guid id) : base(domainEvent, id) { diff --git a/src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedNotificationHandler.cs b/src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedNotificationHandler.cs index 756c30bc1..6a659a929 100644 --- a/src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedNotificationHandler.cs +++ b/src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedNotificationHandler.cs @@ -4,7 +4,7 @@ namespace CompanyName.MyMeetings.Modules.Registrations.Application.UserRegistrations.ConfirmUserRegistration; -public class UserRegistrationConfirmedNotificationHandler : INotificationHandler +internal class UserRegistrationConfirmedNotificationHandler : INotificationHandler { private readonly IUserCreator _userCreator; diff --git a/src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedPublishEventHandler.cs b/src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedPublishEventHandler.cs new file mode 100644 index 000000000..446fb6c78 --- /dev/null +++ b/src/Modules/Registrations/Application/UserRegistrations/ConfirmUserRegistration/UserRegistrationConfirmedPublishEventHandler.cs @@ -0,0 +1,40 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Data; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.EventBus; +using CompanyName.MyMeetings.Modules.Registrations.Application.UserRegistrations.GetUserRegistration; +using CompanyName.MyMeetings.Modules.Registrations.IntegrationEvents; +using MediatR; + +namespace CompanyName.MyMeetings.Modules.Registrations.Application.UserRegistrations.ConfirmUserRegistration +{ + internal class UserRegistrationConfirmedPublishEventHandler : INotificationHandler + { + private readonly IEventsBus _eventsBus; + private readonly ISqlConnectionFactory _sqlConnectionFactory; + + public UserRegistrationConfirmedPublishEventHandler(IEventsBus eventsBus, ISqlConnectionFactory sqlConnectionFactory) + { + _eventsBus = eventsBus; + _sqlConnectionFactory = sqlConnectionFactory; + } + + public async Task Handle(UserRegistrationConfirmedNotification notification, CancellationToken cancellationToken) + { + var connection = _sqlConnectionFactory.GetOpenConnection(); + + var registration = await UserRegistrationProvider.GetById( + connection, + notification.DomainEvent.UserRegistrationId.Value); + + await _eventsBus.Publish(new UserRegistrationConfirmedIntegrationEvent( + notification.Id, + notification.DomainEvent.OccurredOn, + notification.DomainEvent.UserRegistrationId.Value, + registration.Login, + registration.Password, + registration.Email, + registration.FirstName, + registration.LastName, + registration.Name)); + } + } +} \ No newline at end of file diff --git a/src/Modules/Registrations/Application/UserRegistrations/RegisterNewUser/RegisterNewUserCommandHandler.cs b/src/Modules/Registrations/Application/UserRegistrations/RegisterNewUser/RegisterNewUserCommandHandler.cs index 955a0a66c..510396449 100644 --- a/src/Modules/Registrations/Application/UserRegistrations/RegisterNewUser/RegisterNewUserCommandHandler.cs +++ b/src/Modules/Registrations/Application/UserRegistrations/RegisterNewUser/RegisterNewUserCommandHandler.cs @@ -1,4 +1,5 @@ -using CompanyName.MyMeetings.Modules.Registrations.Application.Configuration.Commands; +using CompanyName.MyMeetings.BuildingBlocks.Application.Security; +using CompanyName.MyMeetings.Modules.Registrations.Application.Configuration.Commands; using CompanyName.MyMeetings.Modules.Registrations.Domain.UserRegistrations; namespace CompanyName.MyMeetings.Modules.Registrations.Application.UserRegistrations.RegisterNewUser diff --git a/src/Modules/Registrations/IntegrationEvents/Class1.cs b/src/Modules/Registrations/IntegrationEvents/Class1.cs deleted file mode 100644 index bb581f45a..000000000 --- a/src/Modules/Registrations/IntegrationEvents/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace CompanyName.MyMeetings.Modules.Registrations.IntegrationEvents; - -public class Class1 -{ -} \ No newline at end of file diff --git a/src/Modules/Registrations/IntegrationEvents/UserRegistrationConfirmedIntegrationEvent.cs b/src/Modules/Registrations/IntegrationEvents/UserRegistrationConfirmedIntegrationEvent.cs new file mode 100644 index 000000000..f4a21ad62 --- /dev/null +++ b/src/Modules/Registrations/IntegrationEvents/UserRegistrationConfirmedIntegrationEvent.cs @@ -0,0 +1,33 @@ +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.EventBus; + +namespace CompanyName.MyMeetings.Modules.Registrations.IntegrationEvents +{ + public class UserRegistrationConfirmedIntegrationEvent : IntegrationEvent + { + public Guid UserId { get; } + + public string Login { get; } + + public string Password { get; } + + public string Email { get; } + + public string FirstName { get; } + + public string LastName { get; } + + public string Name { get; } + + public UserRegistrationConfirmedIntegrationEvent(Guid id, DateTime occurredOn, Guid userId, string login, string password, string email, string firstName, string lastName, string name) + : base(id, occurredOn) + { + UserId = userId; + Login = login; + Password = password; + Email = email; + FirstName = firstName; + LastName = lastName; + Name = name; + } + } +} diff --git a/src/Modules/UserAccess/Api/WebApi/CompanyName.MyMeetings.Modules.UserAccess.WebApi.csproj b/src/Modules/UserAccess/Api/WebApi/CompanyName.MyMeetings.Modules.UserAccess.WebApi.csproj new file mode 100644 index 000000000..1aaf9fcef --- /dev/null +++ b/src/Modules/UserAccess/Api/WebApi/CompanyName.MyMeetings.Modules.UserAccess.WebApi.csproj @@ -0,0 +1,12 @@ + + + + enable + enable + + + + + + + diff --git a/src/API/CompanyName.MyMeetings.API/Modules/UserAccess/AuthenticatedUserController.cs b/src/Modules/UserAccess/Api/WebApi/Endpoints/AuthenticatedUserController.cs similarity index 89% rename from src/API/CompanyName.MyMeetings.API/Modules/UserAccess/AuthenticatedUserController.cs rename to src/Modules/UserAccess/Api/WebApi/Endpoints/AuthenticatedUserController.cs index 881cc033b..af8ee2cfe 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/UserAccess/AuthenticatedUserController.cs +++ b/src/Modules/UserAccess/Api/WebApi/Endpoints/AuthenticatedUserController.cs @@ -1,12 +1,13 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.UserAccess.Application.Authorization.GetAuthenticatedUserPermissions; using CompanyName.MyMeetings.Modules.UserAccess.Application.Authorization.GetUserPermissions; using CompanyName.MyMeetings.Modules.UserAccess.Application.Contracts; using CompanyName.MyMeetings.Modules.UserAccess.Application.Users.GetAuthenticatedUser; using CompanyName.MyMeetings.Modules.UserAccess.Application.Users.GetUser; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace CompanyName.MyMeetings.API.Modules.UserAccess +namespace CompanyName.MyMeetings.Modules.UserAccess.WebApi.Endpoints { [Route("api/userAccess/authenticatedUser")] [ApiController] diff --git a/src/API/CompanyName.MyMeetings.API/Modules/UserAccess/EmailsController.cs b/src/Modules/UserAccess/Api/WebApi/Endpoints/EmailsController.cs similarity index 84% rename from src/API/CompanyName.MyMeetings.API/Modules/UserAccess/EmailsController.cs rename to src/Modules/UserAccess/Api/WebApi/Endpoints/EmailsController.cs index ee75f4d2d..178ff12f7 100644 --- a/src/API/CompanyName.MyMeetings.API/Modules/UserAccess/EmailsController.cs +++ b/src/Modules/UserAccess/Api/WebApi/Endpoints/EmailsController.cs @@ -1,10 +1,10 @@ -using CompanyName.MyMeetings.API.Configuration.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using CompanyName.MyMeetings.Modules.UserAccess.Application.Contracts; using CompanyName.MyMeetings.Modules.UserAccess.Application.Emails; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace CompanyName.MyMeetings.API.Modules.UserAccess +namespace CompanyName.MyMeetings.Modules.UserAccess.WebApi.Endpoints { [Route("api/userAccess/emails")] [ApiController] @@ -27,4 +27,4 @@ public async Task GetEmails() return Ok(allEmails); } } -} \ No newline at end of file +} diff --git a/src/Modules/UserAccess/Application/CompanyName.MyMeetings.Modules.UserAccess.Application.csproj b/src/Modules/UserAccess/Application/CompanyName.MyMeetings.Modules.UserAccess.Application.csproj index 7608b9ffb..e85b01b5d 100644 --- a/src/Modules/UserAccess/Application/CompanyName.MyMeetings.Modules.UserAccess.Application.csproj +++ b/src/Modules/UserAccess/Application/CompanyName.MyMeetings.Modules.UserAccess.Application.csproj @@ -1,5 +1,6 @@  + \ No newline at end of file diff --git a/src/Modules/UserAccess/Infrastructure/CompanyName.MyMeetings.Modules.UserAccess.Infrastructure.csproj b/src/Modules/UserAccess/Infrastructure/CompanyName.MyMeetings.Modules.UserAccess.Infrastructure.csproj index 0bc3d0eed..09e81b5e9 100644 --- a/src/Modules/UserAccess/Infrastructure/CompanyName.MyMeetings.Modules.UserAccess.Infrastructure.csproj +++ b/src/Modules/UserAccess/Infrastructure/CompanyName.MyMeetings.Modules.UserAccess.Infrastructure.csproj @@ -1,6 +1,6 @@  - - + + diff --git a/src/Modules/UserAccess/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs b/src/Modules/UserAccess/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs new file mode 100644 index 000000000..e652e6648 --- /dev/null +++ b/src/Modules/UserAccess/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs @@ -0,0 +1,48 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; +using CompanyName.MyMeetings.Modules.UserAccess.Application.Authorization.GetUserPermissions; +using CompanyName.MyMeetings.Modules.UserAccess.Application.Contracts; +using Microsoft.AspNetCore.Authorization; + +namespace CompanyName.MyMeetings.Modules.UserAccess.Infrastructure.Configuration.Identity; + +internal class HasPermissionAuthorizationHandler : AttributeAuthorizationHandler< + HasPermissionAuthorizationRequirement, HasPermissionAttribute> +{ + private readonly IExecutionContextAccessor _executionContextAccessor; + private readonly IUserAccessModule _userAccessModule; + + public HasPermissionAuthorizationHandler( + IExecutionContextAccessor executionContextAccessor, + IUserAccessModule userAccessModule) + { + _executionContextAccessor = executionContextAccessor; + _userAccessModule = userAccessModule; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + HasPermissionAuthorizationRequirement requirement, + HasPermissionAttribute attribute) + { + var permissions = await _userAccessModule.ExecuteQueryAsync(new GetUserPermissionsQuery(_executionContextAccessor.UserId)); + + if (!await AuthorizeAsync(attribute.Name, permissions)) + { + context.Fail(); + return; + } + + context.Succeed(requirement); + } + + private Task AuthorizeAsync(string permission, List permissions) + { +#if !DEBUG + return Task.FromResult(true); +#endif +#pragma warning disable CS0162 // Unreachable code detected + return Task.FromResult(permissions.Any(x => x.Code == permission)); +#pragma warning restore CS0162 // Unreachable code detected + } +} \ No newline at end of file diff --git a/src/Modules/UserAccess/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs b/src/Modules/UserAccess/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs new file mode 100644 index 000000000..66bc8df4f --- /dev/null +++ b/src/Modules/UserAccess/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs @@ -0,0 +1,50 @@ +using Autofac; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; +using CompanyName.MyMeetings.Modules.UserAccess.Application.Contracts; +using CompanyName.MyMeetings.Modules.UserAccess.Infrastructure.Configuration.Identity; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CompanyName.MyMeetings.Modules.UserAccess.Infrastructure.Configuration.ModuleHosting; + +public class UserAccessModule(IConfiguration hostConfiguration) : ModuleBase(hostConfiguration) +{ + public override string WebApiAssemblySearchPattern => "*.Modules.UserAccess.WebApi.dll"; + + public override void InitializeModule(HostServices hostServices) + { + UserAccessStartup.Initialize( + hostServices.ConnectionString, + hostServices.ExecutionContextAccessor, + hostServices.Logger, + hostServices.EmailsConfiguration, + hostServices.TextEncryptionKey, + null, + null); + + hostServices.ApplicationBuilder.AddIdentityService(); + } + + public override void RegisterModule(ContainerBuilder containerBuilder) + { + containerBuilder.RegisterType() + .As() + .InstancePerLifetimeScope(); + } + + protected override void AddHostServices(IServiceCollection services) + { + services.ConfigureIdentityService() + .AddAuthorization(options => + { + options.AddPolicy(HasPermissionAttribute.HasPermissionPolicyName, policyBuilder => + { + policyBuilder.Requirements.Add(new HasPermissionAuthorizationRequirement()); + policyBuilder.AddAuthenticationSchemes("Bearer"); + }); + }) + .AddScoped(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/CompanyName.MyMeetings.Modules.UsersMI.Contracts.csproj b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/CompanyName.MyMeetings.Modules.UsersMI.Contracts.csproj new file mode 100644 index 000000000..8d526a472 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/CompanyName.MyMeetings.Modules.UsersMI.Contracts.csproj @@ -0,0 +1,8 @@ + + + + enable + enable + + + diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/ErrorMessage.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/ErrorMessage.cs new file mode 100644 index 000000000..aa80e8bc8 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/ErrorMessage.cs @@ -0,0 +1,3 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; + +public record ErrorMessage(string Code, string? Message); \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/IResult.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/IResult.cs new file mode 100644 index 000000000..a4205015b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/IResult.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; + +public interface IResult +{ + T? Value { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/Result.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/Result.cs new file mode 100644 index 000000000..4b1395f1b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/Result.cs @@ -0,0 +1,215 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; + +public class Result : Result +{ + public Result() + { + } + + public Result(Result value) + : base(value) + { + } + + public Result(ResultStatus status) + : base(status) + { + } + + public Result(IDictionary> errors) + : base(errors) + { + } + + public Result(ResultStatus status, Result value) + : base(status, value) + { + } + + public Result(ResultStatus status, IDictionary> errors) + : base(status, errors) + { + } + + public static Result Conflict() + { + return new Result(ResultStatus.Conflict); + } + + public static Result Conflict(IDictionary> errors) + { + return new Result(ResultStatus.Conflict, errors); + } + + public static Result Conflict(IDictionary> errors) + { + return new Result(ResultStatus.Conflict, errors); + } + + public static Result Created() + { + return new Result(ResultStatus.Created); + } + + public static Result Created(IDictionary> errors) + { + return new Result(ResultStatus.Created, errors); + } + + public static Result Created(T value) + { + return new Result(ResultStatus.Created, value); + } + + public static Result Error() + { + return new Result(ResultStatus.Error); + } + + public static Result Error() + { + return new Result(ResultStatus.Error); + } + + public static Result Error(IDictionary> errors) + { + return new Result(ResultStatus.Error, errors); + } + + public static Result Error(IDictionary> errors) + { + return new Result(ResultStatus.Error, errors); + } + + public static Result Forbidden() + { + return new Result(ResultStatus.Forbidden); + } + + public static Result Forbidden(IDictionary> errors) + { + return new Result(ResultStatus.Forbidden, errors); + } + + public static Result Forbidden(IDictionary> errors) + { + return new Result(ResultStatus.Forbidden, errors); + } + + public static Result Invalid() + { + return new Result(ResultStatus.Invalid); + } + + public static Result Invalid(IDictionary> errors) + { + return new Result(ResultStatus.Invalid, errors); + } + + public static Result Invalid(IDictionary> errors) + { + return new Result(ResultStatus.Invalid, errors); + } + + public static Result NoContent() + { + return new Result(ResultStatus.NoContent); + } + + public static Result NoContent(IDictionary> errors) + { + return new Result(ResultStatus.NoContent, errors); + } + + public static Result NoContent(IDictionary> errors) + { + return new Result(ResultStatus.NoContent, errors); + } + + public static Result NotFound() + { + return new Result(ResultStatus.NotFound); + } + + public static Result NotFound(IDictionary> errors) + { + return new Result(ResultStatus.NotFound, errors); + } + + public static Result NotFound(IDictionary> errors) + { + return new Result(ResultStatus.NotFound, errors); + } + + public static Result Ok() + { + return new Result(ResultStatus.Ok); + } + + public static Result Ok(T value) + { + return new Result(value); + } + + public static Result Unauthorized() + { + return new Result(ResultStatus.Unauthorized); + } + + public static Result Unauthorized(IDictionary> errors) + { + return new Result(ResultStatus.Unauthorized, errors); + } + + public static Result Unauthorized(IDictionary> errors) + { + return new Result(ResultStatus.Unauthorized, errors); + } +} + +public class Result : IResult +{ + public Result() + { + } + + public Result(ResultStatus status) + { + Status = status; + TimeGeneratedUtc = DateTime.UtcNow; + } + + public Result(T value) + : this(ResultStatus.Ok) + { + Value = value; + } + + public Result(ResultStatus status, T value) + : this(status) + { + Value = value; + } + + public Result(IDictionary> errors) + : this(ResultStatus.Error) + { + Errors = errors; + } + + public Result(ResultStatus status, IDictionary> errors) + : this(status) + { + Errors = errors; + } + + public T? Value { get; set; } + + public ResultStatus Status { get; set; } + + public DateTime TimeGeneratedUtc { get; set; } + + public IDictionary>? Errors { get; set; } + + public bool IsSuccess => Status is ResultStatus.Ok || Status is ResultStatus.NoContent || Status == ResultStatus.Created; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/ResultStatus.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/ResultStatus.cs new file mode 100644 index 000000000..a388b8252 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/Results/ResultStatus.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Serialization; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; + +/// +/// Represents the status of a result in the application. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ResultStatus +{ + /// + /// The operation completed successfully. + /// + Ok, + + /// + /// The operation encountered an error. + /// + Error, + + /// + /// The resource was successfully created. + /// + Created, + + /// + /// The requested resource was not found. + /// + NotFound, + + /// + /// Access to the resource is forbidden. + /// + Forbidden, + + /// + /// The user is not authorized to perform the operation. + /// + Unauthorized, + + /// + /// The request is invalid. + /// + Invalid, + + /// + /// The operation completed successfully but there is no content to return. + /// + NoContent, + + /// + /// There is a conflict with the current state of the resource. + /// + Conflict +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/AuthenticationRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/AuthenticationRequest.cs new file mode 100644 index 000000000..a27d0db87 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/AuthenticationRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authentication; + +public class AuthenticationRequest +{ + public string UserName { get; set; } = null!; + + [DataType(DataType.Password)] + public string Password { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/AuthenticationResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/AuthenticationResponse.cs new file mode 100644 index 000000000..6b39ed37e --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/AuthenticationResponse.cs @@ -0,0 +1,16 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authentication; + +public class AuthenticationResponse +{ + public string? AccessToken { get; set; } + + public string? RefreshToken { get; set; } + + public string? UserName { get; set; } + + public bool IsLockedOut { get; set; } = false; + + public bool IsNotAllowed { get; set; } = false; + + public bool RequiresTwoFactor { get; set; } = false; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/ResetPasswordRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/ResetPasswordRequest.cs new file mode 100644 index 000000000..86c443ca9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/ResetPasswordRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authentication; + +public class ResetPasswordRequest +{ + public string Token { get; set; } = null!; + + public string EmailAddress { get; set; } = null!; + + [DataType(DataType.Password)] + public string Password { get; set; } = null!; + + [Compare(nameof(Password))] + [DataType(DataType.Password)] + public string ConfirmPassword { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/TokenRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/TokenRequest.cs new file mode 100644 index 000000000..6a735eb02 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/TokenRequest.cs @@ -0,0 +1,8 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authentication; + +public class TokenRequest +{ + public string AccessToken { get; set; } = null!; + + public string RefreshToken { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/TokenResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/TokenResponse.cs new file mode 100644 index 000000000..e6873ff77 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authentication/TokenResponse.cs @@ -0,0 +1,8 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authentication; + +public class TokenResponse +{ + public string AccessToken { get; set; } = null!; + + public string RefreshToken { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authorization/PermissionResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authorization/PermissionResponse.cs new file mode 100644 index 000000000..6e27b269c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authorization/PermissionResponse.cs @@ -0,0 +1,10 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authorization; + +public class PermissionResponse +{ + public string Code { get; set; } = null!; + + public string Name { get; set; } = null!; + + public string? Description { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authorization/PermissionsResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authorization/PermissionsResponse.cs new file mode 100644 index 000000000..4942db306 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Authorization/PermissionsResponse.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authorization; + +public class PermissionsResponse +{ + public IEnumerable Permissions { get; set; } = Enumerable.Empty(); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ChangeEmailAddressRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ChangeEmailAddressRequest.cs new file mode 100644 index 000000000..92f6d70db --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ChangeEmailAddressRequest.cs @@ -0,0 +1,8 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; + +public class ChangeEmailAddressRequest +{ + public string Token { get; set; } = null!; + + public string NewEmailAddress { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ChangePasswordRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ChangePasswordRequest.cs new file mode 100644 index 000000000..7317cdd60 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ChangePasswordRequest.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; + +public class ChangePasswordRequest +{ + [DataType(DataType.Password)] + public required string CurrentPassword { get; init; } + + [DataType(DataType.Password)] + public required string NewPassword { get; init; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ConfirmEmailAddressRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ConfirmEmailAddressRequest.cs new file mode 100644 index 000000000..3617aa326 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/ConfirmEmailAddressRequest.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; + +public class ConfirmEmailAddressRequest +{ + public string Token { get; set; } = null!; +} diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/RegisterAuthenticatorRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/RegisterAuthenticatorRequest.cs new file mode 100644 index 000000000..dccc899ae --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/RegisterAuthenticatorRequest.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; + +public class RegisterAuthenticatorRequest +{ + public required string Code { get; init; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/RequestChangeEmailAddressTokenRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/RequestChangeEmailAddressTokenRequest.cs new file mode 100644 index 000000000..ef5b88cba --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/RequestChangeEmailAddressTokenRequest.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; + +public class RequestChangeEmailAddressTokenRequest +{ + public string NewEmailAddress { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/UpdateProfileRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/UpdateProfileRequest.cs new file mode 100644 index 000000000..496e73a00 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/UpdateProfileRequest.cs @@ -0,0 +1,12 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; + +public class UpdateProfileRequest +{ + public string Login { get; set; } = null!; + + public string Name { get; set; } = null!; + + public string? FirstName { get; set; } + + public string? LastName { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/UserAccountResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/UserAccountResponse.cs new file mode 100644 index 000000000..f6eebb030 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Me/UserAccountResponse.cs @@ -0,0 +1,16 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; + +public class UserAccountResponse +{ + public Guid Id { get; set; } + + public string UserName { get; set; } = null!; + + public string? Name { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public string? EmailAddress { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/AddRoleRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/AddRoleRequest.cs new file mode 100644 index 000000000..0d9456642 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/AddRoleRequest.cs @@ -0,0 +1,8 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; + +public class AddRoleRequest +{ + public required string Name { get; init; } + + public required string[] Permissions { get; init; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/PermissionResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/PermissionResponse.cs new file mode 100644 index 000000000..dfa3bcb0a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/PermissionResponse.cs @@ -0,0 +1,10 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; + +public class PermissionResponse +{ + public string Code { get; set; } = null!; + + public string Name { get; set; } = null!; + + public string? Description { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/PermissionsResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/PermissionsResponse.cs new file mode 100644 index 000000000..1fead2355 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/PermissionsResponse.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; + +public class PermissionsResponse +{ + public IEnumerable Permissions { get; set; } = Enumerable.Empty(); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RenameRoleRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RenameRoleRequest.cs new file mode 100644 index 000000000..e091917f0 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RenameRoleRequest.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; + +public class RenameRoleRequest +{ + public string Name { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RoleResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RoleResponse.cs new file mode 100644 index 000000000..092110579 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RoleResponse.cs @@ -0,0 +1,8 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; + +public class RoleResponse +{ + public Guid Id { get; set; } + + public string Name { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RolesResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RolesResponse.cs new file mode 100644 index 000000000..f0793a670 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/RolesResponse.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; + +public class RolesResponse +{ + public IEnumerable Roles { get; set; } = Enumerable.Empty(); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/SetRolePermissionsRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/SetRolePermissionsRequest.cs new file mode 100644 index 000000000..729da8328 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Roles/SetRolePermissionsRequest.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; + +public class SetRolePermissionsRequest +{ + public required string[] Permissions { get; init; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/ChangeUserEmailAddressRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/ChangeUserEmailAddressRequest.cs new file mode 100644 index 000000000..515ff4f7d --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/ChangeUserEmailAddressRequest.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; + +public class ChangeUserEmailAddressRequest +{ + public string NewEmailAddress { get; set; } = null!; +} diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/PermissionResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/PermissionResponse.cs new file mode 100644 index 000000000..a4773c334 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/PermissionResponse.cs @@ -0,0 +1,10 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; + +public class PermissionResponse +{ + public required string Code { get; init; } + + public required string Name { get; init; } + + public string? Description { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/PermissionsResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/PermissionsResponse.cs new file mode 100644 index 000000000..982c1ccab --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/PermissionsResponse.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; + +public class PermissionsResponse +{ + public IEnumerable Permissions { get; set; } = Enumerable.Empty(); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/RoleResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/RoleResponse.cs new file mode 100644 index 000000000..2082f413f --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/RoleResponse.cs @@ -0,0 +1,8 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; + +public class RoleResponse +{ + public Guid Id { get; set; } + + public string Name { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/RolesResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/RolesResponse.cs new file mode 100644 index 000000000..e3a3d6cb3 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/RolesResponse.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; + +public class RolesResponse +{ + public IEnumerable Roles { get; set; } = Enumerable.Empty(); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/SetUserPermissionsRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/SetUserPermissionsRequest.cs new file mode 100644 index 000000000..f4d300515 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/SetUserPermissionsRequest.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; + +public class SetUserPermissionsRequest +{ + public IEnumerable Permissions { get; init; } = Enumerable.Empty(); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/SetUserRolesRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/SetUserRolesRequest.cs new file mode 100644 index 000000000..ec7b32038 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/SetUserRolesRequest.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; + +public class SetUserRolesRequest +{ + public IEnumerable RoleIds { get; set; } = Enumerable.Empty(); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UpdateUserAccountRequest.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UpdateUserAccountRequest.cs new file mode 100644 index 000000000..4c8d6c52e --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UpdateUserAccountRequest.cs @@ -0,0 +1,19 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; + +public class UpdateUserAccountRequest +{ + /// + /// Gets or sets the name. + /// + public required string Name { get; init; } + + /// + /// Gets or sets the first name. + /// + public string? FirstName { get; init; } + + /// + /// Gets or sets the last name. + /// + public string? LastName { get; init; } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UserAccountResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UserAccountResponse.cs new file mode 100644 index 000000000..70d6c0c14 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UserAccountResponse.cs @@ -0,0 +1,71 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; + +public class UserAccountResponse +{ + public Guid Id { get; init; } + + public string? Name { get; init; } + + public string? FirstName { get; init; } + + public string? LastName { get; init; } + + /// + /// Gets or sets the date and time, in UTC, when any user lockout ends. + /// A value in the past means the user is not locked out. + /// + public DateTimeOffset? LockoutEnd { get; init; } + + /// + /// Gets or sets a flag indicating if two factor authentication is enabled for this user. + /// True if 2fa is enabled, otherwise false. + /// + public bool TwoFactorEnabled { get; init; } + + /// + /// Gets or sets a flag indicating if a user has confirmed their telephone address. + /// True if the telephone number has been confirmed, otherwise false. + /// + public bool PhoneNumberConfirmed { get; init; } + + /// + /// Gets or sets a telephone number for the user. + /// + public string? PhoneNumber { get; init; } + + /// + /// Gets or sets a flag indicating if a user has confirmed their email address. + /// True if the email address has been confirmed, otherwise false. + /// + public bool EmailConfirmed { get; init; } + + /// + /// Gets or sets the normalized email address for this user. + /// + public string? NormalizedEmail { get; init; } = null!; + + /// + /// Gets or sets the email address for this user. + /// + public string? Email { get; init; } = null!; + + /// + /// Gets or sets the normalized user name for this user. + /// + public required string NormalizedLogin { get; init; } + + /// + /// Gets or sets the login for this user. + /// + public required string Login { get; init; } + + /// + /// True if the user could be locked out, otherwise false. + /// + public bool LockoutEnabled { get; init; } + + /// + /// Gets or sets the number of failed login attempts for the current user. + /// + public int AccessFailedCount { get; init; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UserAccountsResponse.cs b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UserAccountsResponse.cs new file mode 100644 index 000000000..f0f8a6516 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Contracts/V1/Users/UserAccountsResponse.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; + +public class UserAccountsResponse +{ + public IEnumerable UserAccounts { get; init; } = Enumerable.Empty(); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Sdk/ApiEndpoints.cs b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/ApiEndpoints.cs new file mode 100644 index 000000000..fe0d196a4 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/ApiEndpoints.cs @@ -0,0 +1,69 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Sdk; + +public static class ApiEndpoints +{ + private const string ApiBase = "/api"; + + public static class Authentication + { + public const string Login = $"{Base}/login"; + public const string TwoFactorLogin = $"{Base}/two-factor-login"; + public const string RequestForgotPasswordLink = $"{Base}/request-forgot-password-link"; + public const string ResetPassword = $"{Base}/reset-password"; + public const string ExternalLogin = $"{Base}/external-login"; + public const string ExternalLoginCallback = $"{Base}/external-login-callback"; + public const string RefreshToken = $"{Base}/refresh-token"; + + private const string Base = $"{ApiBase}/authentication"; + } + + public static class Me + { + public const string GetUserAccount = $"{Base}"; + public const string UpdateProfile = $"{Base}/update-profile"; + public const string ChangePassword = $"{Base}/change-password"; + public const string GetAuthenticatorKey = $"{Base}/authenticator-key"; + public const string ChangeEmailAddress = $"{Base}/change-email-address"; + public const string ConfirmEmailAddress = $"{Base}/confirm-email-address"; + public const string RegisterAuthenticator = $"{Base}/register-authenticator"; + public const string RequestChangeEmailAddressToken = $"{Base}/request-change-email-address-token"; + public const string RequestConfirmEmailAddressToken = $"{Base}/request-confirm-email-address-token"; + + private const string Base = $"{ApiBase}/me"; + } + + public static class Authorization + { + public const string GetPermissions = $"{Base}/permissions"; + + private const string Base = $"{ApiBase}/authorization"; + } + + public static class Roles + { + public const string GetRoles = Base; + public const string GetRoleById = $"{Base}/{{roleId}}"; + public const string AddRole = Base; + public const string RenameRole = $"{Base}/{{roleId}}/rename"; + public const string DeleteRole = $"{Base}/{{roleId}}"; + public const string GetRolePermissions = $"{Base}/{{roleId}}/permissions"; + public const string SetRolePermissions = $"{Base}/{{roleId}}/permissions"; + + private const string Base = $"{ApiBase}/users/roles"; + } + + public static class Users + { + public const string GetUsers = Base; + public const string GetUserById = $"{Base}/{{userId}}"; + public const string UpdateUser = $"{Base}/{{userId}}"; + public const string UnlockUser = $"{Base}/{{userId}}/unlock"; + public const string GetUserRoles = $"{Base}/{{userId}}/roles"; + public const string SetUserRoles = $"{Base}/{{userId}}/roles"; + public const string GetUserPermissions = $"{Base}/{{userId}}/permissions"; + public const string SetUserPermissions = $"{Base}/{{userId}}/permissions"; + public const string ChangeUserEmailAddress = $"{Base}/{{userId}}/change-email-address"; + + private const string Base = $"{ApiBase}/users/accounts"; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Sdk/CompanyName.MyMeetings.Modules.UsersMI.Sdk.csproj b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/CompanyName.MyMeetings.Modules.UsersMI.Sdk.csproj new file mode 100644 index 000000000..b030f4a7b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/CompanyName.MyMeetings.Modules.UsersMI.Sdk.csproj @@ -0,0 +1,16 @@ + + + + enable + enable + + + + + + + + + + + \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IAuthenticationApi.cs b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IAuthenticationApi.cs new file mode 100644 index 000000000..f4654577d --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IAuthenticationApi.cs @@ -0,0 +1,23 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authentication; +using Refit; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Sdk; + +public interface IAuthenticationApi +{ + [Post(ApiEndpoints.Authentication.Login)] + Task> LoginAsync(AuthenticationRequest request, CancellationToken cancellationToken = default); + + [Post(ApiEndpoints.Authentication.TwoFactorLogin)] + Task> TwoFactorLoginAsync(string token, CancellationToken cancellationToken = default); + + [Post(ApiEndpoints.Authentication.RequestForgotPasswordLink)] + Task> RequestForgotPasswordLinkAsync(string emailAddress, CancellationToken cancellationToken = default); + + [Post(ApiEndpoints.Authentication.ResetPassword)] + Task ResetPasswordAsync(ResetPasswordRequest resetPassword, CancellationToken cancellationToken = default); + + [Post(ApiEndpoints.Authentication.RefreshToken)] + Task> RefreshTokenAsync(TokenRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IAuthorizationApi.cs b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IAuthorizationApi.cs new file mode 100644 index 000000000..c069a932a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IAuthorizationApi.cs @@ -0,0 +1,11 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authorization; +using Refit; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Sdk; + +public interface IAuthorizationApi +{ + [Get(ApiEndpoints.Authorization.GetPermissions)] + Task> GetPermissionsAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IMeApi.cs b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IMeApi.cs new file mode 100644 index 000000000..12e5e6166 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IMeApi.cs @@ -0,0 +1,35 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; +using Refit; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Sdk; + +public interface IMeApi +{ + [Get(ApiEndpoints.Me.GetUserAccount)] + Task> GetUserAccountAsync(CancellationToken cancellationToken = default); + + [Put(ApiEndpoints.Me.UpdateProfile)] + Task UpdateProfileAsync(UpdateProfileRequest request, CancellationToken cancellationToken = default); + + [Put(ApiEndpoints.Me.ChangePassword)] + Task ChangePasswordAsync(ChangePasswordRequest request, CancellationToken cancellationToken = default); + + [Get(ApiEndpoints.Me.GetAuthenticatorKey)] + Task> GetAuthenticatorKeyAsync(CancellationToken cancellationToken = default); + + [Put(ApiEndpoints.Me.ChangeEmailAddress)] + Task ChangeEmailAddressAsync(ChangeEmailAddressRequest request, CancellationToken cancellationToken = default); + + [Put(ApiEndpoints.Me.ConfirmEmailAddress)] + Task ConfirmEmailAddressAsync(ConfirmEmailAddressRequest request, CancellationToken cancellationToken = default); + + [Post(ApiEndpoints.Me.RegisterAuthenticator)] + Task RegisterAuthenticatorAsync(RegisterAuthenticatorRequest request, CancellationToken cancellationToken = default); + + [Get(ApiEndpoints.Me.RequestChangeEmailAddressToken)] + Task RequestChangeEmailAddressTokenAsync(RequestChangeEmailAddressTokenRequest request, CancellationToken cancellationToken = default); + + [Get(ApiEndpoints.Me.RequestConfirmEmailAddressToken)] + Task RequestConfirmEmailAddressTokenAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IRoleApi.cs b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IRoleApi.cs new file mode 100644 index 000000000..5463c566d --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IRoleApi.cs @@ -0,0 +1,29 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; +using Refit; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Sdk; + +public interface IRoleApi +{ + [Get(ApiEndpoints.Roles.GetRoles)] + Task> GetRolesAsync(CancellationToken cancellationToken = default); + + [Get(ApiEndpoints.Roles.GetRoleById)] + Task> GetRoleAsync(Guid roleId, CancellationToken cancellationToken = default); + + [Post(ApiEndpoints.Roles.AddRole)] + Task> AddRoleAsync(AddRoleRequest request, CancellationToken cancellationToken = default); + + [Put(ApiEndpoints.Roles.RenameRole)] + Task RenameRoleAsync(Guid roleId, RenameRoleRequest request, CancellationToken cancellationToken = default); + + [Delete(ApiEndpoints.Roles.DeleteRole)] + Task DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken = default); + + [Get(ApiEndpoints.Roles.GetRolePermissions)] + Task> GetRolePermissionsAsync(Guid roleId, CancellationToken cancellationToken = default); + + [Put(ApiEndpoints.Roles.SetRolePermissions)] + Task SetRolePermissionsAsync(Guid roleId, SetRolePermissionsRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IUserApi.cs b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IUserApi.cs new file mode 100644 index 000000000..f11841a10 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/Sdk/IUserApi.cs @@ -0,0 +1,35 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; +using Refit; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Sdk; + +public interface IUserApi +{ + [Get(ApiEndpoints.Users.GetUsers)] + Task> GetUserAccountsAsync(CancellationToken cancellationToken = default); + + [Get(ApiEndpoints.Users.GetUserById)] + Task> GetUserAccountAsync(Guid userId, CancellationToken cancellationToken = default); + + [Put(ApiEndpoints.Users.UpdateUser)] + Task UpdateUserAccountAsync(Guid userId, UpdateUserAccountRequest request, CancellationToken cancellationToken = default); + + [Put(ApiEndpoints.Users.UnlockUser)] + Task UnlockUserAccountAsync(Guid userId, CancellationToken cancellationToken = default); + + [Get(ApiEndpoints.Users.GetUserRoles)] + Task> GetUserRolesAsync(Guid userId, CancellationToken cancellationToken = default); + + [Put(ApiEndpoints.Users.SetUserRoles)] + Task SetUserRolesAsync(Guid userId, SetUserRolesRequest request, CancellationToken cancellationToken = default); + + [Get(ApiEndpoints.Users.GetUserPermissions)] + Task> GetUserPermissionsAsync(Guid userId, CancellationToken cancellationToken = default); + + [Put(ApiEndpoints.Users.SetUserPermissions)] + Task SetUserPermissionsAsync(Guid userId, SetUserPermissionsRequest request, CancellationToken cancellationToken = default); + + [Put(ApiEndpoints.Users.ChangeUserEmailAddress)] + Task ChangeUserEmailAddressAsync(Guid userId, ChangeUserEmailAddressRequest request, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/CompanyName.MyMeetings.Modules.UsersMI.WebApi.csproj b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/CompanyName.MyMeetings.Modules.UsersMI.WebApi.csproj new file mode 100644 index 000000000..3048494f9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/CompanyName.MyMeetings.Modules.UsersMI.WebApi.csproj @@ -0,0 +1,17 @@ + + + + enable + enable + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/ApplicationController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/ApplicationController.cs new file mode 100644 index 000000000..b0f1c1723 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/ApplicationController.cs @@ -0,0 +1,64 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Mvc; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints; + +/// +/// Custom base class for API Controllers +/// +/// Decorating the controller class with the ApiController attribute unleashes more magic power the framework offers. +/// This automates the model state checks and the model state IsValid property doesn't need to be checked manually. +/// Further more the [FromBody] attribute isn't needed anymore since the controller now automatically infers the binding source for the incoming data. +/// +[ApiController] +[Route("api/[controller]")] +public class ApplicationController : ControllerBase +{ + protected new Microsoft.AspNetCore.Http.IResult Ok(object? result = null) + { + return Result.Ok(result).ToApiResult(); + } + + /* + protected IActionResult Ok(string successMessage, object result = null) + { + return ApiResult.Ok(result, successMessage); + } + */ + + protected Microsoft.AspNetCore.Http.IResult NotFound(Error error, string? invalidField = null) + { + return Result.NotFound(error).ToApiResult(); + } + + protected Microsoft.AspNetCore.Http.IResult Error(Error error, string? invalidField = null) + { + return Result.Error(error).ToApiResult(); + } + + protected Microsoft.AspNetCore.Http.IResult Error(Error error) + { + return Result.Error(error).ToApiResult(); + } + + protected Microsoft.AspNetCore.Http.IResult FromResponse(Result response) + { + return response.ToApiResult(); + } + + protected Microsoft.AspNetCore.Http.IResult FromResponse(Result response) + { + return response.ToApiResult(); + } + + protected Microsoft.AspNetCore.Http.IResult FromResult(CSharpFunctionalExtensions.Result result) + { + if (result.IsSuccess) + { + return Ok(); + } + + return Error(result.Error); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/AuthenticationController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/AuthenticationController.cs new file mode 100644 index 000000000..c8be7165b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/AuthenticationController.cs @@ -0,0 +1,241 @@ +using System.Security.Claims; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login.External; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.RefreshToken; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.RequestResetPasswordToken; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.ResetPassword; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authentication; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Authentication; + +[AllowAnonymous] +[Route("api/authentication")] +public class AuthenticationController : ApplicationController +{ + private readonly IUserAccessModule _userAccessModule; + + public AuthenticationController(IUserAccessModule userAccessModule) + { + _userAccessModule = userAccessModule; + } + + /// + /// User login. + /// + /// Authentication attributes. + /// ApiResult. + [HttpPost("login")] + [NoPermissionRequired] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + public async Task Login(AuthenticationRequest request) + { + AuthenticationResponse? result = null; + + var response = await _userAccessModule.ExecuteCommandAsync(new AccountLoginCommand(request.UserName, request.Password)); + if (response is not null) + { + if (response.RequiresTwoFactor) + { + await HttpContext.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, response.ClaimsPrincipal!); + + result = new AuthenticationResponse() + { + RequiresTwoFactor = true + }; + } + else if (response.IsAuthenticated) + { + await HttpContext.SignInAsync(IdentityConstants.ApplicationScheme, response.ClaimsPrincipal!); + + result = new AuthenticationResponse() + { + UserName = response.User!.UserName, + AccessToken = response.AccessToken, + RefreshToken = response.RefreshToken + }; + } + + return response.ToResult(result).ToApiResult(); + } + + return Error(Errors.General.InvalidRequest()); + } + + /// + /// User login. + /// + /// User generated token. + /// ApiResult. + [HttpPost("two-factor-login")] + [NoPermissionRequired] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + public async Task TwoFactorLogin(string token) + { + var result = await HttpContext.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); + if (result is not null) + { + if (!result.Succeeded) + { + return Error(Errors.Authentication.LoginRequestExpired()); + } + + var response = await _userAccessModule.ExecuteCommandAsync(new AccountTwoFactorLoginCommand( + Guid.Parse(result.Principal.FindFirstValue("sub")!), + result.Principal.FindFirstValue("amr")!, + token)); + + if (response is not null) + { + if (response.IsAuthenticated) + { + // Clean up the cookie + await HttpContext.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + await HttpContext.SignInAsync(IdentityConstants.ApplicationScheme, response.ClaimsPrincipal!); + + AuthenticationResponse authenticationResult = new AuthenticationResponse() + { + UserName = response.User!.UserName, + AccessToken = response.AccessToken, + RefreshToken = response.RefreshToken + }; + return Ok(authenticationResult); + } + + return Error(Errors.Authentication.InvalidToken()); + } + } + + return Error(Errors.General.InvalidRequest()); + } + + /// + /// Send forgot password link. + /// + /// Email address of the user. + /// ApiResult. + [HttpPost("request-reset-password-token")] + [NoPermissionRequired] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + public async Task RequestResetPasswordToken(string emailAddress) + { + var response = await _userAccessModule.ExecuteCommandAsync(new RequestResetPasswordTokenCommand(emailAddress)); + return response.ToApiResult(); + } + + /// + /// Reset password. + /// + /// Reset password attributes. + /// ApiResult. + [HttpPost("reset-password")] + [NoPermissionRequired] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + public async Task ResetPassword(ResetPasswordRequest resetPassword) + { + var response = await _userAccessModule.ExecuteCommandAsync(new ResetPasswordCommand(resetPassword.Token, resetPassword.EmailAddress, resetPassword.Password)); + return response.ToApiResult(); + } + + [HttpGet("external-login")] + [NoPermissionRequired] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public IResult ExternalLogin(string provider) + { + var properties = new AuthenticationProperties + { + RedirectUri = Url.Action(nameof(ExternalLoginCallback)), + Items = { { "scheme", provider } } + }; + return Results.Challenge(properties, [provider]); + } + + [HttpGet("external-login-callback")] + [NoPermissionRequired] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + public async Task ExternalLoginCallback() + { + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ExternalScheme); + if (result != null) + { + // We really need the external user identifier + var externalUserId = result.Principal?.FindFirstValue("sub") + ?? result.Principal?.FindFirstValue(ClaimTypes.NameIdentifier); + + if (externalUserId is null) + { + return Error(Errors.General.InvalidRequest("Cannot find external user id")); + } + + // Get the provider from the authentication properties which is available from the scheme item + var provider = result.Properties?.Items["scheme"]; + if (provider is null) + { + return Error(Errors.General.InvalidRequest("Missing external provider")); + } + + var emailAddress = result.Principal?.FindFirstValue("email") + ?? result.Principal?.FindFirstValue(ClaimTypes.Email); + if (emailAddress is null) + { + return Error(Errors.General.InvalidRequest("Email address must be provided")); + } + + // Once we have all this we can go ahead an call the external login command + var response = await _userAccessModule.ExecuteCommandAsync(new ExternalAccountLoginCommand(provider, externalUserId, emailAddress, false)); + + if (response != null) + { + if (response.IsAuthenticated) + { + // Clean up the cookie + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + await HttpContext.SignInAsync(IdentityConstants.ApplicationScheme, response.ClaimsPrincipal!); + + var authenticationResult = new AuthenticationResponse() + { + UserName = response.User!.UserName, + AccessToken = response.AccessToken, + RefreshToken = response.RefreshToken + }; + return response.ToApiResult(authenticationResult); + } + + return Error(Errors.Authentication.InvalidToken()); + } + } + + return Error(Errors.General.InvalidRequest()); + } + + [HttpPost("refresh-token")] + [NoPermissionRequired] + public async Task RefreshToken(TokenRequest tokenRequest) + { + var response = await _userAccessModule.ExecuteCommandAsync(new RefreshTokenCommand(tokenRequest.AccessToken, tokenRequest.RefreshToken)); + if (!response.IsSuccess) + { + return FromResponse(response); + } + + var tokenResult = new TokenResponse() + { + AccessToken = response.Value!.AccessToken, + RefreshToken = response.Value!.RefreshToken + }; + return response.ToApiResult(tokenResult); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/Validators/AuthenticationRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/Validators/AuthenticationRequestValidator.cs new file mode 100644 index 000000000..b16d45e1a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/Validators/AuthenticationRequestValidator.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authentication; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Authentication.Validators; + +internal class AuthenticationRequestValidator : AbstractValidator +{ + public AuthenticationRequestValidator() + { + RuleFor(x => x.UserName).CustomNotEmpty(); + RuleFor(x => x.Password).CustomNotEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/Validators/ResetPasswordRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/Validators/ResetPasswordRequestValidator.cs new file mode 100644 index 000000000..4ecbe9680 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/Validators/ResetPasswordRequestValidator.cs @@ -0,0 +1,18 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authentication; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Authentication.Validators; + +internal class ResetPasswordRequestValidator : AbstractValidator +{ + public ResetPasswordRequestValidator() + { + RuleFor(x => x.Token).CustomNotEmpty(); + RuleFor(x => x.EmailAddress).CustomEmailAddress(); + RuleFor(x => x.Password).CustomNotEmpty(); + RuleFor(x => x.ConfirmPassword).CustomNotEmpty(); + + RuleFor(x => x.ConfirmPassword).CustomEqual(x => x.Password); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authorization/AuthorizationController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authorization/AuthorizationController.cs new file mode 100644 index 000000000..23ea81802 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authorization/AuthorizationController.cs @@ -0,0 +1,45 @@ +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using AuthorizationApplication = CompanyName.MyMeetings.Modules.UsersMI.Application.Authorization.GetPermissions; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Authorization; + +[ApiController] +[Route("api/authorization")] +public class AuthorizationController : ApplicationController +{ + private readonly IUserAccessModule _userAccessModule; + + public AuthorizationController(IUserAccessModule userAccessModule) + { + _userAccessModule = userAccessModule; + } + + [HttpGet("permissions")] + [NoPermissionRequired] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + public async Task GetPermissionDirectory() + { + var response = await _userAccessModule.ExecuteQueryAsync(new AuthorizationApplication.GetPermissionsQuery(null)); + if (response.IsSuccess && response.Value is not null) + { + var permissionsResponse = new PermissionsResponse + { + Permissions = response.Value.Select(r => new PermissionResponse + { + Code = r.Code, + Name = r.Name, + Description = r.Description + }).ToList() + }; + return response.ToApiResult(permissionsResponse); + } + + return FromResponse(response); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs new file mode 100644 index 000000000..af33e129c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs @@ -0,0 +1,161 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.AuthenticatorRegistration.GetAuthenticatorKey; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.AuthenticatorRegistration.RegisterAuthenticator; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ChangeEmailAddress; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ChangePassword; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ConfirmEmailAddress; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.GetUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.RequestChangeEmailAddressToken; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.RequestConfirmEmailAddressToken; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.UpdateProfile; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Me; + +[Authorize] +[ApiController] +[Route("api/users/me")] +public class MeController : ApplicationController +{ + private readonly IUserAccessModule _userAccessModule; + private readonly IExecutionContextAccessor _executionContextAccessor; + + public MeController(IUserAccessModule userAccessModule, IExecutionContextAccessor executionContextAccessor) + { + _userAccessModule = userAccessModule; + _executionContextAccessor = executionContextAccessor; + } + + [HttpGet("change-email-address")] + [NoPermissionRequired] + public async Task ChangeEmailAddress(ChangeEmailAddressRequest request) + { + var result = await _userAccessModule.ExecuteCommandAsync(new ChangeEmailAddressCommand(_executionContextAccessor.UserId, request.NewEmailAddress, request.Token)); + if (!result.IsSuccess) + { + return FromResponse(result); + } + + return Ok(); + } + + [HttpPut("change-password")] + [NoPermissionRequired] + public async Task ChangePassword(ChangePasswordRequest request) + { + var result = await _userAccessModule.ExecuteCommandAsync(new ChangePasswordCommand(_executionContextAccessor.UserId, request.CurrentPassword, request.NewPassword)); + if (!result.IsSuccess) + { + return FromResponse(result); + } + + return Ok(); + } + + [HttpPut("confirm-email-address")] + [NoPermissionRequired] + public async Task ConfirmEmailAddress(ConfirmEmailAddressRequest request) + { + var result = await _userAccessModule.ExecuteCommandAsync(new ConfirmEmailAddressCommand(_executionContextAccessor.UserId, request.Token)); + if (!result.IsSuccess) + { + return FromResponse(result); + } + + return Ok(); + } + + [HttpGet("authenticator-key")] + [NoPermissionRequired] + public async Task GetAuthenticatorKey() + { + var result = await _userAccessModule.ExecuteQueryAsync(new GetAuthenticatorKeyQuery(_executionContextAccessor.UserId)); + if (!result.IsSuccess) + { + return FromResponse(result); + } + + return result.ToApiResult(result.Value!); + } + + [HttpGet] + [NoPermissionRequired] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + public async Task GetUserAccount() + { + var result = await _userAccessModule.ExecuteQueryAsync(new GetUserAccountQuery(_executionContextAccessor.UserId)); + if (result.IsSuccess && result.Value is not null) + { + return result.ToApiResult(new UserAccountResponse() + { + Id = result.Value.Id, + Name = result.Value.Name, + FirstName = result.Value.FirstName, + LastName = result.Value.LastName, + UserName = result.Value.UserName ?? string.Empty, + EmailAddress = result.Value.EmailAddress + }); + } + + return FromResponse(result); + } + + [HttpPost("register-authenticator")] + [NoPermissionRequired] + public async Task RegisterAuthenticator(RegisterAuthenticatorRequest request) + { + var result = await _userAccessModule.ExecuteCommandAsync(new RegisterAuthenticatorCommand(_executionContextAccessor.UserId, request.Code)); + if (!result.IsSuccess) + { + return FromResponse(result); + } + + return Ok(); + } + + [HttpGet("request-change-email-address-token")] + [NoPermissionRequired] + public async Task RequestChangeEmailAddressToken(RequestChangeEmailAddressTokenRequest request) + { + var result = await _userAccessModule.ExecuteCommandAsync(new RequestChangeEmailAddressTokenCommand(_executionContextAccessor.UserId, request.NewEmailAddress)); + if (!result.IsSuccess) + { + return FromResponse(result); + } + + return Ok(); + } + + [HttpGet("request-confirm-email-address-token")] + [NoPermissionRequired] + public async Task RequestConfirmEmailAddressToken() + { + var result = await _userAccessModule.ExecuteCommandAsync(new RequestConfirmEmailAddressTokenCommand(_executionContextAccessor.UserId)); + if (!result.IsSuccess) + { + return FromResponse(result); + } + + return Ok(); + } + + [HttpPut("update-profile")] + [NoPermissionRequired] + public async Task UpdateProfile(UpdateProfileRequest request) + { + var result = await _userAccessModule.ExecuteCommandAsync(new UpdateProfileCommand(_executionContextAccessor.UserId, request.Login, request.Name, request.FirstName, request.LastName)); + if (!result.IsSuccess) + { + return FromResponse(result); + } + + return Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/ChangeEmailAddressRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/ChangeEmailAddressRequestValidator.cs new file mode 100644 index 000000000..684f5a2a2 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/ChangeEmailAddressRequestValidator.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Me.Validators; + +internal class ChangeEmailAddressRequestValidator : AbstractValidator +{ + public ChangeEmailAddressRequestValidator() + { + RuleFor(x => x.Token).CustomNotEmpty(); + RuleFor(x => x.NewEmailAddress).CustomEmailAddress(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/ConfirmEmailAddressRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/ConfirmEmailAddressRequestValidator.cs new file mode 100644 index 000000000..ab25c518a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/ConfirmEmailAddressRequestValidator.cs @@ -0,0 +1,13 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Me.Validators; + +internal class ConfirmEmailAddressRequestValidator : AbstractValidator +{ + public ConfirmEmailAddressRequestValidator() + { + RuleFor(x => x.Token).CustomNotEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/RequestChangeEmailAddressTokenRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/RequestChangeEmailAddressTokenRequestValidator.cs new file mode 100644 index 000000000..77fbeb1fb --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/RequestChangeEmailAddressTokenRequestValidator.cs @@ -0,0 +1,13 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Me.Validators; + +internal class RequestChangeEmailAddressTokenRequestValidator : AbstractValidator +{ + public RequestChangeEmailAddressTokenRequestValidator() + { + RuleFor(x => x.NewEmailAddress).CustomEmailAddress(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/UpdateProfileRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/UpdateProfileRequestValidator.cs new file mode 100644 index 000000000..dde97b898 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/Validators/UpdateProfileRequestValidator.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Me.Validators; + +internal class UpdateProfileRequestValidator : AbstractValidator +{ + public UpdateProfileRequestValidator() + { + RuleFor(x => x.Login).CustomNotEmpty(); + RuleFor(x => x.Name).CustomNotEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/RolesController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/RolesController.cs new file mode 100644 index 000000000..2e455fdc7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/RolesController.cs @@ -0,0 +1,148 @@ +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.CreateRole; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.DeleteRole; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRolePermissions; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.RenameRole; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.SetRolePermissions; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using RolesApplication = CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRoles; +using UserRoleContracts = CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Roles; + +[Route("api/users/roles")] +public class RolesController : ApplicationController +{ + private readonly IUserAccessModule _userAccessModule; + + public RolesController(IUserAccessModule userAccessModule) + { + _userAccessModule = userAccessModule; + } + + [HttpGet] + [HasPermission(UsersPermissions.GetRoles)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task GetRoleDirectory() + { + var response = await _userAccessModule.ExecuteQueryAsync(new RolesApplication.Directory.GetRolesQuery()); + if (response.IsSuccess && response.Value is not null) + { + var rolesResponse = new RolesResponse + { + Roles = response.Value.Select(r => new RoleResponse + { + Id = r.Id, + Name = r.Name + }).ToList() + }; + + return response.ToApiResult(rolesResponse); + } + + return FromResponse(response); + } + + [HttpGet("{roleId}")] + [HasPermission(UsersPermissions.GetRoles)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task GetRole(Guid roleId) + { + var response = await _userAccessModule.ExecuteQueryAsync(new RolesApplication.ById.GetRolesQuery(roleId)); + if (response.IsSuccess && response.Value is not null) + { + var roleResponse = new RoleResponse + { + Id = response.Value.Id, + Name = response.Value.Name + }; + + return response.ToApiResult(roleResponse); + } + + return FromResponse(response); + } + + [HttpPost] + [HasPermission(UsersPermissions.AddRole)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task AddRole([FromBody] UserRoleContracts.AddRoleRequest request) + { + var response = await _userAccessModule.ExecuteCommandAsync(new CreateRoleCommand(request.Name, request.Permissions)); + return response.ToApiResult(); + } + + [HttpPatch("{roleId}/rename")] + [HasPermission(UsersPermissions.RenameRole)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task RenameRole(Guid roleId, [FromBody] UserRoleContracts.RenameRoleRequest request) + { + var response = await _userAccessModule.ExecuteCommandAsync(new RenameRoleCommand(roleId, request.Name)); + return response.ToApiResult(); + } + + [HttpDelete("{roleId}")] + [HasPermission(UsersPermissions.DeleteRole)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task DeleteRole(Guid roleId) + { + var response = await _userAccessModule.ExecuteCommandAsync(new DeleteRoleCommand(roleId)); + return response.ToApiResult(); + } + + [HttpGet("{roleId}/permissions")] + [HasPermission(UsersPermissions.GetRolePermissions)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task GetRolePermissions(Guid roleId) + { + var response = await _userAccessModule.ExecuteQueryAsync(new GetRolePermissionsQuery(roleId)); + if (response.IsSuccess && response.Value is not null) + { + var permissionsResponse = new PermissionsResponse + { + Permissions = response.Value.Select(r => new PermissionResponse + { + Code = r.Code, + Name = r.Name, + Description = r.Description + }).ToList() + }; + return response.ToApiResult(permissionsResponse); + } + + return FromResponse(response); + } + + [HttpPatch("{roleId}/permissions")] + [HasPermission(UsersPermissions.SetRolePermissions)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task SetRolePermissions(Guid roleId, [FromBody] UserRoleContracts.SetRolePermissionsRequest request) + { + var response = await _userAccessModule.ExecuteCommandAsync(new SetRolePermissionsCommand(roleId, request.Permissions)); + return response.ToApiResult(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/AddRoleRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/AddRoleRequestValidator.cs new file mode 100644 index 000000000..3f63199d2 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/AddRoleRequestValidator.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Roles.Validators; + +internal class AddRoleRequestValidator : AbstractValidator +{ + public AddRoleRequestValidator() + { + RuleFor(x => x.Name).CustomNotEmpty(); + RuleFor(x => x.Permissions).CustomNotEmpty(); + } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/RenameRoleRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/RenameRoleRequestValidator.cs new file mode 100644 index 000000000..c6dd014a0 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/RenameRoleRequestValidator.cs @@ -0,0 +1,13 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Roles.Validators; + +internal class RenameRoleRequestValidator : AbstractValidator +{ + public RenameRoleRequestValidator() + { + RuleFor(x => x.Name).CustomNotEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/SetRolePermissionsRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/SetRolePermissionsRequestValidator.cs new file mode 100644 index 000000000..9583f26f1 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/Validators/SetRolePermissionsRequestValidator.cs @@ -0,0 +1,13 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Roles.Validators; + +internal class SetRolePermissionsRequestValidator : AbstractValidator +{ + public SetRolePermissionsRequestValidator() + { + RuleFor(x => x.Permissions).CustomNotEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/UsersController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/UsersController.cs new file mode 100644 index 000000000..06b81a743 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/UsersController.cs @@ -0,0 +1,218 @@ +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Authorization.GetPermissions; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.ChangeEmailAddress; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserRoles; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.SetUserPermissions; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.SetUserRoles; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.UnlockUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.UpdateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using UsersApplication = CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserAccounts; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Users; + +[Route("api/users/accounts")] +public class UsersController : ApplicationController +{ + private readonly IUserAccessModule _userAccessModule; + + public UsersController(IUserAccessModule userAccessModule) + { + _userAccessModule = userAccessModule; + } + + /// + /// Gets the user directory. + /// + /// List of users. + [HttpGet] + [HasPermission(UsersPermissions.GetUsers)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task GetUserAccountDirectory() + { + var response = await _userAccessModule.ExecuteQueryAsync(new UsersApplication.Directory.GetUserAccountsQuery()); + if (response.IsSuccess && response.Value is not null) + { + var userAccountsResponse = new UserAccountsResponse + { + UserAccounts = response.Value.Select(r => new UserAccountResponse + { + Id = r.Id, + Name = r.Name, + FirstName = r.FirstName, + LastName = r.LastName, + Login = r.UserName, + NormalizedLogin = r.NormalizedUserName, + LockoutEnd = r.LockoutEnd, + LockoutEnabled = r.LockoutEnabled, + AccessFailedCount = r.AccessFailedCount, + TwoFactorEnabled = r.TwoFactorEnabled, + PhoneNumber = r.PhoneNumber, + PhoneNumberConfirmed = r.PhoneNumberConfirmed, + Email = r.Email, + NormalizedEmail = r.NormalizedEmail, + EmailConfirmed = r.EmailConfirmed + }).ToList() + }; + + return response.ToApiResult(userAccountsResponse); + } + + return FromResponse(response); + } + + [HttpGet("{userId}")] + [HasPermission(UsersPermissions.GetUsers)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task GetUserAccount(Guid userId) + { + var response = await _userAccessModule.ExecuteQueryAsync(new UsersApplication.ById.GetUserAccountsQuery(userId)); + if (response.IsSuccess && response.Value is not null) + { + var userAccountResponse = new UserAccountResponse + { + Id = response.Value.Id, + Name = response.Value.Name, + FirstName = response.Value.FirstName, + LastName = response.Value.LastName, + Login = response.Value.UserName, + NormalizedLogin = response.Value.NormalizedUserName, + LockoutEnd = response.Value.LockoutEnd, + LockoutEnabled = response.Value.LockoutEnabled, + AccessFailedCount = response.Value.AccessFailedCount, + TwoFactorEnabled = response.Value.TwoFactorEnabled, + PhoneNumber = response.Value.PhoneNumber, + PhoneNumberConfirmed = response.Value.PhoneNumberConfirmed, + Email = response.Value.Email, + NormalizedEmail = response.Value.NormalizedEmail, + EmailConfirmed = response.Value.EmailConfirmed + }; + + return response.ToApiResult(userAccountResponse); + } + + return FromResponse(response); + } + + [HttpPut("{userId}")] + [HasPermission(UsersPermissions.UpdateUserAccount)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task UpdateUserAccount(Guid userId, UpdateUserAccountRequest request) + { + var response = await _userAccessModule.ExecuteCommandAsync(new UpdateUserAccountCommand(userId, request.Name, request.FirstName, request.LastName)); + return response.ToApiResult(); + } + + [HttpPut("{userId}/unlock")] + [HasPermission(UsersPermissions.UnlockUserAccount)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task UnlockUserAccount(Guid userId) + { + var response = await _userAccessModule.ExecuteCommandAsync(new UnlockUserAccountCommand(userId)); + return response.ToApiResult(); + } + + [HttpGet("{userId}/roles")] + [HasPermission(UsersPermissions.GetUserRoles)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task GetUserRoles(Guid userId) + { + var response = await _userAccessModule.ExecuteQueryAsync(new GetUserRolesQuery(userId)); + if (response.IsSuccess && response.Value is not null) + { + var userRolesResponse = new RolesResponse + { + Roles = response.Value.Select(r => new RoleResponse + { + Id = r.Id, + Name = r.Name + }).ToList() + }; + + return response.ToApiResult(userRolesResponse); + } + + return FromResponse(response); + } + + [HttpPut("{userId}/roles")] + [HasPermission(UsersPermissions.SetUserRoles)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task SetUserRoles(Guid userId, SetUserRolesRequest request) + { + var response = await _userAccessModule.ExecuteCommandAsync(new SetUserRolesCommand(userId, request.RoleIds)); + return response.ToApiResult(); + } + + [HttpGet("{userId}/permissions")] + [HasPermission(UsersPermissions.GetUserPermissions)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task GetUserPermissions(Guid userId) + { + var response = await _userAccessModule.ExecuteQueryAsync(new GetPermissionsQuery(userId)); + if (response.IsSuccess && response.Value is not null) + { + var userPermissionsResponse = new PermissionsResponse + { + Permissions = response.Value.Select(r => new PermissionResponse + { + Code = r.Code, + Name = r.Name, + Description = r.Description + }).ToList() + }; + + return response.ToApiResult(userPermissionsResponse); + } + + return FromResponse(response); + } + + [HttpPut("{userId}/permissions")] + [HasPermission(UsersPermissions.SetUserPermissions)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task SetUserPermissions(Guid userId, [FromBody] SetUserPermissionsRequest request) + { + var response = await _userAccessModule.ExecuteCommandAsync(new SetUserPermissionsCommand(userId, request.Permissions)); + return response.ToApiResult(); + } + + [HttpPut("{userId}/change-email-address")] + [HasPermission(UsersPermissions.ChangeUserEmailAddress)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] + public async Task ChangeUserEmailAddress(Guid userId, ChangeUserEmailAddressRequest request) + { + var response = await _userAccessModule.ExecuteCommandAsync(new ChangeUserEmailAddressCommand(userId, request.NewEmailAddress)); + return response.ToApiResult(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/ConfirmEmailAddressRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/ConfirmEmailAddressRequestValidator.cs new file mode 100644 index 000000000..d3145c22c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/ConfirmEmailAddressRequestValidator.cs @@ -0,0 +1,13 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Users.Validators; + +internal class ConfirmEmailAddressRequestValidator : AbstractValidator +{ + public ConfirmEmailAddressRequestValidator() + { + RuleFor(x => x.Token).CustomNotEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/SetUserPermissionsRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/SetUserPermissionsRequestValidator.cs new file mode 100644 index 000000000..e22251ed3 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/SetUserPermissionsRequestValidator.cs @@ -0,0 +1,13 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Users.Validators; + +internal class SetUserPermissionsRequestValidator : AbstractValidator +{ + public SetUserPermissionsRequestValidator() + { + RuleFor(x => x.Permissions).CustomNotNull(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/SetUserRolesRequestValidator.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/SetUserRolesRequestValidator.cs new file mode 100644 index 000000000..9aa524b29 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/Validators/SetUserRolesRequestValidator.cs @@ -0,0 +1,13 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Users; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Users.Validators; + +internal class SetUserRolesRequestValidator : AbstractValidator +{ + public SetUserRolesRequestValidator() + { + RuleFor(x => x.RoleIds).CustomNotNull(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/UsersPermissions.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/UsersPermissions.cs new file mode 100644 index 000000000..58022a584 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/UsersPermissions.cs @@ -0,0 +1,28 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints; + +internal class UsersPermissions +{ + // Me + // No permissions needed for these endpoints. Just check if the user is authenticated. + + // Users + public const string GetUsers = "Users.GetUsers"; + public const string UpdateUserAccount = "Users.UpdateUserAccount"; + public const string UnlockUserAccount = "Users.UnlockUserAccount"; + public const string ConfirmEmailAddress = "Users.ConfirmEmailAddress"; + public const string GetAuthenticatorKey = "Users.GetAuthenticatorKey"; + public const string RegisterAuthenticator = "Users.RegisterAuthenticator"; + public const string GetUserRoles = "Users.GetUserRoles"; + public const string SetUserRoles = "Users.SetUserRoles"; + public const string GetUserPermissions = "Users.GetUserPermissions"; + public const string SetUserPermissions = "Users.SetUserPermissions"; + public const string ChangeUserEmailAddress = "Users.ChangeUserEmailAddress"; + + // Roles + public const string GetRoles = "Users.GetRoles"; + public const string AddRole = "Users.AddRole"; + public const string RenameRole = "Users.RenameRole"; + public const string DeleteRole = "Users.DeleteRole"; + public const string GetRolePermissions = "Users.GetRolePermissions"; + public const string SetRolePermissions = "Users.SetRolePermissions"; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/ErrorMapper.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/ErrorMapper.cs new file mode 100644 index 000000000..4ec2793b6 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/ErrorMapper.cs @@ -0,0 +1,89 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi; + +public static class ErrorMapper +{ + public static IDictionary> Translate(this Error error) + { + var errors = new List + { + error + }; + return errors.Translate(); + } + + public static IDictionary> Translate(this IEnumerable errors) + => errors.ToErrorMessages().Wrap(); + + public static IDictionary> ToErrorMessages(this IDictionary> errors) + { + var errorMessages = new Dictionary>(); + foreach (var keyValue in errors) + { + errorMessages.Add(keyValue.Key, keyValue.Value.ToErrorMessages()); + } + + return errorMessages; + } + + public static IEnumerable ToErrorMessages(this IEnumerable errors) + { + var errorMessages = new List(); + foreach (var error in errors) + { + errorMessages.AddRange(error.ToErrorMessages()); + } + + return errorMessages; + } + + public static IEnumerable ToErrorMessages(this Error error, List? errorMessages = null) + { + if (error is null) + { + throw new ArgumentNullException(nameof(error)); + } + + if (errorMessages is null) + { + errorMessages = new List(); + } + + if (!string.IsNullOrEmpty(error.Code + error.Message)) + { + errorMessages.Add(error.ToErrorMessage()); + } + + if (error.Errors is not null) + { + foreach (var subError in error.Errors) + { + subError.ToErrorMessages(errorMessages); + } + } + + return errorMessages; + } + + private static IDictionary> Wrap(this IEnumerable errors) + { + if (errors.Count() <= 0) + { + return new Dictionary>(); + } + + return new Dictionary> { { string.Empty, errors } }; + } + + private static ErrorMessage ToErrorMessage(this Error error) + { + if (error is null) + { + throw new ArgumentNullException(nameof(error)); + } + + return new ErrorMessage(error.Code, error.Message); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/ResultToApiResultExtensions.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/ResultToApiResultExtensions.cs new file mode 100644 index 000000000..9f4aef590 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/ResultToApiResultExtensions.cs @@ -0,0 +1,290 @@ +using System.Net; +using System.Text; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi; + +public static class ResultToApiResultExtensions +{ + public static IResult ToApiResult(this Result result) + { + return result.Status switch + { + ResultStatus.Ok => Results.Ok(result), + ResultStatus.NotFound => Results.NotFound(result), + ResultStatus.Unauthorized => Results.Unauthorized(), + ResultStatus.Forbidden => Results.Forbid(), + ResultStatus.Invalid => Results.BadRequest(result), + ResultStatus.Error => Results.UnprocessableEntity(result), + ResultStatus.Conflict => Results.Conflict(result), + ResultStatus.Created => Results.Created(), + ResultStatus.NoContent => Results.NoContent(), + _ => throw new NotSupportedException($"Result {result.Status} conversion is not supported."), + }; + } + + public static IActionResult ToActionResult(this Result result) + { + return InternalActionResult.FromResult(result); + } +} + +internal class InternalActionResult : IActionResult +{ + private readonly object? _result; + private readonly HttpStatusCode _statusCode; + + private InternalActionResult(HttpStatusCode statusCode) + { + _statusCode = statusCode; + } + + private InternalActionResult(object result, HttpStatusCode statusCode) + : this(statusCode) + { + _result = result; + } + + public Task ExecuteResultAsync(ActionContext context) + { + var result = new ObjectResult(_result) + { + StatusCode = (int)_statusCode + }; + + return result.ExecuteResultAsync(context); + } + + public static IActionResult FromResult(Result result) + { + return result.Status switch + { + ResultStatus.Ok => new InternalActionResult(result, HttpStatusCode.OK), + ResultStatus.NotFound => new InternalActionResult(result, HttpStatusCode.NotFound), + ResultStatus.Unauthorized => new InternalActionResult(result, HttpStatusCode.Unauthorized), + ResultStatus.Forbidden => new InternalActionResult(result, HttpStatusCode.Forbidden), + ResultStatus.Error => new InternalActionResult(result, HttpStatusCode.BadRequest), + ResultStatus.Conflict => new InternalActionResult(result, HttpStatusCode.Conflict), + ResultStatus.Created => new InternalActionResult(HttpStatusCode.Created), + ResultStatus.NoContent => new InternalActionResult(HttpStatusCode.NoContent), + _ => throw new NotSupportedException($"Result {result.GetType()} conversion is not supported."), + }; + } +} + +internal static class ResponseToApiResultExtensions +{ + /// + /// Convert an AppResults.Result to a Result. + /// + /// The AppResults.Result to convert. + /// The Result. + public static IResult ToApiResult(this Application.Contracts.Results.Result result) + { + return result.ToResult().ToApiResult(); + } + + /// + /// Convert an AppResults.Result to a Result. + /// + /// The value type being returned. + /// The AppResults.Result to convert. + /// The value being returned. + /// The Result. + public static IResult ToApiResult(this Application.Contracts.Results.Result result, T value) + { + return result.ToResult(value).ToApiResult(); + } + + /// + /// Convert an AppResults.Result to a Result. + /// + /// The value being returned. + /// The AppResults.Result to convert. + /// The Result. + public static IResult ToApiResult(this Application.Contracts.Results.Result result) + { + return result.ToResult().ToApiResult(); + } +} + +internal static class ResponseToResultExtensions +{ + /// + /// Convert an AppResults.Result to a Result. + /// + /// The AppResults.Result to convert. + /// The Result. + public static Result ToResult(this Application.Contracts.Results.IResult result) + { + return result.Status switch + { + Application.Contracts.Results.ResultStatus.Ok => Result.Ok(), + Application.Contracts.Results.ResultStatus.NotFound => Result.NotFound(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Unauthorized => Result.Unauthorized(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Forbidden => Result.Forbidden(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Invalid => Result.Invalid(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Error => Result.Error(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Conflict => Result.Conflict(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Created => Result.Created(), + Application.Contracts.Results.ResultStatus.NoContent => Result.NoContent(), + _ => throw new NotSupportedException($"AppResults.Result {result.Status} conversion is not supported."), + }; + } + + /// + /// Convert an AppResults.Result to a Result. + /// + /// The value type being returned. + /// The AppResults.Result to convert. + /// The value being returned. + /// The Result. + public static Result ToResult(this Application.Contracts.Results.IResult result, T value) + { + return result.Status switch + { + Application.Contracts.Results.ResultStatus.Ok => Result.Ok(value), + Application.Contracts.Results.ResultStatus.NotFound => Result.NotFound(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Unauthorized => Result.Unauthorized(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Forbidden => Result.Forbidden(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Invalid => Result.Invalid(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Error => Result.Error(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Conflict => Result.Conflict(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Created => Result.Created(value), + Application.Contracts.Results.ResultStatus.NoContent => Result.NoContent(result.Errors.Translate()), + _ => throw new NotSupportedException($"AppResults.Result {result.Status} conversion is not supported."), + }; + } + + /// + /// Convert an AppResults.Result to a Result. + /// + /// The value being returned. + /// The AppResults.Result to convert. + /// The Result. + public static Result ToResult(this Application.Contracts.Results.IResult result) + { + return result.Status switch + { + Application.Contracts.Results.ResultStatus.Ok => Result.Ok(), + Application.Contracts.Results.ResultStatus.NotFound => Result.NotFound(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Unauthorized => Result.Unauthorized(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Forbidden => Result.Forbidden(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Invalid => Result.Invalid(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Error => Result.Error(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Conflict => Result.Conflict(result.Errors.Translate()), + Application.Contracts.Results.ResultStatus.Created => Result.Created(), + Application.Contracts.Results.ResultStatus.NoContent => Result.NoContent(result.Errors.Translate()), + _ => throw new NotSupportedException($"AppResults.Result {result.Status} conversion is not supported."), + }; + } +} + +internal static class ResponseExtensions +{ + public static IResult ToApiResult(this Application.Contracts.Results.IResult result) => result.ConvertToApiResult(); + + public static IResult ToApiResult(this Application.Contracts.Results.IResult result, object value) => result.ConvertToApiResult(value); + + public static IResult ToApiResult(this Application.Contracts.Results.IResult result) => result.ConvertToApiResult(); + + internal static IResult ConvertToApiResult(this Application.Contracts.Results.IResult result, object? value = null) + { + return result.Status switch + { + Application.Contracts.Results.ResultStatus.Ok => typeof(Application.Contracts.Results.Result).IsInstanceOfType(result) + ? Microsoft.AspNetCore.Http.Results.Ok() + : Microsoft.AspNetCore.Http.Results.Ok(value ?? result.GetValue()), + Application.Contracts.Results.ResultStatus.NotFound => NotFoundEntity(result), + Application.Contracts.Results.ResultStatus.Unauthorized => Microsoft.AspNetCore.Http.Results.Unauthorized(), + Application.Contracts.Results.ResultStatus.Forbidden => Microsoft.AspNetCore.Http.Results.Forbid(), + Application.Contracts.Results.ResultStatus.Invalid => Microsoft.AspNetCore.Http.Results.BadRequest(result.Errors), + Application.Contracts.Results.ResultStatus.Error => UnprocessableEntity(result), + Application.Contracts.Results.ResultStatus.Conflict => ConflictEntity(result), + _ => throw new NotSupportedException($"AppResults.Result {result.Status} conversion is not supported."), + }; + } + + private static IResult UnprocessableEntity(Application.Contracts.Results.IResult result) + { + StringBuilder stringBuilder = new("Next error(s) occurred:"); + foreach (Error error in result.Errors) + { + stringBuilder.Append("* ").Append(error).AppendLine(); + } + + return Microsoft.AspNetCore.Http.Results.UnprocessableEntity( + new ProblemDetails { Title = "Something went wrong.", Detail = stringBuilder.ToString() }); + } + + private static IResult NotFoundEntity(Application.Contracts.Results.IResult result) + { + StringBuilder stringBuilder = new("Next error(s) occurred:"); + if (result.Errors.Any()) + { + foreach (Error error in result.Errors) + { + stringBuilder.Append("* ").Append(error).AppendLine(); + } + + return Microsoft.AspNetCore.Http.Results.NotFound( + new ProblemDetails { Title = "Resource not found.", Detail = stringBuilder.ToString() }); + } + + return Microsoft.AspNetCore.Http.Results.NotFound(); + } + + private static IResult ConflictEntity(Application.Contracts.Results.IResult result) + { + StringBuilder stringBuilder = new("Next error(s) occurred:"); + if (result.Errors.Any()) + { + foreach (Error error in result.Errors) + { + stringBuilder.Append("* ").Append(error).AppendLine(); + } + + return Microsoft.AspNetCore.Http.Results.Conflict( + new ProblemDetails { Title = "There was a conflict.", Detail = stringBuilder.ToString() }); + } + + return Microsoft.AspNetCore.Http.Results.Conflict(); + } + + private static IResult CriticalEntity(Application.Contracts.Results.IResult result) + { + StringBuilder stringBuilder = new("Next error(s) occurred:"); + if (result.Errors.Any()) + { + foreach (Error error in result.Errors) + { + stringBuilder.Append("* ").Append(error).AppendLine(); + } + + return Microsoft.AspNetCore.Http.Results.Problem( + new ProblemDetails { Title = "Something went wrong.", Detail = stringBuilder.ToString(), Status = 500 }); + } + + return Microsoft.AspNetCore.Http.Results.StatusCode(500); + } + + private static IResult UnavailableEntity(Application.Contracts.Results.IResult result) + { + StringBuilder stringBuilder = new("Next error(s) occurred:"); + if (result.Errors.Any()) + { + foreach (Error error in result.Errors) + { + stringBuilder.Append("* ").Append(error).AppendLine(); + } + + return Microsoft.AspNetCore.Http.Results.Problem( + new ProblemDetails { Title = "Service unavailable.", Detail = stringBuilder.ToString(), Status = 503 }); + } + + return Microsoft.AspNetCore.Http.Results.StatusCode(503); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommand.cs new file mode 100644 index 000000000..24cd3744d --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommand.cs @@ -0,0 +1,16 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login; + +public class AccountLoginCommand : CommandBase +{ + public AccountLoginCommand(string login, string password) + { + Login = login; + Password = password; + } + + public string Login { get; } + + public string Password { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommandHandler.cs new file mode 100644 index 000000000..22c6a84cd --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommandHandler.cs @@ -0,0 +1,161 @@ +using System.Security.Claims; +using CompanyName.MyMeetings.BuildingBlocks.Application.Emails; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login; + +internal class AccountLoginCommandHandler : ICommandHandler +{ + private readonly IEmailSender _emailSender; + private readonly UserManager _userManager; + private readonly IdentityOptions _identityOptions; + private readonly ITokenClaimsService _tokenClaimsService; + private readonly IUserClaimsPrincipalFactory _userClaimsPrincipalFactory; + + public AccountLoginCommandHandler( + IEmailSender emailSender, + UserManager userManager, + ITokenClaimsService tokenClaimsService, + IUserClaimsPrincipalFactory userClaimsPrincipalFactory) + { + _emailSender = emailSender; + _userManager = userManager; + _identityOptions = _userManager.Options; + _tokenClaimsService = tokenClaimsService; + _userClaimsPrincipalFactory = userClaimsPrincipalFactory; + } + + public async Task Handle(AccountLoginCommand request, CancellationToken cancellationToken) + { + var response = new AuthenticationResult(); + + var user = await _userManager.FindByNameAsync(request.Login); + if (user != null && !await _userManager.IsLockedOutAsync(user)) + { + if (await _userManager.HasPasswordAsync(user)) + { + var passwordCheckSucceeded = await _userManager.CheckPasswordAsync(user, request.Password); + if (passwordCheckSucceeded) + { + // Reset failed account login attempts + await _userManager.ResetAccessFailedCountAsync(user); + + // Check if user is allowed to login + if (_identityOptions.SignIn.RequireConfirmedEmail && !await _userManager.IsEmailConfirmedAsync(user)) + { + if (!string.IsNullOrEmpty(user.Email)) + { + var emailMessage = new EmailMessage( + user.Email!, + "MyMeetings user account not validated!", + $@"You cannot log in with this user account because the e-mail address you have entered has not yet been validated.\n\nUser name: {user.UserName}\nEmail: {user.Email}"); + + await _emailSender.SendEmail(emailMessage); + } + + response.AddError(Errors.UserAccess.EmailNotConfirmed); + } + else + { + if (await _userManager.GetTwoFactorEnabledAsync(user)) + { + var validProviders = await _userManager.GetValidTwoFactorProvidersAsync(user); + + if (validProviders.Contains(_userManager.Options.Tokens.AuthenticatorTokenProvider)) + { + response.StoreTwoFactorAuthentication(Generate2FA(user.Id, _userManager.Options.Tokens.AuthenticatorTokenProvider)); + } + else if (validProviders.Contains("Email")) + { + var token = await _userManager.GenerateTwoFactorTokenAsync(user, "Email"); + + var emailMessage = new EmailMessage( + user.Email, + "MyMeetings Token", + "Here is your token which you need for the registration.\n\nToken: {token}"); + + await _emailSender.SendEmail(emailMessage); + + response.StoreTwoFactorAuthentication(Generate2FA(user.Id, "Email")); + } + } + else + { + var userDto = new UserDto() + { + Id = user.Id, + Name = $"{user.Name}".Trim(), + UserName = user.UserName, + Email = user.Email, + Claims = _tokenClaimsService.GetUserClaims(user) + }; + + var tokens = _tokenClaimsService.GenerateTokens(user); + var principal = await _userClaimsPrincipalFactory.CreateAsync(user); + + response.SetAuthenticatedUser(userDto, tokens.AccessToken, tokens.RefreshToken, principal); + } + } + } + else + { + // Increase the failed account login count + await _userManager.AccessFailedAsync(user); + if (await _userManager.IsLockedOutAsync(user)) + { + await SendNotificationEmailAsync(user); + } + + response.AddError(Errors.UserAccess.InvalidUserNameOrPassword); + } + } + else + { + var emailMessage = new EmailMessage( + user.Email, + "MyMeetings user account not enabled!", + $"You cannot log in with this user account because no password has been defined for this account.\n\nUser name: {user.UserName}\nEmail: {user.Email}"); + + await _emailSender.SendEmail(emailMessage); + + response.AddError(Errors.UserAccess.LoginNotAllowed); + } + } + else + { + // Instead of returning an "User not found." error message return an more generic one. + response.AddError(Errors.UserAccess.InvalidUserNameOrPassword); + } + + return response; + } + + private static ClaimsPrincipal Generate2FA(Guid userId, string provider) + { + var identity = new ClaimsIdentity( + new List + { + new Claim("sub", userId.ToString()), + new Claim("amr", provider) // Authentication method reference + }, + IdentityConstants.TwoFactorUserIdScheme); + + return new ClaimsPrincipal(identity); + } + + private async Task SendNotificationEmailAsync(ApplicationUser user) + { + if (!string.IsNullOrEmpty(user.Email)) + { + var emailMessage = new EmailMessage( + user.Email, + "MyMeetings user account locked!", + $"Your user account has been blocked for security reasons.\n\nUser name: {user.UserName}\n\nThe account has been locked for {_identityOptions.Lockout.DefaultLockoutTimeSpan.Minutes}minutes because the password was entered incorrectly {_identityOptions.Lockout.MaxFailedAccessAttempts} times in a row. After this time, the lock is automatically removed.\n\nIf you are not responsible for locking the user account, please contact the administrator."); + + await _emailSender.SendEmail(emailMessage); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommandValidator.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommandValidator.cs new file mode 100644 index 000000000..251cef33d --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountLoginCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login; + +internal class AccountLoginCommandValidator : AbstractValidator +{ + public AccountLoginCommandValidator() + { + RuleFor(x => x.Login).CustomNotEmpty(); + RuleFor(x => x.Password).CustomNotEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountTwoFactorLoginCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountTwoFactorLoginCommand.cs new file mode 100644 index 000000000..0fb578a0f --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountTwoFactorLoginCommand.cs @@ -0,0 +1,19 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login; + +public class AccountTwoFactorLoginCommand : CommandBase +{ + public AccountTwoFactorLoginCommand(Guid userId, string provider, string token) + { + UserId = userId; + Provider = provider; + Token = token; + } + + public Guid UserId { get; } + + public string Provider { get; } + + public string Token { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountTwoFactorLoginCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountTwoFactorLoginCommandHandler.cs new file mode 100644 index 000000000..a62418d02 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AccountTwoFactorLoginCommandHandler.cs @@ -0,0 +1,56 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login; + +internal class AccountTwoFactorLoginCommandHandler : ICommandHandler +{ + private readonly ITokenClaimsService _tokenClaimsService; + private readonly UserManager _userManager; + private readonly IUserClaimsPrincipalFactory _userClaimsPrincipalFactory; + + public AccountTwoFactorLoginCommandHandler( + UserManager userManager, + ITokenClaimsService tokenClaimsService, + IUserClaimsPrincipalFactory userClaimsPrincipalFactory) + { + _userManager = userManager; + _tokenClaimsService = tokenClaimsService; + _userClaimsPrincipalFactory = userClaimsPrincipalFactory; + } + + public async Task Handle(AccountTwoFactorLoginCommand request, CancellationToken cancellationToken) + { + var response = new AuthenticationResult(); + + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + response.AddError(Errors.General.NotFound(request.UserId, "User")); + return response; + } + + var isValid = await _userManager.VerifyTwoFactorTokenAsync(user, request.Provider, request.Token); + + if (isValid) + { + var userDto = new UserDto() + { + Id = user.Id, + Name = $"{user.FirstName} {user.LastName}", + UserName = user.UserName, + Email = user.Email, + Claims = _tokenClaimsService.GetUserClaims(user) + }; + + var tokens = _tokenClaimsService.GenerateTokens(user); + var principal = await _userClaimsPrincipalFactory.CreateAsync(user); + + response.SetAuthenticatedUser(userDto, tokens.AccessToken, tokens.RefreshToken, principal); + } + + return response; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AuthenticationResult.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AuthenticationResult.cs new file mode 100644 index 000000000..a234d753f --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/AuthenticationResult.cs @@ -0,0 +1,62 @@ +using System.Security.Claims; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login; + +public class AuthenticationResult : Result +{ + public AuthenticationResult() + { + RequiresTwoFactor = false; + } + + public bool IsAuthenticated => ClaimsPrincipal is not null; + + public string? AccessToken { get; private set; } + + public string? RefreshToken { get; private set; } + + public bool RequiresTwoFactor { get; private set; } + + public UserDto? User { get; private set; } + + public ClaimsPrincipal? ClaimsPrincipal { get; private set; } + + public void SetAuthenticatedUser(UserDto user, string accessToken, string refreshToken) + { + ArgumentNullException.ThrowIfNull(user, nameof(user)); + + User = user; + AccessToken = accessToken; + RefreshToken = refreshToken; + } + + public void SetAuthenticatedUser(UserDto user, ClaimsPrincipal claimsPrincipal) + { + ArgumentNullException.ThrowIfNull(user, nameof(user)); + ArgumentNullException.ThrowIfNull(claimsPrincipal, nameof(claimsPrincipal)); + + User = user; + ClaimsPrincipal = claimsPrincipal; + } + + public void SetAuthenticatedUser(UserDto user, string accessToken, string refreshToken, ClaimsPrincipal claimsPrincipal) + { + SetAuthenticatedUser(user, accessToken, refreshToken); + + ArgumentNullException.ThrowIfNull(claimsPrincipal, nameof(claimsPrincipal)); + + ClaimsPrincipal = claimsPrincipal; + } + + public void StoreTwoFactorAuthentication(ClaimsPrincipal claimsPrincipal) + { + if (claimsPrincipal == null) + { + throw new ArgumentNullException(nameof(claimsPrincipal), "Missing the claims principal."); + } + + RequiresTwoFactor = true; + ClaimsPrincipal = claimsPrincipal; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/External/ExternalAccountLoginCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/External/ExternalAccountLoginCommand.cs new file mode 100644 index 000000000..54cadb876 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/External/ExternalAccountLoginCommand.cs @@ -0,0 +1,22 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login.External; + +public class ExternalAccountLoginCommand : CommandBase +{ + public ExternalAccountLoginCommand(string provider, string externalUserId, string emailAddress, bool autoCreateUser) + { + Provider = provider; + ExternalUserId = externalUserId; + EmailAddress = emailAddress; + AutoCreateUser = autoCreateUser; + } + + public string Provider { get; } + + public string ExternalUserId { get; } + + public string EmailAddress { get; } + + public bool AutoCreateUser { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/External/ExternalAccountLoginCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/External/ExternalAccountLoginCommandHandler.cs new file mode 100644 index 000000000..8dfbe3176 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/External/ExternalAccountLoginCommandHandler.cs @@ -0,0 +1,82 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login.External; + +internal class ExternalAccountLoginCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + private readonly ITokenClaimsService _tokenClaimsService; + private readonly IUserClaimsPrincipalFactory _userClaimsPrincipalFactory; + + public ExternalAccountLoginCommandHandler( + UserManager userManager, + ITokenClaimsService tokenClaimsService, + IUserClaimsPrincipalFactory userClaimsPrincipalFactory) + { + _userManager = userManager; + _tokenClaimsService = tokenClaimsService; + _userClaimsPrincipalFactory = userClaimsPrincipalFactory; + } + + public async Task Handle(ExternalAccountLoginCommand request, CancellationToken cancellationToken) + { + var response = new AuthenticationResult(); + + // Try to find the user by the provider and external user unique identifier + // This will return the user we have that is linked to this external account + var user = await _userManager.FindByLoginAsync(request.Provider, request.ExternalUserId); + + // If the user is null, so we have never seen this external user before + if (user is null) + { + // ... We have a few options, but in the case we have the user's email address + var emailAddress = request.EmailAddress; + if (!string.IsNullOrEmpty(emailAddress)) + { + // we check if we can find an user by that email address + user = await _userManager.FindByEmailAsync(emailAddress); + + // If we still have no user we are going to auto create the user if enabled + if (user == null) + { + if (!request.AutoCreateUser) + { + response.AddError(Errors.UserAccess.InvalidUserNameOrPassword); + return response; + } + + if (!Email.IsValid(emailAddress, out Error? error)) + { + response.AddError(error!); + return response; + } + + var email = Email.Parse(emailAddress); + user = ApplicationUser.CreateUser(new UserId(Guid.NewGuid()), emailAddress, email, null, null, null).Value; + + // Create the user without a password + await _userManager.CreateAsync(user); + } + + // Finally we have to link the user with the external account + await _userManager.AddLoginAsync(user, new UserLoginInfo(request.Provider, request.ExternalUserId, request.Provider)); + } + } + + var userDto = new UserDto() + { + Id = user!.Id, + Name = $"{user.Name}".Trim(), + UserName = user.UserName, + Email = user.Email, + Claims = _tokenClaimsService.GetUserClaims(user) + }; + + var principal = await _userClaimsPrincipalFactory.CreateAsync(user); + response.SetAuthenticatedUser(userDto, principal); + return response; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/UserDto.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/UserDto.cs new file mode 100644 index 000000000..4ce9fc0ce --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/Login/UserDto.cs @@ -0,0 +1,16 @@ +using System.Security.Claims; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login; + +public class UserDto +{ + public Guid Id { get; init; } + + public string? UserName { get; init; } = null!; + + public string? Name { get; init; } + + public string? Email { get; init; } + + public List? Claims { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/RefreshTokenCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/RefreshTokenCommand.cs new file mode 100644 index 000000000..4dcbd8cc7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/RefreshTokenCommand.cs @@ -0,0 +1,17 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.RefreshToken; + +public class RefreshTokenCommand : CommandBase> +{ + public RefreshTokenCommand(string accessToken, string refreshToken) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + } + + public string AccessToken { get; } + + public string RefreshToken { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/RefreshTokenCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/RefreshTokenCommandHandler.cs new file mode 100644 index 000000000..0ac23d2e7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/RefreshTokenCommandHandler.cs @@ -0,0 +1,21 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CSharpFunctionalExtensions; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.RefreshToken; + +internal class RefreshTokenCommandHandler : ICommandHandler> +{ + private readonly ITokenClaimsService _tokenService; + + public RefreshTokenCommandHandler(ITokenClaimsService tokenService) + { + _tokenService = tokenService; + } + + public async Task> Handle(RefreshTokenCommand request, CancellationToken cancellationToken) + { + return await _tokenService.GenerateNewTokensAsync(request.AccessToken, request.RefreshToken, cancellationToken) + .Map(tokens => new TokenDto(tokens.AccessToken, tokens.RefreshToken)); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/TokenDto.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/TokenDto.cs new file mode 100644 index 000000000..c2a877380 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RefreshToken/TokenDto.cs @@ -0,0 +1,3 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.RefreshToken; + +public record TokenDto(string AccessToken, string RefreshToken); \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/RequestResetPasswordTokenCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/RequestResetPasswordTokenCommand.cs new file mode 100644 index 000000000..9448c67b6 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/RequestResetPasswordTokenCommand.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.RequestResetPasswordToken; + +public class RequestResetPasswordTokenCommand : CommandBase +{ + public RequestResetPasswordTokenCommand(string emailAddress) + { + EmailAddress = emailAddress; + } + + public string EmailAddress { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/RequestResetPasswordTokenCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/RequestResetPasswordTokenCommandHandler.cs new file mode 100644 index 000000000..be71e82fb --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/RequestResetPasswordTokenCommandHandler.cs @@ -0,0 +1,42 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Emails; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.RequestResetPasswordToken; + +internal class RequestResetPasswordTokenCommandHandler : ICommandHandler +{ + private readonly IEmailSender _emailSender; + private readonly UserManager _userManager; + + public RequestResetPasswordTokenCommandHandler(UserManager userManager, IEmailSender emailSender) + { + _userManager = userManager; + _emailSender = emailSender; + } + + public async Task Handle(RequestResetPasswordTokenCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByEmailAsync(request.EmailAddress); + if (user is not null) + { + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + await _emailSender.SendEmail(new EmailMessage( + request.EmailAddress, + "MyMeetings - Your Password Reset Token", + $@"You requested a password reset token. Use the following token to proceed:\n\n{token}")); + return Result.Ok(); + } + + // email user and inform them that they do not have an account with that email address + var message = new EmailMessage( + request.EmailAddress, + "MyMeetings - Forgot password", + $"We could not find your account with the given email address '{request.EmailAddress}'.\n\nPlease check if you registered with a different email address."); + + await _emailSender.SendEmail(message); + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/ResetPasswordTokenResponse.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/ResetPasswordTokenResponse.cs new file mode 100644 index 000000000..8b5962d94 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/RequestResetPasswordToken/ResetPasswordTokenResponse.cs @@ -0,0 +1,8 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.RequestResetPasswordToken; + +public class ResetPasswordTokenResponse : Result +{ + public string? Token { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommand.cs new file mode 100644 index 000000000..29a0a6e4c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommand.cs @@ -0,0 +1,20 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.ResetPassword; + +public class ResetPasswordCommand : CommandBase +{ + public ResetPasswordCommand(string token, string emailAddress, string password) + { + Token = token; + EmailAddress = emailAddress; + Password = password; + } + + public string Token { get; } + + public string EmailAddress { get; } + + public string Password { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommandHandler.cs new file mode 100644 index 000000000..7c90b2145 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommandHandler.cs @@ -0,0 +1,39 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.ResetPassword; + +internal class ResetPasswordCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + + public ResetPasswordCommandHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(ResetPasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByEmailAsync(request.EmailAddress); + if (user is null) + { + return Errors.General.NotFound(request.EmailAddress, "User"); + } + + var result = await _userManager.ResetPasswordAsync(user, request.Token, request.Password); + if (!result.Succeeded) + { + return result.Errors.Map().Combine(); + } + + // Check if we have to unlock the user account + if (await _userManager.IsLockedOutAsync(user)) + { + await _userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow); + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/ContractMapping.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/ContractMapping.cs new file mode 100644 index 000000000..093ca88cd --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/ContractMapping.cs @@ -0,0 +1,12 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Domain.Repositories; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authorization.GetPermissions; + +internal static class ContractMapping +{ + public static GetPermissionsOptions MapToOptions(this GetPermissionsQuery query) + => new GetPermissionsOptions(null); + + public static GetPermissionsOptions WithCodes(this GetPermissionsOptions permissionsOptions, IEnumerable codes) + => new GetPermissionsOptions(codes); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/GetPermissionsQuery.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/GetPermissionsQuery.cs new file mode 100644 index 000000000..720d2980c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/GetPermissionsQuery.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authorization.GetPermissions; + +public class GetPermissionsQuery : QueryBase>> +{ + public GetPermissionsQuery(Guid? userId) + { + UserId = userId; + } + + public Guid? UserId { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/GetUserPermissionsQueryHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/GetUserPermissionsQueryHandler.cs new file mode 100644 index 000000000..8bf9ef8c7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/GetUserPermissionsQueryHandler.cs @@ -0,0 +1,78 @@ +using System.Security.Claims; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Queries; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using CompanyName.MyMeetings.Modules.UsersMI.Domain.Repositories; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authorization.GetPermissions; + +internal class GetUserPermissionsQueryHandler : IQueryHandler>> +{ + private readonly RoleManager _roleManager; + private readonly UserManager _userManager; + private readonly IReadOnlyPermissionRepository _permissionRepository; + + public GetUserPermissionsQueryHandler( + IReadOnlyPermissionRepository permissionRepository, + UserManager userManager, + RoleManager roleManager) + { + _roleManager = roleManager; + _userManager = userManager; + _permissionRepository = permissionRepository; + } + + public async Task>> Handle(GetPermissionsQuery query, CancellationToken cancellationToken) + { + var options = query.MapToOptions(); + + if (query.UserId is not null) + { + var user = await _userManager.FindByIdAsync(query.UserId.Value.ToString()); + if (user is null) + { + return Errors.General.NotFound(query.UserId.Value, "User"); + } + + var roleNames = await _userManager.GetRolesAsync(user); + var roleClaims = await GetClaimsAsync(roleNames); + var userClaims = await _userManager.GetClaimsAsync(user); + var claims = roleClaims.Union(userClaims) + .Where(x => x.Type == CustomClaimTypes.Permission) + .Select(x => x.Value) + .ToList(); + + // Short circuit + if (!claims.Any()) + { + return Result.Ok(Enumerable.Empty()); + } + + options = options.WithCodes(claims); + } + + var permissions = await _permissionRepository.GetPermissionsAsync(options, cancellationToken); + var result = permissions.Select(p => new PermissionDto(p.Code, p.Name, p.Description)).ToList(); + return result; + } + + private async Task> GetClaimsAsync(IEnumerable roleNames) + { + var claims = new List(); + foreach (var roleName in roleNames) + { + var role = await _roleManager.FindByNameAsync(roleName); + if (role is null) + { + continue; + } + + var roleClaims = await _roleManager.GetClaimsAsync(role); + claims.AddRange(roleClaims ?? Enumerable.Empty()); + } + + return claims; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/PermissionDto.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/PermissionDto.cs new file mode 100644 index 000000000..d8827fd87 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authorization/GetPermissions/PermissionDto.cs @@ -0,0 +1,17 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Authorization.GetPermissions; + +public class PermissionDto +{ + public PermissionDto(string code, string name, string? description) + { + Code = code; + Name = name; + Description = description; + } + + public string Code { get; } + + public string Name { get; } + + public string? Description { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/CompanyName.MyMeetings.Modules.UsersMI.Application.csproj b/src/Modules/Users/MicrosoftIdentity/Application/CompanyName.MyMeetings.Modules.UsersMI.Application.csproj new file mode 100644 index 000000000..c2e180321 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/CompanyName.MyMeetings.Modules.UsersMI.Application.csproj @@ -0,0 +1,11 @@ + + + + enable + enable + + + + + + \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/ICommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/ICommandHandler.cs new file mode 100644 index 000000000..8a364a76e --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/ICommandHandler.cs @@ -0,0 +1,15 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using MediatR; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; + +public interface ICommandHandler : IRequestHandler + where TCommand : ICommand +{ +} + +public interface ICommandHandler : + IRequestHandler + where TCommand : ICommand +{ +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/ICommandsScheduler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/ICommandsScheduler.cs new file mode 100644 index 000000000..6e2653222 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/ICommandsScheduler.cs @@ -0,0 +1,10 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; + +public interface ICommandsScheduler +{ + Task EnqueueAsync(ICommand command); + + Task EnqueueAsync(ICommand command); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/InternalCommandBase.cs b/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/InternalCommandBase.cs new file mode 100644 index 000000000..af566368d --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Commands/InternalCommandBase.cs @@ -0,0 +1,28 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; + +public abstract class InternalCommandBase : ICommand +{ + protected InternalCommandBase(Guid id) + { + Id = id; + } + + public Guid Id { get; } +} + +public abstract class InternalCommandBase : ICommand +{ + protected InternalCommandBase() + { + Id = Guid.NewGuid(); + } + + protected InternalCommandBase(Guid id) + { + Id = id; + } + + public Guid Id { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Queries/IQueryHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Queries/IQueryHandler.cs new file mode 100644 index 000000000..d07f59fa8 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Configuration/Queries/IQueryHandler.cs @@ -0,0 +1,10 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using MediatR; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Queries; + +public interface IQueryHandler : + IRequestHandler + where TQuery : IQuery +{ +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/ApplicationPermissions.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/ApplicationPermissions.cs new file mode 100644 index 000000000..f599b7df3 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/ApplicationPermissions.cs @@ -0,0 +1,6 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +public class ApplicationPermissions +{ + public const string Administrator = "Application.Administrator"; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/CommandBase.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/CommandBase.cs new file mode 100644 index 000000000..84f2342d0 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/CommandBase.cs @@ -0,0 +1,31 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +public abstract class CommandBase : ICommand +{ + public Guid Id { get; } + + protected CommandBase() + { + Id = Guid.NewGuid(); + } + + protected CommandBase(Guid id) + { + Id = id; + } +} + +public abstract class CommandBase : ICommand +{ + protected CommandBase() + { + Id = Guid.NewGuid(); + } + + protected CommandBase(Guid id) + { + Id = id; + } + + public Guid Id { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/CustomClaimTypes.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/CustomClaimTypes.cs new file mode 100644 index 000000000..a67b37c07 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/CustomClaimTypes.cs @@ -0,0 +1,15 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +internal class CustomClaimTypes +{ + internal const string Roles = "roles"; + internal const string Sub = "sub"; + internal const string Email = "email"; + internal const string UserName = "userName"; + + internal const string Name = "name"; + internal const string FirstName = "firstName"; + internal const string LastName = "lastName"; + + internal const string Permission = "Application.Permission"; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/ICommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/ICommand.cs new file mode 100644 index 000000000..a47e8841b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/ICommand.cs @@ -0,0 +1,13 @@ +using MediatR; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +public interface ICommand : IRequest +{ + Guid Id { get; } +} + +public interface ICommand : IRequest +{ + Guid Id { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/IQuery.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/IQuery.cs new file mode 100644 index 000000000..5cf9f0e75 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/IQuery.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +public interface IQuery : IRequest +{ +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/IRecurringCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/IRecurringCommand.cs new file mode 100644 index 000000000..add6a5646 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/IRecurringCommand.cs @@ -0,0 +1,5 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +public interface IRecurringCommand +{ +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/ITokenClaimsService.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/ITokenClaimsService.cs new file mode 100644 index 000000000..918e44ff9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/ITokenClaimsService.cs @@ -0,0 +1,19 @@ +using System.Security.Claims; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using CSharpFunctionalExtensions; +using Microsoft.IdentityModel.Tokens; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +public interface ITokenClaimsService +{ + TokenValidationParameters GetTokenValidationParameters(); + + Tokens GenerateTokens(ApplicationUser user); + + Task> GenerateNewTokensAsync(string accessToken, string refreshToken, CancellationToken cancellationToken); + + List GetUserClaims(ApplicationUser user); +} + +public record Tokens(string AccessToken, string RefreshToken); \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/IUserAccessModule.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/IUserAccessModule.cs new file mode 100644 index 000000000..e8a0cbae7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/IUserAccessModule.cs @@ -0,0 +1,10 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +public interface IUserAccessModule +{ + Task ExecuteCommandAsync(ICommand command); + + Task ExecuteCommandAsync(ICommand command); + + Task ExecuteQueryAsync(IQuery query); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/QueryBase.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/QueryBase.cs new file mode 100644 index 000000000..40eb7dbfa --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/QueryBase.cs @@ -0,0 +1,16 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +public abstract class QueryBase : IQuery +{ + public Guid Id { get; } + + protected QueryBase() + { + Id = Guid.NewGuid(); + } + + protected QueryBase(Guid id) + { + Id = id; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/IResult.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/IResult.cs new file mode 100644 index 000000000..0e5dae995 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/IResult.cs @@ -0,0 +1,19 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Domain; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +public interface IResult +{ + ResultStatus Status { get; } + + IEnumerable Errors { get; } + + bool HasError => Errors.Any(); + + object? GetValue(); +} + +public interface IResult : IResult +{ + T? Value { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/Result.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/Result.cs new file mode 100644 index 000000000..6f206b878 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/Result.cs @@ -0,0 +1,199 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using CSharpFunctionalExtensions; +using MediatR; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +public class Result : Result +{ + public Result() + : base(ResultStatus.Ok) + { + } + + protected Result(ResultStatus status) + : base(status) + { + } + + protected Result(Error error) + : base(ResultStatus.Error) + { + Errors = [error]; + } + + public static implicit operator Result(Error error) + { + if (error.Code.Equals(Domain.Errors.General.NotFound().Code)) + { + return NotFound(error); + } + + return Error(error); + } + + public static implicit operator Result(Result result) => result.IsSuccess + ? Ok() + : result.Error; + + public static implicit operator Result(UnitResult result) => result.IsSuccess + ? Ok() + : result.Error; + + public static Result Ok() => new Result(ResultStatus.Ok); + + public static Result Ok(T value) => new Result(value); + + public static Result NoContent() => new Result(ResultStatus.NoContent); + + public static Result Created(T value) => new Result(value, ResultStatus.Created); + + public static Result Forbidden() => Forbidden(string.Empty); + + public static Result Forbidden(string? message) + { + message = message?.Trim(); + var error = string.IsNullOrEmpty(message) + ? Domain.Errors.Authorization.Forbidden() + : Domain.Errors.Authorization.Forbidden(message); + + return Forbidden(error); + } + + public static Result Forbidden(Error error) => new Result(ResultStatus.Forbidden) + { + Errors = [error] + }; + + public static Result Forbidden() => Forbidden(string.Empty); + + public static Result Forbidden(string? message) + { + message = message?.Trim(); + var error = string.IsNullOrEmpty(message) + ? Domain.Errors.Authorization.Forbidden() + : Domain.Errors.Authorization.Forbidden(message); + return Forbidden(error); + } + + public static Result Forbidden(Error error) => new Result(ResultStatus.Forbidden) + { + Errors = [error] + }; + + public static Result NotFound(Error error) => new Result(ResultStatus.NotFound) + { + Errors = [error] + }; + + public static Result NotFound(Error error) => new Result(ResultStatus.NotFound) + { + Errors = [error] + }; + + public static new Result Error(Error error) => new Result(ResultStatus.Error) + { + Errors = [error] + }; + + public static Result Error(Error error) => new Result(ResultStatus.Error) + { + Errors = [error] + }; +} + +public class Result : IResult +{ + public Result(ResultStatus status) + { + Status = status; + } + + public Result(T value) + { + Value = value; + Status = ResultStatus.Ok; + } + + public Result(T value, ResultStatus status) + : this(value) + { + Status = status; + } + + public Result(Error error) + : this(error, ResultStatus.Error) + { + } + + public Result(Error error, ResultStatus status) + : this([error], status) + { + } + + public Result(IEnumerable errors, ResultStatus status) + { + Errors = errors; + Status = status; + } + + public T? Value { get; init; } + + public ResultStatus Status { get; protected set; } + + public bool IsSuccess => Status is ResultStatus.Ok or ResultStatus.NoContent or ResultStatus.Created; + + public IEnumerable Errors { get; internal set; } = Enumerable.Empty(); + + public object? GetValue() + { + return Value; + } + + public IResult AddError(Error error) + { + ArgumentNullException.ThrowIfNull(error, nameof(error)); + + var errors = new List(Errors); + + if (error.Errors != null) + { + foreach (var errorObj in error.Errors) + { + errors.Add(errorObj); + } + } + + if (error.Code != null) + { + errors.Add(error); + } + + Errors = errors; + Status = ResultStatus.Error; + + return this; + } + + public static implicit operator T(Result response) => response.Value!; + + public static implicit operator Result(T value) => Result.Ok(value); + + public static implicit operator Result(Error error) + { + if (error.Code.Equals(Domain.Errors.General.NotFound().Code)) + { + return Result.NotFound(error); + } + + return Error(error); + } + + public static implicit operator Result(Result result) => result.IsSuccess + ? Result.Ok(result.Value) + : result.Error; + + public static Result Ok(T value) => new Result(value); + + public static Result Error(Error error) => new Result(error); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/ResultStatus.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/ResultStatus.cs new file mode 100644 index 000000000..e3a46b125 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Results/ResultStatus.cs @@ -0,0 +1,49 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +public enum ResultStatus +{ + /// + /// Successful result. + /// + Ok, + + /// + /// General error result. + /// + Error, + + /// + /// Successful created result. + /// + Created, + + /// + /// Not found error result. + /// + NotFound, + + /// + /// Forbidden error result. + /// + Forbidden, + + /// + /// Unauthorized error result. + /// + Unauthorized, + + /// + /// Invalid result, typically used for validation errors. + /// + Invalid, + + /// + /// Successful result with no content. + /// + NoContent, + + /// + /// Conflict error result. + /// + Conflict +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Roles.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Roles.cs new file mode 100644 index 000000000..a0a9c6485 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Roles.cs @@ -0,0 +1,8 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts +{ + public class Roles + { + public const string Admin = "Admin"; + public const string User = "User"; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/CustomValidators.cs b/src/Modules/Users/MicrosoftIdentity/Application/CustomValidators.cs new file mode 100644 index 000000000..bfe420ba7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/CustomValidators.cs @@ -0,0 +1,110 @@ +using System.Linq.Expressions; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using CSharpFunctionalExtensions; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application +{ + public static class CustomValidators + { + public static IRuleBuilderOptions CustomNotNull(this IRuleBuilder ruleBuilder) + { + return DefaultValidatorExtensions.NotNull(ruleBuilder) + .WithMessageAndCode(Errors.General.ValueIsRequired()); + } + + public static IRuleBuilderOptions CustomNotEmpty(this IRuleBuilder ruleBuilder) + { + return DefaultValidatorExtensions.NotEmpty(ruleBuilder) + .WithMessageAndCode(Errors.General.ValueIsRequired()); + } + + public static IRuleBuilderOptions CustomLength(this IRuleBuilder ruleBuilder, int min, int max) + { + return DefaultValidatorExtensions.Length(ruleBuilder, min, max) + .WithMessageAndCode(Errors.General.InvalidLength()); + } + + public static IRuleBuilderOptions CustomMaximumLength(this IRuleBuilder ruleBuilder, int maximumLength) + { + return DefaultValidatorExtensions.MaximumLength(ruleBuilder, maximumLength) + .WithMessageAndCode(Errors.General.InvalidLength(maxLength: maximumLength)); + } + + public static IRuleBuilderOptions CustomGreaterThanOrEqualTo(this IRuleBuilder ruleBuilder, TProperty valueToCompare) + where TProperty : IComparable, IComparable + { + return DefaultValidatorExtensions.GreaterThanOrEqualTo(ruleBuilder, valueToCompare) + .WithMessageAndCode(Errors.General.ValueIsInvalid()); + } + + public static IRuleBuilderOptions CustomGreaterThanOrEqualTo(this IRuleBuilder ruleBuilder, int valueToCompare) + { + return DefaultValidatorExtensions.GreaterThanOrEqualTo(ruleBuilder, valueToCompare) + .WithMessageAndCode(Errors.General.ValueIsInvalid()); + } + + public static IRuleBuilderOptions CustomGreaterThan(this IRuleBuilder ruleBuilder, TProperty valueToCompare) + where TProperty : IComparable, IComparable + { + return DefaultValidatorExtensions.GreaterThan(ruleBuilder, valueToCompare) + .WithMessageAndCode(Errors.General.ValueIsInvalid()); + } + + public static IRuleBuilderOptions CustomEmailAddress(this IRuleBuilder ruleBuilder) + { + return DefaultValidatorExtensions.EmailAddress(ruleBuilder) + .WithMessageAndCode(Errors.General.ValueIsInvalid()); + } + + public static IRuleBuilderOptions CustomEqual(this IRuleBuilder ruleBuilder, Expression> expression, IEqualityComparer? comparer = null) + { + return DefaultValidatorExtensions.Equal(ruleBuilder, expression, comparer) + .WithMessage(Errors.General.ValueIsInvalid()); + } + + public static IRuleBuilderOptions MustBeValueObject( + this IRuleBuilder ruleBuilder, + Func> factoryMethod) + where TValueObject : ValueObject + { + return (IRuleBuilderOptions)ruleBuilder.Custom((value, context) => + { + Result result = factoryMethod(value); + + if (result.IsFailure) + { + if (result.Error.Errors != null) + { + foreach (var error in result.Error.Errors) + { + context.AddFailure(error); + } + } + + if (result.Error.Code != null) + { + context.AddFailure(result.Error); + } + } + }); + } + + public static IRuleBuilderOptionsConditions> ListMustContainNumberOfItems( + this IRuleBuilder> ruleBuilder, int? min = null, int? max = null) + { + return ruleBuilder.Custom((list, context) => + { + if (min.HasValue && list.Count < min.Value) + { + context.AddFailure(Errors.General.CollectionIsTooSmall(min.Value, list.Count)); + } + + if (max.HasValue && list.Count > max.Value) + { + context.AddFailure(Errors.General.CollectionIsTooLarge(max.Value, list.Count)); + } + }); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/FluentValidationExtensions.cs b/src/Modules/Users/MicrosoftIdentity/Application/FluentValidationExtensions.cs new file mode 100644 index 000000000..09358c15b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/FluentValidationExtensions.cs @@ -0,0 +1,40 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application; + +public static class FluentValidationExtensions +{ + /// + /// Adds a new validation failure for the specified message. The failure will be associated with the current property being validated. + /// + /// Type. + /// The validation context. + /// The error. + public static void AddFailure(this ValidationContext validationContext, Error error) + { + validationContext.AddFailure(new FluentValidation.Results.ValidationFailure() + { + PropertyName = validationContext.PropertyPath, + ErrorMessage = error.Message, + ErrorCode = error.Code + }); + } + + public static IRuleBuilderOptions WithMessage(this IRuleBuilderOptions rule, Error error) + { + return rule.WithMessage(error.Message); + } + + public static IRuleBuilderOptions WithCode(this IRuleBuilderOptions rule, Error error) + { + return rule.WithErrorCode(error.Code); + } + + public static IRuleBuilderOptions WithMessageAndCode(this IRuleBuilderOptions rule, Error error) + { + return rule + .WithErrorCode(error.Code) + .WithMessage(error.Message); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/IdentityHelpers.cs b/src/Modules/Users/MicrosoftIdentity/Application/IdentityHelpers.cs new file mode 100644 index 000000000..34f6969eb --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/IdentityHelpers.cs @@ -0,0 +1,12 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application; + +internal static class IdentityHelpers +{ + public static List Map(this IEnumerable errors) + { + return errors.Select(x => new Error(x.Code, x.Description)).ToList(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/GetAuthenticatorKey/GetAuthenticatorKeyQuery.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/GetAuthenticatorKey/GetAuthenticatorKeyQuery.cs new file mode 100644 index 000000000..7fbebbf43 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/GetAuthenticatorKey/GetAuthenticatorKeyQuery.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.AuthenticatorRegistration.GetAuthenticatorKey; + +public class GetAuthenticatorKeyQuery : QueryBase> +{ + public GetAuthenticatorKeyQuery(Guid userId) + { + UserId = userId; + } + + public Guid UserId { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/GetAuthenticatorKey/GetAuthenticatorKeyQueryHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/GetAuthenticatorKey/GetAuthenticatorKeyQueryHandler.cs new file mode 100644 index 000000000..89933a7d3 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/GetAuthenticatorKey/GetAuthenticatorKeyQueryHandler.cs @@ -0,0 +1,53 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Queries; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.AuthenticatorRegistration.GetAuthenticatorKey; + +internal class GetAuthenticatorKeyQueryHandler : IQueryHandler> +{ + private readonly UserManager _userManager; + private readonly IExecutionContextAccessor _executionContextAccessor; + + public GetAuthenticatorKeyQueryHandler(UserManager userManager, IExecutionContextAccessor executionContextAccessor) + { + _userManager = userManager; + _executionContextAccessor = executionContextAccessor; + } + + public async Task> Handle(GetAuthenticatorKeyQuery request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + if (_executionContextAccessor.UserId != user.Id) + { + return Result.Forbidden(Errors.Authorization.Forbidden("No permission to get authenticator key.")); + } + + // Try to get the authenticator key from the user + var authenticatorKey = await _userManager.GetAuthenticatorKeyAsync(user); + + // if none is provided, which means none was generate before + if (authenticatorKey == null) + { + // reset the authenticator key + await _userManager.ResetAuthenticatorKeyAsync(user); + + // and get it again + authenticatorKey = await _userManager.GetAuthenticatorKeyAsync(user); + } + + if (!string.IsNullOrEmpty(authenticatorKey)) + { + return authenticatorKey; + } + + return Errors.Authentication.AuthenticatorKeyNotFound(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/RegisterAuthenticator/RegisterAuthenticatorCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/RegisterAuthenticator/RegisterAuthenticatorCommand.cs new file mode 100644 index 000000000..baef6ec6a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/RegisterAuthenticator/RegisterAuthenticatorCommand.cs @@ -0,0 +1,17 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.AuthenticatorRegistration.RegisterAuthenticator; + +public class RegisterAuthenticatorCommand : CommandBase +{ + public RegisterAuthenticatorCommand(Guid userId, string otpCode) + { + UserId = userId; + OtpCode = otpCode; + } + + public Guid UserId { get; } + + public string OtpCode { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/RegisterAuthenticator/RegisterAuthenticatorCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/RegisterAuthenticator/RegisterAuthenticatorCommandHandler.cs new file mode 100644 index 000000000..03b316eea --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/AuthenticatorRegistration/RegisterAuthenticator/RegisterAuthenticatorCommandHandler.cs @@ -0,0 +1,42 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.AuthenticatorRegistration.RegisterAuthenticator; + +internal class RegisterAuthenticatorCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + private readonly IExecutionContextAccessor _executionContextAccessor; + + public RegisterAuthenticatorCommandHandler(UserManager userManager, IExecutionContextAccessor executionContextAccessor) + { + _userManager = userManager; + _executionContextAccessor = executionContextAccessor; + } + + public async Task Handle(RegisterAuthenticatorCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + if (_executionContextAccessor.UserId != user.Id) + { + return Result.Forbidden(Errors.Authorization.Forbidden("No permission to register authenticator.")); + } + + var isValid = await _userManager.VerifyTwoFactorTokenAsync(user, _userManager.Options.Tokens.AuthenticatorTokenProvider, request.OtpCode); + if (!isValid) + { + return Errors.Authentication.InvalidTwoFactorAuthenticationToken(); + } + + await _userManager.SetTwoFactorEnabledAsync(user, true); + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangeEmailAddress/ChangeEmailAddressCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangeEmailAddress/ChangeEmailAddressCommand.cs new file mode 100644 index 000000000..c1afb6d96 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangeEmailAddress/ChangeEmailAddressCommand.cs @@ -0,0 +1,20 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ChangeEmailAddress; + +public class ChangeEmailAddressCommand : CommandBase +{ + public ChangeEmailAddressCommand(Guid userId, string newEmailAddress, string token) + { + UserId = userId; + NewEmailAddress = newEmailAddress; + Token = token; + } + + public Guid UserId { get; } + + public string NewEmailAddress { get; } + + public string Token { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangeEmailAddress/ChangeEmailAddressCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangeEmailAddress/ChangeEmailAddressCommandHandler.cs new file mode 100644 index 000000000..d950a10ab --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangeEmailAddress/ChangeEmailAddressCommandHandler.cs @@ -0,0 +1,64 @@ +using Azure.Core; +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.BuildingBlocks.Application.Emails; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ChangeEmailAddress; + +internal class ChangeEmailAddressCommandHandler : ICommandHandler +{ + private readonly IEmailSender _emailSender; + private readonly IdentityOptions _identityOptions; + private readonly UserManager _userManager; + private readonly IExecutionContextAccessor _executionContextAccessor; + + public ChangeEmailAddressCommandHandler(UserManager userManager, IEmailSender emailSender, IExecutionContextAccessor executionContextAccessor, IOptions identityOptions) + { + _userManager = userManager; + _emailSender = emailSender; + _identityOptions = identityOptions.Value; + _executionContextAccessor = executionContextAccessor; + } + + public async Task Handle(ChangeEmailAddressCommand command, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(command.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(command.UserId, "User"); + } + + if (_executionContextAccessor.UserId != user.Id) + { + return Result.Forbidden(Errors.Authorization.Forbidden("No permission to change email address.")); + } + + if (!Email.IsValid(command.NewEmailAddress, out Error? error)) + { + return error!; + } + + var newEmail = Email.Parse(command.NewEmailAddress); + + var result = await _userManager.ChangeEmailAsync(user, newEmail.Address, command.Token); + if (!result.Succeeded) + { + return result.Errors.Select(x => new Error(x.Code, x.Description)).Combine(); + } + + if (_identityOptions.SignIn.RequireConfirmedEmail) + { + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + await _emailSender.SendEmail(new EmailMessage( + command.NewEmailAddress, + "MyMeetings – Confirm Your Email Address", + $@"To complete your registration, please use the following token to confirm your email address: {token}")); + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 000000000..7d8dc6cfe --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,20 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ChangePassword; + +public class ChangePasswordCommand : CommandBase +{ + public ChangePasswordCommand(Guid userId, string currentPassword, string newPassword) + { + UserId = userId; + CurrentPassword = currentPassword; + NewPassword = newPassword; + } + + public Guid UserId { get; } + + public string CurrentPassword { get; } + + public string NewPassword { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommandHandler.cs new file mode 100644 index 000000000..edf87a4cf --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommandHandler.cs @@ -0,0 +1,41 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ChangePassword; + +internal class ChangePasswordCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + private readonly IExecutionContextAccessor _executionContextAccessor; + + public ChangePasswordCommandHandler(UserManager userManager, IExecutionContextAccessor executionContextAccessor) + { + _userManager = userManager; + _executionContextAccessor = executionContextAccessor; + } + + public async Task Handle(ChangePasswordCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + if (_executionContextAccessor.UserId != user.Id) + { + return Result.Forbidden(Errors.Authorization.Forbidden("No permission to change password.")); + } + + var result = await _userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword); + if (!result.Succeeded) + { + return result.Errors.Map().Combine(); + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/ConfirmEmailAddress/ConfirmEmailAddressCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/ConfirmEmailAddress/ConfirmEmailAddressCommand.cs new file mode 100644 index 000000000..290e999e5 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/ConfirmEmailAddress/ConfirmEmailAddressCommand.cs @@ -0,0 +1,17 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ConfirmEmailAddress; + +public class ConfirmEmailAddressCommand : CommandBase +{ + public ConfirmEmailAddressCommand(Guid userId, string token) + { + Token = token; + UserId = userId; + } + + public Guid UserId { get; } + + public string Token { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/ConfirmEmailAddress/ConfirmEmailAddressCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/ConfirmEmailAddress/ConfirmEmailAddressCommandHandler.cs new file mode 100644 index 000000000..6f6c2db0a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/ConfirmEmailAddress/ConfirmEmailAddressCommandHandler.cs @@ -0,0 +1,41 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ConfirmEmailAddress; + +internal class ConfirmEmailAddressCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + private readonly IExecutionContextAccessor _executionContextAccessor; + + public ConfirmEmailAddressCommandHandler(UserManager userManager, IExecutionContextAccessor executionContextAccessor) + { + _userManager = userManager; + _executionContextAccessor = executionContextAccessor; + } + + public async Task Handle(ConfirmEmailAddressCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + if (_executionContextAccessor.UserId != user.Id) + { + return Result.Forbidden(Errors.Authorization.Forbidden("No permission to confirm email address.")); + } + + var result = await _userManager.ConfirmEmailAsync(user, request.Token); + if (!result.Succeeded) + { + return result.Errors.Map().Combine(); + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/GetUserAccountQuery.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/GetUserAccountQuery.cs new file mode 100644 index 000000000..da0eb3294 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/GetUserAccountQuery.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.GetUserAccount; + +public class GetUserAccountQuery : QueryBase> +{ + public GetUserAccountQuery(Guid userId) + { + UserId = userId; + } + + public Guid UserId { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/GetUserAccountQueryHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/GetUserAccountQueryHandler.cs new file mode 100644 index 000000000..f7e3f6b3d --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/GetUserAccountQueryHandler.cs @@ -0,0 +1,44 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Queries; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.GetUserAccount; + +internal class GetUserAccountQueryHandler : IQueryHandler> +{ + private readonly UserManager _userManager; + private readonly IExecutionContextAccessor _executionContextAccessor; + + public GetUserAccountQueryHandler(UserManager userManager, IExecutionContextAccessor executionContextAccessor) + { + _userManager = userManager; + _executionContextAccessor = executionContextAccessor; + } + + public async Task> Handle(GetUserAccountQuery request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "user"); + } + + if (_executionContextAccessor.UserId != user.Id) + { + return Result.Forbidden(Errors.Authorization.Forbidden("No permission to get user account.")); + } + + return new UserAccountDto + { + Id = user.Id, + IsActive = user.LockoutEnd is null, + EmailAddress = user.Email, + UserName = user.UserName, + Name = user.Name, + FirstName = user.FirstName, + LastName = user.LastName + }; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/UserAccountDto.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/UserAccountDto.cs new file mode 100644 index 000000000..413db9a9c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/GetUserAccount/UserAccountDto.cs @@ -0,0 +1,18 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.GetUserAccount; + +public class UserAccountDto +{ + public Guid Id { get; set; } + + public bool IsActive { get; set; } + + public string? UserName { get; set; } + + public string? Name { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public string? EmailAddress { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestChangeEmailAddressToken/RequestChangeEmailAddressTokenCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestChangeEmailAddressToken/RequestChangeEmailAddressTokenCommand.cs new file mode 100644 index 000000000..a8e7bb313 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestChangeEmailAddressToken/RequestChangeEmailAddressTokenCommand.cs @@ -0,0 +1,18 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.RequestChangeEmailAddressToken +{ + public class RequestChangeEmailAddressTokenCommand : CommandBase + { + public RequestChangeEmailAddressTokenCommand(Guid userId, string newEmailAddress) + { + UserId = userId; + NewEmailAddress = newEmailAddress; + } + + public Guid UserId { get; } + + public string NewEmailAddress { get; } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestChangeEmailAddressToken/RequestChangeEmailAddressTokenCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestChangeEmailAddressToken/RequestChangeEmailAddressTokenCommandHandler.cs new file mode 100644 index 000000000..6fe7aad0c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestChangeEmailAddressToken/RequestChangeEmailAddressTokenCommandHandler.cs @@ -0,0 +1,49 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.BuildingBlocks.Application.Emails; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.RequestChangeEmailAddressToken; + +internal class RequestChangeEmailAddressTokenCommandHandler : ICommandHandler +{ + private readonly IEmailSender _emailSender; + private readonly UserManager _userManager; + private readonly IExecutionContextAccessor _executionContextAccessor; + + public RequestChangeEmailAddressTokenCommandHandler(UserManager userManager, IEmailSender emailSender, IExecutionContextAccessor executionContextAccessor) + { + _emailSender = emailSender; + _userManager = userManager; + _executionContextAccessor = executionContextAccessor; + } + + public async Task Handle(RequestChangeEmailAddressTokenCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + if (_executionContextAccessor.UserId != user.Id) + { + return Result.Forbidden(Errors.Authorization.Forbidden("No permission to request change email address token.")); + } + + if (user.Email is null) + { + return Errors.General.InvalidRequest("User has no email address"); + } + + var token = await _userManager.GenerateChangeEmailTokenAsync(user, request.NewEmailAddress); + await _emailSender.SendEmail(new EmailMessage( + user.Email, + "MyMeetings - Your Email Change Token", + $"You requested to change your email address. Use the following token to confirm the change: {token}")); + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestConfirmEmailAddressToken/RequestConfirmEmailAddressTokenCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestConfirmEmailAddressToken/RequestConfirmEmailAddressTokenCommand.cs new file mode 100644 index 000000000..45a7a6b46 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestConfirmEmailAddressToken/RequestConfirmEmailAddressTokenCommand.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.RequestConfirmEmailAddressToken; + +public class RequestConfirmEmailAddressTokenCommand : CommandBase +{ + public RequestConfirmEmailAddressTokenCommand(Guid userId) + { + UserId = userId; + } + + public Guid UserId { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestConfirmEmailAddressToken/RequestConfirmEmailAddressTokenCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestConfirmEmailAddressToken/RequestConfirmEmailAddressTokenCommandHandler.cs new file mode 100644 index 000000000..63d713539 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/RequestConfirmEmailAddressToken/RequestConfirmEmailAddressTokenCommandHandler.cs @@ -0,0 +1,49 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.BuildingBlocks.Application.Emails; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.RequestConfirmEmailAddressToken; + +internal class RequestConfirmEmailAddressTokenCommandHandler : ICommandHandler +{ + private readonly IEmailSender _emailSender; + private readonly UserManager _userManager; + private readonly IExecutionContextAccessor _executionContextAccessor; + + public RequestConfirmEmailAddressTokenCommandHandler(UserManager userManager, IEmailSender emailSender, IExecutionContextAccessor executionContextAccessor) + { + _userManager = userManager; + _emailSender = emailSender; + _executionContextAccessor = executionContextAccessor; + } + + public async Task Handle(RequestConfirmEmailAddressTokenCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + if (_executionContextAccessor.UserId != user.Id) + { + return Result.Forbidden(Errors.Authorization.Forbidden("No permission to request confirm email address token.")); + } + + if (user.Email is null) + { + return Errors.General.InvalidRequest("User has no email address"); + } + + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + await _emailSender.SendEmail(new EmailMessage( + user.Email, + "MyMeetings - Your Email Confirmation Token", + $@"You requested a token to confirm your email address. Please use the following token to complete the process: {token}")); + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/UpdateProfile/UpdateProfileCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/UpdateProfile/UpdateProfileCommand.cs new file mode 100644 index 000000000..e792890ab --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/UpdateProfile/UpdateProfileCommand.cs @@ -0,0 +1,26 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.UpdateProfile; + +public class UpdateProfileCommand : CommandBase +{ + public UpdateProfileCommand(Guid userId, string userName, string name, string? firstName, string? lastName) + { + UserId = userId; + UserName = userName; + Name = name; + FirstName = firstName; + LastName = lastName; + } + + public Guid UserId { get; } + + public string UserName { get; } + + public string Name { get; } + + public string? FirstName { get; } + + public string? LastName { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/UpdateProfile/UpdateProfileCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/UpdateProfile/UpdateProfileCommandHandler.cs new file mode 100644 index 000000000..87b602329 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/UpdateProfile/UpdateProfileCommandHandler.cs @@ -0,0 +1,54 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Me.UpdateProfile; + +internal class UpdateProfileCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + private readonly IExecutionContextAccessor _executionContextAccessor; + + public UpdateProfileCommandHandler(UserManager userManager, IExecutionContextAccessor executionContextAccessor) + { + _userManager = userManager; + _executionContextAccessor = executionContextAccessor; + } + + public async Task Handle(UpdateProfileCommand request, CancellationToken cancellationToken) + { + var userById = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (userById is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + if (_executionContextAccessor.UserId != userById.Id) + { + return Result.Forbidden(Errors.Authorization.Forbidden("No permission to update profile.")); + } + + var userName = request.UserName.Trim(); + var userByUserName = await _userManager.FindByNameAsync(userName); + if (userByUserName is not null && userByUserName.Id != userById.Id) + { + return Errors.General.ValueMustBeUnique($"UserName '{request.UserName}' already taken"); + } + + var user = userById; + user.UserName = userName; + user.Name = request.Name.Trim(); + user.FirstName = request.FirstName?.Trim(); + user.LastName = request.LastName?.Trim(); + + var result = await _userManager.UpdateAsync(user); + if (!result.Succeeded) + { + return result.Errors.Select(x => new Error(x.Code, x.Description)).Combine(); + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/CreateRole/CreateRoleCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/CreateRole/CreateRoleCommand.cs new file mode 100644 index 000000000..ecb69b7cf --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/CreateRole/CreateRoleCommand.cs @@ -0,0 +1,17 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.CreateRole; + +public class CreateRoleCommand : CommandBase> +{ + public CreateRoleCommand(string name, IEnumerable? permissions) + { + Name = name; + Permissions = permissions; + } + + public string Name { get; } + + public IEnumerable? Permissions { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/CreateRole/CreateRoleCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/CreateRole/CreateRoleCommandHandler.cs new file mode 100644 index 000000000..095ec53f9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/CreateRole/CreateRoleCommandHandler.cs @@ -0,0 +1,38 @@ +using System.Security.Claims; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.CreateRole; + +internal class CreateRoleCommandHandler : ICommandHandler> +{ + private readonly RoleManager _roleManager; + + public CreateRoleCommandHandler(RoleManager roleManager) + { + _roleManager = roleManager; + } + + public async Task> Handle(CreateRoleCommand request, CancellationToken cancellationToken) + { + var result = await _roleManager.CreateAsync(new Role(request.Name)); + if (!result.Succeeded) + { + return result.Errors.Map().Combine(); + } + + var role = await _roleManager.FindByNameAsync(request.Name); + + var permissions = request.Permissions ?? Enumerable.Empty(); + foreach (var permission in permissions) + { + await _roleManager.AddClaimAsync(role!, new Claim(CustomClaimTypes.Permission, permission)); + role = await _roleManager.FindByNameAsync(role!.Name!); + } + + return Result.Created(role!.Id); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/DeleteRole/DeleteRoleCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/DeleteRole/DeleteRoleCommand.cs new file mode 100644 index 000000000..d211a00dc --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/DeleteRole/DeleteRoleCommand.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.DeleteRole; + +public class DeleteRoleCommand : CommandBase +{ + public DeleteRoleCommand(Guid roleId) + { + RoleId = roleId; + } + + public Guid RoleId { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/DeleteRole/DeleteRoleCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/DeleteRole/DeleteRoleCommandHandler.cs new file mode 100644 index 000000000..8c2daed1c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/DeleteRole/DeleteRoleCommandHandler.cs @@ -0,0 +1,28 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.DeleteRole; + +internal class DeleteRoleCommandHandler : ICommandHandler +{ + private readonly RoleManager _roleManager; + + public DeleteRoleCommandHandler(RoleManager roleManager) + { + _roleManager = roleManager; + } + + public async Task Handle(DeleteRoleCommand request, CancellationToken cancellationToken) + { + var role = await _roleManager.FindByIdAsync(request.RoleId.ToString()); + if (role == null) + { + return Errors.General.NotFound(request.RoleId, "User role"); + } + + await _roleManager.DeleteAsync(role); + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/ContractMapping.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/ContractMapping.cs new file mode 100644 index 000000000..d56a637db --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/ContractMapping.cs @@ -0,0 +1,12 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Domain.Repositories; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRolePermissions; + +internal static class ContractMapping +{ + public static GetPermissionsOptions MapToOptions(this GetRolePermissionsQuery query) + => new GetPermissionsOptions(null); + + public static GetPermissionsOptions WithCodes(this GetPermissionsOptions permissionsOptions, IEnumerable codes) + => new GetPermissionsOptions(codes); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/GetRolePermissionsQuery.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/GetRolePermissionsQuery.cs new file mode 100644 index 000000000..4bf5a9392 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/GetRolePermissionsQuery.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRolePermissions; + +public class GetRolePermissionsQuery : QueryBase>> +{ + public GetRolePermissionsQuery(Guid roleId) + { + RoleId = roleId; + } + + public Guid RoleId { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/GetRolePermissionsQueryHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/GetRolePermissionsQueryHandler.cs new file mode 100644 index 000000000..797adcdb2 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/GetRolePermissionsQueryHandler.cs @@ -0,0 +1,55 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Queries; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using CompanyName.MyMeetings.Modules.UsersMI.Domain.Repositories; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRolePermissions; + +internal class GetRolePermissionsQueryHandler : IQueryHandler>> +{ + private readonly RoleManager _roleManager; + private readonly IReadOnlyPermissionRepository _permissionRepository; + + public GetRolePermissionsQueryHandler( + RoleManager roleManager, + IReadOnlyPermissionRepository permissionRepository) + { + _permissionRepository = permissionRepository; + _roleManager = roleManager; + } + + public async Task>> Handle(GetRolePermissionsQuery request, CancellationToken cancellationToken) + { + var role = _roleManager.Roles + .Where(x => x.Id == request.RoleId) + .Select(x => x) + .FirstOrDefault(); + + if (role is null) + { + return Errors.General.NotFound(request.RoleId, "Role"); + } + + var roleClaims = await _roleManager.GetClaimsAsync(role); + var claims = roleClaims + .Where(x => x.Type == CustomClaimTypes.Permission) + .Select(x => x.Value) + .ToList(); + + // Short circuit + if (!claims.Any()) + { + return Result.Ok(Enumerable.Empty()); + } + + var options = request + .MapToOptions() + .WithCodes(claims); + + var permissions = await _permissionRepository.GetPermissionsAsync(options, cancellationToken); + var result = permissions.Select(p => new PermissionDto(p.Code, p.Name, p.Description)).ToList(); + return result; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/PermissionDto.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/PermissionDto.cs new file mode 100644 index 000000000..637c3c126 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRolePermissions/PermissionDto.cs @@ -0,0 +1,17 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRolePermissions; + +public class PermissionDto +{ + public PermissionDto(string code, string name, string? description) + { + Code = code; + Name = name; + Description = description; + } + + public string Code { get; } + + public string Name { get; } + + public string? Description { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/ById/GetRolesQuery.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/ById/GetRolesQuery.cs new file mode 100644 index 000000000..0054c36e9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/ById/GetRolesQuery.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRoles.ById; + +public class GetRolesQuery : QueryBase> +{ + public GetRolesQuery(Guid roleId) + { + RoleId = roleId; + } + + public Guid RoleId { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/ById/GetRolesQueryHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/ById/GetRolesQueryHandler.cs new file mode 100644 index 000000000..6b2cc947f --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/ById/GetRolesQueryHandler.cs @@ -0,0 +1,34 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Queries; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRoles.ById; + +internal class GetRolesQueryHandler : IQueryHandler> +{ + private readonly RoleManager _roleManager; + + public GetRolesQueryHandler(RoleManager roleManager) + { + _roleManager = roleManager; + } + + public Task> Handle(GetRolesQuery request, CancellationToken cancellationToken) + { + var userRole = (from role in _roleManager.Roles + where role.Id == request.RoleId + select new RoleDto() + { + Id = role.Id, + Name = role.Name ?? string.Empty + }).SingleOrDefault(); + + if (userRole is null) + { + return Task.FromResult(Result.NotFound(Errors.General.NotFound(request.RoleId, "User role"))); + } + + return Task.FromResult(Result.Ok(userRole)); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/Directory/GetRolesQuery.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/Directory/GetRolesQuery.cs new file mode 100644 index 000000000..99d9a7386 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/Directory/GetRolesQuery.cs @@ -0,0 +1,8 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRoles.Directory; + +public class GetRolesQuery : QueryBase>> +{ +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/Directory/GetRolesQueryHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/Directory/GetRolesQueryHandler.cs new file mode 100644 index 000000000..77365ffc2 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/Directory/GetRolesQueryHandler.cs @@ -0,0 +1,28 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Queries; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRoles.Directory; + +internal class GetRolesQueryHandler : IQueryHandler>> +{ + private readonly RoleManager _roleManager; + + public GetRolesQueryHandler(RoleManager roleManager) + { + _roleManager = roleManager; + } + + public Task>> Handle(GetRolesQuery request, CancellationToken cancellationToken) + { + var roles = (from role in _roleManager.Roles + select new RoleDto() + { + Id = role.Id, + Name = role.Name ?? string.Empty + }).ToList(); + + return Task.FromResult(Result.Ok(roles.AsEnumerable())); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/RoleDto.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/RoleDto.cs new file mode 100644 index 000000000..0a621bbe2 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/GetRoles/RoleDto.cs @@ -0,0 +1,8 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRoles; + +public class RoleDto +{ + public Guid Id { get; set; } + + public string Name { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/RenameRole/RenameRoleCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/RenameRole/RenameRoleCommand.cs new file mode 100644 index 000000000..4021744d5 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/RenameRole/RenameRoleCommand.cs @@ -0,0 +1,17 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.RenameRole; + +public class RenameRoleCommand : CommandBase +{ + public RenameRoleCommand(Guid roleId, string name) + { + RoleId = roleId; + Name = name; + } + + public Guid RoleId { get; } + + public string Name { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/RenameRole/RenameRoleCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/RenameRole/RenameRoleCommandHandler.cs new file mode 100644 index 000000000..bf81b1a96 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/RenameRole/RenameRoleCommandHandler.cs @@ -0,0 +1,50 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.RenameRole; + +internal class RenameRoleCommandHandler : ICommandHandler +{ + private readonly RoleManager _roleManager; + + public RenameRoleCommandHandler(RoleManager roleManager) + { + _roleManager = roleManager; + } + + public async Task Handle(RenameRoleCommand request, CancellationToken cancellationToken) + { + var role = (from r in _roleManager.Roles + where r.Id == request.RoleId + select r).SingleOrDefault(); + if (role == null) + { + return Errors.General.NotFound(request.RoleId, "User role"); + } + + // Validate role name + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Errors.General.ValueIsRequired("Role name"); + } + + // Check for duplicate role name + var existingRole = (from r in _roleManager.Roles + where r.Name == request.Name + select r).SingleOrDefault(); + if (existingRole != null && existingRole.Id != role.Id) + { + return Errors.General.ValueMustBeUnique("Role name"); + } + + var result = await _roleManager.SetRoleNameAsync(role, request.Name); + if (!result.Succeeded) + { + return result.Errors.Map().Combine(); + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/SetRolePermissions/SetRolePermissionsCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/SetRolePermissions/SetRolePermissionsCommand.cs new file mode 100644 index 000000000..2bd85b366 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/SetRolePermissions/SetRolePermissionsCommand.cs @@ -0,0 +1,17 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.SetRolePermissions; + +public class SetRolePermissionsCommand : CommandBase +{ + public SetRolePermissionsCommand(Guid roleId, IEnumerable permissions) + { + RoleId = roleId; + Permissions = permissions; + } + + public Guid RoleId { get; } + + public IEnumerable Permissions { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Roles/SetRolePermissions/SetRolePermissionsCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Roles/SetRolePermissions/SetRolePermissionsCommandHandler.cs new file mode 100644 index 000000000..8ada3818a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/Roles/SetRolePermissions/SetRolePermissionsCommandHandler.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.SetRolePermissions; + +internal class SetRolePermissionsCommandHandler : ICommandHandler +{ + private readonly RoleManager _roleManager; + + public SetRolePermissionsCommandHandler(RoleManager roleManager) + { + _roleManager = roleManager; + } + + public async Task Handle(SetRolePermissionsCommand request, CancellationToken cancellationToken) + { + Role? role = GetRoleById(request.RoleId); + if (role is null) + { + return Errors.General.NotFound(request.RoleId, "User role"); + } + + var permissions = request.Permissions ?? Enumerable.Empty(); + + var roleClaims = (await _roleManager.GetClaimsAsync(role)) ?? Enumerable.Empty(); + var permissionsToAdd = permissions.ExceptBy(roleClaims.Select(x => x.Value), y => y).ToList(); + var claimsToRemove = roleClaims.ExceptBy(permissions, y => y.Value).ToList(); + + if (permissionsToAdd.Any()) + { + foreach (var permission in permissionsToAdd) + { + await _roleManager.AddClaimAsync(role, new Claim(CustomClaimTypes.Permission, permission)); + role = GetRoleById(role.Id)!; + } + } + + if (claimsToRemove.Any()) + { + foreach (var claim in claimsToRemove) + { + await _roleManager.RemoveClaimAsync(role, claim); + role = GetRoleById(role.Id)!; + } + } + + return Result.Ok(); + } + + private Role? GetRoleById(Guid roleId) + { + return (from r in _roleManager.Roles + where r.Id == roleId + select r).SingleOrDefault(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/ChangeUserEmailAddress/ChangeUserEmailAddressCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/ChangeUserEmailAddress/ChangeUserEmailAddressCommand.cs new file mode 100644 index 000000000..4b6d8d5b0 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/ChangeUserEmailAddress/ChangeUserEmailAddressCommand.cs @@ -0,0 +1,17 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.ChangeEmailAddress; + +public class ChangeUserEmailAddressCommand : CommandBase +{ + public ChangeUserEmailAddressCommand(Guid userId, string newEmailAddress) + { + UserId = userId; + NewEmailAddress = newEmailAddress; + } + + public Guid UserId { get; } + + public string NewEmailAddress { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/ChangeUserEmailAddress/ChangeUserEmailAddressCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/ChangeUserEmailAddress/ChangeUserEmailAddressCommandHandler.cs new file mode 100644 index 000000000..ea79f3e61 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/ChangeUserEmailAddress/ChangeUserEmailAddressCommandHandler.cs @@ -0,0 +1,44 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Emails; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.ChangeEmailAddress; + +internal class ChangeUserEmailAddressCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + + public ChangeUserEmailAddressCommandHandler(UserManager userManager, IEmailSender emailSender) + { + _userManager = userManager; + _emailSender = emailSender; + } + + public async Task Handle(ChangeUserEmailAddressCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + if (!Email.IsValid(request.NewEmailAddress, out Error? error)) + { + return error!; + } + + var newEmail = Email.Parse(request.NewEmailAddress); + + var changeEmailToken = await _userManager.GenerateChangeEmailTokenAsync(user, newEmail.Address); + var result = await _userManager.ChangeEmailAsync(user, newEmail.Address, changeEmailToken); + if (!result.Succeeded) + { + return result.Errors.Map().Combine(); + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/CreateUserAccountCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/CreateUserAccountCommand.cs new file mode 100644 index 000000000..04a9ad4d9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/CreateUserAccountCommand.cs @@ -0,0 +1,35 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using Newtonsoft.Json; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; + +public class CreateUserAccountCommand : InternalCommandBase> +{ + [JsonConstructor] + public CreateUserAccountCommand(Guid id, Guid userId, string login, string? password, string? name, string? firstName, string? lastName, string? emailAddress) + : base(id) + { + UserId = userId; + Login = login; + Password = password; + Name = name; + FirstName = firstName; + LastName = lastName; + EmailAddress = emailAddress; + } + + public Guid UserId { get; } + + public string Login { get; } + + public string? Password { get; } + + public string? Name { get; } + + public string? FirstName { get; } + + public string? LastName { get; } + + public string? EmailAddress { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/CreateUserAccountCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/CreateUserAccountCommandHandler.cs new file mode 100644 index 000000000..516b30173 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/CreateUserAccountCommandHandler.cs @@ -0,0 +1,77 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Emails; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; + +internal class CreateUserAccountCommandHandler : ICommandHandler> +{ + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + + public CreateUserAccountCommandHandler(UserManager userManager, IEmailSender emailSender) + { + _emailSender = emailSender; + _userManager = userManager; + } + + public async Task> Handle(CreateUserAccountCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByNameAsync(request.Login); + + // If user wasn't found, go ahead and create the new user. + // But if user exists don't tell anyone. -> Protection against account enumeration + if (user is null) + { + Email? email = null; + if (!string.IsNullOrEmpty(request.EmailAddress)) + { + if (!Email.IsValid(request.EmailAddress, out Error? error)) + { + return error!; + } + + email = Email.Parse(request.EmailAddress); + } + + var userResult = ApplicationUser.CreateUser(new UserId(request.UserId), request.Login, email, request.FirstName, request.LastName, request.Name); + if (userResult.IsFailure) + { + return userResult.Error; + } + + user = userResult.Value; + var identityResult = await _userManager.CreateAsync(user); + if (!identityResult.Succeeded) + { + return identityResult.Errors.Map().Combine(); + } + + if (!string.IsNullOrEmpty(request.Password)) + { + var passwordResult = await _userManager.AddPasswordAsync(user, request.Password); + if (!passwordResult.Succeeded) + { + return passwordResult.Errors.Map().Combine(); + } + } + + return Result.Created(user.Id); + } + + // Send email to user informing them that he already have an account. + // This way they can pro actively go out and use the forgot password functionality and reclaim there account. + if (!string.IsNullOrEmpty(user.Email)) + { + var emailMessage = new EmailMessage( + user.Email, + "MyMeetings - Create user account", + $"An account with the provided email address {user.Email} already exists. Please use the forgot password functionality to reclaim your account."); + await _emailSender.SendEmail(emailMessage); + } + + return Result.Ok(Guid.NewGuid()); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/UserRegistrationConfirmedIntegrationEventHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/UserRegistrationConfirmedIntegrationEventHandler.cs new file mode 100644 index 000000000..bf13e8eeb --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/CreateUserAccount/UserRegistrationConfirmedIntegrationEventHandler.cs @@ -0,0 +1,30 @@ +using CompanyName.MyMeetings.Modules.Registrations.IntegrationEvents; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using MediatR; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount +{ + internal class UserRegistrationConfirmedIntegrationEventHandler : INotificationHandler + { + private readonly ICommandsScheduler _commandsScheduler; + + internal UserRegistrationConfirmedIntegrationEventHandler(ICommandsScheduler commandsScheduler) + { + _commandsScheduler = commandsScheduler; + } + + public Task Handle(UserRegistrationConfirmedIntegrationEvent notification, CancellationToken cancellationToken) + { + return _commandsScheduler.EnqueueAsync(new + CreateUserAccountCommand( + Guid.NewGuid(), + notification.UserId, + notification.Login, + notification.Password, + notification.Name, + notification.FirstName, + notification.LastName, + notification.Email)); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/ById/GetUserAccountsQuery.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/ById/GetUserAccountsQuery.cs new file mode 100644 index 000000000..7099636db --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/ById/GetUserAccountsQuery.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserAccounts.ById; + +public class GetUserAccountsQuery : QueryBase> +{ + public GetUserAccountsQuery(Guid userId) + { + UserId = userId; + } + + public Guid UserId { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/ById/GetUserAccountsQueryHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/ById/GetUserAccountsQueryHandler.cs new file mode 100644 index 000000000..bf49e0f04 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/ById/GetUserAccountsQueryHandler.cs @@ -0,0 +1,44 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Queries; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserAccounts.ById; + +internal class GetUserAccountsQueryHandler : IQueryHandler> +{ + private readonly UserManager _userManager; + + public GetUserAccountsQueryHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task> Handle(GetUserAccountsQuery request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + return new UserAccountDto() + { + Id = user.Id, + AccessFailedCount = user.AccessFailedCount, + Email = user.Email, + EmailConfirmed = user.EmailConfirmed, + Name = user.Name, + FirstName = user.FirstName, + LastName = user.LastName, + LockoutEnabled = user.LockoutEnabled, + LockoutEnd = user.LockoutEnd, + UserName = user.UserName!, + NormalizedEmail = user.NormalizedEmail, + NormalizedUserName = user.NormalizedUserName!, + PhoneNumber = user.PhoneNumber, + PhoneNumberConfirmed = user.PhoneNumberConfirmed, + TwoFactorEnabled = user.TwoFactorEnabled + }; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/Directory/GetUserAccountsQuery.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/Directory/GetUserAccountsQuery.cs new file mode 100644 index 000000000..0bed6d14e --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/Directory/GetUserAccountsQuery.cs @@ -0,0 +1,8 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserAccounts.Directory; + +public class GetUserAccountsQuery : QueryBase>> +{ +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/Directory/GetUserAccountsQueryHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/Directory/GetUserAccountsQueryHandler.cs new file mode 100644 index 000000000..8d91af1e4 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/Directory/GetUserAccountsQueryHandler.cs @@ -0,0 +1,41 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Queries; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserAccounts.Directory; + +internal class GetUserAccountsQueryHandler : IQueryHandler>> +{ + private readonly UserManager _userManager; + + public GetUserAccountsQueryHandler(UserManager userManager) + { + _userManager = userManager; + } + + public Task>> Handle(GetUserAccountsQuery request, CancellationToken cancellationToken) + { + var users = (from user in _userManager.Users + select new UserAccountDto() + { + Id = user.Id, + AccessFailedCount = user.AccessFailedCount, + Email = user.Email, + EmailConfirmed = user.EmailConfirmed, + Name = user.Name, + FirstName = user.FirstName, + LastName = user.LastName, + LockoutEnabled = user.LockoutEnabled, + LockoutEnd = user.LockoutEnd, + UserName = user.UserName!, + NormalizedEmail = user.NormalizedEmail, + NormalizedUserName = user.NormalizedUserName!, + PhoneNumber = user.PhoneNumber, + PhoneNumberConfirmed = user.PhoneNumberConfirmed, + TwoFactorEnabled = user.TwoFactorEnabled + }).ToList(); + + return Task.FromResult(Result.Ok(users.AsEnumerable())); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/UserAccountDto.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/UserAccountDto.cs new file mode 100644 index 000000000..26bf13efa --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserAccounts/UserAccountDto.cs @@ -0,0 +1,83 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserAccounts; + +public class UserAccountDto +{ + /// + /// Gets or sets the primary key for this user. + /// + public Guid Id { get; init; } + + /// + /// Gets the name. + /// + public string? Name { get; init; } + + /// + /// Gets or sets the first name. + /// + public string? FirstName { get; init; } + + /// + /// Gets or sets the last name. + /// + public string? LastName { get; init; } + + /// + /// Gets or sets the date and time, in UTC, when any user lockout ends. + /// A value in the past means the user is not locked out. + /// + public DateTimeOffset? LockoutEnd { get; init; } + + /// + /// Gets or sets a flag indicating if two factor authentication is enabled for this user. + /// True if 2fa is enabled, otherwise false. + /// + public bool TwoFactorEnabled { get; init; } + + /// + /// Gets or sets a flag indicating if a user has confirmed their telephone address. + /// True if the telephone number has been confirmed, otherwise false. + /// + public bool PhoneNumberConfirmed { get; init; } + + /// + /// Gets or sets a telephone number for the user. + /// + public string? PhoneNumber { get; init; } + + /// + /// Gets or sets a flag indicating if a user has confirmed their email address. + /// True if the email address has been confirmed, otherwise false. + /// + public bool EmailConfirmed { get; init; } + + /// + /// Gets or sets the normalized email address for this user. + /// + public string? NormalizedEmail { get; init; } + + /// + /// Gets or sets the email address for this user. + /// + public string? Email { get; init; } + + /// + /// Gets or sets the normalized user name for this user. + /// + public required string NormalizedUserName { get; init; } + + /// + /// Gets or sets the login for this user. + /// + public required string UserName { get; init; } + + /// + /// True if the user could be locked out, otherwise false. + /// + public bool LockoutEnabled { get; init; } + + /// + /// Gets or sets the number of failed login attempts for the current user. + /// + public int AccessFailedCount { get; init; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/GetUserRolesQuery.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/GetUserRolesQuery.cs new file mode 100644 index 000000000..19baf44fb --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/GetUserRolesQuery.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserRoles; + +public class GetUserRolesQuery : QueryBase>> +{ + public GetUserRolesQuery(Guid userId) + { + UserId = userId; + } + + public Guid UserId { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/GetUserRolesQueryHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/GetUserRolesQueryHandler.cs new file mode 100644 index 000000000..8aa2f485a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/GetUserRolesQueryHandler.cs @@ -0,0 +1,43 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Queries; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserRoles; + +internal class GetUserRolesQueryHandler : IQueryHandler>> +{ + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + + public GetUserRolesQueryHandler(UserManager userManager, RoleManager roleManager) + { + _userManager = userManager; + _roleManager = roleManager; + } + + public async Task>> Handle(GetUserRolesQuery request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + var roleNames = await _userManager.GetRolesAsync(user); + if (!roleNames.Any()) + { + return Result.Ok(Enumerable.Empty()); + } + + var roles = (from role in _roleManager.Roles + where roleNames.Contains(role.Name ?? string.Empty) + select new RoleDto() + { + Id = role.Id, + Name = role.Name! + }).ToList(); + + return Result.Ok(roles.AsEnumerable()); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/RoleDto.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/RoleDto.cs new file mode 100644 index 000000000..8fdb1559b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/GetUserRoles/RoleDto.cs @@ -0,0 +1,8 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserRoles; + +public class RoleDto +{ + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserPermissions/SetUserPermissionsCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserPermissions/SetUserPermissionsCommand.cs new file mode 100644 index 000000000..e2095ef65 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserPermissions/SetUserPermissionsCommand.cs @@ -0,0 +1,17 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.SetUserPermissions; + +public class SetUserPermissionsCommand : CommandBase +{ + public SetUserPermissionsCommand(Guid userId, IEnumerable permissions) + { + UserId = userId; + Permissions = permissions; + } + + public Guid UserId { get; } + + public IEnumerable Permissions { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserPermissions/SetUserPermissionsCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserPermissions/SetUserPermissionsCommandHandler.cs new file mode 100644 index 000000000..35fedf28c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserPermissions/SetUserPermissionsCommandHandler.cs @@ -0,0 +1,47 @@ +using System.Security.Claims; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.SetUserPermissions; + +internal class SetUserPermissionsCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + + public SetUserPermissionsCommandHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(SetUserPermissionsCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + var permissions = request.Permissions ?? Enumerable.Empty(); + + var userClaims = (await _userManager.GetClaimsAsync(user)) ?? Enumerable.Empty(); + var permissionsToAdd = permissions.ExceptBy(userClaims.Select(x => x.Value), y => y).ToList(); + var claimsToRemove = userClaims.ExceptBy(permissions, y => y.Value).ToList(); + + if (permissionsToAdd.Any()) + { + await _userManager.AddClaimsAsync(user, permissionsToAdd.Select(x => new Claim(CustomClaimTypes.Permission, x)).ToArray()); + user = await _userManager.FindByIdAsync(user.Id.ToString()); + } + + if (claimsToRemove.Any()) + { + await _userManager.RemoveClaimsAsync(user!, claimsToRemove); + user = await _userManager.FindByIdAsync(user!.Id.ToString()); + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserRoles/SetUserRolesCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserRoles/SetUserRolesCommand.cs new file mode 100644 index 000000000..420381ef6 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserRoles/SetUserRolesCommand.cs @@ -0,0 +1,17 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.SetUserRoles; + +public class SetUserRolesCommand : CommandBase +{ + public SetUserRolesCommand(Guid userId, IEnumerable roleIds) + { + UserId = userId; + RoleIds = roleIds; + } + + public Guid UserId { get; } + + public IEnumerable RoleIds { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserRoles/SetUserRolesCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserRoles/SetUserRolesCommandHandler.cs new file mode 100644 index 000000000..4c134e17f --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/SetUserRoles/SetUserRolesCommandHandler.cs @@ -0,0 +1,70 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.SetUserRoles; + +internal class SetUserRolesCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + + public SetUserRolesCommandHandler(UserManager userManager, RoleManager roleManager) + { + _userManager = userManager; + _roleManager = roleManager; + } + + public async Task Handle(SetUserRolesCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user == null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + List roleNames = new(); + foreach (var roleId in request.RoleIds) + { + var role = _roleManager.Roles + .Where(x => x.Id == roleId) + .Select(x => new { x.Id, x.Name }) + .FirstOrDefault(); + + if (role is null) + { + return Errors.General.NotFound(roleId, "Role"); + } + + if (role.Name is not null) + { + roleNames.Add(role.Name); + } + } + + var userRoles = await _userManager.GetRolesAsync(user); + var rolesToAdd = roleNames.ExceptBy(userRoles, y => y).ToList(); + var rolesToRemove = userRoles.ExceptBy(roleNames, y => y).ToList(); + + if (rolesToAdd.Any()) + { + var result = await _userManager.AddToRolesAsync(user, rolesToAdd); + if (!result.Succeeded) + { + return result.Errors.Map().Combine(); + } + } + + if (rolesToRemove.Any()) + { + var result = await _userManager.RemoveFromRolesAsync(user, rolesToRemove); + if (!result.Succeeded) + { + return result.Errors.Map().Combine(); + } + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UnlockUserAccount/UnlockUserAccountCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UnlockUserAccount/UnlockUserAccountCommand.cs new file mode 100644 index 000000000..5b1391ec4 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UnlockUserAccount/UnlockUserAccountCommand.cs @@ -0,0 +1,14 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.UnlockUserAccount; + +public class UnlockUserAccountCommand : CommandBase +{ + public UnlockUserAccountCommand(Guid userId) + { + UserId = userId; + } + + public Guid UserId { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UnlockUserAccount/UnlockUserAccountCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UnlockUserAccount/UnlockUserAccountCommandHandler.cs new file mode 100644 index 000000000..9c42c0442 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UnlockUserAccount/UnlockUserAccountCommandHandler.cs @@ -0,0 +1,33 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.UnlockUserAccount; + +internal class UnlockUserAccountCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + + public UnlockUserAccountCommandHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(UnlockUserAccountCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + var result = await _userManager.SetLockoutEndDateAsync(user, null); + if (!result.Succeeded) + { + return result.Errors.Map().Combine(); + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UpdateUserAccount/UpdateUserAccountCommand.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UpdateUserAccount/UpdateUserAccountCommand.cs new file mode 100644 index 000000000..d8449a62a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UpdateUserAccount/UpdateUserAccountCommand.cs @@ -0,0 +1,23 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.UpdateUserAccount; + +public class UpdateUserAccountCommand : CommandBase +{ + public UpdateUserAccountCommand(Guid userId, string name, string? firstName, string? lastName) + { + UserId = userId; + Name = name; + FirstName = firstName; + LastName = lastName; + } + + public Guid UserId { get; } + + public string Name { get; } + + public string? FirstName { get; } + + public string? LastName { get; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UpdateUserAccount/UpdateUserAccountCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UpdateUserAccount/UpdateUserAccountCommandHandler.cs new file mode 100644 index 000000000..c6eb29f66 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Application/UserAccounts/UpdateUserAccount/UpdateUserAccountCommandHandler.cs @@ -0,0 +1,42 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.UpdateUserAccount; + +internal class UpdateUserAccountCommandHandler : ICommandHandler +{ + private readonly UserManager _userManager; + + public UpdateUserAccountCommandHandler(UserManager userManager) + { + _userManager = userManager; + } + + public async Task Handle(UpdateUserAccountCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user is null) + { + return Errors.General.NotFound(request.UserId, "User"); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Errors.General.ValueIsRequired(nameof(request.Name)); + } + + user.Name = request.Name.Trim(); + user.FirstName = request.FirstName?.Trim(); + user.LastName = request.LastName?.Trim(); + + var result = await _userManager.UpdateAsync(user); + if (!result.Succeeded) + { + return result.Errors.Map().Combine(); + } + + return Result.Ok(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/ApplicationUser.cs b/src/Modules/Users/MicrosoftIdentity/Domain/ApplicationUser.cs new file mode 100644 index 000000000..7c060a4c7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/ApplicationUser.cs @@ -0,0 +1,79 @@ +using CompanyName.MyMeetings.BuildingBlocks.Domain; +using CSharpFunctionalExtensions; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain; + +public class ApplicationUser : IdentityUser, IEntity +{ + private List? _domainEvents; + + protected ApplicationUser(string userName) + : base(userName) + { + } + + private ApplicationUser() + : base() + { + // Only EF. + } + + public static Result CreateUser( + UserId userId, + string login, + Email? email, + string? firstName, + string? lastName, + string? name) + { + if (string.IsNullOrEmpty(login)) + { + return Errors.General.ValueIsRequired(nameof(login)); + } + + return new ApplicationUser(login) + { + Id = userId.Value, + FirstName = firstName, + LastName = lastName, + Name = name, + Email = email + }; + } + + /// + /// Domain events occurred. + /// + public IReadOnlyCollection? DomainEvents => _domainEvents?.AsReadOnly(); + + public void ClearDomainEvents() + { + _domainEvents?.Clear(); + } + + /// + /// Add domain event. + /// + /// Domain event. + protected void AddDomainEvent(IDomainEvent domainEvent) + { + _domainEvents ??= new List(); + + _domainEvents.Add(domainEvent); + } + + protected void CheckRule(IBusinessRule rule) + { + if (rule.IsBroken()) + { + throw new BusinessRuleValidationException(rule); + } + } + + public virtual string? Name { get; set; } + + public virtual string? FirstName { get; set; } + + public virtual string? LastName { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/CompanyName.MyMeetings.Modules.UsersMI.Domain.csproj b/src/Modules/Users/MicrosoftIdentity/Domain/CompanyName.MyMeetings.Modules.UsersMI.Domain.csproj new file mode 100644 index 000000000..0cb45272e --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/CompanyName.MyMeetings.Modules.UsersMI.Domain.csproj @@ -0,0 +1,11 @@ + + + enable + enable + + + + + + + diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/Email.cs b/src/Modules/Users/MicrosoftIdentity/Domain/Email.cs new file mode 100644 index 000000000..67a5c6ac7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/Email.cs @@ -0,0 +1,67 @@ +using CompanyName.MyMeetings.BuildingBlocks.Domain; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain +{ + public class Email : ValueObject + { + public string Address { get; } + + private Email(string address) + { + Address = address; + } + + public static bool IsValid(string emailAddress) + { + try + { + var mailAddress = new System.Net.Mail.MailAddress(emailAddress); + return mailAddress.Address == emailAddress; + } + catch + { + return false; + } + } + + public static bool IsValid(string emailAddress, out Error? error) + { + error = null; + var result = IsValid(emailAddress); + if (!result) + { + error = Errors.General.ValueIsInvalid($"'{emailAddress}' is not a valid email address."); + } + + return result; + } + + public static Email Parse(string emailAddress) + { + if (!IsValid(emailAddress, out Error? error)) + { + throw new InvalidCastException(error!.Message); + } + + return new Email(emailAddress); + } + + public static bool TryParse(string emailAddress, out Email? email) + { + email = null; + if (!IsValid(emailAddress)) + { + return false; + } + + email = new Email(emailAddress); + return true; + } + + public static implicit operator string?(Email? email) + => email?.Address; + + public static explicit operator Email?(string? emailAddress) + => emailAddress is not null ? Parse(emailAddress) : null; + } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/Errors/Error.cs b/src/Modules/Users/MicrosoftIdentity/Domain/Errors/Error.cs new file mode 100644 index 000000000..6adb927b5 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/Errors/Error.cs @@ -0,0 +1,106 @@ +using System.Text.Json.Serialization; +using CSharpFunctionalExtensions; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain +{ + /// + /// Class that represents an error. + /// + /// Errors should not be instantiated arbitrarily, but should be taken from a predefined set of errors. + /// Error messages should not be handled by the domain layer. This is an application concern as you might + /// want to translate them based on user settings. The Error it self is a value object meaning that error will + /// not have an identity and can be use interchangeably. + /// Further more as the Error class is part of the domain layer this is technically a violation of domain model + /// purity and as said it shouldn't deal with application concerns. But this is a minor concession. + /// + public class Error : ValueObject, ICombine + { + private readonly List _errors = new(); + + public Error(string code, string? message) + : this() + { + Code = code ?? "unknown"; + Message = message; + } + + internal Error() + : base() + { + Code = string.Empty; + } + + internal Error(IEnumerable errors) + : this() + { + ArgumentNullException.ThrowIfNull(errors); + + _errors.AddRange(errors); + } + + /// + /// Error code. + /// The code is going to be part of the contract between the API and it's clients. + /// Once published error codes should not be changed. + /// + public string Code { get; } + + /// + /// The error message. + /// Error messages are just for informational purposes. So we can specify some additional information for debugging. + /// Ideally the client should not show that message to the end user and instead should map there own error messages + /// onto the code. + /// + public string? Message { get; } + + [JsonIgnore] + public IReadOnlyCollection Errors => _errors; + + public ICombine Combine(ICombine value) + { + if (value is not Error error) + { + throw new ArgumentException($"Value is not of type {nameof(Error)}"); + } + + var errorList = new List(); + if (!string.IsNullOrEmpty(Code)) + { + errorList.Add(new Error(Code, Message)); + } + + if (_errors is not null) + { + errorList.AddRange(_errors); + } + + if (!string.IsNullOrEmpty(error.Code)) + { + errorList.Add(new Error(error.Code, error.Message)); + } + + if (error._errors.Count > 0) + { + errorList.AddRange(error._errors); + } + + return new Error(errorList); + } + + public override string ToString() + { + if (string.IsNullOrEmpty(Message)) + { + return Code; + } + + return $"{Code}: {Message}"; + } + + protected override IEnumerable GetEqualityComponents() + { + // Only the code field is required as the code will be part of the contract between the API and it's clients. + yield return Code; + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/Errors/ErrorExtensions.cs b/src/Modules/Users/MicrosoftIdentity/Domain/Errors/ErrorExtensions.cs new file mode 100644 index 000000000..3242bbb60 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/Errors/ErrorExtensions.cs @@ -0,0 +1,60 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain +{ + public static class ErrorExtensions + { + public static Error Combine(this IEnumerable errors) + => MergeErrors(errors); + + private static Error MergeErrors(IEnumerable errors) + { + if (errors is null) + { + throw new ArgumentNullException(nameof(errors)); + } + + if (errors.Count() <= 0) + { + return new Error(); + } + + // Take the first error of the collection + var errorDestination = errors.First(); + + if (errors.Count() == 1) + { + return errorDestination; + } + + // and merge the remaining one's into the first one + for (int i = 1; i < errors.Count(); i++) + { + var errorSource = errors.ElementAt(i); + errorSource = MergeChildren(errorSource); + + errorDestination = (Error)errorDestination.Combine(errorSource); + } + + return errorDestination; + } + + private static Error MergeChildren(Error parent) + { + if (parent is null) + { + throw new ArgumentNullException(nameof(parent)); + } + + var children = parent.Errors; + if (children is not null) + { + foreach (var child in children) + { + MergeChildren(child); + parent.Combine(child); + } + } + + return parent; + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/Errors/Errors.cs b/src/Modules/Users/MicrosoftIdentity/Domain/Errors/Errors.cs new file mode 100644 index 000000000..b0b1fe9ad --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/Errors/Errors.cs @@ -0,0 +1,129 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain +{ + /// + /// This class will enumerate all possible errors of the application. + /// + /// As the Error class, this class is an other violation in domain model purity, + /// because we are combining all errors in one list including errors that don't + /// belong to the domain layer. Technically those should be separated into domain + /// and none domain errors. But this is a small concession and it's more useful to keep the + /// full list of errors in one place rather then maintain perfect separation. + /// + public static partial class Errors + { + public static class General + { + public static Error NotFound() => + new Error("record.not.found", "Record not found."); + + public static Error NotFound(string message) => + new Error("record.not.found", message); + + public static Error NotFound(long id) => + new Error("record.not.found", $"Record not found for Id '{id}'."); + + public static Error NotFound(long id, string itemName) => + new Error("record.not.found", $"{itemName} not found for Id '{id}'."); + + public static Error NotFound(Guid id) => + new Error("record.not.found", $"Record not found for Id '{id}'."); + + public static Error NotFound(Guid id, string itemName) => + new Error("record.not.found", $"{itemName} not found for Id '{id}'."); + + public static Error NotFound(string value, string itemName) => + new Error("record.not.found", $"{itemName} not found for value '{value}'."); + + public static Error ValueIsInvalid() => + new Error("value.is.invalid", "Value is invalid."); + + public static Error ValueIsInvalid(string message) => + new Error("value.is.invalid", message); + + public static Error ValueIsRequired() => + new Error("value.is.required", "Value is required."); + + public static Error ValueIsRequired(string? name = null) + { + string label = name == null ? "Value" : name.Trim(); + return new Error("value.is.required", $"{label} is required."); + } + + public static Error ValueMustBeUnique(string name) => + new Error("value.must.be.unique", $"Record already present for Value '{name}'"); + + public static Error ValueMustBePositive(string valueName) => + new("value.must.be.positive", $"{valueName} must be positive."); + + public static Error ValueMustBeGreaterThan(string valueName, int numberToCompare) => + new("value.must.be.greater.than", $"{valueName} must be greater than {numberToCompare}."); + + public static Error ValueMustBeLessThan(string valueName, int numberToCompare) => + new("value.must.be.less.than", $"{valueName} must be less than {numberToCompare}."); + + public static Error InvalidLength(string? name = null, int? maxLength = null) + { + string label = name == null ? " " : $" {name} "; + string lengthHint = maxLength == null ? string.Empty : $" The length may not be longer than {maxLength} characters."; + return new Error("invalid.string.length", $"Invalid{label}length.{lengthHint}"); + } + + public static Error CollectionIsTooSmall(int min, int current) => + new Error("collection.is.too.small", $"The collection must contain {min} items or more. It contains {current} items."); + + public static Error CollectionIsTooLarge(int max, int current) => + new Error("collection.is.too.large", $"The collection must contain {max} items or less. It contains {current} items."); + + public static Error InternalServerError(string message) => + new Error("internal.server.error", message); + + public static Error InvalidRequest() => + new Error("invalid.request", "Invalid request"); + + public static Error InvalidRequest(string message) => + new Error("invalid.request", message); + + public static Error InvalidModel() => + new Error("invalid.model", "Invalid model"); + } + + public static class Authentication + { + public static Error InvalidToken() => + new Error("invalid.token", "Invalid token"); + + public static Error InvalidToken(string message) => + new Error("invalid.token", message); + + public static Error LoginRequestExpired() => + new Error("login.request.expired", "Your login request has expired, please start over."); + + public static Error AuthenticatorKeyNotFound() => + new Error("authenticator.key.not.found", "Authenticator key could not be retrieved."); + + public static Error InvalidTwoFactorAuthenticationToken() => + new Error("invalid.two.factor.authentication.token", "Two factor authentication token is invalid."); + } + + public static class Authorization + { + public static Error Forbidden() => + new Error("forbidden", "You do not have permission to access this resource."); + + public static Error Forbidden(string message) => + new Error("forbidden", message); + } + + public static class UserAccess + { + public static Error InvalidUserNameOrPassword => + new Error("invalid.username.or.password", "Invalid user name or password."); + + public static Error EmailNotConfirmed => + new Error("email.not.confirmed", "Email is not confirmed."); + + public static Error LoginNotAllowed => + new Error("login.not.allowed", "Not allowed to login."); + } + } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/Permission.cs b/src/Modules/Users/MicrosoftIdentity/Domain/Permission.cs new file mode 100644 index 000000000..a9f9aef1d --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/Permission.cs @@ -0,0 +1,10 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain; + +public class Permission +{ + public string Code { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/Repositories/IReadOnlyPermissionRepository.cs b/src/Modules/Users/MicrosoftIdentity/Domain/Repositories/IReadOnlyPermissionRepository.cs new file mode 100644 index 000000000..4a3e33f46 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/Repositories/IReadOnlyPermissionRepository.cs @@ -0,0 +1,8 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain.Repositories; + +public interface IReadOnlyPermissionRepository +{ + Task> GetPermissionsAsync(GetPermissionsOptions options, CancellationToken cancellationToken = default); +} + +public record GetPermissionsOptions(IEnumerable? PermissionCodes); \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/Repositories/IUserRefreshTokenRepository.cs b/src/Modules/Users/MicrosoftIdentity/Domain/Repositories/IUserRefreshTokenRepository.cs new file mode 100644 index 000000000..1045b659a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/Repositories/IUserRefreshTokenRepository.cs @@ -0,0 +1,11 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain.Repositories +{ + public interface IUserRefreshTokenRepository + { + Task GetByJwtIdAsync(string id, CancellationToken cancellationToken); + + void Add(UserRefreshToken userRefreshToken); + + Task DeleteAsync(UserRefreshToken userRefreshToken, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/Role.cs b/src/Modules/Users/MicrosoftIdentity/Domain/Role.cs new file mode 100644 index 000000000..63775156c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/Role.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain +{ + public class Role : IdentityRole + { + public Role() + : base() + { + } + + public Role(string roleName) + : base(roleName) + { + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/UserId.cs b/src/Modules/Users/MicrosoftIdentity/Domain/UserId.cs new file mode 100644 index 000000000..51ef76534 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/UserId.cs @@ -0,0 +1,12 @@ +using CompanyName.MyMeetings.BuildingBlocks.Domain; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain +{ + public class UserId : TypedIdValueBase + { + public UserId(Guid value) + : base(value) + { + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/UserRefreshToken.cs b/src/Modules/Users/MicrosoftIdentity/Domain/UserRefreshToken.cs new file mode 100644 index 000000000..3f2690268 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/UserRefreshToken.cs @@ -0,0 +1,43 @@ +using CompanyName.MyMeetings.BuildingBlocks.Domain; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain +{ + public class UserRefreshToken : Entity, IAggregateRoot + { + private UserRefreshToken() + : base() + { + Id = new UserRefreshTokenId(Guid.NewGuid()); + IsRevoked = false; + AddedDate = DateTime.UtcNow; + ExpiryDate = DateTime.UtcNow.AddMonths(6); + } + + private UserRefreshToken(ApplicationUser user, string jwtId, string token) + : this() + { + User = user; + JwtId = jwtId; + Token = token; + } + + public static UserRefreshToken Create(ApplicationUser user, string jwtId, string token) + { + return new UserRefreshToken(user, jwtId, token); + } + + public UserRefreshTokenId Id { get; protected set; } + + public ApplicationUser User { get; protected set; } = null!; + + public string Token { get; protected set; } = null!; + + public string JwtId { get; protected set; } = null!; + + public bool IsRevoked { get; set; } + + public DateTime AddedDate { get; set; } + + public DateTime ExpiryDate { get; set; } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/UserRefreshTokenId.cs b/src/Modules/Users/MicrosoftIdentity/Domain/UserRefreshTokenId.cs new file mode 100644 index 000000000..67aa46533 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/UserRefreshTokenId.cs @@ -0,0 +1,12 @@ +using CompanyName.MyMeetings.BuildingBlocks.Domain; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain +{ + public class UserRefreshTokenId : TypedIdValueBase + { + public UserRefreshTokenId(Guid value) + : base(value) + { + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Domain/UserRole.cs b/src/Modules/Users/MicrosoftIdentity/Domain/UserRole.cs new file mode 100644 index 000000000..b14710484 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Domain/UserRole.cs @@ -0,0 +1,23 @@ +using CSharpFunctionalExtensions; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Domain +{ + public class UserRole : ValueObject + { + public static UserRole Member => new UserRole(nameof(Member)); + + public static UserRole Administrator => new UserRole(nameof(Administrator)); + + public string Value { get; } + + private UserRole(string value) + { + Value = value; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return Value; + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.csproj b/src/Modules/Users/MicrosoftIdentity/Infrastructure/CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.csproj new file mode 100644 index 000000000..cf509f8f8 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.csproj @@ -0,0 +1,12 @@ + + + + enable + enable + + + + + + + \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/AllConstructorFinder.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/AllConstructorFinder.cs new file mode 100644 index 000000000..cb44e222b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/AllConstructorFinder.cs @@ -0,0 +1,20 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Autofac.Core.Activators.Reflection; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration; + +internal class AllConstructorFinder : IConstructorFinder +{ + private static readonly ConcurrentDictionary Cache = + new ConcurrentDictionary(); + + public ConstructorInfo[] FindConstructors(Type targetType) + { + var result = Cache.GetOrAdd( + targetType, + t => t.GetTypeInfo().DeclaredConstructors.ToArray()); + + return result.Length > 0 ? result : throw new NoConstructorsFoundException(targetType, this); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Assemblies.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Assemblies.cs new file mode 100644 index 000000000..78ecb9e50 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Assemblies.cs @@ -0,0 +1,20 @@ +using System.Reflection; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration; + +/// +/// Static helper class referring to the module assemblies. +/// +internal static class Assemblies +{ + /// + /// Get the application assembly. + /// + public static readonly Assembly Application = typeof(IUserAccessModule).Assembly; + + /// + /// Get the infrastructure assembly. + /// + public static readonly Assembly Infrastructure = typeof(UserAccessStartup).Assembly; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ConfigurationExtensions.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ConfigurationExtensions.cs new file mode 100644 index 000000000..0b2151026 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ConfigurationExtensions.cs @@ -0,0 +1,34 @@ +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration; + +internal static class ConfigurationExtensions +{ + public static UserAccessConfiguration GetUserAccessConfiguration(this IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration, nameof(configuration)); + + var userManagementSection = configuration.GetSection("Modules:UserAccess"); + return userManagementSection.Get() ?? throw new InvalidOperationException("UserAccess configuration section is missing."); + } + + public static string? GetValidAudience(this UserAccessConfiguration configuration) + => configuration.Security?.JwtAudience; + + public static bool ShouldValidateAudience(this UserAccessConfiguration configuration) + => !string.IsNullOrEmpty(configuration.GetValidAudience()); + + public static string? GetValidIssuer(this UserAccessConfiguration configuration) + => configuration.Security?.JwtIssuer; + + public static bool ShouldValidateIssuer(this UserAccessConfiguration configuration) + => !string.IsNullOrEmpty(configuration.GetValidIssuer()); + + public static byte[] GetJwtSecretKeyEncrypted(this UserAccessConfiguration configuration) + => Encoding.ASCII.GetBytes(configuration.Security?.JwtSecretKey ?? string.Empty); + + public static SymmetricSecurityKey GetIssuerSigningKey(this UserAccessConfiguration configuration) + => new SymmetricSecurityKey(configuration.GetJwtSecretKeyEncrypted()); +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/DataAccessModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/DataAccessModule.cs new file mode 100644 index 000000000..7025fabf6 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/DataAccessModule.cs @@ -0,0 +1,41 @@ +using Autofac; +using CompanyName.MyMeetings.BuildingBlocks.Application.Data; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess; + +internal class DataAccessModule : Autofac.Module +{ + private readonly string _databaseConnectionString; + + internal DataAccessModule(string databaseConnectionString) + { + _databaseConnectionString = databaseConnectionString; + } + + protected override void Load(ContainerBuilder builder) + { + builder.Register(x => new DatabaseConfiguration(_databaseConnectionString)) + .As(); + + builder.RegisterType() + .As() + .WithParameter("connectionString", _databaseConnectionString) + .InstancePerLifetimeScope(); + + builder + .RegisterType() + .AsSelf() + .As() + .InstancePerLifetimeScope(); + + var infrastructureAssembly = typeof(UserAccessContext).Assembly; + + builder.RegisterAssemblyTypes(infrastructureAssembly) + .Where(type => type.Name.EndsWith("Repository")) + .AsImplementedInterfaces() + .InstancePerLifetimeScope() + .FindConstructorsWith(new AllConstructorFinder()); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/DatabaseConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/DatabaseConfiguration.cs new file mode 100644 index 000000000..7a438146b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/DatabaseConfiguration.cs @@ -0,0 +1,12 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess +{ + public class DatabaseConfiguration : IDatabaseConfiguration + { + public DatabaseConfiguration(string connectionString) + { + ConnectionString = connectionString; + } + + public string ConnectionString { get; } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/IDatabaseConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/IDatabaseConfiguration.cs new file mode 100644 index 000000000..0b5896b90 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/IDatabaseConfiguration.cs @@ -0,0 +1,7 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess +{ + public interface IDatabaseConfiguration + { + string ConnectionString { get; } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/ApplicationUserEntityTypeConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/ApplicationUserEntityTypeConfiguration.cs new file mode 100644 index 000000000..dd7e7b92e --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/ApplicationUserEntityTypeConfiguration.cs @@ -0,0 +1,17 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess.TypeConfigurations; + +internal class ApplicationUserEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Users", "usersmi"); + + builder.Property("Name").HasMaxLength(255); + builder.Property("FirstName").HasMaxLength(100); + builder.Property("LastName").HasMaxLength(100); + } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityRoleClaimEntityTypeConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityRoleClaimEntityTypeConfiguration.cs new file mode 100644 index 000000000..f1b4c76a6 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityRoleClaimEntityTypeConfiguration.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess.TypeConfigurations; + +internal class IdentityRoleClaimEntityTypeConfiguration : IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder> builder) + { + builder.ToTable("RoleClaims", "usersmi"); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserClaimEntityTypeConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserClaimEntityTypeConfiguration.cs new file mode 100644 index 000000000..e1de9f214 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserClaimEntityTypeConfiguration.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess.TypeConfigurations; + +internal class IdentityUserClaimEntityTypeConfiguration : IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder> builder) + { + builder.ToTable("UserClaims", "usersmi"); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserLoginEntityTypeConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserLoginEntityTypeConfiguration.cs new file mode 100644 index 000000000..e53864907 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserLoginEntityTypeConfiguration.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess.TypeConfigurations; + +internal class IdentityUserLoginEntityTypeConfiguration : IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder> builder) + { + builder.ToTable("UserLogins", "usersmi"); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserRoleEntityTypeConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserRoleEntityTypeConfiguration.cs new file mode 100644 index 000000000..de42d1953 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserRoleEntityTypeConfiguration.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess.TypeConfigurations; + +internal class IdentityUserRoleEntityTypeConfiguration : IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder> builder) + { + builder.ToTable("UserRoles", "usersmi"); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserTokenEntityTypeConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserTokenEntityTypeConfiguration.cs new file mode 100644 index 000000000..15ca7fbee --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/IdentityUserTokenEntityTypeConfiguration.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess.TypeConfigurations; + +internal class IdentityUserTokenEntityTypeConfiguration : IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder> builder) + { + builder.ToTable("UserTokens", "usersmi"); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/RoleEntityTypeConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/RoleEntityTypeConfiguration.cs new file mode 100644 index 000000000..5a366c659 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/RoleEntityTypeConfiguration.cs @@ -0,0 +1,13 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess.TypeConfigurations; + +internal class RoleEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Roles", "usersmi"); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/UserRefreshTokenEntityTypeConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/UserRefreshTokenEntityTypeConfiguration.cs new file mode 100644 index 000000000..9ba35dced --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/DataAccess/TypeConfigurations/UserRefreshTokenEntityTypeConfiguration.cs @@ -0,0 +1,22 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess.TypeConfigurations; + +internal class UserRefreshTokenEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("UserRefreshTokens", "usersmi").HasKey(k => k.Id); + + builder.Property(p => p.Token).IsRequired(); + builder.Property(p => p.JwtId).IsRequired(); + builder.Property(p => p.IsRevoked).IsRequired(); + builder.Property(p => p.AddedDate).IsRequired(); + builder.Property(p => p.ExpiryDate).IsRequired(); + + builder.HasOne(p => p.User).WithMany().IsRequired(); + builder.Navigation(p => p.User).AutoInclude(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Email/EmailModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Email/EmailModule.cs new file mode 100644 index 000000000..5445b4829 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Email/EmailModule.cs @@ -0,0 +1,34 @@ +using Autofac; +using CompanyName.MyMeetings.BuildingBlocks.Application.Emails; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Emails; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Email; + +internal class EmailModule : Module +{ + private readonly IEmailSender? _emailSender; + private readonly EmailsConfiguration _configuration; + + public EmailModule( + EmailsConfiguration configuration, + IEmailSender? emailSender) + { + _configuration = configuration; + _emailSender = emailSender; + } + + protected override void Load(ContainerBuilder builder) + { + if (_emailSender != null) + { + builder.RegisterInstance(_emailSender); + } + else + { + builder.RegisterType() + .As() + .WithParameter("configuration", _configuration) + .InstancePerLifetimeScope(); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/EventsBusModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/EventsBusModule.cs new file mode 100644 index 000000000..ba9dcc04b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/EventsBusModule.cs @@ -0,0 +1,28 @@ +using Autofac; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.EventBus; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.EventsBus; + +internal class EventsBusModule : Autofac.Module +{ + private readonly IEventsBus? _eventsBus; + + public EventsBusModule(IEventsBus? eventsBus) + { + _eventsBus = eventsBus; + } + + protected override void Load(ContainerBuilder builder) + { + if (_eventsBus != null) + { + builder.RegisterInstance(_eventsBus).SingleInstance(); + } + else + { + builder.RegisterType() + .As() + .SingleInstance(); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/EventsBusStartup.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/EventsBusStartup.cs new file mode 100644 index 000000000..f0c0c0b73 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/EventsBusStartup.cs @@ -0,0 +1,30 @@ +using Autofac; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.EventBus; +using CompanyName.MyMeetings.Modules.Registrations.IntegrationEvents; +using Serilog; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.EventsBus; + +public static class EventsBusStartup +{ + public static void Initialize( + ILogger logger) + { + SubscribeToIntegrationEvents(logger); + } + + private static void SubscribeToIntegrationEvents(ILogger logger) + { + var eventBus = UserAccessCompositionRoot.BeginLifetimeScope().Resolve(); + + SubscribeToIntegrationEvent(eventBus, logger); + } + + private static void SubscribeToIntegrationEvent(IEventsBus eventBus, ILogger logger) + where T : IntegrationEvent + { + logger.Information("Subscribe to {@IntegrationEvent}", typeof(T).FullName); + eventBus.Subscribe( + new IntegrationEventGenericHandler()); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/IntegrationEventGenericHandler.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/IntegrationEventGenericHandler.cs new file mode 100644 index 000000000..c48ca907a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/EventsBus/IntegrationEventGenericHandler.cs @@ -0,0 +1,38 @@ +using Autofac; +using CompanyName.MyMeetings.BuildingBlocks.Application.Data; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.EventBus; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Serialization; +using Dapper; +using Newtonsoft.Json; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.EventsBus; + +internal class IntegrationEventGenericHandler : IIntegrationEventHandler + where T : IntegrationEvent +{ + public async Task Handle(T @event) + { + using (var scope = UserAccessCompositionRoot.BeginLifetimeScope()) + { + using (var connection = scope.Resolve().GetOpenConnection()) + { + string type = @event.GetType().FullName!; + var data = JsonConvert.SerializeObject(@event, new JsonSerializerSettings + { + ContractResolver = new AllPropertiesContractResolver() + }); + + var sql = "INSERT INTO [usersmi].[InboxMessages] (Id, OccurredOn, Type, Data) " + + "VALUES (@Id, @OccurredOn, @Type, @Data)"; + + await connection.ExecuteScalarAsync(sql, new + { + @event.Id, + @event.OccurredOn, + type, + data + }); + } + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/CustomPasswordHasher.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/CustomPasswordHasher.cs new file mode 100644 index 000000000..45b7f8a94 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/CustomPasswordHasher.cs @@ -0,0 +1,21 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Security; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Identity; + +public class CustomPasswordHasher : IPasswordHasher +{ + /// + /// As the passwords are already hashed by the PasswordManager upon user registration, we do not need to hash them again here. + /// + /// The application user. + /// The already hashed password. + /// Hashed password. + public string HashPassword(ApplicationUser user, string password) => password; + + public PasswordVerificationResult VerifyHashedPassword(ApplicationUser user, string stored, string provided) + => PasswordManager.VerifyHashedPassword(stored, provided) + ? PasswordVerificationResult.Success + : PasswordVerificationResult.Failed; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/DoesNotContainPasswordValidator.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/DoesNotContainPasswordValidator.cs new file mode 100644 index 000000000..6ca367be9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/DoesNotContainPasswordValidator.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Identity; + +public class DoesNotContainPasswordValidator : IPasswordValidator + where TUser : class +{ + public async Task ValidateAsync(UserManager manager, TUser user, string? password) + { + if (password is null) + { + return IdentityResult.Success; + } + + var username = await manager.GetUserNameAsync(user); + + if (username == password) + { + return IdentityResult.Failed(new IdentityError() { Description = "Password cannot contain username." }); + } + + if (password.Contains("password")) + { + return IdentityResult.Failed(new IdentityError() { Description = "Password cannot contain password." }); + } + + return IdentityResult.Success; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/EmailConfirmationTokenProvider.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/EmailConfirmationTokenProvider.cs new file mode 100644 index 000000000..e474f594b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/EmailConfirmationTokenProvider.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Identity; + +public class EmailConfirmationTokenProvider : DataProtectorTokenProvider + where TUser : class +{ + /// + /// Initializes a new instance of the class. + /// + /// The system data protection provider. + /// The configured . + /// The logger. + public EmailConfirmationTokenProvider(IDataProtectionProvider dataProtectionProvider, IOptions options, ILogger> logger) + : base(dataProtectionProvider, options, logger) + { + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/EmailConfirmationTokenProviderOptions.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/EmailConfirmationTokenProviderOptions.cs new file mode 100644 index 000000000..ff607bfe9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/EmailConfirmationTokenProviderOptions.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Identity; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Identity; + +public class EmailConfirmationTokenProviderOptions : DataProtectionTokenProviderOptions +{ +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs new file mode 100644 index 000000000..26755c95b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs @@ -0,0 +1,64 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Authorization.GetPermissions; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using Microsoft.AspNetCore.Authorization; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Identity; + +internal class HasPermissionAuthorizationHandler : AttributeAuthorizationHandler +{ + private readonly IUserAccessModule _userManagementModule; + private readonly IExecutionContextAccessor _executionContextAccessor; + + public HasPermissionAuthorizationHandler( + IUserAccessModule userManagementModule, + IExecutionContextAccessor executionContextAccessor) + { + _userManagementModule = userManagementModule; + _executionContextAccessor = executionContextAccessor; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + HasPermissionAuthorizationRequirement requirement, + HasPermissionAttribute attribute) + { + if (!_executionContextAccessor.IsAvailable) + { + context.Fail(); + return; + } + + var userId = _executionContextAccessor.UserId; + var response = await _userManagementModule.ExecuteQueryAsync(new GetPermissionsQuery(userId)); + if (!response.IsSuccess) + { + context.Fail(); + return; + } + + var permissions = response.Value ?? Enumerable.Empty(); + + // Short circuit if the user owns the administrator privilege. + if (permissions.Any(x => x.Code.Equals(ApplicationPermissions.Administrator))) + { + context.Succeed(requirement); + return; + } + + // Check if the user owns the necessary rights. + if (!IsAuthorized(attribute.Name, permissions)) + { + context.Fail(); + return; + } + + context.Succeed(requirement); + } + + private bool IsAuthorized(string permission, IEnumerable permissions) + { + return permissions.Any(x => x.Code == permission); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/IdentityConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/IdentityConfiguration.cs new file mode 100644 index 000000000..887dcd3b5 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/IdentityConfiguration.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Identity; + +internal static class IdentityConfiguration +{ + public static IServiceCollection ConfigureIdentityService(this IServiceCollection services, UserAccessConfiguration usersConfiguration) + { + return services + .AddHttpContextAccessor() + .AddJWTBearerAuthentication(usersConfiguration); + } + + private static IServiceCollection AddJWTBearerAuthentication(this IServiceCollection services, UserAccessConfiguration userConfiguration) + { + services.AddScoped(x => userConfiguration); + + services.AddAuthentication() + .AddJwtBearer( + JwtBearerDefaults.AuthenticationScheme, + bearerOptions => + { + bearerOptions.RequireHttpsMetadata = false; + bearerOptions.SaveToken = true; + + bearerOptions.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = userConfiguration.GetIssuerSigningKey(), + + ValidIssuer = userConfiguration.GetValidIssuer(), + ValidateIssuer = userConfiguration.ShouldValidateIssuer(), + ValidAudience = userConfiguration.GetValidAudience(), + ValidateAudience = userConfiguration.ShouldValidateAudience(), + ValidateLifetime = true, + RequireExpirationTime = true, + + // Clock skew compensates for server time drift. + ClockSkew = TimeSpan.FromSeconds(60) + }; + + bearerOptions.Events = new JwtBearerEvents + { + OnChallenge = context => + { + return Task.CompletedTask; + }, + + OnTokenValidated = context => + { + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + if (context.Exception.GetType() == typeof(SecurityTokenExpiredException)) + { + context.Response.Headers.Append("Token-Expired", "true"); + } + + return Task.CompletedTask; + } + }; + }) + .AddCookie(IdentityConstants.ApplicationScheme) + .AddCookie(IdentityConstants.TwoFactorUserIdScheme); + + return services; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/IdentityModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/IdentityModule.cs new file mode 100644 index 000000000..52cc2ea6f --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/IdentityModule.cs @@ -0,0 +1,49 @@ +using Autofac; +using Autofac.Extensions.DependencyInjection; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Identity; + +internal class IdentityModule : Autofac.Module +{ + protected override void Load(ContainerBuilder builder) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(); + + // Password hashing is done by a custom service. Do not use ASP.NET Core Identity's default password hasher. + serviceCollection.AddScoped, CustomPasswordHasher>(); + serviceCollection.AddIdentity(options => + { + options.SignIn.RequireConfirmedEmail = false; + + // Configure password policy + options.Password.RequiredUniqueChars = 1; + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireUppercase = false; + options.Password.RequiredLength = 1; + options.Password.RequireNonAlphanumeric = false; + + // Configure user policy + options.User.RequireUniqueEmail = false; + + // Protecting against brute-force attacks with user lockout + options.Lockout.AllowedForNewUsers = true; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders() + .AddPasswordValidator>(); + + serviceCollection.AddOptions(); + serviceCollection.Configure(options => + options.TokenLifespan = TimeSpan.FromHours(3)); + serviceCollection.Configure(options => + options.TokenLifespan = TimeSpan.FromDays(2)); + builder.Populate(serviceCollection); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Logging/LoggingModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Logging/LoggingModule.cs new file mode 100644 index 000000000..7b35e56ea --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Logging/LoggingModule.cs @@ -0,0 +1,21 @@ +using Autofac; +using Serilog; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Logging; + +internal class LoggingModule : Autofac.Module +{ + private readonly ILogger _logger; + + internal LoggingModule(ILogger logger) + { + _logger = logger; + } + + protected override void Load(ContainerBuilder builder) + { + builder.RegisterInstance(_logger) + .As() + .SingleInstance(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Mediation/MediatorModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Mediation/MediatorModule.cs new file mode 100644 index 000000000..e95c0f776 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Mediation/MediatorModule.cs @@ -0,0 +1,92 @@ +using System.Reflection; +using Autofac; +using Autofac.Core; +using Autofac.Features.Variance; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using FluentValidation; +using MediatR; +using MediatR.Pipeline; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Mediation; + +public class MediatorModule : Autofac.Module +{ + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType() + .As() + .InstancePerDependency() + .IfNotRegistered(typeof(IServiceProvider)); + + builder.RegisterAssemblyTypes(typeof(IMediator).GetTypeInfo().Assembly) + .AsImplementedInterfaces() + .InstancePerLifetimeScope(); + + var mediatorOpenTypes = new[] + { + typeof(IRequestHandler<,>), + typeof(INotificationHandler<>), + typeof(IValidator<>), + typeof(IRequestPreProcessor<>), + typeof(IRequestHandler<>), + typeof(IStreamRequestHandler<,>), + typeof(IRequestPostProcessor<,>), + typeof(IRequestExceptionHandler<,,>), + typeof(IRequestExceptionAction<,>), + typeof(ICommandHandler<>), + typeof(ICommandHandler<,>), + }; + builder.RegisterSource(new ScopedContravariantRegistrationSource( + mediatorOpenTypes)); + foreach (var mediatorOpenType in mediatorOpenTypes) + { + builder + .RegisterAssemblyTypes(Assemblies.Application, ThisAssembly) + .AsClosedTypesOf(mediatorOpenType) + .AsImplementedInterfaces() + .FindConstructorsWith(new AllConstructorFinder()); + } + + builder.RegisterGeneric(typeof(RequestPostProcessorBehavior<,>)).As(typeof(IPipelineBehavior<,>)); + builder.RegisterGeneric(typeof(RequestPreProcessorBehavior<,>)).As(typeof(IPipelineBehavior<,>)); + } + + private class ScopedContravariantRegistrationSource : IRegistrationSource + { + private readonly ContravariantRegistrationSource _source = new(); + private readonly List _types = new(); + + public ScopedContravariantRegistrationSource(params Type[] types) + { + ArgumentNullException.ThrowIfNull(types); + + if (!types.All(x => x.IsGenericTypeDefinition)) + { + throw new ArgumentException("Supplied types should be generic type definitions"); + } + + _types.AddRange(types); + } + + public IEnumerable RegistrationsFor( + Service service, + Func> registrationAccessor) + { + var components = _source.RegistrationsFor(service, registrationAccessor); + foreach (var c in components) + { + var defs = c.Target.Services + .OfType() + .Select(x => x.ServiceType.GetGenericTypeDefinition()); + + if (defs.Any(_types.Contains)) + { + yield return c; + } + } + } + + public bool IsAdapterForIndividualComponents => _source.IsAdapterForIndividualComponents; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs new file mode 100644 index 000000000..1a0a8a879 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs @@ -0,0 +1,70 @@ +using Autofac; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Identity; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.ModuleHosting; + +public class UserAccessModule(IConfiguration hostConfiguration) : ModuleBase(hostConfiguration) +{ + private readonly UserAccessConfiguration _userAccessConfiguration = hostConfiguration.GetUserAccessConfiguration(); + + public override string WebApiAssemblySearchPattern => "*.Modules.UsersMI.WebApi.dll"; + + public override void InitializeModule(HostServices hostServices) + { + UserAccessStartup.Initialize( + hostServices.ConnectionString, + hostServices.ExecutionContextAccessor, + hostServices.Logger, + hostServices.EmailsConfiguration, + hostServices.TextEncryptionKey, + null, + null, + _userAccessConfiguration); + } + + public override void RegisterModule(ContainerBuilder containerBuilder) + { + containerBuilder.RegisterType() + .As() + .InstancePerLifetimeScope(); + } + + protected override void AddHostServices(IServiceCollection services) + { + services.ConfigureIdentityService(_userAccessConfiguration) + .AddAuthorization(options => + { + // Update the default policy + // Since the requests won't be authenticated automatically anymore, putting [Authorize] attributes + // on some actions will result in the requests being rejected and an HTTP 401 will be issued. + + // Since that's not what we want because we want to give the authentication handlers a chance to + // authenticate the request, we change the default policy of the authorization system by indicating + // that the Bearer authentication scheme should be tried to authenticate the request. + + // That doesn't prevent you from being more restrictive on some actions; the [Authorize] attribute + // has an AuthenticationSchemes property that allows you to override which authentication schemes are valid. + + // If you have more complex scenarios, you can make use of policy - based authorization. + // The official documentation is great. https://docs.microsoft.com/en-us/aspnet/core/security/authorization/policies + options.DefaultPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme) + .Build(); + + options.AddPolicy(HasPermissionAttribute.HasPermissionPolicyName, policyBuilder => + { + policyBuilder.Requirements.Add(new HasPermissionAuthorizationRequirement()); + policyBuilder.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme); + }); + }) + .AddScoped(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/CommandsExecutor.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/CommandsExecutor.cs new file mode 100644 index 000000000..f904f634c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/CommandsExecutor.cs @@ -0,0 +1,26 @@ +using Autofac; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using MediatR; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing; + +internal static class CommandsExecutor +{ + internal static async Task Execute(ICommand command) + { + using (var scope = UserAccessCompositionRoot.BeginLifetimeScope()) + { + var mediator = scope.Resolve(); + await mediator.Send(command); + } + } + + internal static async Task Execute(ICommand command) + { + using (var scope = UserAccessCompositionRoot.BeginLifetimeScope()) + { + var mediator = scope.Resolve(); + return await mediator.Send(command); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/IRecurringCommand.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/IRecurringCommand.cs new file mode 100644 index 000000000..b1188cb2e --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/IRecurringCommand.cs @@ -0,0 +1,5 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing; + +public interface IRecurringCommand +{ +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/InboxMessageDto.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/InboxMessageDto.cs new file mode 100644 index 000000000..0a3fcd4f7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/InboxMessageDto.cs @@ -0,0 +1,10 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Inbox; + +public class InboxMessageDto +{ + public Guid Id { get; set; } + + public string? Type { get; set; } + + public string? Data { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxCommand.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxCommand.cs new file mode 100644 index 000000000..585e2052d --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxCommand.cs @@ -0,0 +1,5 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Inbox; + +public class ProcessInboxCommand : Application.Contracts.CommandBase, IRecurringCommand +{ +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxCommandHandler.cs new file mode 100644 index 000000000..d933edd55 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxCommandHandler.cs @@ -0,0 +1,62 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using Dapper; +using MediatR; +using Newtonsoft.Json; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Inbox; + +internal class ProcessInboxCommandHandler : ICommandHandler +{ + private readonly IMediator _mediator; + private readonly ISqlConnectionFactory _sqlConnectionFactory; + + public ProcessInboxCommandHandler(IMediator mediator, ISqlConnectionFactory sqlConnectionFactory) + { + _mediator = mediator; + _sqlConnectionFactory = sqlConnectionFactory; + } + + public async Task Handle(ProcessInboxCommand command, CancellationToken cancellationToken) + { + var connection = this._sqlConnectionFactory.GetOpenConnection(); + string sql = "SELECT " + + $"[InboxMessage].[Id] AS [{nameof(InboxMessageDto.Id)}], " + + $"[InboxMessage].[Type] AS [{nameof(InboxMessageDto.Type)}], " + + $"[InboxMessage].[Data] AS [{nameof(InboxMessageDto.Data)}] " + + "FROM [usersmi].[InboxMessages] AS [InboxMessage] " + + "WHERE [InboxMessage].[ProcessedDate] IS NULL " + + "ORDER BY [InboxMessage].[OccurredOn]"; + + var messages = await connection.QueryAsync(sql); + + const string sqlUpdateProcessedDate = "UPDATE [usersmi].[InboxMessages] " + + "SET [ProcessedDate] = @Date " + + "WHERE [Id] = @Id"; + + foreach (var message in messages) + { + var messageAssembly = AppDomain.CurrentDomain.GetAssemblies() + .SingleOrDefault(assembly => message.Type!.Contains(assembly.GetName().Name!)); + + Type type = messageAssembly!.GetType(message.Type!)!; + var request = JsonConvert.DeserializeObject(message.Data!, type); + + try + { + await _mediator.Publish((INotification)request!, cancellationToken); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + + await connection.ExecuteAsync(sqlUpdateProcessedDate, new + { + Date = DateTime.UtcNow, + message.Id + }); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxJob.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxJob.cs new file mode 100644 index 000000000..f967f66b9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Inbox/ProcessInboxJob.cs @@ -0,0 +1,12 @@ +using Quartz; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Inbox; + +[DisallowConcurrentExecution] +public class ProcessInboxJob : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + await CommandsExecutor.Execute(new ProcessInboxCommand()); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/CommandsScheduler.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/CommandsScheduler.cs new file mode 100644 index 000000000..58a1719a9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/CommandsScheduler.cs @@ -0,0 +1,56 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Data; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Serialization; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using Dapper; +using Newtonsoft.Json; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.InternalCommands; + +public class CommandsScheduler : ICommandsScheduler +{ + private readonly ISqlConnectionFactory _sqlConnectionFactory; + + public CommandsScheduler(ISqlConnectionFactory sqlConnectionFactory) + { + _sqlConnectionFactory = sqlConnectionFactory; + } + + public async Task EnqueueAsync(ICommand command) + { + var connection = this._sqlConnectionFactory.GetOpenConnection(); + + const string sqlInsert = "INSERT INTO [usersmi].[InternalCommands] ([Id], [EnqueueDate] , [Type], [Data]) VALUES " + + "(@Id, @EnqueueDate, @Type, @Data)"; + + await connection.ExecuteAsync(sqlInsert, new + { + command.Id, + EnqueueDate = DateTime.UtcNow, + Type = command.GetType().FullName, + Data = JsonConvert.SerializeObject(command, new JsonSerializerSettings + { + ContractResolver = new AllPropertiesContractResolver() + }) + }); + } + + public async Task EnqueueAsync(ICommand command) + { + var connection = this._sqlConnectionFactory.GetOpenConnection(); + + const string sqlInsert = "INSERT INTO [usersmi].[InternalCommands] ([Id], [EnqueueDate] , [Type], [Data]) VALUES " + + "(@Id, @EnqueueDate, @Type, @Data)"; + + await connection.ExecuteAsync(sqlInsert, new + { + command.Id, + EnqueueDate = DateTime.UtcNow, + Type = command.GetType().FullName, + Data = JsonConvert.SerializeObject(command, new JsonSerializerSettings + { + ContractResolver = new AllPropertiesContractResolver() + }) + }); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommand.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommand.cs new file mode 100644 index 000000000..0b0acfd6b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommand.cs @@ -0,0 +1,5 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.InternalCommands; + +internal class ProcessInternalCommandsCommand : Application.Contracts.CommandBase, IRecurringCommand +{ +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommandHandler.cs new file mode 100644 index 000000000..cc8a0bcda --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsCommandHandler.cs @@ -0,0 +1,82 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using Dapper; +using Newtonsoft.Json; +using Polly; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.InternalCommands; + +internal class ProcessInternalCommandsCommandHandler : ICommandHandler +{ + private readonly ISqlConnectionFactory _sqlConnectionFactory; + + public ProcessInternalCommandsCommandHandler( + ISqlConnectionFactory sqlConnectionFactory) + { + _sqlConnectionFactory = sqlConnectionFactory; + } + + public async Task Handle(ProcessInternalCommandsCommand command, CancellationToken cancellationToken) + { + var connection = this._sqlConnectionFactory.GetOpenConnection(); + + string sql = "SELECT " + + $"[Command].[Id] AS [{nameof(InternalCommandDto.Id)}], " + + $"[Command].[Type] AS [{nameof(InternalCommandDto.Type)}], " + + $"[Command].[Data] AS [{nameof(InternalCommandDto.Data)}] " + + "FROM [usersmi].[InternalCommands] AS [Command] " + + "WHERE [Command].[ProcessedDate] IS NULL " + + "ORDER BY [Command].[EnqueueDate]"; + var commands = await connection.QueryAsync(sql); + + var internalCommandsList = commands.AsList(); + + var policy = Policy + .Handle() + .WaitAndRetryAsync(new[] + { + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(3) + }); + + foreach (var internalCommand in internalCommandsList) + { + var result = await policy.ExecuteAndCaptureAsync(() => ProcessCommand( + internalCommand)); + + if (result.Outcome == OutcomeType.Failure) + { + await connection.ExecuteScalarAsync( + "UPDATE [usersmi].[InternalCommands] " + + "SET ProcessedDate = @NowDate, " + + "Error = @Error " + + "WHERE [Id] = @Id", + new + { + NowDate = DateTime.UtcNow, + Error = result.FinalException.ToString(), + internalCommand.Id + }); + } + } + } + + private async Task ProcessCommand( + InternalCommandDto internalCommand) + { + Type type = Assemblies.Application.GetType(internalCommand.Type!)!; + dynamic commandToProcess = JsonConvert.DeserializeObject(internalCommand.Data!, type)!; + + await CommandsExecutor.Execute(commandToProcess); + } + + private class InternalCommandDto + { + public Guid Id { get; set; } + + public string? Type { get; set; } + + public string? Data { get; set; } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsJob.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsJob.cs new file mode 100644 index 000000000..a6113deec --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/InternalCommands/ProcessInternalCommandsJob.cs @@ -0,0 +1,12 @@ +using Quartz; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.InternalCommands; + +[DisallowConcurrentExecution] +public class ProcessInternalCommandsJob : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + await CommandsExecutor.Execute(new ProcessInternalCommandsCommand()); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/LoggingCommandHandlerDecorator.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/LoggingCommandHandlerDecorator.cs new file mode 100644 index 000000000..9d75e36f9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/LoggingCommandHandlerDecorator.cs @@ -0,0 +1,89 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using Serilog; +using Serilog.Context; +using Serilog.Core; +using Serilog.Events; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing; + +internal class LoggingCommandHandlerDecorator : ICommandHandler + where T : Application.Contracts.ICommand +{ + private readonly ILogger _logger; + private readonly IExecutionContextAccessor _executionContextAccessor; + private readonly ICommandHandler _decorated; + + public LoggingCommandHandlerDecorator( + ILogger logger, + IExecutionContextAccessor executionContextAccessor, + ICommandHandler decorated) + { + _logger = logger; + _executionContextAccessor = executionContextAccessor; + _decorated = decorated; + } + + public async Task Handle(T command, CancellationToken cancellationToken) + { + if (command is IRecurringCommand) + { + await _decorated.Handle(command, cancellationToken); + } + + using ( + LogContext.Push( + new LoggingCommandHandlerDecorator.RequestLogEnricher(_executionContextAccessor), + new CommandLogEnricher(command))) + { + try + { + this._logger.Information( + "Executing command {Command}", + command.GetType().Name); + + await _decorated.Handle(command, cancellationToken); + + this._logger.Information("Command {Command} processed successful", command.GetType().Name); + } + catch (Exception exception) + { + this._logger.Error(exception, "Command {Command} processing failed", command.GetType().Name); + throw; + } + } + } + + private class CommandLogEnricher : ILogEventEnricher + { + private readonly Application.Contracts.ICommand _command; + + public CommandLogEnricher(Application.Contracts.ICommand command) + { + _command = command; + } + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + logEvent.AddOrUpdateProperty(new LogEventProperty("Context", new ScalarValue($"Command:{_command.Id.ToString()}"))); + } + } + + private class RequestLogEnricher : ILogEventEnricher + { + private readonly IExecutionContextAccessor _executionContextAccessor; + + public RequestLogEnricher(IExecutionContextAccessor executionContextAccessor) + { + _executionContextAccessor = executionContextAccessor; + } + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + if (_executionContextAccessor.IsAvailable) + { + logEvent.AddOrUpdateProperty(new LogEventProperty("CorrelationId", new ScalarValue(_executionContextAccessor.CorrelationId))); + } + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/LoggingCommandHandlerWithResultDecorator.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/LoggingCommandHandlerWithResultDecorator.cs new file mode 100644 index 000000000..13aa06ea6 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/LoggingCommandHandlerWithResultDecorator.cs @@ -0,0 +1,87 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using Serilog; +using Serilog.Context; +using Serilog.Core; +using Serilog.Events; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing; + +internal class LoggingCommandHandlerWithResultDecorator : ICommandHandler + where T : ICommand +{ + private readonly ILogger _logger; + private readonly IExecutionContextAccessor _executionContextAccessor; + private readonly ICommandHandler _decorated; + + public LoggingCommandHandlerWithResultDecorator( + ILogger logger, + IExecutionContextAccessor executionContextAccessor, + ICommandHandler decorated) + { + _logger = logger; + _executionContextAccessor = executionContextAccessor; + _decorated = decorated; + } + + public async Task Handle(T command, CancellationToken cancellationToken) + { + using ( + LogContext.Push( + new LoggingCommandHandlerWithResultDecorator.RequestLogEnricher(_executionContextAccessor), + new CommandLogEnricher(command))) + { + try + { + this._logger.Information( + "Executing command {@Command}", + command); + + var result = await _decorated.Handle(command, cancellationToken); + + this._logger.Information("Command processed successful, result {Result}", result); + + return result; + } + catch (Exception exception) + { + this._logger.Error(exception, "Command processing failed"); + throw; + } + } + } + + private class CommandLogEnricher : ILogEventEnricher + { + private readonly ICommand _command; + + public CommandLogEnricher(ICommand command) + { + _command = command; + } + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + logEvent.AddOrUpdateProperty(new LogEventProperty("Context", new ScalarValue($"Command:{_command.Id.ToString()}"))); + } + } + + private class RequestLogEnricher : ILogEventEnricher + { + private readonly IExecutionContextAccessor _executionContextAccessor; + + public RequestLogEnricher(IExecutionContextAccessor executionContextAccessor) + { + _executionContextAccessor = executionContextAccessor; + } + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + if (_executionContextAccessor.IsAvailable) + { + logEvent.AddOrUpdateProperty(new LogEventProperty("CorrelationId", new ScalarValue(_executionContextAccessor.CorrelationId))); + } + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/OutboxMessageDto.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/OutboxMessageDto.cs new file mode 100644 index 000000000..1778e8864 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/OutboxMessageDto.cs @@ -0,0 +1,10 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Outbox; + +public class OutboxMessageDto +{ + public Guid Id { get; set; } + + public string? Type { get; set; } + + public string? Data { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/OutboxModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/OutboxModule.cs new file mode 100644 index 000000000..c2d96afbc --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/OutboxModule.cs @@ -0,0 +1,59 @@ +using Autofac; +using CompanyName.MyMeetings.BuildingBlocks.Application.Events; +using CompanyName.MyMeetings.BuildingBlocks.Application.Outbox; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.DomainEventsDispatching; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Outbox; +using Module = Autofac.Module; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Outbox; + +internal class OutboxModule : Module +{ + private readonly BiDictionary _domainNotificationsMap; + + public OutboxModule(BiDictionary domainNotificationsMap) + { + _domainNotificationsMap = domainNotificationsMap; + } + + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType() + .As() + .FindConstructorsWith(new AllConstructorFinder()) + .InstancePerLifetimeScope(); + + CheckMappings(); + + builder.RegisterType() + .As() + .FindConstructorsWith(new AllConstructorFinder()) + .WithParameter("domainNotificationsMap", _domainNotificationsMap) + .SingleInstance(); + } + + private void CheckMappings() + { + var domainEventNotifications = Assemblies.Application + .GetTypes() + .Where(x => x.GetInterfaces().Contains(typeof(IDomainEventNotification))) + .ToList(); + + List notMappedNotifications = new List(); + foreach (var domainEventNotification in domainEventNotifications) + { + _domainNotificationsMap.TryGetBySecond(domainEventNotification, out var name); + + if (name == null) + { + notMappedNotifications.Add(domainEventNotification); + } + } + + if (notMappedNotifications.Any()) + { + throw new ApplicationException($"Domain Event Notifications {notMappedNotifications.Select(x => x.FullName).Aggregate((x, y) => x + "," + y)} not mapped"); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxCommand.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxCommand.cs new file mode 100644 index 000000000..1fedd8414 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxCommand.cs @@ -0,0 +1,5 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Outbox; + +public class ProcessOutboxCommand : Application.Contracts.CommandBase, IRecurringCommand +{ +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxCommandHandler.cs new file mode 100644 index 000000000..01c492685 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxCommandHandler.cs @@ -0,0 +1,84 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Data; +using CompanyName.MyMeetings.BuildingBlocks.Application.Events; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.DomainEventsDispatching; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using Dapper; +using MediatR; +using Newtonsoft.Json; +using Serilog.Context; +using Serilog.Core; +using Serilog.Events; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Outbox; + +internal class ProcessOutboxCommandHandler : ICommandHandler +{ + private readonly IMediator _mediator; + + private readonly ISqlConnectionFactory _sqlConnectionFactory; + + private readonly IDomainNotificationsMapper _domainNotificationsMapper; + + public ProcessOutboxCommandHandler( + IMediator mediator, + ISqlConnectionFactory sqlConnectionFactory, + IDomainNotificationsMapper domainNotificationsMapper) + { + _mediator = mediator; + _sqlConnectionFactory = sqlConnectionFactory; + _domainNotificationsMapper = domainNotificationsMapper; + } + + public async Task Handle(ProcessOutboxCommand command, CancellationToken cancellationToken) + { + var connection = this._sqlConnectionFactory.GetOpenConnection(); + string sql = "SELECT " + + $"[OutboxMessage].[Id] AS [{nameof(OutboxMessageDto.Id)}], " + + $"[OutboxMessage].[Type] AS [{nameof(OutboxMessageDto.Type)}], " + + $"[OutboxMessage].[Data] AS [{nameof(OutboxMessageDto.Data)}] " + + "FROM [usersmi].[OutboxMessages] AS [OutboxMessage] " + + "WHERE [OutboxMessage].[ProcessedDate] IS NULL " + + "ORDER BY [OutboxMessage].[OccurredOn]"; + + var messages = await connection.QueryAsync(sql); + var messagesList = messages.AsList(); + + const string sqlUpdateProcessedDate = "UPDATE [usersmi].[OutboxMessages] " + + "SET [ProcessedDate] = @Date " + + "WHERE [Id] = @Id"; + if (messagesList.Count > 0) + { + foreach (var message in messagesList) + { + var type = _domainNotificationsMapper.GetType(message.Type); + var @event = JsonConvert.DeserializeObject(message.Data!, type) as IDomainEventNotification; + + using (LogContext.Push(new OutboxMessageContextEnricher(@event!))) + { + await this._mediator.Publish(@event!, cancellationToken); + + await connection.ExecuteAsync(sqlUpdateProcessedDate, new + { + Date = DateTime.UtcNow, + message.Id + }); + } + } + } + } + + private class OutboxMessageContextEnricher : ILogEventEnricher + { + private readonly IDomainEventNotification _notification; + + public OutboxMessageContextEnricher(IDomainEventNotification notification) + { + _notification = notification; + } + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + logEvent.AddOrUpdateProperty(new LogEventProperty("Context", new ScalarValue($"OutboxMessage:{_notification.Id.ToString()}"))); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxJob.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxJob.cs new file mode 100644 index 000000000..76a4d97c9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/Outbox/ProcessOutboxJob.cs @@ -0,0 +1,12 @@ +using Quartz; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Outbox; + +[DisallowConcurrentExecution] +public class ProcessOutboxJob : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + await CommandsExecutor.Execute(new ProcessOutboxCommand()); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ProcessingModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ProcessingModule.cs new file mode 100644 index 000000000..db7f7134c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ProcessingModule.cs @@ -0,0 +1,68 @@ +using Autofac; +using CompanyName.MyMeetings.BuildingBlocks.Application.Events; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.DomainEventsDispatching; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.InternalCommands; +using MediatR; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing; + +internal class ProcessingModule : Autofac.Module +{ + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType() + .As() + .InstancePerLifetimeScope(); + + builder.RegisterType() + .As() + .InstancePerLifetimeScope(); + + builder.RegisterType() + .As() + .InstancePerLifetimeScope(); + + builder.RegisterType() + .As() + .InstancePerLifetimeScope(); + + builder.RegisterType() + .As() + .InstancePerLifetimeScope(); + + builder.RegisterGenericDecorator( + typeof(UnitOfWorkCommandHandlerDecorator<>), + typeof(ICommandHandler<>)); + + builder.RegisterGenericDecorator( + typeof(UnitOfWorkCommandHandlerWithResultDecorator<,>), + typeof(ICommandHandler<,>)); + + builder.RegisterGenericDecorator( + typeof(ValidationCommandHandlerDecorator<>), + typeof(ICommandHandler<>)); + + builder.RegisterGenericDecorator( + typeof(ValidationCommandHandlerWithResultDecorator<,>), + typeof(ICommandHandler<,>)); + + builder.RegisterGenericDecorator( + typeof(LoggingCommandHandlerDecorator<>), + typeof(IRequestHandler<>)); + + builder.RegisterGenericDecorator( + typeof(LoggingCommandHandlerWithResultDecorator<,>), + typeof(IRequestHandler<,>)); + + builder.RegisterGenericDecorator( + typeof(DomainEventsDispatcherNotificationHandlerDecorator<>), + typeof(INotificationHandler<>)); + + builder.RegisterAssemblyTypes(Assemblies.Application) + .AsClosedTypesOf(typeof(IDomainEventNotification<>)) + .InstancePerDependency() + .FindConstructorsWith(new AllConstructorFinder()); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/UnitOfWorkCommandHandlerDecorator.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/UnitOfWorkCommandHandlerDecorator.cs new file mode 100644 index 000000000..a0c0dac6e --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/UnitOfWorkCommandHandlerDecorator.cs @@ -0,0 +1,41 @@ +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing; + +internal class UnitOfWorkCommandHandlerDecorator : ICommandHandler + where T : ICommand +{ + private readonly ICommandHandler _decorated; + private readonly IUnitOfWork _unitOfWork; + private readonly UserAccessContext _userAccessContext; + + public UnitOfWorkCommandHandlerDecorator( + ICommandHandler decorated, + IUnitOfWork unitOfWork, + UserAccessContext userAccessContext) + { + _decorated = decorated; + _unitOfWork = unitOfWork; + _userAccessContext = userAccessContext; + } + + public async Task Handle(T command, CancellationToken cancellationToken) + { + await this._decorated.Handle(command, cancellationToken); + + if (command is InternalCommandBase) + { + var internalCommand = await _userAccessContext.InternalCommands.FirstOrDefaultAsync(x => x.Id == command.Id, cancellationToken: cancellationToken); + + if (internalCommand != null) + { + internalCommand.ProcessedDate = DateTime.UtcNow; + } + } + + await this._unitOfWork.CommitAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/UnitOfWorkCommandHandlerWithResultDecorator.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/UnitOfWorkCommandHandlerWithResultDecorator.cs new file mode 100644 index 000000000..090560187 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/UnitOfWorkCommandHandlerWithResultDecorator.cs @@ -0,0 +1,43 @@ +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing; + +internal class UnitOfWorkCommandHandlerWithResultDecorator : ICommandHandler + where T : ICommand +{ + private readonly ICommandHandler _decorated; + private readonly IUnitOfWork _unitOfWork; + private readonly UserAccessContext _userAccessContext; + + public UnitOfWorkCommandHandlerWithResultDecorator( + ICommandHandler decorated, + IUnitOfWork unitOfWork, + UserAccessContext userAccessContext) + { + _decorated = decorated; + _unitOfWork = unitOfWork; + _userAccessContext = userAccessContext; + } + + public async Task Handle(T command, CancellationToken cancellationToken) + { + var result = await this._decorated.Handle(command, cancellationToken); + + if (command is InternalCommandBase) + { + var internalCommand = await _userAccessContext.InternalCommands.FirstOrDefaultAsync(x => x.Id == command.Id, cancellationToken: cancellationToken); + + if (internalCommand != null) + { + internalCommand.ProcessedDate = DateTime.UtcNow; + } + } + + await this._unitOfWork.CommitAsync(cancellationToken); + + return result; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ValidationCommandHandlerDecorator.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ValidationCommandHandlerDecorator.cs new file mode 100644 index 000000000..9f1784aa7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ValidationCommandHandlerDecorator.cs @@ -0,0 +1,37 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing; + +internal class ValidationCommandHandlerDecorator : ICommandHandler + where T : ICommand +{ + private readonly IList> _validators; + private readonly ICommandHandler _decorated; + + public ValidationCommandHandlerDecorator( + IList> validators, + ICommandHandler decorated) + { + this._validators = validators; + _decorated = decorated; + } + + public async Task Handle(T command, CancellationToken cancellationToken) + { + var errors = _validators + .Select(v => v.Validate(command)) + .SelectMany(result => result.Errors) + .Where(error => error != null) + .ToList(); + + if (errors.Any()) + { + throw new InvalidCommandException(errors.Select(x => x.ErrorMessage).ToList()); + } + + await _decorated.Handle(command, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ValidationCommandHandlerWithResultDecorator.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ValidationCommandHandlerWithResultDecorator.cs new file mode 100644 index 000000000..f453377ef --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Processing/ValidationCommandHandlerWithResultDecorator.cs @@ -0,0 +1,38 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using FluentValidation; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing; + +internal class ValidationCommandHandlerWithResultDecorator : ICommandHandler + where T : ICommand +{ + private readonly IList> _validators; + + private readonly ICommandHandler _decorated; + + public ValidationCommandHandlerWithResultDecorator( + IList> validators, + ICommandHandler decorated) + { + this._validators = validators; + _decorated = decorated; + } + + public Task Handle(T command, CancellationToken cancellationToken) + { + var errors = _validators + .Select(v => v.Validate(command)) + .SelectMany(result => result.Errors) + .Where(error => error != null) + .ToList(); + + if (errors.Any()) + { + throw new InvalidCommandException(errors.Select(x => x.ErrorMessage).ToList()); + } + + return _decorated.Handle(command, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/QuartzModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/QuartzModule.cs new file mode 100644 index 000000000..f94892855 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/QuartzModule.cs @@ -0,0 +1,13 @@ +using Autofac; +using Quartz; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Quartz; + +public class QuartzModule : Autofac.Module +{ + protected override void Load(ContainerBuilder builder) + { + builder.RegisterAssemblyTypes(ThisAssembly) + .Where(x => typeof(IJob).IsAssignableFrom(x)).InstancePerDependency(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/QuartzStartup.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/QuartzStartup.cs new file mode 100644 index 000000000..a6e84892f --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/QuartzStartup.cs @@ -0,0 +1,111 @@ +using System.Collections.Specialized; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Inbox; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.InternalCommands; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Outbox; +using Quartz; +using Quartz.Impl; +using Quartz.Logging; +using Serilog; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Quartz; + +internal static class QuartzStartup +{ + internal static void Initialize(ILogger logger, long? internalProcessingPoolingInterval = null) + { + logger.Information("Quartz starting..."); + + var schedulerConfiguration = new NameValueCollection(); + schedulerConfiguration.Add("quartz.scheduler.instanceName", "Meetings"); + + ISchedulerFactory schedulerFactory = new StdSchedulerFactory(schedulerConfiguration); + IScheduler scheduler = schedulerFactory.GetScheduler().GetAwaiter().GetResult(); + + LogProvider.SetCurrentLogProvider(new SerilogLogProvider(logger)); + + scheduler.Start().GetAwaiter().GetResult(); + + var processOutboxJob = JobBuilder.Create().Build(); + ITrigger trigger; + if (internalProcessingPoolingInterval.HasValue) + { + trigger = + TriggerBuilder + .Create() + .StartNow() + .WithSimpleSchedule(x => + x.WithInterval(TimeSpan.FromMilliseconds(internalProcessingPoolingInterval.Value)) + .RepeatForever()) + .Build(); + } + else + { + trigger = + TriggerBuilder + .Create() + .StartNow() + .WithCronSchedule("0/2 * * ? * *") + .Build(); + } + + scheduler + .ScheduleJob(processOutboxJob, trigger) + .GetAwaiter().GetResult(); + + var processInboxJob = JobBuilder.Create().Build(); + + ITrigger processInboxTrigger; + if (internalProcessingPoolingInterval.HasValue) + { + processInboxTrigger = + TriggerBuilder + .Create() + .StartNow() + .WithSimpleSchedule(x => + x.WithInterval(TimeSpan.FromMilliseconds(internalProcessingPoolingInterval.Value)) + .RepeatForever()) + .Build(); + } + else + { + processInboxTrigger = + TriggerBuilder + .Create() + .StartNow() + .WithCronSchedule("0/2 * * ? * *") + .Build(); + } + + scheduler + .ScheduleJob(processInboxJob, processInboxTrigger) + .GetAwaiter().GetResult(); + + var processInternalCommandsJob = JobBuilder.Create().Build(); + + ITrigger processInternalCommandsTrigger; + if (internalProcessingPoolingInterval.HasValue) + { + processInternalCommandsTrigger = + TriggerBuilder + .Create() + .StartNow() + .WithSimpleSchedule(x => + x.WithInterval(TimeSpan.FromMilliseconds(internalProcessingPoolingInterval.Value)) + .RepeatForever()) + .Build(); + } + else + { + processInternalCommandsTrigger = + TriggerBuilder + .Create() + .StartNow() + .WithCronSchedule("0/2 * * ? * *") + .Build(); + } + + scheduler.ScheduleJob(processInternalCommandsJob, processInternalCommandsTrigger).GetAwaiter().GetResult(); + + logger.Information("Quartz started."); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/SerilogLogProvider.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/SerilogLogProvider.cs new file mode 100644 index 000000000..8871ac5ad --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Quartz/SerilogLogProvider.cs @@ -0,0 +1,67 @@ +using Quartz.Logging; +using Serilog; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Quartz; + +internal class SerilogLogProvider : ILogProvider +{ + private readonly ILogger _logger; + + internal SerilogLogProvider(ILogger logger) + { + _logger = logger; + } + + public Logger GetLogger(string name) + { + return (level, func, exception, parameters) => + { + if (func == null) + { + return true; + } + + if (level == LogLevel.Debug || level == LogLevel.Trace) + { + _logger.Debug(exception, func(), parameters); + } + + if (level == LogLevel.Info) + { + _logger.Information(exception, func(), parameters); + } + + if (level == LogLevel.Warn) + { + _logger.Warning(exception, func(), parameters); + } + + if (level == LogLevel.Error) + { + _logger.Error(exception, func(), parameters); + } + + if (level == LogLevel.Fatal) + { + _logger.Fatal(exception, func(), parameters); + } + + return true; + }; + } + + public IDisposable OpenNestedContext(string message) + { + throw new NotImplementedException(); + } + + public IDisposable OpenMappedContext(string key, string value) + { + throw new NotImplementedException(); + } + + public IDisposable OpenMappedContext(string key, object value, bool destructure = false) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Services/ServicesModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Services/ServicesModule.cs new file mode 100644 index 000000000..fe86385e3 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Services/ServicesModule.cs @@ -0,0 +1,25 @@ +using Autofac; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Services.IdentityTokenService; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Services; + +internal class ServicesModule : Module +{ + private readonly UserAccessConfiguration _userAccessConfiguration; + + public ServicesModule(UserAccessConfiguration userAccessConfiguration) + { + _userAccessConfiguration = userAccessConfiguration; + } + + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType() + .As() + .InstancePerLifetimeScope(); + + builder.Register(x => _userAccessConfiguration) + .As(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessCompositionRoot.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessCompositionRoot.cs new file mode 100644 index 000000000..25bd2b576 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessCompositionRoot.cs @@ -0,0 +1,23 @@ +using Autofac; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration; + +internal static class UserAccessCompositionRoot +{ + private static IContainer? _container; + + internal static void SetContainer(IContainer container) + { + _container = container; + } + + internal static ILifetimeScope BeginLifetimeScope() + { + if (_container is null) + { + throw new Exception("Container has not been initialised. Call SetContainer(instance) before using it."); + } + + return _container.BeginLifetimeScope(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessConfiguration.cs new file mode 100644 index 000000000..6232b857a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessConfiguration.cs @@ -0,0 +1,17 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration; + +public class UserAccessConfiguration +{ + public Security? Security { get; set; } +} + +public class Security +{ + public string? JwtSecretKey { get; set; } + + public string? JwtIssuer { get; set; } + + public string? JwtAudience { get; set; } + + public int JwtTokenLifetimeInMinutes { get; set; } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessStartup.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessStartup.cs new file mode 100644 index 000000000..b02a64723 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/UserAccessStartup.cs @@ -0,0 +1,87 @@ +using Autofac; +using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.BuildingBlocks.Application.Emails; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Emails; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.EventBus; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Email; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.EventsBus; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Identity; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Logging; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Mediation; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Outbox; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Quartz; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Services; +using Serilog; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration +{ + public class UserAccessStartup + { + private static IContainer? _container; + + public static void Initialize( + string connectionString, + IExecutionContextAccessor executionContextAccessor, + ILogger logger, + EmailsConfiguration emailsConfiguration, + string textEncryptionKey, + IEmailSender? emailSender, + IEventsBus? eventsBus, + UserAccessConfiguration userAccessConfiguration, + long? internalProcessingPoolingInterval = null) + { + var moduleLogger = logger.ForContext("Module", "UserAccess"); + + ConfigureCompositionRoot( + connectionString, + executionContextAccessor, + logger, + emailsConfiguration, + textEncryptionKey, + emailSender, + eventsBus, + userAccessConfiguration); + + QuartzStartup.Initialize(moduleLogger, internalProcessingPoolingInterval); + + EventsBusStartup.Initialize(moduleLogger); + } + + private static void ConfigureCompositionRoot( + string connectionString, + IExecutionContextAccessor executionContextAccessor, + ILogger logger, + EmailsConfiguration emailsConfiguration, + string textEncryptionKey, + IEmailSender? emailSender, + IEventsBus? eventsBus, + UserAccessConfiguration userAccessConfiguration) + { + var containerBuilder = new ContainerBuilder(); + + containerBuilder.RegisterModule(new LoggingModule(logger.ForContext("Module", "UsersMI"))); + + var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(logger); + containerBuilder.RegisterModule(new DataAccessModule(connectionString)); + + containerBuilder.RegisterModule(new ProcessingModule()); + containerBuilder.RegisterModule(new EventsBusModule(eventsBus)); + containerBuilder.RegisterModule(new MediatorModule()); + containerBuilder.RegisterModule(new OutboxModule(new BiDictionary())); + + containerBuilder.RegisterModule(new QuartzModule()); + containerBuilder.RegisterModule(new EmailModule(emailsConfiguration, emailSender)); + + containerBuilder.RegisterInstance(executionContextAccessor); + containerBuilder.RegisterModule(new IdentityModule()); + containerBuilder.RegisterModule(new ServicesModule(userAccessConfiguration)); + + _container = containerBuilder.Build(); + + UserAccessCompositionRoot.SetContainer(_container); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Domain/Repositories/PermissionRepository.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Domain/Repositories/PermissionRepository.cs new file mode 100644 index 000000000..d62cd0977 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Domain/Repositories/PermissionRepository.cs @@ -0,0 +1,47 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using CompanyName.MyMeetings.Modules.UsersMI.Domain.Repositories; +using Dapper; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Domain.Repositories; + +internal class PermissionRepository : IReadOnlyPermissionRepository +{ + private readonly ISqlConnectionFactory _connectionFactory; + + public PermissionRepository(ISqlConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task> GetPermissionsAsync(GetPermissionsOptions options, CancellationToken cancellationToken = default) + { + using var connection = _connectionFactory.CreateNewConnection(); + + var commandParameters = new DynamicParameters(); + List whereClauses = new(); + + if (options.PermissionCodes is not null) + { + whereClauses.Add("[P].[Code] IN @Codes"); + commandParameters.Add("Codes", options.PermissionCodes); + } + + var whereClause = string.Empty; + if (Enumerable.Any(whereClauses)) + { + whereClause = "WHERE " + string.Join(" AND ", whereClauses); + } + + string query = $""" + SELECT [P].[Code] AS {nameof(Permission.Code)}, + [P].[Name] AS {nameof(Permission.Name)}, + [P].[Description] AS {nameof(Permission.Description)} + FROM [usersmi].[permissions] AS [P] + {whereClause} + """; + + var permissions = await connection.QueryAsync(new CommandDefinition(query, commandParameters, cancellationToken: cancellationToken)); + return permissions; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Domain/Repositories/UserRefreshTokenRepository.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Domain/Repositories/UserRefreshTokenRepository.cs new file mode 100644 index 000000000..f8c3f0ec1 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Domain/Repositories/UserRefreshTokenRepository.cs @@ -0,0 +1,32 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using CompanyName.MyMeetings.Modules.UsersMI.Domain.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Domain.Repositories; + +internal class UserRefreshTokenRepository : IUserRefreshTokenRepository +{ + private readonly UserAccessContext _context; + + public UserRefreshTokenRepository(UserAccessContext context) + { + _context = context; + } + + public Task GetByJwtIdAsync(string id, CancellationToken cancellationToken) + { + return _context.UserRefreshTokens + .SingleOrDefaultAsync(x => x.JwtId == id, cancellationToken); + } + + public void Add(UserRefreshToken userRefreshToken) + { + _context.UserRefreshTokens.Add(userRefreshToken); + } + + public Task DeleteAsync(UserRefreshToken userRefreshToken, CancellationToken cancellationToken = default) + { + _context.UserRefreshTokens.Remove(userRefreshToken); + return _context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/InternalCommands/InternalCommandEntityTypeConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/InternalCommands/InternalCommandEntityTypeConfiguration.cs new file mode 100644 index 000000000..1d1cef9e4 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/InternalCommands/InternalCommandEntityTypeConfiguration.cs @@ -0,0 +1,16 @@ +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.InternalCommands; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.InternalCommands; + +internal class InternalCommandEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("InternalCommands", "usersmi"); + + builder.HasKey(b => b.Id); + builder.Property(b => b.Id).ValueGeneratedNever(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Outbox/OutboxAccessor.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Outbox/OutboxAccessor.cs new file mode 100644 index 000000000..57b650777 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Outbox/OutboxAccessor.cs @@ -0,0 +1,23 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Outbox; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Outbox; + +public class OutboxAccessor : IOutbox +{ + private readonly UserAccessContext _userAccessContext; + + public OutboxAccessor(UserAccessContext userAccessContext) + { + _userAccessContext = userAccessContext; + } + + public void Add(OutboxMessage message) + { + _userAccessContext.OutboxMessages.Add(message); + } + + public Task Save() + { + return Task.CompletedTask; // Save is done automatically using EF Core Change Tracking mechanism during SaveChanges. + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Outbox/OutboxMessageEntityTypeConfiguration.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Outbox/OutboxMessageEntityTypeConfiguration.cs new file mode 100644 index 000000000..1c71763bf --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Outbox/OutboxMessageEntityTypeConfiguration.cs @@ -0,0 +1,16 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Outbox; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Outbox; + +internal class OutboxMessageEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("OutboxMessages", "usersmi"); + + builder.HasKey(b => b.Id); + builder.Property(b => b.Id).ValueGeneratedNever(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Services/IdentityTokenService/CustomClaimTypes.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Services/IdentityTokenService/CustomClaimTypes.cs new file mode 100644 index 000000000..f94f4818b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Services/IdentityTokenService/CustomClaimTypes.cs @@ -0,0 +1,12 @@ +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Services.IdentityTokenService; + +internal class CustomClaimTypes +{ + internal const string Roles = "roles"; + internal const string Sub = "sub"; + internal const string Email = "email"; + internal const string Name = "name"; + internal const string LoginName = "loginName"; + internal const string FirstName = "firstName"; + internal const string LastName = "lastName"; +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Services/IdentityTokenService/IdentityTokenClaimService.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Services/IdentityTokenService/IdentityTokenClaimService.cs new file mode 100644 index 000000000..9da0f55d8 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Services/IdentityTokenService/IdentityTokenClaimService.cs @@ -0,0 +1,185 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using CompanyName.MyMeetings.Modules.UsersMI.Domain.Repositories; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration; +using CSharpFunctionalExtensions; +using Microsoft.IdentityModel.Tokens; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Services.IdentityTokenService; + +internal class IdentityTokenClaimService : Application.Contracts.ITokenClaimsService +{ + private const int DefaultJwtTokenLifetimeInMinutes = 7; + + private readonly UserAccessConfiguration _userAccessConfiguration; + private readonly IUserRefreshTokenRepository _refreshTokenRepository; + + public IdentityTokenClaimService( + UserAccessConfiguration userAccessConfiguration, + IUserRefreshTokenRepository refreshTokenRepository) + { + _refreshTokenRepository = refreshTokenRepository; + _userAccessConfiguration = userAccessConfiguration; + } + + public Application.Contracts.Tokens GenerateTokens(ApplicationUser user) + { + var tokenHandler = new JwtSecurityTokenHandler(); + + var claims = GetUserClaims(user); + var tokenId = Guid.NewGuid().ToString(); + + // Add an unique identifier which is used by the refresh token + claims.Add(new Claim(JwtRegisteredClaimNames.Jti, tokenId)); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Issuer = _userAccessConfiguration.GetValidIssuer(), + Audience = _userAccessConfiguration.GetValidAudience(), + + // JWT tokens are not supposed to be long-lived, quite the opposite. + // They're designed to be short-lived (5 to 10 minutes) with refresh capabilities. + Expires = DateTime.UtcNow.AddMinutes(_userAccessConfiguration.Security?.JwtTokenLifetimeInMinutes ?? DefaultJwtTokenLifetimeInMinutes), + SigningCredentials = new SigningCredentials(_userAccessConfiguration.GetIssuerSigningKey(), SecurityAlgorithms.HmacSha256) + }; + + // Generate the security object token + var token = tokenHandler.CreateToken(tokenDescriptor); + + // Convert the security object token into a string + var accessToken = tokenHandler.WriteToken(token); + var refreshToken = RandomString(35) + Guid.NewGuid(); + var userRefreshToken = UserRefreshToken.Create(user, tokenId, refreshToken); + + _refreshTokenRepository.Add(userRefreshToken); + return new Application.Contracts.Tokens(accessToken, refreshToken); + } + + public async Task> GenerateNewTokensAsync(string accessToken, string refreshToken, CancellationToken cancellationToken) + { + var accessTokenValidationResult = await ValidateAccessTokenAsync(accessToken, refreshToken, cancellationToken); + if (accessTokenValidationResult.IsFailure) + { + return accessTokenValidationResult.Error; + } + + // As refresh tokens should only be used once + var userRefreshToken = accessTokenValidationResult.Value; + + // .. go ahead an delete the current one + await _refreshTokenRepository.DeleteAsync(userRefreshToken, cancellationToken); + + // .. and finally generate an new pair of tokens + return GenerateTokens(userRefreshToken.User); + } + + public List GetUserClaims(ApplicationUser user) + { + var claims = new List + { + new(CustomClaimTypes.Sub, user.Id.ToString()), + new(CustomClaimTypes.LoginName, user.UserName ?? string.Empty), + new(CustomClaimTypes.Email, user.Email ?? string.Empty) + }; + + if (!string.IsNullOrEmpty(user.Name)) + { + claims.Add(new(CustomClaimTypes.Name, user.Name)); + } + + if (!string.IsNullOrEmpty(user.FirstName)) + { + claims.Add(new(CustomClaimTypes.FirstName, user.FirstName)); + } + + if (!string.IsNullOrEmpty(user.LastName)) + { + claims.Add(new(CustomClaimTypes.LastName, user.LastName)); + } + + return claims; + } + + public TokenValidationParameters GetTokenValidationParameters() + { + return new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = _userAccessConfiguration.GetIssuerSigningKey(), + ValidIssuer = _userAccessConfiguration.GetValidIssuer(), + ValidateIssuer = _userAccessConfiguration.ShouldValidateIssuer(), + ValidAudience = _userAccessConfiguration.GetValidAudience(), + ValidateAudience = _userAccessConfiguration.ShouldValidateAudience(), + ValidateLifetime = true, + RequireExpirationTime = true, + + // Clock skew compensates for server time drift. + ClockSkew = TimeSpan.FromMinutes(5) + }; + } + + private async Task> ValidateAccessTokenAsync(string accessToken, string refreshToken, CancellationToken cancellationToken) + { + // Validate the Jwt format based on the configuration to check if it belongs to the our application. + var tokenValidationParameters = GetTokenValidationParameters(); + + // Here we are saying that we don't care about the accessToken's expiration date + tokenValidationParameters.ValidateLifetime = false; + + var tokenHandler = new JwtSecurityTokenHandler(); + var principal = tokenHandler.ValidateToken(accessToken, tokenValidationParameters, out SecurityToken securityToken); + + // Check if the token has been encrypted using the algorithm we have specified + if (securityToken is not JwtSecurityToken jwtSecurityToken || !jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.InvariantCultureIgnoreCase)) + { + return Errors.Authentication.InvalidToken(); + } + + // Validate access token expiry date + if (!long.TryParse(principal.Claims.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Exp)?.Value, out long expiryTimeStamp)) + { + return Errors.Authentication.InvalidToken(); + } + + // Unix time stamp convertion + var expiryDate = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc) + .AddSeconds(expiryTimeStamp).ToLocalTime(); + + if (expiryDate > DateTime.Now) + { + return Errors.Authentication.InvalidToken("Token has not yet expired"); + } + + // Validate if the token exists + UserRefreshToken? userRefreshToken = await _refreshTokenRepository.GetByJwtIdAsync(jwtSecurityToken.Id, cancellationToken); + if (userRefreshToken is null) + { + return Errors.Authentication.InvalidToken("Token not found"); + } + + if (!userRefreshToken.Token.Equals(refreshToken)) + { + return Errors.Authentication.InvalidToken(); + } + + if (userRefreshToken.IsRevoked) + { + // Clean up the revoked token + await _refreshTokenRepository.DeleteAsync(userRefreshToken, cancellationToken); + return Errors.Authentication.InvalidToken("Token has been revoked."); + } + + return userRefreshToken; + } + + private static string RandomString(int length) + { + var random = new Random(); + var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + return new string(Enumerable.Repeat(chars, length) + .Select(x => x[random.Next(x.Length)]).ToArray()); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/UserAccessContext.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/UserAccessContext.cs new file mode 100644 index 000000000..10da347cb --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/UserAccessContext.cs @@ -0,0 +1,91 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Outbox; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.InternalCommands; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.DataAccess.TypeConfigurations; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.InternalCommands; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Outbox; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.Extensions.Logging; +using Serilog; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure; + +public class UserAccessContext : IdentityDbContext, IdentityUserRole, IdentityUserLogin, IdentityRoleClaim, IdentityUserToken> +{ + private readonly Serilog.ILogger? _logger; + private readonly IDatabaseConfiguration _databaseConfiguration; + + public UserAccessContext(IDatabaseConfiguration databaseConfiguration, Serilog.ILogger logger) + { + _logger = logger; + _databaseConfiguration = databaseConfiguration; + } + + public DbSet UserRefreshTokens { get; set; } = default!; + + public DbSet OutboxMessages { get; set; } = default!; + + public DbSet InternalCommands { get; set; } = default!; + + /// + /// Abstract away the configuration complexity by encapsulating the DbContext. + /// + /// Options builder instance. + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseSqlServer(_databaseConfiguration.ConnectionString, builder => + { + builder.EnableRetryOnFailure(5, TimeSpan.FromSeconds(10), null); + }); + optionsBuilder.ReplaceService(); + + if (_logger is not null) + { + optionsBuilder.UseLoggerFactory(CreateLoggerFactory()); +#if DEBUG + optionsBuilder.EnableSensitiveDataLogging(); +#endif + } + else + { + optionsBuilder.UseLoggerFactory(CreateEmptyLoggerFactory()); + } + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.ApplyConfigurationsFromAssembly(Assemblies.Infrastructure); + + builder.ApplyConfiguration(new ApplicationUserEntityTypeConfiguration()); + builder.ApplyConfiguration(new IdentityRoleClaimEntityTypeConfiguration()); + builder.ApplyConfiguration(new IdentityUserClaimEntityTypeConfiguration()); + builder.ApplyConfiguration(new IdentityUserLoginEntityTypeConfiguration()); + builder.ApplyConfiguration(new IdentityUserRoleEntityTypeConfiguration()); + builder.ApplyConfiguration(new IdentityUserTokenEntityTypeConfiguration()); + builder.ApplyConfiguration(new RoleEntityTypeConfiguration()); + builder.ApplyConfiguration(new UserRefreshTokenEntityTypeConfiguration()); + + builder.ApplyConfiguration(new OutboxMessageEntityTypeConfiguration()); + builder.ApplyConfiguration(new InternalCommandEntityTypeConfiguration()); + } + + private ILoggerFactory CreateLoggerFactory() + { + return LoggerFactory.Create(builder => builder + .AddSerilog(_logger)); + } + + private ILoggerFactory CreateEmptyLoggerFactory() + { + return LoggerFactory.Create(builder => builder + .AddFilter((_, _) => false)); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/UserAccessModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/UserAccessModule.cs new file mode 100644 index 000000000..525b5d314 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/UserAccessModule.cs @@ -0,0 +1,30 @@ +using Autofac; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing; +using MediatR; + +namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure; + +public class UserAccessModule : IUserAccessModule +{ + public async Task ExecuteCommandAsync(ICommand command) + { + return await CommandsExecutor.Execute(command); + } + + public async Task ExecuteCommandAsync(ICommand command) + { + await CommandsExecutor.Execute(command); + } + + public async Task ExecuteQueryAsync(IQuery query) + { + using (var scope = UserAccessCompositionRoot.BeginLifetimeScope()) + { + var mediator = scope.Resolve(); + + return await mediator.Send(query); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Application/ApplicationTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Application/ApplicationTests.cs new file mode 100644 index 000000000..3c4aa9953 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Application/ApplicationTests.cs @@ -0,0 +1,200 @@ +using System.Reflection; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Queries; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.ArchTests.SeedWork; +using FluentValidation; +using MediatR; +using NetArchTest.Rules; +using Newtonsoft.Json; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.ArchTests.Application +{ + [TestFixture] + public class ApplicationTests : TestBase + { + [Test] + public void Command_Should_Be_Immutable() + { + var types = Types.InAssembly(ApplicationAssembly) + .That() + .Inherit(typeof(CommandBase)) + .Or() + .Inherit(typeof(CommandBase<>)) + .Or() + .Inherit(typeof(InternalCommandBase)) + .Or() + .Inherit(typeof(InternalCommandBase<>)) + .Or() + .ImplementInterface(typeof(ICommand)) + .Or() + .ImplementInterface(typeof(ICommand<>)) + .GetTypes(); + + AssertAreImmutable(types); + } + + [Test] + public void Query_Should_Be_Immutable() + { + var types = Types.InAssembly(ApplicationAssembly) + .That().ImplementInterface(typeof(IQuery<>)).GetTypes(); + + AssertAreImmutable(types); + } + + [Test] + public void CommandHandler_Should_Have_Name_EndingWith_CommandHandler() + { + var result = Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(ICommandHandler<>)) + .Or() + .ImplementInterface(typeof(ICommandHandler<,>)) + .And() + .DoNotHaveNameMatching(".*Decorator.*").Should() + .HaveNameEndingWith("CommandHandler") + .GetResult(); + + AssertArchTestResult(result); + } + + [Test] + public void QueryHandler_Should_Have_Name_EndingWith_QueryHandler() + { + var result = Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQueryHandler<,>)) + .Should() + .HaveNameEndingWith("QueryHandler") + .GetResult(); + + AssertArchTestResult(result); + } + + [Test] + public void Command_And_Query_Handlers_Should_Not_Be_Public() + { + var types = Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQueryHandler<,>)) + .Or() + .ImplementInterface(typeof(ICommandHandler<>)) + .Or() + .ImplementInterface(typeof(ICommandHandler<,>)) + .Should().NotBePublic().GetResult().FailingTypes; + + AssertFailingTypes(types); + } + + [Test] + public void Validator_Should_Have_Name_EndingWith_Validator() + { + var result = Types.InAssembly(ApplicationAssembly) + .That() + .Inherit(typeof(AbstractValidator<>)) + .Should() + .HaveNameEndingWith("Validator") + .GetResult(); + + AssertArchTestResult(result); + } + + [Test] + public void Validators_Should_Not_Be_Public() + { + var types = Types.InAssembly(ApplicationAssembly) + .That() + .Inherit(typeof(AbstractValidator<>)) + .Should().NotBePublic().GetResult().FailingTypes; + + AssertFailingTypes(types); + } + + [Test] + public void InternalCommand_Should_Have_JsonConstructorAttribute() + { + var types = Types.InAssembly(ApplicationAssembly) + .That() + .Inherit(typeof(InternalCommandBase)) + .Or() + .Inherit(typeof(InternalCommandBase<>)) + .GetTypes(); + + List failingTypes = []; + + foreach (var type in types) + { + bool hasJsonConstructorDefined = false; + var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + foreach (var constructorInfo in constructors) + { + var jsonConstructorAttribute = constructorInfo.GetCustomAttributes(typeof(JsonConstructorAttribute), false); + if (jsonConstructorAttribute.Length > 0) + { + hasJsonConstructorDefined = true; + break; + } + } + + if (!hasJsonConstructorDefined) + { + failingTypes.Add(type); + } + } + + AssertFailingTypes(failingTypes); + } + + [Test] + public void MediatR_RequestHandler_Should_NotBe_Used_Directly() + { + var types = Types.InAssembly(ApplicationAssembly) + .That().DoNotHaveName("ICommandHandler`1") + .Should().ImplementInterface(typeof(IRequestHandler<>)) + .GetTypes(); + + List failingTypes = []; + foreach (var type in types) + { + bool isCommandHandler = type.GetInterfaces().Any(x => + x.IsGenericType && + x.GetGenericTypeDefinition() == typeof(ICommandHandler<>)); + bool isCommandWithResultHandler = type.GetInterfaces().Any(x => + x.IsGenericType && + x.GetGenericTypeDefinition() == typeof(ICommandHandler<,>)); + bool isQueryHandler = type.GetInterfaces().Any(x => + x.IsGenericType && + x.GetGenericTypeDefinition() == typeof(IQueryHandler<,>)); + if (!isCommandHandler && !isCommandWithResultHandler && !isQueryHandler) + { + failingTypes.Add(type); + } + } + + AssertFailingTypes(failingTypes); + } + + [Test] + public void Command_With_Result_Should_Not_Return_Unit() + { + Type commandWithResultHandlerType = typeof(ICommandHandler<,>); + IEnumerable types = Types.InAssembly(ApplicationAssembly) + .That().ImplementInterface(commandWithResultHandlerType) + .GetTypes().ToList(); + + List failingTypes = []; + foreach (Type type in types) + { + Type interfaceType = type.GetInterface(commandWithResultHandlerType.Name); + if (interfaceType?.GenericTypeArguments[1] == typeof(Unit)) + { + failingTypes.Add(type); + } + } + + AssertFailingTypes(failingTypes); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/CompanyName.MyMeetings.Modules.UsersMI.ArchTests.csproj b/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/CompanyName.MyMeetings.Modules.UsersMI.ArchTests.csproj new file mode 100644 index 000000000..2ef1a363a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/CompanyName.MyMeetings.Modules.UsersMI.ArchTests.csproj @@ -0,0 +1 @@ + diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Domain/DomainTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Domain/DomainTests.cs new file mode 100644 index 000000000..0ad64dabc --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Domain/DomainTests.cs @@ -0,0 +1,228 @@ +using System.Reflection; +using CompanyName.MyMeetings.BuildingBlocks.Domain; +using CompanyName.MyMeetings.Modules.UsersMI.ArchTests.SeedWork; +using NetArchTest.Rules; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.ArchTests.Domain +{ + public class DomainTests : TestBase + { + [Test] + public void DomainEvent_Should_Be_Immutable() + { + var types = Types.InAssembly(DomainAssembly) + .That() + .Inherit(typeof(DomainEventBase)) + .Or() + .ImplementInterface(typeof(IDomainEvent)) + .GetTypes(); + + AssertAreImmutable(types); + } + + [Test] + public void ValueObject_Should_Be_Immutable() + { + var types = Types.InAssembly(DomainAssembly) + .That() + .Inherit(typeof(ValueObject)) + .GetTypes(); + + AssertAreImmutable(types); + } + + [Test] + public void Entity_Which_Is_Not_Aggregate_Root_Cannot_Have_Public_Members() + { + var types = Types.InAssembly(DomainAssembly) + .That() + .Inherit(typeof(Entity)) + .And().DoNotImplementInterface(typeof(IAggregateRoot)).GetTypes(); + + const BindingFlags bindingFlags = BindingFlags.DeclaredOnly | + BindingFlags.Public | + BindingFlags.Instance | + BindingFlags.Static; + + List failingTypes = []; + foreach (var type in types) + { + var publicFields = type.GetFields(bindingFlags); + var publicProperties = type.GetProperties(bindingFlags); + var publicMethods = type.GetMethods(bindingFlags); + + if (publicFields.Any() || publicProperties.Any() || publicMethods.Any()) + { + failingTypes.Add(type); + } + } + + AssertFailingTypes(failingTypes); + } + + [Test] + public void Entity_Cannot_Have_Reference_To_Other_AggregateRoot() + { + var entityTypes = Types.InAssembly(DomainAssembly) + .That() + .Inherit(typeof(Entity)).GetTypes(); + + var aggregateRoots = Types.InAssembly(DomainAssembly) + .That().ImplementInterface(typeof(IAggregateRoot)).GetTypes().ToList(); + + const BindingFlags bindingFlags = BindingFlags.DeclaredOnly | + BindingFlags.NonPublic | + BindingFlags.Instance; + + List failingTypes = []; + foreach (var type in entityTypes) + { + var fields = type.GetFields(bindingFlags); + + foreach (var field in fields) + { + if (aggregateRoots.Contains(field.FieldType) || + field.FieldType.GenericTypeArguments.Any(x => aggregateRoots.Contains(x))) + { + failingTypes.Add(type); + break; + } + } + + var properties = type.GetProperties(bindingFlags); + foreach (var property in properties) + { + if (aggregateRoots.Contains(property.PropertyType) || + property.PropertyType.GenericTypeArguments.Any(x => aggregateRoots.Contains(x))) + { + failingTypes.Add(type); + break; + } + } + } + + AssertFailingTypes(failingTypes); + } + + [Test] + public void Entity_Should_Have_Parameterless_Private_Constructor() + { + var entityTypes = Types.InAssembly(DomainAssembly) + .That() + .Inherit(typeof(Entity)).GetTypes(); + + List failingTypes = []; + foreach (var entityType in entityTypes) + { + bool hasPrivateParameterlessConstructor = false; + var constructors = entityType.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance); + foreach (var constructorInfo in constructors) + { + if (constructorInfo.IsPrivate && constructorInfo.GetParameters().Length == 0) + { + hasPrivateParameterlessConstructor = true; + } + } + + if (!hasPrivateParameterlessConstructor) + { + failingTypes.Add(entityType); + } + } + + AssertFailingTypes(failingTypes); + } + + [Test] + public void Domain_Object_Should_Have_Only_Private_Constructors() + { + var domainObjectTypes = Types.InAssembly(DomainAssembly) + .That() + .Inherit(typeof(Entity)) + .Or() + .Inherit(typeof(ValueObject)) + .GetTypes(); + + List failingTypes = []; + foreach (var domainObjectType in domainObjectTypes) + { + var constructors = domainObjectType.GetConstructors(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); + foreach (var constructorInfo in constructors) + { + if (!constructorInfo.IsPrivate) + { + failingTypes.Add(domainObjectType); + } + } + } + + AssertFailingTypes(failingTypes); + } + + [Test] + public void ValueObject_Should_Have_Private_Constructor_With_Parameters_For_His_State() + { + var valueObjects = Types.InAssembly(DomainAssembly) + .That() + .Inherit(typeof(ValueObject)).GetTypes(); + + List failingTypes = []; + foreach (var entityType in valueObjects) + { + bool hasExpectedConstructor = false; + + const BindingFlags bindingFlags = BindingFlags.DeclaredOnly | + BindingFlags.Public | + BindingFlags.Instance; + var names = entityType.GetFields(bindingFlags).Select(x => x.Name.ToLower()).ToList(); + var propertyNames = entityType.GetProperties(bindingFlags).Select(x => x.Name.ToLower()).ToList(); + names.AddRange(propertyNames); + var constructors = entityType.GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance); + foreach (var constructorInfo in constructors) + { + var parameters = constructorInfo.GetParameters().Select(x => x.Name.ToLower()).ToList(); + + if (names.Intersect(parameters).Count() == names.Count) + { + hasExpectedConstructor = true; + break; + } + } + + if (!hasExpectedConstructor) + { + failingTypes.Add(entityType); + } + } + + AssertFailingTypes(failingTypes); + } + + [Test] + public void DomainEvent_Should_Have_DomainEventPostfix() + { + var result = Types.InAssembly(DomainAssembly) + .That() + .Inherit(typeof(DomainEventBase)) + .Or() + .ImplementInterface(typeof(IDomainEvent)) + .Should().HaveNameEndingWith("DomainEvent") + .GetResult(); + + AssertArchTestResult(result); + } + + [Test] + public void BusinessRule_Should_Have_RulePostfix() + { + var result = Types.InAssembly(DomainAssembly) + .That() + .ImplementInterface(typeof(IBusinessRule)) + .Should().HaveNameEndingWith("Rule") + .GetResult(); + + AssertArchTestResult(result); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Module/LayersTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Module/LayersTests.cs new file mode 100644 index 000000000..a05057f14 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/Module/LayersTests.cs @@ -0,0 +1,43 @@ +using CompanyName.MyMeetings.Modules.UsersMI.ArchTests.SeedWork; +using NetArchTest.Rules; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.ArchTests.Module +{ + [TestFixture] + public class LayersTests : TestBase + { + [Test] + public void DomainLayer_DoesNotHaveDependency_ToApplicationLayer() + { + var result = Types.InAssembly(DomainAssembly) + .Should() + .NotHaveDependencyOn(ApplicationAssembly.GetName().Name) + .GetResult(); + + AssertArchTestResult(result); + } + + [Test] + public void DomainLayer_DoesNotHaveDependency_ToInfrastructureLayer() + { + var result = Types.InAssembly(DomainAssembly) + .Should() + .NotHaveDependencyOn(ApplicationAssembly.GetName().Name) + .GetResult(); + + AssertArchTestResult(result); + } + + [Test] + public void ApplicationLayer_DoesNotHaveDependency_ToInfrastructureLayer() + { + var result = Types.InAssembly(ApplicationAssembly) + .Should() + .NotHaveDependencyOn(InfrastructureAssembly.GetName().Name) + .GetResult(); + + AssertArchTestResult(result); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/SeedWork/TestBase.cs b/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/SeedWork/TestBase.cs new file mode 100644 index 000000000..b66b7acb0 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/ArchTests/SeedWork/TestBase.cs @@ -0,0 +1,43 @@ +using System.Reflection; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Domain; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure; +using NetArchTest.Rules; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.ArchTests.SeedWork +{ + public abstract class TestBase + { + protected static Assembly ApplicationAssembly => typeof(CommandBase).Assembly; + + protected static Assembly DomainAssembly => typeof(ApplicationUser).Assembly; + + protected static Assembly InfrastructureAssembly => typeof(UserAccessContext).Assembly; + + protected static void AssertAreImmutable(IEnumerable types) + { + List failingTypes = []; + foreach (var type in types) + { + if (type.GetFields().Any(x => !x.IsInitOnly) || type.GetProperties().Any(x => x.CanWrite)) + { + failingTypes.Add(type); + break; + } + } + + AssertFailingTypes(failingTypes); + } + + protected static void AssertFailingTypes(IEnumerable types) + { + Assert.That(types, Is.Null.Or.Empty); + } + + protected static void AssertArchTestResult(TestResult result) + { + AssertFailingTypes(result.FailingTypes); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/AssemblyInfo.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/AssemblyInfo.cs new file mode 100644 index 000000000..8845ccb35 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/AssemblyInfo.cs @@ -0,0 +1,11 @@ +using NUnit.Framework; + +[assembly: NonParallelizable] +[assembly: LevelOfParallelism(1)] + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests +{ + public class AssemblyInfo + { + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Authentication/AuthenticationTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Authentication/AuthenticationTests.cs new file mode 100644 index 000000000..a9583388b --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Authentication/AuthenticationTests.cs @@ -0,0 +1,38 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Authentication; + +[TestFixture] +internal class AuthenticationTests : TestBase +{ + private const string UserAccountPassword = "Password1!"; + private CreateUserAccountCommand _userAccountCommand = null!; + + [Test] + public async Task Authenticate_ReturnsOk_WhenCredentialsAreValid() + { + // Arrange + var authenticateCommand = new AccountLoginCommand(_userAccountCommand.Login, UserAccountPassword); + + // Act + var authenticateResult = await UserAccessModule.ExecuteCommandAsync(authenticateCommand); + + // Assert + Assert.That(authenticateResult.IsSuccess, Is.True); + Assert.That(authenticateResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(authenticateResult.AccessToken, Is.Not.Empty); + Assert.That(authenticateResult.IsAuthenticated, Is.True); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _userAccountCommand = UserAccountGenerator(password: UserAccountPassword).Generate(); + + await UserAccessModule.ExecuteCommandAsync(_userAccountCommand); + } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.csproj b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.csproj new file mode 100644 index 000000000..126bb6fc9 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.csproj @@ -0,0 +1,11 @@ + + + enable + enable + + + + + + + \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ChangeEmailAddressTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ChangeEmailAddressTests.cs new file mode 100644 index 000000000..b82234005 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ChangeEmailAddressTests.cs @@ -0,0 +1,101 @@ +using System.Data; +using System.Text.RegularExpressions; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ChangeEmailAddress; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.RequestChangeEmailAddressToken; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Me; + +[TestFixture] +internal class ChangeEmailAddressTests : TestBase +{ + private CreateUserAccountCommand? _currentUserAccountCommand; + private CreateUserAccountCommand? _secondUserAccountCommand; + + [Test] + public async Task ChangeEmailAddress_ReturnsOk_WhenTokenIsValid() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var newEmailAddress = "hallo@mymeetings.com"; + + // To change an email address, the user must first request a token to change the email address. + // The token gets sent by email, so we have to parse the content of the email message to get it. + var requestChangeEmailAddressTokenCommand = new RequestChangeEmailAddressTokenCommand(userId, newEmailAddress); + await UserAccessModule.ExecuteCommandAsync(requestChangeEmailAddressTokenCommand); + string pattern = @":\s*([A-Za-z0-9\/\+=]+)"; + var match = Regex.Matches(EmailSender.EmailMessage?.Content ?? string.Empty, pattern, RegexOptions.Multiline) + .Cast() + .LastOrDefault(); + string token = match?.Groups[1].Value ?? string.Empty; + + var changeEmailAddressCommand = new ChangeEmailAddressCommand(userId, newEmailAddress, token); + + // Act + var changeEmailAddressResult = await UserAccessModule.ExecuteCommandAsync(changeEmailAddressCommand); + + // Assert + Assert.That(changeEmailAddressResult.IsSuccess, Is.True); + Assert.That(changeEmailAddressResult.Status, Is.EqualTo(ResultStatus.Ok)); + } + + [Test] + public async Task ChangeEmailAddress_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var newEmailAddress = "hallo@mymeetings.com"; + var changeEmailAddressCommand = new ChangeEmailAddressCommand(userId, newEmailAddress, "some-token"); + + // Act + var changeEmailAddressResult = await UserAccessModule.ExecuteCommandAsync(changeEmailAddressCommand); + + // Assert + Assert.That(changeEmailAddressResult.IsSuccess, Is.False); + Assert.That(changeEmailAddressResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task ChangeEmailAddress_ReturnsError_WhenEmailAddressIsInvalid() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var newEmailAddress = "invalid-email"; + var changeEmailAddressCommand = new ChangeEmailAddressCommand(userId, newEmailAddress, "some-token"); + + // Act + var changeEmailAddressResult = await UserAccessModule.ExecuteCommandAsync(changeEmailAddressCommand); + + // Assert + Assert.That(changeEmailAddressResult.IsSuccess, Is.False); + Assert.That(changeEmailAddressResult.Status, Is.EqualTo(ResultStatus.Error)); + } + + [Test] + public async Task ChangeEmailAddress_ReturnsForbidden_WhenUserIdDoesNotMatchCurrentUser() + { + // Arrange + var userId = _secondUserAccountCommand!.UserId; + var newEmailAddress = "hallo@mymeetings.com"; + var changeEmailAddressCommand = new ChangeEmailAddressCommand(userId, newEmailAddress, "some-token"); + + // Act + var changeEmailAddressResult = await UserAccessModule.ExecuteCommandAsync(changeEmailAddressCommand); + + // Assert + Assert.That(changeEmailAddressResult.Status, Is.EqualTo(ResultStatus.Forbidden)); + Assert.That(changeEmailAddressResult.IsSuccess, Is.False); + Assert.That(changeEmailAddressResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _currentUserAccountCommand = UserAccountGenerator(userId: CurrentUserId).Generate(); + _secondUserAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_currentUserAccountCommand); + await UserAccessModule.ExecuteCommandAsync(_secondUserAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ChangePasswordTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ChangePasswordTests.cs new file mode 100644 index 000000000..407c16b7a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ChangePasswordTests.cs @@ -0,0 +1,100 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ChangePassword; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Me; + +[TestFixture] +internal class ChangePasswordTests : TestBase +{ + private const string UserAccountPassword = "Password1!"; + private CreateUserAccountCommand? _currentUserAccountCommand; + private CreateUserAccountCommand? _secondUserAccountCommand; + + [Test] + public async Task ChangePassword_ReturnsError_WhenCurrentPasswordIsInvalid() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var changePasswordCommand = new ChangePasswordCommand( + userId, + "InvalidCurrentPassword1!", + "NewPassword1!"); + + // Act + var changePasswordResult = await UserAccessModule.ExecuteCommandAsync(changePasswordCommand); + + // Assert + Assert.That(changePasswordResult.Status, Is.EqualTo(ResultStatus.Error)); + Assert.That(changePasswordResult.IsSuccess, Is.False); + Assert.That(changePasswordResult.Errors, Is.Not.Empty); + } + + [Test] + public async Task ChangePassword_ReturnsOk_WhenCurrentPasswordIsValid() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var changePasswordCommand = new ChangePasswordCommand( + userId, + UserAccountPassword, + "NewPassword1!"); + + // Act + var changePasswordResult = await UserAccessModule.ExecuteCommandAsync(changePasswordCommand); + + // Assert + Assert.That(changePasswordResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(changePasswordResult.IsSuccess, Is.True); + Assert.That(changePasswordResult.Errors, Is.Empty); + } + + [Test] + public async Task ChangePassword_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var changePasswordCommand = new ChangePasswordCommand( + userId, + "SomeCurrentPassword1!", + "NewPassword1!"); + + // Act + var changePasswordResult = await UserAccessModule.ExecuteCommandAsync(changePasswordCommand); + + // Assert + Assert.That(changePasswordResult.Status, Is.EqualTo(ResultStatus.NotFound)); + Assert.That(changePasswordResult.IsSuccess, Is.False); + Assert.That(changePasswordResult.Errors, Is.Not.Empty); + } + + [Test] + public async Task ChangePassword_ReturnsForbidden_WhenUserIdDoesNotMatchCurrentUser() + { + // Arrange + var userId = _secondUserAccountCommand!.UserId; + var changePasswordCommand = new ChangePasswordCommand( + userId, + UserAccountPassword, + "NewPassword1!"); + + // Act + var changePasswordResult = await UserAccessModule.ExecuteCommandAsync(changePasswordCommand); + + // Assert + Assert.That(changePasswordResult.Status, Is.EqualTo(ResultStatus.Forbidden)); + Assert.That(changePasswordResult.IsSuccess, Is.False); + Assert.That(changePasswordResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _currentUserAccountCommand = UserAccountGenerator(userId: CurrentUserId, password: UserAccountPassword).Generate(); + _secondUserAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_currentUserAccountCommand); + await UserAccessModule.ExecuteCommandAsync(_secondUserAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ConfirmEmailAddressTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ConfirmEmailAddressTests.cs new file mode 100644 index 000000000..a7ee398d7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/ConfirmEmailAddressTests.cs @@ -0,0 +1,97 @@ +using System.Data; +using System.Text.RegularExpressions; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.ConfirmEmailAddress; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.RequestConfirmEmailAddressToken; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Me; + +[TestFixture] +internal class ConfirmEmailAddressTests : TestBase +{ + private CreateUserAccountCommand? _currentUserAccountCommand; + private CreateUserAccountCommand? _secondUserAccountCommand; + + [Test] + public async Task ConfirmEmailAddress_ReturnsOk_WhenTokenIsValid() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + + // To confirm an email address, we first need to get the confirmation token. + // The token gets sent by email, so we have to parse the content of the email message to get it. + var requestEmailConfirmationTokenCommand = new RequestConfirmEmailAddressTokenCommand(userId); + await UserAccessModule.ExecuteCommandAsync(requestEmailConfirmationTokenCommand); + string pattern = @":\s*([A-Za-z0-9\/\+=]+)"; + var match = Regex.Matches(EmailSender.EmailMessage?.Content ?? string.Empty, pattern, RegexOptions.Multiline) + .Cast() + .LastOrDefault(); + string token = match?.Groups[1].Value ?? string.Empty; + + var confirmEmailAddressCommand = new ConfirmEmailAddressCommand(userId, token); + + // Act + var confirmEmailAddressResult = await UserAccessModule.ExecuteCommandAsync(confirmEmailAddressCommand); + + // Assert + Assert.That(confirmEmailAddressResult.IsSuccess, Is.True); + Assert.That(confirmEmailAddressResult.Status, Is.EqualTo(ResultStatus.Ok)); + } + + [Test] + public async Task ConfirmEmailAddress_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var confirmEmailAddressCommand = new ConfirmEmailAddressCommand(userId, "some-token"); + + // Act + var confirmEmailAddressResult = await UserAccessModule.ExecuteCommandAsync(confirmEmailAddressCommand); + + // Assert + Assert.That(confirmEmailAddressResult.IsSuccess, Is.False); + Assert.That(confirmEmailAddressResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task ConfirmEmailAddress_ReturnsError_WhenTokenIsInvalid() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var confirmEmailAddressCommand = new ConfirmEmailAddressCommand(userId, "invalid-token"); + + // Act + var confirmEmailAddressResult = await UserAccessModule.ExecuteCommandAsync(confirmEmailAddressCommand); + + // Assert + Assert.That(confirmEmailAddressResult.IsSuccess, Is.False); + Assert.That(confirmEmailAddressResult.Status, Is.EqualTo(ResultStatus.Error)); + } + + [Test] + public async Task ConfirmEmailAddress_ReturnsForbidden_WhenUserIdDoesNotMatchCurrentUser() + { + // Arrange + var userId = _secondUserAccountCommand!.UserId; + var confirmEmailAddressCommand = new ConfirmEmailAddressCommand(userId, "some-token"); + + // Act + var confirmEmailAddressResult = await UserAccessModule.ExecuteCommandAsync(confirmEmailAddressCommand); + + // Assert + Assert.That(confirmEmailAddressResult.IsSuccess, Is.False); + Assert.That(confirmEmailAddressResult.Status, Is.EqualTo(ResultStatus.Forbidden)); + Assert.That(confirmEmailAddressResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _currentUserAccountCommand = UserAccountGenerator(userId: CurrentUserId).Generate(); + _secondUserAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_currentUserAccountCommand); + await UserAccessModule.ExecuteCommandAsync(_secondUserAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/GetAuthenticatorKeyTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/GetAuthenticatorKeyTests.cs new file mode 100644 index 000000000..597f08837 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/GetAuthenticatorKeyTests.cs @@ -0,0 +1,70 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.AuthenticatorRegistration.GetAuthenticatorKey; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Me; + +[TestFixture] +internal class GetAuthenticatorKeyTests : TestBase +{ + private CreateUserAccountCommand? _currentUserAccountCommand; + private CreateUserAccountCommand? _secondUserAccountCommand; + + [Test] + public async Task GetAuthenticatorKey_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var getAuthenticatorKeyQuery = new GetAuthenticatorKeyQuery(userId); + + // Act + var getAuthenticatorKeyResult = await UserAccessModule.ExecuteQueryAsync(getAuthenticatorKeyQuery); + + // Assert + Assert.That(getAuthenticatorKeyResult.IsSuccess, Is.False); + Assert.That(getAuthenticatorKeyResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task GetAuthenticatorKey_ReturnsOk_WhenDataIsValid() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var getAuthenticatorKeyQuery = new GetAuthenticatorKeyQuery(userId); + + // Act + var getAuthenticatorKeyResult = await UserAccessModule.ExecuteQueryAsync(getAuthenticatorKeyQuery); + + // Assert + Assert.That(getAuthenticatorKeyResult.IsSuccess, Is.True); + Assert.That(getAuthenticatorKeyResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(getAuthenticatorKeyResult.Value, Is.Not.Null); + } + + [Test] + public async Task GetAuthenticatorKey_ReturnsForbidden_WhenUserIdDoesNotMatchCurrentUser() + { + // Arrange + var userId = _secondUserAccountCommand!.UserId; + var getAuthenticatorKeyQuery = new GetAuthenticatorKeyQuery(userId); + + // Act + var getAuthenticatorKeyResult = await UserAccessModule.ExecuteQueryAsync(getAuthenticatorKeyQuery); + + // Assert + Assert.That(getAuthenticatorKeyResult.IsSuccess, Is.False); + Assert.That(getAuthenticatorKeyResult.Status, Is.EqualTo(ResultStatus.Forbidden)); + Assert.That(getAuthenticatorKeyResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _currentUserAccountCommand = UserAccountGenerator(userId: CurrentUserId).Generate(); + _secondUserAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_currentUserAccountCommand); + await UserAccessModule.ExecuteCommandAsync(_secondUserAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/GetUserAccountTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/GetUserAccountTests.cs new file mode 100644 index 000000000..81d3e32ea --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/GetUserAccountTests.cs @@ -0,0 +1,69 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.GetUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Me; + +[TestFixture] +internal class GetUserAccountTests : TestBase +{ + private CreateUserAccountCommand? _currentUserAccountCommand; + private CreateUserAccountCommand? _secondUserAccountCommand; + + [Test] + public async Task GetUserAccount_ReturnsOk_WhenUserExists() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var getUserAccountQuery = new GetUserAccountQuery(userId); + + // Act + var getUserAccountResult = await UserAccessModule.ExecuteQueryAsync(getUserAccountQuery); + + // Assert + Assert.That(getUserAccountResult.IsSuccess, Is.True); + Assert.That(getUserAccountResult.Status, Is.EqualTo(ResultStatus.Ok)); + } + + [Test] + public async Task GetUserAccount_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var getUserAccountQuery = new GetUserAccountQuery(userId); + + // Act + var getUserAccountResult = await UserAccessModule.ExecuteQueryAsync(getUserAccountQuery); + + // Assert + Assert.That(getUserAccountResult.IsSuccess, Is.False); + Assert.That(getUserAccountResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task GetUserAccount_ReturnsForbidden_WhenUserIdDoesNotMatchCurrentUser() + { + // Arrange + var userId = _secondUserAccountCommand!.UserId; + var getUserAccountQuery = new GetUserAccountQuery(userId); + + // Act + var getUserAccountResult = await UserAccessModule.ExecuteQueryAsync(getUserAccountQuery); + + // Assert + Assert.That(getUserAccountResult.IsSuccess, Is.False); + Assert.That(getUserAccountResult.Status, Is.EqualTo(ResultStatus.Forbidden)); + Assert.That(getUserAccountResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _currentUserAccountCommand = UserAccountGenerator(userId: CurrentUserId).Generate(); + _secondUserAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_currentUserAccountCommand); + await UserAccessModule.ExecuteCommandAsync(_secondUserAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RegisterAuthenticatorTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RegisterAuthenticatorTests.cs new file mode 100644 index 000000000..9c4213c82 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RegisterAuthenticatorTests.cs @@ -0,0 +1,74 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.AuthenticatorRegistration.GetAuthenticatorKey; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.AuthenticatorRegistration.RegisterAuthenticator; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Me; + +[TestFixture] +internal class RegisterAuthenticatorTests : TestBase +{ + private CreateUserAccountCommand? _currentUserAccountCommand; + private CreateUserAccountCommand? _secondUserAccountCommand; + + [Test] + public async Task RegisterAuthenticator_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var registerAuthenticatorCommand = new RegisterAuthenticatorCommand(userId, "SomeCode"); + + // Act + var registerAuthenticatorResult = await UserAccessModule.ExecuteCommandAsync(registerAuthenticatorCommand); + + // Assert + Assert.That(registerAuthenticatorResult.IsSuccess, Is.False); + Assert.That(registerAuthenticatorResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task RegisterAuthenticator_ReturnsOk_WhenDataIsValid() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var getAuthenticatorKeyQuery = new GetAuthenticatorKeyQuery(userId); + var getAuthenticatorKeyResult = await UserAccessModule.ExecuteQueryAsync(getAuthenticatorKeyQuery); + var authenticator = new Authenticator(); + var code = authenticator.GenerateAuthenticatorCode(getAuthenticatorKeyResult.Value!); + var registerAuthenticatorCommand = new RegisterAuthenticatorCommand(userId, code); + + // Act + var registerAuthenticatorResult = await UserAccessModule.ExecuteCommandAsync(registerAuthenticatorCommand); + + // Assert + Assert.That(registerAuthenticatorResult.IsSuccess, Is.True); + Assert.That(registerAuthenticatorResult.Status, Is.EqualTo(ResultStatus.Ok)); + } + + [Test] + public async Task RegisterAuthenticator_ReturnsForbidden_WhenUserIdDoesNotMatchCurrentUser() + { + // Arrange + var userId = _secondUserAccountCommand!.UserId; + var registerAuthenticatorCommand = new RegisterAuthenticatorCommand(userId, "SomeCode"); + + // Act + var registerAuthenticatorResult = await UserAccessModule.ExecuteCommandAsync(registerAuthenticatorCommand); + + // Assert + Assert.That(registerAuthenticatorResult.IsSuccess, Is.False); + Assert.That(registerAuthenticatorResult.Status, Is.EqualTo(ResultStatus.Forbidden)); + Assert.That(registerAuthenticatorResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _currentUserAccountCommand = UserAccountGenerator(userId: CurrentUserId).Generate(); + _secondUserAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_currentUserAccountCommand); + await UserAccessModule.ExecuteCommandAsync(_secondUserAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RequestChangeEmailAddressTokenTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RequestChangeEmailAddressTokenTests.cs new file mode 100644 index 000000000..951a737cc --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RequestChangeEmailAddressTokenTests.cs @@ -0,0 +1,87 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.RequestChangeEmailAddressToken; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Me; + +[TestFixture] +internal class RequestChangeEmailAddressTokenTests : TestBase +{ + private CreateUserAccountCommand? _currentUserAccountCommand; + private CreateUserAccountCommand? _secondUserAccountCommand; + + [Test] + public async Task RequestChangeEmailAddressToken_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var newEmailAddress = "hallo@mymeetings.com"; + var requestChangeEmailAddressTokenCommand = new RequestChangeEmailAddressTokenCommand(userId, newEmailAddress); + + // Act + var requestChangeEmailAddressTokenResult = await UserAccessModule.ExecuteCommandAsync(requestChangeEmailAddressTokenCommand); + + // Assert + Assert.That(requestChangeEmailAddressTokenResult.IsSuccess, Is.False); + Assert.That(requestChangeEmailAddressTokenResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task RequestChangeEmailAddressToken_ReturnsOk_WhenUserExists() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var newEmailAddress = "hallo@mymeetings.com"; + var requestChangeEmailAddressTokenCommand = new RequestChangeEmailAddressTokenCommand(userId, newEmailAddress); + + // Act + var requestChangeEmailAddressTokenResult = await UserAccessModule.ExecuteCommandAsync(requestChangeEmailAddressTokenCommand); + + // Assert + Assert.That(requestChangeEmailAddressTokenResult.IsSuccess, Is.True); + Assert.That(requestChangeEmailAddressTokenResult.Status, Is.EqualTo(ResultStatus.Ok)); + } + + [Test] + public async Task RequestChangeEmailAddressToken_SendsEmail_WhenUserExists() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var newEmailAddress = "hallo@mymeetings.com"; + var requestChangeEmailAddressTokenCommand = new RequestChangeEmailAddressTokenCommand(userId, newEmailAddress); + + // Act + var requestChangeEmailAddressTokenResult = await UserAccessModule.ExecuteCommandAsync(requestChangeEmailAddressTokenCommand); + + // Assert + Assert.That(EmailSender.EmailMessage, Is.Not.Null); + } + + [Test] + public async Task RequestChangeEmailAddressToken_ReturnsForbidden_WhenUserIdDoesNotMatchCurrentUser() + { + // Arrange + var userId = _secondUserAccountCommand!.UserId; + var newEmailAddress = "hallo@mymeetings.com"; + var requestChangeEmailAddressTokenCommand = new RequestChangeEmailAddressTokenCommand(userId, newEmailAddress); + + // Act + var requestChangeEmailAddressTokenResult = await UserAccessModule.ExecuteCommandAsync(requestChangeEmailAddressTokenCommand); + + // Assert + Assert.That(requestChangeEmailAddressTokenResult.IsSuccess, Is.False); + Assert.That(requestChangeEmailAddressTokenResult.Status, Is.EqualTo(ResultStatus.Forbidden)); + Assert.That(requestChangeEmailAddressTokenResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _currentUserAccountCommand = UserAccountGenerator(userId: CurrentUserId).Generate(); + _secondUserAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_currentUserAccountCommand); + await UserAccessModule.ExecuteCommandAsync(_secondUserAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RequestConfirmEmailAddressTokenTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RequestConfirmEmailAddressTokenTests.cs new file mode 100644 index 000000000..57053633c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/RequestConfirmEmailAddressTokenTests.cs @@ -0,0 +1,83 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.RequestConfirmEmailAddressToken; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Me; + +[TestFixture] +internal class RequestConfirmEmailAddressTokenTests : TestBase +{ + private CreateUserAccountCommand? _currentUserAccountCommand; + private CreateUserAccountCommand? _secondUserAccountCommand; + + [Test] + public async Task RequestConfirmEmailAddressToken_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var requestConfirmEmailAddressTokenCommand = new RequestConfirmEmailAddressTokenCommand(userId); + + // Act + var requestConfirmEmailAddressTokenResult = await UserAccessModule.ExecuteCommandAsync(requestConfirmEmailAddressTokenCommand); + + // Assert + Assert.That(requestConfirmEmailAddressTokenResult.IsSuccess, Is.False); + Assert.That(requestConfirmEmailAddressTokenResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task RequestConfirmEmailAddressToken_ReturnsOk_WhenUserExists() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var requestConfirmEmailAddressTokenCommand = new RequestConfirmEmailAddressTokenCommand(userId); + + // Act + var requestConfirmEmailAddressTokenResult = await UserAccessModule.ExecuteCommandAsync(requestConfirmEmailAddressTokenCommand); + + // Assert + Assert.That(requestConfirmEmailAddressTokenResult.IsSuccess, Is.True); + Assert.That(requestConfirmEmailAddressTokenResult.Status, Is.EqualTo(ResultStatus.Ok)); + } + + [Test] + public async Task RequestConfirmEmailAddressToken_SendsEmail_WhenUserExists() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var requestConfirmEmailAddressTokenCommand = new RequestConfirmEmailAddressTokenCommand(userId); + + // Act + var requestConfirmEmailAddressTokenResult = await UserAccessModule.ExecuteCommandAsync(requestConfirmEmailAddressTokenCommand); + + // Assert + Assert.That(EmailSender.EmailMessage, Is.Not.Null); + } + + [Test] + public async Task RequestConfirmEmailAddressToken_ReturnsForbidden_WhenUserIdDoesNotMatchCurrentUser() + { + // Arrange + var userId = _secondUserAccountCommand!.UserId; + var requestConfirmEmailAddressTokenCommand = new RequestConfirmEmailAddressTokenCommand(userId); + + // Act + var requestConfirmEmailAddressTokenResult = await UserAccessModule.ExecuteCommandAsync(requestConfirmEmailAddressTokenCommand); + + // Assert + Assert.That(requestConfirmEmailAddressTokenResult.IsSuccess, Is.False); + Assert.That(requestConfirmEmailAddressTokenResult.Status, Is.EqualTo(ResultStatus.Forbidden)); + Assert.That(requestConfirmEmailAddressTokenResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _currentUserAccountCommand = UserAccountGenerator(userId: CurrentUserId).Generate(); + _secondUserAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_currentUserAccountCommand); + await UserAccessModule.ExecuteCommandAsync(_secondUserAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/UpdateProfileTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/UpdateProfileTests.cs new file mode 100644 index 000000000..28ba56e17 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Me/UpdateProfileTests.cs @@ -0,0 +1,85 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.UpdateProfile; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Me; + +[TestFixture] +internal class UpdateProfileTests : TestBase +{ + private CreateUserAccountCommand? _currentUserAccountCommand; + private CreateUserAccountCommand? _secondUserAccountCommand; + + [Test] + public async Task UpdateProfile_ReturnsError_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var updateProfileCommand = new UpdateProfileCommand(userId, "foo.bar", "Foo Bar", "Foo", "Bar"); + + // Act + var updateProfileResult = await UserAccessModule.ExecuteCommandAsync(updateProfileCommand); + + // Assert + Assert.That(updateProfileResult.IsSuccess, Is.False); + Assert.That(updateProfileResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task UpdateProfile_ReturnsOk_WhenDataIsValid() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var updateProfileCommand = new UpdateProfileCommand(userId, "foo.bar", "Foo Bar", "Foo", "Bar"); + + // Act + var updateProfileResult = await UserAccessModule.ExecuteCommandAsync(updateProfileCommand); + + // Assert + Assert.That(updateProfileResult.IsSuccess, Is.True); + Assert.That(updateProfileResult.Status, Is.EqualTo(ResultStatus.Ok)); + } + + [Test] + public async Task UpdateProfile_ReturnsError_WhenUserNameIsNotUnique() + { + // Arrange + var userId = _currentUserAccountCommand!.UserId; + var userName = _secondUserAccountCommand!.Login; + var updateProfileCommand = new UpdateProfileCommand(userId, userName, "Foo Bar", "Foo", "Bar"); + + // Act + var updateProfileResult = await UserAccessModule.ExecuteCommandAsync(updateProfileCommand); + + // Assert + Assert.That(updateProfileResult.IsSuccess, Is.False); + Assert.That(updateProfileResult.Status, Is.EqualTo(ResultStatus.Error)); + } + + [Test] + public async Task UpdateProfile_ReturnsForbidden_WhenUserIdDoesNotMatchCurrentUser() + { + // Arrange + var userId = _secondUserAccountCommand!.UserId; + var updateProfileCommand = new UpdateProfileCommand(userId, "foo.bar", "Foo Bar", "Foo", "Bar"); + + // Act + var updateProfileResult = await UserAccessModule.ExecuteCommandAsync(updateProfileCommand); + + // Assert + Assert.That(updateProfileResult.IsSuccess, Is.False); + Assert.That(updateProfileResult.Status, Is.EqualTo(ResultStatus.Forbidden)); + Assert.That(updateProfileResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _currentUserAccountCommand = UserAccountGenerator(userId: CurrentUserId).Generate(); + _secondUserAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_currentUserAccountCommand); + await UserAccessModule.ExecuteCommandAsync(_secondUserAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/0002_SeedPermissions.sql b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/0002_SeedPermissions.sql new file mode 100644 index 000000000..94226bebe --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/0002_SeedPermissions.sql @@ -0,0 +1,86 @@ +INSERT INTO [usersmi].[Permissions] ([Code], [Name], [Description]) + VALUES + + -- ############################################################################################################## + -- # *** APPLICATION *** # + -- ############################################################################################################## + ('Application.Administrator', 'Administrator', NULL), + + + -- ############################################################################################################## + -- # *** USERS *** # + -- ############################################################################################################## + + -- Users + ('Users.GetUsers', 'GetUsers', NULL), + ('Users.CreateUserAccount', 'CreateUserAccount', NULL), + ('Users.UpdateUserAccount', 'UpdateUserAccount', NULL), + ('Users.UnlockUserAccount', 'UnlockUserAccount', NULL), + ('Users.ChangeUserEmailAddress', 'ChangeUserEmailAddress', NULL), + ('Users.GetUserRoles', 'GetUserRoles', NULL), + ('Users.SetUserRoles', 'SetUserRoles', NULL), + ('Users.GetUserPermissions', 'GetUserPermissions', NULL), + ('Users.SetUserPermissions', 'SetUserPermissions', NULL), + + -- User roles + ('Users.GetRoles', 'GetRoles', NULL), + ('Users.AddRole', 'AddRole', NULL), + ('Users.RenameRole', 'RenameRole', NULL), + ('Users.DeleteRole', 'DeleteRole', NULL), + ('Users.GetRolePermissions', 'GetRolePermissions', NULL), + ('Users.SetRolePermissions', 'SetRolePermissions', NULL), + + -- ############################################################################################################## + -- # *** MEETINGS *** # + -- ############################################################################################################## + ('GetMeetingGroupProposals', 'GetMeetingGroupProposals', NULL), + ('ProposeMeetingGroup', 'ProposeMeetingGroup', NULL), + ('CreateNewMeeting', 'CreateNewMeeting', NULL), + ('EditMeeting', 'EditMeeting', NULL), + ('AddMeetingAttendee', 'AddMeetingAttendee', NULL), + ('RemoveMeetingAttendee', 'RemoveMeetingAttendee', NULL), + ('AddNotAttendee', 'AddNotAttendee', NULL), + ('ChangeNotAttendeeDecision', 'ChangeNotAttendeeDecision', NULL), + ('SignUpMemberToWaitlist', 'SignUpMemberToWaitlist', NULL), + ('SignOffMemberFromWaitlist', 'SignOffMemberFromWaitlist', NULL), + ('SetMeetingHostRole', 'SetMeetingHostRole', NULL), + ('SetMeetingAttendeeRole', 'SetMeetingAttendeeRole', NULL), + ('CancelMeeting', 'CancelMeeting', NULL), + ('GetAllMeetingGroups', 'GetAllMeetingGroups', NULL), + ('EditMeetingGroupGeneralAttributes', 'EditMeetingGroupGeneralAttributes', NULL), + ('JoinToGroup', 'JoinToGroup', NULL), + ('LeaveMeetingGroup', 'LeaveMeetingGroup', NULL), + ('AddMeetingComment', 'AddMeetingComment', NULL), + ('EditMeetingComment', 'EditMeetingComment', NULL), + ('RemoveMeetingComment', 'RemoveMeetingComment', NULL), + ('AddMeetingCommentReply', 'AddMeetingCommentReply', NULL), + ('LikeMeetingComment', 'LikeMeetingComment', NULL), + ('UnlikeMeetingComment', 'UnlikeMeetingComment', NULL), + ('EnableMeetingCommenting', 'EnableMeetingCommenting', NULL), + ('DisableMeetingCommenting', 'DisableMeetingCommenting', NULL), + ('MyMeetingGroupsView', 'MyMeetingGroupsView', NULL), + ('AllMeetingGroupsView', 'AllMeetingGroupsView', NULL), + ('SubscriptionView', 'SubscriptionView', NULL), + ('EmailsView', 'EmailsView', NULL), + ('MyMeetingsView', 'MyMeetingsView', NULL), + ('GetAuthenticatedMemberMeetings', 'GetAuthenticatedMemberMeetings', NULL), + + -- ############################################################################################################## + -- # *** ADMINISTRATION *** # + -- ############################################################################################################## + -- + ('AcceptMeetingGroupProposal', 'AcceptMeetingGroupProposal', NULL), + ('AdministrationsView', 'AdministrationsView', NULL), + + -- ############################################################################################################## + -- # *** PAYMENTS *** # + -- ############################################################################################################## + ('RegisterPayment', 'RegisterPayment', NULL), + ('BuySubscription', 'BuySubscription', NULL), + ('RenewSubscription', 'RenewSubscription', NULL), + ('CreatePriceListItem', 'CreatePriceListItem', NULL), + ('ActivatePriceListItem', 'ActivatePriceListItem', NULL), + ('DeactivatePriceListItem', 'DeactivatePriceListItem', NULL), + ('ChangePriceListItemAttributes', 'ChangePriceListItemAttributes', NULL), + ('GetAuthenticatedPayerSubscription', 'GetAuthenticatedPayerSubscription', NULL), + ('GetPriceListItem', 'GetPriceListItem', NULL); \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/CreateRoleTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/CreateRoleTests.cs new file mode 100644 index 000000000..9917c688d --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/CreateRoleTests.cs @@ -0,0 +1,80 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Roles; + +[TestFixture] +internal class CreateRoleTests : TestBase +{ + [Test] + public async Task CreateRole_ReturnsOk_WhenRoleNameIsValid() + { + // Arrange + var createRoleCommand = RoleGenerator().Generate(); + + // Act + var createRoleResult = await UserAccessModule.ExecuteCommandAsync(createRoleCommand); + + // Assert + Assert.That(createRoleResult.IsSuccess, Is.True); + Assert.That(createRoleResult.Status, Is.EqualTo(ResultStatus.Created)); + } + + [Test] + public async Task CreateRole_ReturnsOk_WhenRolePermissionsAreEmpty() + { + // Arrange + var createRoleCommand = RoleGenerator(permissions: Enumerable.Empty()).Generate(); + + // Act + var createRoleResult = await UserAccessModule.ExecuteCommandAsync(createRoleCommand); + + // Assert + Assert.That(createRoleResult.IsSuccess, Is.True); + Assert.That(createRoleResult.Status, Is.EqualTo(ResultStatus.Created)); + } + + [Test] + public async Task CreateRole_ReturnsOk_WhenRolePermissionsAreValid() + { + // Arrange + var createRoleCommand = RoleGenerator(permissions: ["Users.CreateUserAccount", "Users.AddRole"]).Generate(); + + // Act + var createRoleResult = await UserAccessModule.ExecuteCommandAsync(createRoleCommand); + + // Assert + Assert.That(createRoleResult.IsSuccess, Is.True); + Assert.That(createRoleResult.Status, Is.EqualTo(ResultStatus.Created)); + } + + [Test] + public async Task CreateRole_ReturnsError_WhenRoleNameAlreadyExists() + { + // Arrange + var createRoleCommand = RoleGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(createRoleCommand); + + // Act + var createRoleResult = await UserAccessModule.ExecuteCommandAsync(createRoleCommand); + + // Assert + Assert.That(createRoleResult.IsSuccess, Is.False); + Assert.That(createRoleResult.Status, Is.EqualTo(ResultStatus.Error)); + } + + [Test] + public async Task CreateRole_ReturnsError_WhenRoleNameIsEmpty() + { + // Arrange + var createRoleCommand = RoleGenerator(roleName: string.Empty).Generate(); + + // Act + var createRoleResult = await UserAccessModule.ExecuteCommandAsync(createRoleCommand); + + // Assert + Assert.That(createRoleResult.IsSuccess, Is.False); + Assert.That(createRoleResult.Status, Is.EqualTo(ResultStatus.Error)); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/DeleteRoleTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/DeleteRoleTests.cs new file mode 100644 index 000000000..0fadb8f3c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/DeleteRoleTests.cs @@ -0,0 +1,51 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.CreateRole; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.DeleteRole; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Roles; + +[TestFixture] +internal class DeleteRoleTests : TestBase +{ + private Guid _roleId; + private CreateRoleCommand _createRoleCommand = null!; + + [Test] + public async Task DeleteRole_ReturnsOk_WhenRoleExists() + { + // Arrange + var deleteRoleCommand = new DeleteRoleCommand(_roleId); + + // Act + var deleteRoleResult = await UserAccessModule.ExecuteCommandAsync(deleteRoleCommand); + + // Assert + Assert.That(deleteRoleResult.IsSuccess, Is.True); + Assert.That(deleteRoleResult.Status, Is.EqualTo(ResultStatus.Ok)); + } + + [Test] + public async Task DeleteRole_ReturnsNotFound_WhenRoleDoesNotExist() + { + // Arrange + var roleId = Guid.NewGuid(); + var deleteRoleCommand = new DeleteRoleCommand(roleId); + + // Act + var deleteRoleResult = await UserAccessModule.ExecuteCommandAsync(deleteRoleCommand); + + // Assert + Assert.That(deleteRoleResult.IsSuccess, Is.False); + Assert.That(deleteRoleResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _createRoleCommand = RoleGenerator().Generate(); + var result = await UserAccessModule.ExecuteCommandAsync(_createRoleCommand); + _roleId = result.Value; + } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRolePermissionsTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRolePermissionsTests.cs new file mode 100644 index 000000000..34ad66189 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRolePermissionsTests.cs @@ -0,0 +1,55 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.CreateRole; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRolePermissions; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Roles; + +[TestFixture] +internal class GetRolePermissionsTests : TestBase +{ + private Guid _roleId; + private CreateRoleCommand _createRoleCommand = null!; + + [Test] + public async Task GetRolePermissions_ReturnsNotFound_WhenRoleDoesNotExist() + { + // Arrange + var roleId = Guid.NewGuid(); + var getRolePermissionsQuery = new GetRolePermissionsQuery(roleId); + + // Act + var getRolePermissionsResult = await UserAccessModule.ExecuteQueryAsync(getRolePermissionsQuery); + + // Assert + Assert.That(getRolePermissionsResult.IsSuccess, Is.False); + Assert.That(getRolePermissionsResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task GetRolePermissions_ReturnsOk_WhenRoleExists() + { + // Arrange + var getRolePermissionsQuery = new GetRolePermissionsQuery(_roleId); + + // Act + var getRolePermissionsResult = await UserAccessModule.ExecuteQueryAsync(getRolePermissionsQuery); + var permissions = getRolePermissionsResult.Value?.Select(x => x.Code) ?? Enumerable.Empty(); + + // Assert + Assert.That(getRolePermissionsResult.IsSuccess, Is.True); + Assert.That(getRolePermissionsResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(_createRoleCommand.Permissions, Is.EquivalentTo(permissions)); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + await ExecuteScript("Roles/0002_SeedPermissions.sql"); + + _createRoleCommand = RoleGenerator(permissions: ["Users.CreateUserAccount", "Users.AddRole"]).Generate(); + var result = await UserAccessModule.ExecuteCommandAsync(_createRoleCommand); + _roleId = result.Value; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRoleTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRoleTests.cs new file mode 100644 index 000000000..46b2c854a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRoleTests.cs @@ -0,0 +1,53 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.CreateRole; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRoles.ById; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Roles; + +[TestFixture] +internal class GetRoleTests : TestBase +{ + private Guid _roleId; + private CreateRoleCommand _createRoleCommand = null!; + + [Test] + public async Task GetRole_ReturnsNotFound_WhenRoleDoesNotExist() + { + // Arrange + var roleId = Guid.NewGuid(); + var getRoleQuery = new GetRolesQuery(roleId); + + // Act + var getRoleResult = await UserAccessModule.ExecuteQueryAsync(getRoleQuery); + + // Assert + Assert.That(getRoleResult.IsSuccess, Is.False); + Assert.That(getRoleResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task GetRole_ReturnsOk_WhenRoleExists() + { + // Arrange + var getRoleQuery = new GetRolesQuery(_roleId); + + // Act + var getRoleResult = await UserAccessModule.ExecuteQueryAsync(getRoleQuery); + var role = getRoleResult.Value; + + // Assert + Assert.That(getRoleResult.IsSuccess, Is.True); + Assert.That(getRoleResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(role?.Name, Is.EqualTo(_createRoleCommand.Name)); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _createRoleCommand = RoleGenerator().Generate(); + var result = await UserAccessModule.ExecuteCommandAsync(_createRoleCommand); + _roleId = result.Value; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRolesTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRolesTests.cs new file mode 100644 index 000000000..2fac9a7a7 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/GetRolesTests.cs @@ -0,0 +1,46 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRoles.Directory; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Roles; + +[TestFixture] +internal class GetRolesTests : TestBase +{ + [Test] + public async Task GetRoles_ReturnsOk_WhenRoleExists() + { + // Arrange + await UserAccessModule.ExecuteCommandAsync(RoleGenerator().Generate()); + await UserAccessModule.ExecuteCommandAsync(RoleGenerator().Generate()); + await UserAccessModule.ExecuteCommandAsync(RoleGenerator().Generate()); + int expectedRoleCount = 3; + var getRolesQuery = new GetRolesQuery(); + + // Act + var getRolesResult = await UserAccessModule.ExecuteQueryAsync(getRolesQuery); + + // Assert + Assert.That(getRolesResult.IsSuccess, Is.True); + Assert.That(getRolesResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(getRolesResult.Value!.Count, Is.EqualTo(expectedRoleCount)); + } + + [Test] + public async Task GetRoles_ReturnsOk_WhenNoRoleExists() + { + // Arrange + int expectedRoleCount = 0; + var getRolesQuery = new GetRolesQuery(); + + // Act + var getRolesResult = await UserAccessModule.ExecuteQueryAsync(getRolesQuery); + + // Assert + Assert.That(getRolesResult.IsSuccess, Is.True); + Assert.That(getRolesResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(getRolesResult.Value!.Count, Is.EqualTo(expectedRoleCount)); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/RenameRoleTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/RenameRoleTests.cs new file mode 100644 index 000000000..6b0c0af72 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/RenameRoleTests.cs @@ -0,0 +1,84 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.CreateRole; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.RenameRole; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Roles; + +[TestFixture] +internal class RenameRoleTests : TestBase +{ + private Guid _roleId; + private CreateRoleCommand _createRoleCommand = null!; + + [Test] + public async Task RenameRole_ReturnsNotFound_WhenRoleDoesNotExist() + { + // Arrange + var roleId = Guid.NewGuid(); + var newRoleName = "NewRoleName"; + var renameRoleCommand = new RenameRoleCommand(roleId, newRoleName); + + // Act + var renameRoleResult = await UserAccessModule.ExecuteCommandAsync(renameRoleCommand); + + // Assert + Assert.That(renameRoleResult.IsSuccess, Is.False); + Assert.That(renameRoleResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task RenameRole_ReturnsError_WhenRoleNameIsInvalid() + { + // Arrange + var invalidRoleName = string.Empty; // Empty role name + var renameRoleCommand = new RenameRoleCommand(_roleId, invalidRoleName); + + // Act + var renameRoleResult = await UserAccessModule.ExecuteCommandAsync(renameRoleCommand); + + // Assert + Assert.That(renameRoleResult.IsSuccess, Is.False); + Assert.That(renameRoleResult.Status, Is.EqualTo(ResultStatus.Error)); + } + + [Test] + public async Task RenameRole_ReturnsError_WhenRoleNameAlreadyExists() + { + // Arrange + var anotherRoleCommand = RoleGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(anotherRoleCommand); + var renameRoleCommand = new RenameRoleCommand(_roleId, anotherRoleCommand.Name); + + // Act + var renameRoleResult = await UserAccessModule.ExecuteCommandAsync(renameRoleCommand); + + // Assert + Assert.That(renameRoleResult.IsSuccess, Is.False); + Assert.That(renameRoleResult.Status, Is.EqualTo(ResultStatus.Error)); + } + + [Test] + public async Task RenameRole_ReturnsOk_WhenRoleNameIsValid() + { + // Arrange + var newRoleName = "UpdatedRoleName"; + var renameRoleCommand = new RenameRoleCommand(_roleId, newRoleName); + + // Act + var renameRoleResult = await UserAccessModule.ExecuteCommandAsync(renameRoleCommand); + + // Assert + Assert.That(renameRoleResult.IsSuccess, Is.True); + Assert.That(renameRoleResult.Status, Is.EqualTo(ResultStatus.Ok)); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _createRoleCommand = RoleGenerator().Generate(); + var result = await UserAccessModule.ExecuteCommandAsync(_createRoleCommand); + _roleId = result.Value; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/SetRolePermissionsTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/SetRolePermissionsTests.cs new file mode 100644 index 000000000..51401d527 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/Roles/SetRolePermissionsTests.cs @@ -0,0 +1,81 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRolePermissions; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.SetRolePermissions; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.Roles; + +[TestFixture] +internal class SetRolePermissionsTests : TestBase +{ + private Guid _roleId; + + [Test] + public async Task SetRolePermissions_ReturnsOk_WhenRolePermissionsAreEmpty() + { + // Arrange + var expectedPermissionCount = 0; + var newPermissions = Array.Empty(); + var setRolePermissionsCommand = new SetRolePermissionsCommand(_roleId, newPermissions); + var getRolePermissionsQuery = new GetRolePermissionsQuery(_roleId); + + // Act + var setRolePermissionsResult = await UserAccessModule.ExecuteCommandAsync(setRolePermissionsCommand); + var getRolePermissionsResult = await UserAccessModule.ExecuteQueryAsync(getRolePermissionsQuery); + + // Assert + Assert.That(setRolePermissionsResult.IsSuccess, Is.True); + Assert.That(setRolePermissionsResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(getRolePermissionsResult.IsSuccess, Is.True); + Assert.That(getRolePermissionsResult.Value!.Count(), Is.EqualTo(expectedPermissionCount)); + } + + [Test] + public async Task SetRolePermissions_ReturnsNotFound_WhenRoleDoesNotExist() + { + // Arrange + var nonExistentRoleId = Guid.NewGuid(); + var newPermissions = Array.Empty(); + var setRolePermissionsCommand = new SetRolePermissionsCommand(nonExistentRoleId, newPermissions); + + // Act + var setRolePermissionsResult = await UserAccessModule.ExecuteCommandAsync(setRolePermissionsCommand); + + // Assert + Assert.That(setRolePermissionsResult.IsSuccess, Is.False); + Assert.That(setRolePermissionsResult.Status, Is.EqualTo(ResultStatus.NotFound)); + } + + [Test] + public async Task SetRolePermissions_ReturnsOk_WhenPermissionsAreValid() + { + // Arrange + var expectedPermissionCount = 2; + var newPermissions = new[] { "Users.GetUserRoles", "Users.SetUserRoles" }; + var setRolePermissionsCommand = new SetRolePermissionsCommand(_roleId, newPermissions); + var getRolePermissionsQuery = new GetRolePermissionsQuery(_roleId); + + // Act + var setRolePermissionsResult = await UserAccessModule.ExecuteCommandAsync(setRolePermissionsCommand); + var getRolePermissionsResult = await UserAccessModule.ExecuteQueryAsync(getRolePermissionsQuery); + var permissions = getRolePermissionsResult.Value?.Select(x => x.Code) ?? Enumerable.Empty(); + + // Assert + Assert.That(setRolePermissionsResult.IsSuccess, Is.True); + Assert.That(setRolePermissionsResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(getRolePermissionsResult.IsSuccess, Is.True); + Assert.That(getRolePermissionsResult.Value!.Count(), Is.EqualTo(expectedPermissionCount)); + Assert.That(newPermissions, Is.EquivalentTo(permissions)); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + await ExecuteScript("Roles/0002_SeedPermissions.sql"); + + var createRoleCommand = RoleGenerator(permissions: ["Users.GetUsers", "Users.UnlockUserAccount"]).Generate(); + var result = await UserAccessModule.ExecuteCommandAsync(createRoleCommand); + _roleId = result.Value; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/Authenticator.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/Authenticator.cs new file mode 100644 index 000000000..82b596190 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/Authenticator.cs @@ -0,0 +1,12 @@ +using OtpNet; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; + +public class Authenticator +{ + public string GenerateAuthenticatorCode(string secretKey) + { + var totp = new Totp(Base32Encoding.ToBytes(secretKey)); + return totp.ComputeTotp(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/EmailSender.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/EmailSender.cs new file mode 100644 index 000000000..872b79b1a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/EmailSender.cs @@ -0,0 +1,17 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application.Emails; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; + +public class EmailSender : IEmailSender +{ + /// + /// Last sent email message. + /// + public EmailMessage? EmailMessage { get; private set; } + + public Task SendEmail(EmailMessage message) + { + EmailMessage = message; + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/ExecutionContextMock.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/ExecutionContextMock.cs new file mode 100644 index 000000000..8a49c5476 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/ExecutionContextMock.cs @@ -0,0 +1,23 @@ +using CompanyName.MyMeetings.BuildingBlocks.Application; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork +{ + public class ExecutionContextMock : IExecutionContextAccessor + { + public ExecutionContextMock(Guid userId) + { + UserId = userId; + } + + public Guid UserId { get; private set; } + + public Guid CorrelationId { get; } + + public bool IsAvailable { get; } + + public void SetUserId(Guid userId) + { + this.UserId = userId; + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/OutboxMessagesHelper.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/OutboxMessagesHelper.cs new file mode 100644 index 000000000..77810b5cc --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/OutboxMessagesHelper.cs @@ -0,0 +1,35 @@ +using System.Data; +using System.Reflection; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Authentication.Login; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Processing.Outbox; +using Dapper; +using MediatR; +using Newtonsoft.Json; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork +{ + public class OutboxMessagesHelper + { + public static async Task> GetOutboxMessages(IDbConnection connection) + { + const string sql = $""" + SELECT + [OutboxMessage].[Id] as [{nameof(OutboxMessageDto.Id)}], + [OutboxMessage].[Type] as [{nameof(OutboxMessageDto.Type)}], + [OutboxMessage].[Data] as [{nameof(OutboxMessageDto.Data)}] + FROM [users].[OutboxMessages] AS [OutboxMessage] + ORDER BY [OutboxMessage].[OccurredOn] + """; + + var messages = await connection.QueryAsync(sql); + return messages.AsList(); + } + + public static T Deserialize(OutboxMessageDto message) + where T : class, INotification + { + Type type = Assembly.GetAssembly(typeof(AccountLoginCommand))!.GetType(typeof(T).FullName!)!; + return (JsonConvert.DeserializeObject(message.Data ?? string.Empty, type) as T)!; + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/TestBase.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/TestBase.cs new file mode 100644 index 000000000..a9f85ac3a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/SeedWork/TestBase.cs @@ -0,0 +1,144 @@ +using System.Data; +using System.Data.SqlClient; +using Bogus; +using CompanyName.MyMeetings.BuildingBlocks.Application.Security; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Emails; +using CompanyName.MyMeetings.BuildingBlocks.IntegrationTests; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.CreateRole; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure; +using CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration; +using Dapper; +using MediatR; +using NSubstitute; +using NUnit.Framework; +using Serilog; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork +{ + public class TestBase + { + [SetUp] + public async Task BeforeEachTest() + { + const string connectionStringEnvironmentVariable = + "ASPNETCORE_MyMeetings_IntegrationTests_ConnectionString"; + ConnectionString = EnvironmentVariablesProvider.GetVariable(connectionStringEnvironmentVariable); + if (ConnectionString == null) + { + throw new ApplicationException( + $"Define connection string to integration tests database using environment variable: {connectionStringEnvironmentVariable}"); + } + + Logger = Substitute.For(); + EmailSender = new EmailSender(); + CurrentUserId = Guid.NewGuid(); + + UserAccessStartup.Initialize( + ConnectionString, + new ExecutionContextMock(CurrentUserId), + Logger, + new EmailsConfiguration("from@email.com"), + "key", + EmailSender, + null, + new UserAccessConfiguration() + { + Security = new Security() + { + JwtAudience = "api://mymeetings.com/api", + JwtIssuer = "api://mymeetings.com/api", + JwtTokenLifetimeInMinutes = 60, + JwtSecretKey = "very_long_and_secure_key_that_should_beSoredInA_secure_way" + } + }); + + UserAccessModule = new UserAccessModule(); + + using (var sqlConnection = new SqlConnection(ConnectionString)) + { + await ClearDatabase(sqlConnection); + await SeedDatabase(sqlConnection); + } + } + + protected Guid CurrentUserId { get; private set; } + + protected string ConnectionString { get; private set; } = null!; + + protected ILogger Logger { get; private set; } = null!; + + protected IUserAccessModule UserAccessModule { get; private set; } = null!; + + protected EmailSender EmailSender { get; private set; } = null!; + + protected Faker UserAccountGenerator(string? login = null, string? password = null, Guid? userId = null) => + new Faker() + .CustomInstantiator(f => + { + var firstName = f.Name.FirstName(); + var lastName = f.Name.LastName(); + return new CreateUserAccountCommand( + id: Guid.NewGuid(), + userId: userId ?? Guid.NewGuid(), + login: login ?? $"{firstName}.{lastName}".Replace("'", string.Empty).ToLower(), + password: PasswordManager.HashPassword(password ?? f.Internet.Password()), + name: $"{firstName} {lastName}", + firstName: firstName, + lastName: lastName, + emailAddress: f.Internet.Email()); + }); + + protected Faker RoleGenerator(string? roleName = null, IEnumerable? permissions = null) => + new Faker() + .CustomInstantiator(f => + { + return new CreateRoleCommand( + name: roleName ?? f.Lorem.Word().ToLower(), + permissions); + }); + + protected async Task GetLastOutboxMessage() + where T : class, INotification + { + using (var connection = new SqlConnection(ConnectionString)) + { + var messages = await OutboxMessagesHelper.GetOutboxMessages(connection); + + return OutboxMessagesHelper.Deserialize(messages.Last()); + } + } + + protected async Task ExecuteScript(string scriptPath) + { + var sql = await File.ReadAllTextAsync(scriptPath); + + await using var sqlConnection = new SqlConnection(ConnectionString); + await sqlConnection.ExecuteScalarAsync(sql); + } + + protected virtual Task SeedDatabase(IDbConnection connection) + { + return Task.CompletedTask; + } + + private static async Task ClearDatabase(IDbConnection connection) + { + const string sql = "DELETE FROM [usersmi].[InboxMessages] " + + "DELETE FROM [usersmi].[InternalCommands] " + + "DELETE FROM [usersmi].[OutboxMessages] " + + "DELETE FROM [usersmi].[UserRoles] " + + "DELETE FROM [usersmi].[RoleClaims] " + + "DELETE FROM [usersmi].[Roles] " + + "DELETE FROM [usersmi].[UserClaims] " + + "DELETE FROM [usersmi].[UserLogins] " + + "DELETE FROM [usersmi].[UserRefreshTokens] " + + "DELETE FROM [usersmi].[UserTokens] " + + "DELETE FROM [usersmi].[Users] " + + "DELETE FROM [usersmi].[Permissions] "; + + await connection.ExecuteScalarAsync(sql); + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/ChangeUserEmailAddressTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/ChangeUserEmailAddressTests.cs new file mode 100644 index 000000000..cb704c23d --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/ChangeUserEmailAddressTests.cs @@ -0,0 +1,61 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.ChangeEmailAddress; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserAccounts.ById; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.UserAccounts; + +[TestFixture] +internal class ChangeUserEmailAddressTests : TestBase +{ + private CreateUserAccountCommand? _userAccountCommand; + + [Test] + public async Task ChangeUserEmailAddress_ReturnsOk_WhenEmailIsValid() + { + // Arrange + var userId = _userAccountCommand!.UserId; + var newEmailAddress = "kamil@mymeetings.com"; + var changeEmailCommand = new ChangeUserEmailAddressCommand(userId, newEmailAddress); + var getUserQuery = new GetUserAccountsQuery(userId); + + // Act + var changeEmailResult = await UserAccessModule.ExecuteCommandAsync(changeEmailCommand); + var getUserResult = await UserAccessModule.ExecuteQueryAsync(getUserQuery); + + // Assert + Assert.That(changeEmailResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(changeEmailResult.IsSuccess, Is.True); + Assert.That(changeEmailResult.Errors, Is.Empty); + Assert.That(getUserResult.Value!.Email, Is.EqualTo(newEmailAddress)); + } + + [Test] + public async Task ChangeUserEmailAddress_ReturnsError_WhenNewEmailAddressIsInvalid() + { + // Arrange + var userId = _userAccountCommand!.UserId; + var newEmailAddress = "invalid-email"; + var changeEmailCommand = new ChangeUserEmailAddressCommand(userId, newEmailAddress); + var getUserQuery = new GetUserAccountsQuery(userId); + + // Act + var changeEmailResult = await UserAccessModule.ExecuteCommandAsync(changeEmailCommand); + var getUserResult = await UserAccessModule.ExecuteQueryAsync(getUserQuery); + + // Assert + Assert.That(changeEmailResult.Status, Is.EqualTo(ResultStatus.Error)); + Assert.That(changeEmailResult.IsSuccess, Is.False); + Assert.That(changeEmailResult.Errors, Is.Not.Empty); + Assert.That(getUserResult.Value!.Email, Is.EqualTo(_userAccountCommand.EmailAddress)); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _userAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_userAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/CreateUserTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/CreateUserTests.cs new file mode 100644 index 000000000..2e18ebc4e --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/CreateUserTests.cs @@ -0,0 +1,56 @@ +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.UserAccounts; + +[TestFixture] +internal class CreateUserTests : TestBase +{ + [Test] + public async Task Create_ReturnsCreated_WhenUserIsValid() + { + // Arrange + var command = UserAccountGenerator().Generate(); + + // Act + var result = await UserAccessModule.ExecuteCommandAsync(command); + + // Assert + Assert.That(result.Status, Is.EqualTo(ResultStatus.Created)); + } + + [Test] + public async Task Create_ReturnsError_WhenNameIsInvalid() + { + // Arrange + const string invalidName = ""; + var request = UserAccountGenerator(login: invalidName) + .Clone().Generate(); + + // Act + var result = await UserAccessModule.ExecuteCommandAsync(request); + + // Assert + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.Status, Is.EqualTo(ResultStatus.Error)); + } + + [Test] + public async Task Create_ReturnsOk_WhenUserNameAlreadyExists() + { + // Arrange + var request = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(request); + + // Act + var result = await UserAccessModule.ExecuteCommandAsync(request); + + // Assert + // As the system is designed to not leak information about existing users, + // the response should be of status Ok instead of Created. And the user id should be a fictive Guid. + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(result.Value, Is.Not.EqualTo(Guid.Empty)); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserAccountTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserAccountTests.cs new file mode 100644 index 000000000..031ec8424 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserAccountTests.cs @@ -0,0 +1,59 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserAccounts.ById; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.UserAccounts; + +[TestFixture] +public class GetUserAccountTests : TestBase +{ + private CreateUserAccountCommand _userAccountCommand = null!; + + [Test] + public async Task Get_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var getUserQuery = new GetUserAccountsQuery(userId); + + // Act + var getUserResult = await UserAccessModule.ExecuteQueryAsync(getUserQuery); + + // Assert + Assert.That(getUserResult.Status, Is.EqualTo(ResultStatus.NotFound)); + Assert.That(getUserResult.IsSuccess, Is.False); + Assert.That(getUserResult.Value, Is.Null); + } + + [Test] + public async Task Get_ReturnsOk_WhenUserExists() + { + // Arrange + var userId = _userAccountCommand.UserId; + var getUserQuery = new GetUserAccountsQuery(userId); + + // Act + var getUserResult = await UserAccessModule.ExecuteQueryAsync(getUserQuery); + var user = getUserResult.Value!; + + // Assert + Assert.That(getUserResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(getUserResult.IsSuccess, Is.True); + Assert.That(user, Is.Not.Null); + Assert.That(user.Id, Is.EqualTo(_userAccountCommand.UserId)); + Assert.That(user.UserName, Is.EqualTo(_userAccountCommand.Login)); + Assert.That(user.Email, Is.EqualTo(_userAccountCommand.EmailAddress)); + Assert.That(user.FirstName, Is.EqualTo(_userAccountCommand.FirstName)); + Assert.That(user.LastName, Is.EqualTo(_userAccountCommand.LastName)); + Assert.That(user.Name, Is.EqualTo(_userAccountCommand.Name)); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _userAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_userAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserAccountsTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserAccountsTests.cs new file mode 100644 index 000000000..03214a14e --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserAccountsTests.cs @@ -0,0 +1,37 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserAccounts.Directory; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.UserAccounts; + +[TestFixture] +internal class GetUserAccountsTests : TestBase +{ + private CreateUserAccountCommand _firstUserAccountCommand = null!; + private CreateUserAccountCommand _secondUserAccountCommand = null!; + + [Test] + public async Task Get_ReturnsAllUsers_WhenUsersExist() + { + // Arrange + var gerUsersQuery = new GetUserAccountsQuery(); + + // Act + var getUsersResult = await UserAccessModule.ExecuteQueryAsync(gerUsersQuery); + + // Assert + Assert.That(getUsersResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(getUsersResult.Value!.Count, Is.EqualTo(2)); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _firstUserAccountCommand = UserAccountGenerator().Generate(); + _secondUserAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_firstUserAccountCommand); + await UserAccessModule.ExecuteCommandAsync(_secondUserAccountCommand); + } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserRolesTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserRolesTests.cs new file mode 100644 index 000000000..6f5b6a7ab --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/GetUserRolesTests.cs @@ -0,0 +1,78 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.CreateRole; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.GetUserRoles; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.SetUserRoles; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.UserAccounts; + +[TestFixture] +internal class GetUserRolesTests : TestBase +{ + private CreateUserAccountCommand _userAccountWithRolesCommand = null!; + private CreateUserAccountCommand _userAccountWithoutRolesCommand = null!; + private CreateRoleCommand _roleCommand = null!; + + [Test] + public async Task Get_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var getUserRolesQuery = new GetUserRolesQuery(userId); + + // Act + var getUserRolesResult = await UserAccessModule.ExecuteQueryAsync(getUserRolesQuery); + + // Assert + Assert.That(getUserRolesResult.Status, Is.EqualTo(ResultStatus.NotFound)); + Assert.That(getUserRolesResult.Value, Is.Null); + } + + [Test] + public async Task Get_ReturnsUserRoles_WhenUserExists() + { + // Arrange + var userId = _userAccountWithRolesCommand.UserId; + var getUserRolesQuery = new GetUserRolesQuery(userId); + + // Act + var getUserRolesResult = await UserAccessModule.ExecuteQueryAsync(getUserRolesQuery); + + // Assert + Assert.That(getUserRolesResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(getUserRolesResult.Value!.Count, Is.EqualTo(1)); + } + + [Test] + public async Task Get_ReturnsEmptyUserRoles_WhenUserHasNoRolesAssigned() + { + // Arrange + var userId = _userAccountWithoutRolesCommand.UserId; + var getUserRolesQuery = new GetUserRolesQuery(userId); + + // Act + var getUserRolesResult = await UserAccessModule.ExecuteQueryAsync(getUserRolesQuery); + + // Assert + Assert.That(getUserRolesResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(getUserRolesResult.Value!.Count, Is.EqualTo(0)); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _userAccountWithRolesCommand = UserAccountGenerator().Generate(); + _userAccountWithoutRolesCommand = UserAccountGenerator().Generate(); + _roleCommand = RoleGenerator().Generate(); + + await UserAccessModule.ExecuteCommandAsync(_userAccountWithRolesCommand); + await UserAccessModule.ExecuteCommandAsync(_userAccountWithoutRolesCommand); + var roleId = (await UserAccessModule.ExecuteCommandAsync(_roleCommand)).Value; + + // Assign role to user + var assignRoleCommand = new SetUserRolesCommand(_userAccountWithRolesCommand.UserId, [roleId]); + await UserAccessModule.ExecuteCommandAsync(assignRoleCommand); + } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/SetUserPermissionsTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/SetUserPermissionsTests.cs new file mode 100644 index 000000000..e6f725f1c --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/SetUserPermissionsTests.cs @@ -0,0 +1,69 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.SetUserPermissions; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.UserAccounts; + +[TestFixture] +internal class SetUserPermissionsTests : TestBase +{ + private CreateUserAccountCommand _userAccountCommand = null!; + + [Test] + public async Task SetUserPermissions_ReturnsOk_WhenPermissionsAreValid() + { + // Arrange + var userId = _userAccountCommand.UserId; + var setUserRolesCommand = new SetUserPermissionsCommand(userId, ["Users.CreateUserAccount", "Users.AddRole"]); + + // Act + var setUserRolesResult = await UserAccessModule.ExecuteCommandAsync(setUserRolesCommand); + + // Assert + Assert.That(setUserRolesResult.IsSuccess, Is.True); + Assert.That(setUserRolesResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(setUserRolesResult.Errors, Is.Empty); + } + + [Test] + public async Task SetUserPermissions_ReturnsOk_WhenPermissionsAreEmpty() + { + // Arrange + var userId = _userAccountCommand.UserId; + var setUserRolesCommand = new SetUserPermissionsCommand(userId, []); + + // Act + var setUserRolesResult = await UserAccessModule.ExecuteCommandAsync(setUserRolesCommand); + + // Assert + Assert.That(setUserRolesResult.IsSuccess, Is.True); + Assert.That(setUserRolesResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(setUserRolesResult.Errors, Is.Empty); + } + + [Test] + public async Task SetUserPermissions_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var setUserRolesCommand = new SetUserPermissionsCommand(userId, []); + + // Act + var setUserRolesResult = await UserAccessModule.ExecuteCommandAsync(setUserRolesCommand); + + // Assert + Assert.That(setUserRolesResult.IsSuccess, Is.False); + Assert.That(setUserRolesResult.Status, Is.EqualTo(ResultStatus.NotFound)); + Assert.That(setUserRolesResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _userAccountCommand = UserAccountGenerator().Generate(); + + await UserAccessModule.ExecuteCommandAsync(_userAccountCommand); + } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/SetUserRolesTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/SetUserRolesTests.cs new file mode 100644 index 000000000..cfbf9baba --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/SetUserRolesTests.cs @@ -0,0 +1,88 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.SetUserRoles; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.UserAccounts; + +[TestFixture] +internal class SetUserRolesTests : TestBase +{ + private CreateUserAccountCommand _userAccountCommand = null!; + private Guid _roleId; + + [Test] + public async Task SetUserRoles_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var setUserRolesCommand = new SetUserRolesCommand(userId, [_roleId]); + + // Act + var setUserRolesResult = await UserAccessModule.ExecuteCommandAsync(setUserRolesCommand); + + // Assert + Assert.That(setUserRolesResult.IsSuccess, Is.False); + Assert.That(setUserRolesResult.Status, Is.EqualTo(ResultStatus.NotFound)); + Assert.That(setUserRolesResult.Errors, Is.Not.Empty); + } + + [Test] + public async Task SetUserRoles_ReturnsOk_WhenRolesAreValid() + { + // Arrange + var userId = _userAccountCommand.UserId; + var setUserRolesCommand = new SetUserRolesCommand(userId, [_roleId]); + + // Act + var setUserRolesResult = await UserAccessModule.ExecuteCommandAsync(setUserRolesCommand); + + // Assert + Assert.That(setUserRolesResult.IsSuccess, Is.True); + Assert.That(setUserRolesResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(setUserRolesResult.Errors, Is.Empty); + } + + [Test] + public async Task SetUserRoles_ReturnsOk_WhenRolesAreEmpty() + { + // Arrange + var userId = _userAccountCommand.UserId; + var setUserRolesCommand = new SetUserRolesCommand(userId, []); + + // Act + var setUserRolesResult = await UserAccessModule.ExecuteCommandAsync(setUserRolesCommand); + + // Assert + Assert.That(setUserRolesResult.IsSuccess, Is.True); + Assert.That(setUserRolesResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(setUserRolesResult.Errors, Is.Empty); + } + + [Test] + public async Task SetUserRoles_ReturnsNotFound_WhenRoleDoesNotExist() + { + // Arrange + var userId = _userAccountCommand.UserId; + var setUserRolesCommand = new SetUserRolesCommand(userId, [Guid.NewGuid()]); + + // Act + var setUserRolesResult = await UserAccessModule.ExecuteCommandAsync(setUserRolesCommand); + + // Assert + Assert.That(setUserRolesResult.IsSuccess, Is.False); + Assert.That(setUserRolesResult.Status, Is.EqualTo(ResultStatus.NotFound)); + Assert.That(setUserRolesResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _userAccountCommand = UserAccountGenerator().Generate(); + var roleCommand = RoleGenerator().Generate(); + + await UserAccessModule.ExecuteCommandAsync(_userAccountCommand); + _roleId = (await UserAccessModule.ExecuteCommandAsync(roleCommand)).Value; + } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/UnlockUserAccountTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/UnlockUserAccountTests.cs new file mode 100644 index 000000000..feed24409 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/UnlockUserAccountTests.cs @@ -0,0 +1,52 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.UnlockUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.UserAccounts; + +[TestFixture] +internal class UnlockUserAccountTests : TestBase +{ + private CreateUserAccountCommand _userAccountCommand = null!; + + [Test] + public async Task UnlockUserAccount_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var unlockUserAccountCommand = new UnlockUserAccountCommand(userId); + + // Act + var unlockUserAccountResult = await UserAccessModule.ExecuteCommandAsync(unlockUserAccountCommand); + + // Assert + Assert.That(unlockUserAccountResult.IsSuccess, Is.False); + Assert.That(unlockUserAccountResult.Status, Is.EqualTo(ResultStatus.NotFound)); + Assert.That(unlockUserAccountResult.Errors, Is.Not.Empty); + } + + [Test] + public async Task UnlockUserAccount_ReturnsOk_WhenUserExists() + { + // Arrange + var userId = _userAccountCommand.UserId; + var unlockUserAccountCommand = new UnlockUserAccountCommand(userId); + + // Act + var unlockUserAccountResult = await UserAccessModule.ExecuteCommandAsync(unlockUserAccountCommand); + + // Assert + Assert.That(unlockUserAccountResult.IsSuccess, Is.True); + Assert.That(unlockUserAccountResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(unlockUserAccountResult.Errors, Is.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _userAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_userAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/UpdateUserAccountTests.cs b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/UpdateUserAccountTests.cs new file mode 100644 index 000000000..48f59bb74 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/IntegrationTests/UserAccounts/UpdateUserAccountTests.cs @@ -0,0 +1,69 @@ +using System.Data; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.CreateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.Application.UserAccounts.UpdateUserAccount; +using CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.SeedWork; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.IntegrationTests.UserAccounts; + +[TestFixture] +internal class UpdateUserAccountTests : TestBase +{ + private CreateUserAccountCommand _userAccountCommand = null!; + + [Test] + public async Task UpdateUserAccount_ReturnsNotFound_WhenUserDoesNotExist() + { + // Arrange + var userId = Guid.NewGuid(); + var updateUserAccountCommand = new UpdateUserAccountCommand(userId, string.Empty, string.Empty, string.Empty); + + // Act + var updateUserAccountResult = await UserAccessModule.ExecuteCommandAsync(updateUserAccountCommand); + + // Assert + Assert.That(updateUserAccountResult.IsSuccess, Is.False); + Assert.That(updateUserAccountResult.Status, Is.EqualTo(ResultStatus.NotFound)); + Assert.That(updateUserAccountResult.Errors, Is.Not.Empty); + } + + [Test] + public async Task UpdateUserAccount_ReturnsOk_WhenUserExists() + { + // Arrange + var userId = _userAccountCommand.UserId; + var updateUserAccountCommand = new UpdateUserAccountCommand(userId, "newName", "newFirstName", "newLastName"); + + // Act + var updateUserAccountResult = await UserAccessModule.ExecuteCommandAsync(updateUserAccountCommand); + + // Assert + Assert.That(updateUserAccountResult.IsSuccess, Is.True); + Assert.That(updateUserAccountResult.Status, Is.EqualTo(ResultStatus.Ok)); + Assert.That(updateUserAccountResult.Errors, Is.Empty); + } + + [Test] + public async Task UpdateUserAccount_ReturnsError_WhenDataIsInvalid() + { + // Arrange + var userId = _userAccountCommand.UserId; + var invalidName = string.Empty; + var updateUserAccountCommand = new UpdateUserAccountCommand(userId, invalidName, invalidName, invalidName); + + // Act + var updateUserAccountResult = await UserAccessModule.ExecuteCommandAsync(updateUserAccountCommand); + + // Assert + Assert.That(updateUserAccountResult.IsSuccess, Is.False); + Assert.That(updateUserAccountResult.Status, Is.EqualTo(ResultStatus.Error)); + Assert.That(updateUserAccountResult.Errors, Is.Not.Empty); + } + + protected override async Task SeedDatabase(IDbConnection connection) + { + _userAccountCommand = UserAccountGenerator().Generate(); + await UserAccessModule.ExecuteCommandAsync(_userAccountCommand); + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/CompanyName.MyMeetings.Modules.UsersMI.UnitTests.csproj b/src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/CompanyName.MyMeetings.Modules.UsersMI.UnitTests.csproj new file mode 100644 index 000000000..2ef1a363a --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/CompanyName.MyMeetings.Modules.UsersMI.UnitTests.csproj @@ -0,0 +1 @@ + diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/SeedWork/DomainEventsTestHelper.cs b/src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/SeedWork/DomainEventsTestHelper.cs new file mode 100644 index 000000000..b563585ab --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/SeedWork/DomainEventsTestHelper.cs @@ -0,0 +1,48 @@ +using System.Collections; +using System.Reflection; +using CompanyName.MyMeetings.BuildingBlocks.Domain; + +namespace CompanyName.MyMeetings.Modules.UsersMI.UnitTests.SeedWork +{ + public class DomainEventsTestHelper + { + public static List GetAllDomainEvents(Entity aggregate) + { + List domainEvents = []; + + if (aggregate.DomainEvents != null) + { + domainEvents.AddRange(aggregate.DomainEvents); + } + + var fields = aggregate.GetType().GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public).Concat(aggregate.GetType().BaseType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public)).ToArray(); + + foreach (var field in fields) + { + var isEntity = typeof(Entity).IsAssignableFrom(field.FieldType); + + if (isEntity) + { + var entity = field.GetValue(aggregate) as Entity; + domainEvents.AddRange(GetAllDomainEvents(entity).ToList()); + } + + if (field.FieldType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(field.FieldType)) + { + if (field.GetValue(aggregate) is IEnumerable enumerable) + { + foreach (var en in enumerable) + { + if (en is Entity entityItem) + { + domainEvents.AddRange(GetAllDomainEvents(entityItem)); + } + } + } + } + } + + return domainEvents; + } + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/SeedWork/TestBase.cs b/src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/SeedWork/TestBase.cs new file mode 100644 index 000000000..60847ae4e --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Tests/UnitTests/SeedWork/TestBase.cs @@ -0,0 +1,32 @@ +using CompanyName.MyMeetings.BuildingBlocks.Domain; +using NUnit.Framework; + +namespace CompanyName.MyMeetings.Modules.UsersMI.UnitTests.SeedWork +{ + public abstract class TestBase + { + public static T AssertPublishedDomainEvent(Entity aggregate) + where T : IDomainEvent + { + var domainEvent = DomainEventsTestHelper.GetAllDomainEvents(aggregate).OfType().SingleOrDefault(); + + if (domainEvent == null) + { + throw new Exception($"{typeof(T).Name} event not published"); + } + + return domainEvent; + } + + public static void AssertBrokenRule(TestDelegate testDelegate) + where TRule : class, IBusinessRule + { + var message = $"Expected {typeof(TRule).Name} broken rule"; + var businessRuleValidationException = Assert.Catch(testDelegate, message); + if (businessRuleValidationException != null) + { + Assert.That(businessRuleValidationException.BrokenRule, Is.TypeOf(), message); + } + } + } +} \ No newline at end of file From 22a0b32518e57624061393f783936088207f846b Mon Sep 17 00:00:00 2001 From: Jeff Stirn Date: Sat, 1 Nov 2025 18:09:23 +0100 Subject: [PATCH 2/3] fix: improve Swagger docs, password hashing, and module documentation - Fixed Swagger documentation generation by extending Module and ModuleLoader to include XML comments from each Web API assembly. - Fixed password hashing behavior during password reset. - Added action-level documentation to the UserAccess (Microsoft Identity) module. --- .../CompanyName.MyMeetings.API.csproj | 3 - .../Extensions/SwaggerExtensions.cs | 30 +- src/API/CompanyName.MyMeetings.API/Startup.cs | 2 +- .../Infrastructure/ModuleHosting/IModule.cs | 10 + .../ModuleHosting/ModuleBase.cs | 39 ++- .../ModuleHosting/ModuleLoader.cs | 9 + src/Directory.Packages.props | 3 +- ...yMeetings.Modules.UserAccess.WebApi.csproj | 1 + .../ModuleHosting/UserAccessModule.cs | 34 ++ ...e.MyMeetings.Modules.UsersMI.WebApi.csproj | 1 + .../WebApi/Endpoints/ApplicationController.cs | 41 +-- .../AuthenticationController.cs | 144 ++++++--- .../Authorization/AuthorizationController.cs | 9 +- .../Api/WebApi/Endpoints/Me/MeController.cs | 175 +++++++---- .../WebApi/Endpoints/Roles/RolesController.cs | 75 ++++- .../WebApi/Endpoints/Users/UsersController.cs | 90 +++++- ...ApplicationResultsToApiResultExtensions.cs | 27 ++ ...cationResultsToContractResultExtensions.cs | 58 ++++ .../ContractResultsToApiResultExtensions.cs | 24 ++ .../ErrorExtensions.cs} | 2 +- .../Api/WebApi/ResultToApiResultExtensions.cs | 290 ------------------ .../ResetPasswordCommandHandler.cs | 6 +- ...eetings.Modules.UsersMI.Application.csproj | 2 +- .../Application/Contracts/Roles.cs | 8 - .../ChangePasswordCommandHandler.cs | 4 +- .../ModuleHosting/UserAccessModule.cs | 34 ++ 26 files changed, 618 insertions(+), 503 deletions(-) create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ApplicationResultsToApiResultExtensions.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ApplicationResultsToContractResultExtensions.cs create mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ContractResultsToApiResultExtensions.cs rename src/Modules/Users/MicrosoftIdentity/Api/WebApi/{ErrorMapper.cs => Extensions/ErrorExtensions.cs} (98%) delete mode 100644 src/Modules/Users/MicrosoftIdentity/Api/WebApi/ResultToApiResultExtensions.cs delete mode 100644 src/Modules/Users/MicrosoftIdentity/Application/Contracts/Roles.cs diff --git a/src/API/CompanyName.MyMeetings.API/CompanyName.MyMeetings.API.csproj b/src/API/CompanyName.MyMeetings.API/CompanyName.MyMeetings.API.csproj index e3c3fade6..41cb3178f 100644 --- a/src/API/CompanyName.MyMeetings.API/CompanyName.MyMeetings.API.csproj +++ b/src/API/CompanyName.MyMeetings.API/CompanyName.MyMeetings.API.csproj @@ -6,7 +6,4 @@ Linux ..\.. - - bin\Debug\CompanyName.MyMeetings.API.xml - \ No newline at end of file diff --git a/src/API/CompanyName.MyMeetings.API/Configuration/Extensions/SwaggerExtensions.cs b/src/API/CompanyName.MyMeetings.API/Configuration/Extensions/SwaggerExtensions.cs index e6bfc73c8..71b0b2c20 100644 --- a/src/API/CompanyName.MyMeetings.API/Configuration/Extensions/SwaggerExtensions.cs +++ b/src/API/CompanyName.MyMeetings.API/Configuration/Extensions/SwaggerExtensions.cs @@ -1,11 +1,12 @@ using System.Reflection; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; using Microsoft.OpenApi.Models; namespace CompanyName.MyMeetings.API.Configuration.Extensions { internal static class SwaggerExtensions { - internal static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services) + internal static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services, ModuleLoader moduleLoader) { services.AddSwaggerGen(options => { @@ -22,32 +23,7 @@ internal static IServiceCollection AddSwaggerDocumentation(this IServiceCollecti var commentsFile = Path.Combine(baseDirectory, commentsFileName); options.IncludeXmlComments(commentsFile); - options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { - Description = - "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", - Name = "Authorization", - In = ParameterLocation.Header, - Type = SecuritySchemeType.ApiKey - }); - - options.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "Bearer" - }, - Scheme = "oauth2", - Name = "Bearer", - In = ParameterLocation.Header - }, - new List() - } - }); + moduleLoader.ConfigureSwaggerModules(options); }); return services; diff --git a/src/API/CompanyName.MyMeetings.API/Startup.cs b/src/API/CompanyName.MyMeetings.API/Startup.cs index 832a33023..7e7c1378a 100644 --- a/src/API/CompanyName.MyMeetings.API/Startup.cs +++ b/src/API/CompanyName.MyMeetings.API/Startup.cs @@ -56,7 +56,7 @@ public void ConfigureServices(IServiceCollection services) builder.PartManager.ApplicationParts.Clear(); builder.PartManager.ApplicationParts.Add(new AssemblyPart(typeof(Startup).Assembly)); - services.AddSwaggerDocumentation(); + services.AddSwaggerDocumentation(_moduleLoader); services.AddSingleton(); services.AddSingleton(); diff --git a/src/BuildingBlocks/Infrastructure/ModuleHosting/IModule.cs b/src/BuildingBlocks/Infrastructure/ModuleHosting/IModule.cs index b84035c48..183190861 100644 --- a/src/BuildingBlocks/Infrastructure/ModuleHosting/IModule.cs +++ b/src/BuildingBlocks/Infrastructure/ModuleHosting/IModule.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Swashbuckle.AspNetCore.SwaggerGen; #nullable enable namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; @@ -36,4 +37,13 @@ public interface IModule /// /// The services initialized by the host that can be used for module initialization. void InitializeModule(HostServices hostServices); + + /// + /// Configures Swagger generation options for the API documentation. + /// + /// Call this method to customize the generated Swagger documentation, such as adding metadata, + /// security definitions, or custom filters. This method should be invoked during service configuration before + /// building the API documentation. + /// The Swagger generation options to be configured. + void ConfigureSwagger(SwaggerGenOptions options); } \ No newline at end of file diff --git a/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs b/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs index ab353d010..bee90a3d3 100644 --- a/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs +++ b/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs @@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Swashbuckle.AspNetCore.SwaggerGen; +#nullable enable namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; @@ -10,6 +12,18 @@ public abstract class ModuleBase(IConfiguration hostConfiguration) : IModule { public abstract string WebApiAssemblySearchPattern { get; } + public Assembly? WebApiAssembly + { + get + { + var file = Directory + .GetFiles(AppContext.BaseDirectory, WebApiAssemblySearchPattern) + .SingleOrDefault(); + + return file is null ? null : Assembly.LoadFrom(file); + } + } + public IConfiguration HostConfiguration { get; } = hostConfiguration; public abstract void RegisterModule(ContainerBuilder containerBuilder); @@ -22,6 +36,19 @@ public void AddHostServices(IServiceCollection services, ApplicationPartManager AddHostServices(services); } + public virtual void ConfigureSwagger(SwaggerGenOptions options) + { + if (WebApiAssembly is null) + { + return; + } + + var baseDirectory = AppDomain.CurrentDomain.BaseDirectory; + var commentsFileName = $"{WebApiAssembly.GetName().Name}.xml"; + var commentsFile = Path.Combine(baseDirectory, commentsFileName); + options.IncludeXmlComments(commentsFile); + } + /// /// Adds application-specific services to the provided service collection for host configuration. /// @@ -38,18 +65,12 @@ public void AddHostServices(IServiceCollection services, ApplicationPartManager /// predefined search pattern. If the assembly is found, it is loaded and added to the . This allows ASP.NET Core to discover MVC controllers and related features /// defined in the Web API assembly. - /// The to which the Web API assembly will be added as an application part. - /// Cannot be null. + /// The to which the Web API assembly will be added as an application part. private void RegisterModuleParts(ApplicationPartManager applicationPartManager) { - var webApiAssembly = Directory - .GetFiles(AppContext.BaseDirectory, WebApiAssemblySearchPattern) - .Select(Assembly.LoadFrom) - .SingleOrDefault(); - - if (webApiAssembly != null) + if (WebApiAssembly is not null) { - applicationPartManager.ApplicationParts.Add(new AssemblyPart(webApiAssembly)); + applicationPartManager.ApplicationParts.Add(new AssemblyPart(WebApiAssembly)); } } } \ No newline at end of file diff --git a/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleLoader.cs b/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleLoader.cs index 8bf308125..24dba5f2e 100644 --- a/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleLoader.cs +++ b/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleLoader.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Swashbuckle.AspNetCore.SwaggerGen; namespace CompanyName.MyMeetings.BuildingBlocks.Infrastructure.ModuleHosting; @@ -71,4 +72,12 @@ public void InitializeModules(HostServices hostServices) module.InitializeModule(hostServices); } } + + public void ConfigureSwaggerModules(SwaggerGenOptions options) + { + foreach (var module in _modules) + { + module.ConfigureSwagger(options); + } + } } \ No newline at end of file diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index a1b45af1e..3236275ef 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -5,7 +5,7 @@ - + @@ -29,6 +29,7 @@ + diff --git a/src/Modules/UserAccess/Api/WebApi/CompanyName.MyMeetings.Modules.UserAccess.WebApi.csproj b/src/Modules/UserAccess/Api/WebApi/CompanyName.MyMeetings.Modules.UserAccess.WebApi.csproj index 1aaf9fcef..b03a7e137 100644 --- a/src/Modules/UserAccess/Api/WebApi/CompanyName.MyMeetings.Modules.UserAccess.WebApi.csproj +++ b/src/Modules/UserAccess/Api/WebApi/CompanyName.MyMeetings.Modules.UserAccess.WebApi.csproj @@ -1,6 +1,7 @@  + true enable enable diff --git a/src/Modules/UserAccess/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs b/src/Modules/UserAccess/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs index 66bc8df4f..d3cdd0e02 100644 --- a/src/Modules/UserAccess/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs +++ b/src/Modules/UserAccess/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs @@ -6,6 +6,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; namespace CompanyName.MyMeetings.Modules.UserAccess.Infrastructure.Configuration.ModuleHosting; @@ -34,6 +36,38 @@ public override void RegisterModule(ContainerBuilder containerBuilder) .InstancePerLifetimeScope(); } + public override void ConfigureSwagger(SwaggerGenOptions options) + { + base.ConfigureSwagger(options); + + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = + "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + }, + Scheme = "oauth2", + Name = "Bearer", + In = ParameterLocation.Header + }, + new List() + } + }); + } + protected override void AddHostServices(IServiceCollection services) { services.ConfigureIdentityService() diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/CompanyName.MyMeetings.Modules.UsersMI.WebApi.csproj b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/CompanyName.MyMeetings.Modules.UsersMI.WebApi.csproj index 3048494f9..971219559 100644 --- a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/CompanyName.MyMeetings.Modules.UsersMI.WebApi.csproj +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/CompanyName.MyMeetings.Modules.UsersMI.WebApi.csproj @@ -1,6 +1,7 @@  + true enable enable diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/ApplicationController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/ApplicationController.cs index b0f1c1723..00a6db8a9 100644 --- a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/ApplicationController.cs +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/ApplicationController.cs @@ -1,6 +1,7 @@ -using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; using CompanyName.MyMeetings.Modules.UsersMI.Domain; using Microsoft.AspNetCore.Mvc; +using ApplicationResult = CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints; @@ -15,50 +16,38 @@ namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints; [Route("api/[controller]")] public class ApplicationController : ControllerBase { - protected new Microsoft.AspNetCore.Http.IResult Ok(object? result = null) + protected new Microsoft.AspNetCore.Http.IResult Ok() { - return Result.Ok(result).ToApiResult(); + return Result.Ok().ToApiResult(); } - /* - protected IActionResult Ok(string successMessage, object result = null) + protected new Microsoft.AspNetCore.Http.IResult Ok(object? value) { - return ApiResult.Ok(result, successMessage); + return Result.Ok(value).ToApiResult(); } - */ - protected Microsoft.AspNetCore.Http.IResult NotFound(Error error, string? invalidField = null) + protected Microsoft.AspNetCore.Http.IResult Ok(T value) { - return Result.NotFound(error).ToApiResult(); + return Result.Ok(value).ToApiResult(); } - protected Microsoft.AspNetCore.Http.IResult Error(Error error, string? invalidField = null) + protected Microsoft.AspNetCore.Http.IResult NotFound(Error error) { - return Result.Error(error).ToApiResult(); + return Result.NotFound(error.Translate()).ToApiResult(); } protected Microsoft.AspNetCore.Http.IResult Error(Error error) { - return Result.Error(error).ToApiResult(); + return Result.Error(error.Translate()).ToApiResult(); } - protected Microsoft.AspNetCore.Http.IResult FromResponse(Result response) + protected Microsoft.AspNetCore.Http.IResult ToApiResult(ApplicationResult.Result result) { - return response.ToApiResult(); + return result.ToApiResult(); } - protected Microsoft.AspNetCore.Http.IResult FromResponse(Result response) + protected Microsoft.AspNetCore.Http.IResult ToApiResult(ApplicationResult.Result result) { - return response.ToApiResult(); - } - - protected Microsoft.AspNetCore.Http.IResult FromResult(CSharpFunctionalExtensions.Result result) - { - if (result.IsSuccess) - { - return Ok(); - } - - return Error(result.Error); + return result.ToApiResult(); } } \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/AuthenticationController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/AuthenticationController.cs index c8be7165b..74398ff58 100644 --- a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/AuthenticationController.cs +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authentication/AuthenticationController.cs @@ -28,10 +28,17 @@ public AuthenticationController(IUserAccessModule userAccessModule) } /// - /// User login. + /// Authenticates a user using the provided credentials and initiates a login session. Supports two-factor + /// authentication if required by the user's account. /// - /// Authentication attributes. - /// ApiResult. + /// If the user's account requires two-factor authentication, the response will indicate this and + /// the user will be prompted to complete the additional verification step. Otherwise, a successful login will + /// establish an authenticated session and return access and refresh tokens. This endpoint does not require prior + /// permissions. + /// An containing the user's login credentials. The UserName and + /// Password properties must be provided and valid. + /// An representing the outcome of the login attempt. Returns a successful result with + /// authentication details if login succeeds, or an error result if the request is invalid or authentication fails. [HttpPost("login")] [NoPermissionRequired] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] @@ -43,38 +50,48 @@ public async Task Login(AuthenticationRequest request) var response = await _userAccessModule.ExecuteCommandAsync(new AccountLoginCommand(request.UserName, request.Password)); if (response is not null) { - if (response.RequiresTwoFactor) + if (response.IsSuccess) { - await HttpContext.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, response.ClaimsPrincipal!); - - result = new AuthenticationResponse() + if (response.RequiresTwoFactor) { - RequiresTwoFactor = true - }; - } - else if (response.IsAuthenticated) - { - await HttpContext.SignInAsync(IdentityConstants.ApplicationScheme, response.ClaimsPrincipal!); + await HttpContext.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, response.ClaimsPrincipal!); - result = new AuthenticationResponse() + result = new AuthenticationResponse() + { + RequiresTwoFactor = true + }; + } + else if (response.IsAuthenticated) { - UserName = response.User!.UserName, - AccessToken = response.AccessToken, - RefreshToken = response.RefreshToken - }; + await HttpContext.SignInAsync(IdentityConstants.ApplicationScheme, response.ClaimsPrincipal!); + + result = new AuthenticationResponse() + { + UserName = response.User!.UserName, + AccessToken = response.AccessToken, + RefreshToken = response.RefreshToken + }; + } + + return Ok(result); } - return response.ToResult(result).ToApiResult(); + return response.ToApiResult(); } return Error(Errors.General.InvalidRequest()); } /// - /// User login. + /// Attempts to authenticate a user using a two-factor authentication token as part of the login process. /// - /// User generated token. - /// ApiResult. + /// This endpoint should be called after initiating a two-factor authentication flow. If the + /// login request has expired or the token is invalid, an error response is returned. Upon successful + /// authentication, the user's session is established and relevant authentication tokens are issued. + /// The two-factor authentication token provided by the user. This value must be valid and correspond to the current + /// two-factor authentication session. + /// An indicating the outcome of the authentication attempt. Returns a successful result with + /// authentication details if the token is valid; otherwise, returns an error result describing the failure. [HttpPost("two-factor-login")] [NoPermissionRequired] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] @@ -119,10 +136,38 @@ public async Task TwoFactorLogin(string token) } /// - /// Send forgot password link. + /// Attempts to refresh the access token using the provided refresh token and returns the result of the operation. + /// + /// This endpoint does not require authentication. The caller must supply valid tokens; + /// otherwise, the operation will fail and an error result will be returned. + /// An object containing the current access token and refresh token to be used for refreshing authentication + /// credentials. + /// An representing the outcome of the token refresh operation. If successful, contains a new + /// access token and refresh token; otherwise, includes error details. + [HttpPost("refresh-token")] + [NoPermissionRequired] + public async Task RefreshToken(TokenRequest tokenRequest) + { + var response = await _userAccessModule.ExecuteCommandAsync(new RefreshTokenCommand(tokenRequest.AccessToken, tokenRequest.RefreshToken)); + if (!response.IsSuccess) + { + return ToApiResult(response); + } + + var tokenResult = new TokenResponse() + { + AccessToken = response.Value!.AccessToken, + RefreshToken = response.Value!.RefreshToken + }; + return response.ToApiResult(tokenResult); + } + + /// + /// Initiates a password reset process by requesting a reset token for the specified email address. /// - /// Email address of the user. - /// ApiResult. + /// The email address associated with the user account for which the password reset token is requested. + /// An indicating the outcome of the request. Returns a success result if the token was + /// requested successfully; otherwise, returns a failure result with error details. [HttpPost("request-reset-password-token")] [NoPermissionRequired] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] @@ -134,10 +179,12 @@ public async Task RequestResetPasswordToken(string emailAddress) } /// - /// Reset password. + /// Resets the user's password using the provided reset token, email address, and new password. /// - /// Reset password attributes. - /// ApiResult. + /// An object containing the reset token, the user's email address, and the new password to set. All fields must be + /// valid and non-empty. + /// An indicating the outcome of the password reset operation. Returns a success result if the + /// password was reset; otherwise, returns an error result describing the failure. [HttpPost("reset-password")] [NoPermissionRequired] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] @@ -147,6 +194,16 @@ public async Task ResetPassword(ResetPasswordRequest resetPassword) return response.ToApiResult(); } + /// + /// Initiates an authentication challenge using the specified external login provider. + /// + /// This endpoint does not require prior authentication and is typically used to start an OAuth + /// or similar external login flow. The user will be redirected to the provider's login page, and upon successful + /// authentication, will be returned to the application's callback endpoint. + /// The name of the external authentication provider to use for login. This value must correspond to a configured + /// authentication scheme (for example, "Google" or "Facebook"). + /// An that triggers the authentication challenge for the specified provider. The response + /// will redirect the user to the external provider's login page. [HttpGet("external-login")] [NoPermissionRequired] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -161,6 +218,17 @@ public IResult ExternalLogin(string provider) return Results.Challenge(properties, [provider]); } + /// + /// Handles the callback from an external authentication provider and completes the sign-in process for the + /// authenticated user. + /// + /// This endpoint is typically invoked by external identity providers after a user has + /// authenticated. It validates the external authentication response, extracts required user information, and signs + /// the user into the application if authentication is successful. If required information is missing or + /// authentication fails, an appropriate error response is returned. No authentication or permission is required to + /// access this endpoint. + /// An representing the outcome of the external login process. Returns a successful result if + /// authentication is completed; otherwise, returns an error result indicating the reason for failure. [HttpGet("external-login-callback")] [NoPermissionRequired] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] @@ -211,7 +279,7 @@ public async Task ExternalLoginCallback() AccessToken = response.AccessToken, RefreshToken = response.RefreshToken }; - return response.ToApiResult(authenticationResult); + return Ok(authenticationResult); } return Error(Errors.Authentication.InvalidToken()); @@ -220,22 +288,4 @@ public async Task ExternalLoginCallback() return Error(Errors.General.InvalidRequest()); } - - [HttpPost("refresh-token")] - [NoPermissionRequired] - public async Task RefreshToken(TokenRequest tokenRequest) - { - var response = await _userAccessModule.ExecuteCommandAsync(new RefreshTokenCommand(tokenRequest.AccessToken, tokenRequest.RefreshToken)); - if (!response.IsSuccess) - { - return FromResponse(response); - } - - var tokenResult = new TokenResponse() - { - AccessToken = response.Value!.AccessToken, - RefreshToken = response.Value!.RefreshToken - }; - return response.ToApiResult(tokenResult); - } } \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authorization/AuthorizationController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authorization/AuthorizationController.cs index 23ea81802..e4ac5e97a 100644 --- a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authorization/AuthorizationController.cs +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Authorization/AuthorizationController.cs @@ -7,7 +7,6 @@ namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Authorization; -[ApiController] [Route("api/authorization")] public class AuthorizationController : ApplicationController { @@ -18,6 +17,12 @@ public AuthorizationController(IUserAccessModule userAccessModule) _userAccessModule = userAccessModule; } + /// + /// Retrieves a directory of available permissions for the application. + /// + /// The returned permission can be assigned to roles or users to control access to various features. + /// An containing the list of permissions if successful; otherwise, an error result indicating + /// the reason for failure. [HttpGet("permissions")] [NoPermissionRequired] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] @@ -40,6 +45,6 @@ public async Task GetPermissionDirectory() return response.ToApiResult(permissionsResponse); } - return FromResponse(response); + return ToApiResult(response); } } \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs index af33e129c..b3fc62464 100644 --- a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs @@ -18,7 +18,6 @@ namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Me; [Authorize] -[ApiController] [Route("api/users/me")] public class MeController : ApplicationController { @@ -31,129 +30,197 @@ public MeController(IUserAccessModule userAccessModule, IExecutionContextAccesso _executionContextAccessor = executionContextAccessor; } - [HttpGet("change-email-address")] + /// + /// Retrieves the account information for the currently authenticated user. + /// + /// This endpoint does not require explicit permissions; any authenticated user may access their own account details. + /// An containing the user's account details if the request is successful; otherwise, an error + /// result indicating the reason for failure, such as invalid request or unauthorized access. + [HttpGet] [NoPermissionRequired] - public async Task ChangeEmailAddress(ChangeEmailAddressRequest request) + [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] + public async Task GetUserAccount() { - var result = await _userAccessModule.ExecuteCommandAsync(new ChangeEmailAddressCommand(_executionContextAccessor.UserId, request.NewEmailAddress, request.Token)); - if (!result.IsSuccess) + var result = await _userAccessModule.ExecuteQueryAsync(new GetUserAccountQuery(_executionContextAccessor.UserId)); + if (result.IsSuccess && result.Value is not null) { - return FromResponse(result); + return result.ToApiResult(new UserAccountResponse() + { + Id = result.Value.Id, + Name = result.Value.Name, + FirstName = result.Value.FirstName, + LastName = result.Value.LastName, + UserName = result.Value.UserName ?? string.Empty, + EmailAddress = result.Value.EmailAddress + }); } - return Ok(); + return ToApiResult(result); } - [HttpPut("change-password")] + /// + /// Updates the current user's profile information with the specified details. + /// + /// An object containing the new profile information to apply. + /// An indicating the outcome of the update operation. Returns a success result if the profile + /// was updated; otherwise, returns a result describing the failure. + [HttpPut("update-profile")] [NoPermissionRequired] - public async Task ChangePassword(ChangePasswordRequest request) + public async Task UpdateProfile(UpdateProfileRequest request) { - var result = await _userAccessModule.ExecuteCommandAsync(new ChangePasswordCommand(_executionContextAccessor.UserId, request.CurrentPassword, request.NewPassword)); + var result = await _userAccessModule.ExecuteCommandAsync(new UpdateProfileCommand(_executionContextAccessor.UserId, request.Login, request.Name, request.FirstName, request.LastName)); if (!result.IsSuccess) { - return FromResponse(result); + return ToApiResult(result); } return Ok(); } - [HttpPut("confirm-email-address")] + /// + /// Attempts to change the current user's password using the provided credentials. + /// + /// This operation does not require special permissions; any authenticated user may change their + /// own password. The result may indicate failure if the current password is incorrect or if the new password does + /// not meet policy requirements. + /// An object containing the current password and the new password to be set. The current password must be valid; + /// the new password must meet any required password policies. + /// An indicating the outcome of the password change operation. Returns a success result if + /// the password was changed; otherwise, returns an error result describing the failure. + [HttpPut("change-password")] [NoPermissionRequired] - public async Task ConfirmEmailAddress(ConfirmEmailAddressRequest request) + public async Task ChangePassword(ChangePasswordRequest request) { - var result = await _userAccessModule.ExecuteCommandAsync(new ConfirmEmailAddressCommand(_executionContextAccessor.UserId, request.Token)); + var result = await _userAccessModule.ExecuteCommandAsync(new ChangePasswordCommand(_executionContextAccessor.UserId, request.CurrentPassword, request.NewPassword)); if (!result.IsSuccess) { - return FromResponse(result); + return ToApiResult(result); } return Ok(); } - [HttpGet("authenticator-key")] + /// + /// Initiates a request to generate a token for changing the currently authenticated user's email address. + /// + /// This endpoint does not require specific permissions; any authenticated user may request a token to change their own email address. + /// An object containing the new email address to associate with the user's account. The new email address must be + /// valid and not already in use. + /// An indicating the outcome of the request. Returns a success result if the token is + /// generated; otherwise, returns an error result describing the failure. + [HttpGet("request-change-email-address-token")] [NoPermissionRequired] - public async Task GetAuthenticatorKey() + public async Task RequestChangeEmailAddressToken(RequestChangeEmailAddressTokenRequest request) { - var result = await _userAccessModule.ExecuteQueryAsync(new GetAuthenticatorKeyQuery(_executionContextAccessor.UserId)); + var result = await _userAccessModule.ExecuteCommandAsync(new RequestChangeEmailAddressTokenCommand(_executionContextAccessor.UserId, request.NewEmailAddress)); if (!result.IsSuccess) { - return FromResponse(result); + return ToApiResult(result); } - return result.ToApiResult(result.Value!); + return Ok(); } - [HttpGet] + /// + /// Initiates a request to change the authenticated user's email address using the provided verification token and + /// new email address. + /// + /// This operation does not require special permissions; any authenticated user may change their own email address. + /// An object containing the new email address and the verification token required to authorize the change. + /// An indicating the outcome of the email address change operation. Returns a success result + /// if the change is completed; otherwise, returns an error result describing the failure. + [HttpPut("change-email-address")] [NoPermissionRequired] - [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] - public async Task GetUserAccount() + public async Task ChangeEmailAddress(ChangeEmailAddressRequest request) { - var result = await _userAccessModule.ExecuteQueryAsync(new GetUserAccountQuery(_executionContextAccessor.UserId)); - if (result.IsSuccess && result.Value is not null) + var result = await _userAccessModule.ExecuteCommandAsync(new ChangeEmailAddressCommand(_executionContextAccessor.UserId, request.NewEmailAddress, request.Token)); + if (!result.IsSuccess) { - return result.ToApiResult(new UserAccountResponse() - { - Id = result.Value.Id, - Name = result.Value.Name, - FirstName = result.Value.FirstName, - LastName = result.Value.LastName, - UserName = result.Value.UserName ?? string.Empty, - EmailAddress = result.Value.EmailAddress - }); + return ToApiResult(result); } - return FromResponse(result); + return Ok(); } - [HttpPost("register-authenticator")] + /// + /// Initiates a request to generate a confirmation token for the currently authenticated user's email address. + /// + /// This endpoint does not require specific permissions. The confirmation token + /// is typically sent to the user's registered email address and can be used to verify ownership of the + /// email. + /// An indicating the outcome of the request. Returns a success result if the token was + /// generated and sent; otherwise, returns an error result describing the failure. + [HttpGet("request-confirm-email-address-token")] [NoPermissionRequired] - public async Task RegisterAuthenticator(RegisterAuthenticatorRequest request) + public async Task RequestConfirmEmailAddressToken() { - var result = await _userAccessModule.ExecuteCommandAsync(new RegisterAuthenticatorCommand(_executionContextAccessor.UserId, request.Code)); + var result = await _userAccessModule.ExecuteCommandAsync(new RequestConfirmEmailAddressTokenCommand(_executionContextAccessor.UserId)); if (!result.IsSuccess) { - return FromResponse(result); + return ToApiResult(result); } return Ok(); } - [HttpGet("request-change-email-address-token")] + /// + /// Confirms a user's email address using the provided confirmation token. + /// + /// This operation does not require special permissions; any authenticated user may confirm their own email address. + /// An object containing the email confirmation token required to verify the user's email address. Cannot be null. + /// An indicating the outcome of the email confirmation operation. Returns a success result if + /// the email address is confirmed; otherwise, returns an error result describing the failure. + [HttpPut("confirm-email-address")] [NoPermissionRequired] - public async Task RequestChangeEmailAddressToken(RequestChangeEmailAddressTokenRequest request) + public async Task ConfirmEmailAddress(ConfirmEmailAddressRequest request) { - var result = await _userAccessModule.ExecuteCommandAsync(new RequestChangeEmailAddressTokenCommand(_executionContextAccessor.UserId, request.NewEmailAddress)); + var result = await _userAccessModule.ExecuteCommandAsync(new ConfirmEmailAddressCommand(_executionContextAccessor.UserId, request.Token)); if (!result.IsSuccess) { - return FromResponse(result); + return ToApiResult(result); } return Ok(); } - [HttpGet("request-confirm-email-address-token")] + /// + /// Retrieves the authenticator key for the current user to enable two-factor authentication setup. + /// + /// This endpoint does not require special permissions; any authenticated user may retrieve their own authenticator key. + /// The authenticator key can be used to configure an authenticator app for two-factor authentication. If the operation fails, the result + /// will include error details. + /// An containing the authenticator key if retrieval is successful; otherwise, an error result + /// describing the failure. + [HttpGet("authenticator-key")] [NoPermissionRequired] - public async Task RequestConfirmEmailAddressToken() + public async Task GetAuthenticatorKey() { - var result = await _userAccessModule.ExecuteCommandAsync(new RequestConfirmEmailAddressTokenCommand(_executionContextAccessor.UserId)); + var result = await _userAccessModule.ExecuteQueryAsync(new GetAuthenticatorKeyQuery(_executionContextAccessor.UserId)); if (!result.IsSuccess) { - return FromResponse(result); + return ToApiResult(result); } - return Ok(); + return result.ToApiResult(result.Value!); } - [HttpPut("update-profile")] + /// + /// Registers a new authenticator for the current user using the provided registration code by the authenticator app. + /// + /// This operation does not require special permissions; any authenticated user may register their own authenticator. + /// The request containing the registration code required to register the authenticator. Cannot be null. + /// An indicating the outcome of the registration operation. Returns a success result if the + /// authenticator is registered; otherwise, returns an error result describing the failure. + [HttpPost("register-authenticator")] [NoPermissionRequired] - public async Task UpdateProfile(UpdateProfileRequest request) + public async Task RegisterAuthenticator(RegisterAuthenticatorRequest request) { - var result = await _userAccessModule.ExecuteCommandAsync(new UpdateProfileCommand(_executionContextAccessor.UserId, request.Login, request.Name, request.FirstName, request.LastName)); + var result = await _userAccessModule.ExecuteCommandAsync(new RegisterAuthenticatorCommand(_executionContextAccessor.UserId, request.Code)); if (!result.IsSuccess) { - return FromResponse(result); + return ToApiResult(result); } return Ok(); diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/RolesController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/RolesController.cs index 2e455fdc7..0dbf86bf4 100644 --- a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/RolesController.cs +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Roles/RolesController.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using RolesApplication = CompanyName.MyMeetings.Modules.UsersMI.Application.Roles.GetRoles; -using UserRoleContracts = CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Roles; namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Roles; @@ -23,13 +22,18 @@ public RolesController(IUserAccessModule userAccessModule) _userAccessModule = userAccessModule; } + /// + /// Retrieves a list of all roles. + /// + /// Requires the caller to have the appropriate permission to access role information. + /// An containing the collection of roles if the operation is successful; otherwise, an error + /// result describing the failure. [HttpGet] [HasPermission(UsersPermissions.GetRoles)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] - public async Task GetRoleDirectory() + public async Task GetRoles() { var response = await _userAccessModule.ExecuteQueryAsync(new RolesApplication.Directory.GetRolesQuery()); if (response.IsSuccess && response.Value is not null) @@ -46,13 +50,20 @@ public async Task GetRoleDirectory() return response.ToApiResult(rolesResponse); } - return FromResponse(response); + return ToApiResult(response); } + /// + /// Retrieves the details of a role. + /// + /// Requires the caller to have the appropriate permission to access role information. + /// The unique identifier of the role to retrieve. + /// An containing the role details if found; otherwise, an error result indicating the reason + /// for failure. [HttpGet("{roleId}")] [HasPermission(UsersPermissions.GetRoles)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] public async Task GetRole(Guid roleId) @@ -69,37 +80,58 @@ public async Task GetRole(Guid roleId) return response.ToApiResult(roleResponse); } - return FromResponse(response); + return ToApiResult(response); } + /// + /// Creates a new user role with the specified name and permissions. + /// + /// Requires the caller to have the appropriate permission to create a role. + /// An object containing the details of the role to add, including the role name and associated permissions. + /// An indicating the outcome of the operation. Returns a success result if the role is + /// created; otherwise, returns an error result describing the failure. [HttpPost] [HasPermission(UsersPermissions.AddRole)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] - public async Task AddRole([FromBody] UserRoleContracts.AddRoleRequest request) + public async Task AddRole([FromBody] AddRoleRequest request) { var response = await _userAccessModule.ExecuteCommandAsync(new CreateRoleCommand(request.Name, request.Permissions)); return response.ToApiResult(); } - [HttpPatch("{roleId}/rename")] + /// + /// Renames an existing user role. + /// + /// Requires the caller to have the appropriate permission to rename the role. + /// The unique identifier of the role to rename. + /// An object containing the new name for the role. The name must meet any validation requirements defined by the + /// system. + /// An indicating the outcome of the operation. + [HttpPut("{roleId}/rename")] [HasPermission(UsersPermissions.RenameRole)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] - public async Task RenameRole(Guid roleId, [FromBody] UserRoleContracts.RenameRoleRequest request) + public async Task RenameRole(Guid roleId, [FromBody] RenameRoleRequest request) { var response = await _userAccessModule.ExecuteCommandAsync(new RenameRoleCommand(roleId, request.Name)); return response.ToApiResult(); } + /// + /// Deletes the role. + /// + /// Requires the caller to have the appropriate permission to delete the role. + /// The unique identifier of the role to delete. + /// An indicating the outcome of the delete operation. [HttpDelete("{roleId}")] [HasPermission(UsersPermissions.DeleteRole)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] public async Task DeleteRole(Guid roleId) @@ -108,10 +140,16 @@ public async Task DeleteRole(Guid roleId) return response.ToApiResult(); } + /// + /// Retrieves the list of permissions assigned to the specified role. + /// + /// Requires the caller to have the appropriate permission to access role permissions. + /// The unique identifier of the role for which to retrieve permissions. + /// An containing the permissions for the specified role if found; otherwise, a result indicating the error. [HttpGet("{roleId}/permissions")] [HasPermission(UsersPermissions.GetRolePermissions)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] public async Task GetRolePermissions(Guid roleId) @@ -131,16 +169,23 @@ public async Task GetRolePermissions(Guid roleId) return response.ToApiResult(permissionsResponse); } - return FromResponse(response); + return ToApiResult(response); } - [HttpPatch("{roleId}/permissions")] + /// + /// Updates the set of permissions assigned to the specified role. + /// + /// Requires the caller to have the appropriate permission to modify role permissions. + /// The unique identifier of the role whose permissions are to be updated. + /// An object containing the new set of permissions to assign to the role. + /// An indicating the outcome of the operation. + [HttpPut("{roleId}/permissions")] [HasPermission(UsersPermissions.SetRolePermissions)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] - public async Task SetRolePermissions(Guid roleId, [FromBody] UserRoleContracts.SetRolePermissionsRequest request) + public async Task SetRolePermissions(Guid roleId, [FromBody] SetRolePermissionsRequest request) { var response = await _userAccessModule.ExecuteCommandAsync(new SetRolePermissionsCommand(roleId, request.Permissions)); return response.ToApiResult(); diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/UsersController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/UsersController.cs index 06b81a743..ff724bf6c 100644 --- a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/UsersController.cs +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Users/UsersController.cs @@ -25,13 +25,14 @@ public UsersController(IUserAccessModule userAccessModule) } /// - /// Gets the user directory. + /// Retrieves a list of user accounts. /// - /// List of users. + /// Requires the caller to have the appropriate permission to get the user accounts. + /// An containing the user account directory if the request is authorized and successful; + /// otherwise, an error result indicating the reason for failure. [HttpGet] [HasPermission(UsersPermissions.GetUsers)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] public async Task GetUserAccountDirectory() @@ -64,13 +65,20 @@ public async Task GetUserAccountDirectory() return response.ToApiResult(userAccountsResponse); } - return FromResponse(response); + return ToApiResult(response); } + /// + /// Retrieves the account details for a specified user. + /// + /// Requires the caller to have the appropriate permission to access the account details information. + /// The unique identifier of the user whose account information is to be retrieved. + /// An containing the user's account details if found; otherwise, an appropriate error + /// response. [HttpGet("{userId}")] [HasPermission(UsersPermissions.GetUsers)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] public async Task GetUserAccount(Guid userId) @@ -100,12 +108,21 @@ public async Task GetUserAccount(Guid userId) return response.ToApiResult(userAccountResponse); } - return FromResponse(response); + return ToApiResult(response); } + /// + /// Updates the account information for the specified user. + /// + /// Requires the caller to have the appropriate permission to update user accounts. + /// The unique identifier of the user whose account will be updated. + /// An object containing the updated account details. + /// An indicating the outcome of the update operation. Returns a success result if the update + /// is completed; otherwise, returns an error result describing the failure. [HttpPut("{userId}")] [HasPermission(UsersPermissions.UpdateUserAccount)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] @@ -115,10 +132,16 @@ public async Task UpdateUserAccount(Guid userId, UpdateUserAccountReque return response.ToApiResult(); } - [HttpPut("{userId}/unlock")] + /// + /// Unlocks the specified user account, allowing the user to regain access if previously locked. + /// + /// This operation requires the caller to have the appropriate permission. + /// The unique identifier of the user account to unlock. + /// An indicating the outcome of the unlock operation. + [HttpPatch("{userId}/unlock")] [HasPermission(UsersPermissions.UnlockUserAccount)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] public async Task UnlockUserAccount(Guid userId) @@ -127,10 +150,17 @@ public async Task UnlockUserAccount(Guid userId) return response.ToApiResult(); } + /// + /// Retrieves the list of roles assigned to the specified user. + /// + /// This operation requires the caller to have the appropriate permission to access user roles. + /// The unique identifier of the user whose roles are to be retrieved. + /// An containing the user's roles if the operation is successful; otherwise, an error result + /// indicating the reason for failure. [HttpGet("{userId}/roles")] [HasPermission(UsersPermissions.GetUserRoles)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] public async Task GetUserRoles(Guid userId) @@ -150,13 +180,20 @@ public async Task GetUserRoles(Guid userId) return response.ToApiResult(userRolesResponse); } - return FromResponse(response); + return ToApiResult(response); } + /// + /// Updates the roles assigned to the specified user. + /// + /// This operation requires the caller to have the appropriate permission. + /// The unique identifier of the user whose roles are to be updated. + /// An object containing the list of role IDs to assign to the user. + /// An indicating the outcome of the operation. [HttpPut("{userId}/roles")] [HasPermission(UsersPermissions.SetUserRoles)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] public async Task SetUserRoles(Guid userId, SetUserRolesRequest request) @@ -165,10 +202,17 @@ public async Task SetUserRoles(Guid userId, SetUserRolesRequest request return response.ToApiResult(); } + /// + /// Retrieves the set of permissions assigned to the specified user. + /// + /// Requires the caller to have the appropriate permission to access user permissions. + /// The unique identifier of the user whose permissions are to be retrieved. + /// An containing the user's permissions if found; otherwise, a result indicating the + /// appropriate error status. [HttpGet("{userId}/permissions")] [HasPermission(UsersPermissions.GetUserPermissions)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] public async Task GetUserPermissions(Guid userId) @@ -189,13 +233,20 @@ public async Task GetUserPermissions(Guid userId) return response.ToApiResult(userPermissionsResponse); } - return FromResponse(response); + return ToApiResult(response); } + /// + /// Updates the permissions assigned to the specified user. + /// + /// This operation requires the caller to have the appropriate permission. + /// The unique identifier of the user whose permissions are to be updated. + /// An object containing the new set of permissions to assign to the user. Cannot be null. + /// An indicating the outcome of the operation. [HttpPut("{userId}/permissions")] [HasPermission(UsersPermissions.SetUserPermissions)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] public async Task SetUserPermissions(Guid userId, [FromBody] SetUserPermissionsRequest request) @@ -204,10 +255,19 @@ public async Task SetUserPermissions(Guid userId, [FromBody] SetUserPer return response.ToApiResult(); } + /// + /// Changes the email address associated with the specified user. + /// + /// This operation requires the caller to have the appropriate permission. + /// The unique identifier of the user whose email address will be updated. + /// An object containing the new email address to assign to the user. Must not be null. + /// An indicating the outcome of the operation. Returns a 200 OK result if the email address + /// was changed successfully; 404 Not Found if the user does not exist; 401 Unauthorized or 403 Forbidden if the + /// caller lacks sufficient permissions. [HttpPut("{userId}/change-email-address")] [HasPermission(UsersPermissions.ChangeUserEmailAddress)] [ProducesResponseType(typeof(IResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(IResult), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(IResult), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(IResult), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(IResult), StatusCodes.Status403Forbidden)] public async Task ChangeUserEmailAddress(Guid userId, ChangeUserEmailAddressRequest request) diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ApplicationResultsToApiResultExtensions.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ApplicationResultsToApiResultExtensions.cs new file mode 100644 index 000000000..860d31cdc --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ApplicationResultsToApiResultExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Http; +using ApplicationResults = CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi; + +internal static class ApplicationResultsToApiResultExtensions +{ + public static IResult ToApiResult(this ApplicationResults.IResult result) + { + return result.ToContractResult().ToApiResult(); + } + + public static IResult ToApiResult(this ApplicationResults.Result result, T value) + { + return result.ToContractResult(value).ToApiResult(); + } + + public static IResult ToApiResult(this ApplicationResults.Result result) + { + return result.ToContractResult().ToApiResult(); + } + + public static IResult ToApiResult(this ApplicationResults.Result result, TValue value) + { + return result.ToContractResult(value).ToApiResult(); + } +} diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ApplicationResultsToContractResultExtensions.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ApplicationResultsToContractResultExtensions.cs new file mode 100644 index 000000000..dadc0b281 --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ApplicationResultsToContractResultExtensions.cs @@ -0,0 +1,58 @@ +using ApplicationResults = CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; +using ContractResults = CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi; + +internal static class ApplicationResultsToContractResultExtensions +{ + public static ContractResults.Result ToContractResult(this ApplicationResults.IResult result) + { + return result.Status switch + { + ApplicationResults.ResultStatus.Ok => ContractResults.Result.Ok(), + ApplicationResults.ResultStatus.NotFound => ContractResults.Result.NotFound(result.Errors.Translate()), + ApplicationResults.ResultStatus.Unauthorized => ContractResults.Result.Unauthorized(result.Errors.Translate()), + ApplicationResults.ResultStatus.Forbidden => ContractResults.Result.Forbidden(result.Errors.Translate()), + ApplicationResults.ResultStatus.Invalid => ContractResults.Result.Invalid(result.Errors.Translate()), + ApplicationResults.ResultStatus.Error => ContractResults.Result.Error(result.Errors.Translate()), + ApplicationResults.ResultStatus.Conflict => ContractResults.Result.Conflict(result.Errors.Translate()), + ApplicationResults.ResultStatus.Created => ContractResults.Result.Created(), + ApplicationResults.ResultStatus.NoContent => ContractResults.Result.NoContent(result.Errors.Translate()), + _ => throw new NotSupportedException($"Application Result {result.Status} conversion is not supported."), + }; + } + + public static ContractResults.Result ToContractResult(this ApplicationResults.IResult result, T value) + { + return result.Status switch + { + ApplicationResults.ResultStatus.Ok => ContractResults.Result.Ok(value), + ApplicationResults.ResultStatus.NotFound => ContractResults.Result.NotFound(result.Errors.Translate()), + ApplicationResults.ResultStatus.Unauthorized => ContractResults.Result.Unauthorized(result.Errors.Translate()), + ApplicationResults.ResultStatus.Forbidden => ContractResults.Result.Forbidden(result.Errors.Translate()), + ApplicationResults.ResultStatus.Invalid => ContractResults.Result.Invalid(result.Errors.Translate()), + ApplicationResults.ResultStatus.Error => ContractResults.Result.Error(result.Errors.Translate()), + ApplicationResults.ResultStatus.Conflict => ContractResults.Result.Conflict(result.Errors.Translate()), + ApplicationResults.ResultStatus.Created => ContractResults.Result.Created(value), + ApplicationResults.ResultStatus.NoContent => ContractResults.Result.NoContent(result.Errors.Translate()), + _ => throw new NotSupportedException($"Application Result {result.Status} conversion is not supported."), + }; + } + + public static ContractResults.Result ToContractResult(this ApplicationResults.IResult result) + { + return result.Status switch + { + ApplicationResults.ResultStatus.Ok => ContractResults.Result.Ok(), + ApplicationResults.ResultStatus.NotFound => ContractResults.Result.NotFound(result.Errors.Translate()), + ApplicationResults.ResultStatus.Unauthorized => ContractResults.Result.Unauthorized(result.Errors.Translate()), + ApplicationResults.ResultStatus.Forbidden => ContractResults.Result.Forbidden(result.Errors.Translate()), + ApplicationResults.ResultStatus.Invalid => ContractResults.Result.Invalid(result.Errors.Translate()), + ApplicationResults.ResultStatus.Error => ContractResults.Result.Error(result.Errors.Translate()), + ApplicationResults.ResultStatus.Conflict => ContractResults.Result.Conflict(result.Errors.Translate()), + ApplicationResults.ResultStatus.Created => ContractResults.Result.Created(), + ApplicationResults.ResultStatus.NoContent => ContractResults.Result.NoContent(result.Errors.Translate()), + _ => throw new NotSupportedException($"Application Result {result.Status} conversion is not supported."), + }; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ContractResultsToApiResultExtensions.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ContractResultsToApiResultExtensions.cs new file mode 100644 index 000000000..45ca062dc --- /dev/null +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ContractResultsToApiResultExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; +using ContractResults = CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; + +namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi; + +internal static class ContractResultsToApiResultExtensions +{ + public static IResult ToApiResult(this ContractResults.Result result) + { + return result.Status switch + { + ContractResults.ResultStatus.Ok => Results.Ok(result), + ContractResults.ResultStatus.NotFound => Results.NotFound(result), + ContractResults.ResultStatus.Unauthorized => Results.Unauthorized(), + ContractResults.ResultStatus.Forbidden => Results.Forbid(), + ContractResults.ResultStatus.Invalid => Results.BadRequest(result), + ContractResults.ResultStatus.Error => Results.UnprocessableEntity(result), + ContractResults.ResultStatus.Conflict => Results.Conflict(result), + ContractResults.ResultStatus.Created => Results.Created((string?)null, result), + ContractResults.ResultStatus.NoContent => Results.NoContent(), + _ => throw new NotSupportedException($"Result {result.Status} conversion is not supported."), + }; + } +} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/ErrorMapper.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ErrorExtensions.cs similarity index 98% rename from src/Modules/Users/MicrosoftIdentity/Api/WebApi/ErrorMapper.cs rename to src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ErrorExtensions.cs index 4ec2793b6..3d3fef3b4 100644 --- a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/ErrorMapper.cs +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Extensions/ErrorExtensions.cs @@ -3,7 +3,7 @@ namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi; -public static class ErrorMapper +public static class ErrorExtensions { public static IDictionary> Translate(this Error error) { diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/ResultToApiResultExtensions.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/ResultToApiResultExtensions.cs deleted file mode 100644 index 9f4aef590..000000000 --- a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/ResultToApiResultExtensions.cs +++ /dev/null @@ -1,290 +0,0 @@ -using System.Net; -using System.Text; -using CompanyName.MyMeetings.Modules.UsersMI.Contracts.Results; -using CompanyName.MyMeetings.Modules.UsersMI.Domain; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi; - -public static class ResultToApiResultExtensions -{ - public static IResult ToApiResult(this Result result) - { - return result.Status switch - { - ResultStatus.Ok => Results.Ok(result), - ResultStatus.NotFound => Results.NotFound(result), - ResultStatus.Unauthorized => Results.Unauthorized(), - ResultStatus.Forbidden => Results.Forbid(), - ResultStatus.Invalid => Results.BadRequest(result), - ResultStatus.Error => Results.UnprocessableEntity(result), - ResultStatus.Conflict => Results.Conflict(result), - ResultStatus.Created => Results.Created(), - ResultStatus.NoContent => Results.NoContent(), - _ => throw new NotSupportedException($"Result {result.Status} conversion is not supported."), - }; - } - - public static IActionResult ToActionResult(this Result result) - { - return InternalActionResult.FromResult(result); - } -} - -internal class InternalActionResult : IActionResult -{ - private readonly object? _result; - private readonly HttpStatusCode _statusCode; - - private InternalActionResult(HttpStatusCode statusCode) - { - _statusCode = statusCode; - } - - private InternalActionResult(object result, HttpStatusCode statusCode) - : this(statusCode) - { - _result = result; - } - - public Task ExecuteResultAsync(ActionContext context) - { - var result = new ObjectResult(_result) - { - StatusCode = (int)_statusCode - }; - - return result.ExecuteResultAsync(context); - } - - public static IActionResult FromResult(Result result) - { - return result.Status switch - { - ResultStatus.Ok => new InternalActionResult(result, HttpStatusCode.OK), - ResultStatus.NotFound => new InternalActionResult(result, HttpStatusCode.NotFound), - ResultStatus.Unauthorized => new InternalActionResult(result, HttpStatusCode.Unauthorized), - ResultStatus.Forbidden => new InternalActionResult(result, HttpStatusCode.Forbidden), - ResultStatus.Error => new InternalActionResult(result, HttpStatusCode.BadRequest), - ResultStatus.Conflict => new InternalActionResult(result, HttpStatusCode.Conflict), - ResultStatus.Created => new InternalActionResult(HttpStatusCode.Created), - ResultStatus.NoContent => new InternalActionResult(HttpStatusCode.NoContent), - _ => throw new NotSupportedException($"Result {result.GetType()} conversion is not supported."), - }; - } -} - -internal static class ResponseToApiResultExtensions -{ - /// - /// Convert an AppResults.Result to a Result. - /// - /// The AppResults.Result to convert. - /// The Result. - public static IResult ToApiResult(this Application.Contracts.Results.Result result) - { - return result.ToResult().ToApiResult(); - } - - /// - /// Convert an AppResults.Result to a Result. - /// - /// The value type being returned. - /// The AppResults.Result to convert. - /// The value being returned. - /// The Result. - public static IResult ToApiResult(this Application.Contracts.Results.Result result, T value) - { - return result.ToResult(value).ToApiResult(); - } - - /// - /// Convert an AppResults.Result to a Result. - /// - /// The value being returned. - /// The AppResults.Result to convert. - /// The Result. - public static IResult ToApiResult(this Application.Contracts.Results.Result result) - { - return result.ToResult().ToApiResult(); - } -} - -internal static class ResponseToResultExtensions -{ - /// - /// Convert an AppResults.Result to a Result. - /// - /// The AppResults.Result to convert. - /// The Result. - public static Result ToResult(this Application.Contracts.Results.IResult result) - { - return result.Status switch - { - Application.Contracts.Results.ResultStatus.Ok => Result.Ok(), - Application.Contracts.Results.ResultStatus.NotFound => Result.NotFound(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Unauthorized => Result.Unauthorized(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Forbidden => Result.Forbidden(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Invalid => Result.Invalid(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Error => Result.Error(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Conflict => Result.Conflict(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Created => Result.Created(), - Application.Contracts.Results.ResultStatus.NoContent => Result.NoContent(), - _ => throw new NotSupportedException($"AppResults.Result {result.Status} conversion is not supported."), - }; - } - - /// - /// Convert an AppResults.Result to a Result. - /// - /// The value type being returned. - /// The AppResults.Result to convert. - /// The value being returned. - /// The Result. - public static Result ToResult(this Application.Contracts.Results.IResult result, T value) - { - return result.Status switch - { - Application.Contracts.Results.ResultStatus.Ok => Result.Ok(value), - Application.Contracts.Results.ResultStatus.NotFound => Result.NotFound(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Unauthorized => Result.Unauthorized(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Forbidden => Result.Forbidden(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Invalid => Result.Invalid(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Error => Result.Error(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Conflict => Result.Conflict(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Created => Result.Created(value), - Application.Contracts.Results.ResultStatus.NoContent => Result.NoContent(result.Errors.Translate()), - _ => throw new NotSupportedException($"AppResults.Result {result.Status} conversion is not supported."), - }; - } - - /// - /// Convert an AppResults.Result to a Result. - /// - /// The value being returned. - /// The AppResults.Result to convert. - /// The Result. - public static Result ToResult(this Application.Contracts.Results.IResult result) - { - return result.Status switch - { - Application.Contracts.Results.ResultStatus.Ok => Result.Ok(), - Application.Contracts.Results.ResultStatus.NotFound => Result.NotFound(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Unauthorized => Result.Unauthorized(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Forbidden => Result.Forbidden(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Invalid => Result.Invalid(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Error => Result.Error(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Conflict => Result.Conflict(result.Errors.Translate()), - Application.Contracts.Results.ResultStatus.Created => Result.Created(), - Application.Contracts.Results.ResultStatus.NoContent => Result.NoContent(result.Errors.Translate()), - _ => throw new NotSupportedException($"AppResults.Result {result.Status} conversion is not supported."), - }; - } -} - -internal static class ResponseExtensions -{ - public static IResult ToApiResult(this Application.Contracts.Results.IResult result) => result.ConvertToApiResult(); - - public static IResult ToApiResult(this Application.Contracts.Results.IResult result, object value) => result.ConvertToApiResult(value); - - public static IResult ToApiResult(this Application.Contracts.Results.IResult result) => result.ConvertToApiResult(); - - internal static IResult ConvertToApiResult(this Application.Contracts.Results.IResult result, object? value = null) - { - return result.Status switch - { - Application.Contracts.Results.ResultStatus.Ok => typeof(Application.Contracts.Results.Result).IsInstanceOfType(result) - ? Microsoft.AspNetCore.Http.Results.Ok() - : Microsoft.AspNetCore.Http.Results.Ok(value ?? result.GetValue()), - Application.Contracts.Results.ResultStatus.NotFound => NotFoundEntity(result), - Application.Contracts.Results.ResultStatus.Unauthorized => Microsoft.AspNetCore.Http.Results.Unauthorized(), - Application.Contracts.Results.ResultStatus.Forbidden => Microsoft.AspNetCore.Http.Results.Forbid(), - Application.Contracts.Results.ResultStatus.Invalid => Microsoft.AspNetCore.Http.Results.BadRequest(result.Errors), - Application.Contracts.Results.ResultStatus.Error => UnprocessableEntity(result), - Application.Contracts.Results.ResultStatus.Conflict => ConflictEntity(result), - _ => throw new NotSupportedException($"AppResults.Result {result.Status} conversion is not supported."), - }; - } - - private static IResult UnprocessableEntity(Application.Contracts.Results.IResult result) - { - StringBuilder stringBuilder = new("Next error(s) occurred:"); - foreach (Error error in result.Errors) - { - stringBuilder.Append("* ").Append(error).AppendLine(); - } - - return Microsoft.AspNetCore.Http.Results.UnprocessableEntity( - new ProblemDetails { Title = "Something went wrong.", Detail = stringBuilder.ToString() }); - } - - private static IResult NotFoundEntity(Application.Contracts.Results.IResult result) - { - StringBuilder stringBuilder = new("Next error(s) occurred:"); - if (result.Errors.Any()) - { - foreach (Error error in result.Errors) - { - stringBuilder.Append("* ").Append(error).AppendLine(); - } - - return Microsoft.AspNetCore.Http.Results.NotFound( - new ProblemDetails { Title = "Resource not found.", Detail = stringBuilder.ToString() }); - } - - return Microsoft.AspNetCore.Http.Results.NotFound(); - } - - private static IResult ConflictEntity(Application.Contracts.Results.IResult result) - { - StringBuilder stringBuilder = new("Next error(s) occurred:"); - if (result.Errors.Any()) - { - foreach (Error error in result.Errors) - { - stringBuilder.Append("* ").Append(error).AppendLine(); - } - - return Microsoft.AspNetCore.Http.Results.Conflict( - new ProblemDetails { Title = "There was a conflict.", Detail = stringBuilder.ToString() }); - } - - return Microsoft.AspNetCore.Http.Results.Conflict(); - } - - private static IResult CriticalEntity(Application.Contracts.Results.IResult result) - { - StringBuilder stringBuilder = new("Next error(s) occurred:"); - if (result.Errors.Any()) - { - foreach (Error error in result.Errors) - { - stringBuilder.Append("* ").Append(error).AppendLine(); - } - - return Microsoft.AspNetCore.Http.Results.Problem( - new ProblemDetails { Title = "Something went wrong.", Detail = stringBuilder.ToString(), Status = 500 }); - } - - return Microsoft.AspNetCore.Http.Results.StatusCode(500); - } - - private static IResult UnavailableEntity(Application.Contracts.Results.IResult result) - { - StringBuilder stringBuilder = new("Next error(s) occurred:"); - if (result.Errors.Any()) - { - foreach (Error error in result.Errors) - { - stringBuilder.Append("* ").Append(error).AppendLine(); - } - - return Microsoft.AspNetCore.Http.Results.Problem( - new ProblemDetails { Title = "Service unavailable.", Detail = stringBuilder.ToString(), Status = 503 }); - } - - return Microsoft.AspNetCore.Http.Results.StatusCode(503); - } -} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommandHandler.cs index 7c90b2145..71520d3bb 100644 --- a/src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommandHandler.cs +++ b/src/Modules/Users/MicrosoftIdentity/Application/Authentication/ResetPassword/ResetPasswordCommandHandler.cs @@ -1,4 +1,5 @@ -using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; +using CompanyName.MyMeetings.BuildingBlocks.Application.Security; +using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; using CompanyName.MyMeetings.Modules.UsersMI.Domain; using Microsoft.AspNetCore.Identity; @@ -22,7 +23,8 @@ public async Task Handle(ResetPasswordCommand request, CancellationToken return Errors.General.NotFound(request.EmailAddress, "User"); } - var result = await _userManager.ResetPasswordAsync(user, request.Token, request.Password); + var newPassword = PasswordManager.HashPassword(request.Password); + var result = await _userManager.ResetPasswordAsync(user, request.Token, newPassword); if (!result.Succeeded) { return result.Errors.Map().Combine(); diff --git a/src/Modules/Users/MicrosoftIdentity/Application/CompanyName.MyMeetings.Modules.UsersMI.Application.csproj b/src/Modules/Users/MicrosoftIdentity/Application/CompanyName.MyMeetings.Modules.UsersMI.Application.csproj index c2e180321..56e76436a 100644 --- a/src/Modules/Users/MicrosoftIdentity/Application/CompanyName.MyMeetings.Modules.UsersMI.Application.csproj +++ b/src/Modules/Users/MicrosoftIdentity/Application/CompanyName.MyMeetings.Modules.UsersMI.Application.csproj @@ -4,7 +4,7 @@ enable enable - + diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Roles.cs b/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Roles.cs deleted file mode 100644 index a0a9c6485..000000000 --- a/src/Modules/Users/MicrosoftIdentity/Application/Contracts/Roles.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts -{ - public class Roles - { - public const string Admin = "Admin"; - public const string User = "User"; - } -} \ No newline at end of file diff --git a/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommandHandler.cs b/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommandHandler.cs index edf87a4cf..54918ff4a 100644 --- a/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommandHandler.cs +++ b/src/Modules/Users/MicrosoftIdentity/Application/Me/ChangePassword/ChangePasswordCommandHandler.cs @@ -1,4 +1,5 @@ using CompanyName.MyMeetings.BuildingBlocks.Application; +using CompanyName.MyMeetings.BuildingBlocks.Application.Security; using CompanyName.MyMeetings.Modules.UsersMI.Application.Configuration.Commands; using CompanyName.MyMeetings.Modules.UsersMI.Application.Contracts.Results; using CompanyName.MyMeetings.Modules.UsersMI.Domain; @@ -30,7 +31,8 @@ public async Task Handle(ChangePasswordCommand request, CancellationToke return Result.Forbidden(Errors.Authorization.Forbidden("No permission to change password.")); } - var result = await _userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword); + var newPassword = PasswordManager.HashPassword(request.NewPassword); + var result = await _userManager.ChangePasswordAsync(user, request.CurrentPassword, newPassword); if (!result.Succeeded) { return result.Errors.Map().Combine(); diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs index 1a0a8a879..90e593be7 100644 --- a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/ModuleHosting/UserAccessModule.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.ModuleHosting; @@ -36,6 +38,38 @@ public override void RegisterModule(ContainerBuilder containerBuilder) .InstancePerLifetimeScope(); } + public override void ConfigureSwagger(SwaggerGenOptions options) + { + base.ConfigureSwagger(options); + + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = + "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + }, + Scheme = "oauth2", + Name = "Bearer", + In = ParameterLocation.Header + }, + new List() + } + }); + } + protected override void AddHostServices(IServiceCollection services) { services.ConfigureIdentityService(_userAccessConfiguration) From 0632e0f87479393b154f9675ee9969c68ddb7487 Mon Sep 17 00:00:00 2001 From: Jeff Stirn Date: Sun, 2 Nov 2025 15:02:01 +0100 Subject: [PATCH 3/3] feat(security): enforce global authorization and improve permission handler - Enforced global authorization by applying `.RequireAuthorization()` to all controller endpoints. Anonymous access must now be explicitly allowed via [AllowAnonymous] on controllers or actions. - Added `AuthorizationChecker.CheckAllEndpoints(WebApiAssembly)` to validate endpoint security during module registration. - Refactored `HasPermissionAuthorizationHandler` --- src/API/CompanyName.MyMeetings.API/Startup.cs | 9 +++- .../ModuleHosting/ModuleBase.cs | 2 + .../Api/WebApi/Endpoints/Me/MeController.cs | 2 - .../HasPermissionAuthorizationHandler.cs | 53 ++++++++++++------- 4 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/API/CompanyName.MyMeetings.API/Startup.cs b/src/API/CompanyName.MyMeetings.API/Startup.cs index 7e7c1378a..1cda9a050 100644 --- a/src/API/CompanyName.MyMeetings.API/Startup.cs +++ b/src/API/CompanyName.MyMeetings.API/Startup.cs @@ -108,7 +108,14 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IService // app.UseAuthentication(); app.UseAuthorization(); - app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers() + + // By default: Protect all controllers + // Add [AllowAnonymous] either to the controller class or the action to allow anonymous/guest access. + .RequireAuthorization(); + }); } private static void ConfigureLogger() diff --git a/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs b/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs index bee90a3d3..02bfa93b6 100644 --- a/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs +++ b/src/BuildingBlocks/Infrastructure/ModuleHosting/ModuleBase.cs @@ -1,5 +1,6 @@ using System.Reflection; using Autofac; +using CompanyName.MyMeetings.BuildingBlocks.Infrastructure.Authorization; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -70,6 +71,7 @@ private void RegisterModuleParts(ApplicationPartManager applicationPartManager) { if (WebApiAssembly is not null) { + AuthorizationChecker.CheckAllEndpoints(WebApiAssembly); applicationPartManager.ApplicationParts.Add(new AssemblyPart(WebApiAssembly)); } } diff --git a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs index b3fc62464..ee5d3fb6b 100644 --- a/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs +++ b/src/Modules/Users/MicrosoftIdentity/Api/WebApi/Endpoints/Me/MeController.cs @@ -11,13 +11,11 @@ using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.RequestConfirmEmailAddressToken; using CompanyName.MyMeetings.Modules.UsersMI.Application.Me.UpdateProfile; using CompanyName.MyMeetings.Modules.UsersMI.Contracts.V1.Me; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace CompanyName.MyMeetings.Modules.UsersMI.WebApi.Endpoints.Me; -[Authorize] [Route("api/users/me")] public class MeController : ApplicationController { diff --git a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs index 26755c95b..6599ac08c 100644 --- a/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs +++ b/src/Modules/Users/MicrosoftIdentity/Infrastructure/Configuration/Identity/HasPermissionAuthorizationHandler.cs @@ -6,16 +6,17 @@ namespace CompanyName.MyMeetings.Modules.UsersMI.Infrastructure.Configuration.Identity; -internal class HasPermissionAuthorizationHandler : AttributeAuthorizationHandler +internal sealed class HasPermissionAuthorizationHandler + : AttributeAuthorizationHandler { - private readonly IUserAccessModule _userManagementModule; + private readonly IUserAccessModule _userAccessModule; private readonly IExecutionContextAccessor _executionContextAccessor; public HasPermissionAuthorizationHandler( - IUserAccessModule userManagementModule, + IUserAccessModule userAccessModule, IExecutionContextAccessor executionContextAccessor) { - _userManagementModule = userManagementModule; + _userAccessModule = userAccessModule; _executionContextAccessor = executionContextAccessor; } @@ -30,25 +31,14 @@ protected override async Task HandleRequirementAsync( return; } - var userId = _executionContextAccessor.UserId; - var response = await _userManagementModule.ExecuteQueryAsync(new GetPermissionsQuery(userId)); - if (!response.IsSuccess) + if (!TryGetUserId(out var userId)) { context.Fail(); return; } - var permissions = response.Value ?? Enumerable.Empty(); - - // Short circuit if the user owns the administrator privilege. - if (permissions.Any(x => x.Code.Equals(ApplicationPermissions.Administrator))) - { - context.Succeed(requirement); - return; - } - - // Check if the user owns the necessary rights. - if (!IsAuthorized(attribute.Name, permissions)) + var permissions = await GetUserPermissionsAsync(userId); + if (!HasAdministratorPermission(permissions) || !HasRequiredPermission(attribute.Name, permissions)) { context.Fail(); return; @@ -57,8 +47,31 @@ protected override async Task HandleRequirementAsync( context.Succeed(requirement); } - private bool IsAuthorized(string permission, IEnumerable permissions) + private bool TryGetUserId(out Guid userId) + { + try + { + userId = _executionContextAccessor.UserId; + return true; + } + catch (ApplicationException) + { + userId = default; + return false; + } + } + + private async Task> GetUserPermissionsAsync(Guid userId) { - return permissions.Any(x => x.Code == permission); + var response = await _userAccessModule.ExecuteQueryAsync(new GetPermissionsQuery(userId)); + return response.IsSuccess + ? response.Value ?? Enumerable.Empty() + : Enumerable.Empty(); } + + private static bool HasAdministratorPermission(IEnumerable permissions) => + permissions.Any(x => x.Code.Equals(ApplicationPermissions.Administrator)); + + private static bool HasRequiredPermission(string requiredPermission, IEnumerable permissions) => + permissions.Any(x => x.Code == requiredPermission); } \ No newline at end of file