feat: harden SaaS authentication and authorization
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.Auth;
|
||||
@@ -35,6 +36,7 @@ public sealed record TenantMembershipSummary(
|
||||
/// <param name="Phone">手机号。</param>
|
||||
/// <param name="Email">邮箱。</param>
|
||||
/// <param name="Name">用户显示名称。</param>
|
||||
/// <param name="Realm">当前令牌的 tenant 或 platform 授权域。</param>
|
||||
/// <param name="Tenant">当前登录租户成员摘要。</param>
|
||||
/// <param name="Tokens">认证令牌对。</param>
|
||||
public sealed record AuthenticatedUser(
|
||||
@@ -42,25 +44,47 @@ public sealed record AuthenticatedUser(
|
||||
string? Phone,
|
||||
string? Email,
|
||||
string? Name,
|
||||
TenantMembershipSummary Tenant,
|
||||
AuthRealm Realm,
|
||||
TenantMembershipSummary? Tenant,
|
||||
AuthTokenPair Tokens);
|
||||
|
||||
public enum AuthenticationStatus
|
||||
{
|
||||
[JsonStringEnumMemberName("authenticated")]
|
||||
Authenticated,
|
||||
[JsonStringEnumMemberName("mfa_required")]
|
||||
MfaRequired,
|
||||
[JsonStringEnumMemberName("mfa_enrollment_required")]
|
||||
MfaEnrollmentRequired,
|
||||
[JsonStringEnumMemberName("password_change_required")]
|
||||
PasswordChangeRequired
|
||||
}
|
||||
|
||||
public sealed record AuthenticationResult(
|
||||
AuthenticationStatus Status,
|
||||
AuthenticatedUser? User = null,
|
||||
string? ChallengeToken = null,
|
||||
DateTimeOffset? ChallengeExpiresAt = null);
|
||||
|
||||
public sealed record PasswordLoginRequest(
|
||||
Guid TenantId,
|
||||
AuthRealm Realm,
|
||||
Guid? TenantId,
|
||||
string Phone,
|
||||
string Password,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
|
||||
public sealed record SmsLoginRequest(
|
||||
Guid TenantId,
|
||||
AuthRealm Realm,
|
||||
Guid? TenantId,
|
||||
string Phone,
|
||||
string Code,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
|
||||
public sealed record WechatLoginRequest(
|
||||
Guid TenantId,
|
||||
AuthRealm Realm,
|
||||
Guid? TenantId,
|
||||
string Code,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
@@ -73,6 +97,24 @@ public sealed record RefreshSessionRequest(
|
||||
public sealed record LogoutSessionRequest(
|
||||
string RefreshToken);
|
||||
|
||||
public sealed record MfaChallengeRequest(
|
||||
string ChallengeToken,
|
||||
string? Code,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
|
||||
public sealed record PasswordChangeChallengeRequest(
|
||||
string ChallengeToken,
|
||||
string NewPassword,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
|
||||
public sealed record MfaSetupResult(string SharedKey, string AuthenticatorUri);
|
||||
|
||||
public sealed record MfaConfirmResult(
|
||||
AuthenticationResult Authentication,
|
||||
IReadOnlyList<string> RecoveryCodes);
|
||||
|
||||
public sealed record SmsSendResult(
|
||||
Guid VerificationId,
|
||||
DateTimeOffset ExpiresAt);
|
||||
@@ -82,4 +124,5 @@ public sealed record SendSmsCodeRequest(
|
||||
string Phone,
|
||||
SmsPurpose Purpose,
|
||||
string? IpAddress,
|
||||
string? UserAgent);
|
||||
string? UserAgent,
|
||||
string? DeviceId = null);
|
||||
|
||||
@@ -19,3 +19,6 @@ public sealed class SmsRateLimitedException()
|
||||
|
||||
public sealed class AuthProviderNotConfiguredException(string provider)
|
||||
: AuthException("auth_provider_not_configured", $"The {provider} auth provider is not configured.");
|
||||
|
||||
public sealed class InvalidAuthChallengeException(string code = "invalid_auth_challenge")
|
||||
: AuthException(code, "The authentication challenge is invalid, consumed, or expired.");
|
||||
|
||||
@@ -2,19 +2,19 @@ namespace Tiku.Application.Auth;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<AuthenticatedUser> LoginWithPasswordAsync(
|
||||
Task<AuthenticationResult> LoginWithPasswordAsync(
|
||||
PasswordLoginRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticatedUser> LoginWithSmsAsync(
|
||||
Task<AuthenticationResult> LoginWithSmsAsync(
|
||||
SmsLoginRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticatedUser> LoginWithWechatWebAsync(
|
||||
Task<AuthenticationResult> LoginWithWechatWebAsync(
|
||||
WechatLoginRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticatedUser> LoginWithWechatMiniAppAsync(
|
||||
Task<AuthenticationResult> LoginWithWechatMiniAppAsync(
|
||||
WechatLoginRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -25,4 +25,22 @@ public interface IAuthService
|
||||
Task LogoutAsync(
|
||||
LogoutSessionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MfaSetupResult> SetupTotpAsync(
|
||||
MfaChallengeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<MfaConfirmResult> ConfirmTotpAsync(
|
||||
MfaChallengeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticationResult> VerifyTotpAsync(
|
||||
MfaChallengeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthenticationResult> ChangeRequiredPasswordAsync(
|
||||
PasswordChangeChallengeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
49
Tiku.Application/Auth/IAuthSessionStore.cs
Normal file
49
Tiku.Application/Auth/IAuthSessionStore.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface IAuthSessionStore
|
||||
{
|
||||
string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId);
|
||||
bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator);
|
||||
string HashRefreshToken(string refreshToken);
|
||||
|
||||
Task<AuthTokenPair> IssueAsync(
|
||||
AuthSessionIssueRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthTokenPair> RotateAsync(
|
||||
string refreshToken,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AuthSessionValidationResult?> ValidateAccessSessionAsync(
|
||||
Guid sessionId,
|
||||
Guid userId,
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default);
|
||||
Task RevokeRealmAsync(Guid userId, AuthRealm realm, Guid? tenantId, string reason, CancellationToken cancellationToken = default);
|
||||
Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record AuthSessionIssueRequest(
|
||||
Guid UserId,
|
||||
string? Phone,
|
||||
string? Email,
|
||||
string SecurityStamp,
|
||||
AuthRealm Realm,
|
||||
Guid? TenantId,
|
||||
string Provider,
|
||||
bool MfaSatisfied,
|
||||
string? IpAddress,
|
||||
string? UserAgent,
|
||||
Guid? TokenFamilyId = null,
|
||||
Guid? ParentSessionId = null);
|
||||
|
||||
public sealed record AuthSessionValidationResult(Guid UserId, AuthRealm Realm, Guid? TenantId, bool MfaSatisfied);
|
||||
|
||||
public readonly record struct RefreshTokenLocator(AuthRealm Realm, Guid? TenantId, Guid SessionId);
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface IPasswordHasher
|
||||
{
|
||||
string Hash(string password);
|
||||
bool Verify(string password, string passwordHash);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public interface ISessionService
|
||||
{
|
||||
string GenerateRefreshToken(Guid tenantId, Guid sessionId);
|
||||
bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator);
|
||||
string HashRefreshToken(string refreshToken);
|
||||
|
||||
Task<AuthTokenPair> IssueAsync(
|
||||
Guid userId,
|
||||
string? phone,
|
||||
string? email,
|
||||
TenantMembership membership,
|
||||
string provider,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public readonly record struct RefreshTokenLocator(Guid TenantId, Guid SessionId);
|
||||
@@ -9,5 +9,7 @@ public interface ITokenService
|
||||
Guid sessionId,
|
||||
string? phone,
|
||||
string? email,
|
||||
TenantMembership membership);
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
bool mfaSatisfied);
|
||||
}
|
||||
|
||||
23
Tiku.Application/Auth/SmsSecurityOptions.cs
Normal file
23
Tiku.Application/Auth/SmsSecurityOptions.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public sealed class SmsSecurityOptions
|
||||
{
|
||||
public const string SectionName = "Authentication:Sms";
|
||||
|
||||
public string CodePepper { get; set; } = string.Empty;
|
||||
public int MaxVerificationAttempts { get; set; } = 5;
|
||||
public int TenantRequestsPerHour { get; set; } = 100;
|
||||
public int PhoneRequestsPerHour { get; set; } = 5;
|
||||
public int IpRequestsPerHour { get; set; } = 20;
|
||||
public int DeviceRequestsPerHour { get; set; } = 10;
|
||||
|
||||
public static bool BeValid(SmsSecurityOptions options)
|
||||
{
|
||||
return options.CodePepper.Length >= 32 &&
|
||||
options.MaxVerificationAttempts == 5 &&
|
||||
options.TenantRequestsPerHour > 0 &&
|
||||
options.PhoneRequestsPerHour > 0 &&
|
||||
options.IpRequestsPerHour > 0 &&
|
||||
options.DeviceRequestsPerHour > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,43 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Operations;
|
||||
|
||||
namespace Tiku.Application.Backoffice;
|
||||
|
||||
public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatform);
|
||||
public sealed record BackofficeActor(Guid UserId, Guid? TenantId, bool IsPlatform)
|
||||
{
|
||||
public static BackofficeActor FromTenantAccess(CurrentAccessSnapshot access)
|
||||
{
|
||||
if (access.UserId is not { } userId ||
|
||||
access.TenantId is not { } tenantId ||
|
||||
!access.IsCurrentTenantMember)
|
||||
{
|
||||
throw new InvalidOperationException("Tenant backoffice actor was not resolved.");
|
||||
}
|
||||
|
||||
return new BackofficeActor(userId, tenantId, false);
|
||||
}
|
||||
|
||||
public static BackofficeActor FromPlatformAccess(CurrentAccessSnapshot access)
|
||||
{
|
||||
if (access.UserId is not { } userId || !access.IsUserActive)
|
||||
{
|
||||
throw new InvalidOperationException("Platform backoffice actor was not resolved.");
|
||||
}
|
||||
|
||||
return new BackofficeActor(userId, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BackofficeBootstrap(
|
||||
IReadOnlyCollection<BackofficePermissionItem> Permissions,
|
||||
IReadOnlyCollection<BackofficeMenuItem> Menus,
|
||||
IReadOnlyCollection<BackofficeRoleItem> Roles);
|
||||
|
||||
public sealed record BackofficeUiBootstrap(
|
||||
IReadOnlyCollection<string> PermissionCodes,
|
||||
IReadOnlyCollection<BackofficeMenuItem> Menus);
|
||||
|
||||
public sealed record BackofficePermissionItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Application.Backoffice;
|
||||
|
||||
public interface IBackofficeService
|
||||
{
|
||||
Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
|
||||
CurrentAccessSnapshot access,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
|
||||
CurrentAccessSnapshot access,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BackofficeBootstrap> GetTenantBootstrapAsync(
|
||||
BackofficeActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
64
Tiku.Application/Security/BackendPermissions.cs
Normal file
64
Tiku.Application/Security/BackendPermissions.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
149
Tiku.Application/Security/ICurrentAccessContext.cs
Normal file
149
Tiku.Application/Security/ICurrentAccessContext.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
9
Tiku.Application/Security/IJwtKeyRing.cs
Normal file
9
Tiku.Application/Security/IJwtKeyRing.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public interface IJwtKeyRing
|
||||
{
|
||||
SigningCredentials SigningCredentials { get; }
|
||||
IReadOnlyCollection<SecurityKey> ValidationKeys { get; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,25 +90,6 @@ public interface ITenantAdminDirectService
|
||||
TenantAdminAuditLogFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantAdminPermissionMatrix> GetPermissionMatrixAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<TenantAdminRoleTemplateItem>> GetRoleTemplatesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminRoleTemplateFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminRoleTemplateItem>> UpsertRoleTemplateAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantAdminRoleTemplateCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantAdminRoleTemplateItem>> DisableRoleTemplateAsync(
|
||||
TenantAdminActor actor,
|
||||
Guid roleTemplateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantBrandingItem>> UpsertBrandingAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantBrandingCommand command,
|
||||
|
||||
@@ -6,7 +6,18 @@ using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.TenantAdmin;
|
||||
|
||||
public sealed record TenantAdminActor(Guid TenantId, Guid UserId, TenantRole Role = TenantRole.TenantAdmin);
|
||||
public sealed record TenantAdminActor(Guid TenantId, Guid UserId)
|
||||
{
|
||||
public static TenantAdminActor FromResolvedIdentity(Guid? tenantId, Guid? userId)
|
||||
{
|
||||
if (tenantId is null || userId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Tenant admin actor was not resolved.");
|
||||
}
|
||||
|
||||
return new(tenantId.Value, userId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TenantAdminClassFilter(
|
||||
Guid? RegionId = null,
|
||||
@@ -44,10 +55,6 @@ public sealed record TenantAdminAuditLogFilter(
|
||||
Guid? ActorUserId = null,
|
||||
int? Limit = null);
|
||||
|
||||
public sealed record TenantAdminRoleTemplateFilter(
|
||||
string? Status = null,
|
||||
int? Limit = null);
|
||||
|
||||
public sealed record TenantAdminBadgeFilter(
|
||||
string? Category = null,
|
||||
bool IncludeInactive = false,
|
||||
@@ -140,24 +147,8 @@ public sealed record UpsertTenantAdminMemberCommand(
|
||||
UserLookupCommand User,
|
||||
string? Role,
|
||||
string? Status,
|
||||
Guid? RoleTemplateId,
|
||||
JsonElement Permissions,
|
||||
string? PrimaryRole);
|
||||
|
||||
public sealed record UpsertTenantAdminRoleTemplateCommand(
|
||||
Guid? Id,
|
||||
string? Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
string? BaseRole,
|
||||
string? Status,
|
||||
JsonElement Permissions,
|
||||
JsonElement MenuPermissions,
|
||||
JsonElement ModulePermissions,
|
||||
JsonElement FieldPermissions,
|
||||
JsonElement DataScope,
|
||||
int? Order);
|
||||
|
||||
public sealed record UpsertTenantBrandingCommand(
|
||||
string BrandName,
|
||||
string? ShortName,
|
||||
@@ -357,10 +348,6 @@ public sealed record TenantAdminMemberItem(
|
||||
Guid UserId,
|
||||
TenantRole Role,
|
||||
MembershipStatus Status,
|
||||
JsonElement Permissions,
|
||||
Guid? RoleTemplateId,
|
||||
string? RoleTemplateCode,
|
||||
string? RoleTemplateName,
|
||||
string? LegacyRole,
|
||||
TenantAdminUserSummary User,
|
||||
DateTimeOffset CreatedAt,
|
||||
@@ -379,37 +366,6 @@ public sealed record TenantAdminAuditLogItem(
|
||||
string? ActorPhone,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record TenantAdminPermissionMatrix(
|
||||
TenantAdminCurrentPermission Current,
|
||||
IReadOnlyCollection<TenantAdminPermissionCatalogItem> Permissions,
|
||||
IReadOnlyDictionary<string, IReadOnlyCollection<string>> RoleDefaults);
|
||||
|
||||
public sealed record TenantAdminCurrentPermission(
|
||||
Guid UserId,
|
||||
Guid TenantId,
|
||||
TenantRole Role);
|
||||
|
||||
public sealed record TenantAdminPermissionCatalogItem(string Key, string Label);
|
||||
|
||||
public sealed record TenantAdminRoleTemplateItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string? Description,
|
||||
TenantRole BaseRole,
|
||||
TenantRoleTemplateStatus Status,
|
||||
JsonElement Permissions,
|
||||
JsonElement MenuPermissions,
|
||||
JsonElement ModulePermissions,
|
||||
JsonElement FieldPermissions,
|
||||
JsonElement DataScope,
|
||||
bool IsSystem,
|
||||
int Order,
|
||||
Guid? CreatedBy,
|
||||
Guid? UpdatedBy,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record TenantBrandingItem(
|
||||
Guid TenantId,
|
||||
string BrandName,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
Reference in New Issue
Block a user