mirror of https://github.com/abpframework/abp.git
Browse Source
- move OnRefreshingPrincipal logic to AbpOpenIddictSecurityStampValidatorOptionsExtensions - add unit tests for removal and callback composition orderpull/25711/head
5 changed files with 205 additions and 178 deletions
@ -1,3 +0,0 @@ |
|||||
using System.Runtime.CompilerServices; |
|
||||
|
|
||||
[assembly: InternalsVisibleTo("Volo.Abp.OpenIddict.AspNetCore.Tests")] |
|
||||
@ -0,0 +1,48 @@ |
|||||
|
using System.Linq; |
||||
|
using Microsoft.AspNetCore.Identity; |
||||
|
using Volo.Abp.Security.Claims; |
||||
|
|
||||
|
namespace Volo.Abp.OpenIddict; |
||||
|
|
||||
|
public static class AbpOpenIddictSecurityStampValidatorOptionsExtensions |
||||
|
{ |
||||
|
public static SecurityStampValidatorOptions RemoveClientIdClaim(this SecurityStampValidatorOptions options) |
||||
|
{ |
||||
|
// OpenIddictClaimsPrincipalContributor stamps the ambient /connect/authorize request's client_id
|
||||
|
// onto every principal built by CreateUserPrincipalAsync. That is meant for the access_token, but the
|
||||
|
// cookie security-stamp validator rebuilds the interactive cookie through the same method, so a refresh
|
||||
|
// that lands on /connect/authorize leaks client_id into the cookie and corrupts ICurrentClient.
|
||||
|
// OnRefreshingPrincipal is the only place the cookie is re-written and never runs for token issuance.
|
||||
|
var previousOnRefreshingPrincipal = options.OnRefreshingPrincipal; |
||||
|
options.OnRefreshingPrincipal = async context => |
||||
|
{ |
||||
|
// Run the previous callback first: ABP Identity's UpdatePrincipal copies claims that are on the
|
||||
|
// current cookie but not on the new principal forward, re-introducing client_id from an
|
||||
|
// already-corrupted cookie. Removing it afterwards lets such a cookie self-heal on its next refresh.
|
||||
|
if (previousOnRefreshingPrincipal != null) |
||||
|
{ |
||||
|
await previousOnRefreshingPrincipal.Invoke(context); |
||||
|
} |
||||
|
|
||||
|
RemoveClientIdClaimsFromPrincipal(context); |
||||
|
}; |
||||
|
|
||||
|
return options; |
||||
|
} |
||||
|
|
||||
|
private static void RemoveClientIdClaimsFromPrincipal(SecurityStampRefreshingPrincipalContext context) |
||||
|
{ |
||||
|
if (context.NewPrincipal == null) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
foreach (var identity in context.NewPrincipal.Identities) |
||||
|
{ |
||||
|
foreach (var claim in identity.FindAll(AbpClaimTypes.ClientId).ToArray()) |
||||
|
{ |
||||
|
identity.RemoveClaim(claim); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,154 @@ |
|||||
|
using System; |
||||
|
using System.Linq; |
||||
|
using System.Security.Claims; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.AspNetCore.Identity; |
||||
|
using Shouldly; |
||||
|
using Volo.Abp.Security.Claims; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Volo.Abp.OpenIddict; |
||||
|
|
||||
|
public class AbpOpenIddictSecurityStampValidatorOptionsExtensions_Tests |
||||
|
{ |
||||
|
[Fact] |
||||
|
public async Task Should_Remove_ClientId_From_Refreshed_Principal() |
||||
|
{ |
||||
|
var context = new SecurityStampRefreshingPrincipalContext |
||||
|
{ |
||||
|
NewPrincipal = CreateCookiePrincipal( |
||||
|
new Claim(AbpClaimTypes.UserId, "user-1"), |
||||
|
new Claim(AbpClaimTypes.ClientId, "MyClient")) |
||||
|
}; |
||||
|
|
||||
|
await RefreshPrincipalAsync(context); |
||||
|
|
||||
|
context.NewPrincipal.FindAll(AbpClaimTypes.ClientId).ShouldBeEmpty(); |
||||
|
context.NewPrincipal.FindFirst(AbpClaimTypes.UserId).Value.ShouldBe("user-1"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Not_Touch_A_Principal_Without_ClientId() |
||||
|
{ |
||||
|
var context = new SecurityStampRefreshingPrincipalContext |
||||
|
{ |
||||
|
NewPrincipal = CreateCookiePrincipal(new Claim(AbpClaimTypes.UserId, "user-1")) |
||||
|
}; |
||||
|
|
||||
|
await RefreshPrincipalAsync(context); |
||||
|
|
||||
|
context.NewPrincipal.Claims.Count().ShouldBe(1); |
||||
|
context.NewPrincipal.FindFirst(AbpClaimTypes.UserId).Value.ShouldBe("user-1"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Remove_Every_ClientId_Claim_From_Every_Identity() |
||||
|
{ |
||||
|
var principal = new ClaimsPrincipal(); |
||||
|
principal.AddIdentity(new ClaimsIdentity( |
||||
|
new[] { new Claim(AbpClaimTypes.UserId, "user-1"), new Claim(AbpClaimTypes.ClientId, "Client-A") }, |
||||
|
IdentityConstants.ApplicationScheme)); |
||||
|
principal.AddIdentity(new ClaimsIdentity( |
||||
|
new[] { new Claim(AbpClaimTypes.ClientId, "Client-B"), new Claim(AbpClaimTypes.ClientId, "Client-C") }, |
||||
|
IdentityConstants.ApplicationScheme)); |
||||
|
|
||||
|
var context = new SecurityStampRefreshingPrincipalContext { NewPrincipal = principal }; |
||||
|
|
||||
|
await RefreshPrincipalAsync(context); |
||||
|
|
||||
|
context.NewPrincipal.FindAll(AbpClaimTypes.ClientId).ShouldBeEmpty(); |
||||
|
context.NewPrincipal.FindFirst(AbpClaimTypes.UserId).Value.ShouldBe("user-1"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Not_Throw_When_New_Principal_Is_Null() |
||||
|
{ |
||||
|
var context = new SecurityStampRefreshingPrincipalContext { NewPrincipal = null }; |
||||
|
|
||||
|
await Should.NotThrowAsync(() => RefreshPrincipalAsync(context)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Remove_ClientId_After_Running_The_Previously_Registered_Callback() |
||||
|
{ |
||||
|
// The real module order: ABP Identity's callback is registered first, the removal after it. Identity's
|
||||
|
// SecurityStampValidatorCallback.UpdatePrincipal copies client_id from an already-corrupted cookie onto
|
||||
|
// the refreshed principal; the removal still strips it, so the cookie self-heals on its next refresh.
|
||||
|
var previousCallbackRan = false; |
||||
|
Task PreviousCallback(SecurityStampRefreshingPrincipalContext context) |
||||
|
{ |
||||
|
previousCallbackRan = true; |
||||
|
CopyClientIdForward(context); |
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
var refreshingContext = CreateCorruptedCookieRefreshContext(); |
||||
|
|
||||
|
await RefreshPrincipalAsync(refreshingContext, PreviousCallback); |
||||
|
|
||||
|
previousCallbackRan.ShouldBeTrue(); |
||||
|
refreshingContext.NewPrincipal.FindAll(AbpClaimTypes.ClientId).ShouldBeEmpty(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Remove_ClientId_When_A_Callback_Is_Registered_After_It() |
||||
|
{ |
||||
|
// The reverse order: the removal is registered first and an Identity-style callback (which runs its own
|
||||
|
// copy-forward before invoking the previous callback) is registered after it. Because the two wrappers
|
||||
|
// chain in opposite directions, the removal still runs last, so the order the modules load does not matter.
|
||||
|
var options = new SecurityStampValidatorOptions(); |
||||
|
options.RemoveClientIdClaim(); |
||||
|
|
||||
|
var previousOnRefreshingPrincipal = options.OnRefreshingPrincipal; |
||||
|
options.OnRefreshingPrincipal = async context => |
||||
|
{ |
||||
|
CopyClientIdForward(context); |
||||
|
if (previousOnRefreshingPrincipal != null) |
||||
|
{ |
||||
|
await previousOnRefreshingPrincipal.Invoke(context); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
var refreshingContext = CreateCorruptedCookieRefreshContext(); |
||||
|
|
||||
|
await options.OnRefreshingPrincipal(refreshingContext); |
||||
|
|
||||
|
refreshingContext.NewPrincipal.FindAll(AbpClaimTypes.ClientId).ShouldBeEmpty(); |
||||
|
} |
||||
|
|
||||
|
private static async Task RefreshPrincipalAsync( |
||||
|
SecurityStampRefreshingPrincipalContext context, |
||||
|
Func<SecurityStampRefreshingPrincipalContext, Task> previousCallback = null) |
||||
|
{ |
||||
|
var options = new SecurityStampValidatorOptions { OnRefreshingPrincipal = previousCallback }; |
||||
|
|
||||
|
options.RemoveClientIdClaim().ShouldBeSameAs(options); |
||||
|
|
||||
|
await options.OnRefreshingPrincipal(context); |
||||
|
} |
||||
|
|
||||
|
private static void CopyClientIdForward(SecurityStampRefreshingPrincipalContext context) |
||||
|
{ |
||||
|
var clientId = context.CurrentPrincipal.FindFirst(AbpClaimTypes.ClientId); |
||||
|
if (clientId != null) |
||||
|
{ |
||||
|
context.NewPrincipal.Identities.First().AddClaim(clientId); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static SecurityStampRefreshingPrincipalContext CreateCorruptedCookieRefreshContext() |
||||
|
{ |
||||
|
return new SecurityStampRefreshingPrincipalContext |
||||
|
{ |
||||
|
CurrentPrincipal = CreateCookiePrincipal( |
||||
|
new Claim(AbpClaimTypes.UserId, "user-1"), |
||||
|
new Claim(AbpClaimTypes.ClientId, "MyClient")), |
||||
|
NewPrincipal = CreateCookiePrincipal(new Claim(AbpClaimTypes.UserId, "user-1")) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private static ClaimsPrincipal CreateCookiePrincipal(params Claim[] claims) |
||||
|
{ |
||||
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, IdentityConstants.ApplicationScheme)); |
||||
|
} |
||||
|
} |
||||
@ -1,120 +0,0 @@ |
|||||
using System.Linq; |
|
||||
using System.Security.Claims; |
|
||||
using System.Threading.Tasks; |
|
||||
using Microsoft.AspNetCore.Identity; |
|
||||
using Microsoft.Extensions.DependencyInjection; |
|
||||
using Microsoft.Extensions.Options; |
|
||||
using Shouldly; |
|
||||
using Volo.Abp.Security.Claims; |
|
||||
using Xunit; |
|
||||
|
|
||||
namespace Volo.Abp.OpenIddict; |
|
||||
|
|
||||
/// <summary>
|
|
||||
/// Tests for the fix that stops the OAuth <c>client_id</c> of a <c>/connect/authorize</c> request
|
|
||||
/// from leaking into the interactive authentication cookie.
|
|
||||
///
|
|
||||
/// Background: when the cookie's security stamp happens to be refreshed during a
|
|
||||
/// <c>/connect/authorize</c> request, <c>OpenIddictClaimsPrincipalContributor</c> stamps the ambient
|
|
||||
/// request's <c>client_id</c> onto the principal that is written back to the cookie. From then on
|
|
||||
/// <c>ICurrentClient.Id</c> resolves to that client for every later cookie-authenticated request
|
|
||||
/// (corrupting audit-log client attribution). The fix strips <c>client_id</c> from the principal at
|
|
||||
/// the security-stamp <c>OnRefreshingPrincipal</c> callback, which only runs when the cookie is
|
|
||||
/// re-issued and never for OpenIddict token issuance.
|
|
||||
/// </summary>
|
|
||||
public class OpenIddictCookieClientIdLeak_Tests |
|
||||
{ |
|
||||
[Fact] |
|
||||
public void Should_Remove_ClientId_From_Refreshed_Cookie_Principal() |
|
||||
{ |
|
||||
// A cookie principal that was wrongly stamped with client_id while being rebuilt
|
|
||||
// by the security-stamp validator during /connect/authorize.
|
|
||||
var context = new SecurityStampRefreshingPrincipalContext |
|
||||
{ |
|
||||
CurrentPrincipal = CreateCookiePrincipal(new Claim(AbpClaimTypes.UserId, "user-1")), |
|
||||
NewPrincipal = CreateCookiePrincipal( |
|
||||
new Claim(AbpClaimTypes.UserId, "user-1"), |
|
||||
new Claim(AbpClaimTypes.ClientId, "MyClient")) |
|
||||
}; |
|
||||
|
|
||||
AbpOpenIddictAspNetCoreModule.RemoveClientIdClaimsFromRefreshedPrincipal(context); |
|
||||
|
|
||||
context.NewPrincipal.FindAll(AbpClaimTypes.ClientId).ShouldBeEmpty(); |
|
||||
// unrelated claims are preserved
|
|
||||
context.NewPrincipal.FindFirst(AbpClaimTypes.UserId)!.Value.ShouldBe("user-1"); |
|
||||
} |
|
||||
|
|
||||
[Fact] |
|
||||
public void Should_Not_Touch_A_Principal_That_Has_No_ClientId() |
|
||||
{ |
|
||||
var context = new SecurityStampRefreshingPrincipalContext |
|
||||
{ |
|
||||
NewPrincipal = CreateCookiePrincipal(new Claim(AbpClaimTypes.UserId, "user-1")) |
|
||||
}; |
|
||||
|
|
||||
AbpOpenIddictAspNetCoreModule.RemoveClientIdClaimsFromRefreshedPrincipal(context); |
|
||||
|
|
||||
context.NewPrincipal.Claims.Count().ShouldBe(1); |
|
||||
context.NewPrincipal.FindFirst(AbpClaimTypes.UserId)!.Value.ShouldBe("user-1"); |
|
||||
} |
|
||||
|
|
||||
[Fact] |
|
||||
public void Should_Not_Throw_When_New_Principal_Is_Null() |
|
||||
{ |
|
||||
var context = new SecurityStampRefreshingPrincipalContext { NewPrincipal = null }; |
|
||||
|
|
||||
Should.NotThrow(() => AbpOpenIddictAspNetCoreModule.RemoveClientIdClaimsFromRefreshedPrincipal(context)); |
|
||||
} |
|
||||
|
|
||||
[Fact] |
|
||||
public async Task Registered_Callback_Should_Strip_ClientId_After_Running_The_Previous_Callback() |
|
||||
{ |
|
||||
// Reproduces the real composition order. A previously registered callback - e.g. ABP
|
|
||||
// Identity's SecurityStampValidatorCallback.UpdatePrincipal - re-introduces client_id from
|
|
||||
// an already-corrupted cookie onto the refreshed principal. The fix is chained AFTER it, so
|
|
||||
// the claim is still removed and the cookie self-heals on its next refresh.
|
|
||||
var services = new ServiceCollection(); |
|
||||
services.AddOptions(); |
|
||||
|
|
||||
var previousCallbackRan = false; |
|
||||
services.Configure<SecurityStampValidatorOptions>(options => |
|
||||
{ |
|
||||
options.OnRefreshingPrincipal = context => |
|
||||
{ |
|
||||
previousCallbackRan = true; |
|
||||
var currentClientId = context.CurrentPrincipal!.FindFirst(AbpClaimTypes.ClientId); |
|
||||
if (currentClientId != null) |
|
||||
{ |
|
||||
context.NewPrincipal!.Identities.First().AddClaim(currentClientId); |
|
||||
} |
|
||||
|
|
||||
return Task.CompletedTask; |
|
||||
}; |
|
||||
}); |
|
||||
|
|
||||
AbpOpenIddictAspNetCoreModule.ConfigureSecurityStampValidator(services); |
|
||||
|
|
||||
var options = services |
|
||||
.BuildServiceProvider() |
|
||||
.GetRequiredService<IOptions<SecurityStampValidatorOptions>>() |
|
||||
.Value; |
|
||||
|
|
||||
var context = new SecurityStampRefreshingPrincipalContext |
|
||||
{ |
|
||||
CurrentPrincipal = CreateCookiePrincipal( |
|
||||
new Claim(AbpClaimTypes.UserId, "user-1"), |
|
||||
new Claim(AbpClaimTypes.ClientId, "MyClient")), |
|
||||
NewPrincipal = CreateCookiePrincipal(new Claim(AbpClaimTypes.UserId, "user-1")) |
|
||||
}; |
|
||||
|
|
||||
await options.OnRefreshingPrincipal!(context); |
|
||||
|
|
||||
previousCallbackRan.ShouldBeTrue(); |
|
||||
context.NewPrincipal.FindAll(AbpClaimTypes.ClientId).ShouldBeEmpty(); |
|
||||
} |
|
||||
|
|
||||
private static ClaimsPrincipal CreateCookiePrincipal(params Claim[] claims) |
|
||||
{ |
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, IdentityConstants.ApplicationScheme)); |
|
||||
} |
|
||||
} |
|
||||
Loading…
Reference in new issue