feat: harden SaaS authentication and authorization

This commit is contained in:
2026-07-28 12:15:51 +08:00
parent f22f329d33
commit 5d2248efee
123 changed files with 9090 additions and 2822 deletions

View File

@@ -1,24 +1,13 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
namespace Tiku.IntegrationTests.Api;
public sealed class SecurityFoundationTests
{
private static readonly JwtOptions JwtOptions = new()
{
Issuer = "tiku-backend",
Audience = "tiku-api",
SigningKey = "development-only-tiku-signing-key-change-before-production"
};
[Fact]
public async Task Authenticated_policy_returns_unauthorized_without_token()
{
@@ -36,15 +25,18 @@ public sealed class SecurityFoundationTests
await using var factory = CreateFactory();
var userId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId);
var sessionId = await factory.SeedActiveSessionAsync(
userId,
tenantId,
includeMembership: true);
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
client.DefaultRequestHeaders.Authorization = new(
"Bearer",
CreateToken([
TestJwtKeys.CreateToken([
new Claim(TikuClaimTypes.UserId, userId.ToString()),
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString())
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
]));
var response = await client.GetAsync("/api/_security/tenant-admin");
@@ -58,11 +50,15 @@ public sealed class SecurityFoundationTests
var userId = Guid.NewGuid();
await using var factory = CreateFactory();
var tenantId = Guid.NewGuid();
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId);
var sessionId = await factory.SeedActiveSessionAsync(
userId,
tenantId,
includeMembership: true);
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
client.DefaultRequestHeaders.Authorization = new(
"Bearer",
CreateToken([
TestJwtKeys.CreateToken([
new Claim(TikuClaimTypes.UserId, userId.ToString()),
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
@@ -76,6 +72,73 @@ public sealed class SecurityFoundationTests
Assert.Contains("true", body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Jwt_without_jti_and_iat_is_rejected()
{
await using var factory = CreateFactory();
var userId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true);
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
client.DefaultRequestHeaders.Authorization = new(
"Bearer",
TestJwtKeys.CreateToken([
new Claim(TikuClaimTypes.UserId, userId.ToString()),
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
], includeStandardClaims: false));
var response = await client.GetAsync("/api/_security/authenticated");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Jwt_mfa_claim_must_match_the_database_session()
{
await using var factory = CreateFactory();
var userId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true);
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
client.DefaultRequestHeaders.Authorization = new(
"Bearer",
TestJwtKeys.CreateToken([
new Claim(TikuClaimTypes.UserId, userId.ToString()),
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
new Claim(TikuClaimTypes.Mfa, "mfa")
]));
var response = await client.GetAsync("/api/_security/authenticated");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Jwt_with_unknown_kid_is_rejected()
{
await using var factory = CreateFactory();
var userId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true);
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
client.DefaultRequestHeaders.Authorization = new(
"Bearer",
TestJwtKeys.CreateToken([
new Claim(TikuClaimTypes.UserId, userId.ToString()),
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
], keyId: "unknown-key"));
var response = await client.GetAsync("/api/_security/authenticated");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Global_rate_limiter_returns_too_many_requests_problem()
{
@@ -129,19 +192,4 @@ public sealed class SecurityFoundationTests
return new ApiTestFactory();
}
private static string CreateToken(IEnumerable<Claim> claims)
{
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
JwtOptions.Issuer,
JwtOptions.Audience,
claims,
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}