Browse Source

Reject invalid identity session cookies instead of signing them out

pull/26028/head
maliming 7 days ago
parent
commit
7b24d3d49f
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 25
      framework/src/Volo.Abp.AspNetCore.Mvc.Client.Common/Volo/Abp/AspNetCore/Mvc/Client/RemoteDynamicClaimsPrincipalContributorCache.cs
  2. 6
      framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/AbpAspNetCoreMvcClientModule.cs
  3. 104
      framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/AbpRemoteDynamicClaimsCookieAuthenticationOptionsExtensions.cs
  4. 43
      framework/src/Volo.Abp.AspNetCore/Microsoft/Extensions/DependencyInjection/CookieAuthenticationOptionsExtensions.cs
  5. 9
      framework/src/Volo.Abp.Security/Volo/Abp/Security/Claims/RemoteDynamicClaimsPrincipalContributorCacheBase.cs
  6. 109
      framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/AbpRemoteDynamicClaimsCookieAuthenticationOptionsExtensions_Tests.cs
  7. 6
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs
  8. 66
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentitySessionCookieAuthenticationOptionsExtensions.cs
  9. 46
      modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/HttpContextIdentitySessionValidationResultAccessor.cs
  10. 8
      modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentitySessionChecker.cs
  11. 8
      modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentitySessionValidationResultAccessor.cs
  12. 12
      modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/NullIdentitySessionChecker.cs
  13. 17
      modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/NullIdentitySessionValidationResultAccessor.cs
  14. 6
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreTestModule.cs
  15. 35
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/FakeIdentitySessionChecker.cs
  16. 108
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/IdentitySessionCookieValidation_Tests.cs
  17. 83
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/IdentitySessionSlidingRenewal_Tests.cs
  18. 26
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/ShortLivedCookieIdentityTestModule.cs
  19. 18
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/ShortLivedCookieIdentityTestStartup.cs
  20. 19
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs
  21. 18
      modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TestTimeProvider.cs

25
framework/src/Volo.Abp.AspNetCore.Mvc.Client.Common/Volo/Abp/AspNetCore/Mvc/Client/RemoteDynamicClaimsPrincipalContributorCache.cs

@ -1,5 +1,6 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@ -46,20 +47,38 @@ public class RemoteDynamicClaimsPrincipalContributorCache : RemoteDynamicClaimsP
return await Cache.GetAsync(AbpDynamicClaimCacheItem.CalculateCacheKey(userId, tenantId));
}
protected async override Task RefreshAsync(Guid userId, Guid? tenantId = null)
public virtual Task<AbpDynamicClaimCacheItem> GetAsync(Guid userId, Guid? tenantId, string accessToken)
{
return GetAsync(userId, tenantId, () => RefreshAsync(userId, tenantId, accessToken));
}
protected override Task RefreshAsync(Guid userId, Guid? tenantId = null)
{
return RefreshAsync(userId, tenantId, null);
}
protected virtual async Task RefreshAsync(Guid userId, Guid? tenantId, string? accessToken)
{
try
{
var client = HttpClientFactory.CreateClient(HttpClientName);
var requestMessage = new HttpRequestMessage(HttpMethod.Post, AbpClaimsPrincipalFactoryOptions.Value.RemoteRefreshUrl);
await HttpClientAuthenticator.Authenticate(new RemoteServiceHttpClientAuthenticateContext(client, requestMessage, new RemoteServiceConfiguration("/"), string.Empty));
if (accessToken != null)
{
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
}
else
{
await HttpClientAuthenticator.Authenticate(new RemoteServiceHttpClientAuthenticateContext(client, requestMessage, new RemoteServiceConfiguration("/"), string.Empty));
}
var response = await client.SendAsync(requestMessage);
response.EnsureSuccessStatusCode();
}
catch (Exception e)
{
Logger.LogWarning(e, $"Failed to refresh remote claims for user: {userId}");
await ApplicationConfigurationDtoCache.RemoveAsync(await CacheHelper.CreateCacheKeyAsync(CurrentUser.Id));
await ApplicationConfigurationDtoCache.RemoveAsync(await CacheHelper.CreateCacheKeyAsync(CurrentUser.Id ?? userId));
throw;
}
}

6
framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/AbpAspNetCoreMvcClientModule.cs

