GetUserId_reads_NameIdentifier_when_sub_was_mapped
This commit is contained in:
parent
44b391d496
commit
0d3fbf22c3
5 changed files with 290 additions and 1 deletions
156
src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
Normal file
156
src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
[Collection("JwtClaimMapping")]
|
||||
public sealed class BlogApiMappedClaimsTests : IClassFixture<MappedClaimsBlogsWebServerFixture>
|
||||
{
|
||||
private readonly MappedClaimsBlogsWebServerFixture _fixture;
|
||||
|
||||
public BlogApiMappedClaimsTests(MappedClaimsBlogsWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private void ResetDatabase()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.Database.EnsureDeleted();
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
private HttpClient NewClient(string subject = "tester")
|
||||
{
|
||||
var http = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.Addresses.First())
|
||||
};
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
IssueMappedClaimsToken(subject));
|
||||
return http;
|
||||
}
|
||||
|
||||
private static string IssueMappedClaimsToken(string subject)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new("sub", subject),
|
||||
new("scope", "blogs"),
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: TestTokenIssuer.Issuer,
|
||||
audience: TestTokenIssuer.Audience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
expires: now.AddHours(1),
|
||||
signingCredentials: new SigningCredentials(
|
||||
TestTokenIssuer.SigningKey,
|
||||
SecurityAlgorithms.HmacSha256));
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostBlog_with_mapped_sub_claim_sets_AuthorId_from_authenticated_user()
|
||||
{
|
||||
ResetDatabase();
|
||||
using var http = NewClient(subject: "mapped-user");
|
||||
|
||||
var draft = new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Billet JWT remappe",
|
||||
AuthorId = "payload-attacker",
|
||||
Article = "Contenu de test.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var response = await http.PostAsJsonAsync("/api/v1/blog", draft);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var created = await response.Content.ReadFromJsonAsync<BlogPost>();
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("mapped-user", created!.AuthorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutBlog_with_mapped_sub_claim_allows_owner_to_update()
|
||||
{
|
||||
ResetDatabase();
|
||||
using var http = NewClient(subject: "mapped-owner");
|
||||
|
||||
var createdResponse = await http.PostAsJsonAsync("/api/v1/blog", new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Billet à modifier",
|
||||
AuthorId = "payload-attacker",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
|
||||
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>();
|
||||
Assert.NotNull(created);
|
||||
|
||||
var updateResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost
|
||||
{
|
||||
Id = created.Id,
|
||||
Title = "Billet modifié",
|
||||
AuthorId = created.AuthorId,
|
||||
Article = "Contenu mis à jour.",
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutBlog_with_mapped_sub_claim_rejects_non_owner()
|
||||
{
|
||||
ResetDatabase();
|
||||
using var ownerHttp = NewClient(subject: "mapped-owner");
|
||||
|
||||
var createdResponse = await ownerHttp.PostAsJsonAsync("/api/v1/blog", new BlogPost
|
||||
{
|
||||
Id = 0,
|
||||
Title = "Billet protégé",
|
||||
AuthorId = "payload-attacker",
|
||||
Article = "Contenu initial.",
|
||||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
|
||||
var created = await createdResponse.Content.ReadFromJsonAsync<BlogPost>();
|
||||
Assert.NotNull(created);
|
||||
|
||||
using var otherHttp = NewClient(subject: "mapped-other");
|
||||
var updateResponse = await otherHttp.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost
|
||||
{
|
||||
Id = created.Id,
|
||||
Title = "Tentative de modification",
|
||||
AuthorId = created.AuthorId,
|
||||
Article = "Contenu non autorisé.",
|
||||
DateCreated = created.DateCreated,
|
||||
DateModified = DateTime.UtcNow
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, updateResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Blog;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
|
@ -20,6 +22,7 @@ namespace Yavsc.Blogs.Tests;
|
|||
/// header (or sending a token signed with the wrong key) gets a
|
||||
/// 401 back from the framework.
|
||||
/// </summary>
|
||||
[Collection("JwtClaimMapping")]
|
||||
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
||||
{
|
||||
private readonly BlogsWebServerFixture _fixture;
|
||||
|
|
@ -180,6 +183,17 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUserId_reads_NameIdentifier_when_sub_was_mapped()
|
||||
{
|
||||
var principal = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.NameIdentifier, "tester")],
|
||||
authenticationType: "Bearer"));
|
||||
|
||||
Assert.Equal("tester", principal.GetUserId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlog_returns_401_when_no_token_is_provided()
|
||||
{
|
||||
|
|
|
|||
8
src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs
Normal file
8
src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
using Xunit;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
[CollectionDefinition("JwtClaimMapping", DisableParallelization = true)]
|
||||
public sealed class JwtClaimMappingCollection
|
||||
{
|
||||
}
|
||||
109
src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs
Normal file
109
src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Yavsc.Blogs.Controllers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Blogs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Dedicated integration-test host that mirrors the production JWT
|
||||
/// remapping behavior: MapInboundClaims remains enabled and the
|
||||
/// default inbound map rewrites "sub" to ClaimTypes.NameIdentifier.
|
||||
/// This is the closest in-process reproduction of the production
|
||||
/// authentication surface for the blog API.
|
||||
/// </summary>
|
||||
public sealed class MappedClaimsBlogsWebServerFixture : IDisposable
|
||||
{
|
||||
private readonly InMemoryDatabaseRoot _inMemoryRoot = new();
|
||||
private readonly Dictionary<string, string> _savedInboundMap;
|
||||
private readonly WebApplication _app;
|
||||
|
||||
public MappedClaimsBlogsWebServerFixture()
|
||||
{
|
||||
_savedInboundMap = new Dictionary<string, string>(JwtSecurityTokenHandler.DefaultInboundClaimTypeMap);
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier;
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:5104");
|
||||
|
||||
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
|
||||
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot));
|
||||
|
||||
builder.Services.AddSingleton<IFileSystemAuthManager>(new NoopFileSystemAuthManager());
|
||||
builder.Services.AddScoped<BlogSpotService>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
||||
builder.Services.AddControllers()
|
||||
.AddApplicationPart(typeof(BlogApiController).Assembly);
|
||||
builder.Services.AddAuthorization(opt =>
|
||||
{
|
||||
opt.AddPolicy("BlogScope", policy =>
|
||||
{
|
||||
policy.RequireAuthenticatedUser()
|
||||
.RequireClaim("scope", "blogs");
|
||||
});
|
||||
});
|
||||
builder.Services.AddAuthentication("Bearer")
|
||||
.AddJwtBearer("Bearer", options =>
|
||||
{
|
||||
options.IncludeErrorDetails = true;
|
||||
options.MapInboundClaims = true;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = TestTokenIssuer.Issuer,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = TestTokenIssuer.SigningKey,
|
||||
RoleClaimType = YavscConstants.RoleClaimType,
|
||||
NameClaimType = YavscConstants.NameClaimType,
|
||||
};
|
||||
});
|
||||
|
||||
_app = builder.Build();
|
||||
_app.UseRouting();
|
||||
_app.UseAuthentication();
|
||||
_app.UseAuthorization();
|
||||
_app.MapControllers();
|
||||
_app.StartAsync().GetAwaiter().GetResult();
|
||||
|
||||
Addresses = ["http://127.0.0.1:5104"];
|
||||
Services = _app.Services;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Addresses { get; }
|
||||
|
||||
public IServiceProvider Services { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_app.StopAsync().GetAwaiter().GetResult();
|
||||
_app.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
||||
foreach (var kvp in _savedInboundMap)
|
||||
{
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
|
||||
{
|
||||
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)
|
||||
=> FileAccessRight.None;
|
||||
|
||||
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,9 @@ namespace Yavsc.Server.Helpers
|
|||
|
||||
public static string GetUserId(this ClaimsPrincipal user)
|
||||
{
|
||||
return user.FindFirstValue("sub");
|
||||
return user.FindFirstValue("sub")
|
||||
?? user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? user.FindFirstValue("nameid");
|
||||
}
|
||||
|
||||
public static string GetUserName(this ClaimsPrincipal user)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue