513 lines
19 KiB
C#
513 lines
19 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Tiku.Application.Auth;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Application.Tenancy;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Tenancy;
|
|
|
|
namespace Tiku.Infrastructure.Auth;
|
|
|
|
public sealed partial class AuthService
|
|
{
|
|
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);
|
|
|
|
return await IssueAuthenticatedResultAsync(
|
|
user, realm, tenant, membership, provider,
|
|
identifier, ipAddress, userAgent, cancellationToken);
|
|
}
|
|
|
|
private async Task<AuthenticationResult> IssueAuthenticatedResultAsync(
|
|
User user,
|
|
AuthRealm realm,
|
|
Tenant? tenant,
|
|
TenantMembership? membership,
|
|
string provider,
|
|
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,
|
|
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;
|
|
}
|
|
|
|
await using var transaction = dbContext.Database.CurrentTransaction is null
|
|
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
|
: null;
|
|
var providerSubject = $"{config.AppId}:{identity.OpenId}";
|
|
var user = await UpsertWechatUserAsync(
|
|
provider,
|
|
providerSubject,
|
|
config.AppId,
|
|
identity,
|
|
cancellationToken);
|
|
await EnsureTenantMembershipAsync(
|
|
request.TenantId.Value,
|
|
user.Id,
|
|
cancellationToken);
|
|
// Persist the external identity and membership together only after the
|
|
// tenant policy and existing membership state have accepted the login.
|
|
// A denied first login must not leave a user or provider identity behind.
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
if (transaction is not null) await transaction.CommitAsync(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;
|
|
|
|
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 not null)
|
|
// Invited and Disabled memberships require an explicit administrator action.
|
|
throw new TenantAccessDeniedException();
|
|
|
|
var policy = await dbContext.TenantAuthPolicies.AsNoTracking()
|
|
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
|
if (policy is not null && !policy.AllowExternalStudentSelfRegistration) throw new TenantAccessDeniedException();
|
|
|
|
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
|
tenantId,
|
|
SaasQuotaMetricCatalog.StudentCount,
|
|
cancellationToken: cancellationToken);
|
|
|
|
dbContext.TenantMemberships.Add(new TenantMembership
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
Role = TenantRole.Student,
|
|
Status = MembershipStatus.Active
|
|
});
|
|
}
|
|
|
|
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 == BackendRoleStatus.Active &&
|
|
(permission.Area == BackendPermissionArea.Platform ||
|
|
permission.Area == 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 == BackendRoleStatus.Active &&
|
|
(permission.Area == BackendPermissionArea.Tenant ||
|
|
permission.Area == BackendPermissionArea.Both)
|
|
select permission.Id).AnyAsync(cancellationToken);
|
|
}
|
|
|
|
private static string HashChallengeToken(string token)
|
|
{
|
|
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant();
|
|
}
|
|
|
|
private async Task AddSecurityAuditAsync(
|
|
Guid userId,
|
|
Guid? tenantId,
|
|
string action,
|
|
string? reason,
|
|
string? ipAddress,
|
|
string? userAgent,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
dbContext.AuditLogs.Add(new 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
|
|
});
|
|
}
|
|
} |