@ -1,4 +1,5 @@
using System;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.EventBus;
using Volo.Abp.Modularity;
@ -22,4 +23,9 @@ public class AbpAspNetCoreMvcClientModule : AbpModule
});
}
}
public override void PostConfigureServices(ServiceConfigurationContext context)
{
context.Services.PostConfigureAll<CookieAuthenticationOptions>(cookieOptions => cookieOptions.ValidateRemoteDynamicClaims());
}
}

104
framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/AbpRemoteDynamicClaimsCookieAuthenticationOptionsExtensions.cs

@ -0,0 +1,104 @@
using System;
using System.Linq;
using System.Security.Claims;
using System.Security.Principal;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Security.Claims;
namespace Volo.Abp.AspNetCore.Mvc.Client;
public static class AbpRemoteDynamicClaimsCookieAuthenticationOptionsExtensions
{
public static CookieAuthenticationOptions ValidateRemoteDynamicClaims(this CookieAuthenticationOptions options)
{
var previousOnCheckSlidingExpiration = options.Events.OnCheckSlidingExpiration;
options.Events.OnCheckSlidingExpiration = async cookieSlidingExpirationContext =>
{
await previousOnCheckSlidingExpiration(cookieSlidingExpirationContext);
if (cookieSlidingExpirationContext.ShouldRenew &&
cookieSlidingExpirationContext.Principal != null &&
!await AreRemoteDynamicClaimsValidAsync(cookieSlidingExpirationContext.HttpContext, cookieSlidingExpirationContext.Scheme.Name, cookieSlidingExpirationContext.Principal, cookieSlidingExpirationContext.Properties))
{
cookieSlidingExpirationContext.ShouldRenew = false;
}
};
var previousOnValidatePrincipal = options.Events.OnValidatePrincipal;
options.Events.OnValidatePrincipal = async cookieValidatePrincipalContext =>
{
await previousOnValidatePrincipal(cookieValidatePrincipalContext);
if (cookieValidatePrincipalContext.Principal != null &&
!await AreRemoteDynamicClaimsValidAsync(cookieValidatePrincipalContext.HttpContext, cookieValidatePrincipalContext.Scheme.Name, cookieValidatePrincipalContext.Principal, cookieValidatePrincipalContext.Properties))
{
cookieValidatePrincipalContext.ShouldRenew = false;
cookieValidatePrincipalContext.RejectPrincipal();
}
};
return options;
}
private static async Task<bool> AreRemoteDynamicClaimsValidAsync(HttpContext httpContext, string scheme, ClaimsPrincipal principal, AuthenticationProperties properties)
{
if (scheme == IdentityConstants.ExternalScheme ||
scheme == IdentityConstants.TwoFactorUserIdScheme ||
scheme == IdentityConstants.TwoFactorRememberMeScheme)
{
return true;
}
var identity = principal.Identities.FirstOrDefault();
var userId = identity?.FindUserId();
if (userId == null)
{
return true;
}
var abpClaimsPrincipalFactoryOptions = httpContext.RequestServices.GetRequiredService<IOptions<AbpClaimsPrincipalFactoryOptions>>().Value;
if (!abpClaimsPrincipalFactoryOptions.IsDynamicClaimsEnabled || !abpClaimsPrincipalFactoryOptions.IsRemoteRefreshEnabled)
{
return true;
}
var accessToken = properties.GetTokenValue("access_token");
if (accessToken.IsNullOrWhiteSpace())
{
return true;
}
var dynamicClaimsCache = httpContext.RequestServices.GetService<RemoteDynamicClaimsPrincipalContributorCache>();
if (dynamicClaimsCache == null)
{
return true;
}
try
{
// The multi-tenancy middleware hasn't resolved the tenant yet, but the dynamic claims are cached per tenant.
var tenantId = identity!.FindTenantId();
using (httpContext.RequestServices.GetRequiredService<ICurrentTenant>().Change(tenantId))
{
await dynamicClaimsCache.GetAsync(userId.Value, tenantId, accessToken);
}
return true;
}
catch (Exception e)
{
httpContext.RequestServices
.GetRequiredService<ILogger<AbpAspNetCoreMvcClientModule>>()
.LogWarning(e, $"Failed to refresh remote dynamic claims for user: {userId.Value}, the authentication cookie is rejected.");
return false;
}
}
}

43
framework/src/Volo.Abp.AspNetCore/Microsoft/Extensions/DependencyInjection/CookieAuthenticationOptionsExtensions.cs

