mirror of https://github.com/abpframework/abp.git
21 changed files with 1599 additions and 34 deletions
@ -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.PermissionManagement.IdentityServer; |
||||
|
using Volo.Abp.SecurityLog; |
||||
|
using Volo.Abp.Settings; |
||||
|
using Volo.Abp.Uow; |
||||
|
|
||||
namespace Volo.Abp.IdentityServer; |
namespace Volo.Abp.IdentityServer; |
||||
|
|
||||
[DependsOn( |
[DependsOn( |
||||
|
typeof(AbpIdentityAspNetCoreModule), |
||||
typeof(AbpIdentityServerTestEntityFrameworkCoreModule), |
typeof(AbpIdentityServerTestEntityFrameworkCoreModule), |
||||
typeof(AbpPermissionManagementDomainIdentityServerModule) |
typeof(AbpPermissionManagementDomainIdentityServerModule) |
||||
)] |
)] |
||||
public class AbpIdentityServerDomainTestModule : AbpModule |
public class AbpIdentityServerDomainTestModule : AbpModule |
||||
{ |
{ |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
context.Services.AddSingleton<IdentityUserStoreFailureSimulator>(); |
||||
|
context.Services.AddSingleton<IdentityServerTestSettingValueProvider>(); |
||||
|
context.Services.Replace(ServiceDescriptor.Scoped<IdentityUserStore, TestIdentityUserStore>()); |
||||
|
context.Services.Replace(ServiceDescriptor.Singleton<IUnitOfWorkManager>( |
||||
|
serviceProvider => serviceProvider.GetRequiredService<UnitOfWorkManager>())); |
||||
|
|
||||
|
Configure<AbpSecurityLogOptions>(options => options.IsEnabled = false); |
||||
|
Configure<AbpSettingOptions>(options => |
||||
|
{ |
||||
|
options.ValueProviders.Add<IdentityServerTestSettingValueProvider>(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
} |
} |
||||
|
|||||
@ -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<IdentityServerTestSettingValueProvider>(); |
||||
|
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<IdentityUserStoreFailureSimulator>(); |
||||
|
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(); |
||||
|
} |
||||
|
} |
||||
@ -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<IdentityUserManager>(); |
||||
|
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<string> GenerateTwoFactorCodeAsync() |
||||
|
{ |
||||
|
return WithUnitOfWorkAsync(async serviceProvider => |
||||
|
{ |
||||
|
var userManager = serviceProvider.GetRequiredService<IdentityUserManager>(); |
||||
|
var user = await userManager.FindByNameAsync(UserName); |
||||
|
return await userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
protected Task<string> GenerateRecoveryCodeAsync() |
||||
|
{ |
||||
|
return WithUnitOfWorkAsync(async serviceProvider => |
||||
|
{ |
||||
|
var userManager = serviceProvider.GetRequiredService<IdentityUserManager>(); |
||||
|
var user = await userManager.FindByNameAsync(UserName); |
||||
|
var recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 1); |
||||
|
return recoveryCodes.Single(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
protected Task<int> GetAccessFailedCountAsync() |
||||
|
{ |
||||
|
return WithUnitOfWorkAsync(async serviceProvider => |
||||
|
{ |
||||
|
var userManager = serviceProvider.GetRequiredService<IdentityUserManager>(); |
||||
|
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<IdentityUserManager>(); |
||||
|
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<IUnitOfWorkManager>(); |
||||
|
unitOfWorkManager.Current.ShouldNotBeNull(); |
||||
|
unitOfWorkManager.Current.Options.IsTransactional.ShouldBeTrue(); |
||||
|
|
||||
|
var httpContextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>(); |
||||
|
var originalHttpContext = httpContextAccessor.HttpContext; |
||||
|
httpContextAccessor.HttpContext = new DefaultHttpContext |
||||
|
{ |
||||
|
RequestServices = serviceProvider |
||||
|
}; |
||||
|
try |
||||
|
{ |
||||
|
await serviceProvider |
||||
|
.GetRequiredService<IResourceOwnerPasswordValidator>() |
||||
|
.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<IServiceProvider, Task> action) |
||||
|
{ |
||||
|
return WithUnitOfWorkAsync(new AbpUnitOfWorkOptions(), action); |
||||
|
} |
||||
|
|
||||
|
protected async Task WithUnitOfWorkAsync( |
||||
|
AbpUnitOfWorkOptions options, |
||||
|
Func<IServiceProvider, Task> action) |
||||
|
{ |
||||
|
using var scope = ServiceProvider.CreateScope(); |
||||
|
var unitOfWorkManager = scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>(); |
||||
|
using var uow = unitOfWorkManager.Begin(options); |
||||
|
await action(scope.ServiceProvider); |
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
|
||||
|
protected Task<TResult> WithUnitOfWorkAsync<TResult>(Func<IServiceProvider, Task<TResult>> action) |
||||
|
{ |
||||
|
return WithUnitOfWorkAsync(new AbpUnitOfWorkOptions(), action); |
||||
|
} |
||||
|
|
||||
|
protected async Task<TResult> WithUnitOfWorkAsync<TResult>( |
||||
|
AbpUnitOfWorkOptions options, |
||||
|
Func<IServiceProvider, Task<TResult>> action) |
||||
|
{ |
||||
|
using var scope = ServiceProvider.CreateScope(); |
||||
|
var unitOfWorkManager = scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>(); |
||||
|
using var uow = unitOfWorkManager.Begin(options); |
||||
|
var result = await action(scope.ServiceProvider); |
||||
|
await uow.CompleteAsync(); |
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
@ -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<IdentityUserStoreFailureSimulator>(); |
||||
|
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<IdentityUserStoreFailureSimulator>(); |
||||
|
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<IdentityUserStoreFailureSimulator>(); |
||||
|
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<IdentityUserStoreFailureSimulator>(); |
||||
|
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); |
||||
|
} |
||||
|
} |
||||
@ -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<string, string> _values = new(); |
||||
|
|
||||
|
public string Name => ProviderName; |
||||
|
|
||||
|
public void Set(string name, string value) |
||||
|
{ |
||||
|
_values[name] = value; |
||||
|
} |
||||
|
|
||||
|
public void Clear() |
||||
|
{ |
||||
|
_values.Clear(); |
||||
|
} |
||||
|
|
||||
|
public Task<string> GetOrNullAsync(SettingDefinition setting) |
||||
|
{ |
||||
|
return Task.FromResult(_values.GetOrDefault(setting.Name)); |
||||
|
} |
||||
|
|
||||
|
public Task<List<SettingValue>> GetAllAsync(SettingDefinition[] settings) |
||||
|
{ |
||||
|
return Task.FromResult(settings |
||||
|
.Select(setting => new SettingValue(setting.Name, _values.GetOrDefault(setting.Name))) |
||||
|
.ToList()); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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<IdentityRoleStore> 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<IdentityResult> 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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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<Program> |
||||
|
{ |
||||
|
protected const string NewPassword = "2q3w4E*"; |
||||
|
|
||||
|
protected Task<HttpResponseMessage> RequestPasswordTokenAsync( |
||||
|
Dictionary<string, string> additionalParameters = null, |
||||
|
string password = null) |
||||
|
{ |
||||
|
var parameters = new Dictionary<string, string> |
||||
|
{ |
||||
|
["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<string> GenerateTwoFactorCodeAsync() |
||||
|
{ |
||||
|
return WithUnitOfWorkAsync(async serviceProvider => |
||||
|
{ |
||||
|
var userManager = serviceProvider.GetRequiredService<IdentityUserManager>(); |
||||
|
var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); |
||||
|
return await userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
protected Task<int> GetAccessFailedCountAsync() |
||||
|
{ |
||||
|
return WithUnitOfWorkAsync( |
||||
|
new AbpUnitOfWorkOptions { IsTransactional = false }, |
||||
|
async serviceProvider => |
||||
|
{ |
||||
|
var userManager = serviceProvider.GetRequiredService<IdentityUserManager>(); |
||||
|
var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); |
||||
|
return await userManager.GetAccessFailedCountAsync(user); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
protected virtual Task WithUnitOfWorkAsync(Func<IServiceProvider, Task> action) |
||||
|
{ |
||||
|
return WithUnitOfWorkAsync(new AbpUnitOfWorkOptions(), action); |
||||
|
} |
||||
|
|
||||
|
protected virtual async Task WithUnitOfWorkAsync( |
||||
|
AbpUnitOfWorkOptions options, |
||||
|
Func<IServiceProvider, Task> action) |
||||
|
{ |
||||
|
using var scope = ServiceProvider.CreateScope(); |
||||
|
var uowManager = scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>(); |
||||
|
using var uow = uowManager.Begin(options); |
||||
|
await action(scope.ServiceProvider); |
||||
|
await uow.CompleteAsync(); |
||||
|
} |
||||
|
|
||||
|
protected virtual Task<TResult> WithUnitOfWorkAsync<TResult>(Func<IServiceProvider, Task<TResult>> action) |
||||
|
{ |
||||
|
return WithUnitOfWorkAsync(new AbpUnitOfWorkOptions(), action); |
||||
|
} |
||||
|
|
||||
|
protected virtual async Task<TResult> WithUnitOfWorkAsync<TResult>( |
||||
|
AbpUnitOfWorkOptions options, |
||||
|
Func<IServiceProvider, Task<TResult>> action) |
||||
|
{ |
||||
|
using var scope = ServiceProvider.CreateScope(); |
||||
|
var uowManager = scope.ServiceProvider.GetRequiredService<IUnitOfWorkManager>(); |
||||
|
using var uow = uowManager.Begin(options); |
||||
|
var result = await action(scope.ServiceProvider); |
||||
|
await uow.CompleteAsync(); |
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
@ -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<string, string> |
||||
|
{ |
||||
|
["NewPassword"] = NewPassword, |
||||
|
["ChangePasswordToken"] = changePasswordToken |
||||
|
}); |
||||
|
|
||||
|
await AssertRequiresTwoFactorAsync(passwordChangeResponse); |
||||
|
(await GetAccessFailedCountAsync()).ShouldBe(1); |
||||
|
|
||||
|
var code = await GenerateTwoFactorCodeAsync(); |
||||
|
var successResponse = await RequestPasswordTokenAsync(new Dictionary<string, string> |
||||
|
{ |
||||
|
["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<string, string> |
||||
|
{ |
||||
|
["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<OpenIddictTestSettingValueProvider>(); |
||||
|
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<string, string> |
||||
|
{ |
||||
|
["NewPassword"] = NewPassword, |
||||
|
["ChangePasswordToken"] = changePasswordToken |
||||
|
}); |
||||
|
|
||||
|
await AssertRequiresTwoFactorAsync(response); |
||||
|
(await GetAccessFailedCountAsync()).ShouldBe(1); |
||||
|
|
||||
|
var code = await GenerateTwoFactorCodeAsync(); |
||||
|
var successResponse = await RequestPasswordTokenAsync(new Dictionary<string, string> |
||||
|
{ |
||||
|
["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<IdentityUserStoreFailureSimulator>(); |
||||
|
failureSimulator.FailAfterSuccessfulUpdates(successfulUpdatesBeforeFailure); |
||||
|
HttpResponseMessage failedResponse; |
||||
|
try |
||||
|
{ |
||||
|
failedResponse = await RequestPasswordTokenAsync(new Dictionary<string, string> |
||||
|
{ |
||||
|
["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<IdentityUserStoreFailureSimulator>(); |
||||
|
failureSimulator.FailAfterSuccessfulUpdates(2); |
||||
|
HttpResponseMessage failedResponse; |
||||
|
try |
||||
|
{ |
||||
|
failedResponse = await RequestPasswordTokenAsync(new Dictionary<string, string> |
||||
|
{ |
||||
|
["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<string> 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<IdentityUserManager>(); |
||||
|
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<IdentityUserManager>(); |
||||
|
var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); |
||||
|
return ( |
||||
|
OldPasswordIsValid: await userManager.CheckPasswordAsync(user, OpenIddictPasswordGrantTestData.Password), |
||||
|
NewPasswordIsValid: await userManager.CheckPasswordAsync(user, NewPassword), |
||||
|
user.ShouldChangePasswordOnNextLogin); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -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*"; |
||||
|
} |
||||
@ -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<string, string> |
||||
|
{ |
||||
|
["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<string, string> |
||||
|
{ |
||||
|
["TwoFactorProvider"] = TokenOptions.DefaultEmailProvider, |
||||
|
["TwoFactorCode"] = "invalid-code" |
||||
|
}); |
||||
|
(await GetAccessFailedCountAsync()).ShouldBe(1); |
||||
|
|
||||
|
var response = await RequestPasswordTokenAsync(new Dictionary<string, string> |
||||
|
{ |
||||
|
["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<string, string> |
||||
|
{ |
||||
|
["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<IdentityUserStoreFailureSimulator>(); |
||||
|
failureSimulator.FailAccessFailedCountReset(); |
||||
|
HttpResponseMessage response; |
||||
|
try |
||||
|
{ |
||||
|
response = await RequestPasswordTokenAsync(new Dictionary<string, string> |
||||
|
{ |
||||
|
["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<IdentityUserStoreFailureSimulator>(); |
||||
|
failureSimulator.FailAccessFailedCountReset(); |
||||
|
HttpResponseMessage failedResponse; |
||||
|
try |
||||
|
{ |
||||
|
failedResponse = await RequestPasswordTokenAsync(new Dictionary<string, string> |
||||
|
{ |
||||
|
["RecoveryCode"] = recoveryCode |
||||
|
}); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
failureSimulator.Reset(); |
||||
|
} |
||||
|
|
||||
|
await AssertInvalidGrantAsync(failedResponse); |
||||
|
(await GetAccessFailedCountAsync()).ShouldBe(1); |
||||
|
|
||||
|
var retryResponse = await RequestPasswordTokenAsync(new Dictionary<string, string> |
||||
|
{ |
||||
|
["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<IdentityUserStoreFailureSimulator>(); |
||||
|
failureSimulator.FailAfterSuccessfulUpdates(0); |
||||
|
HttpResponseMessage failedResponse; |
||||
|
try |
||||
|
{ |
||||
|
failedResponse = await RequestPasswordTokenAsync(new Dictionary<string, string> |
||||
|
{ |
||||
|
["RecoveryCode"] = recoveryCode |
||||
|
}); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
failureSimulator.Reset(); |
||||
|
} |
||||
|
|
||||
|
await AssertInvalidGrantAsync(failedResponse); |
||||
|
(await GetAccessFailedCountAsync()).ShouldBe(1); |
||||
|
|
||||
|
var retryResponse = await RequestPasswordTokenAsync(new Dictionary<string, string> |
||||
|
{ |
||||
|
["RecoveryCode"] = recoveryCode |
||||
|
}); |
||||
|
|
||||
|
await AssertAccessTokenAsync(retryResponse); |
||||
|
(await GetAccessFailedCountAsync()).ShouldBe(0); |
||||
|
} |
||||
|
|
||||
|
private Task<string> GenerateTwoFactorCodeAfterFailedAccessAsync() |
||||
|
{ |
||||
|
return WithUnitOfWorkAsync(async serviceProvider => |
||||
|
{ |
||||
|
var userManager = serviceProvider.GetRequiredService<IdentityUserManager>(); |
||||
|
var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); |
||||
|
(await userManager.AccessFailedAsync(user)).CheckErrors(); |
||||
|
return await userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private Task<string> GenerateRecoveryCodeAfterFailedAccessAsync() |
||||
|
{ |
||||
|
return WithUnitOfWorkAsync(async serviceProvider => |
||||
|
{ |
||||
|
var userManager = serviceProvider.GetRequiredService<IdentityUserManager>(); |
||||
|
var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); |
||||
|
(await userManager.AccessFailedAsync(user)).CheckErrors(); |
||||
|
var recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 1); |
||||
|
return recoveryCodes.Single(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -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<IdentityUserManager>(); |
||||
|
var user = await userManager.FindByNameAsync(OpenIddictPasswordGrantTestData.UserName); |
||||
|
(await userManager.SetTwoFactorEnabledAsync(user, false)).CheckErrors(); |
||||
|
}); |
||||
|
|
||||
|
var response = await RequestPasswordTokenAsync(); |
||||
|
|
||||
|
await AssertAccessTokenAsync(response); |
||||
|
} |
||||
|
} |
||||
@ -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<string, string> _values = new(); |
||||
|
|
||||
|
public string Name => ProviderName; |
||||
|
|
||||
|
public void Set(string name, string value) |
||||
|
{ |
||||
|
_values[name] = value; |
||||
|
} |
||||
|
|
||||
|
public void Clear() |
||||
|
{ |
||||
|
_values.Clear(); |
||||
|
} |
||||
|
|
||||
|
public Task<string> GetOrNullAsync(SettingDefinition setting) |
||||
|
{ |
||||
|
return Task.FromResult(_values.GetOrDefault(setting.Name)); |
||||
|
} |
||||
|
|
||||
|
public Task<List<SettingValue>> GetAllAsync(SettingDefinition[] settings) |
||||
|
{ |
||||
|
return Task.FromResult(settings |
||||
|
.Select(setting => new SettingValue(setting.Name, _values.GetOrDefault(setting.Name))) |
||||
|
.ToList()); |
||||
|
} |
||||
|
} |
||||
@ -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<IdentityRoleStore> 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<IdentityResult> 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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
namespace Volo.Abp.OpenIddict.Integration; |
||||
|
|
||||
|
public class TokenVisibilityRecorder |
||||
|
{ |
||||
|
public long? TokenCountAtResponseStart { get; set; } |
||||
|
} |
||||
Loading…
Reference in new issue