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

@@ -1,5 +1,9 @@
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity;
@@ -10,9 +14,10 @@ namespace Tiku.Infrastructure.Auth;
public sealed class AuthService(
TikuDbContext dbContext,
IPasswordHasher passwordHasher,
SignInManager<User> signInManager,
UserManager<User> userManager,
ISmsVerificationService smsVerificationService,
ISessionService sessionService,
IAuthSessionStore sessionStore,
IWechatOAuthClient wechatOAuthClient,
ITenantExternalProviderConfigService providerConfigService) : IAuthService
{
@@ -24,35 +29,38 @@ public sealed class AuthService(
private static readonly string[] WechatMiniAppProviderAliases = ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
private static readonly string[] WechatIdentityProviders = ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
public async Task<AuthenticatedUser> LoginWithPasswordAsync(
public async Task<AuthenticationResult> LoginWithPasswordAsync(
PasswordLoginRequest request,
CancellationToken cancellationToken = default)
{
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
var identifier = request.Phone.Trim();
var normalizedEmail = userManager.NormalizeEmail(identifier);
var normalizedUserName = userManager.NormalizeName(identifier);
var user = await dbContext.Users
.SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken);
var identity = user is null
? null
: await dbContext.UserIdentities
.SingleOrDefaultAsync(
entity =>
entity.UserId == user.Id &&
entity.Provider == PasswordProvider &&
entity.ProviderSubject == phone,
cancellationToken);
.SingleOrDefaultAsync(entity =>
entity.Phone == identifier ||
entity.NormalizedEmail == normalizedEmail ||
entity.NormalizedUserName == normalizedUserName,
cancellationToken);
var passwordResult = user is null || user.Status != UserStatus.Active
? SignInResult.Failed
: await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
if (user is null ||
identity is null ||
!TryGetPasswordHash(identity.SecretPayload, out var passwordHash) ||
!passwordHasher.Verify(request.Password, passwordHash))
if (!passwordResult.Succeeded)
{
var loginResult = passwordResult.IsLockedOut
? AuthLoginResult.Blocked
: AuthLoginResult.Failed;
var failureCode = passwordResult.IsLockedOut
? "account_locked"
: "invalid_credentials";
await AddLoginEventAsync(
request.TenantId,
user?.Id,
PasswordProvider,
phone,
AuthLoginResult.Failed,
"invalid_credentials",
identifier,
loginResult,
failureCode,
request.IpAddress,
request.UserAgent,
cancellationToken);
@@ -60,16 +68,17 @@ public sealed class AuthService(
}
return await CompleteSuccessfulLoginAsync(
request.Realm,
request.TenantId,
user,
user!,
PasswordProvider,
phone,
identifier,
request.IpAddress,
request.UserAgent,
cancellationToken);
}
public async Task<AuthenticatedUser> LoginWithSmsAsync(
public async Task<AuthenticationResult> LoginWithSmsAsync(
SmsLoginRequest request,
CancellationToken cancellationToken = default)
{
@@ -79,8 +88,12 @@ public sealed class AuthService(
try
{
if (!request.TenantId.HasValue)
{
throw new InvalidCredentialsException("tenant_required_for_sms");
}
await smsVerificationService.VerifyCodeAsync(
request.TenantId,
request.TenantId.Value,
phone,
SmsPurpose.Login,
request.Code,
@@ -117,6 +130,7 @@ public sealed class AuthService(
}
return await CompleteSuccessfulLoginAsync(
request.Realm,
request.TenantId,
user,
SmsProvider,
@@ -126,7 +140,7 @@ public sealed class AuthService(
cancellationToken);
}
public Task<AuthenticatedUser> LoginWithWechatWebAsync(
public Task<AuthenticationResult> LoginWithWechatWebAsync(
WechatLoginRequest request,
CancellationToken cancellationToken = default)
{
@@ -138,7 +152,7 @@ public sealed class AuthService(
cancellationToken);
}
public Task<AuthenticatedUser> LoginWithWechatMiniAppAsync(
public Task<AuthenticationResult> LoginWithWechatMiniAppAsync(
WechatLoginRequest request,
CancellationToken cancellationToken = default)
{
@@ -154,90 +168,218 @@ public sealed class AuthService(
RefreshSessionRequest request,
CancellationToken cancellationToken = default)
{
if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator))
{
throw new SessionRevokedException();
}
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
var now = DateTimeOffset.UtcNow;
var session = await dbContext.AuthSessions
.SingleOrDefaultAsync(entity =>
entity.Id == locator.SessionId &&
entity.TenantId == locator.TenantId &&
entity.TokenHash == tokenHash,
cancellationToken);
if (session is null || session.RevokedAt is not null || session.ExpiresAt <= now)
{
throw new SessionRevokedException();
}
var user = await dbContext.Users.FindAsync([session.UserId], cancellationToken)
?? throw new SessionRevokedException();
var membership = await FindActiveMembershipAsync(session.TenantId, session.UserId, cancellationToken)
?? throw new TenantAccessDeniedException();
session.RevokedAt = now;
await AddLoginEventAsync(
session.TenantId,
session.UserId,
"refresh",
user.Phone ?? user.Email,
AuthLoginResult.Success,
null,
request.IpAddress,
request.UserAgent,
cancellationToken);
return await sessionService.IssueAsync(
user.Id,
user.Phone,
user.Email,
membership,
"refresh",
request.IpAddress,
request.UserAgent,
cancellationToken);
return await sessionStore.RotateAsync(
request.RefreshToken, request.IpAddress, request.UserAgent, cancellationToken);
}
public async Task LogoutAsync(
LogoutSessionRequest request,
CancellationToken cancellationToken = default)
{
if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator))
{
return;
}
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
var session = await dbContext.AuthSessions
.SingleOrDefaultAsync(entity =>
entity.Id == locator.SessionId &&
entity.TenantId == locator.TenantId &&
entity.TokenHash == tokenHash,
cancellationToken);
if (session is null || session.RevokedAt is not null)
{
return;
}
session.RevokedAt = DateTimeOffset.UtcNow;
await AddLoginEventAsync(
session.TenantId,
session.UserId,
"logout",
null,
AuthLoginResult.Success,
null,
null,
null,
cancellationToken);
await sessionStore.RevokeFamilyAsync(request.RefreshToken, "logout", cancellationToken);
}
private async Task<AuthenticatedUser> CompleteSuccessfulLoginAsync(
Guid tenantId,
public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default)
{
var user = await userManager.FindByIdAsync(userId.ToString())
?? throw new InvalidCredentialsException();
var stampResult = await userManager.UpdateSecurityStampAsync(user);
if (!stampResult.Succeeded)
{
throw new InvalidOperationException("Unable to update the user's security stamp.");
}
await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken);
}
public async Task<MfaSetupResult> SetupTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
var reset = await userManager.ResetAuthenticatorKeyAsync(user);
if (!reset.Succeeded)
{
throw new InvalidOperationException("Unable to initialize the authenticator key.");
}
var key = await userManager.GetAuthenticatorKeyAsync(user)
?? throw new InvalidOperationException("Authenticator key was not generated.");
challenge.SecurityStamp = user.SecurityStamp ?? string.Empty;
var account = user.Email ?? user.Phone ?? user.Id.ToString();
var uri = $"otpauth://totp/{Uri.EscapeDataString("TIKU:" + account)}" +
$"?secret={Uri.EscapeDataString(key)}&issuer={Uri.EscapeDataString("TIKU")}&digits=6";
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_setup", null,
request.IpAddress, request.UserAgent, cancellationToken);
return new MfaSetupResult(key, uri);
}
public async Task<MfaConfirmResult> ConfirmTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
if (string.IsNullOrWhiteSpace(request.Code) ||
!await userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultAuthenticatorProvider, NormalizeTotp(request.Code)))
{
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_denied", "invalid_code",
request.IpAddress, request.UserAgent, cancellationToken);
throw new InvalidCredentialsException("invalid_mfa_code");
}
var enabled = await userManager.SetTwoFactorEnabledAsync(user, true);
if (!enabled.Succeeded)
{
throw new InvalidOperationException("Unable to enable two-factor authentication.");
}
await ConsumeChallengeAsync(challenge, cancellationToken);
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.enrollment_confirmed", null,
request.IpAddress, request.UserAgent, cancellationToken);
var recoveryCodes = (await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10))?.ToArray() ?? [];
var authentication = await IssueFromChallengeAsync(
challenge, user, request.IpAddress, request.UserAgent, cancellationToken);
return new MfaConfirmResult(authentication, recoveryCodes);
}
public async Task<AuthenticationResult> VerifyTotpAsync(
MfaChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.MfaVerification, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
var recoveryCode = request.Code?.Trim();
var totpCode = NormalizeTotp(request.Code);
var verifiedByTotp = !string.IsNullOrWhiteSpace(totpCode) &&
await userManager.VerifyTwoFactorTokenAsync(
user, TokenOptions.DefaultAuthenticatorProvider, totpCode);
var verifiedByRecoveryCode = !verifiedByTotp &&
!string.IsNullOrWhiteSpace(recoveryCode) &&
(await userManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode)).Succeeded;
if (!verifiedByTotp && !verifiedByRecoveryCode)
{
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.verification_denied", "invalid_code",
request.IpAddress, request.UserAgent, cancellationToken);
throw new InvalidCredentialsException("invalid_mfa_code");
}
await ConsumeChallengeAsync(challenge, cancellationToken);
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.mfa.verified",
verifiedByRecoveryCode ? "recovery_code" : "totp",
request.IpAddress, request.UserAgent, cancellationToken);
return await IssueFromChallengeAsync(
challenge, user, request.IpAddress, request.UserAgent, cancellationToken);
}
public async Task<AuthenticationResult> ChangeRequiredPasswordAsync(
PasswordChangeChallengeRequest request,
CancellationToken cancellationToken = default)
{
var challenge = await FindChallengeAsync(
request.ChallengeToken, AuthChallengePurpose.PasswordChange, cancellationToken);
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
?? throw new InvalidAuthChallengeException();
var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
var reset = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword);
if (!reset.Succeeded)
{
throw new InvalidCredentialsException("invalid_new_password");
}
user.ForcePasswordChange = false;
var updated = await userManager.UpdateAsync(user);
if (!updated.Succeeded)
{
throw new InvalidOperationException("Unable to clear the password-change requirement.");
}
await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken);
await ConsumeChallengeAsync(challenge, cancellationToken);
await AddSecurityAuditAsync(
user.Id, challenge.TenantId, "auth.password.changed", null,
request.IpAddress, request.UserAgent, cancellationToken);
return await CompleteSuccessfulLoginAsync(
challenge.Realm, challenge.TenantId, user, challenge.Provider, user.Email ?? user.Phone ?? user.Id.ToString(),
request.IpAddress, request.UserAgent, cancellationToken);
}
private async Task<AuthenticationResult> IssueFromChallengeAsync(
AuthChallenge challenge,
User user,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
if (!await HasBackendPermissionsAsync(
challenge.Realm, challenge.TenantId, user.Id, cancellationToken))
{
throw new InvalidAuthChallengeException("backend_access_revoked");
}
Tenant? tenant = null;
TenantMembership? membership = null;
if (challenge.Realm == AuthRealm.Tenant && challenge.TenantId.HasValue)
{
tenant = await dbContext.Tenants.SingleOrDefaultAsync(
item => item.Id == challenge.TenantId.Value && item.Status == TenantStatus.Active, cancellationToken);
membership = await FindActiveMembershipAsync(challenge.TenantId.Value, user.Id, cancellationToken);
if (tenant is null || membership is null)
{
throw new TenantAccessDeniedException();
}
}
return await IssueAuthenticatedResultAsync(
user, challenge.Realm, tenant, membership, challenge.Provider,
mfaSatisfied: true, null, ipAddress, userAgent, cancellationToken);
}
private async Task<AuthChallenge> FindChallengeAsync(
string token,
AuthChallengePurpose purpose,
CancellationToken cancellationToken)
{
var tokenHash = HashChallengeToken(token);
var now = DateTimeOffset.UtcNow;
return await dbContext.AuthChallenges.SingleOrDefaultAsync(
item => item.TokenHash == tokenHash && item.Purpose == purpose &&
item.ConsumedAt == null && item.ExpiresAt > now &&
dbContext.Users.Any(user =>
user.Id == item.UserId && user.Status == UserStatus.Active &&
user.SecurityStamp == item.SecurityStamp),
cancellationToken)
?? throw new InvalidAuthChallengeException();
}
private async Task ConsumeChallengeAsync(AuthChallenge challenge, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var consumed = await dbContext.AuthChallenges
.Where(item => item.Id == challenge.Id && item.ConsumedAt == null && item.ExpiresAt > now)
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ConsumedAt, now), cancellationToken);
if (consumed != 1)
{
throw new InvalidAuthChallengeException();
}
}
private async Task<AuthenticationResult> CompleteSuccessfulLoginAsync(
AuthRealm realm,
Guid? tenantId,
User user,
string provider,
string identifier,
@@ -245,36 +387,96 @@ public sealed class AuthService(
string? userAgent,
CancellationToken cancellationToken)
{
var membership = await FindActiveMembershipAsync(tenantId, user.Id, cancellationToken);
if (membership is null)
if (user.Status != UserStatus.Active)
{
await AddLoginEventAsync(
tenantId,
user.Id,
provider,
identifier,
AuthLoginResult.Failed,
"tenant_access_denied",
ipAddress,
userAgent,
cancellationToken);
tenantId, user.Id, provider, identifier, AuthLoginResult.Failed,
"user_disabled", ipAddress, userAgent, cancellationToken);
throw new InvalidCredentialsException();
}
TenantMembership? membership = null;
Tenant? tenant = null;
if (realm == AuthRealm.Tenant && tenantId.HasValue)
{
membership = await FindActiveMembershipAsync(tenantId.Value, user.Id, cancellationToken);
tenant = await dbContext.Tenants.SingleOrDefaultAsync(
item => item.Id == tenantId.Value && item.Status == TenantStatus.Active, cancellationToken);
if (membership is null || tenant is null)
{
await AddLoginEventAsync(tenantId, user.Id, provider, identifier, AuthLoginResult.Failed,
"tenant_access_denied", ipAddress, userAgent, cancellationToken);
throw new TenantAccessDeniedException();
}
}
else if (realm == AuthRealm.Platform)
{
if (!await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken))
{
await AddLoginEventAsync(
null, user.Id, provider, identifier, AuthLoginResult.Failed,
"platform_access_denied", ipAddress, userAgent, cancellationToken);
throw new TenantAccessDeniedException();
}
}
else
{
throw new TenantAccessDeniedException();
}
var tenant = await dbContext.Tenants.FindAsync([tenantId], cancellationToken)
?? throw new TenantAccessDeniedException();
var tokens = await sessionService.IssueAsync(
user.Id,
user.Phone,
user.Email,
membership,
provider,
ipAddress,
userAgent,
if (user.ForcePasswordChange)
{
return await CreateChallengeResultAsync(
user, realm, tenantId, AuthChallengePurpose.PasswordChange, provider,
AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken);
}
var requiresMfa = await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken);
if (requiresMfa)
{
var hasAuthenticator = user.TwoFactorEnabled &&
!string.IsNullOrWhiteSpace(await userManager.GetAuthenticatorKeyAsync(user));
return await CreateChallengeResultAsync(
user, realm, tenantId,
hasAuthenticator ? AuthChallengePurpose.MfaVerification : AuthChallengePurpose.MfaEnrollment,
provider,
hasAuthenticator ? AuthenticationStatus.MfaRequired : AuthenticationStatus.MfaEnrollmentRequired,
ipAddress, userAgent, cancellationToken);
}
return await IssueAuthenticatedResultAsync(
user, realm, tenant, membership, provider, mfaSatisfied: false,
identifier, ipAddress, userAgent, cancellationToken);
}
private async Task<AuthenticationResult> IssueAuthenticatedResultAsync(
User user,
AuthRealm realm,
Tenant? tenant,
TenantMembership? membership,
string provider,
bool mfaSatisfied,
string? identifier,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
var tokens = await sessionStore.IssueAsync(
new AuthSessionIssueRequest(
user.Id,
user.Phone,
user.Email,
user.SecurityStamp ?? string.Empty,
realm,
tenant?.Id,
provider,
mfaSatisfied,
ipAddress,
userAgent),
cancellationToken);
await AddLoginEventAsync(
tenantId,
tenant?.Id,
user.Id,
provider,
identifier,
@@ -284,28 +486,28 @@ public sealed class AuthService(
userAgent,
cancellationToken);
return new AuthenticatedUser(
user.Id,
user.Phone,
user.Email,
user.Name,
new TenantMembershipSummary(
tenant.Id,
tenant.Name,
membership.Role,
membership.Status),
tokens);
var tenantSummary = tenant is not null && membership is not null
? new TenantMembershipSummary(tenant.Id, tenant.Name, membership.Role, membership.Status)
: null;
return new AuthenticationResult(
AuthenticationStatus.Authenticated,
new AuthenticatedUser(user.Id, user.Phone, user.Email, user.Name, realm, tenantSummary, tokens));
}
private async Task<AuthenticatedUser> LoginWithWechatAsync(
private async Task<AuthenticationResult> LoginWithWechatAsync(
WechatLoginRequest request,
string provider,
IReadOnlyList<string> providerAliases,
Func<WechatProviderOptions, string, CancellationToken, Task<WechatIdentity>> exchangeCodeAsync,
CancellationToken cancellationToken)
{
if (request.Realm != AuthRealm.Tenant || !request.TenantId.HasValue)
{
throw new InvalidCredentialsException("tenant_realm_required_for_wechat");
}
var config = await LoadWechatProviderOptionsAsync(
request.TenantId,
request.TenantId.Value,
provider,
providerAliases,
cancellationToken);
@@ -338,11 +540,12 @@ public sealed class AuthService(
identity,
cancellationToken);
await EnsureTenantMembershipAsync(
request.TenantId,
request.TenantId.Value,
user.Id,
cancellationToken);
return await CompleteSuccessfulLoginAsync(
request.Realm,
request.TenantId,
user,
provider,
@@ -438,7 +641,6 @@ public sealed class AuthService(
existingIdentity.UserId = user.Id;
existingIdentity.OpenId = wechatIdentity.OpenId;
existingIdentity.UnionId = wechatIdentity.UnionId;
existingIdentity.SecretPayload = CreateWechatSecretPayload(appId, wechatIdentity);
await dbContext.SaveChangesAsync(cancellationToken);
return user;
@@ -521,8 +723,111 @@ public sealed class AuthService(
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<AuthenticationResult> CreateChallengeResultAsync(
User user,
AuthRealm realm,
Guid? tenantId,
AuthChallengePurpose purpose,
string provider,
AuthenticationStatus status,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
var realmCode = realm == AuthRealm.Tenant ? "t" : "p";
var tenantCode = tenantId?.ToString("N") ?? "-";
var rawToken = $"c1.{realmCode}.{tenantCode}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48))}";
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5);
dbContext.AuthChallenges.Add(new AuthChallenge
{
UserId = user.Id,
Realm = realm,
TenantId = tenantId,
Purpose = purpose,
TokenHash = HashChallengeToken(rawToken),
SecurityStamp = user.SecurityStamp ?? string.Empty,
Provider = provider,
ExpiresAt = expiresAt,
IpAddress = ipAddress,
UserAgent = userAgent
});
await dbContext.SaveChangesAsync(cancellationToken);
await AddSecurityAuditAsync(
user.Id, tenantId, "auth.challenge.issued", status.ToString(),
ipAddress, userAgent, cancellationToken);
return new AuthenticationResult(status, ChallengeToken: rawToken, ChallengeExpiresAt: expiresAt);
}
private async Task<bool> HasBackendPermissionsAsync(
AuthRealm realm,
Guid? tenantId,
Guid userId,
CancellationToken cancellationToken)
{
if (realm == AuthRealm.Platform)
{
return await (
from userRole in dbContext.PlatformBackendUserRoles
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
where userRole.UserId == userId &&
role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active &&
(permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Platform ||
permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both)
select permission.Id).AnyAsync(cancellationToken);
}
if (!tenantId.HasValue)
{
return false;
}
return await (
from userRole in dbContext.TenantBackendUserRoles
join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id
join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
where userRole.TenantId == tenantId.Value && userRole.UserId == userId &&
binding.TenantId == tenantId.Value &&
role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active &&
(permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Tenant ||
permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both)
select permission.Id).AnyAsync(cancellationToken);
}
private static string HashChallengeToken(string token) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant();
private static string NormalizeTotp(string? code) =>
(code ?? string.Empty).Replace(" ", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal);
private async Task AddSecurityAuditAsync(
Guid userId,
Guid? tenantId,
string action,
string? reason,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
{
TenantId = tenantId,
ActorUserId = userId,
Action = action,
TargetType = "user",
TargetId = userId.ToString(),
Details = JsonSerializer.SerializeToElement(new { reason }),
IpAddress = ipAddress,
UserAgent = userAgent
});
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task AddLoginEventAsync(
Guid tenantId,
Guid? tenantId,
Guid? userId,
string provider,
string? identifier,
@@ -547,15 +852,6 @@ public sealed class AuthService(
await dbContext.SaveChangesAsync(cancellationToken);
}
private static bool TryGetPasswordHash(JsonElement secretPayload, out string passwordHash)
{
passwordHash = string.Empty;
return secretPayload.ValueKind == JsonValueKind.Object &&
secretPayload.TryGetProperty("passwordHash", out var property) &&
property.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(passwordHash = property.GetString() ?? string.Empty);
}
private static string? GetJsonString(JsonElement element, params string[] names)
{
if (element.ValueKind != JsonValueKind.Object)
@@ -578,19 +874,13 @@ public sealed class AuthService(
private static JsonElement CreateWechatRawProfile(WechatIdentity identity)
{
using var document = JsonDocument.Parse(identity.RawJson);
return document.RootElement.Clone();
return JsonSerializer.SerializeToElement(new
{
openId = identity.OpenId,
unionId = identity.UnionId,
nickname = identity.Nickname,
avatarUrl = identity.AvatarUrl
});
}
private static JsonElement CreateWechatSecretPayload(string appId, WechatIdentity identity)
{
var payload = new
{
appId,
sessionKey = identity.SessionKey,
raw = JsonSerializer.Deserialize<JsonElement>(identity.RawJson),
updatedAt = DateTimeOffset.UtcNow
};
return JsonSerializer.SerializeToElement(payload);
}
}

View File

@@ -0,0 +1,353 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Auth;
public sealed class AuthSessionStore(
TikuDbContext dbContext,
ITokenService tokenService,
IOptions<JwtOptions> options) : IAuthSessionStore
{
private readonly JwtOptions options = options.Value;
public string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId)
{
var realmCode = realm == AuthRealm.Tenant ? "t" : "p";
var tenant = tenantId?.ToString("N") ?? "-";
return $"v2.{realmCode}.{tenant}.{sessionId:N}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(64))}";
}
public bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator)
{
locator = default;
var parts = refreshToken?.Split('.', 5, StringSplitOptions.None) ?? [];
if (parts.Length != 5 || parts[0] != "v2" || parts[4].Length < 64 ||
!Guid.TryParseExact(parts[3], "N", out var sessionId))
{
return false;
}
if (parts[1] == "p" && parts[2] == "-")
{
locator = new RefreshTokenLocator(AuthRealm.Platform, null, sessionId);
return true;
}
if (parts[1] == "t" && Guid.TryParseExact(parts[2], "N", out var tenantId))
{
locator = new RefreshTokenLocator(AuthRealm.Tenant, tenantId, sessionId);
return true;
}
return false;
}
public string HashRefreshToken(string refreshToken) =>
Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(refreshToken))).ToLowerInvariant();
public async Task<AuthTokenPair> IssueAsync(
AuthSessionIssueRequest request,
CancellationToken cancellationToken = default)
{
ValidateRealm(request.Realm, request.TenantId);
var session = CreateSession(request, Guid.NewGuid());
var refreshToken = GenerateRefreshToken(session.Realm, session.TenantId, session.Id);
session.TokenHash = HashRefreshToken(refreshToken);
dbContext.AuthSessions.Add(session);
await dbContext.SaveChangesAsync(cancellationToken);
return CreatePair(request, session, refreshToken);
}
public async Task<AuthTokenPair> RotateAsync(
string refreshToken,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken = default)
{
if (!TryParseRefreshToken(refreshToken, out var locator))
{
throw new SessionRevokedException();
}
var tokenHash = HashRefreshToken(refreshToken);
var now = DateTimeOffset.UtcNow;
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
var current = await dbContext.AuthSessions.SingleOrDefaultAsync(
item => item.Id == locator.SessionId && item.Realm == locator.Realm &&
item.TenantId == locator.TenantId && item.TokenHash == tokenHash,
cancellationToken);
if (current is null)
{
throw new SessionRevokedException();
}
if (current.RevokedAt.HasValue || current.ReplacedBySessionId.HasValue || current.ExpiresAt <= now)
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "refresh_token_reuse", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == current.UserId, cancellationToken);
if (user is null || user.Status != UserStatus.Active ||
!string.Equals(user.SecurityStamp, current.SecurityStamp, StringComparison.Ordinal))
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "identity_state_changed", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
try
{
await AssertRealmAccessAsync(
current.Realm, current.TenantId, current.UserId, current.MfaSatisfied, cancellationToken);
}
catch (TenantAccessDeniedException)
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "realm_access_revoked", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
var nextId = Guid.NewGuid();
var updated = await dbContext.AuthSessions
.Where(item => item.Id == current.Id && item.RevokedAt == null && item.ReplacedBySessionId == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.RevokedAt, now)
.SetProperty(item => item.RevokedReason, "rotated")
.SetProperty(item => item.ReplacedBySessionId, nextId), cancellationToken);
if (updated != 1)
{
await RevokeFamilyCoreAsync(current.TokenFamilyId, "refresh_token_reuse", now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
throw new SessionRevokedException();
}
var request = new AuthSessionIssueRequest(
user.Id, user.Phone, user.Email, user.SecurityStamp ?? string.Empty,
current.Realm, current.TenantId, "refresh", current.MfaSatisfied,
ipAddress, userAgent, current.TokenFamilyId, current.Id);
var next = CreateSession(request, nextId);
var nextToken = GenerateRefreshToken(next.Realm, next.TenantId, next.Id);
next.TokenHash = HashRefreshToken(nextToken);
dbContext.AuthSessions.Add(next);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return CreatePair(request, next, nextToken);
}
public async Task<AuthSessionValidationResult?> ValidateAccessSessionAsync(
Guid sessionId,
Guid userId,
AuthRealm realm,
Guid? tenantId,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync(
item => item.Id == sessionId && item.UserId == userId && item.Realm == realm &&
item.TenantId == tenantId && item.RevokedAt == null && item.ExpiresAt > now,
cancellationToken);
if (session is null)
{
return null;
}
var user = await dbContext.Users.AsNoTracking().SingleOrDefaultAsync(item => item.Id == userId, cancellationToken);
if (user is null || user.Status != UserStatus.Active ||
!string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal))
{
return null;
}
try
{
await AssertRealmAccessAsync(
realm, tenantId, userId, session.MfaSatisfied, cancellationToken);
}
catch (TenantAccessDeniedException)
{
return null;
}
return new AuthSessionValidationResult(userId, realm, tenantId, session.MfaSatisfied);
}
public async Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default)
{
if (!TryParseRefreshToken(refreshToken, out var locator))
{
return;
}
var hash = HashRefreshToken(refreshToken);
var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync(
item => item.Id == locator.SessionId && item.TokenHash == hash, cancellationToken);
if (session is not null)
{
await RevokeFamilyCoreAsync(session.TokenFamilyId, reason, DateTimeOffset.UtcNow, cancellationToken);
}
}
public async Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var count = await dbContext.AuthSessions.Where(item => item.UserId == userId && item.RevokedAt == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.RevokedAt, DateTimeOffset.UtcNow)
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
if (count > 0)
{
dbContext.AuditLogs.Add(new AuditLog
{
ActorUserId = userId,
Action = "auth.sessions.revoked_all",
TargetType = "user",
TargetId = userId.ToString(),
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
});
await dbContext.SaveChangesAsync(cancellationToken);
}
}
public async Task RevokeRealmAsync(
Guid userId,
AuthRealm realm,
Guid? tenantId,
string reason,
CancellationToken cancellationToken = default)
{
ValidateRealm(realm, tenantId);
var now = DateTimeOffset.UtcNow;
var count = await dbContext.AuthSessions
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.RevokedAt, now)
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
if (count > 0)
{
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = tenantId,
ActorUserId = userId,
Action = "auth.sessions.realm_revoked",
TargetType = "user",
TargetId = userId.ToString(),
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now })
});
await dbContext.SaveChangesAsync(cancellationToken);
}
}
private AuthSession CreateSession(AuthSessionIssueRequest request, Guid sessionId) => new()
{
Id = sessionId,
Realm = request.Realm,
TenantId = request.TenantId,
UserId = request.UserId,
TokenFamilyId = request.TokenFamilyId ?? sessionId,
ParentSessionId = request.ParentSessionId,
SecurityStamp = request.SecurityStamp,
MfaSatisfied = request.MfaSatisfied,
Provider = request.Provider,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays),
IpAddress = request.IpAddress,
UserAgent = request.UserAgent
};
private AuthTokenPair CreatePair(AuthSessionIssueRequest request, AuthSession session, string refreshToken)
{
var access = tokenService.CreateAccessToken(
request.UserId, session.Id, request.Phone, request.Email,
request.Realm, request.TenantId, request.MfaSatisfied);
return new AuthTokenPair(access.Token, refreshToken, access.ExpiresAt, session.ExpiresAt);
}
private async Task AssertRealmAccessAsync(
AuthRealm realm,
Guid? tenantId,
Guid userId,
bool mfaSatisfied,
CancellationToken cancellationToken)
{
if (realm == AuthRealm.Tenant && tenantId.HasValue)
{
var active = await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) &&
await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active, cancellationToken);
if (active && (!mfaSatisfied || await HasTenantBackendPermissionAsync(tenantId.Value, userId, cancellationToken)))
{
return;
}
}
else if (realm == AuthRealm.Platform)
{
var active = await (
from userRole in dbContext.PlatformBackendUserRoles
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
where userRole.UserId == userId && role.Status == BackendRoleStatus.Active &&
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
select permission.Id).AnyAsync(cancellationToken);
if (active) return;
}
throw new TenantAccessDeniedException();
}
private Task<bool> HasTenantBackendPermissionAsync(
Guid tenantId,
Guid userId,
CancellationToken cancellationToken) =>
(from userRole in dbContext.TenantBackendUserRoles
join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id
join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
where userRole.TenantId == tenantId && userRole.UserId == userId &&
binding.TenantId == tenantId && role.Status == BackendRoleStatus.Active &&
(permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both)
select permission.Id).AnyAsync(cancellationToken);
private async Task<int> RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken)
{
var owner = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.TokenFamilyId == familyId)
.Select(item => new { item.UserId, item.TenantId })
.FirstOrDefaultAsync(cancellationToken);
var count = await dbContext.AuthSessions.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.RevokedAt, now)
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
if (count > 0 && owner is not null)
{
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = owner.TenantId,
ActorUserId = owner.UserId,
Action = "auth.session_family.revoked",
TargetType = "auth_session_family",
TargetId = familyId.ToString(),
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
});
await dbContext.SaveChangesAsync(cancellationToken);
}
return count;
}
private static void ValidateRealm(AuthRealm realm, Guid? tenantId)
{
if ((realm == AuthRealm.Tenant) != tenantId.HasValue)
{
throw new ArgumentException("Tenant sessions require a tenant and platform sessions must not have one.");
}
}
}