@ -20,6 +20,18 @@ public static class CookieAuthenticationOptionsExtensions
{
advance ??= TimeSpan.FromMinutes(3);
validationInterval ??= TimeSpan.FromMinutes(1);
var previousOnCheckSlidingExpiration = options.Events.OnCheckSlidingExpiration;
options.Events.OnCheckSlidingExpiration = async slidingExpirationContext =>
{
await previousOnCheckSlidingExpiration(slidingExpirationContext);
if (slidingExpirationContext.ShouldRenew && IsAccessTokenExpired(slidingExpirationContext.Properties, advance.Value))
{
slidingExpirationContext.ShouldRenew = false;
}
};
var previousHandler = options.Events.OnValidatePrincipal;
options.Events.OnValidatePrincipal = async principalContext =>
{
@ -31,12 +43,10 @@ public static class CookieAuthenticationOptionsExtensions
var logger = principalContext.HttpContext.RequestServices.GetRequiredService<ILogger<CookieAuthenticationOptions>>();
var tokenExpiresAt = principalContext.Properties.GetString(".Token.expires_at");
if (!tokenExpiresAt.IsNullOrWhiteSpace() && DateTimeOffset.TryParseExact(tokenExpiresAt, "o", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var expiresAt) &&
expiresAt <= DateTimeOffset.UtcNow.Add(advance.Value))
if (IsAccessTokenExpired(principalContext.Properties, advance.Value))
{
logger.LogInformation("The access_token expires within {AdvanceSeconds}s; signing out.", advance.Value.TotalSeconds);
await SignOutAndInvokePreviousHandlerAsync(principalContext, previousHandler);
logger.LogInformation("The access_token expires within {AdvanceSeconds}s; rejecting the principal.", advance.Value.TotalSeconds);
await RejectPrincipalAndInvokePreviousHandlerAsync(principalContext, previousHandler);
return;
}
@ -74,14 +84,14 @@ public static class CookieAuthenticationOptionsExtensions
if (response.IsError)
{
logger.LogError("Token introspection error: {Error}", response.Error);
await SignOutAndInvokePreviousHandlerAsync(principalContext, previousHandler);
await RejectPrincipalAndInvokePreviousHandlerAsync(principalContext, previousHandler);
return;
}
if (!response.IsActive)
{
logger.LogError("The access_token is not active.");
await SignOutAndInvokePreviousHandlerAsync(principalContext, previousHandler);
await RejectPrincipalAndInvokePreviousHandlerAsync(principalContext, previousHandler);
return;
}
@ -91,7 +101,7 @@ public static class CookieAuthenticationOptionsExtensions
else
{
logger.LogError("The access_token is not found in the cookie properties. Ensure SaveTokens of OpenIdConnectOptions is true.");
await SignOutAsync(principalContext);
await RejectPrincipalAsync(principalContext);
}
}
@ -113,10 +123,19 @@ public static class CookieAuthenticationOptionsExtensions
return openIdConnectOptions;
}
private static async Task SignOutAsync(CookieValidatePrincipalContext principalContext)
private static bool IsAccessTokenExpired(AuthenticationProperties properties, TimeSpan advance)
{
var tokenExpiresAt = properties.GetString(".Token.expires_at");
return !tokenExpiresAt.IsNullOrWhiteSpace() &&
DateTimeOffset.TryParseExact(tokenExpiresAt, "o", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var expiresAt) &&
expiresAt <= DateTimeOffset.UtcNow.Add(advance);
}
private static Task RejectPrincipalAsync(CookieValidatePrincipalContext principalContext)
{
principalContext.ShouldRenew = false;
principalContext.RejectPrincipal();
await principalContext.HttpContext.SignOutAsync(principalContext.Scheme.Name);
return Task.CompletedTask;
}
private static Task InvokePreviousHandlerAsync(CookieValidatePrincipalContext principalContext, Func<CookieValidatePrincipalContext, Task>? previousHandler)
@ -124,9 +143,9 @@ public static class CookieAuthenticationOptionsExtensions
return previousHandler != null ? previousHandler(principalContext) : Task.CompletedTask;
}
private static async Task SignOutAndInvokePreviousHandlerAsync(CookieValidatePrincipalContext principalContext, Func<CookieValidatePrincipalContext, Task>? previousHandler)
private static async Task RejectPrincipalAndInvokePreviousHandlerAsync(CookieValidatePrincipalContext principalContext, Func<CookieValidatePrincipalContext, Task>? previousHandler)
{
await SignOutAsync(principalContext);
await RejectPrincipalAsync(principalContext);
await InvokePreviousHandlerAsync(principalContext, previousHandler);
}
}

