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