View File

@@ -0,0 +1,59 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Security;
namespace Tiku.Infrastructure.Auth;
internal sealed class JwtKeyRing : IJwtKeyRing, IDisposable
{
private readonly List<RSA> keys = [];
public JwtKeyRing(IOptions<JwtOptions> options)
{
var value = options.Value;
var signingRsa = RSA.Create(3072);
keys.Add(signingRsa);
if (!string.IsNullOrWhiteSpace(value.PrivateKeyPem))
{
signingRsa.ImportFromPem(value.PrivateKeyPem);
}
var signingKey = CreateKey(signingRsa, value.KeyId);
SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
var validationKeys = new List<SecurityKey> { signingKey };
foreach (var pair in value.PublicKeys.Where(pair => pair.Key != value.KeyId))
{
var rsa = RSA.Create();
rsa.ImportFromPem(pair.Value);
keys.Add(rsa);
validationKeys.Add(CreateKey(rsa, pair.Key));
}
ValidationKeys = validationKeys;
}
public SigningCredentials SigningCredentials { get; }
public IReadOnlyCollection<SecurityKey> ValidationKeys { get; }
private static RsaSecurityKey CreateKey(RSA rsa, string keyId) => new(rsa)
{
KeyId = keyId,
// IdentityModel caches signature providers globally by key identity. A key ring owns
// and disposes its RSA instances, so a provider retained by another in-process host
// could otherwise reference an RSA instance that has already been disposed.
CryptoProviderFactory = new CryptoProviderFactory
{
CacheSignatureProviders = false
}
};
public void Dispose()
{
foreach (var key in keys)
{
key.Dispose();
}
}
}

