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

@@ -0,0 +1,288 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Security;
namespace Tiku.Api.Security;
public sealed record CurrentTenantMemberRequirement : IAuthorizationRequirement;
public sealed record CurrentPlatformAccessRequirement : IAuthorizationRequirement;
public sealed record TenantPermissionRequirement : IAuthorizationRequirement
{
public TenantPermissionRequirement(string permissionCode)
{
BackendPermissions.EnsureTenant(permissionCode);
PermissionCode = permissionCode;
}
public string PermissionCode { get; }
}
public sealed record PlatformPermissionRequirement : IAuthorizationRequirement
{
public PlatformPermissionRequirement(string permissionCode)
{
BackendPermissions.EnsurePlatform(permissionCode);
PermissionCode = permissionCode;
}
public string PermissionCode { get; }
}
public sealed record MfaRequirement : IAuthorizationRequirement;
public sealed record AllDataScopeRequirement : IAuthorizationRequirement;
public sealed record TenantResourceAccessRequirement : IAuthorizationRequirement;
public sealed record TenantResourceAuthorizationResource(
Guid TenantId,
Guid? OwnerUserId = null,
Guid? RegionId = null,
Guid? ClassId = null);
internal sealed class CurrentAccessAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<CurrentTenantMemberRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
CurrentTenantMemberRequirement requirement)
{
if (!IsTenantRealm(context.User))
{
return;
}
var access = await accessContext.GetAsync();
if (access.IsCurrentTenantMember &&
access.TenantId is { } tenantId &&
FindTenantId(context.User) == tenantId)
{
context.Succeed(requirement);
}
}
internal static bool IsTenantRealm(ClaimsPrincipal principal) =>
string.Equals(principal.FindFirst(TikuClaimTypes.Realm)?.Value, "tenant", StringComparison.Ordinal);
internal static bool IsPlatformRealm(ClaimsPrincipal principal) =>
string.Equals(principal.FindFirst(TikuClaimTypes.Realm)?.Value, "platform", StringComparison.Ordinal) &&
principal.FindFirst(TikuClaimTypes.TenantId) is null;
private static Guid? FindTenantId(ClaimsPrincipal principal) =>
Guid.TryParse(principal.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) ? tenantId : null;
}
internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<TenantPermissionRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
TenantPermissionRequirement requirement)
{
if (!CurrentAccessAuthorizationHandler.IsTenantRealm(context.User))
{
return;
}
var access = await accessContext.GetAsync();
if (access.HasTenantPermission(requirement.PermissionCode))
{
context.Succeed(requirement);
}
}
}
internal sealed class CurrentPlatformAccessAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<CurrentPlatformAccessRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
CurrentPlatformAccessRequirement requirement)
{
if (!CurrentAccessAuthorizationHandler.IsPlatformRealm(context.User))
{
return;
}
var access = await accessContext.GetAsync();
if (access.IsUserActive && access.PlatformPermissions.Count > 0)
{
context.Succeed(requirement);
}
}
}
internal sealed class TenantResourceAccessAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<TenantResourceAccessRequirement, TenantResourceAuthorizationResource>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
TenantResourceAccessRequirement requirement,
TenantResourceAuthorizationResource resource)
{
if (!CurrentAccessAuthorizationHandler.IsTenantRealm(context.User))
{
return;
}
var access = await accessContext.GetAsync();
if (access.UserId is { } userId &&
access.IsCurrentTenantMember &&
access.TenantId == resource.TenantId &&
access.DataScope.AllowsResource(userId, resource.OwnerUserId, resource.RegionId, resource.ClassId))
{
context.Succeed(requirement);
}
}
}
internal sealed class PlatformPermissionAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<PlatformPermissionRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
PlatformPermissionRequirement requirement)
{
if (!CurrentAccessAuthorizationHandler.IsPlatformRealm(context.User))
{
return;
}
var access = await accessContext.GetAsync();
if (access.HasPlatformPermission(requirement.PermissionCode))
{
context.Succeed(requirement);
}
}
}
internal sealed class MfaAuthorizationHandler : AuthorizationHandler<MfaRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MfaRequirement requirement)
{
if (context.User.FindAll(TikuClaimTypes.Mfa).Any(claim =>
string.Equals(claim.Value, "mfa", StringComparison.OrdinalIgnoreCase) ||
string.Equals(claim.Value, "totp", StringComparison.OrdinalIgnoreCase) ||
string.Equals(claim.Value, bool.TrueString, StringComparison.OrdinalIgnoreCase)))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
internal sealed class AllDataScopeAuthorizationHandler(ICurrentAccessContext accessContext) :
AuthorizationHandler<AllDataScopeRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AllDataScopeRequirement requirement)
{
var access = await accessContext.GetAsync();
if (access.IsCurrentTenantMember && access.DataScope.Mode == DataScopeMode.All)
{
context.Succeed(requirement);
}
}
}
public static class AccessAuthorizationServiceCollectionExtensions
{
public static IServiceCollection AddTikuRbacAuthorization(this IServiceCollection services)
{
services.AddScoped<IAuthorizationHandler, CurrentAccessAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, CurrentPlatformAccessAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, TenantPermissionAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, PlatformPermissionAuthorizationHandler>();
services.AddSingleton<IAuthorizationHandler, MfaAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, AllDataScopeAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, TenantResourceAccessAuthorizationHandler>();
services.AddAuthorization(options =>
{
options.AddPolicy(
TikuPolicies.CurrentTenantMember,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new CurrentTenantMemberRequirement()));
options.AddPolicy(
TikuPolicies.Mfa,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new MfaRequirement()));
options.AddPolicy(
TikuPolicies.TenantBackofficeBootstrap,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new MfaRequirement()));
options.AddPolicy(
TikuPolicies.PlatformBackofficeBootstrap,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentPlatformAccessRequirement(),
new MfaRequirement()));
// Temporary compatibility for controllers that have not yet been
// split into their module-specific permission policy. This must
// remain database-backed; an authenticated-only alias would reopen
// every legacy tenant administration endpoint to ordinary users.
options.AddPolicy(
TikuPolicies.TenantAdmin,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(BackendPermissions.TenantRoleManage),
new MfaRequirement()));
options.AddPolicy(
TikuPolicies.TenantContentManageAllScope,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(BackendPermissions.TenantContentManage),
new AllDataScopeRequirement(),
new MfaRequirement()));
options.AddPolicy(
TikuPolicies.TenantCommerceOperateAllScope,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(BackendPermissions.TenantCommerceOperate),
new AllDataScopeRequirement(),
new MfaRequirement()));
foreach (var permissionCode in BackendPermissions.Tenant)
{
options.AddPolicy(
permissionCode,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new CurrentTenantMemberRequirement(),
new TenantPermissionRequirement(permissionCode),
new MfaRequirement()));
}
foreach (var permissionCode in BackendPermissions.Platform)
{
options.AddPolicy(
permissionCode,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(
new PlatformPermissionRequirement(permissionCode),
new MfaRequirement()));
}
});
return services;
}
}

