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,64 @@
namespace Tiku.Application.Security;
public static class BackendPermissions
{
public const string TenantDashboardView = "tenant:dashboard:view";
public const string TenantStaffManage = "tenant:staff:manage";
public const string TenantRoleManage = "tenant:role:manage";
public const string TenantStudentManage = "tenant:student:manage";
public const string TenantContentManage = "tenant:content:manage";
public const string TenantSettingsManage = "tenant:settings:manage";
public const string TenantProviderManage = "tenant:provider:manage";
public const string TenantCommerceOperate = "tenant:commerce:operate";
public const string TenantCrmManage = "tenant:crm:manage";
public const string TenantCommissionManage = "tenant:commission:manage";
public const string TenantJobManage = "tenant:job:manage";
public const string PlatformDashboardView = "platform:dashboard:view";
public const string PlatformTenantManage = "platform:tenant:manage";
public const string PlatformStaffManage = "platform:staff:manage";
public const string PlatformRoleManage = "platform:role:manage";
public const string PlatformQuestionBankManage = "platform:question-bank:manage";
public const string PlatformAuditView = "platform:audit:view";
public static readonly IReadOnlySet<string> Tenant = new HashSet<string>(StringComparer.Ordinal)
{
TenantDashboardView,
TenantStaffManage,
TenantRoleManage,
TenantStudentManage,
TenantContentManage,
TenantSettingsManage,
TenantProviderManage,
TenantCommerceOperate,
TenantCrmManage,
TenantCommissionManage,
TenantJobManage
};
public static readonly IReadOnlySet<string> Platform = new HashSet<string>(StringComparer.Ordinal)
{
PlatformDashboardView,
PlatformTenantManage,
PlatformStaffManage,
PlatformRoleManage,
PlatformQuestionBankManage,
PlatformAuditView
};
public static void EnsureTenant(string permissionCode)
{
if (!Tenant.Contains(permissionCode))
{
throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Unknown tenant permission.");
}
}
public static void EnsurePlatform(string permissionCode)
{
if (!Platform.Contains(permissionCode))
{
throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Unknown platform permission.");
}
}
}

View File

@@ -8,7 +8,6 @@ public sealed class CurrentUser : ICurrentUser
public Guid? SessionId { get; private set; }
public string? Phone { get; private set; }
public string? Email { get; private set; }
public string? TenantRole { get; private set; }
public bool IsAuthenticated { get; private set; }
public void Load(ClaimsPrincipal principal)
@@ -18,6 +17,5 @@ public sealed class CurrentUser : ICurrentUser
SessionId = principal.FindGuid(TikuClaimTypes.SessionId);
Phone = principal.FindValue(TikuClaimTypes.Phone);
Email = principal.FindValue(TikuClaimTypes.Email);
TenantRole = principal.FindValue(TikuClaimTypes.TenantRole);
}
}

View File

@@ -0,0 +1,149 @@
using System.Text.Json;
namespace Tiku.Application.Security;
public enum DataScopeMode
{
Self,
Restricted,
All
}
public sealed record CurrentDataScope(
DataScopeMode Mode,
IReadOnlySet<Guid> RegionIds,
IReadOnlySet<Guid> ClassIds,
bool IncludesSelf)
{
public static CurrentDataScope Self { get; } = new(
DataScopeMode.Self,
new HashSet<Guid>(),
new HashSet<Guid>(),
true);
public bool AllowsResource(Guid currentUserId, Guid? ownerUserId = null, Guid? regionId = null, Guid? classId = null)
{
if (Mode == DataScopeMode.All)
{
return true;
}
if (IncludesSelf && ownerUserId == currentUserId)
{
return true;
}
return Mode == DataScopeMode.Restricted &&
((regionId.HasValue && RegionIds.Contains(regionId.Value)) ||
(classId.HasValue && ClassIds.Contains(classId.Value)));
}
public static CurrentDataScope Merge(IEnumerable<JsonElement> roleScopes)
{
var regionIds = new HashSet<Guid>();
var classIds = new HashSet<Guid>();
var includesSelf = false;
var hasRestrictedScope = false;
foreach (var roleScope in roleScopes)
{
var parsed = Parse(roleScope);
if (parsed.Mode == DataScopeMode.All)
{
return new CurrentDataScope(DataScopeMode.All, new HashSet<Guid>(), new HashSet<Guid>(), true);
}
includesSelf |= parsed.IncludesSelf;
hasRestrictedScope |= parsed.Mode == DataScopeMode.Restricted;
regionIds.UnionWith(parsed.RegionIds);
classIds.UnionWith(parsed.ClassIds);
}
return hasRestrictedScope || regionIds.Count > 0 || classIds.Count > 0
? new CurrentDataScope(DataScopeMode.Restricted, regionIds, classIds, includesSelf)
: Self;
}
private static CurrentDataScope Parse(JsonElement value)
{
if (value.ValueKind != JsonValueKind.Object)
{
return Self;
}
var mode = ReadString(value, "mode") ?? ReadString(value, "type");
if (string.Equals(mode, nameof(DataScopeMode.All), StringComparison.OrdinalIgnoreCase))
{
return new CurrentDataScope(DataScopeMode.All, new HashSet<Guid>(), new HashSet<Guid>(), true);
}
if (string.Equals(mode, nameof(DataScopeMode.Self), StringComparison.OrdinalIgnoreCase))
{
return Self;
}
var regions = ReadGuids(value, "regionIds");
var classes = ReadGuids(value, "classIds");
var restricted = string.Equals(mode, nameof(DataScopeMode.Restricted), StringComparison.OrdinalIgnoreCase) ||
regions.Count > 0 ||
classes.Count > 0;
return restricted
? new CurrentDataScope(DataScopeMode.Restricted, regions, classes, ReadBoolean(value, "includesSelf") || ReadBoolean(value, "ownLeadsOnly"))
: Self;
}
private static string? ReadString(JsonElement value, string propertyName)
{
return value.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
}
private static bool ReadBoolean(JsonElement value, string propertyName)
{
return value.TryGetProperty(propertyName, out var property) &&
property.ValueKind is JsonValueKind.True or JsonValueKind.False &&
property.GetBoolean();
}
private static HashSet<Guid> ReadGuids(JsonElement value, string propertyName)
{
var result = new HashSet<Guid>();
if (!value.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array)
{
return result;
}
foreach (var item in property.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id))
{
result.Add(id);
}
}
return result;
}
}
public sealed record CurrentAccessSnapshot(
Guid? UserId,
Guid? TenantId,
bool IsUserActive,
bool IsCurrentTenantMember,
IReadOnlySet<string> TenantPermissions,
IReadOnlySet<string> PlatformPermissions,
CurrentDataScope DataScope)
{
public bool HasTenantPermission(string permissionCode) =>
IsCurrentTenantMember && TenantPermissions.Contains(permissionCode);
public bool HasPlatformPermission(string permissionCode) =>
IsUserActive && PlatformPermissions.Contains(permissionCode);
}
public interface ICurrentAccessContext
{
Task<CurrentAccessSnapshot> GetAsync(CancellationToken cancellationToken = default);
}