View File

@@ -1,59 +0,0 @@
using System.Security.Cryptography;
using Tiku.Application.Auth;
namespace Tiku.Infrastructure.Auth;
public sealed class PasswordHasher : IPasswordHasher
{
private const int SaltSize = 16;
private const int HashSize = 32;
private const int Iterations = 210_000;
private const string Prefix = "pbkdf2-sha256";
public string Hash(string password)
{
ArgumentException.ThrowIfNullOrWhiteSpace(password);
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(
password,
salt,
Iterations,
HashAlgorithmName.SHA256,
HashSize);
return string.Join(
'$',
Prefix,
Iterations.ToString(System.Globalization.CultureInfo.InvariantCulture),
Convert.ToBase64String(salt),
Convert.ToBase64String(hash));
}
public bool Verify(string password, string passwordHash)
{
if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(passwordHash))
{
return false;
}
var parts = passwordHash.Split('$');
if (parts.Length != 4 ||
!string.Equals(parts[0], Prefix, StringComparison.Ordinal) ||
!int.TryParse(parts[1], out var iterations))
{
return false;
}
var salt = Convert.FromBase64String(parts[2]);
var expected = Convert.FromBase64String(parts[3]);
var actual = Rfc2898DeriveBytes.Pbkdf2(
password,
salt,
iterations,
HashAlgorithmName.SHA256,
expected.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
}

View File

@@ -1,4 +1,5 @@
using Tiku.Application.Auth;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Auth;
@@ -13,6 +14,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
{
"password" => await authService.LoginWithPasswordAsync(
new PasswordLoginRequest(
AuthRealm.Tenant,
request.TenantId,
request.Identifier,
request.Secret,
@@ -21,6 +23,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
cancellationToken),
"sms" => await authService.LoginWithSmsAsync(
new SmsLoginRequest(
AuthRealm.Tenant,
request.TenantId,
request.Identifier,
request.Secret,
@@ -29,6 +32,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
cancellationToken),
"wechat_web" => await authService.LoginWithWechatWebAsync(
new WechatLoginRequest(
AuthRealm.Tenant,
request.TenantId,
request.Secret,
request.IpAddress,
@@ -36,6 +40,7 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
cancellationToken),
"wechat_miniapp" => await authService.LoginWithWechatMiniAppAsync(
new WechatLoginRequest(
AuthRealm.Tenant,
request.TenantId,
request.Secret,
request.IpAddress,
@@ -44,11 +49,13 @@ internal sealed class SelfHostedIdentityProvider(IAuthService authService) : IId
_ => throw new AuthProviderNotConfiguredException(provider)
};
var user = authenticated.User ?? throw new InvalidAuthChallengeException("interactive_authentication_required");
return new IdentityProviderResult(
provider,
authenticated.UserId.ToString("N"),
authenticated.Phone,
authenticated.Email,
authenticated.Name);
user.UserId.ToString("N"),
user.Phone,
user.Email,
user.Name);
}
}

View File

@@ -1,90 +0,0 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Auth;
public sealed class SessionService(
TikuDbContext dbContext,
ITokenService tokenService,
IOptions<JwtOptions> options) : ISessionService
{
private readonly JwtOptions options = options.Value;
public string GenerateRefreshToken(Guid tenantId, Guid sessionId)
{
return $"v1.{tenantId:N}.{sessionId:N}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(64))}";
}
public bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator)
{
locator = default;
if (string.IsNullOrWhiteSpace(refreshToken))
{
return false;
}
var parts = refreshToken.Split('.', 4, StringSplitOptions.None);
if (parts.Length != 4 || parts[0] != "v1" || parts[3].Length < 32 ||
!Guid.TryParseExact(parts[1], "N", out var tenantId) ||
!Guid.TryParseExact(parts[2], "N", out var sessionId))
{
return false;
}
locator = new RefreshTokenLocator(tenantId, sessionId);
return true;
}
public string HashRefreshToken(string refreshToken)
{
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(refreshToken));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public async Task<AuthTokenPair> IssueAsync(
Guid userId,
string? phone,
string? email,
TenantMembership membership,
string provider,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken = default)
{
var session = new AuthSession
{
Id = Guid.NewGuid(),
TenantId = membership.TenantId,
UserId = userId,
TokenHash = string.Empty,
Provider = provider,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays),
IpAddress = ipAddress,
UserAgent = userAgent
};
var refreshToken = GenerateRefreshToken(session.TenantId, session.Id);
session.TokenHash = HashRefreshToken(refreshToken);
dbContext.AuthSessions.Add(session);
await dbContext.SaveChangesAsync(cancellationToken);
var accessToken = tokenService.CreateAccessToken(
userId,
session.Id,
phone,
email,
membership);
return new AuthTokenPair(
accessToken.Token,
refreshToken,
accessToken.ExpiresAt,
session.ExpiresAt);
}
}

View File

@@ -6,10 +6,29 @@ namespace Tiku.Infrastructure.Auth;
public static class SmsCodeHashing
{
public static string Hash(Guid tenantId, string phone, SmsPurpose purpose, string code)
public static string Hash(
Guid tenantId,
string phone,
SmsPurpose purpose,
string code,
string pepper)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pepper);
var normalized = $"{tenantId:N}:{NormalizePhone(phone)}:{purpose}:{code.Trim()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
var hash = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(pepper),
Encoding.UTF8.GetBytes(normalized));
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static string HashScope(string value, string pepper)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pepper);
var hash = HMACSHA256.HashData(
Encoding.UTF8.GetBytes(pepper),
Encoding.UTF8.GetBytes(value.Trim().ToLowerInvariant()));
return Convert.ToHexString(hash).ToLowerInvariant();
}

View File

@@ -1,6 +1,10 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Domain.Common;
using Microsoft.Extensions.Options;
using Tiku.Application.Auth;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -8,44 +12,33 @@ namespace Tiku.Infrastructure.Auth;
public sealed class SmsVerificationService(
TikuDbContext dbContext,
ISmsProvider smsProvider) : ISmsVerificationService
ISmsProvider smsProvider,
IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
{
private const int MaxPhoneRequestsPerHour = 5;
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10);
private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1);
private readonly SmsSecurityOptions options = securityOptions.Value;
public async Task<SmsSendResult> CreateCodeAsync(
SendSmsCodeRequest request,
CancellationToken cancellationToken = default)
{
EnsureValidOptions();
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
var bucketStart = TruncateToHour(DateTimeOffset.UtcNow);
var scopeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, "phone-bucket");
var rateLimit = await dbContext.SmsSendRateLimits.FindAsync(
[request.TenantId, SmsRateLimitDimension.Phone, scopeHash, bucketStart],
cancellationToken);
var now = DateTimeOffset.UtcNow;
await ConsumeRateLimitsAsync(request, phone, now, cancellationToken);
if (rateLimit is null)
{
rateLimit = new SmsSendRateLimit
{
TenantId = request.TenantId,
Dimension = SmsRateLimitDimension.Phone,
ScopeHash = scopeHash,
BucketStart = bucketStart
};
dbContext.SmsSendRateLimits.Add(rateLimit);
}
var code = RandomNumberGenerator
.GetInt32(100000, 1000000)
.ToString(CultureInfo.InvariantCulture);
var codeHash = SmsCodeHashing.Hash(
request.TenantId,
phone,
request.Purpose,
code,
options.CodePepper);
if (rateLimit.RequestCount >= MaxPhoneRequestsPerHour)
{
throw new SmsRateLimitedException();
}
rateLimit.RequestCount++;
rateLimit.UpdatedAt = DateTimeOffset.UtcNow;
var code = Random.Shared.Next(100000, 999999).ToString(System.Globalization.CultureInfo.InvariantCulture);
var codeHash = SmsCodeHashing.Hash(request.TenantId, phone, request.Purpose, code);
SmsProviderSendResult sendResult;
try
{
@@ -69,18 +62,38 @@ public sealed class SmsVerificationService(
CodeHash = codeHash,
Provider = "failed",
Status = SmsVerificationStatus.Failed,
ExpiresAt = DateTimeOffset.UtcNow,
ExpiresAt = now,
IpAddress = request.IpAddress,
UserAgent = request.UserAgent,
Metadata = JsonDefaults.Object()
});
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
{
TenantId = request.TenantId,
Provider = "sms",
Identifier = phone,
Result = AuthLoginResult.Failed,
FailureCode = "sms_provider_send_failed",
IpAddress = request.IpAddress,
UserAgent = request.UserAgent
});
await dbContext.SaveChangesAsync(cancellationToken);
throw exception is SmsProviderException
? exception
: new SmsProviderException("SMS provider failed to send the verification code.", "sms_provider_send_failed", exception);
: new SmsProviderException(
"SMS provider failed to send the verification code.",
"sms_provider_send_failed",
exception);
}
await ExpirePreviousCodesAsync(
request.TenantId,
phone,
request.Purpose,
now,
cancellationToken);
var verification = new SmsVerificationCode
{
TenantId = request.TenantId,
@@ -89,12 +102,29 @@ public sealed class SmsVerificationService(
CodeHash = codeHash,
Provider = sendResult.Provider,
Status = SmsVerificationStatus.Sent,
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeLifetime),
ExpiresAt = now.Add(CodeLifetime),
IpAddress = request.IpAddress,
UserAgent = request.UserAgent
};
dbContext.SmsVerificationCodes.Add(verification);
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
{
TenantId = request.TenantId,
Provider = "sms",
Identifier = phone,
Result = AuthLoginResult.Sent,
IpAddress = request.IpAddress,
UserAgent = request.UserAgent,
Metadata = JsonSerializer.SerializeToElement(new
{
verificationId = verification.Id,
sendResult.Provider,
sendResult.Status,
sendResult.MessageId,
request.DeviceId
})
});
await dbContext.SaveChangesAsync(cancellationToken);
return new SmsSendResult(verification.Id, verification.ExpiresAt);
@@ -107,35 +137,346 @@ public sealed class SmsVerificationService(
string code,
CancellationToken cancellationToken = default)
{
EnsureValidOptions();
var normalizedPhone = SmsCodeHashing.NormalizePhone(phone);
var now = DateTimeOffset.UtcNow;
var codeHash = SmsCodeHashing.Hash(tenantId, normalizedPhone, purpose, code);
var codeHash = SmsCodeHashing.Hash(
tenantId,
normalizedPhone,
purpose,
code,
options.CodePepper);
var verification = await dbContext.SmsVerificationCodes
.AsNoTracking()
.Where(entity =>
entity.TenantId == tenantId &&
entity.Phone == normalizedPhone &&
entity.Purpose == purpose &&
entity.ConsumedAt == null)
entity.ConsumedAt == null &&
entity.Status == SmsVerificationStatus.Sent)
.OrderByDescending(entity => entity.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (verification is null ||
verification.ExpiresAt <= now ||
verification.Status != SmsVerificationStatus.Sent)
if (verification is null)
{
throw new InvalidCredentialsException("invalid_sms_code");
}
verification.Attempts++;
if (!string.Equals(verification.CodeHash, codeHash, StringComparison.Ordinal))
if (verification.ExpiresAt <= now)
{
await dbContext.SaveChangesAsync(cancellationToken);
await MarkExpiredAsync(verification.Id, now, cancellationToken);
throw new InvalidCredentialsException("invalid_sms_code");
}
if (HashesMatch(verification.CodeHash, codeHash))
{
var consumed = await TryConsumeAsync(verification.Id, now, cancellationToken);
if (consumed)
{
return;
}
throw new InvalidCredentialsException("invalid_sms_code");
}
await RecordFailedAttemptAsync(verification.Id, now, cancellationToken);
throw new InvalidCredentialsException("invalid_sms_code");
}
private async Task ConsumeRateLimitsAsync(
SendSmsCodeRequest request,
string phone,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var limits = BuildRateLimits(request, phone);
var bucketStart = TruncateToHour(now);
if (!dbContext.Database.IsRelational())
{
await ConsumeInMemoryRateLimitsAsync(limits, request.TenantId, bucketStart, now, cancellationToken);
return;
}
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
foreach (var limit in limits)
{
var dimension = ToSnakeCase(limit.Dimension);
var affected = await dbContext.Database.ExecuteSqlInterpolatedAsync($$"""
INSERT INTO sms_send_rate_limits
(tenant_id, dimension, scope_hash, bucket_start, request_count, updated_at)
VALUES
({{request.TenantId}}, {{dimension}}, {{limit.ScopeHash}}, {{bucketStart}}, 1, {{now}})
ON CONFLICT (tenant_id, dimension, scope_hash, bucket_start)
DO UPDATE SET
request_count = sms_send_rate_limits.request_count + 1,
updated_at = EXCLUDED.updated_at
WHERE sms_send_rate_limits.request_count < {{limit.Maximum}}
""", cancellationToken);
if (affected == 0)
{
await transaction.RollbackAsync(cancellationToken);
throw new SmsRateLimitedException();
}
}
await transaction.CommitAsync(cancellationToken);
}
private async Task ConsumeInMemoryRateLimitsAsync(
IReadOnlyCollection<RateLimitSpec> limits,
Guid tenantId,
DateTimeOffset bucketStart,
DateTimeOffset now,
CancellationToken cancellationToken)
{
await InMemoryRateLimitLock.WaitAsync(cancellationToken);
try
{
var counters = new List<(RateLimitSpec Limit, SmsSendRateLimit? Counter)>();
foreach (var limit in limits)
{
var counter = await dbContext.SmsSendRateLimits.FindAsync(
[tenantId, limit.Dimension, limit.ScopeHash, bucketStart],
cancellationToken);
if (counter?.RequestCount >= limit.Maximum)
{
throw new SmsRateLimitedException();
}
counters.Add((limit, counter));
}
foreach (var (limit, existingCounter) in counters)
{
var counter = existingCounter;
if (counter is null)
{
counter = new SmsSendRateLimit
{
TenantId = tenantId,
Dimension = limit.Dimension,
ScopeHash = limit.ScopeHash,
BucketStart = bucketStart
};
dbContext.SmsSendRateLimits.Add(counter);
}
counter.RequestCount++;
counter.UpdatedAt = now;
}
await dbContext.SaveChangesAsync(cancellationToken);
}
finally
{
InMemoryRateLimitLock.Release();
}
}
private IReadOnlyList<RateLimitSpec> BuildRateLimits(SendSmsCodeRequest request, string phone)
{
var limits = new List<RateLimitSpec>
{
CreateLimit(SmsRateLimitDimension.Tenant, $"tenant:{request.TenantId:N}", options.TenantRequestsPerHour),
CreateLimit(SmsRateLimitDimension.Phone, $"phone:{request.TenantId:N}:{phone}", options.PhoneRequestsPerHour)
};
if (!string.IsNullOrWhiteSpace(request.IpAddress))
{
limits.Add(CreateLimit(
SmsRateLimitDimension.Ip,
$"ip:{request.IpAddress.Trim()}",
options.IpRequestsPerHour));
}
var deviceKey = string.IsNullOrWhiteSpace(request.DeviceId)
? request.UserAgent
: request.DeviceId;
if (!string.IsNullOrWhiteSpace(deviceKey))
{
limits.Add(CreateLimit(
SmsRateLimitDimension.Device,
$"device:{deviceKey.Trim()}",
options.DeviceRequestsPerHour));
}
return limits;
}
private RateLimitSpec CreateLimit(SmsRateLimitDimension dimension, string scope, int maximum)
{
return new RateLimitSpec(
dimension,
SmsCodeHashing.HashScope(scope, options.CodePepper),
maximum);
}
private async Task ExpirePreviousCodesAsync(
Guid tenantId,
string phone,
SmsPurpose purpose,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var query = dbContext.SmsVerificationCodes.Where(entity =>
entity.TenantId == tenantId &&
entity.Phone == phone &&
entity.Purpose == purpose &&
entity.ConsumedAt == null &&
(entity.Status == SmsVerificationStatus.Pending ||
entity.Status == SmsVerificationStatus.Sent));
if (dbContext.Database.IsRelational())
{
await query.ExecuteUpdateAsync(
setters => setters
.SetProperty(entity => entity.Status, SmsVerificationStatus.Expired)
.SetProperty(entity => entity.ExpiresAt, now),
cancellationToken);
return;
}
foreach (var verification in await query.ToListAsync(cancellationToken))
{
verification.Status = SmsVerificationStatus.Expired;
verification.ExpiresAt = now;
}
}
private async Task MarkExpiredAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
{
if (dbContext.Database.IsRelational())
{
await dbContext.SmsVerificationCodes
.Where(entity =>
entity.Id == id &&
entity.Status == SmsVerificationStatus.Sent &&
entity.ConsumedAt == null &&
entity.ExpiresAt <= now)
.ExecuteUpdateAsync(
setters => setters.SetProperty(entity => entity.Status, SmsVerificationStatus.Expired),
cancellationToken);
return;
}
var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
if (verification is not null &&
verification.Status == SmsVerificationStatus.Sent &&
verification.ConsumedAt is null &&
verification.ExpiresAt <= now)
{
verification.Status = SmsVerificationStatus.Expired;
await dbContext.SaveChangesAsync(cancellationToken);
}
}
private async Task<bool> TryConsumeAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
{
if (dbContext.Database.IsRelational())
{
var affected = await dbContext.SmsVerificationCodes
.Where(entity =>
entity.Id == id &&
entity.Status == SmsVerificationStatus.Sent &&
entity.ConsumedAt == null &&
entity.ExpiresAt > now &&
entity.Attempts < options.MaxVerificationAttempts)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(entity => entity.Status, SmsVerificationStatus.Verified)
.SetProperty(entity => entity.ConsumedAt, now),
cancellationToken);
return affected == 1;
}
var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
if (verification is null ||
verification.Status != SmsVerificationStatus.Sent ||
verification.ConsumedAt is not null ||
verification.ExpiresAt <= now ||
verification.Attempts >= options.MaxVerificationAttempts)
{
return false;
}
verification.Status = SmsVerificationStatus.Verified;
verification.ConsumedAt = now;
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
private async Task RecordFailedAttemptAsync(Guid id, DateTimeOffset now, CancellationToken cancellationToken)
{
if (dbContext.Database.IsRelational())
{
await dbContext.SmsVerificationCodes
.Where(entity =>
entity.Id == id &&
entity.Status == SmsVerificationStatus.Sent &&
entity.ConsumedAt == null &&
entity.ExpiresAt > now &&
entity.Attempts < options.MaxVerificationAttempts)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(entity => entity.Attempts, entity => entity.Attempts + 1)
.SetProperty(
entity => entity.Status,
entity => entity.Attempts + 1 >= options.MaxVerificationAttempts
? SmsVerificationStatus.Blocked
: SmsVerificationStatus.Sent),
cancellationToken);
return;
}
var verification = await dbContext.SmsVerificationCodes.FindAsync([id], cancellationToken);
if (verification is null ||
verification.Status != SmsVerificationStatus.Sent ||
verification.ConsumedAt is not null ||
verification.ExpiresAt <= now ||
verification.Attempts >= options.MaxVerificationAttempts)
{
return;
}
verification.Attempts++;
if (verification.Attempts >= options.MaxVerificationAttempts)
{
verification.Status = SmsVerificationStatus.Blocked;
}
await dbContext.SaveChangesAsync(cancellationToken);
}
private void EnsureValidOptions()
{
if (!SmsSecurityOptions.BeValid(options))
{
throw new InvalidOperationException(
$"{SmsSecurityOptions.SectionName} must contain a pepper of at least 32 characters, " +
"exactly five verification attempts, and positive rate limits.");
}
}
private static bool HashesMatch(string expected, string actual)
{
try
{
return CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(expected),
Convert.FromHexString(actual));
}
catch (FormatException)
{
return false;
}
}
private static string ToSnakeCase(SmsRateLimitDimension dimension)
{
return dimension.ToString().ToLowerInvariant();
}
private static DateTimeOffset TruncateToHour(DateTimeOffset value)
@@ -149,4 +490,9 @@ public sealed class SmsVerificationService(
0,
value.Offset);
}
private sealed record RateLimitSpec(
SmsRateLimitDimension Dimension,
string ScopeHash,
int Maximum);
}