9
framework/src/Volo.Abp.Security/Volo/Abp/Security/Claims/RemoteDynamicClaimsPrincipalContributorCacheBase.cs

@ -19,7 +19,12 @@ public abstract class RemoteDynamicClaimsPrincipalContributorCacheBase<TContribu
Logger = NullLogger<TContributorCache>.Instance;
}
public async Task<AbpDynamicClaimCacheItem> GetAsync(Guid userId, Guid? tenantId = null)
public Task<AbpDynamicClaimCacheItem> GetAsync(Guid userId, Guid? tenantId = null)
{
return GetAsync(userId, tenantId, () => RefreshAsync(userId, tenantId));
}
protected virtual async Task<AbpDynamicClaimCacheItem> GetAsync(Guid userId, Guid? tenantId, Func<Task> refresh)
{
Logger.LogDebug($"Get dynamic claims cache for user: {userId}");
var dynamicClaims = await GetCacheAsync(userId, tenantId);
@ -31,7 +36,7 @@ public abstract class RemoteDynamicClaimsPrincipalContributorCacheBase<TContribu
Logger.LogDebug($"Refresh dynamic claims for user: {userId} from remote service.");
try
{
await RefreshAsync(userId, tenantId);
await refresh();
}
catch (Exception e)
{

109
framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/AbpRemoteDynamicClaimsCookieAuthenticationOptionsExtensions_Tests.cs

@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NSubstitute;
using Shouldly;
using Volo.Abp.Caching;
using Volo.Abp.Http.Client.Authentication;
using Volo.Abp.Security.Claims;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.Client;
public class AbpRemoteDynamicClaimsCookieAuthenticationOptionsExtensions_Tests : AbpAspNetCoreMvcClientTestBase
{
private readonly RemoteRefreshResponseHandler _remoteRefreshResponseHandler = new();
protected override void AfterAddApplication(IServiceCollection services)
{
var httpClientFactory = Substitute.For<IHttpClientFactory>();
httpClientFactory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(_remoteRefreshResponseHandler)
{
BaseAddress = new Uri("https://localhost/")
});
services.Replace(ServiceDescriptor.Singleton(httpClientFactory));
services.Replace(ServiceDescriptor.Transient(_ => Substitute.For<IRemoteServiceHttpClientAuthenticator>()));
services.Configure<AbpClaimsPrincipalFactoryOptions>(options =>
{
options.IsDynamicClaimsEnabled = true;
});
}
[Fact]
public async Task Should_Reject_The_Principal_When_The_Remote_Refresh_Is_Rejected()
{
var context = await ValidatePrincipalAsync(Guid.NewGuid());
context.Principal.ShouldBeNull();
context.ShouldRenew.ShouldBeFalse();
_remoteRefreshResponseHandler.ReceivedAccessTokens.ShouldBe(new[] { "test-access-token" });
}
[Fact]
public async Task Should_Keep_The_Principal_When_The_Dynamic_Claims_Are_Cached()
{
var userId = Guid.NewGuid();
await GetRequiredService<IDistributedCache<AbpDynamicClaimCacheItem>>().SetAsync(
AbpDynamicClaimCacheItem.CalculateCacheKey(userId, null),
new AbpDynamicClaimCacheItem());
var context = await ValidatePrincipalAsync(userId);
context.Principal.ShouldNotBeNull();
_remoteRefreshResponseHandler.ReceivedAccessTokens.ShouldBeEmpty();
}
[Fact]
public async Task Should_Keep_The_Principal_When_There_Is_No_Access_Token()
{
var context = await ValidatePrincipalAsync(Guid.NewGuid(), accessToken: null);
context.Principal.ShouldNotBeNull();
_remoteRefreshResponseHandler.ReceivedAccessTokens.ShouldBeEmpty();
}
private async Task<CookieValidatePrincipalContext> ValidatePrincipalAsync(Guid userId, string accessToken = "test-access-token")
{
var options = new CookieAuthenticationOptions().ValidateRemoteDynamicClaims();
var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme, AbpClaimTypes.UserName, AbpClaimTypes.Role);
identity.AddClaim(new Claim(AbpClaimTypes.UserId, userId.ToString()));
identity.AddClaim(new Claim(AbpClaimTypes.UserName, "john"));
var properties = new AuthenticationProperties();
if (accessToken != null)
{
properties.StoreTokens(new[] { new AuthenticationToken { Name = "access_token", Value = accessToken } });
}
var context = new CookieValidatePrincipalContext(
new DefaultHttpContext { RequestServices = ServiceProvider },
new AuthenticationScheme(CookieAuthenticationDefaults.AuthenticationScheme, null, typeof(CookieAuthenticationHandler)),
options,
new AuthenticationTicket(new ClaimsPrincipal(identity), properties, CookieAuthenticationDefaults.AuthenticationScheme));
await options.Events.ValidatePrincipal(context);
return context;
}
private class RemoteRefreshResponseHandler : HttpMessageHandler
{
public List<string> ReceivedAccessTokens { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
ReceivedAccessTokens.Add(request.Headers.Authorization?.Parameter);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized));
}
}
}

