:drunk: AI trying to fix bugs
This commit is contained in:
@ -89,6 +89,62 @@ namespace DysonNetwork.Common.Clients
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task DeleteResourceReferencesAsync(string resourceId, string? usage = null)
|
||||
{
|
||||
var url = $"api/filereferences/resource/{resourceId}";
|
||||
if (!string.IsNullOrEmpty(usage))
|
||||
{
|
||||
url += $"?usage={Uri.EscapeDataString(usage)}";
|
||||
}
|
||||
|
||||
var response = await _httpClient.DeleteAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<List<CloudFileReference>> GetFileReferencesAsync(string fileId)
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"api/filereferences/file/{fileId}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
var references = await JsonSerializer.DeserializeAsync<List<CloudFileReference>>(stream, _jsonOptions);
|
||||
return references ?? new List<CloudFileReference>();
|
||||
}
|
||||
|
||||
public async Task<List<CloudFileReference>> GetResourceReferencesAsync(string resourceId, string? usage = null)
|
||||
{
|
||||
var url = $"api/filereferences/resource/{resourceId}";
|
||||
if (!string.IsNullOrEmpty(usage))
|
||||
{
|
||||
url += $"?usage={Uri.EscapeDataString(usage)}";
|
||||
}
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
var references = await JsonSerializer.DeserializeAsync<List<CloudFileReference>>(stream, _jsonOptions);
|
||||
return references ?? new List<CloudFileReference>();
|
||||
}
|
||||
|
||||
public async Task<bool> HasReferencesAsync(string fileId)
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"api/filereferences/file/{fileId}/exists");
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task UpdateReferenceExpirationAsync(string referenceId, Instant? expiredAt)
|
||||
{
|
||||
var request = new { ExpiredAt = expiredAt };
|
||||
var content = new StringContent(
|
||||
JsonSerializer.Serialize(request, _jsonOptions),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var response = await _httpClient.PutAsync($"api/filereferences/{referenceId}/expiration", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
|
@ -35,24 +35,30 @@ namespace DysonNetwork.Common.Clients
|
||||
return file;
|
||||
}
|
||||
|
||||
public async Task<Stream> DownloadFileAsync(string fileId)
|
||||
public async Task<Stream> GetFileStreamAsync(string fileId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fileId))
|
||||
throw new ArgumentNullException(nameof(fileId));
|
||||
|
||||
var response = await _httpClient.GetAsync($"api/files/{fileId}/download");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStreamAsync();
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
if (stream == null)
|
||||
throw new InvalidOperationException("Failed to read file stream from response.");
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
public async Task<CloudFile> UploadFileAsync(Stream fileStream, string fileName, string contentType, string? folderId = null)
|
||||
public async Task<CloudFile> UploadFileAsync(Stream fileStream, string fileName, string? contentType = null)
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new StreamContent(fileStream);
|
||||
content.Add(fileContent, "file", fileName);
|
||||
|
||||
if (!string.IsNullOrEmpty(folderId))
|
||||
if (!string.IsNullOrEmpty(contentType))
|
||||
{
|
||||
content.Add(new StringContent(folderId), "folderId");
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
|
||||
}
|
||||
content.Add(fileContent, "file", fileName);
|
||||
|
||||
var response = await _httpClient.PostAsync("api/files/upload", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@ -61,6 +67,39 @@ namespace DysonNetwork.Common.Clients
|
||||
var file = await JsonSerializer.DeserializeAsync<CloudFile>(responseStream, _jsonOptions);
|
||||
return file;
|
||||
}
|
||||
|
||||
public async Task<CloudFile> ProcessImageAsync(Stream imageStream, string fileName, string? contentType = null)
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new StreamContent(imageStream);
|
||||
if (!string.IsNullOrEmpty(contentType))
|
||||
{
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
|
||||
}
|
||||
content.Add(fileContent, "image", fileName);
|
||||
|
||||
var response = await _httpClient.PostAsync("api/files/process-image", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
var file = await JsonSerializer.DeserializeAsync<CloudFile>(responseStream, _jsonOptions);
|
||||
return file;
|
||||
}
|
||||
|
||||
public async Task<string> GetFileUrl(string fileId, bool useCdn = false)
|
||||
{
|
||||
var url = $"api/files/{fileId}/url";
|
||||
if (useCdn)
|
||||
{
|
||||
url += "?useCdn=true";
|
||||
}
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
return result.Trim('"');
|
||||
}
|
||||
|
||||
public async Task DeleteFileAsync(string fileId)
|
||||
{
|
||||
|
@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
@ -9,7 +9,13 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
|
||||
<PackageReference Include="NetTopologySuite" Version="2.5.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||
|
@ -7,6 +7,15 @@ using OtpNet;
|
||||
|
||||
namespace DysonNetwork.Common.Models;
|
||||
|
||||
public enum AccountStatus
|
||||
{
|
||||
PendingActivation,
|
||||
Active,
|
||||
Suspended,
|
||||
Banned,
|
||||
Deleted
|
||||
}
|
||||
|
||||
[Index(nameof(Name), IsUnique = true)]
|
||||
public class Account : ModelBase
|
||||
{
|
||||
@ -30,6 +39,47 @@ public class Account : ModelBase
|
||||
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
|
||||
|
||||
[JsonIgnore] public ICollection<Subscription> Subscriptions { get; set; } = new List<Subscription>();
|
||||
|
||||
public AccountStatus Status { get; set; } = AccountStatus.PendingActivation;
|
||||
|
||||
[NotMapped]
|
||||
public string? Email => GetPrimaryEmail();
|
||||
|
||||
public string? GetPrimaryEmail()
|
||||
{
|
||||
return Contacts
|
||||
.FirstOrDefault(c => c.Type == AccountContactType.Email && c.IsPrimary)
|
||||
?.Content;
|
||||
}
|
||||
|
||||
public void SetPrimaryEmail(string email)
|
||||
{
|
||||
// Remove primary flag from existing primary email if any
|
||||
foreach (var contact in Contacts.Where(c => c.Type == AccountContactType.Email && c.IsPrimary))
|
||||
{
|
||||
contact.IsPrimary = false;
|
||||
}
|
||||
|
||||
// Find or create the email contact
|
||||
var emailContact = Contacts.FirstOrDefault(c =>
|
||||
c.Type == AccountContactType.Email &&
|
||||
string.Equals(c.Content, email, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (emailContact == null)
|
||||
{
|
||||
emailContact = new AccountContact
|
||||
{
|
||||
Type = AccountContactType.Email,
|
||||
Content = email,
|
||||
IsPrimary = true
|
||||
};
|
||||
Contacts.Add(emailContact);
|
||||
}
|
||||
else
|
||||
{
|
||||
emailContact.IsPrimary = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class Leveling
|
||||
@ -128,11 +178,28 @@ public class AccountAuthFactor : ModelBase
|
||||
/// </summary>
|
||||
public int Trustworthy { get; set; } = 1;
|
||||
|
||||
[MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsBackup { get; set; }
|
||||
public Instant? LastUsedAt { get; set; }
|
||||
public Instant? EnabledAt { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
public Instant? DisabledAt { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public Dictionary<string, object>? Metadata { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public Account Account { get; set; } = null!;
|
||||
|
||||
// Navigation property for related AuthSessions
|
||||
[JsonIgnore]
|
||||
public virtual ICollection<AuthSession>? Sessions { get; set; }
|
||||
|
||||
public AccountAuthFactor HashSecret(int cost = 12)
|
||||
{
|
||||
@ -174,20 +241,5 @@ public enum AccountAuthFactorType
|
||||
EmailCode,
|
||||
InAppCode,
|
||||
TimedCode,
|
||||
PinCode,
|
||||
}
|
||||
|
||||
public class AccountConnection : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(4096)] public string Provider { get; set; } = null!;
|
||||
[MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!;
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new();
|
||||
|
||||
[JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; }
|
||||
[JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; }
|
||||
public Instant? LastUsedAt { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account Account { get; set; } = null!;
|
||||
PinCode
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Common.Models;
|
||||
@ -8,15 +10,8 @@ namespace DysonNetwork.Common.Models;
|
||||
/// <summary>
|
||||
/// Represents a connection between an account and an authentication provider
|
||||
/// </summary>
|
||||
public class AccountConnection
|
||||
public class AccountConnection : ModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the connection
|
||||
/// </summary>
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public string Id { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The account ID this connection is associated with
|
||||
/// </summary>
|
||||
@ -36,6 +31,16 @@ public class AccountConnection
|
||||
[MaxLength(256)]
|
||||
public string ProvidedIdentifier { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Alias for ProvidedIdentifier for backward compatibility
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public string ProviderId
|
||||
{
|
||||
get => ProvidedIdentifier;
|
||||
set => ProvidedIdentifier = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the connection
|
||||
/// </summary>
|
||||
@ -57,6 +62,27 @@ public class AccountConnection
|
||||
/// </summary>
|
||||
public Instant? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw profile data from the provider
|
||||
/// </summary>
|
||||
[Column(TypeName = "jsonb")]
|
||||
public JsonDocument? ProfileData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the connection was first established
|
||||
/// </summary>
|
||||
public Instant ConnectedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the connection
|
||||
/// </summary>
|
||||
[Column(TypeName = "jsonb")]
|
||||
public JsonDocument? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the connection is currently active
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
/// <summary>
|
||||
/// When the connection was first established
|
||||
/// </summary>
|
||||
@ -67,15 +93,33 @@ public class AccountConnection
|
||||
/// </summary>
|
||||
public Instant? LastUsedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the connection
|
||||
/// </summary>
|
||||
[Column(TypeName = "jsonb")]
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation property for the associated account
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(AccountId))]
|
||||
[JsonIgnore]
|
||||
public virtual Account? Account { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Updates the connection's tokens and related metadata
|
||||
/// </summary>
|
||||
/// <param name="accessToken">The new access token</param>
|
||||
/// <param name="refreshToken">The new refresh token, if any</param>
|
||||
/// <param name="expiresAt">When the access token expires, if any</param>
|
||||
public void UpdateTokens(string? accessToken, string? refreshToken, Instant? expiresAt)
|
||||
{
|
||||
AccessToken = accessToken;
|
||||
|
||||
if (!string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
RefreshToken = refreshToken;
|
||||
}
|
||||
|
||||
if (expiresAt.HasValue)
|
||||
{
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
LastUsedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
}
|
||||
}
|
||||
|
47
DysonNetwork.Common/Models/Auth/AuthFactorType.cs
Normal file
47
DysonNetwork.Common/Models/Auth/AuthFactorType.cs
Normal file
@ -0,0 +1,47 @@
|
||||
namespace DysonNetwork.Common.Models.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the different types of authentication factors that can be used for multi-factor authentication.
|
||||
/// </summary>
|
||||
public enum AuthFactorType
|
||||
{
|
||||
/// <summary>
|
||||
/// Password-based authentication factor.
|
||||
/// </summary>
|
||||
Password = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Time-based One-Time Password (TOTP) authentication factor.
|
||||
/// </summary>
|
||||
Totp = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Email-based authentication factor.
|
||||
/// </summary>
|
||||
Email = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Phone/SMS-based authentication factor.
|
||||
/// </summary>
|
||||
Phone = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Security key (FIDO2/WebAuthn) authentication factor.
|
||||
/// </summary>
|
||||
SecurityKey = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Recovery code authentication factor.
|
||||
/// </summary>
|
||||
RecoveryCode = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Backup code authentication factor.
|
||||
/// </summary>
|
||||
BackupCode = 6,
|
||||
|
||||
/// <summary>
|
||||
/// OpenID Connect (OIDC) authentication factor.
|
||||
/// </summary>
|
||||
Oidc = 7
|
||||
}
|
@ -45,30 +45,3 @@ public enum AuthChallengePlatform
|
||||
System = 100,
|
||||
Unknown = 999
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AuthFactorType
|
||||
{
|
||||
Password = 0,
|
||||
EmailCode = 1,
|
||||
PhoneCode = 2,
|
||||
Totp = 3,
|
||||
WebAuthn = 4,
|
||||
RecoveryCode = 5,
|
||||
|
||||
// Social and federation
|
||||
Google = 10,
|
||||
Apple = 11,
|
||||
Microsoft = 12,
|
||||
Facebook = 13,
|
||||
Twitter = 14,
|
||||
Github = 15,
|
||||
|
||||
// Enterprise
|
||||
Saml = 50,
|
||||
Oidc = 51,
|
||||
Ldap = 52,
|
||||
|
||||
// Custom factor types
|
||||
Custom = 100
|
||||
}
|
||||
|
@ -1,10 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Common.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for all entity models in the system.
|
||||
/// Provides common properties and functionality for tracking entity lifecycle.
|
||||
/// </summary>
|
||||
public abstract class ModelBase
|
||||
{
|
||||
public Instant CreatedAt { get; set; }
|
||||
public Instant UpdatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for the entity.
|
||||
/// </summary>
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date and time when the entity was created, in UTC.
|
||||
/// </summary>
|
||||
public Instant CreatedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date and time when the entity was last updated, in UTC.
|
||||
/// </summary>
|
||||
public Instant UpdatedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date and time when the entity was soft-deleted, in UTC.
|
||||
/// Null if the entity has not been deleted.
|
||||
/// </summary>
|
||||
public Instant? DeletedAt { get; set; }
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DysonNetwork.Common.Models;
|
||||
|
||||
|
100
DysonNetwork.Common/Services/Permission/PermissionMiddleware.cs
Normal file
100
DysonNetwork.Common/Services/Permission/PermissionMiddleware.cs
Normal file
@ -0,0 +1,100 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DysonNetwork.Common.Extensions;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
|
||||
namespace DysonNetwork.Common.Services.Permission;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method, Inherited = true)]
|
||||
public class RequiredPermissionAttribute(string area, string key) : Attribute
|
||||
{
|
||||
public string Area { get; set; } = area;
|
||||
public string Key { get; } = key;
|
||||
}
|
||||
|
||||
public class PermissionMiddleware<TDbContext> where TDbContext : DbContext
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public PermissionMiddleware(RequestDelegate next, IServiceProvider serviceProvider)
|
||||
{
|
||||
_next = next;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext httpContext)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var permissionService = new PermissionService<TDbContext>(
|
||||
scope.ServiceProvider.GetRequiredService<TDbContext>(),
|
||||
scope.ServiceProvider.GetRequiredService<ICacheService>()
|
||||
);
|
||||
|
||||
var endpoint = httpContext.GetEndpoint();
|
||||
var attr = endpoint?.Metadata.OfType<RequiredPermissionAttribute>().FirstOrDefault();
|
||||
|
||||
if (attr != null)
|
||||
{
|
||||
if (httpContext.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await httpContext.Response.WriteAsync("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
var currentUserId = httpContext.User.GetUserId();
|
||||
if (currentUserId == Guid.Empty)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await httpContext.Response.WriteAsync("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Check for superuser from PassClient
|
||||
// if (currentUser.IsSuperuser)
|
||||
// {
|
||||
// await _next(httpContext);
|
||||
// return;
|
||||
// }
|
||||
|
||||
var actor = $"user:{currentUserId}";
|
||||
var hasPermission = await permissionService.HasPermissionAsync(actor, attr.Area, attr.Key);
|
||||
|
||||
if (!hasPermission)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await httpContext.Response.WriteAsync("Forbidden");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _next.Invoke(httpContext);
|
||||
}
|
||||
}
|
||||
|
||||
public static class PermissionServiceExtensions
|
||||
{
|
||||
public static IServiceCollection AddPermissionService<TDbContext>(this IServiceCollection services)
|
||||
where TDbContext : DbContext
|
||||
{
|
||||
services.AddScoped<PermissionService<TDbContext>>(sp =>
|
||||
new PermissionService<TDbContext>(
|
||||
sp.GetRequiredService<TDbContext>(),
|
||||
sp.GetRequiredService<ICacheService>()
|
||||
));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IApplicationBuilder UsePermissionMiddleware<TDbContext>(this IApplicationBuilder builder)
|
||||
where TDbContext : DbContext
|
||||
{
|
||||
return builder.UseMiddleware<PermissionMiddleware<TDbContext>>(builder.ApplicationServices);
|
||||
}
|
||||
}
|
203
DysonNetwork.Common/Services/Permission/PermissionService.cs
Normal file
203
DysonNetwork.Common/Services/Permission/PermissionService.cs
Normal file
@ -0,0 +1,203 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Common.Models;
|
||||
using DysonNetwork.Common.Services;
|
||||
|
||||
namespace DysonNetwork.Common.Services.Permission;
|
||||
|
||||
public class PermissionService<TDbContext> where TDbContext : DbContext
|
||||
{
|
||||
private readonly TDbContext _db;
|
||||
private readonly ICacheService _cache;
|
||||
private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(1);
|
||||
|
||||
private const string PermCacheKeyPrefix = "perm:";
|
||||
private const string PermGroupCacheKeyPrefix = "perm-cg:";
|
||||
private const string PermissionGroupPrefix = "perm-g:";
|
||||
|
||||
public PermissionService(TDbContext db, ICacheService cache)
|
||||
{
|
||||
_db = db;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
private static string GetPermissionCacheKey(string actor, string area, string key) =>
|
||||
PermCacheKeyPrefix + actor + ":" + area + ":" + key;
|
||||
|
||||
private static string GetGroupsCacheKey(string actor) =>
|
||||
PermGroupCacheKeyPrefix + actor;
|
||||
|
||||
private static string GetPermissionGroupKey(string actor) =>
|
||||
PermissionGroupPrefix + actor;
|
||||
|
||||
public async Task<bool> HasPermissionAsync(string actor, string area, string key)
|
||||
{
|
||||
var value = await GetPermissionAsync<bool>(actor, area, key);
|
||||
return value;
|
||||
}
|
||||
|
||||
public async Task<T?> GetPermissionAsync<T>(string actor, string area, string key)
|
||||
{
|
||||
var cacheKey = GetPermissionCacheKey(actor, area, key);
|
||||
|
||||
var (hit, cachedValue) = await _cache.GetAsyncWithStatus<T>(cacheKey);
|
||||
if (hit)
|
||||
return cachedValue;
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var groupsKey = GetGroupsCacheKey(actor);
|
||||
|
||||
var groupsId = await _cache.GetAsync<List<Guid>>(groupsKey);
|
||||
if (groupsId == null)
|
||||
{
|
||||
groupsId = await _db.Set<PermissionGroupMember>()
|
||||
.Where(n => n.Actor == actor)
|
||||
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
|
||||
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||
.Select(e => e.GroupId)
|
||||
.ToListAsync();
|
||||
|
||||
await _cache.SetWithGroupsAsync(groupsKey, groupsId,
|
||||
[GetPermissionGroupKey(actor)],
|
||||
CacheExpiration);
|
||||
}
|
||||
|
||||
var permission = await _db.Set<PermissionNode>()
|
||||
.Where(n => (n.GroupId == null && n.Actor == actor) ||
|
||||
(n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
|
||||
.Where(n => n.Key == key && n.Area == area)
|
||||
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
|
||||
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var result = permission is not null ? DeserializePermissionValue<T>(permission.Value) : default;
|
||||
|
||||
await _cache.SetWithGroupsAsync(cacheKey, result,
|
||||
[GetPermissionGroupKey(actor)],
|
||||
CacheExpiration);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<PermissionNode> AddPermissionNode<T>(
|
||||
string actor,
|
||||
string area,
|
||||
string key,
|
||||
T value,
|
||||
Instant? expiredAt = null,
|
||||
Instant? affectedAt = null
|
||||
)
|
||||
{
|
||||
if (value is null) throw new ArgumentNullException(nameof(value));
|
||||
|
||||
var node = new PermissionNode
|
||||
{
|
||||
Actor = actor,
|
||||
Key = key,
|
||||
Area = area,
|
||||
Value = SerializePermissionValue(value),
|
||||
ExpiredAt = expiredAt,
|
||||
AffectedAt = affectedAt
|
||||
};
|
||||
|
||||
_db.Set<PermissionNode>().Add(node);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Invalidate related caches
|
||||
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
public async Task<PermissionNode> AddPermissionNodeToGroup<T>(
|
||||
PermissionGroup group,
|
||||
string actor,
|
||||
string area,
|
||||
string key,
|
||||
T value,
|
||||
Instant? expiredAt = null,
|
||||
Instant? affectedAt = null
|
||||
)
|
||||
{
|
||||
if (value is null) throw new ArgumentNullException(nameof(value));
|
||||
|
||||
var node = new PermissionNode
|
||||
{
|
||||
Actor = actor,
|
||||
Key = key,
|
||||
Area = area,
|
||||
Value = SerializePermissionValue(value),
|
||||
ExpiredAt = expiredAt,
|
||||
AffectedAt = affectedAt,
|
||||
Group = group,
|
||||
GroupId = group.Id
|
||||
};
|
||||
|
||||
_db.Set<PermissionNode>().Add(node);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Invalidate related caches
|
||||
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||
await _cache.RemoveAsync(GetGroupsCacheKey(actor));
|
||||
await _cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
public async Task RemovePermissionNode(string actor, string area, string key)
|
||||
{
|
||||
var node = await _db.Set<PermissionNode>()
|
||||
.Where(n => n.Actor == actor && n.Area == area && n.Key == key)
|
||||
.FirstOrDefaultAsync();
|
||||
if (node is not null) _db.Set<PermissionNode>().Remove(node);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Invalidate cache
|
||||
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||
}
|
||||
|
||||
public async Task RemovePermissionNodeFromGroup(PermissionGroup group, string actor, string area, string key)
|
||||
{
|
||||
var node = await _db.Set<PermissionNode>()
|
||||
.Where(n => n.GroupId == group.Id)
|
||||
.Where(n => n.Actor == actor && n.Area == area && n.Key == key)
|
||||
.FirstOrDefaultAsync();
|
||||
if (node is null) return;
|
||||
_db.Set<PermissionNode>().Remove(node);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Invalidate caches
|
||||
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||
await _cache.RemoveAsync(GetGroupsCacheKey(actor));
|
||||
await _cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
|
||||
}
|
||||
|
||||
private async Task InvalidatePermissionCacheAsync(string actor, string area, string key)
|
||||
{
|
||||
var cacheKey = GetPermissionCacheKey(actor, area, key);
|
||||
await _cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
private static T? DeserializePermissionValue<T>(JsonDocument json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json.RootElement.GetRawText());
|
||||
}
|
||||
|
||||
private static JsonDocument SerializePermissionValue<T>(T obj)
|
||||
{
|
||||
var str = JsonSerializer.Serialize(obj);
|
||||
return JsonDocument.Parse(str);
|
||||
}
|
||||
|
||||
public static PermissionNode NewPermissionNode<T>(string actor, string area, string key, T value)
|
||||
{
|
||||
return new PermissionNode
|
||||
{
|
||||
Actor = actor,
|
||||
Area = area,
|
||||
Key = key,
|
||||
Value = SerializePermissionValue(value),
|
||||
};
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user