diff --git a/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs b/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs index e0b2e1fcde..dd52f90719 100644 --- a/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs +++ b/modules/identityserver/src/Volo.Abp.IdentityServer.Domain/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator.cs @@ -41,6 +41,8 @@ public class AbpResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator protected ISettingProvider SettingProvider { get; } + protected IUnitOfWorkManager UnitOfWorkManager { get; } + public AbpResourceOwnerPasswordValidator( IdentityUserManager userManager, SignInManager signInManager, @@ -50,7 +52,8 @@ public class AbpResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator IOptions abpIdentityOptions, IServiceScopeFactory serviceScopeFactory, IOptions identityOptions, - ISettingProvider settingProvider) + ISettingProvider settingProvider, + IUnitOfWorkManager unitOfWorkManager) { UserManager = userManager; SignInManager = signInManager; @@ -61,6 +64,7 @@ public class AbpResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator AbpIdentityOptions = abpIdentityOptions.Value; IdentityOptions = identityOptions; SettingProvider = settingProvider; + UnitOfWorkManager = unitOfWorkManager; } /// @@ -197,7 +201,8 @@ public class AbpResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator } Logger.LogInformation("Authentication failed for username: {username}, reason: InvalidRecoveryCode", context.UserName); - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, Localizer["InvalidRecoveryCode"]); + await RollbackAndSetInvalidGrantResultAsync(context, Localizer["InvalidRecoveryCode"]); + return; } var twoFactorProvider = context.Request?.Raw?["TwoFactorProvider"]; @@ -211,7 +216,12 @@ public class AbpResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator return; } - await UserManager.AccessFailedAsync(user); + var accessFailedResult = await UserManager.AccessFailedAsync(user); + if (!accessFailedResult.Succeeded) + { + await RollbackAndSetInvalidGrantResultAsync(context, Localizer["InvalidUserNameOrPassword"]); + return; + } Logger.LogInformation("Authentication failed for username: {username}, reason: InvalidAuthenticatorCode", context.UserName); context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, Localizer["InvalidAuthenticatorCode"]); @@ -255,7 +265,16 @@ public class AbpResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator { if (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, changePasswordType.ToString(), changePasswordToken)) { - var changePasswordResult = await UserManager.ChangePasswordAsync(user, currentPassword, newPassword); + IdentityResult changePasswordResult; + try + { + changePasswordResult = await UserManager.ChangePasswordAsync(user, currentPassword, newPassword); + } + catch (AbpIdentityResultException exception) + { + changePasswordResult = exception.IdentityResult; + } + if (changePasswordResult.Succeeded) { await IdentitySecurityLogManager.SaveAsync(new IdentitySecurityLogContext @@ -271,13 +290,29 @@ public class AbpResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator user.SetShouldChangePasswordOnNextLogin(false); } - await UserManager.UpdateAsync(user); - await SetSuccessResultAsync(context, user); + var updateUserResult = await UserManager.UpdateAsync(user); + if (!updateUserResult.Succeeded) + { + await RollbackAndSetInvalidGrantResultAsync(context, Localizer["InvalidUserNameOrPassword"]); + return; + } + + if (await IsTfaEnabledAsync(user)) + { + await HandleTwoFactorLoginAsync(context, user); + } + else + { + await SetSuccessResultAsync(context, user); + } } else { Logger.LogInformation("ChangePassword failed for username: {username}, reason: {changePasswordResult}", context.UserName, changePasswordResult); - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, changePasswordResult.Errors.Select(x => x.Description).JoinAsString(", ")); + await RollbackAndSetInvalidGrantResultAsync( + context, + changePasswordResult.Errors.Select(x => x.Description).JoinAsString(", ")); + return; } } else @@ -289,7 +324,7 @@ public class AbpResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator else { Logger.LogInformation($"Authentication failed for username: {{{context.UserName}}}, reason: {{{changePasswordType.ToString()}}}"); - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, nameof(user.ShouldChangePasswordOnNextLogin), + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, changePasswordType.ToString(), new Dictionary() { {"userId", user.Id}, @@ -308,6 +343,13 @@ public class AbpResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator protected virtual async Task SetSuccessResultAsync(ResourceOwnerPasswordValidationContext context, IdentityUser user) { + var resetAccessFailedCountResult = await UserManager.ResetAccessFailedCountAsync(user); + if (!resetAccessFailedCountResult.Succeeded) + { + await RollbackAndSetInvalidGrantResultAsync(context, Localizer["InvalidUserNameOrPassword"]); + return; + } + var sub = await UserManager.GetUserIdAsync(user); Logger.LogInformation("Credentials validated for username: {username}", context.UserName); @@ -335,6 +377,17 @@ public class AbpResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator ); } + protected virtual async Task RollbackAndSetInvalidGrantResultAsync(ResourceOwnerPasswordValidationContext context, string errorDescription) + { + var currentUnitOfWork = UnitOfWorkManager.Current; + if (currentUnitOfWork != null) + { + await currentUnitOfWork.RollbackAsync(); + } + + context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, errorDescription); + } + protected virtual async Task ReplaceEmailToUsernameOfInputIfNeeds(ResourceOwnerPasswordValidationContext context) { if (!ValidationHelper.IsValidEmailAddress(context.UserName)) diff --git a/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo.Abp.IdentityServer.Domain.Tests.csproj b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo.Abp.IdentityServer.Domain.Tests.csproj index 341ae13bd3..78eee74de4 100644 --- a/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo.Abp.IdentityServer.Domain.Tests.csproj +++ b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo.Abp.IdentityServer.Domain.Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AbpIdentityServerDomainTestModule.cs b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AbpIdentityServerDomainTestModule.cs index 421b3dd076..5dec4cac87 100644 --- a/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AbpIdentityServerDomainTestModule.cs +++ b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AbpIdentityServerDomainTestModule.cs @@ -1,13 +1,36 @@ -using Volo.Abp.Modularity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Identity; +using Volo.Abp.Identity.AspNetCore; +using Volo.Abp.IdentityServer.AspNetIdentity; +using Volo.Abp.Modularity; using Volo.Abp.PermissionManagement.IdentityServer; +using Volo.Abp.SecurityLog; +using Volo.Abp.Settings; +using Volo.Abp.Uow; namespace Volo.Abp.IdentityServer; [DependsOn( + typeof(AbpIdentityAspNetCoreModule), typeof(AbpIdentityServerTestEntityFrameworkCoreModule), typeof(AbpPermissionManagementDomainIdentityServerModule) )] public class AbpIdentityServerDomainTestModule : AbpModule { + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.Replace(ServiceDescriptor.Scoped()); + context.Services.Replace(ServiceDescriptor.Singleton( + serviceProvider => serviceProvider.GetRequiredService())); + + Configure(options => options.IsEnabled = false); + Configure(options => + { + options.ValueProviders.Add(); + }); + } } diff --git a/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidatorPasswordChange_Tests.cs b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidatorPasswordChange_Tests.cs new file mode 100644 index 0000000000..8e8db23773 --- /dev/null +++ b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidatorPasswordChange_Tests.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Specialized; +using System.Threading.Tasks; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Identity; +using Shouldly; +using Volo.Abp.Identity.Settings; +using Xunit; + +namespace Volo.Abp.IdentityServer.AspNetIdentity; + +public class AbpResourceOwnerPasswordValidatorPasswordChange_Tests : AbpResourceOwnerPasswordValidatorTestBase +{ + [Fact] + public async Task Required_Password_Change_With_TwoFactor_Should_Require_Code_Before_Authenticating() + { + await CreateUserAsync( + twoFactorEnabled: true, + shouldChangePasswordOnNextLogin: true, + addFailedAccess: true); + + var challengeContext = CreateContext(); + await ValidateAsync(challengeContext); + var changePasswordToken = AssertPasswordChangeChallenge( + challengeContext, + "ShouldChangePasswordOnNextLogin"); + + var passwordChangeContext = CreateContext(new NameValueCollection + { + ["NewPassword"] = NewPassword, + ["ChangePasswordToken"] = changePasswordToken + }); + await ValidateAsync(passwordChangeContext); + + AssertRequiresTwoFactor(passwordChangeContext); + (await GetAccessFailedCountAsync()).ShouldBe(1); + + var code = await GenerateTwoFactorCodeAsync(); + var successContext = CreateContext(new NameValueCollection + { + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = code + }, NewPassword); + await ValidateAsync(successContext); + + successContext.Result.IsError.ShouldBeFalse(); + successContext.Result.Subject.ShouldNotBeNull(); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } + + [Fact] + public async Task Required_Password_Change_Without_TwoFactor_Should_Authenticate_And_Reset_Failed_Count() + { + await CreateUserAsync( + twoFactorEnabled: false, + shouldChangePasswordOnNextLogin: true, + addFailedAccess: true); + + var challengeContext = CreateContext(); + await ValidateAsync(challengeContext); + var changePasswordToken = AssertPasswordChangeChallenge( + challengeContext, + "ShouldChangePasswordOnNextLogin"); + + var successContext = CreateContext(new NameValueCollection + { + ["NewPassword"] = NewPassword, + ["ChangePasswordToken"] = changePasswordToken + }); + await ValidateAsync(successContext); + + successContext.Result.IsError.ShouldBeFalse(); + successContext.Result.Subject.ShouldNotBeNull(); + (await GetAccessFailedCountAsync()).ShouldBe(0); + + var passwordState = await GetPasswordStateAsync(); + passwordState.OldPasswordIsValid.ShouldBeFalse(); + passwordState.NewPasswordIsValid.ShouldBeTrue(); + passwordState.ShouldChangePasswordOnNextLogin.ShouldBeFalse(); + } + + [Fact] + public async Task Periodic_Password_Change_With_TwoFactor_Should_Use_Periodic_Challenge_And_Require_Code() + { + var settingValueProvider = GetRequiredService(); + settingValueProvider.Set( + IdentitySettingNames.Password.ForceUsersToPeriodicallyChangePassword, + true.ToString()); + settingValueProvider.Set( + IdentitySettingNames.Password.PasswordChangePeriodDays, + 1.ToString()); + + try + { + await CreateUserAsync( + twoFactorEnabled: true, + addFailedAccess: true, + lastPasswordChangeTime: DateTimeOffset.UtcNow.AddDays(-2)); + + var challengeContext = CreateContext(); + await ValidateAsync(challengeContext); + var changePasswordToken = AssertPasswordChangeChallenge( + challengeContext, + "PeriodicallyChangePassword"); + + var passwordChangeContext = CreateContext(new NameValueCollection + { + ["NewPassword"] = NewPassword, + ["ChangePasswordToken"] = changePasswordToken + }); + await ValidateAsync(passwordChangeContext); + + AssertRequiresTwoFactor(passwordChangeContext); + (await GetAccessFailedCountAsync()).ShouldBe(1); + + var code = await GenerateTwoFactorCodeAsync(); + var successContext = CreateContext(new NameValueCollection + { + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = code + }, NewPassword); + await ValidateAsync(successContext); + + successContext.Result.IsError.ShouldBeFalse(); + successContext.Result.Subject.ShouldNotBeNull(); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } + finally + { + settingValueProvider.Clear(); + } + } + + [Fact] + public async Task Password_Change_Should_Roll_Back_When_Access_Failed_Count_Update_Fails() + { + await CreateUserAsync( + twoFactorEnabled: true, + shouldChangePasswordOnNextLogin: true); + + var challengeContext = CreateContext(); + await ValidateAsync(challengeContext); + var changePasswordToken = AssertPasswordChangeChallenge( + challengeContext, + "ShouldChangePasswordOnNextLogin"); + + var failureSimulator = GetRequiredService(); + failureSimulator.FailAfterSuccessfulUpdates(2); + var failedContext = CreateContext(new NameValueCollection + { + ["NewPassword"] = NewPassword, + ["ChangePasswordToken"] = changePasswordToken, + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = "invalid-code" + }); + try + { + await ValidateAsync(failedContext); + } + finally + { + failureSimulator.Reset(); + } + + failedContext.Result.IsError.ShouldBeTrue(); + failedContext.Result.Error.ShouldBe("invalid_grant"); + (await GetAccessFailedCountAsync()).ShouldBe(0); + + var passwordState = await GetPasswordStateAsync(); + passwordState.OldPasswordIsValid.ShouldBeTrue(); + passwordState.NewPasswordIsValid.ShouldBeFalse(); + passwordState.ShouldChangePasswordOnNextLogin.ShouldBeTrue(); + + var retryContext = CreateContext(); + await ValidateAsync(retryContext); + AssertPasswordChangeChallenge(retryContext, "ShouldChangePasswordOnNextLogin"); + } + + private static string AssertPasswordChangeChallenge( + ResourceOwnerPasswordValidationContext context, + string expectedErrorDescription) + { + context.Result.IsError.ShouldBeTrue(); + context.Result.Error.ShouldBe("invalid_grant"); + context.Result.ErrorDescription.ShouldBe(expectedErrorDescription); + context.Result.CustomResponse.ShouldNotBeNull(); + context.Result.CustomResponse.ContainsKey("changePasswordToken").ShouldBeTrue(); + return context.Result.CustomResponse["changePasswordToken"].ToString(); + } + + private static void AssertRequiresTwoFactor(ResourceOwnerPasswordValidationContext context) + { + context.Result.IsError.ShouldBeTrue(); + context.Result.Error.ShouldBe("invalid_grant"); + context.Result.ErrorDescription.ShouldBe("RequiresTwoFactor"); + context.Result.CustomResponse.ShouldNotBeNull(); + context.Result.CustomResponse.ContainsKey("twoFactorToken").ShouldBeTrue(); + } +} diff --git a/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidatorTestBase.cs b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidatorTestBase.cs new file mode 100644 index 0000000000..d3ccf34770 --- /dev/null +++ b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidatorTestBase.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Specialized; +using System.Linq; +using System.Threading.Tasks; +using IdentityServer4.Models; +using IdentityServer4.Validation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Identity; +using Volo.Abp.Uow; +using IdentityUser = Volo.Abp.Identity.IdentityUser; + +namespace Volo.Abp.IdentityServer.AspNetIdentity; + +public abstract class AbpResourceOwnerPasswordValidatorTestBase : AbpIdentityServerDomainTestBase +{ + protected const string UserName = "password-grant-user"; + protected const string Password = "1q2w3E*"; + protected const string NewPassword = "2q3w4E*"; + + protected Task CreateUserAsync( + bool twoFactorEnabled, + bool shouldChangePasswordOnNextLogin = false, + bool addFailedAccess = false, + DateTimeOffset? lastPasswordChangeTime = null) + { + return WithUnitOfWorkAsync(async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = new IdentityUser(Guid.NewGuid(), UserName, UserName + "@abp.io"); + user.SetEmailConfirmed(true); + user.SetShouldChangePasswordOnNextLogin(shouldChangePasswordOnNextLogin); + + (await userManager.CreateAsync(user, Password)).CheckErrors(); + (await userManager.SetLockoutEnabledAsync(user, true)).CheckErrors(); + (await userManager.SetTwoFactorEnabledAsync(user, twoFactorEnabled)).CheckErrors(); + + if (lastPasswordChangeTime.HasValue) + { + user.SetLastPasswordChangeTime(lastPasswordChangeTime); + (await userManager.UpdateAsync(user)).CheckErrors(); + } + + if (addFailedAccess) + { + (await userManager.AccessFailedAsync(user)).CheckErrors(); + } + }); + } + + protected Task GenerateTwoFactorCodeAsync() + { + return WithUnitOfWorkAsync(async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = await userManager.FindByNameAsync(UserName); + return await userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider); + }); + } + + protected Task GenerateRecoveryCodeAsync() + { + return WithUnitOfWorkAsync(async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = await userManager.FindByNameAsync(UserName); + var recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 1); + return recoveryCodes.Single(); + }); + } + + protected Task GetAccessFailedCountAsync() + { + return WithUnitOfWorkAsync(async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = await userManager.FindByNameAsync(UserName); + return await userManager.GetAccessFailedCountAsync(user); + }); + } + + protected Task<(bool OldPasswordIsValid, bool NewPasswordIsValid, bool ShouldChangePasswordOnNextLogin)> GetPasswordStateAsync() + { + return WithUnitOfWorkAsync(async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = await userManager.FindByNameAsync(UserName); + return ( + OldPasswordIsValid: await userManager.CheckPasswordAsync(user, Password), + NewPasswordIsValid: await userManager.CheckPasswordAsync(user, NewPassword), + user.ShouldChangePasswordOnNextLogin); + }); + } + + protected Task ValidateAsync(ResourceOwnerPasswordValidationContext context) + { + return WithUnitOfWorkAsync( + new AbpUnitOfWorkOptions { IsTransactional = true }, + async serviceProvider => + { + var unitOfWorkManager = serviceProvider.GetRequiredService(); + unitOfWorkManager.Current.ShouldNotBeNull(); + unitOfWorkManager.Current.Options.IsTransactional.ShouldBeTrue(); + + var httpContextAccessor = serviceProvider.GetRequiredService(); + var originalHttpContext = httpContextAccessor.HttpContext; + httpContextAccessor.HttpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + }; + try + { + await serviceProvider + .GetRequiredService() + .ValidateAsync(context); + } + finally + { + httpContextAccessor.HttpContext = originalHttpContext; + } + }); + } + + protected static ResourceOwnerPasswordValidationContext CreateContext( + NameValueCollection raw = null, + string password = null) + { + return new ResourceOwnerPasswordValidationContext + { + UserName = UserName, + Password = password ?? Password, + Request = new ValidatedTokenRequest + { + Raw = raw ?? new NameValueCollection(), + Client = new Client { ClientId = "test-client" } + } + }; + } + + protected Task WithUnitOfWorkAsync(Func action) + { + return WithUnitOfWorkAsync(new AbpUnitOfWorkOptions(), action); + } + + protected async Task WithUnitOfWorkAsync( + AbpUnitOfWorkOptions options, + Func action) + { + using var scope = ServiceProvider.CreateScope(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + using var uow = unitOfWorkManager.Begin(options); + await action(scope.ServiceProvider); + await uow.CompleteAsync(); + } + + protected Task WithUnitOfWorkAsync(Func> action) + { + return WithUnitOfWorkAsync(new AbpUnitOfWorkOptions(), action); + } + + protected async Task WithUnitOfWorkAsync( + AbpUnitOfWorkOptions options, + Func> action) + { + using var scope = ServiceProvider.CreateScope(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + using var uow = unitOfWorkManager.Begin(options); + var result = await action(scope.ServiceProvider); + await uow.CompleteAsync(); + return result; + } +} diff --git a/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator_Tests.cs b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator_Tests.cs new file mode 100644 index 0000000000..caadfcbf75 --- /dev/null +++ b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/AbpResourceOwnerPasswordValidator_Tests.cs @@ -0,0 +1,187 @@ +using System.Collections.Specialized; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Shouldly; +using Xunit; + +namespace Volo.Abp.IdentityServer.AspNetIdentity; + +public class AbpResourceOwnerPasswordValidator_Tests : AbpResourceOwnerPasswordValidatorTestBase +{ + [Fact] + public async Task Invalid_Recovery_Code_Should_Not_Be_Overwritten_By_RequiresTwoFactor() + { + await CreateUserAsync(twoFactorEnabled: true); + var context = CreateContext(new NameValueCollection + { + ["RecoveryCode"] = "invalid-recovery-code" + }); + + await ValidateAsync(context); + + context.Result.IsError.ShouldBeTrue(); + context.Result.Error.ShouldBe("invalid_grant"); + context.Result.ErrorDescription.ShouldBe("Invalid recovery code!"); + } + + [Fact] + public async Task Recovery_Code_Should_Remain_Usable_When_Redemption_Update_Fails() + { + await CreateUserAsync(twoFactorEnabled: true); + var recoveryCode = await GenerateRecoveryCodeAsync(); + var failureSimulator = GetRequiredService(); + failureSimulator.FailAfterSuccessfulUpdates(0); + var failedContext = CreateContext(new NameValueCollection + { + ["RecoveryCode"] = recoveryCode + }); + try + { + await ValidateAsync(failedContext); + } + finally + { + failureSimulator.Reset(); + } + + failedContext.Result.IsError.ShouldBeTrue(); + failedContext.Result.Error.ShouldBe("invalid_grant"); + + var retryContext = CreateContext(new NameValueCollection + { + ["RecoveryCode"] = recoveryCode + }); + await ValidateAsync(retryContext); + + retryContext.Result.IsError.ShouldBeFalse(); + retryContext.Result.Subject.ShouldNotBeNull(); + } + + [Fact] + public async Task ChangePassword_Update_Exception_Should_Return_InvalidGrant_And_Preserve_User_State() + { + await CreateUserAsync(twoFactorEnabled: false, shouldChangePasswordOnNextLogin: true); + var challengeContext = CreateContext(); + await ValidateAsync(challengeContext); + var changePasswordToken = challengeContext.Result.CustomResponse["changePasswordToken"].ToString(); + var failureSimulator = GetRequiredService(); + failureSimulator.FailAfterSuccessfulUpdates(0); + var context = CreateContext(new NameValueCollection + { + ["NewPassword"] = NewPassword, + ["ChangePasswordToken"] = changePasswordToken + }); + try + { + await ValidateAsync(context); + } + finally + { + failureSimulator.Reset(); + } + + context.Result.IsError.ShouldBeTrue(); + context.Result.Error.ShouldBe("invalid_grant"); + + var userState = await GetPasswordStateAsync(); + userState.OldPasswordIsValid.ShouldBeTrue(); + userState.NewPasswordIsValid.ShouldBeFalse(); + userState.ShouldChangePasswordOnNextLogin.ShouldBeTrue(); + } + + [Fact] + public async Task Valid_TwoFactor_Code_Should_Reset_Access_Failed_Count() + { + await CreateUserAsync(twoFactorEnabled: true, addFailedAccess: true); + var code = await GenerateTwoFactorCodeAsync(); + var context = CreateContext(new NameValueCollection + { + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = code + }); + + await ValidateAsync(context); + + context.Result.IsError.ShouldBeFalse(); + context.Result.Subject.ShouldNotBeNull(); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } + + [Fact] + public async Task Valid_Recovery_Code_Should_Reset_Access_Failed_Count() + { + await CreateUserAsync(twoFactorEnabled: true, addFailedAccess: true); + var recoveryCode = await GenerateRecoveryCodeAsync(); + var context = CreateContext(new NameValueCollection + { + ["RecoveryCode"] = recoveryCode + }); + + await ValidateAsync(context); + + context.Result.IsError.ShouldBeFalse(); + context.Result.Subject.ShouldNotBeNull(); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } + + [Fact] + public async Task Reset_Failure_Should_Not_Authenticate() + { + await CreateUserAsync(twoFactorEnabled: true, addFailedAccess: true); + var code = await GenerateTwoFactorCodeAsync(); + var failureSimulator = GetRequiredService(); + failureSimulator.FailAccessFailedCountReset(); + var context = CreateContext(new NameValueCollection + { + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = code + }); + try + { + await ValidateAsync(context); + } + finally + { + failureSimulator.Reset(); + } + + context.Result.IsError.ShouldBeTrue(); + context.Result.Error.ShouldBe("invalid_grant"); + (await GetAccessFailedCountAsync()).ShouldBe(1); + } + + [Fact] + public async Task Recovery_Code_Should_Remain_Usable_When_Reset_Failure_Rolls_Back_Request() + { + await CreateUserAsync(twoFactorEnabled: true, addFailedAccess: true); + var recoveryCode = await GenerateRecoveryCodeAsync(); + var failureSimulator = GetRequiredService(); + failureSimulator.FailAccessFailedCountReset(); + var failedContext = CreateContext(new NameValueCollection + { + ["RecoveryCode"] = recoveryCode + }); + try + { + await ValidateAsync(failedContext); + } + finally + { + failureSimulator.Reset(); + } + + failedContext.Result.IsError.ShouldBeTrue(); + failedContext.Result.Error.ShouldBe("invalid_grant"); + (await GetAccessFailedCountAsync()).ShouldBe(1); + + var retryContext = CreateContext(new NameValueCollection + { + ["RecoveryCode"] = recoveryCode + }); + await ValidateAsync(retryContext); + + retryContext.Result.IsError.ShouldBeFalse(); + retryContext.Result.Subject.ShouldNotBeNull(); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } +} diff --git a/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/IdentityServerTestSettingValueProvider.cs b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/IdentityServerTestSettingValueProvider.cs new file mode 100644 index 0000000000..aeeb8b4c46 --- /dev/null +++ b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/IdentityServerTestSettingValueProvider.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Settings; + +namespace Volo.Abp.IdentityServer.AspNetIdentity; + +public class IdentityServerTestSettingValueProvider : ISettingValueProvider +{ + public const string ProviderName = "IdentityServerDomainTest"; + + private readonly Dictionary _values = new(); + + public string Name => ProviderName; + + public void Set(string name, string value) + { + _values[name] = value; + } + + public void Clear() + { + _values.Clear(); + } + + public Task GetOrNullAsync(SettingDefinition setting) + { + return Task.FromResult(_values.GetOrDefault(setting.Name)); + } + + public Task> GetAllAsync(SettingDefinition[] settings) + { + return Task.FromResult(settings + .Select(setting => new SettingValue(setting.Name, _values.GetOrDefault(setting.Name))) + .ToList()); + } +} diff --git a/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/IdentityUserStoreFailureSimulator.cs b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/IdentityUserStoreFailureSimulator.cs new file mode 100644 index 0000000000..83f199d35b --- /dev/null +++ b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/IdentityUserStoreFailureSimulator.cs @@ -0,0 +1,41 @@ +namespace Volo.Abp.IdentityServer.AspNetIdentity; + +public class IdentityUserStoreFailureSimulator +{ + private int? _successfulUpdatesBeforeFailure; + + public bool IsAccessFailedCountResetFailureEnabled { get; private set; } + + public void FailAccessFailedCountReset() + { + IsAccessFailedCountResetFailureEnabled = true; + } + + public void FailAfterSuccessfulUpdates(int successfulUpdateCount) + { + _successfulUpdatesBeforeFailure = successfulUpdateCount; + } + + public bool ShouldFailUpdate() + { + if (!_successfulUpdatesBeforeFailure.HasValue) + { + return false; + } + + if (_successfulUpdatesBeforeFailure.Value > 0) + { + _successfulUpdatesBeforeFailure--; + return false; + } + + _successfulUpdatesBeforeFailure = null; + return true; + } + + public void Reset() + { + IsAccessFailedCountResetFailureEnabled = false; + _successfulUpdatesBeforeFailure = null; + } +} diff --git a/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/TestIdentityUserStore.cs b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/TestIdentityUserStore.cs new file mode 100644 index 0000000000..fed0c60cb5 --- /dev/null +++ b/modules/identityserver/test/Volo.Abp.IdentityServer.Domain.Tests/Volo/Abp/IdentityServer/AspNetIdentity/TestIdentityUserStore.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Volo.Abp.Guids; +using Volo.Abp.Identity; +using IdentityUser = Volo.Abp.Identity.IdentityUser; + +namespace Volo.Abp.IdentityServer.AspNetIdentity; + +public class TestIdentityUserStore : IdentityUserStore +{ + private readonly IdentityUserStoreFailureSimulator _failureSimulator; + private bool _failNextUpdate; + + public TestIdentityUserStore( + IIdentityUserRepository userRepository, + IIdentityRoleRepository roleRepository, + IGuidGenerator guidGenerator, + ILogger logger, + ILookupNormalizer lookupNormalizer, + IdentityErrorDescriber describer, + IdentityUserStoreFailureSimulator failureSimulator) + : base(userRepository, roleRepository, guidGenerator, logger, lookupNormalizer, describer) + { + _failureSimulator = failureSimulator; + } + + public override Task ResetAccessFailedCountAsync(IdentityUser user, CancellationToken cancellationToken = default) + { + _failNextUpdate = _failureSimulator.IsAccessFailedCountResetFailureEnabled; + return base.ResetAccessFailedCountAsync(user, cancellationToken); + } + + public override Task UpdateAsync(IdentityUser user, CancellationToken cancellationToken = default) + { + if (_failNextUpdate || _failureSimulator.ShouldFailUpdate()) + { + _failNextUpdate = false; + return Task.FromResult(IdentityResult.Failed(new IdentityError + { + Code = "IdentityUserUpdateFailed", + Description = "The identity user could not be updated." + })); + } + + return base.UpdateAsync(user, cancellationToken); + } +} diff --git a/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs b/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs index 54b1b34abb..74b53a7774 100644 --- a/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs +++ b/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/Controllers/TokenController.Password.cs @@ -215,13 +215,7 @@ public partial class TokenController return await SetSuccessResultAsync(request, user); } - var properties = new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Invalid recovery code!" - }); - - return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return await RollbackAndCreateInvalidGrantResultAsync("Invalid recovery code!"); } var twoFactorProvider = request.GetParameter("TwoFactorProvider")?.ToString(); @@ -234,7 +228,11 @@ public partial class TokenController return await SetSuccessResultAsync(request, user); } - await UserManager.AccessFailedAsync(user); + var accessFailedResult = await UserManager.AccessFailedAsync(user); + if (!accessFailedResult.Succeeded) + { + return await RollbackAndCreateInvalidGrantResultAsync("Invalid username or password!"); + } Logger.LogInformation("Authentication failed for username: {username}, reason: InvalidAuthenticatorCode", request.Username); @@ -293,7 +291,16 @@ public partial class TokenController { if (await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, changePasswordType.ToString(), changePasswordToken)) { - var changePasswordResult = await UserManager.ChangePasswordAsync(user, currentPassword, newPassword); + IdentityResult changePasswordResult; + try + { + changePasswordResult = await UserManager.ChangePasswordAsync(user, currentPassword, newPassword); + } + catch (AbpIdentityResultException exception) + { + changePasswordResult = exception.IdentityResult; + } + if (changePasswordResult.Succeeded) { await IdentitySecurityLogManager.SaveAsync(new IdentitySecurityLogContext @@ -309,19 +316,25 @@ public partial class TokenController user.SetShouldChangePasswordOnNextLogin(false); } - await UserManager.UpdateAsync(user); + var updateUserResult = await UserManager.UpdateAsync(user); + if (!updateUserResult.Succeeded) + { + return await RollbackAndCreateInvalidGrantResultAsync("Invalid username or password!"); + } + + if (await IsTfaEnabledAsync(user)) + { + return await HandleTwoFactorLoginAsync(request, user); + } + return await SetSuccessResultAsync(request, user); } else { Logger.LogInformation("ChangePassword failed for username: {username}, reason: {changePasswordResult}", request.Username, changePasswordResult.Errors.Select(x => x.Description).JoinAsString(", ")); - var properties = new AuthenticationProperties(new Dictionary - { - [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, - [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = changePasswordResult.Errors.Select(x => x.Description).JoinAsString(", ") - }); - return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + return await RollbackAndCreateInvalidGrantResultAsync( + changePasswordResult.Errors.Select(x => x.Description).JoinAsString(", ")); } } else @@ -387,6 +400,12 @@ public partial class TokenController protected virtual async Task SetSuccessResultAsync(OpenIddictRequest request, IdentityUser user) { + var resetAccessFailedCountResult = await UserManager.ResetAccessFailedCountAsync(user); + if (!resetAccessFailedCountResult.Succeeded) + { + return await RollbackAndCreateInvalidGrantResultAsync("Invalid username or password!"); + } + // Clear the dynamic claims cache. await IdentityDynamicClaimsPrincipalContributorCache.ClearAsync(user.Id, user.TenantId); @@ -421,6 +440,22 @@ public partial class TokenController return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + protected virtual async Task RollbackAndCreateInvalidGrantResultAsync(string errorDescription) + { + if (CurrentUnitOfWork != null) + { + await CurrentUnitOfWork.RollbackAsync(); + } + + var properties = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = errorDescription + }); + + return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + protected virtual async Task UpdateUserLastSignInTimeAsync(IdentityUser user) { await UserManager.UpdateLastSignInTimeAsync(user.Id); diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj index 003b78894c..052adbabe2 100644 --- a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj @@ -19,6 +19,8 @@ + + diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/IdentityUserStoreFailureSimulator.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/IdentityUserStoreFailureSimulator.cs new file mode 100644 index 0000000000..67f1fd0480 --- /dev/null +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/IdentityUserStoreFailureSimulator.cs @@ -0,0 +1,41 @@ +namespace Volo.Abp.OpenIddict.Integration; + +public class IdentityUserStoreFailureSimulator +{ + private int? _successfulUpdatesBeforeFailure; + + public bool IsAccessFailedCountResetFailureEnabled { get; private set; } + + public void FailAccessFailedCountReset() + { + IsAccessFailedCountResetFailureEnabled = true; + } + + public void FailAfterSuccessfulUpdates(int successfulUpdateCount) + { + _successfulUpdatesBeforeFailure = successfulUpdateCount; + } + + public bool ShouldFailUpdate() + { + if (!_successfulUpdatesBeforeFailure.HasValue) + { + return false; + } + + if (_successfulUpdatesBeforeFailure.Value > 0) + { + _successfulUpdatesBeforeFailure--; + return false; + } + + _successfulUpdatesBeforeFailure = null; + return true; + } + + public void Reset() + { + IsAccessFailedCountResetFailureEnabled = false; + _successfulUpdatesBeforeFailure = null; + } +} diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantIntegrationTestBase.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantIntegrationTestBase.cs new file mode 100644 index 0000000000..39c240a627 --- /dev/null +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantIntegrationTestBase.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.AspNetCore.TestBase; +using Volo.Abp.Identity; +using Volo.Abp.Uow; + +namespace Volo.Abp.OpenIddict.Integration; + +public abstract class OpenIddictPasswordGrantIntegrationTestBase : AbpWebApplicationFactoryIntegratedTest +{ + protected const string NewPassword = "2q3w4E*"; + + protected Task RequestPasswordTokenAsync( + Dictionary additionalParameters = null, + string password = null) + { + var parameters = new Dictionary + { + ["grant_type"] = "password", + ["client_id"] = "test-client", + ["client_secret"] = "test-secret", + ["username"] = OpenIddictPasswordGrantTestData.UserName, + ["password"] = password ?? OpenIddictPasswordGrantTestData.Password + }; + + if (additionalParameters != null) + { + foreach (var parameter in additionalParameters) + { + parameters[parameter.Key] = parameter.Value; + } + } + + return Client.PostAsync("/connect/token", new FormUrlEncodedContent(parameters)); + } + + protected static async Task AssertAccessTokenAsync(HttpResponseMessage response) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + document.RootElement.GetProperty("access_token").GetString().ShouldNotBeNullOrWhiteSpace(); + } + + protected static async Task AssertInvalidGrantAsync(HttpResponseMessage response) + { + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = document.RootElement; + root.GetProperty("error").GetString().ShouldBe("invalid_grant"); + root.TryGetProperty("access_token", out _).ShouldBeFalse(); + } + + protected Task GenerateTwoFactorCodeAsync() + { + return WithUnitOfWorkAsync(async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); + return await userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider); + }); + } + + protected Task GetAccessFailedCountAsync() + { + return WithUnitOfWorkAsync( + new AbpUnitOfWorkOptions { IsTransactional = false }, + async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); + return await userManager.GetAccessFailedCountAsync(user); + }); + } + + protected virtual Task WithUnitOfWorkAsync(Func action) + { + return WithUnitOfWorkAsync(new AbpUnitOfWorkOptions(), action); + } + + protected virtual async Task WithUnitOfWorkAsync( + AbpUnitOfWorkOptions options, + Func action) + { + using var scope = ServiceProvider.CreateScope(); + var uowManager = scope.ServiceProvider.GetRequiredService(); + using var uow = uowManager.Begin(options); + await action(scope.ServiceProvider); + await uow.CompleteAsync(); + } + + protected virtual Task WithUnitOfWorkAsync(Func> action) + { + return WithUnitOfWorkAsync(new AbpUnitOfWorkOptions(), action); + } + + protected virtual async Task WithUnitOfWorkAsync( + AbpUnitOfWorkOptions options, + Func> action) + { + using var scope = ServiceProvider.CreateScope(); + var uowManager = scope.ServiceProvider.GetRequiredService(); + using var uow = uowManager.Begin(options); + var result = await action(scope.ServiceProvider); + await uow.CompleteAsync(); + return result; + } +} diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantPasswordChange_Integration_Tests.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantPasswordChange_Integration_Tests.cs new file mode 100644 index 0000000000..a88a865568 --- /dev/null +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantPasswordChange_Integration_Tests.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Identity; +using Volo.Abp.Identity.Settings; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.OpenIddict.Integration; + +public class OpenIddictPasswordGrantPasswordChange_Integration_Tests : OpenIddictPasswordGrantIntegrationTestBase +{ + [Fact] + public async Task Required_Password_Change_With_TwoFactor_Should_Require_Code_Before_Issuing_Token() + { + await ConfigurePasswordChangeUserAsync( + twoFactorEnabled: true, + shouldChangePasswordOnNextLogin: true, + addFailedAccess: true); + + var challenge = await RequestPasswordTokenAsync(); + var changePasswordToken = await AssertPasswordChangeChallengeAsync( + challenge, + "ShouldChangePasswordOnNextLogin"); + + var passwordChangeResponse = await RequestPasswordTokenAsync(new Dictionary + { + ["NewPassword"] = NewPassword, + ["ChangePasswordToken"] = changePasswordToken + }); + + await AssertRequiresTwoFactorAsync(passwordChangeResponse); + (await GetAccessFailedCountAsync()).ShouldBe(1); + + var code = await GenerateTwoFactorCodeAsync(); + var successResponse = await RequestPasswordTokenAsync(new Dictionary + { + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = code + }, NewPassword); + + await AssertAccessTokenAsync(successResponse); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } + + [Fact] + public async Task Required_Password_Change_Without_TwoFactor_Should_Issue_Token_And_Reset_Failed_Count() + { + await ConfigurePasswordChangeUserAsync( + twoFactorEnabled: false, + shouldChangePasswordOnNextLogin: true, + addFailedAccess: true); + + var challenge = await RequestPasswordTokenAsync(); + var changePasswordToken = await AssertPasswordChangeChallengeAsync( + challenge, + "ShouldChangePasswordOnNextLogin"); + + var response = await RequestPasswordTokenAsync(new Dictionary + { + ["NewPassword"] = NewPassword, + ["ChangePasswordToken"] = changePasswordToken + }); + + await AssertAccessTokenAsync(response); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } + + [Fact] + public async Task Periodic_Password_Change_With_TwoFactor_Should_Require_Code_Before_Issuing_Token() + { + var settingValueProvider = GetRequiredService(); + settingValueProvider.Set( + IdentitySettingNames.Password.ForceUsersToPeriodicallyChangePassword, + true.ToString()); + settingValueProvider.Set( + IdentitySettingNames.Password.PasswordChangePeriodDays, + 1.ToString()); + + try + { + await ConfigurePasswordChangeUserAsync( + twoFactorEnabled: true, + addFailedAccess: true, + lastPasswordChangeTime: DateTimeOffset.UtcNow.AddDays(-2)); + + var challenge = await RequestPasswordTokenAsync(); + var changePasswordToken = await AssertPasswordChangeChallengeAsync( + challenge, + "PeriodicallyChangePassword"); + + var response = await RequestPasswordTokenAsync(new Dictionary + { + ["NewPassword"] = NewPassword, + ["ChangePasswordToken"] = changePasswordToken + }); + + await AssertRequiresTwoFactorAsync(response); + (await GetAccessFailedCountAsync()).ShouldBe(1); + + var code = await GenerateTwoFactorCodeAsync(); + var successResponse = await RequestPasswordTokenAsync(new Dictionary + { + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = code + }, NewPassword); + + await AssertAccessTokenAsync(successResponse); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } + finally + { + settingValueProvider.Clear(); + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public async Task Password_Change_Update_Failure_Should_Roll_Back_Password_And_Required_Change_State( + int successfulUpdatesBeforeFailure) + { + await ConfigurePasswordChangeUserAsync( + twoFactorEnabled: false, + shouldChangePasswordOnNextLogin: true); + + var challenge = await RequestPasswordTokenAsync(); + var changePasswordToken = await AssertPasswordChangeChallengeAsync( + challenge, + "ShouldChangePasswordOnNextLogin"); + + var failureSimulator = GetRequiredService(); + failureSimulator.FailAfterSuccessfulUpdates(successfulUpdatesBeforeFailure); + HttpResponseMessage failedResponse; + try + { + failedResponse = await RequestPasswordTokenAsync(new Dictionary + { + ["NewPassword"] = NewPassword, + ["ChangePasswordToken"] = changePasswordToken + }); + } + finally + { + failureSimulator.Reset(); + } + + await AssertInvalidGrantAsync(failedResponse); + + var passwordState = await GetPasswordStateAsync(); + passwordState.OldPasswordIsValid.ShouldBeTrue(); + passwordState.NewPasswordIsValid.ShouldBeFalse(); + passwordState.ShouldChangePasswordOnNextLogin.ShouldBeTrue(); + + var retryChallenge = await RequestPasswordTokenAsync(); + await AssertPasswordChangeChallengeAsync( + retryChallenge, + "ShouldChangePasswordOnNextLogin"); + } + + [Fact] + public async Task Password_Change_Should_Roll_Back_When_Access_Failed_Count_Update_Fails() + { + await ConfigurePasswordChangeUserAsync( + twoFactorEnabled: true, + shouldChangePasswordOnNextLogin: true); + + var challenge = await RequestPasswordTokenAsync(); + var changePasswordToken = await AssertPasswordChangeChallengeAsync( + challenge, + "ShouldChangePasswordOnNextLogin"); + + var failureSimulator = GetRequiredService(); + failureSimulator.FailAfterSuccessfulUpdates(2); + HttpResponseMessage failedResponse; + try + { + failedResponse = await RequestPasswordTokenAsync(new Dictionary + { + ["NewPassword"] = NewPassword, + ["ChangePasswordToken"] = changePasswordToken, + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = "invalid-code" + }); + } + finally + { + failureSimulator.Reset(); + } + + await AssertInvalidGrantAsync(failedResponse); + (await GetAccessFailedCountAsync()).ShouldBe(0); + + var passwordState = await GetPasswordStateAsync(); + passwordState.OldPasswordIsValid.ShouldBeTrue(); + passwordState.NewPasswordIsValid.ShouldBeFalse(); + passwordState.ShouldChangePasswordOnNextLogin.ShouldBeTrue(); + + var retryChallenge = await RequestPasswordTokenAsync(); + await AssertPasswordChangeChallengeAsync( + retryChallenge, + "ShouldChangePasswordOnNextLogin"); + } + + private static async Task AssertPasswordChangeChallengeAsync( + HttpResponseMessage response, + string expectedErrorDescription) + { + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = document.RootElement; + root.GetProperty("error_description").GetString().ShouldBe(expectedErrorDescription); + root.TryGetProperty("access_token", out _).ShouldBeFalse(); + return root.GetProperty("changePasswordToken").GetString(); + } + + private static async Task AssertRequiresTwoFactorAsync(HttpResponseMessage response) + { + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = document.RootElement; + root.GetProperty("error_description").GetString().ShouldBe("RequiresTwoFactor"); + root.TryGetProperty("access_token", out _).ShouldBeFalse(); + } + + private Task ConfigurePasswordChangeUserAsync( + bool twoFactorEnabled, + bool shouldChangePasswordOnNextLogin = false, + bool addFailedAccess = false, + DateTimeOffset? lastPasswordChangeTime = null) + { + return WithUnitOfWorkAsync(async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); + user.SetShouldChangePasswordOnNextLogin(shouldChangePasswordOnNextLogin); + if (lastPasswordChangeTime.HasValue) + { + user.SetLastPasswordChangeTime(lastPasswordChangeTime); + } + + (await userManager.SetTwoFactorEnabledAsync(user, twoFactorEnabled)).CheckErrors(); + if (addFailedAccess) + { + (await userManager.AccessFailedAsync(user)).CheckErrors(); + } + }); + } + + private Task<(bool OldPasswordIsValid, bool NewPasswordIsValid, bool ShouldChangePasswordOnNextLogin)> GetPasswordStateAsync() + { + return WithUnitOfWorkAsync( + new AbpUnitOfWorkOptions { IsTransactional = false }, + async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); + return ( + OldPasswordIsValid: await userManager.CheckPasswordAsync(user, OpenIddictPasswordGrantTestData.Password), + NewPasswordIsValid: await userManager.CheckPasswordAsync(user, NewPassword), + user.ShouldChangePasswordOnNextLogin); + }); + } +} diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantTestData.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantTestData.cs new file mode 100644 index 0000000000..4d87d41d4a --- /dev/null +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantTestData.cs @@ -0,0 +1,8 @@ +namespace Volo.Abp.OpenIddict.Integration; + +public static class OpenIddictPasswordGrantTestData +{ + public const string UserName = "two-factor-user"; + public const string Email = "two-factor-user@abp.io"; + public const string Password = "1q2w3E*"; +} diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantTwoFactor_Integration_Tests.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantTwoFactor_Integration_Tests.cs new file mode 100644 index 0000000000..562b03574e --- /dev/null +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrantTwoFactor_Integration_Tests.cs @@ -0,0 +1,177 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Identity; +using Xunit; + +namespace Volo.Abp.OpenIddict.Integration; + +public class OpenIddictPasswordGrantTwoFactor_Integration_Tests : OpenIddictPasswordGrantIntegrationTestBase +{ + [Fact] + public async Task Password_Stage_Should_Not_Reset_Access_Failed_Count() + { + var invalidCodeResponse = await RequestPasswordTokenAsync(new Dictionary + { + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = "invalid-code" + }); + + invalidCodeResponse.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await GetAccessFailedCountAsync()).ShouldBe(1); + + var passwordStageResponse = await RequestPasswordTokenAsync(); + + passwordStageResponse.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await GetAccessFailedCountAsync()).ShouldBe(1); + } + + [Fact] + public async Task Valid_TwoFactor_Code_Should_Reset_Access_Failed_Count_Before_Issuing_Token() + { + var code = await GenerateTwoFactorCodeAsync(); + await RequestPasswordTokenAsync(new Dictionary + { + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = "invalid-code" + }); + (await GetAccessFailedCountAsync()).ShouldBe(1); + + var response = await RequestPasswordTokenAsync(new Dictionary + { + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = code + }); + + await AssertAccessTokenAsync(response); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } + + [Fact] + public async Task Valid_Recovery_Code_Should_Reset_Access_Failed_Count_Before_Issuing_Token() + { + var recoveryCode = await GenerateRecoveryCodeAfterFailedAccessAsync(); + + var response = await RequestPasswordTokenAsync(new Dictionary + { + ["RecoveryCode"] = recoveryCode + }); + + await AssertAccessTokenAsync(response); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } + + [Fact] + public async Task Reset_Failure_Should_Not_Issue_Token() + { + var code = await GenerateTwoFactorCodeAfterFailedAccessAsync(); + var failureSimulator = GetRequiredService(); + failureSimulator.FailAccessFailedCountReset(); + HttpResponseMessage response; + try + { + response = await RequestPasswordTokenAsync(new Dictionary + { + ["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, + ["TwoFactorCode"] = code + }); + } + finally + { + failureSimulator.Reset(); + } + + await AssertInvalidGrantAsync(response); + (await GetAccessFailedCountAsync()).ShouldBe(1); + } + + [Fact] + public async Task Recovery_Code_Should_Remain_Usable_When_Reset_Failure_Rolls_Back_Request() + { + var recoveryCode = await GenerateRecoveryCodeAfterFailedAccessAsync(); + var failureSimulator = GetRequiredService(); + failureSimulator.FailAccessFailedCountReset(); + HttpResponseMessage failedResponse; + try + { + failedResponse = await RequestPasswordTokenAsync(new Dictionary + { + ["RecoveryCode"] = recoveryCode + }); + } + finally + { + failureSimulator.Reset(); + } + + await AssertInvalidGrantAsync(failedResponse); + (await GetAccessFailedCountAsync()).ShouldBe(1); + + var retryResponse = await RequestPasswordTokenAsync(new Dictionary + { + ["RecoveryCode"] = recoveryCode + }); + + await AssertAccessTokenAsync(retryResponse); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } + + [Fact] + public async Task Recovery_Code_Should_Remain_Usable_When_Redemption_Update_Fails() + { + var recoveryCode = await GenerateRecoveryCodeAfterFailedAccessAsync(); + var failureSimulator = GetRequiredService(); + failureSimulator.FailAfterSuccessfulUpdates(0); + HttpResponseMessage failedResponse; + try + { + failedResponse = await RequestPasswordTokenAsync(new Dictionary + { + ["RecoveryCode"] = recoveryCode + }); + } + finally + { + failureSimulator.Reset(); + } + + await AssertInvalidGrantAsync(failedResponse); + (await GetAccessFailedCountAsync()).ShouldBe(1); + + var retryResponse = await RequestPasswordTokenAsync(new Dictionary + { + ["RecoveryCode"] = recoveryCode + }); + + await AssertAccessTokenAsync(retryResponse); + (await GetAccessFailedCountAsync()).ShouldBe(0); + } + + private Task GenerateTwoFactorCodeAfterFailedAccessAsync() + { + return WithUnitOfWorkAsync(async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); + (await userManager.AccessFailedAsync(user)).CheckErrors(); + return await userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider); + }); + } + + private Task GenerateRecoveryCodeAfterFailedAccessAsync() + { + return WithUnitOfWorkAsync(async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); + (await userManager.AccessFailedAsync(user)).CheckErrors(); + var recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 1); + return recoveryCodes.Single(); + }); + } +} diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrant_Integration_Tests.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrant_Integration_Tests.cs new file mode 100644 index 0000000000..5fbe08e5a1 --- /dev/null +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictPasswordGrant_Integration_Tests.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Identity; +using Xunit; + +namespace Volo.Abp.OpenIddict.Integration; + +public class OpenIddictPasswordGrant_Integration_Tests : OpenIddictPasswordGrantIntegrationTestBase +{ + [Fact] + public async Task Password_Grant_Without_TwoFactor_Should_Issue_Token() + { + await WithUnitOfWorkAsync(async serviceProvider => + { + var userManager = serviceProvider.GetRequiredService(); + var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); + (await userManager.SetTwoFactorEnabledAsync(user, false)).CheckErrors(); + }); + + var response = await RequestPasswordTokenAsync(); + + await AssertAccessTokenAsync(response); + } +} diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTestSettingValueProvider.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTestSettingValueProvider.cs new file mode 100644 index 0000000000..6aa4abf7bd --- /dev/null +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTestSettingValueProvider.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Settings; + +namespace Volo.Abp.OpenIddict.Integration; + +public class OpenIddictTestSettingValueProvider : ISettingValueProvider +{ + public const string ProviderName = "OpenIddictIntegrationTest"; + + private readonly Dictionary _values = new(); + + public string Name => ProviderName; + + public void Set(string name, string value) + { + _values[name] = value; + } + + public void Clear() + { + _values.Clear(); + } + + public Task GetOrNullAsync(SettingDefinition setting) + { + return Task.FromResult(_values.GetOrDefault(setting.Name)); + } + + public Task> GetAllAsync(SettingDefinition[] settings) + { + return Task.FromResult(settings + .Select(setting => new SettingValue(setting.Name, _values.GetOrDefault(setting.Name))) + .ToList()); + } +} diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs index 29e9c5a180..3818fe1c40 100644 --- a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs @@ -2,33 +2,40 @@ using System; using System.IO; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using OpenIddict.Abstractions; using OpenIddict.Server; using Volo.Abp.AspNetCore.TestBase; using Volo.Abp.AspNetCore.Uow; +using Volo.Abp.Autofac; using Volo.Abp.Data; using Volo.Abp.Domain.Repositories; using Volo.Abp.EntityFrameworkCore; using Volo.Abp.EntityFrameworkCore.Sqlite; +using Volo.Abp.Identity; +using Volo.Abp.Identity.AspNetCore; +using Volo.Abp.Identity.EntityFrameworkCore; using Volo.Abp.Modularity; using Volo.Abp.OpenIddict.Applications; using Volo.Abp.OpenIddict.EntityFrameworkCore; using Volo.Abp.OpenIddict.Tokens; +using Volo.Abp.SecurityLog; +using Volo.Abp.Settings; using Volo.Abp.Uow; -using Volo.Abp.Autofac; +using IdentityUser = Volo.Abp.Identity.IdentityUser; namespace Volo.Abp.OpenIddict.Integration; -public class TokenVisibilityRecorder -{ - public long? TokenCountAtResponseStart { get; set; } -} - [DependsOn( typeof(AbpAspNetCoreTestBaseModule), + typeof(AbpIdentityAspNetCoreModule), + typeof(AbpIdentityEntityFrameworkCoreModule), typeof(AbpOpenIddictAspNetCoreModule), typeof(AbpOpenIddictEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule), @@ -59,6 +66,15 @@ public class OpenIddictTokenIntegrationTestModule : AbpModule public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.Replace(ServiceDescriptor.Scoped()); + + Configure(options => options.IsEnabled = false); + Configure(options => + { + options.ValueProviders.Add(); + }); // A remapped token endpoint, so the tests can prove the opt-in list is derived from the configured // server endpoints (custom endpoints are followed) rather than a hardcoded "/connect" prefix. @@ -77,6 +93,12 @@ public class OpenIddictTokenIntegrationTestModule : AbpModule dbContext.Database.EnsureCreated(); } + using (var dbContext = new IdentityDbContext( + new DbContextOptionsBuilder().UseSqlite(ConnectionString).Options)) + { + dbContext.GetService().CreateTables(); + } + Configure(options => { options.ConnectionStrings.Default = ConnectionString; @@ -90,7 +112,7 @@ public class OpenIddictTokenIntegrationTestModule : AbpModule public override void OnApplicationInitialization(ApplicationInitializationContext context) { - SeedClientAsync(context.ServiceProvider).GetAwaiter().GetResult(); + SeedDataAsync(context.ServiceProvider).GetAwaiter().GetResult(); var app = context.GetApplicationBuilder(); app.UseRouting(); @@ -129,7 +151,7 @@ public class OpenIddictTokenIntegrationTestModule : AbpModule } } - private static async Task SeedClientAsync(IServiceProvider serviceProvider) + private static async Task SeedDataAsync(IServiceProvider serviceProvider) { using var scope = serviceProvider.CreateScope(); var uowManager = scope.ServiceProvider.GetRequiredService(); @@ -147,11 +169,26 @@ public class OpenIddictTokenIntegrationTestModule : AbpModule Permissions = { OpenIddictConstants.Permissions.Endpoints.Token, - OpenIddictConstants.Permissions.GrantTypes.ClientCredentials + OpenIddictConstants.Permissions.GrantTypes.ClientCredentials, + OpenIddictConstants.Permissions.GrantTypes.Password } }); } + var userManager = scope.ServiceProvider.GetRequiredService(); + if (await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName) == null) + { + var user = new IdentityUser( + Guid.NewGuid(), + OpenIddictPasswordGrantTestData.UserName, + OpenIddictPasswordGrantTestData.Email); + user.SetEmailConfirmed(true); + + (await userManager.CreateAsync(user, OpenIddictPasswordGrantTestData.Password)).CheckErrors(); + (await userManager.SetLockoutEnabledAsync(user, true)).CheckErrors(); + (await userManager.SetTwoFactorEnabledAsync(user, true)).CheckErrors(); + } + await uow.CompleteAsync(); } } diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/TestIdentityUserStore.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/TestIdentityUserStore.cs new file mode 100644 index 0000000000..4076e99666 --- /dev/null +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/TestIdentityUserStore.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Volo.Abp.Guids; +using Volo.Abp.Identity; +using IdentityUser = Volo.Abp.Identity.IdentityUser; + +namespace Volo.Abp.OpenIddict.Integration; + +public class TestIdentityUserStore : IdentityUserStore +{ + private readonly IdentityUserStoreFailureSimulator _failureSimulator; + private bool _failNextUpdate; + + public TestIdentityUserStore( + IIdentityUserRepository userRepository, + IIdentityRoleRepository roleRepository, + IGuidGenerator guidGenerator, + ILogger logger, + ILookupNormalizer lookupNormalizer, + IdentityErrorDescriber describer, + IdentityUserStoreFailureSimulator failureSimulator) + : base(userRepository, roleRepository, guidGenerator, logger, lookupNormalizer, describer) + { + _failureSimulator = failureSimulator; + } + + public override Task ResetAccessFailedCountAsync(IdentityUser user, CancellationToken cancellationToken = default) + { + _failNextUpdate = _failureSimulator.IsAccessFailedCountResetFailureEnabled; + return base.ResetAccessFailedCountAsync(user, cancellationToken); + } + + public override Task UpdateAsync(IdentityUser user, CancellationToken cancellationToken = default) + { + if (_failNextUpdate || _failureSimulator.ShouldFailUpdate()) + { + _failNextUpdate = false; + return Task.FromResult(IdentityResult.Failed(new IdentityError + { + Code = "IdentityUserUpdateFailed", + Description = "The identity user could not be updated." + })); + } + + return base.UpdateAsync(user, cancellationToken); + } +} diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/TokenVisibilityRecorder.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/TokenVisibilityRecorder.cs new file mode 100644 index 0000000000..772cc61718 --- /dev/null +++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/TokenVisibilityRecorder.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.OpenIddict.Integration; + +public class TokenVisibilityRecorder +{ + public long? TokenCountAtResponseStart { get; set; } +}