6
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreModule.cs

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@ -33,6 +34,8 @@ public class AbpIdentityAspNetCoreModule : AbpModule
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddHttpContextAccessor();
Configure<IdentityOptions>(options =>
{
options.Tokens.PasswordResetTokenProvider = AbpPasswordResetTokenProvider.ProviderName;
@ -61,6 +64,9 @@ public class AbpIdentityAspNetCoreModule : AbpModule
public override void PostConfigureServices(ServiceConfigurationContext context)
{
context.Services.PostConfigure<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme,
cookieOptions => cookieOptions.ValidateIdentitySession());
// Replace the default UserValidator with AbpIdentityUserValidator
context.Services.RemoveAll(x => x.ServiceType == typeof(IUserValidator<IdentityUser>) && x.ImplementationType == typeof(UserValidator<IdentityUser>));
context.Services.AddAbpOptions<SecurityStampValidatorOptions>()

66
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/AbpIdentitySessionCookieAuthenticationOptionsExtensions.cs

@ -0,0 +1,66 @@
using System;
using System.Security.Claims;
using System.Security.Principal;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Security.Claims;
namespace Volo.Abp.Identity.AspNetCore;
public static class AbpIdentitySessionCookieAuthenticationOptionsExtensions
{
public static CookieAuthenticationOptions ValidateIdentitySession(this CookieAuthenticationOptions options)
{
var previousOnCheckSlidingExpiration = options.Events.OnCheckSlidingExpiration;
options.Events.OnCheckSlidingExpiration = async cookieSlidingExpirationContext =>
{
await previousOnCheckSlidingExpiration(cookieSlidingExpirationContext);
if (cookieSlidingExpirationContext.ShouldRenew &&
!await IsIdentitySessionValidAsync(cookieSlidingExpirationContext.HttpContext, cookieSlidingExpirationContext.Principal))
{
cookieSlidingExpirationContext.ShouldRenew = false;
}
};
var previousOnValidatePrincipal = options.Events.OnValidatePrincipal;
options.Events.OnValidatePrincipal = async cookieValidatePrincipalContext =>
{
await previousOnValidatePrincipal(cookieValidatePrincipalContext);
if (cookieValidatePrincipalContext.Principal != null &&
!await IsIdentitySessionValidAsync(cookieValidatePrincipalContext.HttpContext, cookieValidatePrincipalContext.Principal))
{
cookieValidatePrincipalContext.ShouldRenew = false;
cookieValidatePrincipalContext.RejectPrincipal();
}
};
return options;
}
private static async Task<bool> IsIdentitySessionValidAsync(HttpContext httpContext, ClaimsPrincipal principal)
{
var sessionId = principal.FindSessionId();
if (sessionId.IsNullOrWhiteSpace())
{
return true;
}
if (!httpContext.RequestServices.GetRequiredService<IOptions<AbpClaimsPrincipalFactoryOptions>>().Value.IsDynamicClaimsEnabled)
{
return true;
}
var currentTenant = httpContext.RequestServices.GetRequiredService<ICurrentTenant>();
var identitySessionChecker = httpContext.RequestServices.GetRequiredService<IIdentitySessionChecker>();
using (currentTenant.Change(principal.FindTenantId()))
{
return await identitySessionChecker.IsValidateAsync(sessionId);
}
}
}

46
modules/identity/src/Volo.Abp.Identity.AspNetCore/Volo/Abp/Identity/AspNetCore/HttpContextIdentitySessionValidationResultAccessor.cs

@ -0,0 +1,46 @@
using System.Collections.Generic;
using Microsoft.AspNetCore.Http;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Identity.AspNetCore;
[Dependency(ReplaceServices = true)]
public class HttpContextIdentitySessionValidationResultAccessor : IIdentitySessionValidationResultAccessor, ITransientDependency
{
public const string HttpContextItemName = "__AbpIdentitySessionValidationResults";
private readonly IHttpContextAccessor _httpContextAccessor;
public HttpContextIdentitySessionValidationResultAccessor(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public bool? GetOrNull(string sessionId)
{
return GetResults()?.TryGetValue(sessionId, out var isValid) == true ? isValid : null;
}
public void Set(string sessionId, bool isValid)
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null)
{
return;
}
var results = GetResults();
if (results == null)
{
results = new Dictionary<string, bool>();
httpContext.Items[HttpContextItemName] = results;
}
results[sessionId] = isValid;
}
private Dictionary<string, bool> GetResults()
{
return _httpContextAccessor.HttpContext?.Items[HttpContextItemName] as Dictionary<string, bool>;
}
}

8
modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentitySessionChecker.cs

@ -0,0 +1,8 @@
using System.Threading.Tasks;
namespace Volo.Abp.Identity;
public interface IIdentitySessionChecker
{
Task<bool> IsValidateAsync(string sessionId);
}

8
modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/IIdentitySessionValidationResultAccessor.cs

@ -0,0 +1,8 @@
namespace Volo.Abp.Identity;
public interface IIdentitySessionValidationResultAccessor
{
bool? GetOrNull(string sessionId);
void Set(string sessionId, bool isValid);
}

12
modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/NullIdentitySessionChecker.cs

@ -0,0 +1,12 @@
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Identity;
public class NullIdentitySessionChecker : IIdentitySessionChecker, ISingletonDependency
{
public Task<bool> IsValidateAsync(string sessionId)
{
return Task.FromResult(true);
}
}

17
modules/identity/src/Volo.Abp.Identity.Domain/Volo/Abp/Identity/NullIdentitySessionValidationResultAccessor.cs

@ -0,0 +1,17 @@
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Identity;
public class NullIdentitySessionValidationResultAccessor : IIdentitySessionValidationResultAccessor, ISingletonDependency
{
public static NullIdentitySessionValidationResultAccessor Instance { get; } = new();
public bool? GetOrNull(string sessionId)
{
return null;
}
public void Set(string sessionId, bool isValid)
{
}
}

6
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/AbpIdentityAspNetCoreTestModule.cs

@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.AspNetCore.Mvc;
using Volo.Abp.AspNetCore.TestBase;
using Volo.Abp.Modularity;
using Volo.Abp.Security.Claims;
namespace Volo.Abp.Identity.AspNetCore;
@ -29,6 +30,11 @@ public class AbpIdentityAspNetCoreTestModule : AbpModule
{
options.ExternalLoginProviders.Add<FakeExternalLoginProvider>(FakeExternalLoginProvider.Name);
});
Configure<AbpClaimsPrincipalFactoryOptions>(options =>
{
options.IsDynamicClaimsEnabled = true;
});
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)

