forked from xiongyuxing/tiku-backend.net
feat: harden SaaS authentication and authorization
This commit is contained in:
141
Tiku.Infrastructure/Security/CurrentAccessContext.cs
Normal file
141
Tiku.Infrastructure/Security/CurrentAccessContext.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal sealed class CurrentAccessContext(
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext tenantContext,
|
||||
TikuDbContext dbContext) : ICurrentAccessContext
|
||||
{
|
||||
private Task<CurrentAccessSnapshot>? snapshotTask;
|
||||
|
||||
public Task<CurrentAccessSnapshot> GetAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// The context is scoped to one request. Do not allow an aborted authorization
|
||||
// check to poison the cached access snapshot used later in that request.
|
||||
return snapshotTask ??= LoadAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task<CurrentAccessSnapshot> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!currentUser.IsAuthenticated || currentUser.UserId is not { } userId)
|
||||
{
|
||||
return Empty();
|
||||
}
|
||||
|
||||
var isUserActive = await dbContext.Users.AsNoTracking()
|
||||
.AnyAsync(user => user.Id == userId && user.Status == UserStatus.Active, cancellationToken);
|
||||
if (!isUserActive)
|
||||
{
|
||||
return new CurrentAccessSnapshot(
|
||||
userId,
|
||||
tenantContext.TenantId,
|
||||
false,
|
||||
false,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
CurrentDataScope.Self);
|
||||
}
|
||||
|
||||
var platformPermissions = await LoadPlatformPermissionsAsync(userId, cancellationToken);
|
||||
if (tenantContext.TenantId is not { } tenantId)
|
||||
{
|
||||
return new CurrentAccessSnapshot(
|
||||
userId,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
platformPermissions,
|
||||
CurrentDataScope.Self);
|
||||
}
|
||||
|
||||
var isTenantActive = await dbContext.Tenants.AsNoTracking()
|
||||
.AnyAsync(tenant => tenant.Id == tenantId && tenant.Status == TenantStatus.Active, cancellationToken);
|
||||
var isActiveMember = isTenantActive && await dbContext.TenantMemberships.AsNoTracking()
|
||||
.AnyAsync(
|
||||
membership => membership.TenantId == tenantId &&
|
||||
membership.UserId == userId &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (!isActiveMember)
|
||||
{
|
||||
return new CurrentAccessSnapshot(
|
||||
userId,
|
||||
tenantId,
|
||||
true,
|
||||
false,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
platformPermissions,
|
||||
CurrentDataScope.Self);
|
||||
}
|
||||
|
||||
var tenantRoles = await (
|
||||
from userRole in dbContext.TenantBackendUserRoles.AsNoTracking()
|
||||
join role in dbContext.TenantBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||
where userRole.TenantId == tenantId &&
|
||||
userRole.UserId == userId &&
|
||||
role.Status == BackendRoleStatus.Active
|
||||
select new { role.Id, role.DataScope })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var roleIds = tenantRoles.Select(role => role.Id).ToArray();
|
||||
var tenantPermissions = roleIds.Length == 0
|
||||
? new HashSet<string>(StringComparer.Ordinal)
|
||||
: (await (
|
||||
from binding in dbContext.TenantBackendRolePermissions.AsNoTracking()
|
||||
join permission in dbContext.BackendPermissions.AsNoTracking()
|
||||
on binding.PermissionCode equals permission.Code
|
||||
where binding.TenantId == tenantId &&
|
||||
roleIds.Contains(binding.RoleId) &&
|
||||
(permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both)
|
||||
select binding.PermissionCode)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
return new CurrentAccessSnapshot(
|
||||
userId,
|
||||
tenantId,
|
||||
true,
|
||||
true,
|
||||
tenantPermissions,
|
||||
platformPermissions,
|
||||
CurrentDataScope.Merge(tenantRoles.Select(role => role.DataScope)));
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> LoadPlatformPermissionsAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return (await (
|
||||
from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking()
|
||||
join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||
join binding in dbContext.PlatformBackendRolePermissions.AsNoTracking() on role.Id equals binding.RoleId
|
||||
join permission in dbContext.BackendPermissions.AsNoTracking()
|
||||
on binding.PermissionCode equals permission.Code
|
||||
where userRole.UserId == userId &&
|
||||
role.Status == BackendRoleStatus.Active &&
|
||||
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
|
||||
select binding.PermissionCode)
|
||||
.Distinct()
|
||||
.ToArrayAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private CurrentAccessSnapshot Empty()
|
||||
{
|
||||
return new CurrentAccessSnapshot(
|
||||
null,
|
||||
tenantContext.TenantId,
|
||||
false,
|
||||
false,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
CurrentDataScope.Self);
|
||||
}
|
||||
}
|
||||
62
Tiku.Infrastructure/Security/DataProtectionKeyRingOptions.cs
Normal file
62
Tiku.Infrastructure/Security/DataProtectionKeyRingOptions.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
public sealed class DataProtectionKeyRingOptions
|
||||
{
|
||||
public const string SectionName = "Security:DataProtection";
|
||||
|
||||
public string ApplicationName { get; set; } = "Tiku.Api";
|
||||
public string CertificatePath { get; set; } = string.Empty;
|
||||
public string CertificatePassword { get; set; } = string.Empty;
|
||||
|
||||
public static bool BeValid(DataProtectionKeyRingOptions options, bool requireCertificate)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(options.ApplicationName) &&
|
||||
(!requireCertificate || !string.IsNullOrWhiteSpace(options.CertificatePath));
|
||||
}
|
||||
|
||||
public X509Certificate2? LoadCertificate(bool requireCertificate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CertificatePath))
|
||||
{
|
||||
if (requireCertificate)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Data Protection certificate is required outside Development. " +
|
||||
"Configure Security:DataProtection:CertificatePath or " +
|
||||
"TIKU_DATA_PROTECTION_CERTIFICATE_PATH.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var certificate = X509CertificateLoader.LoadPkcs12FromFile(
|
||||
Path.GetFullPath(CertificatePath.Trim()),
|
||||
CertificatePassword,
|
||||
X509KeyStorageFlags.DefaultKeySet);
|
||||
if (!certificate.HasPrivateKey)
|
||||
{
|
||||
certificate.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
"Data Protection certificate must contain a private key.");
|
||||
}
|
||||
|
||||
return certificate;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is CryptographicException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Data Protection certificate could not be loaded from the configured PKCS#12 file.",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
Tiku.Infrastructure/Security/DataScopeQueryableExtensions.cs
Normal file
47
Tiku.Infrastructure/Security/DataScopeQueryableExtensions.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System.Linq.Expressions;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Security;
|
||||
|
||||
internal static class DataScopeQueryableExtensions
|
||||
{
|
||||
public static IQueryable<TEntity> ApplyDataScope<TEntity>(
|
||||
this IQueryable<TEntity> query,
|
||||
CurrentDataScope scope,
|
||||
Expression<Func<TEntity, bool>>? selfPredicate,
|
||||
Expression<Func<TEntity, bool>>? restrictedPredicate)
|
||||
{
|
||||
if (scope.Mode == DataScopeMode.All)
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
Expression<Func<TEntity, bool>>? predicate = null;
|
||||
if (scope.IncludesSelf && selfPredicate is not null)
|
||||
{
|
||||
predicate = selfPredicate;
|
||||
}
|
||||
|
||||
if (scope.Mode == DataScopeMode.Restricted && restrictedPredicate is not null)
|
||||
{
|
||||
predicate = predicate is null ? restrictedPredicate : OrElse(predicate, restrictedPredicate);
|
||||
}
|
||||
|
||||
return predicate is null ? query.Where(_ => false) : query.Where(predicate);
|
||||
}
|
||||
|
||||
private static Expression<Func<TEntity, bool>> OrElse<TEntity>(
|
||||
Expression<Func<TEntity, bool>> left,
|
||||
Expression<Func<TEntity, bool>> right)
|
||||
{
|
||||
var parameter = Expression.Parameter(typeof(TEntity), "entity");
|
||||
var leftBody = new ReplaceParameterVisitor(left.Parameters[0], parameter).Visit(left.Body)!;
|
||||
var rightBody = new ReplaceParameterVisitor(right.Parameters[0], parameter).Visit(right.Body)!;
|
||||
return Expression.Lambda<Func<TEntity, bool>>(Expression.OrElse(leftBody, rightBody), parameter);
|
||||
}
|
||||
|
||||
private sealed class ReplaceParameterVisitor(ParameterExpression source, ParameterExpression target) : ExpressionVisitor
|
||||
{
|
||||
protected override Expression VisitParameter(ParameterExpression node) => node == source ? target : base.VisitParameter(node);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user