View File

@@ -0,0 +1,59 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Tiku.Application.Security;
using Tiku.Domain.Operations;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Api.Security;
internal sealed class AuditingAuthorizationMiddlewareResultHandler(
IServiceScopeFactory scopeFactory,
ILogger<AuditingAuthorizationMiddlewareResultHandler> logger) : IAuthorizationMiddlewareResultHandler
{
private readonly AuthorizationMiddlewareResultHandler fallback = new();
public async Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult)
{
if (authorizeResult.Forbidden && context.User.Identity?.IsAuthenticated == true)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = Guid.TryParse(context.User.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId)
? tenantId
: null,
ActorUserId = Guid.TryParse(context.User.FindFirst(TikuClaimTypes.UserId)?.Value, out var userId)
? userId
: null,
Action = "authorization.access_denied",
TargetType = "http_endpoint",
TargetId = context.Request.Path,
IpAddress = context.Connection.RemoteIpAddress?.ToString(),
UserAgent = context.Request.Headers.UserAgent.ToString(),
Details = JsonSerializer.SerializeToElement(new
{
Method = context.Request.Method,
Path = context.Request.Path.Value,
Realm = context.User.FindFirst(TikuClaimTypes.Realm)?.Value,
Failure = authorizeResult.AuthorizationFailure?.FailureReasons.Select(reason => reason.Message).ToArray()
})
});
await dbContext.SaveChangesAsync(context.RequestAborted);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to persist authorization denial audit event.");
}
}
await fallback.HandleAsync(next, context, policy, authorizeResult);
}
}

View File

@@ -1,31 +0,0 @@
using System.Security.Claims;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using ZLinq;
namespace Tiku.Api.Security;
internal static class TenantRoleAuthorization
{
private static readonly HashSet<string> AdminRoles = new(StringComparer.Ordinal)
{
nameof(TenantRole.PlatformAdmin),
nameof(TenantRole.TenantOwner),
nameof(TenantRole.TenantAdmin)
};
public static bool IsTenantMember(ClaimsPrincipal principal)
{
return principal.Identity?.IsAuthenticated == true &&
principal.HasClaim(claim => claim.Type == TikuClaimTypes.TenantId);
}
public static bool IsTenantAdmin(ClaimsPrincipal principal)
{
return IsTenantMember(principal) &&
principal.Claims.AsValueEnumerable()
.Where(claim => claim.Type == TikuClaimTypes.TenantRole)
.Select(claim => claim.Value)
.Any(role => AdminRoles.Contains(role));
}
}