35
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/FakeIdentitySessionChecker.cs

@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Security.Principal;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Security.Claims;
namespace Volo.Abp.Identity.AspNetCore;
[Dependency(ReplaceServices = true)]
public class FakeIdentitySessionChecker : IIdentitySessionChecker, ISingletonDependency
{
public HashSet<string> RevokedSessionIds { get; } = new();
public Task<bool> IsValidateAsync(string sessionId)
{
return Task.FromResult(!RevokedSessionIds.Contains(sessionId));
}
}
public class TestSessionIdClaimsPrincipalContributor : IAbpClaimsPrincipalContributor, ITransientDependency
{
public Task ContributeAsync(AbpClaimsPrincipalContributorContext context)
{
var identity = context.ClaimsPrincipal.Identities.FirstOrDefault();
if (identity != null && identity.FindSessionId() == null)
{
identity.AddClaim(new Claim(AbpClaimTypes.SessionId, Guid.NewGuid().ToString()));
}
return Task.CompletedTask;
}
}

108
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/IdentitySessionCookieValidation_Tests.cs

@ -0,0 +1,108 @@
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Shouldly;
using Xunit;
namespace Volo.Abp.Identity.AspNetCore;
public class IdentitySessionCookieValidation_Tests : AbpIdentityAspNetCoreTestBase
{
[Fact]
public async Task Should_Authenticate_The_Cookie_When_The_Session_Is_Valid()
{
var cookie = await LoginAsync();
using (var response = await GetCurrentUserAsync(cookie))
{
(await response.Content.ReadAsStringAsync()).ShouldStartWith("admin|");
}
}
[Fact]
public async Task Should_Reject_The_Cookie_Without_Writing_It_When_The_Session_Is_Revoked()
{
var cookie = await LoginAsync();
var sessionId = await GetSessionIdAsync(cookie);
GetRequiredService<FakeIdentitySessionChecker>().RevokedSessionIds.Add(sessionId);
using (var response = await GetCurrentUserAsync(cookie))
{
(await response.Content.ReadAsStringAsync()).ShouldBe("anonymous");
response.Headers.Contains("Set-Cookie").ShouldBeFalse();
}
}
[Fact]
public async Task Account_Switch_Cookie_Should_Survive_An_In_Flight_Request_Carrying_The_Old_Revoked_Cookie()
{
var oldCookie = await LoginAsync();
var oldSessionId = await GetSessionIdAsync(oldCookie);
var newCookie = await SwitchAccountAsync(oldCookie, "john.nash");
newCookie.ShouldNotBeNull();
newCookie.ShouldNotBe(oldCookie);
// The switch revoked the old session.
GetRequiredService<FakeIdentitySessionChecker>().RevokedSessionIds.Add(oldSessionId);
// An in-flight request still carrying the old cookie must be rejected without emitting a
// delete-cookie that would wipe the freshly issued account-switch cookie.
using (var response = await GetCurrentUserAsync(oldCookie))
{
(await response.Content.ReadAsStringAsync()).ShouldBe("anonymous");
response.Headers.Contains("Set-Cookie").ShouldBeFalse();
}
using (var response = await GetCurrentUserAsync(newCookie))
{
(await response.Content.ReadAsStringAsync()).ShouldStartWith("john.nash|");
}
}
private async Task<string> SwitchAccountAsync(string cookie, string userName)
{
using (var requestMessage = new HttpRequestMessage(HttpMethod.Get, "api/signin-test/switch-account?userName=" + userName))
{
requestMessage.Headers.Add("Cookie", cookie);
using (var response = await Client.SendAsync(requestMessage))
{
(await response.Content.ReadAsStringAsync()).ShouldBe("Succeeded");
var cookieName = CookieAuthenticationDefaults.CookiePrefix + IdentityConstants.ApplicationScheme;
return response.Headers.GetValues("Set-Cookie")
.Select(x => x.Split(';')[0])
.Last(x => x.StartsWith(cookieName) && x.Length > cookieName.Length + 1);
}
}
}
private async Task<string> LoginAsync()
{
using (var response = await Client.GetAsync("api/signin-test/password?userName=admin&password=1q2w3E*"))
{
(await response.Content.ReadAsStringAsync()).ShouldBe("Succeeded");
var cookie = response.Headers.GetValues("Set-Cookie").First(x => x.StartsWith(CookieAuthenticationDefaults.CookiePrefix + IdentityConstants.ApplicationScheme));
return cookie.Split(';')[0];
}
}
private async Task<string> GetSessionIdAsync(string cookie)
{
using (var response = await GetCurrentUserAsync(cookie))
{
return (await response.Content.ReadAsStringAsync()).Split('|')[1];
}
}
private async Task<HttpResponseMessage> GetCurrentUserAsync(string cookie)
{
using (var requestMessage = new HttpRequestMessage(HttpMethod.Get, "api/signin-test/current-user"))
{
requestMessage.Headers.Add("Cookie", cookie);
return await Client.SendAsync(requestMessage);
}
}
}