View File

@@ -1,15 +1,13 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Auth;
public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
public sealed class TokenService(IOptions<JwtOptions> options, IJwtKeyRing keyRing) : ITokenService
{
private readonly JwtOptions options = options.Value;
@@ -18,17 +16,30 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
Guid sessionId,
string? phone,
string? email,
TenantMembership membership)
AuthRealm realm,
Guid? tenantId,
bool mfaSatisfied)
{
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(options.AccessTokenMinutes);
var claims = new List<Claim>
{
new(TikuClaimTypes.UserId, userId.ToString()),
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(TikuClaimTypes.SessionId, sessionId.ToString()),
new(TikuClaimTypes.TenantId, membership.TenantId.ToString()),
new(TikuClaimTypes.TenantRole, membership.Role.ToString())
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new(TikuClaimTypes.Realm, realm.ToString().ToLowerInvariant())
};
if (tenantId.HasValue)
{
claims.Add(new Claim(TikuClaimTypes.TenantId, tenantId.Value.ToString()));
}
if (mfaSatisfied)
{
claims.Add(new Claim(TikuClaimTypes.Mfa, "mfa"));
}
if (!string.IsNullOrWhiteSpace(phone))
{
claims.Add(new Claim(TikuClaimTypes.Phone, phone));
@@ -39,15 +50,12 @@ public sealed class TokenService(IOptions<JwtOptions> options) : ITokenService
claims.Add(new Claim(TikuClaimTypes.Email, email));
}
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey)),
SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
options.Issuer,
options.Audience,
claims,
expires: expiresAt.UtcDateTime,
signingCredentials: credentials);
signingCredentials: keyRing.SigningCredentials);
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Backoffice;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
@@ -14,19 +15,23 @@ internal sealed class BackofficeService(
{
private static readonly BuiltinPermission[] BuiltinPermissions =
[
new("tenant:dashboard:view", "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
new("tenant:staff:manage", "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
new("tenant:role:manage", "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
new("tenant:student:manage", "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
new("tenant:content:manage", "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
new("tenant:provider:manage", "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
new("tenant:commerce:operate", "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
new("platform:dashboard:view", "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
new("platform:tenant:manage", "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
new("platform:staff:manage", "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
new("platform:role:manage", "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
new("platform:question-bank:manage", "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
new("platform:audit:view", "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
new(BackendPermissions.TenantDashboardView, "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
new(BackendPermissions.TenantStaffManage, "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
new(BackendPermissions.TenantRoleManage, "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
new(BackendPermissions.TenantStudentManage, "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
new(BackendPermissions.TenantContentManage, "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
new(BackendPermissions.TenantSettingsManage, "租户设置管理", BackendPermissionArea.Tenant, "tenant_settings"),
new(BackendPermissions.TenantProviderManage, "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
new(BackendPermissions.TenantCommerceOperate, "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
new(BackendPermissions.TenantCrmManage, "租户客户管理", BackendPermissionArea.Tenant, "tenant_crm"),
new(BackendPermissions.TenantCommissionManage, "租户佣金管理", BackendPermissionArea.Tenant, "tenant_commission"),
new(BackendPermissions.TenantJobManage, "租户任务管理", BackendPermissionArea.Tenant, "tenant_job"),
new(BackendPermissions.PlatformDashboardView, "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
new(BackendPermissions.PlatformTenantManage, "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
new(BackendPermissions.PlatformStaffManage, "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
new(BackendPermissions.PlatformRoleManage, "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
new(BackendPermissions.PlatformAuditView, "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
@@ -47,6 +52,43 @@ internal sealed class BackofficeService(
new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50)
];
public async Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
CurrentAccessSnapshot access,
CancellationToken cancellationToken = default)
{
if (!access.IsUserActive || !access.IsCurrentTenantMember ||
access.UserId is null || access.TenantId is null)
{
throw new BackofficeException("Tenant backoffice access is denied.", "tenant_access_denied");
}
await EnsureCatalogAsync(cancellationToken);
var permissionCodes = access.TenantPermissions.Order(StringComparer.Ordinal).ToArray();
var menus = await LoadEffectiveMenusAsync(
BackendPermissionArea.Tenant,
permissionCodes,
cancellationToken);
return new BackofficeUiBootstrap(permissionCodes, menus);
}
public async Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
CurrentAccessSnapshot access,
CancellationToken cancellationToken = default)
{
if (!access.IsUserActive || access.UserId is null || access.PlatformPermissions.Count == 0)
{
throw new BackofficeException("Platform backoffice access is denied.", "platform_access_denied");
}
await EnsureCatalogAsync(cancellationToken);
var permissionCodes = access.PlatformPermissions.Order(StringComparer.Ordinal).ToArray();
var menus = await LoadEffectiveMenusAsync(
BackendPermissionArea.Platform,
permissionCodes,
cancellationToken);
return new BackofficeUiBootstrap(permissionCodes, menus);
}
public async Task<BackofficeBootstrap> GetTenantBootstrapAsync(
BackofficeActor actor,
CancellationToken cancellationToken = default)
@@ -193,6 +235,21 @@ internal sealed class BackofficeService(
{
var tenantId = RequireTenantAdmin(actor);
var roleIds = command.RoleIds.Distinct().ToArray();
var ownerRoleId = await dbContext.TenantBackendRoles
.Where(item => item.TenantId == tenantId && item.Code == "tenant_owner" && item.IsSystem)
.Select(item => (Guid?)item.Id)
.SingleOrDefaultAsync(cancellationToken);
var isActiveOwner = await dbContext.TenantMemberships.AnyAsync(
item => item.TenantId == tenantId &&
item.UserId == command.UserId &&
item.Role == TenantRole.TenantOwner &&
item.Status == MembershipStatus.Active,
cancellationToken);
if (isActiveOwner && ownerRoleId.HasValue && !roleIds.Contains(ownerRoleId.Value))
{
throw new BackofficeException("Tenant owner system role cannot be removed.", "system_role_locked");
}
var count = await dbContext.TenantBackendRoles.CountAsync(
item => item.TenantId == tenantId && roleIds.Contains(item.Id) && item.Status == BackendRoleStatus.Active,
cancellationToken);
@@ -329,6 +386,21 @@ internal sealed class BackofficeService(
}
}
private async Task<BackofficeMenuItem[]> LoadEffectiveMenusAsync(
BackendPermissionArea area,
IReadOnlyCollection<string> permissionCodes,
CancellationToken cancellationToken)
{
var codes = permissionCodes.ToArray();
var menus = await dbContext.BackendMenus.AsNoTracking()
.Where(item => item.IsActive && item.Area == area &&
(item.PermissionCode == null || codes.Contains(item.PermissionCode)))
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Code)
.ToArrayAsync(cancellationToken);
return menus.Select(ToMenuItem).ToArray();
}
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
{
var count = await dbContext.BackendMenus.CountAsync(

View File

@@ -0,0 +1,161 @@
using System.Data;
using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Tiku.Application.Security;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Bootstrap;
public sealed record PlatformAdminBootstrapOptions(
string Email,
string TemporaryPassword,
string? DisplayName = null);
public sealed record PlatformAdminBootstrapResult(Guid UserId, Guid RoleId, string Email);
public sealed class PlatformAdminBootstrapper(
TikuDbContext dbContext,
UserManager<User> userManager)
{
public const string SuperAdminRoleCode = "platform_super_admin";
public async Task<PlatformAdminBootstrapResult> BootstrapAsync(
PlatformAdminBootstrapOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
var email = options.Email.Trim();
if (email.Length == 0)
{
throw new ArgumentException("Platform administrator email is required.", nameof(options));
}
if (string.IsNullOrWhiteSpace(options.TemporaryPassword))
{
throw new ArgumentException("Platform administrator temporary password is required.", nameof(options));
}
IDbContextTransaction? transaction = null;
if (dbContext.Database.IsRelational())
{
transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken);
}
await using (transaction)
{
var existingAdministrator = await (
from binding in dbContext.PlatformBackendUserRoles.AsNoTracking()
join boundRole in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals boundRole.Id
join boundUser in dbContext.Users.AsNoTracking() on binding.UserId equals boundUser.Id
where boundRole.Status == BackendRoleStatus.Active && boundUser.Status == UserStatus.Active
select boundUser.Id)
.AnyAsync(cancellationToken);
if (existingAdministrator)
{
throw new PlatformAdminBootstrapException(
"A platform administrator already exists. Bootstrap is a one-time operation.",
"platform_admin_already_exists");
}
var normalizedEmail = userManager.NormalizeEmail(email);
if (await dbContext.Users.AsNoTracking().AnyAsync(
user => user.NormalizedEmail == normalizedEmail || user.NormalizedUserName == normalizedEmail,
cancellationToken))
{
throw new PlatformAdminBootstrapException(
"The bootstrap email is already assigned to a user.",
"bootstrap_user_already_exists");
}
var user = new User
{
Email = email,
UserName = email,
Name = string.IsNullOrWhiteSpace(options.DisplayName) ? "Platform Administrator" : options.DisplayName.Trim(),
EmailConfirmed = true,
Status = UserStatus.Active,
ForcePasswordChange = true,
TwoFactorEnabled = false
};
var createResult = await userManager.CreateAsync(user, options.TemporaryPassword);
if (!createResult.Succeeded)
{
var errors = string.Join(", ", createResult.Errors.Select(error => $"{error.Code}: {error.Description}"));
throw new PlatformAdminBootstrapException(
$"Platform administrator could not be created: {errors}",
"bootstrap_user_invalid");
}
var role = new PlatformBackendRole
{
Code = SuperAdminRoleCode,
Name = "Platform Super Administrator",
Description = "Built-in role with all platform permissions. Created by the one-time bootstrap command.",
Status = BackendRoleStatus.Active,
IsSystem = true
};
dbContext.PlatformBackendRoles.Add(role);
var platformPermissionCodes = BackendPermissions.Platform.ToArray();
var existingPermissionCodes = await dbContext.BackendPermissions
.Where(permission => platformPermissionCodes.Contains(permission.Code))
.Select(permission => permission.Code)
.ToHashSetAsync(StringComparer.Ordinal, cancellationToken);
foreach (var permissionCode in platformPermissionCodes.Where(code => !existingPermissionCodes.Contains(code)))
{
dbContext.BackendPermissions.Add(new BackendPermission
{
Code = permissionCode,
Name = permissionCode,
Area = BackendPermissionArea.Platform,
Module = "platform",
Description = "Built-in platform permission.",
IsSystem = true
});
}
dbContext.PlatformBackendRolePermissions.AddRange(
platformPermissionCodes.Select(permissionCode => new PlatformBackendRolePermission
{
RoleId = role.Id,
PermissionCode = permissionCode
}));
dbContext.PlatformBackendUserRoles.Add(new PlatformBackendUserRole
{
UserId = user.Id,
RoleId = role.Id
});
dbContext.AuditLogs.Add(new AuditLog
{
ActorUserId = user.Id,
Action = "platform.bootstrap_admin.created",
TargetType = "users",
TargetId = user.Id.ToString(),
Details = JsonSerializer.SerializeToElement(new
{
user.Email,
RoleCode = SuperAdminRoleCode,
ForcePasswordChange = true,
MfaEnrollmentRequired = true
})
});
await dbContext.SaveChangesAsync(cancellationToken);
if (transaction is not null)
{
await transaction.CommitAsync(cancellationToken);
}
return new PlatformAdminBootstrapResult(user.Id, role.Id, email);
}
}
}
public sealed class PlatformAdminBootstrapException(string message, string code) : InvalidOperationException(message)
{
public string Code { get; } = code;
}

View File

@@ -3,18 +3,21 @@ using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Commerce;
internal sealed class CommerceAdminService(
TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector,
ITenantExternalProviderConfigService providerConfigService) : ICommerceAdminService
ITenantExternalProviderConfigService providerConfigService,
ICurrentAccessContext currentAccessContext) : ICommerceAdminService
{
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
CommerceAdminActor actor,
@@ -105,8 +108,14 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var orders = dbContext.Orders.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
item => item.UserId == actor.UserId,
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (!string.IsNullOrWhiteSpace(query.Status))
{
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
@@ -125,8 +134,16 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var scopedOrders = dbContext.Orders.AsNoTracking()
.Where(order => order.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
order => order.UserId == actor.UserId,
order => order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value));
var payments = from payment in dbContext.Payments.AsNoTracking()
join order in dbContext.Orders.AsNoTracking()
join order in scopedOrders
on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id }
where payment.TenantId == actor.TenantId
select new { payment, order.OrderNo };
@@ -568,8 +585,19 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var refunds = dbContext.CommerceRefundRequests.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
item => dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId &&
order.Id == item.OrderId &&
order.RegionId.HasValue &&
regionIds.Contains(order.RegionId.Value)));
if (!string.IsNullOrWhiteSpace(query.Status))
{
refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status));
@@ -588,9 +616,16 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var order = await dbContext.Orders.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.OrderId,
cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found");
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var order = await dbContext.Orders
.Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId)
.ApplyDataScope(
scope,
item => item.UserId == actor.UserId,
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
.SingleOrDefaultAsync(cancellationToken)
?? throw new CommerceException("Order was not found.", "order_not_found");
if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded))
{
throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable");
@@ -639,9 +674,21 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId,
cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var refund = await dbContext.CommerceRefundRequests
.Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId)
.ApplyDataScope(
scope,
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
item => dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId &&
order.Id == item.OrderId &&
order.RegionId.HasValue &&
regionIds.Contains(order.RegionId.Value)))
.SingleOrDefaultAsync(cancellationToken)
?? throw new CommerceException("Refund request was not found.", "refund_not_found");
var fromStatus = refund.Status;
if (!IsAllowedRefundTransition(fromStatus, command.Status))
{
@@ -687,6 +734,25 @@ internal sealed class CommerceAdminService(
CancellationToken cancellationToken = default)
{
await AssertAdminAsync(actor, cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var refundExists = await dbContext.CommerceRefundRequests
.Where(item => item.TenantId == actor.TenantId && item.Id == refundRequestId)
.ApplyDataScope(
scope,
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
item => dbContext.Orders.Any(order =>
order.TenantId == actor.TenantId &&
order.Id == item.OrderId &&
order.RegionId.HasValue &&
regionIds.Contains(order.RegionId.Value)))
.AnyAsync(cancellationToken);
if (!refundExists)
{
throw new CommerceException("Refund request was not found.", "refund_not_found");
}
var items = await dbContext.CommerceRefundEvents.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId)
.OrderBy(item => item.CreatedAt)
@@ -807,20 +873,24 @@ internal sealed class CommerceAdminService(
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
{
var isAdmin = await dbContext.TenantMemberships.AnyAsync(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == MembershipStatus.Active &&
(item.Role == TenantRole.PlatformAdmin ||
item.Role == TenantRole.TenantOwner ||
item.Role == TenantRole.TenantAdmin),
cancellationToken);
if (!isAdmin)
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.UserId != actor.UserId ||
access.TenantId != actor.TenantId ||
!access.HasTenantPermission(BackendPermissions.TenantCommerceOperate))
{
throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied");
}
}
private async Task<CurrentDataScope> RequireDataScopeAsync(
CommerceAdminActor actor,
CancellationToken cancellationToken)
{
await AssertAdminAsync(actor, cancellationToken);
return (await currentAccessContext.GetAsync(cancellationToken)).DataScope;
}
private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) =>
new(
item.Id,

View File

@@ -4,17 +4,20 @@ using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Content;
public sealed class ContentManagementService(
TikuDbContext dbContext,
IQuestionReferenceService questionReferenceService) : IContentManagementService
IQuestionReferenceService questionReferenceService,
ICurrentAccessContext currentAccessContext) : IContentManagementService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
@@ -24,9 +27,15 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ContentEntries
.AsNoTracking()
.Where(entry => entry.TenantId == actor.TenantId);
.Where(entry => entry.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
entry => entry.CreatedBy == actor.UserId,
entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -67,6 +76,7 @@ public sealed class ContentManagementService(
UpsertContentEntryCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
@@ -81,6 +91,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = entry is null;
if (command.Id.HasValue && (entry is null || entry.Id != command.Id.Value))
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
if (entry is not null && !scope.AllowsResource(actor.UserId, entry.CreatedBy, entry.RegionId))
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
if (entry is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
entry ??= new ContentEntry
{
Id = command.Id ?? Guid.NewGuid(),
@@ -117,14 +142,21 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
if (!filter.EntryId.HasValue)
{
throw new ContentManagementException("entryId is required.", "entry_id_required");
}
await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ContentNodes
.AsNoTracking()
.Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value);
.Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value)
.ApplyDataScope(
scope,
node => node.CreatedBy == actor.UserId,
node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -178,9 +210,11 @@ public sealed class ContentManagementService(
UpsertContentNodeCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken);
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
await AssertNodeAsync(actor, scope, command.ParentId, cancellationToken);
var nodeKey = Normalize(command.NodeKey) ??
Normalize(command.Id?.ToString("N")) ??
@@ -193,6 +227,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = node is null;
if (command.Id.HasValue && (node is null || node.Id != command.Id.Value))
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
if (node is not null && !scope.AllowsResource(actor.UserId, node.CreatedBy, node.RegionId))
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
if (node is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
node ??= new ContentNode
{
Id = command.Id ?? Guid.NewGuid(),
@@ -246,9 +295,15 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.QuestionCollections
.AsNoTracking()
.Where(collection => collection.TenantId == actor.TenantId);
.Where(collection => collection.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
collection => collection.CreatedBy == actor.UserId,
collection => collection.RegionId.HasValue && regionIds.Contains(collection.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -296,10 +351,11 @@ public sealed class ContentManagementService(
UpsertQuestionCollectionCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken);
await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken);
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
await AssertReferenceAsync<Subject>(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken);
await AssertReferenceAsync<Category>(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken);
await AssertReferenceAsync<QuestionBank>(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken);
@@ -312,6 +368,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = collection is null;
if (command.Id.HasValue && (collection is null || collection.Id != command.Id.Value))
{
throw new ContentManagementException("Collection was not found.", "collection_not_found");
}
if (collection is not null && !scope.AllowsResource(actor.UserId, collection.CreatedBy, collection.RegionId))
{
throw new ContentManagementException("Collection was not found.", "collection_not_found");
}
if (collection is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Collection was not found.", "collection_not_found");
}
collection ??= new QuestionCollection
{
Id = command.Id ?? Guid.NewGuid(),
@@ -352,9 +423,15 @@ public sealed class ContentManagementService(
ReplaceCollectionItemsCommand command,
CancellationToken cancellationToken = default)
{
var collection = await dbContext.QuestionCollections.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.CollectionId,
cancellationToken);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var collection = await dbContext.QuestionCollections
.Where(item => item.TenantId == actor.TenantId && item.Id == command.CollectionId)
.ApplyDataScope(
scope,
item => item.CreatedBy == actor.UserId,
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
.SingleOrDefaultAsync(cancellationToken);
if (collection is null)
{
@@ -409,9 +486,15 @@ public sealed class ContentManagementService(
ContentManagementFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.PracticeBlueprints
.AsNoTracking()
.Where(blueprint => blueprint.TenantId == actor.TenantId);
.Where(blueprint => blueprint.TenantId == actor.TenantId)
.ApplyDataScope(
scope,
blueprint => blueprint.CreatedBy == actor.UserId,
blueprint => blueprint.RegionId.HasValue && regionIds.Contains(blueprint.RegionId.Value));
if (!filter.IncludeInactive)
{
@@ -464,10 +547,11 @@ public sealed class ContentManagementService(
UpsertPracticeBlueprintCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken);
await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken);
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
await AssertReferenceAsync<QuestionCollection>(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken);
var blueprint = await ResolveEntityByIdOrLegacyAsync(
@@ -478,6 +562,21 @@ public sealed class ContentManagementService(
cancellationToken);
var isNew = blueprint is null;
if (command.Id.HasValue && (blueprint is null || blueprint.Id != command.Id.Value))
{
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
}
if (blueprint is not null && !scope.AllowsResource(actor.UserId, blueprint.CreatedBy, blueprint.RegionId))
{
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
}
if (blueprint is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
{
throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found");
}
blueprint ??= new PracticeBlueprint
{
Id = command.Id ?? Guid.NewGuid(),
@@ -584,11 +683,74 @@ public sealed class ContentManagementService(
await AssertReferenceAsync<ContentEntry>(tenantId, entryId, "entry_not_found", cancellationToken);
}
private async Task AssertEntryAsync(
ContentManagementActor actor,
CurrentDataScope scope,
Guid? entryId,
CancellationToken cancellationToken)
{
if (!entryId.HasValue)
{
return;
}
var regionIds = scope.RegionIds.ToArray();
var exists = await dbContext.ContentEntries
.Where(entry => entry.TenantId == actor.TenantId && entry.Id == entryId.Value)
.ApplyDataScope(
scope,
entry => entry.CreatedBy == actor.UserId,
entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value))
.AnyAsync(cancellationToken);
if (!exists)
{
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
}
}
private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken)
{
await AssertReferenceAsync<ContentNode>(tenantId, nodeId, "node_not_found", cancellationToken);
}
private async Task AssertNodeAsync(
ContentManagementActor actor,
CurrentDataScope scope,
Guid? nodeId,
CancellationToken cancellationToken)
{
if (!nodeId.HasValue)
{
return;
}
var regionIds = scope.RegionIds.ToArray();
var exists = await dbContext.ContentNodes
.Where(node => node.TenantId == actor.TenantId && node.Id == nodeId.Value)
.ApplyDataScope(
scope,
node => node.CreatedBy == actor.UserId,
node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value))
.AnyAsync(cancellationToken);
if (!exists)
{
throw new ContentManagementException("Content node was not found.", "node_not_found");
}
}
private async Task<CurrentDataScope> RequireDataScopeAsync(
ContentManagementActor actor,
CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId)
{
throw new ContentManagementException("Content resource was not found.", "content_not_found");
}
return access.DataScope;
}
private async Task AssertReferenceAsync<TEntity>(
Guid tenantId,
Guid? id,

View File

@@ -5,6 +5,7 @@ using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
@@ -12,12 +13,14 @@ using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Content;
public sealed class DirectContentService(
TikuDbContext dbContext,
IQuestionReferenceService questionReferenceService) : IDirectContentService
IQuestionReferenceService questionReferenceService,
ICurrentAccessContext currentAccessContext) : IDirectContentService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
@@ -120,7 +123,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.VocabularyUnits.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.VocabularyUnits.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -159,6 +166,7 @@ public sealed class DirectContentService(
VocabularyUnitCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
@@ -166,6 +174,7 @@ public sealed class DirectContentService(
var item = await ResolveByIdOrLegacyAsync(dbContext.VocabularyUnits, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "vocabulary_unit_not_found");
item ??= new VocabularyUnit { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.EntryId = command.EntryId;
@@ -272,7 +281,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.HandbookSubjects.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.HandbookSubjects.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -321,6 +334,7 @@ public sealed class DirectContentService(
HandbookSubjectCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
@@ -330,6 +344,7 @@ public sealed class DirectContentService(
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "handbook_subject_not_found");
item ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.SchoolId = command.SchoolId;
@@ -513,7 +528,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.Schools.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.Schools.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -536,10 +555,12 @@ public sealed class DirectContentService(
SchoolCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "school_not_found");
item ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.LegacyId = Normalize(command.LegacyId);
@@ -560,7 +581,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.Majors.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.Majors.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -594,11 +619,13 @@ public sealed class DirectContentService(
MajorCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "major_not_found");
item ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.SchoolId = command.SchoolId;
@@ -622,7 +649,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ScorelineFields.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ScorelineFields.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null);
@@ -646,6 +677,7 @@ public sealed class DirectContentService(
ScorelineFieldCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey);
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName);
if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim()))
@@ -656,6 +688,7 @@ public sealed class DirectContentService(
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_field_not_found");
item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.LegacyId = Normalize(command.LegacyId);
@@ -685,7 +718,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ScorelineRecords.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ScorelineRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -727,6 +764,7 @@ public sealed class DirectContentService(
ScorelineRecordCommand command,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
if (command.Year is < 1900 or > 3000)
{
throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid");
@@ -737,6 +775,7 @@ public sealed class DirectContentService(
await AssertReferenceAsync<Major>(actor.TenantId, command.MajorId, "major_not_found", cancellationToken);
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
var isNew = item is null;
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "scoreline_record_not_found");
item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
item.RegionId = command.RegionId;
item.SchoolId = command.SchoolId;
@@ -760,8 +799,11 @@ public sealed class DirectContentService(
AdminLimitFilter filter,
CancellationToken cancellationToken = default)
{
var scope = await RequireDataScopeAsync(actor, cancellationToken);
var regionIds = scope.RegionIds.ToArray();
var query = dbContext.ScorelineRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
.Where(item => item.TenantId == actor.TenantId)
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
if (filter.RegionId.HasValue)
{
query = query.Where(item => item.RegionId == filter.RegionId.Value);
@@ -1720,6 +1762,38 @@ public sealed class DirectContentService(
item.IssuesCount);
}
private async Task<CurrentDataScope> RequireDataScopeAsync(
DirectContentActor actor,
CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.UserId != actor.UserId ||
access.TenantId != actor.TenantId ||
!access.HasTenantPermission(BackendPermissions.TenantContentManage))
{
throw new ContentManagementException("Tenant content access was denied.", "content_access_denied");
}
return access.DataScope;
}
private static void EnsureRegionWriteAllowed(
CurrentDataScope scope,
DirectContentActor actor,
Guid? currentRegionId,
Guid? targetRegionId,
bool isNew,
string notFoundCode)
{
var canAccessCurrent = isNew || scope.AllowsResource(actor.UserId, regionId: currentRegionId);
var canAccessTarget = scope.AllowsResource(actor.UserId, regionId: targetRegionId);
if (!canAccessCurrent || !canAccessTarget)
{
throw new ContentManagementException("Content resource was not found.", notFoundCode);
}
}
private async Task<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Tiku.Application.Assets;
@@ -35,10 +36,12 @@ using Tiku.Infrastructure.Profile;
using Tiku.Infrastructure.Points;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Infrastructure.Security;
using Tiku.Infrastructure.Storage;
using Tiku.Infrastructure.StudyContent;
using Tiku.Infrastructure.TenantAdmin;
using Tiku.Infrastructure.Tenancy;
using Tiku.Domain.Identity;
namespace Tiku.Infrastructure;
@@ -59,6 +62,21 @@ public static class DependencyInjection
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
});
services.AddIdentityCore<User>(options =>
{
options.Password.RequiredLength = 10;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.User.RequireUniqueEmail = false;
})
.AddEntityFrameworkStores<TikuDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
services.AddScoped<ITenantDirectory, TenantDirectory>();
services.AddMemoryCache();
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
@@ -69,9 +87,10 @@ public static class DependencyInjection
services.AddScoped<ITenantDomainLifecycleService, TenantDomainLifecycleService>();
services.AddOptions<DomainLifecycleOptions>();
services.AddSingleton<ITenantExecutionScope, TenantExecutionScope>();
services.AddScoped<IPasswordHasher, PasswordHasher>();
services.AddSingleton<IJwtKeyRing, JwtKeyRing>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<ISessionService, SessionService>();
services.AddScoped<AuthSessionStore>();
services.AddScoped<IAuthSessionStore>(provider => provider.GetRequiredService<AuthSessionStore>());
services.AddScoped<ISmsProvider, AliyunSmsProvider>();
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
@@ -94,6 +113,7 @@ public static class DependencyInjection
services.AddScoped<ILearningActivityService, LearningActivityService>();
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
services.AddScoped<IBackofficeService, BackofficeService>();
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
services.AddScoped<IOperationAuditService, OperationAuditService>();
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
services.AddScoped<ICommerceService, CommerceService>();

View File

@@ -4,6 +4,7 @@ using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Growth;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
using Tiku.Domain.Growth;
@@ -13,7 +14,9 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Growth;
public sealed class CommissionService(TikuDbContext dbContext) : ICommissionService
public sealed class CommissionService(
TikuDbContext dbContext,
ICurrentAccessContext currentAccessContext) : ICommissionService
{
public async Task<CommissionSettingsItem> GetSettingsAsync(CommissionAdminActor actor, CancellationToken cancellationToken = default)
{
@@ -40,11 +43,20 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ
var member = await dbContext.TenantMemberships
.FirstOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == command.UserId, cancellationToken)
?? throw new CommissionException("Commission member was not found.", "commission_member_not_found");
member.Permissions = JsonSerializer.SerializeToElement(new
var settings = await GetSettingsCoreAsync(actor.TenantId, cancellationToken);
var config = settings.Config.ValueKind == JsonValueKind.Object
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(settings.Config.GetRawText()) ?? []
: [];
var memberRates = config.TryGetValue("memberRates", out var existingRates) && existingRates.ValueKind == JsonValueKind.Object
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(existingRates.GetRawText()) ?? []
: [];
memberRates[member.UserId.ToString("N")] = JsonSerializer.SerializeToElement(new
{
commissionRate = command.CommissionRate,
commissionConfig = command.CommissionConfig
});
config["memberRates"] = JsonSerializer.SerializeToElement(memberRates);
settings.Config = JsonSerializer.SerializeToElement(config);
await dbContext.SaveChangesAsync(cancellationToken);
return new { member.UserId, commissionRate = command.CommissionRate, commissionConfig = command.CommissionConfig };
}
@@ -265,7 +277,7 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ
foreach (var row in orders)
{
if (query.ReferrerUserId.HasValue && row.lead.ReferrerUserId != query.ReferrerUserId) continue;
var rate = GetMemberRate(await GetMembershipPermissionsAsync(tenantId, row.lead.ReferrerUserId!.Value, cancellationToken)) ?? settings.DefaultRate;
var rate = GetMemberRate(settings.Config, row.lead.ReferrerUserId!.Value) ?? settings.DefaultRate;
var settled = existing.FirstOrDefault(item => item.SourceType == CommissionSourceType.Order && item.SourceId == row.order.Id)?.SettlementId;
result.Add(new SourceCandidate(CommissionSourceType.Order, row.order.Id, row.order.OrderNo, row.lead.ReferrerUserId.Value, row.order.UserId, row.order.AmountCents, rate, (int)Math.Round(row.order.AmountCents * rate), CommissionRateSource.Member, settled, row.order.PaidAt, "protected_lead"));
}
@@ -282,7 +294,7 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ
if (!row.code.AgentUserId.HasValue) continue;
var agentUserId = row.code.AgentUserId.Value;
if (query.ReferrerUserId.HasValue && agentUserId != query.ReferrerUserId) continue;
var rate = row.batch?.CommissionRate ?? GetMemberRate(await GetMembershipPermissionsAsync(tenantId, agentUserId, cancellationToken)) ?? settings.DefaultRate;
var rate = row.batch?.CommissionRate ?? GetMemberRate(settings.Config, agentUserId) ?? settings.DefaultRate;
var sourceAmount = row.code.UnitPriceCents ?? row.batch?.DefaultUnitPriceCents ?? 0;
var settled = existing.FirstOrDefault(item => item.SourceType == CommissionSourceType.ActivationCode && item.SourceId == row.code.Id)?.SettlementId;
result.Add(new SourceCandidate(CommissionSourceType.ActivationCode, row.code.Id, row.code.Code, agentUserId, row.code.UsedBy, sourceAmount, rate, (int)Math.Round(sourceAmount * rate), row.batch?.CommissionRate is null ? CommissionRateSource.Member : CommissionRateSource.Batch, settled, row.code.UsedAt, "activation_code_agent"));
@@ -290,11 +302,22 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ
return result.OrderBy(item => item.SourcePaidAt).ToArray();
}
private async Task<JsonElement> GetMembershipPermissionsAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) =>
await dbContext.TenantMemberships.AsNoTracking().Where(item => item.TenantId == tenantId && item.UserId == userId).Select(item => item.Permissions).FirstOrDefaultAsync(cancellationToken);
private static decimal? GetMemberRate(JsonElement config, Guid userId)
{
if (config.ValueKind != JsonValueKind.Object ||
!config.TryGetProperty("memberRates", out var memberRates) ||
memberRates.ValueKind != JsonValueKind.Object ||
!memberRates.TryGetProperty(userId.ToString("N"), out var memberRate) ||
memberRate.ValueKind != JsonValueKind.Object ||
!memberRate.TryGetProperty("commissionRate", out var value) ||
value.ValueKind != JsonValueKind.Number ||
!value.TryGetDecimal(out var rate))
{
return null;
}
private static decimal? GetMemberRate(JsonElement permissions) =>
permissions.ValueKind == JsonValueKind.Object && permissions.TryGetProperty("commissionRate", out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetDecimal(out var rate) ? rate : null;
return rate;
}
private async Task<TenantCommissionSetting> GetSettingsCoreAsync(Guid tenantId, CancellationToken cancellationToken)
{
@@ -311,8 +334,14 @@ public sealed class CommissionService(TikuDbContext dbContext) : ICommissionServ
private async Task AssertAdminAsync(CommissionAdminActor actor, CancellationToken cancellationToken)
{
var ok = await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.Status == MembershipStatus.Active && (item.Role == TenantRole.PlatformAdmin || item.Role == TenantRole.TenantOwner || item.Role == TenantRole.TenantAdmin), cancellationToken);
if (!ok) throw new CommissionException("Commission admin access was denied.", "commission_access_denied");
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.TenantId != actor.TenantId ||
access.UserId != actor.UserId ||
!access.HasTenantPermission(BackendPermissions.TenantCommissionManage))
{
throw new CommissionException("Commission admin access was denied.", "commission_access_denied");
}
}
private async Task AddAuditAsync(CommissionAdminActor actor, string action, string targetType, Guid targetId, object details, CancellationToken cancellationToken)

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Growth;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Growth;
using Tiku.Domain.Tenancy;
@@ -11,7 +12,8 @@ namespace Tiku.Infrastructure.Growth;
internal sealed class CrmService(
TikuDbContext dbContext,
ITenantSecretProtector tenantSecretProtector) : ICrmService
ITenantSecretProtector tenantSecretProtector,
ICurrentAccessContext currentAccessContext) : ICrmService
{
private static readonly HashSet<string> SensitiveKeys = new(StringComparer.OrdinalIgnoreCase)
{
@@ -270,16 +272,12 @@ internal sealed class CrmService(
private async Task AssertAdminAsync(CrmAdminActor actor, CancellationToken cancellationToken)
{
var isAdmin = await dbContext.TenantMemberships.AnyAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == MembershipStatus.Active &&
(item.Role == TenantRole.PlatformAdmin ||
item.Role == TenantRole.TenantOwner ||
item.Role == TenantRole.TenantAdmin),
cancellationToken);
if (!isAdmin)
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.UserId != actor.UserId ||
access.TenantId != actor.TenantId ||
!access.HasTenantPermission(BackendPermissions.TenantCrmManage) ||
access.DataScope.Mode != DataScopeMode.All)
{
throw new CrmException("CRM admin access was denied.", "crm_access_denied");
}

View File

@@ -3,6 +3,7 @@ using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Growth;
using Tiku.Application.Security;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
using Tiku.Domain.Growth;
@@ -14,7 +15,8 @@ namespace Tiku.Infrastructure.Growth;
public sealed class ReferralService(
TikuDbContext dbContext,
IReferralQrcodeGenerator qrcodeGenerator) : IReferralService
IReferralQrcodeGenerator qrcodeGenerator,
ICurrentAccessContext currentAccessContext) : IReferralService
{
private static readonly HashSet<string> AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase)
{
@@ -109,7 +111,7 @@ public sealed class ReferralService(
referralCode.UserId,
membership.Role,
user.Name,
user.Username,
user.UserName,
user.Phone
})
.FirstOrDefaultAsync(cancellationToken);
@@ -121,7 +123,7 @@ public sealed class ReferralService(
row.UserId,
row.Code,
row.Role.ToString(),
FirstNonBlank(row.Name, row.Username, row.Phone));
FirstNonBlank(row.Name, row.UserName, row.Phone));
}
public async Task<ReferralTrackResult> TrackEventAsync(
@@ -655,16 +657,11 @@ public sealed class ReferralService(
private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken)
{
var isAdmin = await dbContext.TenantMemberships.AnyAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == MembershipStatus.Active &&
(item.Role == TenantRole.PlatformAdmin ||
item.Role == TenantRole.TenantOwner ||
item.Role == TenantRole.TenantAdmin),
cancellationToken);
if (!isAdmin)
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.TenantId != actor.TenantId ||
access.UserId != actor.UserId ||
!access.HasTenantPermission(BackendPermissions.TenantCrmManage))
{
throw new ReferralException("Referral admin access was denied.", "referral_access_denied");
}
@@ -677,7 +674,7 @@ public sealed class ReferralService(
{
var user = await dbContext.Users.AsNoTracking()
.Where(item => item.Id == referrerUserId)
.Select(item => new { item.Name, item.Username, item.Phone })
.Select(item => new { item.Name, item.UserName, item.Phone })
.FirstOrDefaultAsync(cancellationToken);
var membership = await dbContext.TenantMemberships.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.UserId == referrerUserId)
@@ -714,7 +711,7 @@ public sealed class ReferralService(
return new ReferralStatsItem(
referrerUserId,
FirstNonBlank(user?.Name, user?.Username, user?.Phone),
FirstNonBlank(user?.Name, user?.UserName, user?.Phone),
membership.ToString(),
inviteCode,
leads.Length,

View File

@@ -9,22 +9,30 @@ internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ConfigureEntity("users");
builder.ToTable("users");
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).HasDefaultValueSql("gen_random_uuid()");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Username).HasMaxLength(100);
builder.Property(entity => entity.UserName).HasMaxLength(100);
builder.Property(entity => entity.NormalizedUserName).HasMaxLength(100);
builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320);
builder.Property(entity => entity.NormalizedEmail).HasMaxLength(320);
builder.Property(entity => entity.Phone).HasMaxLength(32);
builder.Property(entity => entity.PhoneNumber).HasMaxLength(32);
builder.Property(entity => entity.PasswordHash).HasMaxLength(1024);
builder.Property(entity => entity.SecurityStamp).HasMaxLength(64);
builder.Property(entity => entity.ConcurrencyStamp).HasMaxLength(64).IsConcurrencyToken();
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.AvatarUrl).HasMaxLength(2048);
builder.Property(entity => entity.PrimaryRole).HasMaxLength(50);
builder.Property(entity => entity.LegacyPasswordHash).HasMaxLength(512);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.RawProfile).IsJson("{}");
builder.HasIndex(entity => entity.LegacyId).IsUnique();
builder.HasIndex(entity => entity.Username).IsUnique();
builder.HasIndex(entity => entity.Email).IsUnique();
builder.HasIndex(entity => entity.NormalizedUserName).IsUnique();
builder.HasIndex(entity => entity.NormalizedEmail);
builder.HasIndex(entity => entity.Phone).IsUnique();
}
}
@@ -42,8 +50,6 @@ internal sealed class UserIdentityConfiguration : IEntityTypeConfiguration<UserI
builder.Property(entity => entity.OpenId).HasMaxLength(255);
builder.Property(entity => entity.Phone).HasMaxLength(32);
builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320);
builder.Property(entity => entity.SecretPayload).IsJson("{}");
builder.HasIndex(entity => new { entity.Provider, entity.ProviderSubject }).IsUnique();
builder.HasOne<User>()
.WithMany()

View File

@@ -43,7 +43,6 @@ internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration<T
builder.Property(entity => entity.Role).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Permissions).IsJson("{}");
builder.Property(entity => entity.LegacyRole).HasMaxLength(50);
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Role }).IsUnique();
@@ -52,11 +51,6 @@ internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration<T
.WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<TenantRoleTemplate>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RoleTemplateId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -51,7 +51,6 @@ internal sealed class SmsVerificationCodeConfiguration : IEntityTypeConfiguratio
public void Configure(EntityTypeBuilder<SmsVerificationCode> builder)
{
builder.ConfigureEntity("sms_verification_codes");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.Phone).HasMaxLength(32);
builder.Property(entity => entity.Purpose).HasSnakeCaseEnum();
builder.Property(entity => entity.CodeHash).HasMaxLength(256);
@@ -96,7 +95,7 @@ internal sealed class AuthLoginEventConfiguration : IEntityTypeConfiguration<Aut
builder.HasOne<Tenant>().WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.SetNull);
@@ -107,18 +106,30 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration<AuthSe
{
public void Configure(EntityTypeBuilder<AuthSession> builder)
{
builder.ConfigureTenantEntity("auth_sessions");
builder.ConfigureEntity("auth_sessions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Realm).HasSnakeCaseEnum();
builder.Property(entity => entity.TokenHash).HasMaxLength(256);
builder.Property(entity => entity.SecurityStamp).HasMaxLength(128);
builder.Property(entity => entity.Provider).HasMaxLength(50);
builder.Property(entity => entity.RevokedReason).HasMaxLength(100);
builder.Property(entity => entity.IpAddress).HasMaxLength(64);
builder.Property(entity => entity.UserAgent).HasMaxLength(1000);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => entity.TokenHash)
.IsUnique()
.HasAnnotation("Tiku:GlobalUnique", true);
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.ExpiresAt })
builder.HasIndex(entity => new { entity.Realm, entity.TenantId, entity.UserId, entity.ExpiresAt })
.HasFilter("revoked_at is null");
builder.HasIndex(entity => new { entity.TokenFamilyId, entity.RevokedAt });
builder.ToTable(table => table.HasCheckConstraint(
"ck_auth_sessions_realm_tenant",
"(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"));
builder.HasOne<Tenant>().WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
@@ -126,6 +137,29 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration<AuthSe
}
}
internal sealed class AuthChallengeConfiguration : IEntityTypeConfiguration<AuthChallenge>
{
public void Configure(EntityTypeBuilder<AuthChallenge> builder)
{
builder.ConfigureEntity("auth_challenges");
builder.Property(entity => entity.Realm).HasSnakeCaseEnum();
builder.Property(entity => entity.Purpose).HasSnakeCaseEnum();
builder.Property(entity => entity.TokenHash).HasMaxLength(64);
builder.Property(entity => entity.SecurityStamp).HasMaxLength(128);
builder.Property(entity => entity.Provider).HasMaxLength(50);
builder.Property(entity => entity.IpAddress).HasMaxLength(100);
builder.Property(entity => entity.UserAgent).HasMaxLength(1024);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => entity.TokenHash).IsUnique();
builder.HasIndex(entity => new { entity.UserId, entity.Purpose, entity.ExpiresAt });
builder.ToTable(table => table.HasCheckConstraint(
"ck_auth_challenges_realm_tenant",
"(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"));
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class SmsSendRateLimitConfiguration : IEntityTypeConfiguration<SmsSendRateLimit>
{
public void Configure(EntityTypeBuilder<SmsSendRateLimit> builder)
@@ -154,32 +188,6 @@ internal sealed class SmsSendRateLimitConfiguration : IEntityTypeConfiguration<S
}
}
internal sealed class TenantRoleTemplateConfiguration : IEntityTypeConfiguration<TenantRoleTemplate>
{
public void Configure(EntityTypeBuilder<TenantRoleTemplate> builder)
{
builder.ConfigureTenantEntity("tenant_role_templates");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Code).HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.BaseRole).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Permissions).IsJson("{}");
builder.Property(entity => entity.MenuPermissions).IsJson("{}");
builder.Property(entity => entity.ModulePermissions).IsJson("{}");
builder.Property(entity => entity.FieldPermissions).IsJson("{}");
builder.Property(entity => entity.DataScope).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.SortOrder });
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.CreatedBy)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UpdatedBy)
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class TenantClassConfiguration : IEntityTypeConfiguration<TenantClass>
{
public void Configure(EntityTypeBuilder<TenantClass> builder)

View File

@@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(TikuDbContext))]
[Migration("20260728014412_InitialSchema")]
[Migration("20260728031410_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
@@ -28,6 +28,110 @@ namespace Tiku.Infrastructure.Persistence.Migrations
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("text")
.HasColumnName("friendly_name");
b.Property<string>("Xml")
.HasColumnType("text")
.HasColumnName("xml");
b.HasKey("Id")
.HasName("pk_data_protection_keys");
b.ToTable("data_protection_keys", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text")
.HasColumnName("claim_type");
b.Property<string>("ClaimValue")
.HasColumnType("text")
.HasColumnName("claim_value");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_user_claims");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_claims_user_id");
b.ToTable("user_claims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text")
.HasColumnName("login_provider");
b.Property<string>("ProviderKey")
.HasColumnType("text")
.HasColumnName("provider_key");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text")
.HasColumnName("provider_display_name");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("LoginProvider", "ProviderKey")
.HasName("pk_user_logins");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_logins_user_id");
b.ToTable("user_logins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<string>("LoginProvider")
.HasColumnType("text")
.HasColumnName("login_provider");
b.Property<string>("Name")
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Value")
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("UserId", "LoginProvider", "Name")
.HasName("pk_user_tokens");
b.ToTable("user_tokens", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
{
b.Property<Guid>("Id")
@@ -7918,11 +8022,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer")
.HasColumnName("access_failed_count");
b.Property<string>("AvatarUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("avatar_url");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("concurrency_stamp");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
@@ -7934,6 +8048,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("citext")
.HasColumnName("email");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean")
.HasColumnName("email_confirmed");
b.Property<bool>("ForcePasswordChange")
.HasColumnType("boolean")
.HasColumnName("force_password_change");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
@@ -7943,25 +8065,48 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(64)")
.HasColumnName("legacy_id");
b.Property<string>("LegacyPasswordHash")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("legacy_password_hash");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean")
.HasColumnName("lockout_enabled");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone")
.HasColumnName("lockout_end");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<bool>("PasswordMigrationRequired")
.HasColumnType("boolean")
.HasColumnName("password_migration_required");
b.Property<string>("NormalizedEmail")
.HasMaxLength(320)
.HasColumnType("character varying(320)")
.HasColumnName("normalized_email");
b.Property<string>("NormalizedUserName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("normalized_user_name");
b.Property<string>("PasswordHash")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("password_hash");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("phone");
b.Property<string>("PhoneNumber")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("phone_number");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean")
.HasColumnName("phone_number_confirmed");
b.Property<string>("PrimaryRole")
.IsRequired()
.HasMaxLength(50)
@@ -7978,36 +8123,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("integer")
.HasColumnName("score");
b.Property<string>("SecurityStamp")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("security_stamp");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean")
.HasColumnName("two_factor_enabled");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<string>("Username")
b.Property<string>("UserName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("username");
.HasColumnName("user_name");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Email")
.IsUnique()
.HasDatabaseName("ix_users_email");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_users_legacy_id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("email_index");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("user_name_index");
b.HasIndex("Phone")
.IsUnique()
.HasDatabaseName("ix_users_phone");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
@@ -8052,12 +8211,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(255)")
.HasColumnName("provider_subject");
b.Property<JsonElement>("SecretPayload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("secret_payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("UnionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
@@ -12651,6 +12804,95 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("question_versions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("ConsumedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("consumed_at");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<string>("IpAddress")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("ip_address");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Purpose")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("purpose");
b.Property<string>("Realm")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("realm");
b.Property<string>("SecurityStamp")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("security_stamp");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("token_hash");
b.Property<string>("UserAgent")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("user_agent");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_auth_challenges");
b.HasIndex("TenantId")
.HasDatabaseName("ix_auth_challenges_tenant_id");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("ix_auth_challenges_token_hash");
b.HasIndex("UserId", "Purpose", "ExpiresAt")
.HasDatabaseName("ix_auth_challenges_user_id_purpose_expires_at");
b.ToTable("auth_challenges", null, t =>
{
t.HasCheckConstraint("ck_auth_challenges_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
{
b.Property<Guid>("Id")
@@ -12755,20 +12997,53 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("MfaSatisfied")
.HasColumnType("boolean")
.HasColumnName("mfa_satisfied");
b.Property<Guid?>("ParentSessionId")
.HasColumnType("uuid")
.HasColumnName("parent_session_id");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Realm")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("realm");
b.Property<Guid?>("ReplacedBySessionId")
.HasColumnType("uuid")
.HasColumnName("replaced_by_session_id");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<Guid>("TenantId")
b.Property<string>("RevokedReason")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("revoked_reason");
b.Property<string>("SecurityStamp")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("security_stamp");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<Guid>("TokenFamilyId")
.HasColumnType("uuid")
.HasColumnName("token_family_id");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(256)
@@ -12793,8 +13068,8 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_auth_sessions");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_auth_sessions_tenant_id_id");
b.HasIndex("TenantId")
.HasDatabaseName("ix_auth_sessions_tenant_id");
b.HasIndex("TokenHash")
.IsUnique()
@@ -12804,11 +13079,17 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_sessions_user_id");
b.HasIndex("TenantId", "UserId", "ExpiresAt")
.HasDatabaseName("ix_auth_sessions_tenant_id_user_id_expires_at")
b.HasIndex("TokenFamilyId", "RevokedAt")
.HasDatabaseName("ix_auth_sessions_token_family_id_revoked_at");
b.HasIndex("Realm", "TenantId", "UserId", "ExpiresAt")
.HasDatabaseName("ix_auth_sessions_realm_tenant_id_user_id_expires_at")
.HasFilter("revoked_at is null");
b.ToTable("auth_sessions", (string)null);
b.ToTable("auth_sessions", null, t =>
{
t.HasCheckConstraint("ck_auth_sessions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b =>
@@ -12934,9 +13215,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_sms_verification_codes");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_sms_verification_codes_tenant_id_id");
b.HasIndex("TenantId", "Phone", "Purpose")
.IsUnique()
.HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose")
@@ -13626,22 +13904,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(50)")
.HasColumnName("legacy_role");
b.Property<JsonElement>("Permissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("role");
b.Property<Guid?>("RoleTemplateId")
.HasColumnType("uuid")
.HasColumnName("role_template_id");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
@@ -13671,9 +13939,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_tenant_memberships_user_id");
b.HasIndex("TenantId", "RoleTemplateId")
.HasDatabaseName("ix_tenant_memberships_tenant_id_role_template_id");
b.HasIndex("TenantId", "UserId", "Role")
.IsUnique()
.HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role");
@@ -13681,126 +13946,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_memberships", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("BaseRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("base_role");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uuid")
.HasColumnName("created_by");
b.Property<JsonElement>("DataScope")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("data_scope")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<JsonElement>("FieldPermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("field_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<JsonElement>("MenuPermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("menu_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<JsonElement>("ModulePermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("module_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<JsonElement>("Permissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid")
.HasColumnName("updated_by");
b.HasKey("Id")
.HasName("pk_tenant_role_templates");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_role_templates_tenant_id_id");
b.HasIndex("CreatedBy")
.HasDatabaseName("ix_tenant_role_templates_created_by");
b.HasIndex("UpdatedBy")
.HasDatabaseName("ix_tenant_role_templates_updated_by");
b.HasIndex("TenantId", "Code")
.IsUnique()
.HasDatabaseName("ix_tenant_role_templates_tenant_id_code");
b.HasIndex("TenantId", "Status", "SortOrder")
.HasDatabaseName("ix_tenant_role_templates_tenant_id_status_sort_order");
b.ToTable("tenant_role_templates", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>
{
b.Property<Guid>("Id")
@@ -14156,6 +14301,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_student_notes", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_claims_users_user_id");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_logins_users_user_id");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_tokens_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -17207,12 +17382,28 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_question_versions_questions_tenant_id_question_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_auth_challenges_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_challenges_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.SetNull)
.IsRequired()
.HasConstraintName("fk_auth_login_events_tenants_tenant_id");
@@ -17229,7 +17420,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_sessions_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
@@ -17378,35 +17568,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_memberships_users_user_id");
b.HasOne("Tiku.Domain.Tenancy.TenantRoleTemplate", null)
.WithMany()
.HasForeignKey("TenantId", "RoleTemplateId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_tenant_memberships_tenant_role_templates_tenant_id_role_tem~");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_tenant_role_templates_users_created_by");
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_role_templates_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_tenant_role_templates_users_updated_by");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>

View File

@@ -1,7 +1,7 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
using Tiku.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
@@ -38,6 +38,20 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table.UniqueConstraint("ak_backend_permissions_code", x => x.code);
});
migrationBuilder.CreateTable(
name: "data_protection_keys",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
friendly_name = table.Column<string>(type: "text", nullable: true),
xml = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_data_protection_keys", x => x.id);
});
migrationBuilder.CreateTable(
name: "platform_backend_roles",
columns: table => new
@@ -137,19 +151,31 @@ namespace Tiku.Infrastructure.Persistence.Migrations
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
username = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
email = table.Column<string>(type: "citext", maxLength: 320, nullable: true),
phone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
avatar_url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
primary_role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
score = table.Column<int>(type: "integer", nullable: false),
last_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
legacy_password_hash = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
password_migration_required = table.Column<bool>(type: "boolean", nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
force_password_change = table.Column<bool>(type: "boolean", nullable: false),
raw_profile = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
user_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
normalized_user_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
email = table.Column<string>(type: "citext", maxLength: 320, nullable: true),
normalized_email = table.Column<string>(type: "character varying(320)", maxLength: 320, nullable: true),
email_confirmed = table.Column<bool>(type: "boolean", nullable: false),
password_hash = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
security_stamp = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
concurrency_stamp = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
phone_number = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
phone_number_confirmed = table.Column<bool>(type: "boolean", nullable: false),
two_factor_enabled = table.Column<bool>(type: "boolean", nullable: false),
lockout_end = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
lockout_enabled = table.Column<bool>(type: "boolean", nullable: false),
access_failed_count = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
@@ -265,6 +291,27 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "user_claims",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
claim_type = table.Column<string>(type: "text", nullable: true),
claim_value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_user_claims", x => x.id);
table.ForeignKey(
name: "fk_user_claims_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_identities",
columns: table => new
@@ -277,7 +324,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
open_id = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
phone = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
email = table.Column<string>(type: "citext", maxLength: 320, nullable: true),
secret_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
@@ -292,6 +338,46 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_logins",
columns: table => new
{
login_provider = table.Column<string>(type: "text", nullable: false),
provider_key = table.Column<string>(type: "text", nullable: false),
provider_display_name = table.Column<string>(type: "text", nullable: true),
user_id = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_user_logins", x => new { x.login_provider, x.provider_key });
table.ForeignKey(
name: "fk_user_logins_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_tokens",
columns: table => new
{
user_id = table.Column<Guid>(type: "uuid", nullable: false),
login_provider = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_user_tokens", x => new { x.user_id, x.login_provider, x.name });
table.ForeignKey(
name: "fk_user_tokens_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "platform_backend_role_menus",
columns: table => new
@@ -403,6 +489,42 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "auth_challenges",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
realm = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
purpose = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
token_hash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
security_stamp = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
consumed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ip_address = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
user_agent = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_auth_challenges", x => x.id);
table.CheckConstraint("ck_auth_challenges_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
table.ForeignKey(
name: "fk_auth_challenges_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_auth_challenges_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "auth_login_events",
columns: table => new
@@ -428,7 +550,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_auth_login_events_users_user_id",
column: x => x.user_id,
@@ -442,22 +564,29 @@ namespace Tiku.Infrastructure.Persistence.Migrations
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
realm = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
token_family_id = table.Column<Guid>(type: "uuid", nullable: false),
parent_session_id = table.Column<Guid>(type: "uuid", nullable: true),
replaced_by_session_id = table.Column<Guid>(type: "uuid", nullable: true),
token_hash = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
security_stamp = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
mfa_satisfied = table.Column<bool>(type: "boolean", nullable: false),
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
revoked_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
revoked_reason = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
ip_address = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
user_agent = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_auth_sessions", x => x.id);
table.UniqueConstraint("ak_auth_sessions_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_auth_sessions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
table.ForeignKey(
name: "fk_auth_sessions_tenants_tenant_id",
column: x => x.tenant_id,
@@ -1186,7 +1315,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
constraints: table =>
{
table.PrimaryKey("pk_sms_verification_codes", x => x.id);
table.UniqueConstraint("ak_sms_verification_codes_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_sms_verification_codes_attempts", "attempts >= 0");
table.ForeignKey(
name: "fk_sms_verification_codes_tenants_tenant_id",
@@ -1498,50 +1626,34 @@ namespace Tiku.Infrastructure.Persistence.Migrations
});
migrationBuilder.CreateTable(
name: "tenant_role_templates",
name: "tenant_memberships",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
description = table.Column<string>(type: "text", nullable: true),
base_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
menu_permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
module_permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
field_permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
data_scope = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
is_system = table.Column<bool>(type: "boolean", nullable: false),
sort_order = table.Column<int>(type: "integer", nullable: false),
created_by = table.Column<Guid>(type: "uuid", nullable: true),
updated_by = table.Column<Guid>(type: "uuid", nullable: true),
legacy_role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_role_templates", x => x.id);
table.UniqueConstraint("ak_tenant_role_templates_tenant_id_id", x => new { x.tenant_id, x.id });
table.PrimaryKey("pk_tenant_memberships", x => x.id);
table.UniqueConstraint("ak_tenant_memberships_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_role_templates_tenants_tenant_id",
name: "fk_tenant_memberships_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_tenant_role_templates_users_created_by",
column: x => x.created_by,
name: "fk_tenant_memberships_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_tenant_role_templates_users_updated_by",
column: x => x.updated_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
@@ -2885,45 +2997,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "tenant_memberships",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
role_template_id = table.Column<Guid>(type: "uuid", nullable: true),
role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
permissions = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
legacy_role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_tenant_memberships", x => x.id);
table.UniqueConstraint("ak_tenant_memberships_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_tenant_memberships_tenant_role_templates_tenant_id_role_tem~",
columns: x => new { x.tenant_id, x.role_template_id },
principalTable: "tenant_role_templates",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_tenant_memberships_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_tenant_memberships_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "content_nodes",
columns: table => new
@@ -6392,6 +6465,22 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "audit_logs",
columns: new[] { "tenant_id", "target_type", "target_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_auth_challenges_tenant_id",
table: "auth_challenges",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "ix_auth_challenges_token_hash",
table: "auth_challenges",
column: "token_hash",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_auth_challenges_user_id_purpose_expires_at",
table: "auth_challenges",
columns: new[] { "user_id", "purpose", "expires_at" });
migrationBuilder.CreateIndex(
name: "ix_auth_login_events_tenant_id_user_id_created_at",
table: "auth_login_events",
@@ -6403,11 +6492,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_tenant_id_user_id_expires_at",
name: "ix_auth_sessions_realm_tenant_id_user_id_expires_at",
table: "auth_sessions",
columns: new[] { "tenant_id", "user_id", "expires_at" },
columns: new[] { "realm", "tenant_id", "user_id", "expires_at" },
filter: "revoked_at is null");
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_tenant_id",
table: "auth_sessions",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_token_family_id_revoked_at",
table: "auth_sessions",
columns: new[] { "token_family_id", "revoked_at" });
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_token_hash",
table: "auth_sessions",
@@ -8609,11 +8708,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "tenant_invoices",
columns: new[] { "tenant_id", "status", "due_date" });
migrationBuilder.CreateIndex(
name: "ix_tenant_memberships_tenant_id_role_template_id",
table: "tenant_memberships",
columns: new[] { "tenant_id", "role_template_id" });
migrationBuilder.CreateIndex(
name: "ix_tenant_memberships_tenant_id_user_id_role",
table: "tenant_memberships",
@@ -8656,27 +8750,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "tenant_question_references",
columns: new[] { "question_owner_tenant_id", "question_id" });
migrationBuilder.CreateIndex(
name: "ix_tenant_role_templates_created_by",
table: "tenant_role_templates",
column: "created_by");
migrationBuilder.CreateIndex(
name: "ix_tenant_role_templates_tenant_id_code",
table: "tenant_role_templates",
columns: new[] { "tenant_id", "code" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_tenant_role_templates_tenant_id_status_sort_order",
table: "tenant_role_templates",
columns: new[] { "tenant_id", "status", "sort_order" });
migrationBuilder.CreateIndex(
name: "ix_tenant_role_templates_updated_by",
table: "tenant_role_templates",
column: "updated_by");
migrationBuilder.CreateIndex(
name: "ix_tenant_secrets_tenant_id_purpose_provider_secret_key",
table: "tenant_secrets",
@@ -8850,6 +8923,11 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "user_badges",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_user_claims_user_id",
table: "user_claims",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_user_identities_provider_provider_subject",
table: "user_identities",
@@ -8861,6 +8939,11 @@ namespace Tiku.Infrastructure.Persistence.Migrations
table: "user_identities",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_user_logins_user_id",
table: "user_logins",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_user_notifications_created_by",
table: "user_notifications",
@@ -8942,10 +9025,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_users_email",
name: "email_index",
table: "users",
column: "email",
unique: true);
column: "normalized_email");
migrationBuilder.CreateIndex(
name: "ix_users_legacy_id",
@@ -8960,9 +9042,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
unique: true);
migrationBuilder.CreateIndex(
name: "ix_users_username",
name: "user_name_index",
table: "users",
column: "username",
column: "normalized_user_name",
unique: true);
migrationBuilder.CreateIndex(
@@ -9347,6 +9429,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "app_assets");
migrationBuilder.DropTable(
name: "auth_challenges");
migrationBuilder.DropTable(
name: "auth_login_events");
@@ -9395,6 +9480,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "dashboard_daily_stats");
migrationBuilder.DropTable(
name: "data_protection_keys");
migrationBuilder.DropTable(
name: "entitlements");
@@ -9578,15 +9666,24 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "user_badges");
migrationBuilder.DropTable(
name: "user_claims");
migrationBuilder.DropTable(
name: "user_identities");
migrationBuilder.DropTable(
name: "user_logins");
migrationBuilder.DropTable(
name: "user_notifications");
migrationBuilder.DropTable(
name: "user_score_events");
migrationBuilder.DropTable(
name: "user_tokens");
migrationBuilder.DropTable(
name: "user_word_favorites");
@@ -9662,9 +9759,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
migrationBuilder.DropTable(
name: "tenant_backend_roles");
migrationBuilder.DropTable(
name: "tenant_role_templates");
migrationBuilder.DropTable(
name: "tenant_classes");

View File

@@ -25,6 +25,110 @@ namespace Tiku.Infrastructure.Persistence.Migrations
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("text")
.HasColumnName("friendly_name");
b.Property<string>("Xml")
.HasColumnType("text")
.HasColumnName("xml");
b.HasKey("Id")
.HasName("pk_data_protection_keys");
b.ToTable("data_protection_keys", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text")
.HasColumnName("claim_type");
b.Property<string>("ClaimValue")
.HasColumnType("text")
.HasColumnName("claim_value");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_user_claims");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_claims_user_id");
b.ToTable("user_claims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text")
.HasColumnName("login_provider");
b.Property<string>("ProviderKey")
.HasColumnType("text")
.HasColumnName("provider_key");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text")
.HasColumnName("provider_display_name");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("LoginProvider", "ProviderKey")
.HasName("pk_user_logins");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_logins_user_id");
b.ToTable("user_logins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<string>("LoginProvider")
.HasColumnType("text")
.HasColumnName("login_provider");
b.Property<string>("Name")
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Value")
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("UserId", "LoginProvider", "Name")
.HasName("pk_user_tokens");
b.ToTable("user_tokens", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
{
b.Property<Guid>("Id")
@@ -7915,11 +8019,21 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer")
.HasColumnName("access_failed_count");
b.Property<string>("AvatarUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("avatar_url");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("concurrency_stamp");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
@@ -7931,6 +8045,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("citext")
.HasColumnName("email");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean")
.HasColumnName("email_confirmed");
b.Property<bool>("ForcePasswordChange")
.HasColumnType("boolean")
.HasColumnName("force_password_change");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
@@ -7940,25 +8062,48 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(64)")
.HasColumnName("legacy_id");
b.Property<string>("LegacyPasswordHash")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("legacy_password_hash");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean")
.HasColumnName("lockout_enabled");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone")
.HasColumnName("lockout_end");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<bool>("PasswordMigrationRequired")
.HasColumnType("boolean")
.HasColumnName("password_migration_required");
b.Property<string>("NormalizedEmail")
.HasMaxLength(320)
.HasColumnType("character varying(320)")
.HasColumnName("normalized_email");
b.Property<string>("NormalizedUserName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("normalized_user_name");
b.Property<string>("PasswordHash")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("password_hash");
b.Property<string>("Phone")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("phone");
b.Property<string>("PhoneNumber")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("phone_number");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean")
.HasColumnName("phone_number_confirmed");
b.Property<string>("PrimaryRole")
.IsRequired()
.HasMaxLength(50)
@@ -7975,36 +8120,50 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("integer")
.HasColumnName("score");
b.Property<string>("SecurityStamp")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("security_stamp");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean")
.HasColumnName("two_factor_enabled");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<string>("Username")
b.Property<string>("UserName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("username");
.HasColumnName("user_name");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Email")
.IsUnique()
.HasDatabaseName("ix_users_email");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_users_legacy_id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("email_index");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("user_name_index");
b.HasIndex("Phone")
.IsUnique()
.HasDatabaseName("ix_users_phone");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
@@ -8049,12 +8208,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(255)")
.HasColumnName("provider_subject");
b.Property<JsonElement>("SecretPayload")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("secret_payload")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("UnionId")
.HasMaxLength(255)
.HasColumnType("character varying(255)")
@@ -12648,6 +12801,95 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("question_versions", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("ConsumedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("consumed_at");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<string>("IpAddress")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("ip_address");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Purpose")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("purpose");
b.Property<string>("Realm")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("realm");
b.Property<string>("SecurityStamp")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("security_stamp");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("token_hash");
b.Property<string>("UserAgent")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("user_agent");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_auth_challenges");
b.HasIndex("TenantId")
.HasDatabaseName("ix_auth_challenges_tenant_id");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("ix_auth_challenges_token_hash");
b.HasIndex("UserId", "Purpose", "ExpiresAt")
.HasDatabaseName("ix_auth_challenges_user_id_purpose_expires_at");
b.ToTable("auth_challenges", null, t =>
{
t.HasCheckConstraint("ck_auth_challenges_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
{
b.Property<Guid>("Id")
@@ -12752,20 +12994,53 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("MfaSatisfied")
.HasColumnType("boolean")
.HasColumnName("mfa_satisfied");
b.Property<Guid?>("ParentSessionId")
.HasColumnType("uuid")
.HasColumnName("parent_session_id");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("provider");
b.Property<string>("Realm")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("realm");
b.Property<Guid?>("ReplacedBySessionId")
.HasColumnType("uuid")
.HasColumnName("replaced_by_session_id");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<Guid>("TenantId")
b.Property<string>("RevokedReason")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("revoked_reason");
b.Property<string>("SecurityStamp")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("security_stamp");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<Guid>("TokenFamilyId")
.HasColumnType("uuid")
.HasColumnName("token_family_id");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(256)
@@ -12790,8 +13065,8 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_auth_sessions");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_auth_sessions_tenant_id_id");
b.HasIndex("TenantId")
.HasDatabaseName("ix_auth_sessions_tenant_id");
b.HasIndex("TokenHash")
.IsUnique()
@@ -12801,11 +13076,17 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_sessions_user_id");
b.HasIndex("TenantId", "UserId", "ExpiresAt")
.HasDatabaseName("ix_auth_sessions_tenant_id_user_id_expires_at")
b.HasIndex("TokenFamilyId", "RevokedAt")
.HasDatabaseName("ix_auth_sessions_token_family_id_revoked_at");
b.HasIndex("Realm", "TenantId", "UserId", "ExpiresAt")
.HasDatabaseName("ix_auth_sessions_realm_tenant_id_user_id_expires_at")
.HasFilter("revoked_at is null");
b.ToTable("auth_sessions", (string)null);
b.ToTable("auth_sessions", null, t =>
{
t.HasCheckConstraint("ck_auth_sessions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
});
});
modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b =>
@@ -12931,9 +13212,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_sms_verification_codes");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_sms_verification_codes_tenant_id_id");
b.HasIndex("TenantId", "Phone", "Purpose")
.IsUnique()
.HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose")
@@ -13623,22 +13901,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(50)")
.HasColumnName("legacy_role");
b.Property<JsonElement>("Permissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("role");
b.Property<Guid?>("RoleTemplateId")
.HasColumnType("uuid")
.HasColumnName("role_template_id");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
@@ -13668,9 +13936,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_tenant_memberships_user_id");
b.HasIndex("TenantId", "RoleTemplateId")
.HasDatabaseName("ix_tenant_memberships_tenant_id_role_template_id");
b.HasIndex("TenantId", "UserId", "Role")
.IsUnique()
.HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role");
@@ -13678,126 +13943,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_memberships", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("BaseRole")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("base_role");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("code");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uuid")
.HasColumnName("created_by");
b.Property<JsonElement>("DataScope")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("data_scope")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<JsonElement>("FieldPermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("field_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("is_system");
b.Property<JsonElement>("MenuPermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("menu_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<JsonElement>("ModulePermissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("module_permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("name");
b.Property<JsonElement>("Permissions")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("permissions")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasColumnName("sort_order");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid")
.HasColumnName("updated_by");
b.HasKey("Id")
.HasName("pk_tenant_role_templates");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_tenant_role_templates_tenant_id_id");
b.HasIndex("CreatedBy")
.HasDatabaseName("ix_tenant_role_templates_created_by");
b.HasIndex("UpdatedBy")
.HasDatabaseName("ix_tenant_role_templates_updated_by");
b.HasIndex("TenantId", "Code")
.IsUnique()
.HasDatabaseName("ix_tenant_role_templates_tenant_id_code");
b.HasIndex("TenantId", "Status", "SortOrder")
.HasDatabaseName("ix_tenant_role_templates_tenant_id_status_sort_order");
b.ToTable("tenant_role_templates", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>
{
b.Property<Guid>("Id")
@@ -14153,6 +14298,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("tenant_student_notes", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_claims_users_user_id");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_logins_users_user_id");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_tokens_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
@@ -17204,12 +17379,28 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_question_versions_questions_tenant_id_question_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_auth_challenges_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_challenges_users_user_id");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.SetNull)
.IsRequired()
.HasConstraintName("fk_auth_login_events_tenants_tenant_id");
@@ -17226,7 +17417,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_sessions_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
@@ -17375,35 +17565,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_memberships_users_user_id");
b.HasOne("Tiku.Domain.Tenancy.TenantRoleTemplate", null)
.WithMany()
.HasForeignKey("TenantId", "RoleTemplateId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_tenant_memberships_tenant_role_templates_tenant_id_role_tem~");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantRoleTemplate", b =>
{
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("CreatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_tenant_role_templates_users_created_by");
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tenant_role_templates_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_tenant_role_templates_users_updated_by");
});
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b =>

View File

@@ -1,4 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using System.Reflection;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
@@ -18,7 +21,7 @@ namespace Tiku.Infrastructure.Persistence;
public sealed class TikuDbContext(
DbContextOptions<TikuDbContext> options,
ITenantContext tenantContext) : DbContext(options)
ITenantContext tenantContext) : IdentityUserContext<User, Guid>(options), IDataProtectionKeyContext
{
public TikuDbContext(DbContextOptions<TikuDbContext> options)
: this(options, CreateToolingTenantContext())
@@ -37,7 +40,8 @@ public sealed class TikuDbContext(
return context;
}
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<User> Users => Set<User>();
public new DbSet<User> Users => Set<User>();
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
public DbSet<UserIdentity> UserIdentities => Set<UserIdentity>();
public DbSet<TenantMembership> TenantMemberships => Set<TenantMembership>();
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
@@ -49,8 +53,8 @@ public sealed class TikuDbContext(
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
public DbSet<AuthLoginEvent> AuthLoginEvents => Set<AuthLoginEvent>();
public DbSet<AuthSession> AuthSessions => Set<AuthSession>();
public DbSet<AuthChallenge> AuthChallenges => Set<AuthChallenge>();
public DbSet<SmsSendRateLimit> SmsSendRateLimits => Set<SmsSendRateLimit>();
public DbSet<TenantRoleTemplate> TenantRoleTemplates => Set<TenantRoleTemplate>();
public DbSet<TenantClass> TenantClasses => Set<TenantClass>();
public DbSet<TenantClassMember> TenantClassMembers => Set<TenantClassMember>();
public DbSet<TenantStudentNote> TenantStudentNotes => Set<TenantStudentNote>();
@@ -186,9 +190,14 @@ public sealed class TikuDbContext(
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<IdentityUserClaim<Guid>>().ToTable("user_claims");
modelBuilder.Entity<IdentityUserLogin<Guid>>().ToTable("user_logins");
modelBuilder.Entity<IdentityUserToken<Guid>>().ToTable("user_tokens");
modelBuilder.HasPostgresExtension("citext");
modelBuilder.HasPostgresExtension("ltree");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
modelBuilder.Entity<DataProtectionKey>().ToTable("data_protection_keys");
ApplyTenantQueryFilters(modelBuilder);
ValidateTenantModel(modelBuilder);
modelBuilder.UseSnakeCaseIdentifiers();
@@ -298,6 +307,15 @@ public sealed class TikuDbContext(
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in ChangeTracker.Entries<User>().Where(entry => entry.State == EntityState.Modified))
{
if (entry.Property(user => user.Status).IsModified ||
entry.Property(user => user.PasswordHash).IsModified)
{
entry.Entity.SecurityStamp = Guid.NewGuid().ToString("N");
}
}
foreach (var entry in ChangeTracker.Entries<IHasTimestamps>())
{
if (entry.State == EntityState.Added)

View File

@@ -392,7 +392,7 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
return new StudentProfileItem(
profile.Id,
user.Id,
user.Username,
user.UserName,
user.Phone,
user.Email,
user.Name,

View 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);
}
}

View 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);
}
}
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@
<PackageReference Include="AlipaySDKNet.Standard" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options" />