From a775be5ef15cde73e2fb93de967ea7c5dd9cd1e4 Mon Sep 17 00:00:00 2001 From: Vitalii Dasaev Date: Sat, 25 Apr 2020 11:25:31 +0300 Subject: [PATCH] #8 add email verification --- .editorconfig | 13 +- .../IAdminsClient.cs | 14 +- .../Models/AdminUser.cs | 4 +- .../Models/Enums/Localization.cs | 11 + .../Models/Enums/VerificationCodeError.cs | 24 ++ .../Requests/RegistrationRequestModel.cs | 7 +- ...erificationCodeConfirmationRequestModel.cs | 16 ++ ...rificationCodeConfirmationResponseModel.cs | 18 ++ settings.yaml | 9 + .../Enums/Localization.cs | 8 + .../Enums/VerificationCodeError.cs | 12 + .../Models/AdminUser.cs | 5 +- .../Models/CallRateLimitSettingsDto.cs | 10 + .../Models/Emails/AdminCreatedEmailDto.cs | 14 + .../Models/RegistrationRequestDto.cs | 19 ++ .../ConfirmVerificationCodeResultModel.cs | 11 + .../Models/Verification/IVerificationCode.cs | 15 ++ .../Verification/VerificationCodeResult.cs | 28 ++ .../IEmailVerificationCodeRepository.cs | 12 + .../Services/IAdminUsersService.cs | 13 +- .../Services/ICallRateLimiterService.cs | 13 + .../Services/IEmailVerificationService.cs | 15 ++ .../Services/INotificationsService.cs | 8 +- .../AdminUserService.cs | 160 +++++------ .../CallRateLimiterService.cs | 73 +++++ .../EmailVerificationService.cs | 94 +++++++ ...vice.AdminManagement.DomainServices.csproj | 5 +- .../NotificationsService.cs | 30 ++- .../AdminManagementContext.cs | 7 +- .../Entities/EmailVerificationCodeEntity.cs | 39 +++ ...425081712_AddEmailVerification.Designer.cs | 108 ++++++++ .../20200425081712_AddEmailVerification.cs | 40 +++ .../AdminManagementContextModelSnapshot.cs | 26 +- .../Repositories/AdminUsersRepository.cs | 14 +- .../EmailVerificationCodeRepository.cs | 81 ++++++ .../AutoMapperProfile.cs | 18 +- .../Controllers/AdminsController.cs | 32 ++- .../Managers/ShutdownManager.cs | 13 +- .../Managers/StartupManager.cs | 13 +- .../Modules/DataLayerModule.cs | 10 +- .../Modules/RabbitMqModule.cs | 13 +- .../Modules/ServiceModule.cs | 25 +- .../Settings/AdminCreatedEmail.cs | 5 +- .../Settings/AdminManagementSettings.cs | 3 +- .../Settings/AppSettings.cs | 2 +- .../Settings/LimitationSettings.cs | 10 + .../AdminUsersServiceTests.cs | 103 ++++--- .../EmailVerificationServiceTests.cs | 254 ++++++++++++++++++ ...dminManagement.DomainServices.Tests.csproj | 1 + 49 files changed, 1262 insertions(+), 216 deletions(-) create mode 100644 client/MAVN.Service.AdminManagement.Client/Models/Enums/Localization.cs create mode 100644 client/MAVN.Service.AdminManagement.Client/Models/Enums/VerificationCodeError.cs create mode 100644 client/MAVN.Service.AdminManagement.Client/Models/Requests/Verification/VerificationCodeConfirmationRequestModel.cs create mode 100644 client/MAVN.Service.AdminManagement.Client/Models/Responses/Verification/VerificationCodeConfirmationResponseModel.cs create mode 100644 src/MAVN.Service.AdminManagement.Domain/Enums/Localization.cs create mode 100644 src/MAVN.Service.AdminManagement.Domain/Enums/VerificationCodeError.cs create mode 100644 src/MAVN.Service.AdminManagement.Domain/Models/CallRateLimitSettingsDto.cs create mode 100644 src/MAVN.Service.AdminManagement.Domain/Models/Emails/AdminCreatedEmailDto.cs create mode 100644 src/MAVN.Service.AdminManagement.Domain/Models/RegistrationRequestDto.cs create mode 100644 src/MAVN.Service.AdminManagement.Domain/Models/Verification/ConfirmVerificationCodeResultModel.cs create mode 100644 src/MAVN.Service.AdminManagement.Domain/Models/Verification/IVerificationCode.cs create mode 100644 src/MAVN.Service.AdminManagement.Domain/Models/Verification/VerificationCodeResult.cs create mode 100644 src/MAVN.Service.AdminManagement.Domain/Repositories/IEmailVerificationCodeRepository.cs create mode 100644 src/MAVN.Service.AdminManagement.Domain/Services/ICallRateLimiterService.cs create mode 100644 src/MAVN.Service.AdminManagement.Domain/Services/IEmailVerificationService.cs create mode 100644 src/MAVN.Service.AdminManagement.DomainServices/CallRateLimiterService.cs create mode 100644 src/MAVN.Service.AdminManagement.DomainServices/EmailVerificationService.cs create mode 100644 src/MAVN.Service.AdminManagement.MsSqlRepositories/Entities/EmailVerificationCodeEntity.cs create mode 100644 src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/20200425081712_AddEmailVerification.Designer.cs create mode 100644 src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/20200425081712_AddEmailVerification.cs create mode 100644 src/MAVN.Service.AdminManagement.MsSqlRepositories/Repositories/EmailVerificationCodeRepository.cs create mode 100644 src/MAVN.Service.AdminManagement/Settings/LimitationSettings.cs create mode 100644 tests/MAVN.Service.AdminManagement.DomainServices.Tests/EmailVerificationServiceTests.cs diff --git a/.editorconfig b/.editorconfig index 2dc0806..2c3f6ce 100644 --- a/.editorconfig +++ b/.editorconfig @@ -81,4 +81,15 @@ csharp_new_line_before_else = true csharp_new_line_before_catch = true csharp_new_line_before_finally = true csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_members_in_anonymous_types = true \ No newline at end of file +csharp_new_line_before_members_in_anonymous_types = true + +# underscore +dotnet_naming_rule.private_members_with_underscore.symbols = private_fields +dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore +dotnet_naming_rule.private_members_with_underscore.severity = suggestion + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_style.prefix_underscore.capitalization = camel_case +dotnet_naming_style.prefix_underscore.required_prefix = _ \ No newline at end of file diff --git a/client/MAVN.Service.AdminManagement.Client/IAdminsClient.cs b/client/MAVN.Service.AdminManagement.Client/IAdminsClient.cs index 008cf2c..343251e 100644 --- a/client/MAVN.Service.AdminManagement.Client/IAdminsClient.cs +++ b/client/MAVN.Service.AdminManagement.Client/IAdminsClient.cs @@ -1,10 +1,10 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; using System.Threading.Tasks; using JetBrains.Annotations; using MAVN.Service.AdminManagement.Client.Models; using MAVN.Service.AdminManagement.Client.Models.Requests; +using MAVN.Service.AdminManagement.Client.Models.Requests.Verification; +using MAVN.Service.AdminManagement.Client.Models.Responses.Verification; using Refit; namespace MAVN.Service.AdminManagement.Client @@ -30,6 +30,14 @@ public interface IAdminsClient [Post("/api/admins/register")] Task RegisterAsync([Body] RegistrationRequestModel request); + /// + /// Confirm Email in the system. + /// + /// Request. + /// + [Post("/api/admins/confirmemail")] + Task ConfirmEmailAsync([Body] VerificationCodeConfirmationRequestModel request); + /// /// Update admin data. /// diff --git a/client/MAVN.Service.AdminManagement.Client/Models/AdminUser.cs b/client/MAVN.Service.AdminManagement.Client/Models/AdminUser.cs index 85e8318..8510061 100644 --- a/client/MAVN.Service.AdminManagement.Client/Models/AdminUser.cs +++ b/client/MAVN.Service.AdminManagement.Client/Models/AdminUser.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using JetBrains.Annotations; @@ -22,6 +22,8 @@ public class AdminUser /// Email address of the Admin /// public string Email { get; set; } + /// Email Verified flag. + public string IsEmailVerified { get; set; } /// /// First Name /// diff --git a/client/MAVN.Service.AdminManagement.Client/Models/Enums/Localization.cs b/client/MAVN.Service.AdminManagement.Client/Models/Enums/Localization.cs new file mode 100644 index 0000000..58db0bb --- /dev/null +++ b/client/MAVN.Service.AdminManagement.Client/Models/Enums/Localization.cs @@ -0,0 +1,11 @@ +namespace MAVN.Service.AdminManagement.Client.Models.Enums +{ + /// Localization + public enum Localization + { + /// En + En = 0, + /// De + De + } +} diff --git a/client/MAVN.Service.AdminManagement.Client/Models/Enums/VerificationCodeError.cs b/client/MAVN.Service.AdminManagement.Client/Models/Enums/VerificationCodeError.cs new file mode 100644 index 0000000..0ad531c --- /dev/null +++ b/client/MAVN.Service.AdminManagement.Client/Models/Enums/VerificationCodeError.cs @@ -0,0 +1,24 @@ +using JetBrains.Annotations; + +namespace MAVN.Service.AdminManagement.Client.Models.Enums +{ + /// + /// Enum for verification code errors. + /// + [PublicAPI] + public enum VerificationCodeError + { + /// ErrorCode: None + None, + /// ErrorCode: AlreadyVerified + AlreadyVerified, + /// ErrorCode: Verification code does not exist + VerificationCodeDoesNotExist, + /// ErrorCode: VerificationCodeMismatch + VerificationCodeMismatch, + /// ErrorCode: VerificationCodeExpired + VerificationCodeExpired, + /// Admin does not exist + AdminDoesNotExist, + } +} diff --git a/client/MAVN.Service.AdminManagement.Client/Models/Requests/RegistrationRequestModel.cs b/client/MAVN.Service.AdminManagement.Client/Models/Requests/RegistrationRequestModel.cs index a4316c3..63b55a6 100644 --- a/client/MAVN.Service.AdminManagement.Client/Models/Requests/RegistrationRequestModel.cs +++ b/client/MAVN.Service.AdminManagement.Client/Models/Requests/RegistrationRequestModel.cs @@ -1,7 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; -using MAVN.Service.AdminManagement.Client.Models.Requests; +using MAVN.Service.AdminManagement.Client.Models.Enums; namespace MAVN.Service.AdminManagement.Client.Models { @@ -46,5 +46,8 @@ public class RegistrationRequestModel /// Back Office Permissions [Required] public IReadOnlyList Permissions { get; set; } + + /// Localization + public Localization Localization { get; set; } } } diff --git a/client/MAVN.Service.AdminManagement.Client/Models/Requests/Verification/VerificationCodeConfirmationRequestModel.cs b/client/MAVN.Service.AdminManagement.Client/Models/Requests/Verification/VerificationCodeConfirmationRequestModel.cs new file mode 100644 index 0000000..e8ae296 --- /dev/null +++ b/client/MAVN.Service.AdminManagement.Client/Models/Requests/Verification/VerificationCodeConfirmationRequestModel.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; + +namespace MAVN.Service.AdminManagement.Client.Models.Requests.Verification +{ + /// + /// Confirm verification code request model. + /// + [PublicAPI] + public class VerificationCodeConfirmationRequestModel + { + /// Verification code value + [Required] + public string VerificationCode { get; set; } + } +} diff --git a/client/MAVN.Service.AdminManagement.Client/Models/Responses/Verification/VerificationCodeConfirmationResponseModel.cs b/client/MAVN.Service.AdminManagement.Client/Models/Responses/Verification/VerificationCodeConfirmationResponseModel.cs new file mode 100644 index 0000000..af9fc3b --- /dev/null +++ b/client/MAVN.Service.AdminManagement.Client/Models/Responses/Verification/VerificationCodeConfirmationResponseModel.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using MAVN.Service.AdminManagement.Client.Models.Enums; + +namespace MAVN.Service.AdminManagement.Client.Models.Responses.Verification +{ + /// + /// ConfirmEmail response model. + /// + [PublicAPI] + public class VerificationCodeConfirmationResponseModel + { + /// Is verified + public bool IsVerified { get; set; } + + /// Error + public VerificationCodeError Error { get; set; } + } +} diff --git a/settings.yaml b/settings.yaml index b60caef..44b7330 100644 --- a/settings.yaml +++ b/settings.yaml @@ -18,6 +18,11 @@ AdminManagementService: settings-key: AdminManagementService-Redis-InstanceName Ttl: settings-key: AdminManagementService-Redis-Ttl + LimitationSettings: + EmailVerificationMaxAllowedRequestsNumber: + settings-key: CustomerManagementService-EmailVerificationMaxAllowedRequestsNumber + EmailVerificationCallsMonitoredPeriod: + settings-key: CustomerManagementService-EmailVerificationCallsMonitoredPeriod BackOfficeLink: settings-key: AdminManagementService-BackOfficeLink AdminCreatedEmail: @@ -25,6 +30,10 @@ AdminManagementService: settings-key: AdminManagementService-AdminCreatedEmail-EmailTemplateId SubjectTemplateId: settings-key: AdminManagementService-AdminCreatedEmail-SubjectTemplateId + VerificationLinkExpirePeriod: + settings-key: AdminManagementService-AdminCreatedEmail-VerificationLinkExpirePeriod + VerificationLinkPath: + settings-key: AdminManagementService-AdminCreatedEmail-VerificationLinkPath PasswordResetEmail: EmailTemplateId: settings-key: AdminManagementService-PasswordResetEmail-EmailTemplateId diff --git a/src/MAVN.Service.AdminManagement.Domain/Enums/Localization.cs b/src/MAVN.Service.AdminManagement.Domain/Enums/Localization.cs new file mode 100644 index 0000000..c6a9529 --- /dev/null +++ b/src/MAVN.Service.AdminManagement.Domain/Enums/Localization.cs @@ -0,0 +1,8 @@ +namespace MAVN.Service.AdminManagement.Domain.Enums +{ + public enum Localization + { + En = 0, + De + } +} diff --git a/src/MAVN.Service.AdminManagement.Domain/Enums/VerificationCodeError.cs b/src/MAVN.Service.AdminManagement.Domain/Enums/VerificationCodeError.cs new file mode 100644 index 0000000..2134d9c --- /dev/null +++ b/src/MAVN.Service.AdminManagement.Domain/Enums/VerificationCodeError.cs @@ -0,0 +1,12 @@ +namespace MAVN.Service.AdminManagement.Domain.Enums +{ + public enum VerificationCodeError + { + None, + AlreadyVerified, + VerificationCodeDoesNotExist, + VerificationCodeMismatch, + VerificationCodeExpired, + AdminDoesNotExist, + } +} diff --git a/src/MAVN.Service.AdminManagement.Domain/Models/AdminUser.cs b/src/MAVN.Service.AdminManagement.Domain/Models/AdminUser.cs index aa13f0e..049a9af 100644 --- a/src/MAVN.Service.AdminManagement.Domain/Models/AdminUser.cs +++ b/src/MAVN.Service.AdminManagement.Domain/Models/AdminUser.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace MAVN.Service.AdminManagement.Domain.Models @@ -18,6 +18,9 @@ public class AdminUser /// public string Email { get; set; } + /// Email Verified flag. + public bool IsEmailVerified { get; set; } + /// /// The first name. /// diff --git a/src/MAVN.Service.AdminManagement.Domain/Models/CallRateLimitSettingsDto.cs b/src/MAVN.Service.AdminManagement.Domain/Models/CallRateLimitSettingsDto.cs new file mode 100644 index 0000000..b2518c3 --- /dev/null +++ b/src/MAVN.Service.AdminManagement.Domain/Models/CallRateLimitSettingsDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace MAVN.Service.AdminManagement.Domain.Models +{ + public class CallRateLimitSettingsDto + { + public int EmailVerificationMaxAllowedRequestsNumber { get; set; } + public TimeSpan EmailVerificationCallsMonitoredPeriod { get; set; } + } +} diff --git a/src/MAVN.Service.AdminManagement.Domain/Models/Emails/AdminCreatedEmailDto.cs b/src/MAVN.Service.AdminManagement.Domain/Models/Emails/AdminCreatedEmailDto.cs new file mode 100644 index 0000000..c75e925 --- /dev/null +++ b/src/MAVN.Service.AdminManagement.Domain/Models/Emails/AdminCreatedEmailDto.cs @@ -0,0 +1,14 @@ +using MAVN.Service.AdminManagement.Domain.Enums; + +namespace MAVN.Service.AdminManagement.Domain.Models.Emails +{ + public class AdminCreatedEmailDto + { + public string AdminUserId { get; set; } + public string Email { get; set; } + public string EmailVerificationCode { get; set; } + public string Password { get; set; } + public string Name { get; set; } + public Localization Localization { get; set; } + } +} diff --git a/src/MAVN.Service.AdminManagement.Domain/Models/RegistrationRequestDto.cs b/src/MAVN.Service.AdminManagement.Domain/Models/RegistrationRequestDto.cs new file mode 100644 index 0000000..7b02ece --- /dev/null +++ b/src/MAVN.Service.AdminManagement.Domain/Models/RegistrationRequestDto.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using MAVN.Service.AdminManagement.Domain.Enums; + +namespace MAVN.Service.AdminManagement.Domain.Models +{ + public class RegistrationRequestDto + { + public string Email { get; set; } + public string Password { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public string PhoneNumber { get; set; } + public string Company { get; set; } + public string Department { get; set; } + public string JobTitle { get; set; } + public IReadOnlyList Permissions { get; set; } + public Localization Localization { get; set; } + } +} diff --git a/src/MAVN.Service.AdminManagement.Domain/Models/Verification/ConfirmVerificationCodeResultModel.cs b/src/MAVN.Service.AdminManagement.Domain/Models/Verification/ConfirmVerificationCodeResultModel.cs new file mode 100644 index 0000000..3509bed --- /dev/null +++ b/src/MAVN.Service.AdminManagement.Domain/Models/Verification/ConfirmVerificationCodeResultModel.cs @@ -0,0 +1,11 @@ +using MAVN.Service.AdminManagement.Domain.Enums; + +namespace MAVN.Service.AdminManagement.Domain.Models.Verification +{ + public class ConfirmVerificationCodeResultModel + { + public bool IsVerified { get; set; } + + public VerificationCodeError Error { get; set; } + } +} diff --git a/src/MAVN.Service.AdminManagement.Domain/Models/Verification/IVerificationCode.cs b/src/MAVN.Service.AdminManagement.Domain/Models/Verification/IVerificationCode.cs new file mode 100644 index 0000000..cd3cf46 --- /dev/null +++ b/src/MAVN.Service.AdminManagement.Domain/Models/Verification/IVerificationCode.cs @@ -0,0 +1,15 @@ +using System; + +namespace MAVN.Service.AdminManagement.Domain.Models.Verification +{ + public interface IVerificationCode + { + string AdminId { get; set; } + + string VerificationCode { get; set; } + + bool IsVerified { get; set; } + + DateTime ExpireDate { get; set; } + } +} diff --git a/src/MAVN.Service.AdminManagement.Domain/Models/Verification/VerificationCodeResult.cs b/src/MAVN.Service.AdminManagement.Domain/Models/Verification/VerificationCodeResult.cs new file mode 100644 index 0000000..09e28e1 --- /dev/null +++ b/src/MAVN.Service.AdminManagement.Domain/Models/Verification/VerificationCodeResult.cs @@ -0,0 +1,28 @@ +using System; +using MAVN.Service.AdminManagement.Domain.Enums; + +namespace MAVN.Service.AdminManagement.Domain.Models.Verification +{ + public class VerificationCodeResult + { + public VerificationCodeError Error { get; private set; } + + public DateTime? ExpiresAt { get; private set; } + + public static VerificationCodeResult Succeeded(DateTime expiresAt) + { + return new VerificationCodeResult + { + ExpiresAt = expiresAt + }; + } + + public static VerificationCodeResult Failed(VerificationCodeError error) + { + return new VerificationCodeResult + { + Error = error + }; + } + } +} diff --git a/src/MAVN.Service.AdminManagement.Domain/Repositories/IEmailVerificationCodeRepository.cs b/src/MAVN.Service.AdminManagement.Domain/Repositories/IEmailVerificationCodeRepository.cs new file mode 100644 index 0000000..d50c4ab --- /dev/null +++ b/src/MAVN.Service.AdminManagement.Domain/Repositories/IEmailVerificationCodeRepository.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using MAVN.Service.AdminManagement.Domain.Models.Verification; + +namespace MAVN.Service.AdminManagement.Domain.Repositories +{ + public interface IEmailVerificationCodeRepository + { + Task CreateOrUpdateAsync(string adminId, string verificationCode); + Task GetByValueAsync(string value); + Task SetAsVerifiedAsync(string value); + } +} diff --git a/src/MAVN.Service.AdminManagement.Domain/Services/IAdminUsersService.cs b/src/MAVN.Service.AdminManagement.Domain/Services/IAdminUsersService.cs index 4cfd4e8..ab3499b 100644 --- a/src/MAVN.Service.AdminManagement.Domain/Services/IAdminUsersService.cs +++ b/src/MAVN.Service.AdminManagement.Domain/Services/IAdminUsersService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading.Tasks; using MAVN.Service.AdminManagement.Domain.Enums; using MAVN.Service.AdminManagement.Domain.Models; @@ -29,16 +29,7 @@ public interface IAdminUserService string adminUserId, List permissions); - Task RegisterAsync( - string email, - string password, - string firstName, - string lastName, - string phoneNumber, - string company, - string department, - string jobTitle, - IReadOnlyList permissions); + Task RegisterAsync(RegistrationRequestDto model); Task GetByIdAsync(string adminId); diff --git a/src/MAVN.Service.AdminManagement.Domain/Services/ICallRateLimiterService.cs b/src/MAVN.Service.AdminManagement.Domain/Services/ICallRateLimiterService.cs new file mode 100644 index 0000000..6fdf099 --- /dev/null +++ b/src/MAVN.Service.AdminManagement.Domain/Services/ICallRateLimiterService.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; + +namespace MAVN.Service.AdminManagement.Domain.Services +{ + public interface ICallRateLimiterService + { + Task ClearAllCallRecordsForEmailVerificationAsync(string adminId); + + Task RecordEmailVerificationCallAsync(string adminId); + + Task IsAllowedToCallEmailVerificationAsync(string adminId); + } +} diff --git a/src/MAVN.Service.AdminManagement.Domain/Services/IEmailVerificationService.cs b/src/MAVN.Service.AdminManagement.Domain/Services/IEmailVerificationService.cs new file mode 100644 index 0000000..00c6592 --- /dev/null +++ b/src/MAVN.Service.AdminManagement.Domain/Services/IEmailVerificationService.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using MAVN.Service.AdminManagement.Domain.Models.Verification; + +namespace MAVN.Service.AdminManagement.Domain.Services +{ + public interface IEmailVerificationService + { + /// + /// Confirms verification code + /// + /// Verification code value in base64 format + /// + Task ConfirmCodeAsync(string verificationCode); + } +} diff --git a/src/MAVN.Service.AdminManagement.Domain/Services/INotificationsService.cs b/src/MAVN.Service.AdminManagement.Domain/Services/INotificationsService.cs index 53472a1..f848715 100644 --- a/src/MAVN.Service.AdminManagement.Domain/Services/INotificationsService.cs +++ b/src/MAVN.Service.AdminManagement.Domain/Services/INotificationsService.cs @@ -1,10 +1,12 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; +using MAVN.Service.AdminManagement.Domain.Enums; +using MAVN.Service.AdminManagement.Domain.Models.Emails; namespace MAVN.Service.AdminManagement.Domain.Services { public interface INotificationsService { - Task NotifyAdminCreatedAsync(string adminUserId, string email, string login, string password, string name); + Task NotifyAdminCreatedAsync(AdminCreatedEmailDto model); Task NotifyAdminPasswordResetAsync(string adminUserId, string email, string login, string password, string name); } -} \ No newline at end of file +} diff --git a/src/MAVN.Service.AdminManagement.DomainServices/AdminUserService.cs b/src/MAVN.Service.AdminManagement.DomainServices/AdminUserService.cs index 2bca994..ab55da6 100644 --- a/src/MAVN.Service.AdminManagement.DomainServices/AdminUserService.cs +++ b/src/MAVN.Service.AdminManagement.DomainServices/AdminUserService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -14,11 +14,14 @@ using Lykke.Service.Credentials.Client; using Lykke.Service.Credentials.Client.Models.Requests; using Lykke.Service.Credentials.Client.Models.Responses; -using Lykke.Service.CustomerProfile.Client; -using Lykke.Service.CustomerProfile.Client.Models.Enums; -using Lykke.Service.CustomerProfile.Client.Models.Requests; -using Lykke.Service.CustomerProfile.Client.Models.Responses; +using MAVN.Service.CustomerProfile.Client; +using MAVN.Service.CustomerProfile.Client.Models.Enums; +using MAVN.Service.CustomerProfile.Client.Models.Requests; +using MAVN.Service.CustomerProfile.Client.Models.Responses; using MoreLinq; +using AutoMapper; +using MAVN.Service.AdminManagement.Domain.Models.Emails; +using Common; namespace MAVN.Service.AdminManagement.DomainServices { @@ -28,7 +31,9 @@ public class AdminUserService : IAdminUserService private readonly INotificationsService _notificationsService; private readonly ICredentialsClient _credentialsClient; private readonly ICustomerProfileClient _customerProfileClient; + private readonly IEmailVerificationCodeRepository _emailVerificationCodeRepository; private readonly IPermissionsService _permissionsService; + private readonly IMapper _mapper; private readonly IPermissionsCache _permissionsCache; private readonly ILog _log; @@ -36,15 +41,19 @@ public class AdminUserService : IAdminUserService IAdminUsersRepository adminUsersRepository, ICredentialsClient credentialsClient, ICustomerProfileClient customerProfileClient, + IEmailVerificationCodeRepository emailVerificationCodeRepository, IPermissionsService permissionsService, ILogFactory logFactory, + IMapper mapper, INotificationsService notificationsService, IPermissionsCache permissionsCache) { _adminUsersRepository = adminUsersRepository; _credentialsClient = credentialsClient; _customerProfileClient = customerProfileClient; + _emailVerificationCodeRepository = emailVerificationCodeRepository; _permissionsService = permissionsService; + _mapper = mapper; _notificationsService = notificationsService; _permissionsCache = permissionsCache; _log = logFactory.CreateLog(this); @@ -181,27 +190,18 @@ public async Task UpdatePermissionsAsync(string adminUserId, Li return new AdminUserResult {Profile = profile}; } - public async Task RegisterAsync( - string email, - string password, - string firstName, - string lastName, - string phoneNumber, - string company, - string department, - string jobTitle, - IReadOnlyList permissions) + public async Task RegisterAsync(RegistrationRequestDto model) { var adminId = Guid.NewGuid().ToString(); - - email = email.ToLower(); + + model.Email = model.Email.ToLower(); CredentialsCreateResponse adminCredentialsCreationResult; try { adminCredentialsCreationResult = await _credentialsClient.Admins.CreateAsync( - new AdminCredentialsCreateRequest {Login = email, AdminId = adminId, Password = password}); + new AdminCredentialsCreateRequest {Login = model.Email, AdminId = adminId, Password = model.Password}); } catch (ClientApiException exception) when (exception.HttpStatusCode == HttpStatusCode.BadRequest) { @@ -211,7 +211,7 @@ public async Task UpdatePermissionsAsync(string adminUserId, Li if (adminCredentialsCreationResult.Error == CredentialsError.LoginAlreadyExists) return new RegistrationResultModel {Error = ServicesError.AlreadyRegistered}; - var emailHash = GetHash(email); + var emailHash = GetHash(model.Email); if (adminCredentialsCreationResult.Error != CredentialsError.None) { @@ -247,21 +247,13 @@ public async Task UpdatePermissionsAsync(string adminUserId, Li } var adminProfileCreationResult = await _customerProfileClient.AdminProfiles.AddAsync( - new AdminProfileRequest - { - AdminId = Guid.Parse(adminId), - Email = email, - FirstName = firstName, - LastName = lastName, - Company = company, - Department = department, - JobTitle = jobTitle, - PhoneNumber = phoneNumber - }); + _mapper.Map(model, + opt => opt.AfterMap((src, dest) => { dest.AdminId = Guid.Parse(adminId); })) + ); - await _permissionsService.CreateOrUpdatePermissionsAsync(adminId, permissions); + await _permissionsService.CreateOrUpdatePermissionsAsync(adminId, model.Permissions); - await _permissionsCache.SetAsync(adminId, permissions.ToList()); + await _permissionsCache.SetAsync(adminId, model.Permissions.ToList()); if (adminProfileCreationResult.ErrorCode != AdminProfileErrorCodes.None) { @@ -269,25 +261,36 @@ public async Task UpdatePermissionsAsync(string adminUserId, Li context: $"adminUserId: {adminId}; error: {adminProfileCreationResult.ErrorCode}"); } - var fullName = $"{firstName} {lastName}"; + #region email verification code + + var emailVerificationCode = Guid.NewGuid().ToString(); - await _notificationsService.NotifyAdminCreatedAsync(adminId, email, email, password, fullName); + var emailVerificationCodeEntity = await _emailVerificationCodeRepository.CreateOrUpdateAsync( + adminId, + emailVerificationCode); - return new RegistrationResultModel { Admin = new AdminUser + #endregion + + await _notificationsService.NotifyAdminCreatedAsync(new AdminCreatedEmailDto { AdminUserId = adminId, - Company = adminProfileCreationResult.Data.Company, - Department = adminProfileCreationResult.Data.Department, - Email = adminProfileCreationResult.Data.Email, - FirstName = adminProfileCreationResult.Data.FirstName, - LastName = adminProfileCreationResult.Data.LastName, - JobTitle = adminProfileCreationResult.Data.JobTitle, - PhoneNumber = adminProfileCreationResult.Data.PhoneNumber, - Permissions = permissions.ToList(), - RegisteredAt = registrationDateTime, - UseDefaultPermissions = false, - IsActive = true - }}; + Email = model.Email, + EmailVerificationCode = emailVerificationCode.ToBase64(), + Password = model.Password, + Name = $"{model.FirstName} {model.LastName}", + Localization = model.Localization + }); + + _log.Info(message: "Successfully generated AdminCreatedEmail", context: adminId); + + var adminUser = _mapper.Map(adminProfileCreationResult.Data); + adminUser.AdminUserId = adminId; + adminUser.Permissions = model.Permissions.ToList(); + adminUser.RegisteredAt = registrationDateTime; + adminUser.UseDefaultPermissions = false; + adminUser.IsActive = true; + + return new RegistrationResultModel { Admin = adminUser }; } public async Task GetByIdAsync(string adminId) @@ -334,8 +337,8 @@ public async Task GetByIdAsync(string adminId) await _adminUsersRepository.TryUpdateAsync(adminUserEncrypted); var adminProfile = await _customerProfileClient.AdminProfiles.GetByIdAsync(Guid.Parse(adminUserId)); - - await _customerProfileClient.AdminProfiles.UpdateAsync(new AdminProfileRequest + + adminProfile = await _customerProfileClient.AdminProfiles.UpdateAsync(new AdminProfileRequest { AdminId = Guid.Parse(adminUserId), Email = adminProfile.Data.Email, @@ -346,25 +349,14 @@ public async Task GetByIdAsync(string adminId) JobTitle = jobTitle, PhoneNumber = phoneNumber }); - + + var adminUser = _mapper.Map(adminUserEncrypted); + _mapper.Map(adminProfile, adminUser); + return new AdminUserResult { Error = AdminUserErrorCodes.None, - Profile = new AdminUser - { - AdminUserId = adminUserId, - Email = adminProfile.Data.Email, - IsActive = isActive, - Company = company, - Department = department, - FirstName = firstName, - JobTitle = jobTitle, - LastName = lastName, - PhoneNumber = phoneNumber, - RegisteredAt = adminUserEncrypted.RegisteredAt, - UseDefaultPermissions = adminUserEncrypted.UseDefaultPermissions, - Permissions = await GetPermissionsAsync(adminUserId) - } + Profile = adminUser }; } @@ -413,25 +405,13 @@ public async Task> GetPermissionsAsync(string adminId) foreach (var adminUserEncrypted in adminUsersEncrypted) { - var admin = new AdminUser - { - AdminUserId = adminUserEncrypted.AdminUserId, - RegisteredAt = adminUserEncrypted.RegisteredAt, - IsActive = adminUserEncrypted.IsActive, - UseDefaultPermissions = adminUserEncrypted.UseDefaultPermissions - }; + var admin = _mapper.Map(adminUserEncrypted); var adminId = Guid.Parse(adminUserEncrypted.AdminUserId); if (adminProfileMap.TryGetValue(adminId, out var adminProfile)) { - admin.FirstName = adminProfile.FirstName; - admin.LastName = adminProfile.LastName; - admin.Email = adminProfile.Email; - admin.Company = adminProfile.Company; - admin.Department = adminProfile.Department; - admin.JobTitle = adminProfile.JobTitle; - admin.PhoneNumber = adminProfile.PhoneNumber; + _mapper.Map(adminProfile, admin); } else { @@ -447,32 +427,20 @@ public async Task> GetPermissionsAsync(string adminId) private async Task LoadSensitiveDataAsync(AdminUserEncrypted adminUserEncrypted) { - var admin = new AdminUser - { - AdminUserId = adminUserEncrypted.AdminUserId, - RegisteredAt = adminUserEncrypted.RegisteredAt, - IsActive = adminUserEncrypted.IsActive, - UseDefaultPermissions = adminUserEncrypted.UseDefaultPermissions - }; + var admin = _mapper.Map(adminUserEncrypted); var adminId = Guid.Parse(adminUserEncrypted.AdminUserId); - var response = await _customerProfileClient.AdminProfiles.GetByIdAsync(adminId); + var adminProfile = await _customerProfileClient.AdminProfiles.GetByIdAsync(adminId); - if (response.ErrorCode != AdminProfileErrorCodes.None) + if (adminProfile.ErrorCode != AdminProfileErrorCodes.None) { _log.Error(message: "An error occurred while getting admin profile.", - context: $"adminUserId: {adminUserEncrypted.AdminUserId}; error: {response.ErrorCode}"); + context: $"adminUserId: {adminUserEncrypted.AdminUserId}; error: {adminProfile.ErrorCode}"); } else { - admin.FirstName = response.Data.FirstName; - admin.LastName = response.Data.LastName; - admin.Email = response.Data.Email; - admin.Company = response.Data.Company; - admin.Department = response.Data.Department; - admin.JobTitle = response.Data.JobTitle; - admin.PhoneNumber = response.Data.PhoneNumber; + _mapper.Map(adminProfile, admin); } return admin; diff --git a/src/MAVN.Service.AdminManagement.DomainServices/CallRateLimiterService.cs b/src/MAVN.Service.AdminManagement.DomainServices/CallRateLimiterService.cs new file mode 100644 index 0000000..836fc67 --- /dev/null +++ b/src/MAVN.Service.AdminManagement.DomainServices/CallRateLimiterService.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading.Tasks; +using MAVN.Service.AdminManagement.Domain.Models; +using MAVN.Service.AdminManagement.Domain.Services; +using StackExchange.Redis; + +namespace MAVN.Service.AdminManagement.DomainServices +{ + public class CallRateLimiterService : ICallRateLimiterService + { + private readonly CallRateLimitSettingsDto _settings; + private readonly string _redisInstanceName; + private const string EmailVerificationKeyPattern = "{0}::admin_emailverification::{1}"; + + private readonly IDatabase _db; + + public CallRateLimiterService(IConnectionMultiplexer connectionMultiplexer, CallRateLimitSettingsDto settings, string redisInstanceName) + { + _settings = settings; + _redisInstanceName = redisInstanceName; + _db = connectionMultiplexer.GetDatabase(); + } + + public Task ClearAllCallRecordsForEmailVerificationAsync(string adminId) + => ClearAllCallRecordsAsync(adminId, EmailVerificationKeyPattern); + + public Task RecordEmailVerificationCallAsync(string adminId) + => RecordCallAsync(adminId, EmailVerificationKeyPattern, _settings.EmailVerificationCallsMonitoredPeriod); + + public Task IsAllowedToCallEmailVerificationAsync(string adminId) + => IsAllowedToCallAsync(adminId, EmailVerificationKeyPattern, + _settings.EmailVerificationCallsMonitoredPeriod, _settings.EmailVerificationMaxAllowedRequestsNumber); + + private async Task ClearAllCallRecordsAsync(string adminId, string pattern) + { + var key = GetKeyFromPattern(adminId, pattern); + + await _db.SortedSetRemoveRangeByScoreAsync(key, double.MinValue, double.MaxValue); + } + + private async Task RecordCallAsync(string adminId, string pattern, TimeSpan monitoredPeriod) + { + var key = GetKeyFromPattern(adminId, pattern); + + await _db.SortedSetAddAsync(key, DateTime.UtcNow.ToString(), DateTime.UtcNow.Ticks); + await _db.KeyExpireAsync(key, monitoredPeriod); + } + + private async Task IsAllowedToCallAsync(string adminId, string pattern, TimeSpan monitoredPeriod, int maxNumberOfCalls) + { + await ClearOldCallRecordsAsync(adminId, pattern, monitoredPeriod); + + var key = GetKeyFromPattern(adminId, pattern); + var now = DateTime.UtcNow; + var activeCallRecords = await _db.SortedSetRangeByScoreAsync(key, (now - monitoredPeriod).Ticks, + now.Ticks); + + return activeCallRecords.Length < maxNumberOfCalls; + } + + private async Task ClearOldCallRecordsAsync(string adminId, string pattern, TimeSpan monitoredPeriod) + { + var key = GetKeyFromPattern(adminId, pattern); + await _db.SortedSetRemoveRangeByScoreAsync(key, double.MinValue, + (DateTime.UtcNow - monitoredPeriod).Ticks); + } + + private string GetKeyFromPattern(string adminId, string pattern) + { + return string.Format(pattern, _redisInstanceName, adminId); + } + } +} diff --git a/src/MAVN.Service.AdminManagement.DomainServices/EmailVerificationService.cs b/src/MAVN.Service.AdminManagement.DomainServices/EmailVerificationService.cs new file mode 100644 index 0000000..cc4f00b --- /dev/null +++ b/src/MAVN.Service.AdminManagement.DomainServices/EmailVerificationService.cs @@ -0,0 +1,94 @@ +using System; +using System.Threading.Tasks; +using Common; +using Common.Log; +using Lykke.Common.Log; +using MAVN.Service.AdminManagement.Contract.Events; +using MAVN.Service.AdminManagement.Domain.Enums; +using MAVN.Service.AdminManagement.Domain.Models.Verification; +using MAVN.Service.AdminManagement.Domain.Repositories; +using MAVN.Service.AdminManagement.Domain.Services; + +namespace MAVN.Service.AdminManagement.DomainServices +{ + public class EmailVerificationService : IEmailVerificationService + { + private readonly IEmailVerificationCodeRepository _emailVerificationCodeRepository; + private readonly IRabbitPublisher _codeVerifiedEventPublisher; + private readonly ILog _log; + + public EmailVerificationService( + IEmailVerificationCodeRepository emailVerificationCodeRepository, + IRabbitPublisher codeVerifiedEventPublisher, + ILogFactory logFactory) + { + _emailVerificationCodeRepository = emailVerificationCodeRepository; + _codeVerifiedEventPublisher = codeVerifiedEventPublisher; + _log = logFactory.CreateLog(this); + } + + /// + public async Task ConfirmCodeAsync(string verificationCode) + { + if (string.IsNullOrEmpty(verificationCode)) + throw new ArgumentNullException(nameof(verificationCode)); + + string verificationCodeValue; + try + { + verificationCodeValue = verificationCode.Base64ToString(); + } + catch (FormatException) + { + _log.Warning($"Verification code {verificationCode} format error (must be base64 string)"); + return new ConfirmVerificationCodeResultModel + { + IsVerified = false, + Error = VerificationCodeError.VerificationCodeDoesNotExist + }; + } + + var existingEntity = await _emailVerificationCodeRepository.GetByValueAsync(verificationCodeValue); + if (existingEntity == null) + { + _log.Warning($"Verification code {verificationCodeValue} not found in the system"); + return new ConfirmVerificationCodeResultModel + { + Error = VerificationCodeError.VerificationCodeDoesNotExist + }; + } + + if (existingEntity.IsVerified) + { + _log.Info($"Verification code {verificationCodeValue} already verified when trying to confirm"); + return new ConfirmVerificationCodeResultModel + { + Error = VerificationCodeError.AlreadyVerified, + IsVerified = true + }; + } + + if (verificationCodeValue != existingEntity.VerificationCode) + { + _log.Warning($"VerificationCode {verificationCodeValue} does not match the stored verification code"); + return new ConfirmVerificationCodeResultModel { Error = VerificationCodeError.VerificationCodeMismatch }; + } + + if (existingEntity.ExpireDate < DateTime.UtcNow) + { + _log.Warning($"VerificationCode {verificationCodeValue} has expired"); + return new ConfirmVerificationCodeResultModel { Error = VerificationCodeError.VerificationCodeExpired }; + } + + await Task.WhenAll(_emailVerificationCodeRepository.SetAsVerifiedAsync(verificationCodeValue)) + .ContinueWith(_ => + _codeVerifiedEventPublisher.PublishAsync(new AdminEmailVerifiedEvent + { + AdminId = existingEntity.AdminId, + TimeStamp = DateTime.UtcNow + })); + + return new ConfirmVerificationCodeResultModel { IsVerified = true }; + } + } +} diff --git a/src/MAVN.Service.AdminManagement.DomainServices/MAVN.Service.AdminManagement.DomainServices.csproj b/src/MAVN.Service.AdminManagement.DomainServices/MAVN.Service.AdminManagement.DomainServices.csproj index c19fb69..78d0439 100644 --- a/src/MAVN.Service.AdminManagement.DomainServices/MAVN.Service.AdminManagement.DomainServices.csproj +++ b/src/MAVN.Service.AdminManagement.DomainServices/MAVN.Service.AdminManagement.DomainServices.csproj @@ -4,14 +4,17 @@ 1.0.0 + - + + + diff --git a/src/MAVN.Service.AdminManagement.DomainServices/NotificationsService.cs b/src/MAVN.Service.AdminManagement.DomainServices/NotificationsService.cs index 4c9a4ef..91c52a6 100644 --- a/src/MAVN.Service.AdminManagement.DomainServices/NotificationsService.cs +++ b/src/MAVN.Service.AdminManagement.DomainServices/NotificationsService.cs @@ -1,8 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading.Tasks; using Lykke.Common; using MAVN.Service.AdminManagement.Domain.Services; using Lykke.Service.NotificationSystem.SubscriberContract; +using MAVN.Service.AdminManagement.Domain.Enums; +using MAVN.Service.AdminManagement.Domain.Models.Emails; namespace MAVN.Service.AdminManagement.DomainServices { @@ -13,6 +15,7 @@ public class NotificationsService : INotificationsService private readonly string _backOfficeUrl; private readonly string _adminCreatedEmailTemplateId; private readonly string _adminCreatedEmailSubjectTemplateId; + private readonly string _adminCreatedVerificationLinkPath; private readonly string _adminPasswordResetEmailTemplateId; private readonly string _adminPasswordResetEmailSubjectTemplateId; @@ -21,6 +24,7 @@ public class NotificationsService : INotificationsService string backOfficeUrl, string adminCreatedEmailTemplateId, string adminCreatedEmailSubjectTemplateId, + string adminCreatedVerificationLinkPath, string adminPasswordResetEmailTemplateId, string adminPasswordResetEmailSubjectTemplateId) { @@ -28,21 +32,26 @@ public class NotificationsService : INotificationsService _backOfficeUrl = backOfficeUrl; _adminCreatedEmailTemplateId = adminCreatedEmailTemplateId; _adminCreatedEmailSubjectTemplateId = adminCreatedEmailSubjectTemplateId; + _adminCreatedVerificationLinkPath = adminCreatedVerificationLinkPath; _adminPasswordResetEmailTemplateId = adminPasswordResetEmailTemplateId; _adminPasswordResetEmailSubjectTemplateId = adminPasswordResetEmailSubjectTemplateId; } - public async Task NotifyAdminCreatedAsync(string adminUserId, string email, string login, string password, string name) + public async Task NotifyAdminCreatedAsync(AdminCreatedEmailDto model) { + var url = GetLocalizedPath(_backOfficeUrl, model.Localization); + var values = new Dictionary { - {"Name", name}, - {"BackOfficeUrl", _backOfficeUrl}, - {"Login", login}, - {"Password", password} + {nameof(model.Name), model.Name}, + {"BackOfficeUrl", url}, + {"EmailVerificationLink", url + _adminCreatedVerificationLinkPath.TrimStart('/').Replace("{0}", model.EmailVerificationCode)}, + {"Login", model.Email}, + {nameof(model.Password), model.Password}, + {nameof(model.Localization), model.Localization.ToString()} }; - await SendEmailAsync(adminUserId, email, values, _adminCreatedEmailTemplateId, + await SendEmailAsync(model.AdminUserId, model.Email, values, _adminCreatedEmailTemplateId, _adminCreatedEmailSubjectTemplateId); } @@ -74,5 +83,10 @@ private async Task SendEmailAsync(string customerId, string destination, Diction Source = $"{AppEnvironment.Name} - {AppEnvironment.Version}" }); } + + private string GetLocalizedPath(string url, Localization localization) + { + return url.TrimEnd('/') + $"/{localization.ToString().ToLower()}/"; + } } -} \ No newline at end of file +} diff --git a/src/MAVN.Service.AdminManagement.MsSqlRepositories/AdminManagementContext.cs b/src/MAVN.Service.AdminManagement.MsSqlRepositories/AdminManagementContext.cs index a89c9cc..8733770 100644 --- a/src/MAVN.Service.AdminManagement.MsSqlRepositories/AdminManagementContext.cs +++ b/src/MAVN.Service.AdminManagement.MsSqlRepositories/AdminManagementContext.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Data.Common; using JetBrains.Annotations; using Lykke.Common.MsSql; @@ -14,6 +14,7 @@ public class AdminManagementContext : MsSqlContext internal DbSet AdminUser { get; set; } internal DbSet Permissions { get; set; } + internal DbSet EmailVerificationCodes { get; set; } [UsedImplicitly] public AdminManagementContext() : base(Schema) @@ -53,6 +54,10 @@ protected override void OnLykkeModelCreating(ModelBuilder modelBuilder) .HasConversion( v => v.ToString(), v => (PermissionLevel)Enum.Parse(typeof(PermissionLevel), v)); + + modelBuilder.Entity() + .HasIndex(_ => _.VerificationCode) + .IsUnique(); } } } diff --git a/src/MAVN.Service.AdminManagement.MsSqlRepositories/Entities/EmailVerificationCodeEntity.cs b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Entities/EmailVerificationCodeEntity.cs new file mode 100644 index 0000000..2dda2c7 --- /dev/null +++ b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Entities/EmailVerificationCodeEntity.cs @@ -0,0 +1,39 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using MAVN.Service.AdminManagement.Domain.Models.Verification; + +namespace MAVN.Service.AdminManagement.MsSqlRepositories.Entities +{ + [Table("email_verification_codes")] + public class EmailVerificationCodeEntity : IVerificationCode + { + [Key] + [Required] + [Column("admin_id")] + public string AdminId { get; set; } + + [Required] + [Column("code")] + public string VerificationCode { get; set; } + + [Required] + [Column("is_verified")] + public bool IsVerified { get; set; } + + [Required] + [Column("expire_date")] + public DateTime ExpireDate { get; set; } + + internal static EmailVerificationCodeEntity Create(string adminId, string code, TimeSpan expirePeriod) + { + return new EmailVerificationCodeEntity + { + AdminId = adminId, + VerificationCode = code, + ExpireDate = DateTime.UtcNow.Add(expirePeriod), + IsVerified = false + }; + } + } +} diff --git a/src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/20200425081712_AddEmailVerification.Designer.cs b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/20200425081712_AddEmailVerification.Designer.cs new file mode 100644 index 0000000..e4590a2 --- /dev/null +++ b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/20200425081712_AddEmailVerification.Designer.cs @@ -0,0 +1,108 @@ +// +using System; +using MAVN.Service.AdminManagement.MsSqlRepositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace MAVN.Service.AdminManagement.MsSqlRepositories.Migrations +{ + [DbContext(typeof(AdminManagementContext))] + [Migration("20200425081712_AddEmailVerification")] + partial class AddEmailVerification + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("admin_users") + .HasAnnotation("ProductVersion", "2.2.4-servicing-10062") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("MAVN.Service.AdminManagement.MsSqlRepositories.Entities.AdminUserEntity", b => + { + b.Property("AdminUserId") + .ValueGeneratedOnAdd() + .HasColumnName("admin_id"); + + b.Property("EmailHash") + .IsRequired() + .HasColumnName("email_hash") + .HasColumnType("char(64)"); + + b.Property("IsDisabled") + .ValueGeneratedOnAdd() + .HasColumnName("is_disabled") + .HasDefaultValue(false); + + b.Property("RegisteredAt") + .HasColumnName("registered_at"); + + b.Property("UseCustomPermissions") + .ValueGeneratedOnAdd() + .HasColumnName("use_custom_permissions") + .HasDefaultValue(false); + + b.HasKey("AdminUserId"); + + b.HasIndex("EmailHash") + .IsUnique(); + + b.ToTable("AdminUser"); + }); + + modelBuilder.Entity("MAVN.Service.AdminManagement.MsSqlRepositories.Entities.EmailVerificationCodeEntity", b => + { + b.Property("AdminId") + .ValueGeneratedOnAdd() + .HasColumnName("admin_id"); + + b.Property("ExpireDate") + .HasColumnName("expire_date"); + + b.Property("IsVerified") + .HasColumnName("is_verified"); + + b.Property("VerificationCode") + .IsRequired() + .HasColumnName("code"); + + b.HasKey("AdminId"); + + b.HasIndex("VerificationCode") + .IsUnique(); + + b.ToTable("email_verification_codes"); + }); + + modelBuilder.Entity("MAVN.Service.AdminManagement.MsSqlRepositories.Entities.PermissionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("AdminUserId") + .IsRequired() + .HasColumnName("admin_id"); + + b.Property("Level") + .IsRequired() + .HasColumnName("level"); + + b.Property("Type") + .IsRequired() + .HasColumnName("type"); + + b.HasKey("Id"); + + b.HasIndex("AdminUserId"); + + b.ToTable("permissions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/20200425081712_AddEmailVerification.cs b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/20200425081712_AddEmailVerification.cs new file mode 100644 index 0000000..b0c695d --- /dev/null +++ b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/20200425081712_AddEmailVerification.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace MAVN.Service.AdminManagement.MsSqlRepositories.Migrations +{ + public partial class AddEmailVerification : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "email_verification_codes", + schema: "admin_users", + columns: table => new + { + admin_id = table.Column(nullable: false), + code = table.Column(nullable: false), + is_verified = table.Column(nullable: false), + expire_date = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_email_verification_codes", x => x.admin_id); + }); + + migrationBuilder.CreateIndex( + name: "IX_email_verification_codes_code", + schema: "admin_users", + table: "email_verification_codes", + column: "code", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "email_verification_codes", + schema: "admin_users"); + } + } +} diff --git a/src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/AdminManagementContextModelSnapshot.cs b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/AdminManagementContextModelSnapshot.cs index 6933a17..6600150 100644 --- a/src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/AdminManagementContextModelSnapshot.cs +++ b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Migrations/AdminManagementContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using MAVN.Service.AdminManagement.MsSqlRepositories; using Microsoft.EntityFrameworkCore; @@ -52,6 +52,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AdminUser"); }); + modelBuilder.Entity("MAVN.Service.AdminManagement.MsSqlRepositories.Entities.EmailVerificationCodeEntity", b => + { + b.Property("AdminId") + .ValueGeneratedOnAdd() + .HasColumnName("admin_id"); + + b.Property("ExpireDate") + .HasColumnName("expire_date"); + + b.Property("IsVerified") + .HasColumnName("is_verified"); + + b.Property("VerificationCode") + .IsRequired() + .HasColumnName("code"); + + b.HasKey("AdminId"); + + b.HasIndex("VerificationCode") + .IsUnique(); + + b.ToTable("email_verification_codes"); + }); + modelBuilder.Entity("MAVN.Service.AdminManagement.MsSqlRepositories.Entities.PermissionEntity", b => { b.Property("Id") diff --git a/src/MAVN.Service.AdminManagement.MsSqlRepositories/Repositories/AdminUsersRepository.cs b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Repositories/AdminUsersRepository.cs index 696a388..0232647 100644 --- a/src/MAVN.Service.AdminManagement.MsSqlRepositories/Repositories/AdminUsersRepository.cs +++ b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Repositories/AdminUsersRepository.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using AutoMapper; @@ -28,7 +28,7 @@ public async Task> GetAllAsync() { using (var context = _contextFactory.CreateDataContext()) { - var entities = await context.AdminUser.ToListAsync(); + var entities = await context.AdminUser.AsNoTracking().ToListAsync(); return _mapper.Map>(entities); } @@ -38,14 +38,14 @@ public async Task<(IReadOnlyList admins, int count)> GetPagi { using (var context = _contextFactory.CreateDataContext()) { - var entities = await context.AdminUser + var entities = await context.AdminUser.AsNoTracking() .Where(x => !active.HasValue || x.IsDisabled == !active.Value) .OrderByDescending(x => x.RegisteredAt) .Skip(skip) .Take(take) .ToListAsync(); - var count = context.AdminUser.Count(x => !active.HasValue || x.IsDisabled == !active.Value); + var count = context.AdminUser.AsNoTracking().Count(x => !active.HasValue || x.IsDisabled == !active.Value); var admins = _mapper.Map>(entities); return (admins, count); @@ -59,14 +59,14 @@ public async Task GetByEmailAsync(string email, bool? active var emailHash = GetHash(email); var entity = await context - .AdminUser.FirstOrDefaultAsync(c => c.EmailHash == emailHash && (!active.HasValue || c.IsDisabled == !active.Value)); + .AdminUser.AsNoTracking().FirstOrDefaultAsync(c => c.EmailHash == emailHash && (!active.HasValue || c.IsDisabled == !active.Value)); if (entity == null) { emailHash = GetHash(email.ToLower()); entity = await context - .AdminUser.FirstOrDefaultAsync(c => c.EmailHash == emailHash && (!active.HasValue || c.IsDisabled == !active.Value)); + .AdminUser.AsNoTracking().FirstOrDefaultAsync(c => c.EmailHash == emailHash && (!active.HasValue || c.IsDisabled == !active.Value)); } return _mapper.Map(entity); @@ -118,7 +118,7 @@ public async Task GetAsync(string adminUserId) { using (var context = _contextFactory.CreateDataContext()) { - var entity = await context.AdminUser + var entity = await context.AdminUser.AsNoTracking() .FirstOrDefaultAsync(a => a.AdminUserId == adminUserId); return _mapper.Map(entity); diff --git a/src/MAVN.Service.AdminManagement.MsSqlRepositories/Repositories/EmailVerificationCodeRepository.cs b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Repositories/EmailVerificationCodeRepository.cs new file mode 100644 index 0000000..5668a86 --- /dev/null +++ b/src/MAVN.Service.AdminManagement.MsSqlRepositories/Repositories/EmailVerificationCodeRepository.cs @@ -0,0 +1,81 @@ +using System; +using System.Data.SqlClient; +using System.Threading.Tasks; +using Lykke.Common.MsSql; +using MAVN.Service.AdminManagement.Domain.Models.Verification; +using MAVN.Service.AdminManagement.Domain.Repositories; +using MAVN.Service.AdminManagement.MsSqlRepositories.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MAVN.Service.AdminManagement.MsSqlRepositories.Repositories +{ + public class EmailVerificationCodeRepository : IEmailVerificationCodeRepository + { + private readonly MsSqlContextFactory _contextFactory; + private readonly TimeSpan _verificationLinkExpirePeriod; + + public EmailVerificationCodeRepository( + MsSqlContextFactory contextFactory, + TimeSpan verificationLinkExpirePeriod) + { + _contextFactory = contextFactory; + _verificationLinkExpirePeriod = verificationLinkExpirePeriod; + } + + public async Task CreateOrUpdateAsync(string adminId, string verificationCode) + { + var entity = + EmailVerificationCodeEntity.Create(adminId, verificationCode, _verificationLinkExpirePeriod); + + using (var context = _contextFactory.CreateDataContext()) + { + await context.EmailVerificationCodes.AddAsync(entity); + + try + { + await context.SaveChangesAsync(); + } + catch (DbUpdateException e) + { + if (e.InnerException is SqlException sqlException && + sqlException.Number == MsSqlErrorCodes.PrimaryKeyConstraintViolation) + { + context.EmailVerificationCodes.Update(entity); + + await context.SaveChangesAsync(); + } + else throw; + } + } + + return entity; + } + + public async Task GetByValueAsync(string value) + { + using (var context = _contextFactory.CreateDataContext()) + { + var entity = await context.EmailVerificationCodes.AsNoTracking().SingleOrDefaultAsync(x => x.VerificationCode == value); + + return entity; + } + } + + public async Task SetAsVerifiedAsync(string value) + { + using (var context = _contextFactory.CreateDataContext()) + { + var entity = await context.EmailVerificationCodes.SingleOrDefaultAsync(x => x.VerificationCode == value); + + if (entity == null) + throw new InvalidOperationException($"Verification code {value} doesn't exist"); + + entity.IsVerified = true; + + context.EmailVerificationCodes.Update(entity); + + await context.SaveChangesAsync(); + } + } + } +} diff --git a/src/MAVN.Service.AdminManagement/AutoMapperProfile.cs b/src/MAVN.Service.AdminManagement/AutoMapperProfile.cs index c23b86b..957bc68 100644 --- a/src/MAVN.Service.AdminManagement/AutoMapperProfile.cs +++ b/src/MAVN.Service.AdminManagement/AutoMapperProfile.cs @@ -2,8 +2,12 @@ using JetBrains.Annotations; using MAVN.Service.AdminManagement.Client.Models; using MAVN.Service.AdminManagement.Client.Models.Enums; +using MAVN.Service.AdminManagement.Client.Models.Responses.Verification; using MAVN.Service.AdminManagement.Domain.Enums; using MAVN.Service.AdminManagement.Domain.Models; +using MAVN.Service.AdminManagement.Domain.Models.Verification; +using MAVN.Service.CustomerProfile.Client.Models.Requests; +using MAVN.Service.CustomerProfile.Client.Models.Responses; namespace MAVN.Service.AdminManagement { @@ -14,11 +18,23 @@ public AutoMapperProfile() { AllowNullCollections = true; + // Auth and password CreateMap(); CreateMap(); CreateMap(); CreateMap(); + + // Registration + CreateMap(); + CreateMap() + .ForMember(dest => dest.AdminId, opt => opt.Ignore()); CreateMap(); + CreateMap(); + CreateMap(); + + // AdminUser + CreateMap(MemberList.None); + CreateMap(MemberList.None); CreateMap(); CreateMap(); CreateMap() @@ -26,9 +42,9 @@ public AutoMapperProfile() .ForMember(c => c.Error, a => a.MapFrom(x => x.Error)); CreateMap(); + // Permission CreateMap(); CreateMap(); - CreateMap(); CreateMap(); } diff --git a/src/MAVN.Service.AdminManagement/Controllers/AdminsController.cs b/src/MAVN.Service.AdminManagement/Controllers/AdminsController.cs index 8432e1c..0e5e954 100644 --- a/src/MAVN.Service.AdminManagement/Controllers/AdminsController.cs +++ b/src/MAVN.Service.AdminManagement/Controllers/AdminsController.cs @@ -7,6 +7,9 @@ using MAVN.Service.AdminManagement.Client; using MAVN.Service.AdminManagement.Client.Models; using MAVN.Service.AdminManagement.Client.Models.Requests; +using MAVN.Service.AdminManagement.Client.Models.Requests.Verification; +using MAVN.Service.AdminManagement.Client.Models.Responses.Verification; +using MAVN.Service.AdminManagement.Domain.Enums; using MAVN.Service.AdminManagement.Domain.Exceptions; using MAVN.Service.AdminManagement.Domain.Models; using MAVN.Service.AdminManagement.Domain.Services; @@ -21,15 +24,18 @@ namespace MAVN.Service.AdminManagement.Controllers public class AdminsController : Controller, IAdminsClient { private readonly IAdminUserService _adminUserService; + private readonly IEmailVerificationService _emailVerificationService; private readonly IMapper _mapper; private readonly IAutofillValuesService _autofillValuesService; public AdminsController( IAdminUserService adminUserService, + IEmailVerificationService emailVerificationService, IMapper mapper, IAutofillValuesService autofillValuesService) { _adminUserService = adminUserService; + _emailVerificationService = emailVerificationService; _mapper = mapper; _autofillValuesService = autofillValuesService; } @@ -66,20 +72,26 @@ public async Task GetAutofillValuesAsync() [ProducesResponseType(typeof(ErrorResponse), (int) HttpStatusCode.BadRequest)] public async Task RegisterAsync([FromBody] RegistrationRequestModel request) { - var result = await _adminUserService.RegisterAsync( - request.Email, - request.Password, - request.FirstName, - request.LastName, - request.PhoneNumber, - request.Company, - request.Department, - request.JobTitle, - _mapper.Map>(request.Permissions)); + var result = await _adminUserService.RegisterAsync(_mapper.Map(request)); return _mapper.Map(result); } + /// + /// Confirm Email in the system. + /// + /// Request. + /// + [HttpPost("confirmemail")] + [ProducesResponseType(typeof(VerificationCodeConfirmationResponseModel), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponse), (int)HttpStatusCode.BadRequest)] + public async Task ConfirmEmailAsync([FromBody] VerificationCodeConfirmationRequestModel request) + { + var confirmEmailModel = await _emailVerificationService.ConfirmCodeAsync(request.VerificationCode); + + return _mapper.Map(confirmEmailModel); + } + /// /// Registers new admin in the system. /// diff --git a/src/MAVN.Service.AdminManagement/Managers/ShutdownManager.cs b/src/MAVN.Service.AdminManagement/Managers/ShutdownManager.cs index 96a9279..8792592 100644 --- a/src/MAVN.Service.AdminManagement/Managers/ShutdownManager.cs +++ b/src/MAVN.Service.AdminManagement/Managers/ShutdownManager.cs @@ -1,24 +1,31 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Lykke.Sdk; using MAVN.Service.AdminManagement.Domain.Services; using Lykke.Service.NotificationSystem.SubscriberContract; +using MAVN.Service.AdminManagement.Contract.Events; namespace MAVN.Service.AdminManagement.Managers { public class ShutdownManager : IShutdownManager { private readonly IRabbitPublisher _emailMessageEventPublisher; + private readonly IRabbitPublisher _adminEmailVerifiedEventPublisher; - public ShutdownManager(IRabbitPublisher emailMessageEventPublisher) + public ShutdownManager( + IRabbitPublisher emailMessageEventPublisher, + IRabbitPublisher adminEmailVerifiedEventPublisher + ) { _emailMessageEventPublisher = emailMessageEventPublisher; + _adminEmailVerifiedEventPublisher = adminEmailVerifiedEventPublisher; } public Task StopAsync() { _emailMessageEventPublisher.Stop(); + _adminEmailVerifiedEventPublisher.Stop(); return Task.CompletedTask; } } -} \ No newline at end of file +} diff --git a/src/MAVN.Service.AdminManagement/Managers/StartupManager.cs b/src/MAVN.Service.AdminManagement/Managers/StartupManager.cs index c2febff..edb6b18 100644 --- a/src/MAVN.Service.AdminManagement/Managers/StartupManager.cs +++ b/src/MAVN.Service.AdminManagement/Managers/StartupManager.cs @@ -1,24 +1,31 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using MAVN.Service.AdminManagement.Domain.Services; using Lykke.Sdk; using Lykke.Service.NotificationSystem.SubscriberContract; +using MAVN.Service.AdminManagement.Contract.Events; namespace MAVN.Service.AdminManagement.Managers { public class StartupManager : IStartupManager { private readonly IRabbitPublisher _emailMessageEventPublisher; + private readonly IRabbitPublisher _adminEmailVerifiedEventPublisher; - public StartupManager(IRabbitPublisher emailMessageEventPublisher) + public StartupManager( + IRabbitPublisher emailMessageEventPublisher, + IRabbitPublisher adminEmailVerifiedEventPublisher + ) { _emailMessageEventPublisher = emailMessageEventPublisher; + _adminEmailVerifiedEventPublisher = adminEmailVerifiedEventPublisher; } public Task StartAsync() { _emailMessageEventPublisher.Start(); + _adminEmailVerifiedEventPublisher.Start(); return Task.CompletedTask; } } -} \ No newline at end of file +} diff --git a/src/MAVN.Service.AdminManagement/Modules/DataLayerModule.cs b/src/MAVN.Service.AdminManagement/Modules/DataLayerModule.cs index 2af912f..f8da758 100644 --- a/src/MAVN.Service.AdminManagement/Modules/DataLayerModule.cs +++ b/src/MAVN.Service.AdminManagement/Modules/DataLayerModule.cs @@ -1,4 +1,4 @@ -using Autofac; +using Autofac; using JetBrains.Annotations; using Lykke.Common.MsSql; using MAVN.Service.AdminManagement.Domain.Repositories; @@ -6,6 +6,7 @@ using MAVN.Service.AdminManagement.MsSqlRepositories.Repositories; using MAVN.Service.AdminManagement.Settings; using Lykke.SettingsReader; +using System; namespace MAVN.Service.AdminManagement.Modules { @@ -13,10 +14,12 @@ namespace MAVN.Service.AdminManagement.Modules public class DataLayerModule : Module { private readonly DbSettings _settings; + private readonly TimeSpan _verificationLinkExpirePeriod; public DataLayerModule(IReloadingManager settings) { _settings = settings.CurrentValue.AdminManagementService.Db; + _verificationLinkExpirePeriod = settings.CurrentValue.AdminManagementService.AdminCreatedEmail.VerificationLinkExpirePeriod; } protected override void Load(ContainerBuilder builder) @@ -33,6 +36,11 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType() .As() .SingleInstance(); + + builder.RegisterType() + .WithParameter(TypedParameter.From(_verificationLinkExpirePeriod)) + .As() + .SingleInstance(); } } } diff --git a/src/MAVN.Service.AdminManagement/Modules/RabbitMqModule.cs b/src/MAVN.Service.AdminManagement/Modules/RabbitMqModule.cs index 347fae4..4b95fa5 100644 --- a/src/MAVN.Service.AdminManagement/Modules/RabbitMqModule.cs +++ b/src/MAVN.Service.AdminManagement/Modules/RabbitMqModule.cs @@ -1,4 +1,4 @@ -using System; +using System; using Autofac; using JetBrains.Annotations; using MAVN.Service.AdminManagement.Domain.Services; @@ -6,6 +6,7 @@ using MAVN.Service.AdminManagement.Settings; using Lykke.Service.NotificationSystem.SubscriberContract; using Lykke.SettingsReader; +using MAVN.Service.AdminManagement.Contract.Events; namespace MAVN.Service.AdminManagement.Modules { @@ -15,6 +16,7 @@ public class RabbitMqModule : Module private readonly IReloadingManager _appSettings; private const string NotificationSystemEmailExchangeName = "lykke.notificationsystem.command.emailmessage"; + private const string AdminExchangeName = "lykke.admin.emailcodeverified"; public RabbitMqModule(IReloadingManager appSettings) { @@ -33,6 +35,13 @@ protected override void Load(ContainerBuilder builder) .SingleInstance() .WithParameter("connectionString", rabbitMqSettings.RabbitMqConnectionString) .WithParameter("exchangeName", NotificationSystemEmailExchangeName); + + builder.RegisterType>() + .As>() + .As() + .SingleInstance() + .WithParameter("connectionString", rabbitMqSettings.RabbitMqConnectionString) + .WithParameter("exchangeName", AdminExchangeName); } } -} \ No newline at end of file +} diff --git a/src/MAVN.Service.AdminManagement/Modules/ServiceModule.cs b/src/MAVN.Service.AdminManagement/Modules/ServiceModule.cs index f420348..83853be 100644 --- a/src/MAVN.Service.AdminManagement/Modules/ServiceModule.cs +++ b/src/MAVN.Service.AdminManagement/Modules/ServiceModule.cs @@ -1,4 +1,4 @@ -using Autofac; +using Autofac; using JetBrains.Annotations; using Lykke.Sdk; using Lykke.Service.Credentials.Client; @@ -6,10 +6,11 @@ using MAVN.Service.AdminManagement.DomainServices; using MAVN.Service.AdminManagement.Managers; using MAVN.Service.AdminManagement.Settings; -using Lykke.Service.CustomerProfile.Client; +using MAVN.Service.CustomerProfile.Client; using Lykke.Service.Sessions.Client; using Lykke.SettingsReader; using StackExchange.Redis; +using MAVN.Service.AdminManagement.Domain.Models; namespace MAVN.Service.AdminManagement.Modules { @@ -59,11 +60,31 @@ protected override void Load(ContainerBuilder builder) .WithParameter("ttl", _appSettings.CurrentValue.AdminManagementService.Redis.Ttl) .SingleInstance(); + #region Verification + + builder.RegisterType() + .As() + .SingleInstance(); + + var callRateLimitSettingsDto = new CallRateLimitSettingsDto + { + EmailVerificationCallsMonitoredPeriod = _appSettings.CurrentValue.AdminManagementService.LimitationSettings.EmailVerificationCallsMonitoredPeriod, + EmailVerificationMaxAllowedRequestsNumber = _appSettings.CurrentValue.AdminManagementService.LimitationSettings.EmailVerificationMaxAllowedRequestsNumber, + }; + + builder.RegisterType() + .As() + .WithParameter(TypedParameter.From(callRateLimitSettingsDto)) + .WithParameter(TypedParameter.From(_appSettings.CurrentValue.AdminManagementService.Redis.InstanceName)); + + #endregion + builder.RegisterType() .As() .WithParameter("backOfficeUrl", _appSettings.CurrentValue.AdminManagementService.BackOfficeLink) .WithParameter("adminCreatedEmailTemplateId", _appSettings.CurrentValue.AdminManagementService.AdminCreatedEmail.EmailTemplateId) .WithParameter("adminCreatedEmailSubjectTemplateId", _appSettings.CurrentValue.AdminManagementService.AdminCreatedEmail.SubjectTemplateId) + .WithParameter("adminCreatedVerificationLinkPath", _appSettings.CurrentValue.AdminManagementService.AdminCreatedEmail.VerificationLinkPath) .WithParameter("adminPasswordResetEmailTemplateId", _appSettings.CurrentValue.AdminManagementService.PasswordResetEmail.EmailTemplateId) .WithParameter("adminPasswordResetEmailSubjectTemplateId", _appSettings.CurrentValue.AdminManagementService.PasswordResetEmail.SubjectTemplateId) .SingleInstance(); diff --git a/src/MAVN.Service.AdminManagement/Settings/AdminCreatedEmail.cs b/src/MAVN.Service.AdminManagement/Settings/AdminCreatedEmail.cs index b6a9445..cb3ac84 100644 --- a/src/MAVN.Service.AdminManagement/Settings/AdminCreatedEmail.cs +++ b/src/MAVN.Service.AdminManagement/Settings/AdminCreatedEmail.cs @@ -1,3 +1,4 @@ +using System; using JetBrains.Annotations; namespace MAVN.Service.AdminManagement.Settings @@ -7,5 +8,7 @@ public class AdminCreatedEmail { public string EmailTemplateId { set; get; } public string SubjectTemplateId { set; get; } + public TimeSpan VerificationLinkExpirePeriod { get; set; } + public string VerificationLinkPath { set; get; } } -} \ No newline at end of file +} diff --git a/src/MAVN.Service.AdminManagement/Settings/AdminManagementSettings.cs b/src/MAVN.Service.AdminManagement/Settings/AdminManagementSettings.cs index 127ab64..243c5f0 100644 --- a/src/MAVN.Service.AdminManagement/Settings/AdminManagementSettings.cs +++ b/src/MAVN.Service.AdminManagement/Settings/AdminManagementSettings.cs @@ -1,4 +1,4 @@ -using JetBrains.Annotations; +using JetBrains.Annotations; using Lykke.SettingsReader.Attributes; namespace MAVN.Service.AdminManagement.Settings @@ -15,5 +15,6 @@ public class AdminManagementSettings public RabbitMqSettings RabbitMq { get; set; } public RedisSettings Redis { set; get; } + public LimitationSettings LimitationSettings { get; set; } } } diff --git a/src/MAVN.Service.AdminManagement/Settings/AppSettings.cs b/src/MAVN.Service.AdminManagement/Settings/AppSettings.cs index 449f99a..e7723bb 100644 --- a/src/MAVN.Service.AdminManagement/Settings/AppSettings.cs +++ b/src/MAVN.Service.AdminManagement/Settings/AppSettings.cs @@ -1,7 +1,7 @@ using JetBrains.Annotations; using Lykke.Sdk.Settings; using Lykke.Service.Credentials.Client; -using Lykke.Service.CustomerProfile.Client; +using MAVN.Service.CustomerProfile.Client; using Lykke.Service.Sessions.Client; namespace MAVN.Service.AdminManagement.Settings diff --git a/src/MAVN.Service.AdminManagement/Settings/LimitationSettings.cs b/src/MAVN.Service.AdminManagement/Settings/LimitationSettings.cs new file mode 100644 index 0000000..75fdc3d --- /dev/null +++ b/src/MAVN.Service.AdminManagement/Settings/LimitationSettings.cs @@ -0,0 +1,10 @@ +using System; + +namespace MAVN.Service.AdminManagement.Settings +{ + public class LimitationSettings + { + public int EmailVerificationMaxAllowedRequestsNumber { get; set; } + public TimeSpan EmailVerificationCallsMonitoredPeriod { get; set; } + } +} diff --git a/tests/MAVN.Service.AdminManagement.DomainServices.Tests/AdminUsersServiceTests.cs b/tests/MAVN.Service.AdminManagement.DomainServices.Tests/AdminUsersServiceTests.cs index 3687f44..3e45300 100644 --- a/tests/MAVN.Service.AdminManagement.DomainServices.Tests/AdminUsersServiceTests.cs +++ b/tests/MAVN.Service.AdminManagement.DomainServices.Tests/AdminUsersServiceTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading.Tasks; using Falcon.Common; @@ -10,17 +10,21 @@ using Lykke.Service.Credentials.Client; using Lykke.Service.Credentials.Client.Models.Requests; using Lykke.Service.Credentials.Client.Models.Responses; -using Lykke.Service.CustomerProfile.Client; -using Lykke.Service.CustomerProfile.Client.Models.Enums; -using Lykke.Service.CustomerProfile.Client.Models.Requests; -using Lykke.Service.CustomerProfile.Client.Models.Responses; +using MAVN.Service.CustomerProfile.Client; +using MAVN.Service.CustomerProfile.Client.Models.Enums; +using MAVN.Service.CustomerProfile.Client.Models.Requests; +using MAVN.Service.CustomerProfile.Client.Models.Responses; using Moq; using Xunit; +using AutoMapper; namespace MAVN.Service.AdminManagement.DomainServices.Tests { public class AdminUsersServiceTests { + private readonly Mock _emailVerificationCodeRepositoryMock = + new Mock(); + private readonly Mock _adminUsersRepositoryMock = new Mock(); @@ -63,12 +67,17 @@ public AdminUsersServiceTests() ErrorCode = AdminProfileErrorCodes.None }); + var mockMapper = new MapperConfiguration(cfg => { cfg.AddProfile(new AutoMapperProfile()); }); + var mapper = mockMapper.CreateMapper(); + _service = new AdminUserService( _adminUsersRepositoryMock.Object, _credentialsClientMock.Object, _customerProfileClientMock.Object, + _emailVerificationCodeRepositoryMock.Object, _permissionsServiceMock.Object, EmptyLogFactory.Instance, + mapper, _notificationsServiceMock.Object, _permissionsCacheMock.Object); } @@ -78,16 +87,7 @@ public async Task Register_New_Admin_And_Return_Identifier() { // act - var result = await _service.RegisterAsync( - "email@email.com", - "password", - "first name", - "last name", - "phone_number", - "company", - "department", - "jobTitle", - new List()); + var result = await _service.RegisterAsync(GetRegisterRequest()); // assert @@ -102,18 +102,13 @@ public async Task Create_Credentials_While_Registering_New_Admin() const string email = "email@email.com"; const string password = "password"; + var model = GetRegisterRequest(); + model.Email = email; + model.Password = password; + // act - await _service.RegisterAsync( - email, - password, - "first name", - "last name", - "phone_number", - "company", - "department", - "jobTitle", - new List()); + await _service.RegisterAsync(model); // assert @@ -129,18 +124,12 @@ public async Task Save_Encrypted_Data_While_Registering_New_Admin() const string email = "email@email.com"; string emailHash = new Sha256HashingUtil().Sha256HashEncoding1252(email); + var model = GetRegisterRequest(); + model.Email = email; + // act - await _service.RegisterAsync( - email, - "password", - "first name", - "last name", - "phone_number", - "company", - "department", - "jobTitle", - new List()); + await _service.RegisterAsync(model); // assert @@ -157,18 +146,14 @@ public async Task Create_Profile_While_Registering_New_Admin() const string firstName = "first name"; const string lastName = "last name"; + var model = GetRegisterRequest(); + model.Email = email; + model.FirstName = firstName; + model.LastName = lastName; + // act - await _service.RegisterAsync( - email, - "password", - firstName, - lastName, - "phone_number", - "company", - "department", - "jobTitle", - new List()); + await _service.RegisterAsync(model); // assert @@ -186,20 +171,28 @@ public async Task Return_Error_Login_Already_Exists_While_Registering_Admin_With // act - var result = await _service.RegisterAsync( - "email@email.com", - "password", - "first name", - "last name", - "phone_number", - "company", - "department", - "jobTitle", - new List()); + var result = await _service.RegisterAsync(GetRegisterRequest()); // assert Assert.Equal(ServicesError.AlreadyRegistered, result.Error); } + + private RegistrationRequestDto GetRegisterRequest() + { + return new RegistrationRequestDto + { + Email = "email@email.com", + Password = "password", + FirstName = "first name", + LastName = "last name", + PhoneNumber = "phone_number", + Company = "company", + Department = "department", + JobTitle = "jobTitle", + Permissions = new List(), + Localization = Localization.En + }; + } } } diff --git a/tests/MAVN.Service.AdminManagement.DomainServices.Tests/EmailVerificationServiceTests.cs b/tests/MAVN.Service.AdminManagement.DomainServices.Tests/EmailVerificationServiceTests.cs new file mode 100644 index 0000000..6e86100 --- /dev/null +++ b/tests/MAVN.Service.AdminManagement.DomainServices.Tests/EmailVerificationServiceTests.cs @@ -0,0 +1,254 @@ +using System; +using System.Threading.Tasks; +using Common; +using Lykke.Logs; +using Lykke.Logs.Loggers.LykkeConsole; +using MAVN.Service.AdminManagement.Contract.Events; +using MAVN.Service.AdminManagement.Domain.Enums; +using MAVN.Service.AdminManagement.Domain.Models.Verification; +using MAVN.Service.AdminManagement.Domain.Repositories; +using MAVN.Service.AdminManagement.Domain.Services; +using Moq; +using Xunit; + +namespace MAVN.Service.AdminManagement.DomainServices.Tests +{ + public class EmailVerificationServiceTests + { + [Fact] + public async Task UserTriesToConfirmEmail_WithNewAdmin_Successfully() + { + var verificationEmailRepository = new Mock(); + + var verificationEmailGetResponse = GetMockedVerificationCode(); + + var confirmEmailResponse = new ConfirmVerificationCodeResultModel + { + Error = VerificationCodeError.None, + IsVerified = true + }; + + verificationEmailRepository + .Setup(x => x.GetByValueAsync(It.IsAny())) + .ReturnsAsync(verificationEmailGetResponse.Object); + + verificationEmailRepository + .Setup(x => x.CreateOrUpdateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IVerificationCode)null); + + var publisherCodeVerified = new Mock>(); + + EmailVerificationService emailVerificationService; + using (var logFactory = LogFactory.Create().AddUnbufferedConsole()) + { + emailVerificationService = new EmailVerificationService( + verificationEmailRepository.Object, + publisherCodeVerified.Object, + logFactory + ); + } + + var result = await emailVerificationService.ConfirmCodeAsync("DDD666".ToBase64()); + + Assert.Equal(confirmEmailResponse.Error.ToString(), result.Error.ToString()); + Assert.True(result.IsVerified); + } + + [Fact] + public async Task UserTriesToConfirmEmail_WithAdminThatDoesNotExists_AdminNotExistingReturned() + { + var verificationEmailRepository = new Mock(); + + var confirmEmailResponse = new ConfirmVerificationCodeResultModel + { + Error = VerificationCodeError.VerificationCodeDoesNotExist, + IsVerified = true + }; + + verificationEmailRepository + .Setup(x => x.GetByValueAsync(It.IsAny())) + .ReturnsAsync(null as IVerificationCode); + + verificationEmailRepository + .Setup(x => x.CreateOrUpdateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IVerificationCode)null); + + var publisherCodeVerified = new Mock>(); + + EmailVerificationService emailVerificationService; + using (var logFactory = LogFactory.Create().AddUnbufferedConsole()) + { + emailVerificationService = new EmailVerificationService( + verificationEmailRepository.Object, + publisherCodeVerified.Object, + logFactory + ); + } + + var result = await emailVerificationService.ConfirmCodeAsync("DDD666".ToBase64()); + + Assert.Equal(confirmEmailResponse.Error.ToString(), result.Error.ToString()); + Assert.False(result.IsVerified); + } + + [Fact] + public async Task UserTriesToConfirmEmail_WithVerificationCodeThatNotExistsInTheStorage_VerificationCodeMismatchReturned() + { + var verificationEmailRepository = new Mock(); + + var verificationEmailGetResponse = GetMockedVerificationCode(); + + verificationEmailRepository + .Setup(x => x.GetByValueAsync(It.IsAny())) + .ReturnsAsync(verificationEmailGetResponse.Object); + + verificationEmailRepository + .Setup(x => x.CreateOrUpdateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IVerificationCode)null); + + var publisherCodeVerified = new Mock>(); + + EmailVerificationService emailVerificationService; + using (var logFactory = LogFactory.Create().AddUnbufferedConsole()) + { + emailVerificationService = new EmailVerificationService( + verificationEmailRepository.Object, + publisherCodeVerified.Object, + logFactory + ); + } + + var result = await emailVerificationService.ConfirmCodeAsync("123456".ToBase64()); + + Assert.Equal(VerificationCodeError.VerificationCodeMismatch.ToString(), result.Error.ToString()); + Assert.False(result.IsVerified); + } + + [Fact] + public async Task UserTriesToConfirmEmail_WithVerificationCodeThatHasAlreadyExpired_VerificationCodeExpiredReturned() + { + var verificationEmailRepository = new Mock(); + + var verificationEmailGetResponse = GetMockedVerificationCode(); + + verificationEmailGetResponse.SetupProperty(_ => _.ExpireDate, DateTime.UtcNow.AddYears(-1000)); + + var confirmEmailResponse = new ConfirmVerificationCodeResultModel + { + Error = VerificationCodeError.VerificationCodeExpired, + IsVerified = true + }; + + verificationEmailRepository + .Setup(x => x.GetByValueAsync(It.IsAny())) + .ReturnsAsync(verificationEmailGetResponse.Object); + + verificationEmailRepository + .Setup(x => x.CreateOrUpdateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IVerificationCode)null); + + var publisherCodeVerified = new Mock>(); + + EmailVerificationService emailVerificationService; + using (var logFactory = LogFactory.Create().AddUnbufferedConsole()) + { + emailVerificationService = new EmailVerificationService( + verificationEmailRepository.Object, + publisherCodeVerified.Object, + logFactory + ); + } + + var result = await emailVerificationService.ConfirmCodeAsync("DDD666".ToBase64()); + + Assert.Equal(confirmEmailResponse.Error.ToString(), result.Error.ToString()); + Assert.False(result.IsVerified); + } + + [Fact] + public async Task UserTriesToConfirmEmail_WithAdminThatIsAlreadyVerified_AlreadyVerifiedReturned() + { + var verificationEmailRepository = new Mock(); + + var verificationEmailGetResponse = GetMockedVerificationCode(); + + verificationEmailGetResponse.SetupProperty(_ => _.IsVerified, true); + + verificationEmailRepository + .Setup(x => x.GetByValueAsync(It.IsAny())) + .ReturnsAsync(verificationEmailGetResponse.Object); + + verificationEmailRepository + .Setup(x => x.CreateOrUpdateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IVerificationCode)null); + + var publisherCodeVerified = new Mock>(); + + EmailVerificationService emailVerificationService; + using (var logFactory = LogFactory.Create().AddUnbufferedConsole()) + { + emailVerificationService = new EmailVerificationService( + verificationEmailRepository.Object, + publisherCodeVerified.Object, + logFactory + ); + } + + var result = await emailVerificationService.ConfirmCodeAsync("DDD666".ToBase64()); + + Assert.Equal(VerificationCodeError.AlreadyVerified.ToString(), result.Error.ToString()); + Assert.True(result.IsVerified); + } + + [Fact] + public async Task UserTriesToConfirmEmail_WithVerificationCodeThatNotExistsInTheSrorage_VerificationCodeMismatchReturned() + { + var verificationEmailRepository = new Mock(); + + var verificationEmailGetResponse = GetMockedVerificationCode(); + + var confirmEmailResponse = new ConfirmVerificationCodeResultModel + { + Error = VerificationCodeError.VerificationCodeMismatch, + IsVerified = true + }; + + verificationEmailRepository + .Setup(x => x.GetByValueAsync(It.IsAny())) + .ReturnsAsync(verificationEmailGetResponse.Object); + + verificationEmailRepository + .Setup(x => x.CreateOrUpdateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(verificationEmailGetResponse.Object); + + var publisherCodeVerified = new Mock>(); + + EmailVerificationService emailVerificationService; + using (var logFactory = LogFactory.Create().AddUnbufferedConsole()) + { + emailVerificationService = new EmailVerificationService( + verificationEmailRepository.Object, + publisherCodeVerified.Object, + logFactory + ); + } + + var result = + await emailVerificationService.ConfirmCodeAsync("123456".ToBase64()); + + Assert.Equal(confirmEmailResponse.Error.ToString(), result.Error.ToString()); + Assert.False(result.IsVerified); + } + + private Mock GetMockedVerificationCode() + { + var verificationEmailGetResponse = new Mock(); + verificationEmailGetResponse.SetupProperty(_ => _.AdminId, "70fb9648-f482-4c29-901b-25fe6febd8af"); + verificationEmailGetResponse.SetupProperty(_ => _.ExpireDate, DateTime.UtcNow.AddYears(1000)); + verificationEmailGetResponse.SetupProperty(_ => _.VerificationCode, "DDD666"); + verificationEmailGetResponse.SetupProperty(_ => _.IsVerified, false); + + return verificationEmailGetResponse; + } + } +} diff --git a/tests/MAVN.Service.AdminManagement.DomainServices.Tests/MAVN.Service.AdminManagement.DomainServices.Tests.csproj b/tests/MAVN.Service.AdminManagement.DomainServices.Tests/MAVN.Service.AdminManagement.DomainServices.Tests.csproj index a560369..72b93ec 100644 --- a/tests/MAVN.Service.AdminManagement.DomainServices.Tests/MAVN.Service.AdminManagement.DomainServices.Tests.csproj +++ b/tests/MAVN.Service.AdminManagement.DomainServices.Tests/MAVN.Service.AdminManagement.DomainServices.Tests.csproj @@ -13,5 +13,6 @@ +