83
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/IdentitySessionSlidingRenewal_Tests.cs

@ -0,0 +1,83 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Shouldly;
using Volo.Abp.AspNetCore.TestBase;
using Xunit;
namespace Volo.Abp.Identity.AspNetCore;
public class IdentitySessionSlidingRenewal_Tests : AbpAspNetCoreIntegratedTestBase<ShortLivedCookieIdentityTestStartup>
{
private static readonly string AuthenticationCookieName = CookieAuthenticationDefaults.CookiePrefix + IdentityConstants.ApplicationScheme;
[Fact]
public async Task Should_Renew_The_Cookie_When_The_Session_Is_Valid_Past_The_Half_Life()
{
var cookie = await LoginAsync();
// Past the half-life of the 10s window, so a renewal is scheduled for a still-valid session.
GetRequiredService<TestTimeProvider>().Advance(TimeSpan.FromSeconds(6));
using (var response = await SendAsync("api/signin-test/current-user", cookie))
{
(await response.Content.ReadAsStringAsync()).ShouldStartWith("admin|");
GetAuthCookieHeader(response).ShouldNotBeNull();
}
}
[Fact]
public async Task Should_Not_Renew_The_Cookie_When_The_Session_Is_Already_Revoked_At_Auth_Time()
{
var cookie = await LoginAsync();
var sessionId = await GetSessionIdAsync(cookie);
GetRequiredService<FakeIdentitySessionChecker>().RevokedSessionIds.Add(sessionId);
// Same instant the valid session would renew at, so the missing renewal is the fix, not expiry.
GetRequiredService<TestTimeProvider>().Advance(TimeSpan.FromSeconds(6));
using (var response = await SendAsync("api/signin-test/current-user", cookie))
{
(await response.Content.ReadAsStringAsync()).ShouldBe("anonymous");
GetAuthCookieHeader(response).ShouldBeNull();
}
}
private async Task<string> LoginAsync()
{
using (var response = await Client.GetAsync("api/signin-test/password?userName=admin&password=1q2w3E*"))
{
(await response.Content.ReadAsStringAsync()).ShouldBe("Succeeded");
var cookie = response.Headers.GetValues("Set-Cookie").First(x => x.StartsWith(AuthenticationCookieName));
return cookie.Split(';')[0];
}
}
private async Task<string> GetSessionIdAsync(string cookie)
{
using (var response = await SendAsync("api/signin-test/current-user", cookie))
{
return (await response.Content.ReadAsStringAsync()).Split('|')[1];
}
}
private async Task<HttpResponseMessage> SendAsync(string url, string cookie)
{
using (var requestMessage = new HttpRequestMessage(HttpMethod.Get, url))
{
requestMessage.Headers.Add("Cookie", cookie);
return await Client.SendAsync(requestMessage);
}
}
private static string GetAuthCookieHeader(HttpResponseMessage response)
{
return response.Headers.TryGetValues("Set-Cookie", out var values)
? values.FirstOrDefault(x => x.StartsWith(AuthenticationCookieName))
: null;
}
}