View File

@@ -8,7 +8,6 @@ public interface ICurrentUser
Guid? SessionId { get; }
string? Phone { get; }
string? Email { get; }
string? TenantRole { get; }
bool IsAuthenticated { get; }
void Load(ClaimsPrincipal principal);
}

View File

@@ -0,0 +1,9 @@
using Microsoft.IdentityModel.Tokens;
namespace Tiku.Application.Security;
public interface IJwtKeyRing
{
SigningCredentials SigningCredentials { get; }
IReadOnlyCollection<SecurityKey> ValidationKeys { get; }
}

View File

@@ -9,14 +9,73 @@ public sealed class JwtOptions
public string Audience { get; set; } = "tiku-api";
[System.ComponentModel.DataAnnotations.Required]
[System.ComponentModel.DataAnnotations.MinLength(32)]
public string SigningKey { get; set; } = string.Empty;
public string KeyId { get; set; } = "development-ephemeral";
public string PrivateKeyPem { get; set; } = string.Empty;
public Dictionary<string, string> PublicKeys { get; set; } = new(StringComparer.Ordinal);
[System.ComponentModel.DataAnnotations.Range(1, 1440)]
public int AccessTokenMinutes { get; set; } = 30;
public int AccessTokenMinutes { get; set; } = 15;
[System.ComponentModel.DataAnnotations.Range(1, 365)]
public int RefreshTokenDays { get; set; } = 30;
public bool ValidateSessions { get; set; } = true;
public static bool BeValid(JwtOptions options, bool isProduction)
{
if (string.IsNullOrWhiteSpace(options.Issuer) ||
string.IsNullOrWhiteSpace(options.Audience) ||
string.IsNullOrWhiteSpace(options.KeyId) ||
options.AccessTokenMinutes != 15 ||
(isProduction && string.Equals(
options.KeyId,
"development-ephemeral",
StringComparison.Ordinal)))
{
return false;
}
if (string.IsNullOrWhiteSpace(options.PrivateKeyPem))
{
if (isProduction)
{
return false;
}
}
else if (!IsValidRsaPem(options.PrivateKeyPem, requirePrivateKey: true))
{
return false;
}
return options.PublicKeys.All(pair =>
!string.IsNullOrWhiteSpace(pair.Key) &&
!string.Equals(pair.Key, options.KeyId, StringComparison.Ordinal) &&
IsValidRsaPem(pair.Value, requirePrivateKey: false));
}
private static bool IsValidRsaPem(string pem, bool requirePrivateKey)
{
try
{
using var rsa = System.Security.Cryptography.RSA.Create();
rsa.ImportFromPem(pem);
if (rsa.KeySize < 2048)
{
return false;
}
if (requirePrivateKey)
{
_ = rsa.ExportParameters(includePrivateParameters: true);
}
return true;
}
catch (Exception exception) when (
exception is ArgumentException or
System.Security.Cryptography.CryptographicException)
{
return false;
}
}
}

View File

@@ -4,10 +4,11 @@ namespace Tiku.Application.Security;
public static class TikuClaimTypes
{
public const string UserId = "tiku:user_id";
public const string TenantId = "tiku:tenant_id";
public const string SessionId = "tiku:session_id";
public const string TenantRole = "tiku:tenant_role";
public const string UserId = "sub";
public const string TenantId = "tid";
public const string SessionId = "sid";
public const string Realm = "scope";
public const string Mfa = "amr";
public const string Phone = ClaimTypes.MobilePhone;
public const string Email = ClaimTypes.Email;
}

View File

@@ -4,5 +4,23 @@ public static class TikuPolicies
{
public const string AuthenticatedUser = "authenticated_user";
public const string CurrentTenantMember = "current_tenant_member";
public const string TenantBackofficeBootstrap = "tenant_backoffice_bootstrap";
public const string PlatformBackofficeBootstrap = "platform_backoffice_bootstrap";
public const string Mfa = "mfa";
public const string TenantContentManageAllScope = "tenant:content:manage:all_scope";
public const string TenantCommerceOperateAllScope = "tenant:commerce:operate:all_scope";
public const string TenantAdmin = "tenant_admin";
public static string TenantPermission(string permissionCode)
{
BackendPermissions.EnsureTenant(permissionCode);
return permissionCode;
}
public static string PlatformPermission(string permissionCode)
{
BackendPermissions.EnsurePlatform(permissionCode);
return permissionCode;
}
}