From 09cdbbc25d4c5d1c4cc2cdf75ab106e06305e828 Mon Sep 17 00:00:00 2001 From: vc-ci Date: Mon, 31 Jul 2023 07:11:22 +0000 Subject: [PATCH 1/2] 6.26.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 31dc2ff2..f56b3e53 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ VirtoCommerce - 6.25.0 + 6.26.0 $(VersionSuffix)-$(BuildNumber) From 3f406663d65f44647568dd53a85aae0277e724f8 Mon Sep 17 00:00:00 2001 From: Oleg Zhuk Date: Mon, 28 Aug 2023 17:55:23 +0200 Subject: [PATCH 2/2] PT-13083: Refactored and improved error codes for the locked user (#652) * feat: Refactored and improved Divide error codes for the locked user --- .../Security/SecurityErrorDescriber.cs | 55 ++++++++++---- ...yRequiredEmailVerificationSpecification.cs | 12 +++ .../IsUserTemporaryLockedOutSpecification.cs | 13 ++++ .../Security/User.cs | 7 ++ .../Controllers/Api/ApiAccountController.cs | 75 +++++++++++-------- .../Domain/Security/SecurityConverter.cs | 6 +- .../Properties/launchSettings.json | 18 ++--- VirtoCommerce.Storefront/appsettings.json | 4 +- 8 files changed, 132 insertions(+), 58 deletions(-) create mode 100644 VirtoCommerce.Storefront.Model/Security/Specifications/IsUserLockedByRequiredEmailVerificationSpecification.cs create mode 100644 VirtoCommerce.Storefront.Model/Security/Specifications/IsUserTemporaryLockedOutSpecification.cs diff --git a/VirtoCommerce.Storefront.Model/Security/SecurityErrorDescriber.cs b/VirtoCommerce.Storefront.Model/Security/SecurityErrorDescriber.cs index ed810e7b..a4cca1af 100644 --- a/VirtoCommerce.Storefront.Model/Security/SecurityErrorDescriber.cs +++ b/VirtoCommerce.Storefront.Model/Security/SecurityErrorDescriber.cs @@ -9,7 +9,7 @@ public static FormError UsernameOrEmailIsRequired() return new FormError { Code = nameof(UsernameOrEmailIsRequired).PascalToKebabCase(), - Description = "Username or email is required" + Description = "Please provide a username or email" }; } public static FormError LoginFailed() @@ -17,7 +17,7 @@ public static FormError LoginFailed() return new FormError { Code = nameof(LoginFailed).PascalToKebabCase(), - Description = "Login attempt failed" + Description = "Login attempt failed. Please check your credentials." }; } @@ -26,7 +26,7 @@ public static FormError UserNotFound() return new FormError { Code = nameof(UserNotFound).PascalToKebabCase(), - Description = "User not found" + Description = "User not found. Please ensure you've entered the correct information." }; } public static FormError UserCannotLoginInStore() @@ -34,7 +34,7 @@ public static FormError UserCannotLoginInStore() return new FormError { Code = nameof(UserCannotLoginInStore).PascalToKebabCase(), - Description = "User cannot login to current store" + Description = "Access denied. You cannot sign in to the current store" }; } @@ -43,7 +43,7 @@ public static FormError PhoneNumberNotFound() return new FormError { Code = nameof(PhoneNumberNotFound).PascalToKebabCase(), - Description = "Reset password by code is Failed. Phone Number not found" + Description = "Password reset failed. Phone number not found for verification." }; } @@ -52,7 +52,16 @@ public static FormError AccountIsBlocked() return new FormError { Code = nameof(AccountIsBlocked).PascalToKebabCase(), - Description = "Account is blocked" + Description = "Your account has been blocked. Please contact support for assistance." + }; + } + + public static FormError EmailVerificationIsRequired() + { + return new FormError + { + Code = nameof(EmailVerificationIsRequired).PascalToKebabCase(), + Description = "Email verification required. Please verify your email address." }; } @@ -61,7 +70,7 @@ public static FormError OperationFailed() return new FormError { Code = nameof(OperationFailed).PascalToKebabCase(), - Description = "Operation failed" + Description = "Oops, something went wrong. The operation could not be completed." }; } @@ -70,7 +79,7 @@ public static FormError ResetPasswordIsTurnedOff() return new FormError { Code = nameof(ResetPasswordIsTurnedOff).PascalToKebabCase(), - Description = "Reset password by code is turned off" + Description = "Password reset by code is currently unavailable." }; } @@ -79,7 +88,7 @@ public static FormError InvalidToken() return new FormError { Code = nameof(InvalidToken).PascalToKebabCase(), - Description = "Token is invalid or expired" + Description = "Sorry, the token is invalid or has expired. Please request a new one." }; } public static FormError InvalidUrl() @@ -87,7 +96,7 @@ public static FormError InvalidUrl() return new FormError { Code = nameof(InvalidUrl).PascalToKebabCase(), - Description = "Url is invalid" + Description = "The URL you provided is not valid." }; } public static FormError ResetPasswordInvalidData() @@ -95,7 +104,7 @@ public static FormError ResetPasswordInvalidData() return new FormError { Code = nameof(ResetPasswordInvalidData).PascalToKebabCase(), - Description = "Reset password data is invalid" + Description = "Password reset data is invalid. Please try again." }; } public static FormError PasswordAndConfirmPasswordDoesNotMatch() @@ -103,7 +112,7 @@ public static FormError PasswordAndConfirmPasswordDoesNotMatch() return new FormError { Code = nameof(PasswordAndConfirmPasswordDoesNotMatch).PascalToKebabCase(), - Description = "Password and Confirm password doesn't match" + Description = "Passwords don't match. Please ensure both passwords are the same." }; } public static FormError InvitationHasAreadyBeenUsed() @@ -111,7 +120,7 @@ public static FormError InvitationHasAreadyBeenUsed() return new FormError { Code = nameof(InvitationHasAreadyBeenUsed).PascalToKebabCase(), - Description = "Invitation has already been used" + Description = "This invitation has already been used. Please contact the sender if you need assistance." }; } public static FormError PhoneNumberVerificationFailed() @@ -119,7 +128,7 @@ public static FormError PhoneNumberVerificationFailed() return new FormError { Code = nameof(PhoneNumberVerificationFailed).PascalToKebabCase(), - Description = "Phone number verification failed" + Description = "Phone number verification failed. Please try again or contact support." }; } public static FormError ErrorSendNotification(string error) @@ -135,7 +144,23 @@ public static FormError UserIsLockedOut() return new FormError { Code = nameof(UserIsLockedOut).PascalToKebabCase(), - Description = "User is locked out" + Description = "Your account has been locked. Please contact support for assistance." + }; + } + public static FormError UserIsTemporaryLockedOut() + { + return new FormError + { + Code = nameof(UserIsLockedOut).PascalToKebabCase(), + Description = "Your account has been temporarily locked. Please try again after some time." + }; + } + public static FormError PasswordExpired() + { + return new FormError + { + Code = nameof(PasswordExpired).PascalToKebabCase(), + Description = "Your password has been expired and must be changed." }; } diff --git a/VirtoCommerce.Storefront.Model/Security/Specifications/IsUserLockedByRequiredEmailVerificationSpecification.cs b/VirtoCommerce.Storefront.Model/Security/Specifications/IsUserLockedByRequiredEmailVerificationSpecification.cs new file mode 100644 index 00000000..ff73033d --- /dev/null +++ b/VirtoCommerce.Storefront.Model/Security/Specifications/IsUserLockedByRequiredEmailVerificationSpecification.cs @@ -0,0 +1,12 @@ +using VirtoCommerce.Storefront.Model.Common.Specifications; + +namespace VirtoCommerce.Storefront.Model.Security.Specifications +{ + public class IsUserLockedByRequiredEmailVerificationSpecification : ISpecification + { + public virtual bool IsSatisfiedBy(User user) + { + return user.Contact.Status == "Locked" && !user.EmailConfirmed; + } + } +} diff --git a/VirtoCommerce.Storefront.Model/Security/Specifications/IsUserTemporaryLockedOutSpecification.cs b/VirtoCommerce.Storefront.Model/Security/Specifications/IsUserTemporaryLockedOutSpecification.cs new file mode 100644 index 00000000..b0af5871 --- /dev/null +++ b/VirtoCommerce.Storefront.Model/Security/Specifications/IsUserTemporaryLockedOutSpecification.cs @@ -0,0 +1,13 @@ +using System; +using VirtoCommerce.Storefront.Model.Common.Specifications; + +namespace VirtoCommerce.Storefront.Model.Security.Specifications +{ + public class IsUserTemporaryLockedOutSpecification : ISpecification + { + public virtual bool IsSatisfiedBy(User user) + { + return user.LockoutEndDateUtc != DateTime.MaxValue.ToUniversalTime(); + } + } +} diff --git a/VirtoCommerce.Storefront.Model/Security/User.cs b/VirtoCommerce.Storefront.Model/Security/User.cs index 04b0eb02..1559e8f3 100644 --- a/VirtoCommerce.Storefront.Model/Security/User.cs +++ b/VirtoCommerce.Storefront.Model/Security/User.cs @@ -80,7 +80,14 @@ public bool IsLockedOut /// The flag indicates that the user is an administrator /// public bool IsAdministrator { get; set; } + public string UserType { get; set; } + + /// + /// Represents user status. + /// + public string Status { get; set; } + public AccountState UserState { get; set; } /// /// The user ID of an operator who has loggen in on behalf of a customer diff --git a/VirtoCommerce.Storefront/Controllers/Api/ApiAccountController.cs b/VirtoCommerce.Storefront/Controllers/Api/ApiAccountController.cs index e2e3c507..0fc22567 100644 --- a/VirtoCommerce.Storefront/Controllers/Api/ApiAccountController.cs +++ b/VirtoCommerce.Storefront/Controllers/Api/ApiAccountController.cs @@ -88,59 +88,74 @@ public async Task> Login([FromBody] Login return UserActionIdentityResult.Failed(SecurityErrorDescriber.UsernameOrEmailIsRequired()); } - var result = CheckLoginUser(user); - - if (result != UserActionIdentityResult.Success) + if (user == null) { - return result; + return UserActionIdentityResult.Failed(SecurityErrorDescriber.LoginFailed()); } var loginResult = await _signInManager.PasswordSignInAsync(user.UserName, login.Password, login.RememberMe, lockoutOnFailure: true); if (!loginResult.Succeeded) { - result = UserActionIdentityResult.Failed(SecurityErrorDescriber.LoginFailed()); + if (loginResult.IsLockedOut) + { + if (new IsUserLockedByRequiredEmailVerificationSpecification().IsSatisfiedBy(user)) + { + return UserActionIdentityResult.Failed(SecurityErrorDescriber.EmailVerificationIsRequired()); + } + if (new IsUserTemporaryLockedOutSpecification().IsSatisfiedBy(user)) + { + return UserActionIdentityResult.Failed(SecurityErrorDescriber.UserIsTemporaryLockedOut()); + } + + return UserActionIdentityResult.Failed(SecurityErrorDescriber.UserIsLockedOut()); + } + + return UserActionIdentityResult.Failed(SecurityErrorDescriber.LoginFailed()); } else { - await SetLastLoginDate(user); - await _publisher.Publish(new UserLoginEvent(WorkContext, user)); - if (string.IsNullOrEmpty(returnUrl)) + if (user.Contact == null) { - return result; + ResetIdentityCookies(); + + return UserActionIdentityResult.Failed(SecurityErrorDescriber.UserNotFound()); } - var newUrl = Url.IsLocalUrl(returnUrl) ? returnUrl : "~/"; - result.ReturnUrl = UrlBuilder.ToAppRelative(newUrl, WorkContext.CurrentStore, WorkContext.CurrentLanguage); - } - return result; - } + if (new IsUserPasswordExpiredSpecification().IsSatisfiedBy(user)) + { + ResetIdentityCookies(); - private UserActionIdentityResult CheckLoginUser(User user) - { - if (user == null) - { - return UserActionIdentityResult.Failed(SecurityErrorDescriber.LoginFailed()); - } + return UserActionIdentityResult.Failed(SecurityErrorDescriber.PasswordExpired()); + } - if (!new CanUserLoginToStoreSpecification(user).IsSatisfiedBy(WorkContext.CurrentStore)) - { - return UserActionIdentityResult.Failed(SecurityErrorDescriber.UserCannotLoginInStore()); - } + if (!new CanUserLoginToStoreSpecification(user).IsSatisfiedBy(WorkContext.CurrentStore)) + { + ResetIdentityCookies(); + + return UserActionIdentityResult.Failed(SecurityErrorDescriber.UserCannotLoginInStore()); + } - if (new IsUserLockedOutSpecification().IsSatisfiedBy(user)) - { - return UserActionIdentityResult.Failed(SecurityErrorDescriber.UserIsLockedOut()); } - if (new IsUserSuspendedSpecification().IsSatisfiedBy(user)) + await SetLastLoginDate(user); + await _publisher.Publish(new UserLoginEvent(WorkContext, user)); + + var result = UserActionIdentityResult.Success; + + if (!string.IsNullOrEmpty(returnUrl)) { - return UserActionIdentityResult.Failed(SecurityErrorDescriber.AccountIsBlocked()); + var newUrl = Url.IsLocalUrl(returnUrl) ? returnUrl : "~/"; + result.ReturnUrl = UrlBuilder.ToAppRelative(newUrl, WorkContext.CurrentStore, WorkContext.CurrentLanguage); } - return UserActionIdentityResult.Success; + return result; } + private void ResetIdentityCookies() + { + Response.Cookies.Delete(".AspNetCore.Identity.Application"); + } // POST: storefrontapi/account/user [HttpPost("user")] diff --git a/VirtoCommerce.Storefront/Domain/Security/SecurityConverter.cs b/VirtoCommerce.Storefront/Domain/Security/SecurityConverter.cs index 1277eb0f..61e243dd 100644 --- a/VirtoCommerce.Storefront/Domain/Security/SecurityConverter.cs +++ b/VirtoCommerce.Storefront/Domain/Security/SecurityConverter.cs @@ -24,7 +24,7 @@ public static dto.Role ToRoleDto(this Role role) { Id = role.Id, Name = role.Name, - Permissions = role?.Permissions.Select(x => new dto.Permission { Id = x, Name = x }).ToList() + Permissions = role.Permissions.Select(x => new dto.Permission { Id = x, Name = x }).ToList() }; } public static Role ToRole(this dto.Role roleDto) @@ -33,7 +33,7 @@ public static Role ToRole(this dto.Role roleDto) { Id = roleDto.Id, Name = roleDto.Name, - Permissions = roleDto?.Permissions.Select(x => x.Id).ToList() + Permissions = roleDto.Permissions.Select(x => x.Id).ToList() }; } @@ -97,6 +97,7 @@ public static User ToUser(this dto.ApplicationUser userDto) UserType = userDto.UserType, TwoFactorEnabled = userDto.TwoFactorEnabled ?? false, PhoneNumberConfirmed = userDto.PhoneNumberConfirmed ?? false, + Status = userDto.Status, }; if (!userDto.Roles.IsNullOrEmpty()) @@ -144,6 +145,7 @@ public static dto.ApplicationUser ToUserDto(this User user) PhoneNumber = user.PhoneNumber, PhoneNumberConfirmed = user.PhoneNumberConfirmed, PasswordExpired = user.PasswordExpired, + Status = user.Status }; if (!user.Roles.IsNullOrEmpty()) diff --git a/VirtoCommerce.Storefront/Properties/launchSettings.json b/VirtoCommerce.Storefront/Properties/launchSettings.json index d0d5938e..d651ad76 100644 --- a/VirtoCommerce.Storefront/Properties/launchSettings.json +++ b/VirtoCommerce.Storefront/Properties/launchSettings.json @@ -1,12 +1,4 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:2082/", - "sslPort": 0 - } - }, "profiles": { "IIS Express": { "commandName": "IISExpress", @@ -22,7 +14,15 @@ "ASPNETCORE_preventHostingStartup": "True", "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "http://localhost:2083/" + "applicationUrl": "https://localhost:4302/" + } + }, + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:2082/", + "sslPort": 0 } } } \ No newline at end of file diff --git a/VirtoCommerce.Storefront/appsettings.json b/VirtoCommerce.Storefront/appsettings.json index 8cde13b1..82b2d60a 100644 --- a/VirtoCommerce.Storefront/appsettings.json +++ b/VirtoCommerce.Storefront/appsettings.json @@ -5,7 +5,7 @@ //"RedisConnectionString": "127.0.0.1:6379,ssl=False" }, "VirtoCommerce": { - "DefaultStore": "Electronics", + "DefaultStore": "B2B-store", "StoreUrls": { // Define mapping of concrete stores with public urls in this section //"{store id}":"{store public url}" @@ -22,7 +22,7 @@ }, "PageSizeMaxValue": 100, "Endpoint": { - "Url": "http://localhost:10645/", + "Url": "https://localhost:5001/", // Use AppId and SecretKey for Platform API authentication (has higher priority than UserName/Password) //"AppId": "27e0d789f12641049bd0e939185b4fd2", //"SecretKey": "34f0a3c12c9dbb59b63b5fece955b7b2b9a3b20f84370cba1524dd5c53503a2e2cb733536ecf7ea1e77319a47084a3a2c9d94d36069a432ecc73b72aeba6ea78",