26
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/ShortLivedCookieIdentityTestModule.cs

@ -0,0 +1,26 @@
using System;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.Modularity;
namespace Volo.Abp.Identity.AspNetCore;
[DependsOn(typeof(AbpIdentityAspNetCoreTestModule))]
public class ShortLivedCookieIdentityTestModule : AbpModule
{
public override void PostConfigureServices(ServiceConfigurationContext context)
{
var timeProvider = new TestTimeProvider();
context.Services.AddSingleton(timeProvider);
// Short lifetime with sliding expiration on a controllable clock, so a request past the
// half-life schedules a cookie renewal without real time delays.
context.Services.PostConfigure<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme, options =>
{
options.ExpireTimeSpan = TimeSpan.FromSeconds(10);
options.SlidingExpiration = true;
options.TimeProvider = timeProvider;
});
}
}

18
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/ShortLivedCookieIdentityTestStartup.cs

@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Volo.Abp.Identity.AspNetCore;
public class ShortLivedCookieIdentityTestStartup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddApplication<ShortLivedCookieIdentityTestModule>();
}
public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory)
{
app.InitializeApplication();
}
}

19
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/SignInTestController.cs

@ -3,7 +3,9 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.AspNetCore.Mvc;
using Volo.Abp.Users;
namespace Volo.Abp.Identity.AspNetCore;
@ -30,6 +32,23 @@ public class SignInTestController : AbpController
return Content(result.ToString());
}
[Route("current-user")]
public ActionResult GetCurrentUser()
{
return Content(CurrentUser.IsAuthenticated ? CurrentUser.UserName + "|" + CurrentUser.FindSessionId() : "anonymous");
}
[Route("switch-account")]
public async Task<ActionResult> SwitchAccount(string userName)
{
// Account switch (LinkLogin / impersonation): sign out the current identity and sign in
// as another user within the same request.
await _signInManager.SignOutAsync();
var user = await _signInManager.UserManager.FindByNameAsync(userName);
await _signInManager.SignInAsync(user, isPersistent: false);
return Content("Succeeded");
}
[Route("write-two-factor-cookie")]
public async Task<ActionResult> WriteTwoFactorCookie(string userId)
{

18
modules/identity/test/Volo.Abp.Identity.AspNetCore.Tests/Volo/Abp/Identity/AspNetCore/TestTimeProvider.cs

@ -0,0 +1,18 @@
using System;
namespace Volo.Abp.Identity.AspNetCore;
public class TestTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow()
{
return _utcNow;
}
public void Advance(TimeSpan timeSpan)
{
_utcNow = _utcNow.Add(timeSpan);
}
}
Loading…
Cancel
Save