Files
tiku-backend.net/Tiku.Infrastructure/Auth/AuthService.cs

887 lines
34 KiB
C#

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;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Auth;
public sealed class AuthService(
TikuDbContext dbContext,
SignInManager<User> signInManager,
UserManager<User> userManager,
ISmsVerificationService smsVerificationService,
IAuthSessionStore sessionStore,
IWechatOAuthClient wechatOAuthClient,
ITenantExternalProviderConfigService providerConfigService) : IAuthService
{
private const string PasswordProvider = "password";
private const string SmsProvider = "sms";
private const string WechatWebProvider = "wechat_web";
private const string WechatMiniAppProvider = "wechat_miniapp";
private static readonly string[] WechatWebProviderAliases = ["wechat_web", "wechat-web", "wechat"];
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<AuthenticationResult> LoginWithPasswordAsync(
PasswordLoginRequest request,
CancellationToken cancellationToken = default)
{
var identifier = request.Phone.Trim();
var normalizedEmail = userManager.NormalizeEmail(identifier);
var normalizedUserName = userManager.NormalizeName(identifier);
var user = await dbContext.Users
.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 (!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,
identifier,
loginResult,
failureCode,
request.IpAddress,
request.UserAgent,
cancellationToken);
throw new InvalidCredentialsException();
}
return await CompleteSuccessfulLoginAsync(
request.Realm,
request.TenantId,
user!,
PasswordProvider,
identifier,
request.IpAddress,
request.UserAgent,
cancellationToken);
}
public async Task<AuthenticationResult> LoginWithSmsAsync(
SmsLoginRequest request,
CancellationToken cancellationToken = default)
{
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
var user = await dbContext.Users
.SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken);
try
{
if (!request.TenantId.HasValue)
{
throw new InvalidCredentialsException("tenant_required_for_sms");
}
await smsVerificationService.VerifyCodeAsync(
request.TenantId.Value,
phone,
SmsPurpose.Login,
request.Code,
cancellationToken);
}
catch (InvalidCredentialsException exception)
{
await AddLoginEventAsync(
request.TenantId,
user?.Id,
SmsProvider,
phone,
AuthLoginResult.Failed,
exception.Code,
request.IpAddress,
request.UserAgent,
cancellationToken);
throw;
}
if (user is null)
{
await AddLoginEventAsync(
request.TenantId,
null,
SmsProvider,
phone,
AuthLoginResult.Failed,
"user_not_found",
request.IpAddress,
request.UserAgent,
cancellationToken);
throw new InvalidCredentialsException();
}
return await CompleteSuccessfulLoginAsync(
request.Realm,
request.TenantId,
user,
SmsProvider,
phone,
request.IpAddress,
request.UserAgent,
cancellationToken);
}
public Task<AuthenticationResult> LoginWithWechatWebAsync(
WechatLoginRequest request,
CancellationToken cancellationToken = default)
{
return LoginWithWechatAsync(
request,
WechatWebProvider,
WechatWebProviderAliases,
(options, code, token) => wechatOAuthClient.ExchangeWebCodeAsync(options, code, token),
cancellationToken);
}
public Task<AuthenticationResult> LoginWithWechatMiniAppAsync(
WechatLoginRequest request,
CancellationToken cancellationToken = default)
{
return LoginWithWechatAsync(
request,
WechatMiniAppProvider,
WechatMiniAppProviderAliases,
(options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token),
cancellationToken);
}
public async Task<AuthTokenPair> RefreshAsync(
RefreshSessionRequest request,
CancellationToken cancellationToken = default)
{
return await sessionStore.RotateAsync(
request.RefreshToken, request.IpAddress, request.UserAgent, cancellationToken);
}
public async Task LogoutAsync(
LogoutSessionRequest request,
CancellationToken cancellationToken = default)
{
await sessionStore.RevokeFamilyAsync(request.RefreshToken, "logout", cancellationToken);
}
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,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
if (user.Status != UserStatus.Active)
{
await AddLoginEventAsync(
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();
}
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(
tenant?.Id,
user.Id,
provider,
identifier,
AuthLoginResult.Success,
null,
ipAddress,
userAgent,
cancellationToken);
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<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.Value,
provider,
providerAliases,
cancellationToken);
WechatIdentity identity;
try
{
identity = await exchangeCodeAsync(config, request.Code, cancellationToken);
}
catch (AuthException exception)
{
await AddLoginEventAsync(
request.TenantId,
null,
provider,
null,
AuthLoginResult.Failed,
exception.Code,
request.IpAddress,
request.UserAgent,
cancellationToken);
throw;
}
var providerSubject = $"{config.AppId}:{identity.OpenId}";
var user = await UpsertWechatUserAsync(
provider,
providerSubject,
config.AppId,
identity,
cancellationToken);
await EnsureTenantMembershipAsync(
request.TenantId.Value,
user.Id,
cancellationToken);
return await CompleteSuccessfulLoginAsync(
request.Realm,
request.TenantId,
user,
provider,
identity.OpenId,
request.IpAddress,
request.UserAgent,
cancellationToken);
}
private async Task<WechatProviderOptions> LoadWechatProviderOptionsAsync(
Guid tenantId,
string provider,
IReadOnlyList<string> aliases,
CancellationToken cancellationToken)
{
TenantExternalProviderAccount? account = null;
foreach (var alias in aliases)
{
try
{
account = await providerConfigService.GetActiveProviderAsync(
tenantId,
TenantExternalProviderCapability.Identity,
alias,
cancellationToken);
break;
}
catch (TenantExternalProviderException)
{
}
}
if (account is null)
{
throw new AuthProviderNotConfiguredException(provider);
}
var appId = GetJsonString(account.ConfigPublic, "appId", "clientId");
var appSecret = GetJsonString(account.SecretPayload, "appSecret", "clientSecret", "secret");
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret))
{
throw new AuthProviderNotConfiguredException(provider);
}
return new WechatProviderOptions(appId, appSecret);
}
private async Task<User> UpsertWechatUserAsync(
string provider,
string providerSubject,
string appId,
WechatIdentity wechatIdentity,
CancellationToken cancellationToken)
{
var existingIdentity = await dbContext.UserIdentities
.SingleOrDefaultAsync(
identity =>
identity.Provider == provider &&
identity.ProviderSubject == providerSubject,
cancellationToken);
var user = existingIdentity is null
? await FindUserByWechatUnionIdAsync(wechatIdentity.UnionId, cancellationToken)
: await dbContext.Users.FindAsync([existingIdentity.UserId], cancellationToken);
if (user is null)
{
user = new User
{
Name = wechatIdentity.Nickname,
AvatarUrl = wechatIdentity.AvatarUrl,
PrimaryRole = "student",
RawProfile = CreateWechatRawProfile(wechatIdentity)
};
dbContext.Users.Add(user);
}
else
{
user.Name = string.IsNullOrWhiteSpace(user.Name) ? wechatIdentity.Nickname : user.Name;
user.AvatarUrl = string.IsNullOrWhiteSpace(user.AvatarUrl) ? wechatIdentity.AvatarUrl : user.AvatarUrl;
}
if (existingIdentity is null)
{
existingIdentity = new UserIdentity
{
UserId = user.Id,
Provider = provider,
ProviderSubject = providerSubject
};
dbContext.UserIdentities.Add(existingIdentity);
}
existingIdentity.UserId = user.Id;
existingIdentity.OpenId = wechatIdentity.OpenId;
existingIdentity.UnionId = wechatIdentity.UnionId;
await dbContext.SaveChangesAsync(cancellationToken);
return user;
}
private async Task<User?> FindUserByWechatUnionIdAsync(
string? unionId,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(unionId))
{
return null;
}
var identity = await dbContext.UserIdentities
.Where(entity =>
entity.UnionId == unionId &&
WechatIdentityProviders.Contains(entity.Provider))
.OrderBy(entity => entity.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
return identity is null
? null
: await dbContext.Users.FindAsync([identity.UserId], cancellationToken);
}
private async Task EnsureTenantMembershipAsync(
Guid tenantId,
Guid userId,
CancellationToken cancellationToken)
{
var activeMembershipExists = await dbContext.TenantMemberships.AnyAsync(
membership =>
membership.TenantId == tenantId &&
membership.UserId == userId &&
membership.Status == MembershipStatus.Active,
cancellationToken);
if (activeMembershipExists)
{
return;
}
var studentMembership = await dbContext.TenantMemberships
.FirstOrDefaultAsync(
membership =>
membership.TenantId == tenantId &&
membership.UserId == userId &&
membership.Role == TenantRole.Student,
cancellationToken);
if (studentMembership is null)
{
dbContext.TenantMemberships.Add(new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
}
else
{
studentMembership.Status = MembershipStatus.Active;
}
await dbContext.SaveChangesAsync(cancellationToken);
}
private async Task<TenantMembership?> FindActiveMembershipAsync(
Guid tenantId,
Guid userId,
CancellationToken cancellationToken)
{
return await dbContext.TenantMemberships
.Where(entity =>
entity.TenantId == tenantId &&
entity.UserId == userId &&
entity.Status == MembershipStatus.Active)
.OrderBy(entity => entity.Role)
.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? userId,
string provider,
string? identifier,
AuthLoginResult result,
string? failureCode,
string? ipAddress,
string? userAgent,
CancellationToken cancellationToken)
{
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
{
TenantId = tenantId,
UserId = userId,
Provider = provider,
Identifier = identifier,
Result = result,
FailureCode = failureCode,
IpAddress = ipAddress,
UserAgent = userAgent
});
await dbContext.SaveChangesAsync(cancellationToken);
}
private static string? GetJsonString(JsonElement element, params string[] names)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var name in names)
{
if (element.TryGetProperty(name, out var property) &&
property.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(property.GetString()))
{
return property.GetString()!.Trim();
}
}
return null;
}
private static JsonElement CreateWechatRawProfile(WechatIdentity identity)
{
return JsonSerializer.SerializeToElement(new
{
openId = identity.OpenId,
unionId = identity.UnionId,
nickname = identity.Nickname,
avatarUrl = identity.AvatarUrl
});
}
}