diff --git a/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs b/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs index 8d9bc25df1..258c647eb0 100644 --- a/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs +++ b/src/BuildingBlocks/Mailing/Services/SmtpMailService.cs @@ -1,4 +1,4 @@ -using MailKit.Security; +using MailKit.Security; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MimeKit; @@ -14,68 +14,124 @@ public class SmtpMailService(IOptions settings, ILogger 0) + private static void AddBccRecipients(MimeMessage email, MailRequest request) + { + if (request.Bcc is null || request.Bcc.Count == 0) { - foreach (string address in request.Bcc.Where(bccValue => !string.IsNullOrWhiteSpace(bccValue))) - email.Bcc.Add(MailboxAddress.Parse(address.Trim())); + return; } - // Cc - if (request.Cc != null && request.Cc.Count > 0) + foreach (string address in request.Bcc.Where(bcc => !string.IsNullOrWhiteSpace(bcc))) { - foreach (string? address in request.Cc.Where(ccValue => !string.IsNullOrWhiteSpace(ccValue))) - email.Cc.Add(MailboxAddress.Parse(address.Trim())); + email.Bcc.Add(MailboxAddress.Parse(address.Trim())); } + } - // Headers - if (request.Headers != null) + private static void AddCcRecipients(MimeMessage email, MailRequest request) + { + if (request.Cc is null || request.Cc.Count == 0) { - foreach (var header in request.Headers) - email.Headers.Add(header.Key, header.Value); + return; } - // Content - var builder = new BodyBuilder(); - email.Sender = new MailboxAddress(request.DisplayName ?? _settings.DisplayName, request.From ?? _settings.From); + foreach (string? address in request.Cc.Where(cc => !string.IsNullOrWhiteSpace(cc))) + { + email.Cc.Add(MailboxAddress.Parse(address.Trim())); + } + } + + private static void AddHeaders(MimeMessage email, MailRequest request) + { + if (request.Headers is null) + { + return; + } + + foreach (var header in request.Headers) + { + email.Headers.Add(header.Key, header.Value); + } + } + + private static void ConfigureContent(MimeMessage email, MailRequest request) + { email.Subject = request.Subject; - builder.HtmlBody = request.Body; + } - // Create the file attachments for this e-mail message - if (request.AttachmentData != null) + private static async Task AddAttachmentsAsync(MimeMessage email, MailRequest request, CancellationToken ct) + { + var builder = new BodyBuilder { HtmlBody = request.Body }; + + if (request.AttachmentData is not null) { - foreach (var attachmentInfo in request.AttachmentData) + foreach (var attachment in request.AttachmentData) { using var stream = new MemoryStream(); - await stream.WriteAsync(attachmentInfo.Value, ct); + await stream.WriteAsync(attachment.Value, ct); stream.Position = 0; - await builder.Attachments.AddAsync(attachmentInfo.Key, stream, ct); + await builder.Attachments.AddAsync(attachment.Key, stream, ct); } } email.Body = builder.ToMessageBody(); + } + private async Task SendEmailAsync(MimeMessage email, CancellationToken ct) + { using var client = new SmtpClient(); + try { - await client.ConnectAsync(_settings.Smtp.Host, _settings.Smtp.Port, SecureSocketOptions.StartTls, ct); + await client.ConnectAsync(_settings.Smtp!.Host, _settings.Smtp.Port, SecureSocketOptions.StartTls, ct); await client.AuthenticateAsync(_settings.Smtp.UserName, _settings.Smtp.Password, ct); await client.SendAsync(email, ct); } diff --git a/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/AuditHttpMiddleware.cs b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/AuditHttpMiddleware.cs index 570ae4c151..8ad5a7fad9 100644 --- a/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/AuditHttpMiddleware.cs +++ b/src/Modules/Auditing/Modules.Auditing/Infrastructure/Http/AuditHttpMiddleware.cs @@ -1,4 +1,3 @@ -// Modules.Auditing/AuditHttpMiddleware.cs using FSH.Modules.Auditing.Contracts; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -25,94 +24,147 @@ public async Task InvokeAsync(HttpContext ctx) return; } - var masker = ctx.RequestServices.GetService(); + var requestContext = await CaptureRequestAsync(ctx); var sw = Stopwatch.StartNew(); + var originalBody = ctx.Response.Body; + await using var responseBuffer = new MemoryStream(); + ctx.Response.Body = responseBuffer; + + try + { + await _next(ctx); + sw.Stop(); + + await WriteSuccessAuditAsync(ctx, requestContext, responseBuffer, originalBody, sw); + } + catch (Exception ex) + { + sw.Stop(); + await WriteExceptionAuditAsync(ctx, ex); + ctx.Response.Body = originalBody; + throw; + } + } + + private async Task CaptureRequestAsync(HttpContext ctx) + { object? reqPreview = null; int reqSize = 0; - if (_opts.CaptureBodies && - ContentTypeHelper.IsJsonLike(ctx.Request.ContentType, _opts.AllowedContentTypes)) + + if (ShouldCaptureBody(ctx.Request.ContentType)) { + var masker = ctx.RequestServices.GetService(); (reqPreview, reqSize) = await HttpBodyReader.ReadRequestAsync(ctx, _opts.MaxRequestBytes, ctx.RequestAborted); + if (reqPreview is not null && masker is not null) { reqPreview = masker.ApplyMasking(reqPreview); } } - var originalBody = ctx.Response.Body; - await using var tee = new MemoryStream(); - await using var respBuffer = new MemoryStream(); - ctx.Response.Body = tee; + return new RequestCaptureContext(reqPreview, reqSize); + } - try + private async Task WriteSuccessAuditAsync( + HttpContext ctx, + RequestCaptureContext requestContext, + MemoryStream responseBuffer, + Stream originalBody, + Stopwatch sw) + { + var (respPreview, respSize) = await CaptureResponseAsync(ctx, responseBuffer); + + await RestoreResponseBodyAsync(responseBuffer, originalBody, ctx); + + await WriteActivityAuditAsync(ctx, requestContext, respPreview, respSize, sw); + } + + private async Task<(object? Preview, int Size)> CaptureResponseAsync(HttpContext ctx, MemoryStream responseBuffer) + { + if (!ShouldCaptureBody(ctx.Response.ContentType)) { - await _next(ctx); - sw.Stop(); + return (null, 0); + } - object? respPreview = null; - int respSize = 0; + var masker = ctx.RequestServices.GetService(); + responseBuffer.Position = 0; - if (_opts.CaptureBodies && - ContentTypeHelper.IsJsonLike(ctx.Response.ContentType, _opts.AllowedContentTypes)) - { - tee.Position = 0; - await tee.CopyToAsync(respBuffer, ctx.RequestAborted); - (respPreview, respSize) = await HttpBodyReader.ReadResponseAsync( - respBuffer, _opts.MaxResponseBytes, ctx.RequestAborted); - if (respPreview is not null && masker is not null) - { - respPreview = masker.ApplyMasking(respPreview); - } - } + await using var respBuffer = new MemoryStream(); + await responseBuffer.CopyToAsync(respBuffer, ctx.RequestAborted); - respBuffer.Position = 0; - ctx.Response.Body = originalBody; - if (respBuffer.Length > 0) - await respBuffer.CopyToAsync(originalBody, ctx.RequestAborted); - - await Audit.ForActivity(Contracts.ActivityKind.Http, ctx.Request.Path) - .WithActivityResult( - statusCode: ctx.Response.StatusCode, - durationMs: (int)sw.Elapsed.TotalMilliseconds, - captured: _opts.CaptureBodies ? BodyCapture.Both : BodyCapture.None, - requestSize: reqSize, - responseSize: respSize, - requestPreview: reqPreview, - responsePreview: respPreview) - .WithSource("api") - .WithTenant((_publisher.CurrentScope?.TenantId) ?? null) - .WithUser(_publisher.CurrentScope?.UserId, _publisher.CurrentScope?.UserName) - .WithCorrelation(_publisher.CurrentScope?.CorrelationId ?? ctx.TraceIdentifier) - .WithRequestId(_publisher.CurrentScope?.RequestId ?? ctx.TraceIdentifier) - .WriteAsync(ctx.RequestAborted); + var (respPreview, respSize) = await HttpBodyReader.ReadResponseAsync( + respBuffer, _opts.MaxResponseBytes, ctx.RequestAborted); + + if (respPreview is not null && masker is not null) + { + respPreview = masker.ApplyMasking(respPreview); } - catch (Exception ex) + + return (respPreview, respSize); + } + + private static async Task RestoreResponseBodyAsync(MemoryStream responseBuffer, Stream originalBody, HttpContext ctx) + { + responseBuffer.Position = 0; + ctx.Response.Body = originalBody; + + if (responseBuffer.Length > 0) { - sw.Stop(); + await responseBuffer.CopyToAsync(originalBody, ctx.RequestAborted); + } + } - var sev = ExceptionSeverityClassifier.Classify(ex); - if (sev >= _opts.MinExceptionSeverity) - { - await Audit.ForException(ex, ExceptionArea.Api, - routeOrLocation: ctx.Request.Path, severity: sev) - .WithSource("api") - .WithTenant((_publisher.CurrentScope?.TenantId) ?? null) - .WithUser(_publisher.CurrentScope?.UserId, _publisher.CurrentScope?.UserName) - .WithCorrelation(_publisher.CurrentScope?.CorrelationId ?? ctx.TraceIdentifier) - .WithRequestId(_publisher.CurrentScope?.RequestId ?? ctx.TraceIdentifier) - .WriteAsync(ctx.RequestAborted); - } + private async Task WriteActivityAuditAsync( + HttpContext ctx, + RequestCaptureContext requestContext, + object? respPreview, + int respSize, + Stopwatch sw) + { + await Audit.ForActivity(Contracts.ActivityKind.Http, ctx.Request.Path) + .WithActivityResult( + statusCode: ctx.Response.StatusCode, + durationMs: (int)sw.Elapsed.TotalMilliseconds, + captured: _opts.CaptureBodies ? BodyCapture.Both : BodyCapture.None, + requestSize: requestContext.Size, + responseSize: respSize, + requestPreview: requestContext.Preview, + responsePreview: respPreview) + .WithSource("api") + .WithTenant(_publisher.CurrentScope?.TenantId) + .WithUser(_publisher.CurrentScope?.UserId, _publisher.CurrentScope?.UserName) + .WithCorrelation(_publisher.CurrentScope?.CorrelationId ?? ctx.TraceIdentifier) + .WithRequestId(_publisher.CurrentScope?.RequestId ?? ctx.TraceIdentifier) + .WriteAsync(ctx.RequestAborted); + } - ctx.Response.Body = originalBody; - throw; + private async Task WriteExceptionAuditAsync(HttpContext ctx, Exception ex) + { + var sev = ExceptionSeverityClassifier.Classify(ex); + if (sev < _opts.MinExceptionSeverity) + { + return; } + + await Audit.ForException(ex, ExceptionArea.Api, routeOrLocation: ctx.Request.Path, severity: sev) + .WithSource("api") + .WithTenant(_publisher.CurrentScope?.TenantId) + .WithUser(_publisher.CurrentScope?.UserId, _publisher.CurrentScope?.UserName) + .WithCorrelation(_publisher.CurrentScope?.CorrelationId ?? ctx.TraceIdentifier) + .WithRequestId(_publisher.CurrentScope?.RequestId ?? ctx.TraceIdentifier) + .WriteAsync(ctx.RequestAborted); } + private bool ShouldCaptureBody(string? contentType) => + _opts.CaptureBodies && ContentTypeHelper.IsJsonLike(contentType, _opts.AllowedContentTypes); + private bool ShouldSkip(HttpContext ctx) { var path = ctx.Request.Path.Value ?? string.Empty; return _opts.ExcludePathStartsWith.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); } + + private readonly record struct RequestCaptureContext(object? Preview, int Size); } diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/EntityDiffBuilder.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/EntityDiffBuilder.cs index 439df89f47..951f5107ca 100644 --- a/src/Modules/Auditing/Modules.Auditing/Persistence/EntityDiffBuilder.cs +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/EntityDiffBuilder.cs @@ -1,4 +1,4 @@ -using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Auditing.Contracts; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; @@ -9,6 +9,12 @@ namespace FSH.Modules.Auditing.Persistence; /// internal static class EntityDiffBuilder { + private static readonly HashSet ScalarTypes = + [ + typeof(string), typeof(decimal), typeof(DateTime), typeof(DateTimeOffset), + typeof(Guid), typeof(TimeSpan), typeof(byte[]), typeof(bool) + ]; + internal sealed record Diff( string DbContext, string? Schema, @@ -22,119 +28,162 @@ public static List Build(IEnumerable entries) { var list = new List(); - foreach (var e in entries) + foreach (var entry in entries) { - var entityType = e.Metadata; - var table = entityType.GetTableName() ?? entityType.GetDefaultTableName() ?? entityType.DisplayName(); - var schema = entityType.GetSchema(); - var key = BuildKey(e); - var op = e.State switch + var diff = BuildDiff(entry); + if (diff is not null) { - EntityState.Added => EntityOperation.Insert, - EntityState.Modified => DetectSoftDelete(e) ? EntityOperation.SoftDelete : EntityOperation.Update, - EntityState.Deleted => EntityOperation.Delete, - _ => EntityOperation.None - }; - - var changes = new List(); - foreach (var p in e.Properties) + list.Add(diff); + } + } + + return list; + } + + private static Diff? BuildDiff(EntityEntry entry) + { + var entityType = entry.Metadata; + var table = entityType.GetTableName() ?? entityType.GetDefaultTableName() ?? entityType.DisplayName(); + var schema = entityType.GetSchema(); + var key = BuildKey(entry); + var operation = DetermineOperation(entry); + + var changes = CollectPropertyChanges(entry); + if (changes.Count == 0) + { + return null; + } + + return new Diff( + DbContext: entry.Context.GetType().Name, + Schema: schema, + Table: table!, + EntityName: entityType.ClrType.Name, + Key: key, + Operation: operation, + Changes: changes); + } + + private static EntityOperation DetermineOperation(EntityEntry entry) => entry.State switch + { + EntityState.Added => EntityOperation.Insert, + EntityState.Modified => DetectSoftDelete(entry) ? EntityOperation.SoftDelete : EntityOperation.Update, + EntityState.Deleted => EntityOperation.Delete, + _ => EntityOperation.None + }; + + private static List CollectPropertyChanges(EntityEntry entry) + { + var changes = new List(); + + foreach (var property in entry.Properties) + { + if (ShouldSkipProperty(property)) { - if (p.Metadata.IsShadowProperty() && !p.Metadata.IsPrimaryKey()) continue; - if (p.Metadata.IsConcurrencyToken) continue; - if (p.Metadata.IsIndexerProperty()) continue; - if (p.Metadata.IsKey()) continue; // keys are in "key" string already - if (!p.Metadata.IsNullable && p.Metadata.ClrType.IsClass && p.Metadata.IsForeignKey()) continue; // nav FKs often noisy - - // Include only scalar types - if (!IsScalar(p.Metadata.ClrType)) continue; - - var name = p.Metadata.Name; - var typeName = ToSimpleTypeName(p.Metadata.ClrType); - - object? oldVal = null; - object? newVal = null; - var isModified = false; - - switch (e.State) - { - case EntityState.Added: - newVal = p.CurrentValue; - isModified = true; - break; - - case EntityState.Modified: - oldVal = p.OriginalValue; - newVal = p.CurrentValue; - isModified = p.IsModified && !Equals(oldVal, newVal); - break; - - case EntityState.Deleted: - oldVal = p.OriginalValue; - isModified = true; - break; - } - - if (isModified) - { - changes.Add(new PropertyChange( - Name: name, - DataType: typeName, - OldValue: oldVal, - NewValue: newVal, - IsSensitive: IsSensitive(name))); - } + continue; } - if (changes.Count > 0) + var change = TryCreatePropertyChange(entry, property); + if (change is not null) { - list.Add(new Diff( - DbContext: e.Context.GetType().Name, - Schema: schema, - Table: table!, - EntityName: entityType.ClrType.Name, - Key: key, - Operation: op, - Changes: changes)); + changes.Add(change); } } - return list; + return changes; + } + + private static bool ShouldSkipProperty(PropertyEntry property) + { + var metadata = property.Metadata; + + if (metadata.IsShadowProperty() && !metadata.IsPrimaryKey()) return true; + if (metadata.IsConcurrencyToken) return true; + if (metadata.IsIndexerProperty()) return true; + if (metadata.IsKey()) return true; + if (!metadata.IsNullable && metadata.ClrType.IsClass && metadata.IsForeignKey()) return true; + if (!IsScalar(metadata.ClrType)) return true; + + return false; + } + + private static PropertyChange? TryCreatePropertyChange(EntityEntry entry, PropertyEntry property) + { + var (oldVal, newVal, isModified) = GetPropertyValues(entry.State, property); + + if (!isModified) + { + return null; + } + + return new PropertyChange( + Name: property.Metadata.Name, + DataType: ToSimpleTypeName(property.Metadata.ClrType), + OldValue: oldVal, + NewValue: newVal, + IsSensitive: IsSensitive(property.Metadata.Name)); + } + + private static (object? OldValue, object? NewValue, bool IsModified) GetPropertyValues( + EntityState state, + PropertyEntry property) + { + return state switch + { + EntityState.Added => (null, property.CurrentValue, true), + EntityState.Modified => GetModifiedValues(property), + EntityState.Deleted => (property.OriginalValue, null, true), + _ => (null, null, false) + }; + } + + private static (object? OldValue, object? NewValue, bool IsModified) GetModifiedValues(PropertyEntry property) + { + var oldVal = property.OriginalValue; + var newVal = property.CurrentValue; + var isModified = property.IsModified && !Equals(oldVal, newVal); + + return (oldVal, newVal, isModified); } private static string BuildKey(EntityEntry entry) { var keyProps = entry.Properties.Where(p => p.Metadata.IsPrimaryKey()).ToArray(); - if (keyProps.Length == 0) return $""; + if (keyProps.Length == 0) + { + return ""; + } + return string.Join("|", keyProps.Select(k => $"{k.Metadata.Name}:{k.CurrentValue ?? k.OriginalValue}")); } private static bool DetectSoftDelete(EntityEntry entry) { - // Convention: boolean property named "IsDeleted" flipped to true - var prop = entry.Properties.FirstOrDefault(p => p.Metadata.Name.Equals("IsDeleted", StringComparison.OrdinalIgnoreCase) - && p.Metadata.ClrType == typeof(bool)); - if (prop is null) return false; + var prop = entry.Properties.FirstOrDefault(p => + p.Metadata.Name.Equals("IsDeleted", StringComparison.OrdinalIgnoreCase) && + p.Metadata.ClrType == typeof(bool)); + + if (prop is null) + { + return false; + } + var orig = prop.OriginalValue as bool? ?? false; var curr = prop.CurrentValue as bool? ?? false; return !orig && curr; } - private static bool IsSensitive(string propertyName) - { - // Simple heuristic. Replace with attribute-based masking later. - return propertyName.Contains("password", StringComparison.OrdinalIgnoreCase) - || propertyName.Contains("secret", StringComparison.OrdinalIgnoreCase) - || propertyName.Contains("token", StringComparison.OrdinalIgnoreCase); - } + private static bool IsSensitive(string propertyName) => + propertyName.Contains("password", StringComparison.OrdinalIgnoreCase) || + propertyName.Contains("secret", StringComparison.OrdinalIgnoreCase) || + propertyName.Contains("token", StringComparison.OrdinalIgnoreCase); private static bool IsScalar(Type t) { t = Nullable.GetUnderlyingType(t) ?? t; - return t.IsPrimitive || t.IsEnum || t == typeof(string) || t == typeof(decimal) || - t == typeof(DateTime) || t == typeof(DateTimeOffset) || t == typeof(Guid) || - t == typeof(TimeSpan) || t == typeof(byte[]) || t == typeof(bool); + return t.IsPrimitive || t.IsEnum || ScalarTypes.Contains(t); } - private static string ToSimpleTypeName(Type t) - => (Nullable.GetUnderlyingType(t) ?? t).Name; + private static string ToSimpleTypeName(Type t) => + (Nullable.GetUnderlyingType(t) ?? t).Name; } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryHandler.cs index 7c820e31b5..70c79f5e50 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryHandler.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using FSH.Framework.Core.Context; using FSH.Framework.Persistence; using FSH.Framework.Shared.Persistence; @@ -107,6 +108,15 @@ public async ValueTask> Handle(SearchUsersQuery query, Ca }; } + private static readonly Dictionary>> SortableFields = new(StringComparer.OrdinalIgnoreCase) + { + ["firstname"] = u => u.FirstName, + ["lastname"] = u => u.LastName, + ["email"] = u => u.Email, + ["username"] = u => u.UserName, + ["isactive"] = u => u.IsActive + }; + private static IQueryable ApplySorting(IQueryable query, string? sort) { if (string.IsNullOrWhiteSpace(sort)) @@ -119,30 +129,44 @@ private static IQueryable ApplySorting(IQueryable query, strin foreach (var part in sortParts) { - var descending = part.StartsWith('-'); - var field = descending ? part[1..] : part; - - orderedQuery = (orderedQuery, field.ToLowerInvariant()) switch + var (field, descending) = ParseSortField(part); + + if (!SortableFields.TryGetValue(field, out var selector)) { - (null, "firstname") => descending ? query.OrderByDescending(u => u.FirstName) : query.OrderBy(u => u.FirstName), - (null, "lastname") => descending ? query.OrderByDescending(u => u.LastName) : query.OrderBy(u => u.LastName), - (null, "email") => descending ? query.OrderByDescending(u => u.Email) : query.OrderBy(u => u.Email), - (null, "username") => descending ? query.OrderByDescending(u => u.UserName) : query.OrderBy(u => u.UserName), - (null, "isactive") => descending ? query.OrderByDescending(u => u.IsActive) : query.OrderBy(u => u.IsActive), - (null, _) => query.OrderBy(u => u.FirstName), - - (not null, "firstname") => descending ? orderedQuery.ThenByDescending(u => u.FirstName) : orderedQuery.ThenBy(u => u.FirstName), - (not null, "lastname") => descending ? orderedQuery.ThenByDescending(u => u.LastName) : orderedQuery.ThenBy(u => u.LastName), - (not null, "email") => descending ? orderedQuery.ThenByDescending(u => u.Email) : orderedQuery.ThenBy(u => u.Email), - (not null, "username") => descending ? orderedQuery.ThenByDescending(u => u.UserName) : orderedQuery.ThenBy(u => u.UserName), - (not null, "isactive") => descending ? orderedQuery.ThenByDescending(u => u.IsActive) : orderedQuery.ThenBy(u => u.IsActive), - (not null, _) => orderedQuery.ThenBy(u => u.FirstName) - }; + selector = u => u.FirstName; // Default fallback + } + + orderedQuery = ApplySortExpression(query, orderedQuery, selector, descending); } return orderedQuery ?? query.OrderBy(u => u.FirstName); } + private static (string field, bool descending) ParseSortField(string part) + { + var descending = part.StartsWith('-'); + var field = descending ? part[1..] : part; + return (field, descending); + } + + private static IOrderedQueryable ApplySortExpression( + IQueryable query, + IOrderedQueryable? orderedQuery, + Expression> selector, + bool descending) + { + if (orderedQuery is null) + { + return descending + ? query.OrderByDescending(selector) + : query.OrderBy(selector); + } + + return descending + ? orderedQuery.ThenByDescending(selector) + : orderedQuery.ThenBy(selector); + } + private string? ResolveImageUrl(string? imageUrl) { if (string.IsNullOrWhiteSpace(imageUrl)) diff --git a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs index 360966525b..cc6787bc19 100644 --- a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs @@ -1,4 +1,4 @@ -using Finbuckle.MultiTenant.Abstractions; +using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Constants; using FSH.Framework.Shared.Multitenancy; @@ -37,103 +37,110 @@ public IdentityService( ArgumentNullException.ThrowIfNull(email); ArgumentNullException.ThrowIfNull(password); - var currentTenant = _multiTenantContextAccessor!.MultiTenantContext.TenantInfo; - if (currentTenant == null) throw new UnauthorizedException(); + var tenant = GetValidatedTenant(); + var user = await FindAndValidateUserByCredentialsAsync(email, password); - if (string.IsNullOrWhiteSpace(currentTenant.Id) - || await _userManager.FindByEmailAsync(email.Trim().Normalize()) is not { } user - || !await _userManager.CheckPasswordAsync(user, password)) - { - throw new UnauthorizedException(); - } + ValidateUserStatus(user); + ValidateTenantStatus(tenant); - if (!user.IsActive) - { - throw new UnauthorizedException("user is deactivated"); - } + var claims = await BuildUserClaimsAsync(user, tenant.Id, ct); + return (user.Id, claims); + } - if (!user.EmailConfirmed) - { - throw new UnauthorizedException("email not confirmed"); - } + public async Task<(string Subject, IEnumerable Claims)?> + ValidateRefreshTokenAsync(string refreshToken, CancellationToken ct = default) + { + var tenant = GetValidatedTenant(); + var user = await FindUserByRefreshTokenAsync(refreshToken, tenant.Id, ct); + + ValidateRefreshTokenExpiry(user); + ValidateUserStatus(user); + ValidateTenantStatus(tenant); + + var claims = await BuildUserClaimsAsync(user, tenant.Id, ct); + return (user.Id, claims); + } + + public async Task StoreRefreshTokenAsync(string subject, string refreshToken, DateTime expiresAtUtc, CancellationToken ct = default) + { + var tenant = GetValidatedTenant(); + var user = await _userManager.FindByIdAsync(subject) + ?? throw new UnauthorizedException("user not found"); - if (currentTenant.Id != MultitenancyConstants.Root.Id) + var hashedToken = HashToken(refreshToken); + user.RefreshToken = hashedToken; + user.RefreshTokenExpiryTime = expiresAtUtc; + + _logger.LogDebug( + "Storing refresh token for user {UserId} in tenant {TenantId}. Token hash: {TokenHash}, Expires: {ExpiresAt}", + subject, tenant.Id, hashedToken[..Math.Min(8, hashedToken.Length)] + "...", expiresAtUtc); + + var result = await _userManager.UpdateAsync(user); + if (!result.Succeeded) { - if (!currentTenant.IsActive) - { - throw new UnauthorizedException($"tenant {currentTenant.Id} is deactivated"); - } - - if (DateTime.UtcNow > currentTenant.ValidUpto) - { - throw new UnauthorizedException($"tenant {currentTenant.Id} validity has expired"); - } + _logger.LogError("Failed to persist refresh token for user {UserId}: {Errors}", + subject, string.Join(", ", result.Errors.Select(e => e.Description))); + throw new UnauthorizedException("could not persist refresh token"); } + } - // Build user claims - var claims = new List - { - new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - new(ClaimTypes.NameIdentifier, user.Id), - new(ClaimTypes.Email, user.Email!), - new(ClaimTypes.Name, user.FirstName ?? string.Empty), - new(ClaimTypes.MobilePhone, user.PhoneNumber ?? string.Empty), - new(ClaimConstants.Fullname, $"{user.FirstName} {user.LastName}"), - new(ClaimTypes.Surname, user.LastName ?? string.Empty), - new(ClaimConstants.Tenant, _multiTenantContextAccessor!.MultiTenantContext.TenantInfo!.Id), - new(ClaimConstants.ImageUrl, user.ImageUrl == null ? string.Empty : user.ImageUrl.ToString()) - }; - - // Add roles as claims (direct roles + group-derived roles) - var directRoles = await _userManager.GetRolesAsync(user); - var groupRoles = await _groupRoleService.GetUserGroupRolesAsync(user.Id, ct); + private AppTenantInfo GetValidatedTenant() + { + var tenant = _multiTenantContextAccessor!.MultiTenantContext.TenantInfo + ?? throw new UnauthorizedException(); - // Combine and deduplicate roles - var allRoles = directRoles.Union(groupRoles).Distinct(); - claims.AddRange(allRoles.Select(r => new Claim(ClaimTypes.Role, r))); + if (string.IsNullOrWhiteSpace(tenant.Id)) + { + throw new UnauthorizedException(); + } - return (user.Id, claims); + return tenant; } - public async Task<(string Subject, IEnumerable Claims)?> - ValidateRefreshTokenAsync(string refreshToken, CancellationToken ct = default) + private async Task FindAndValidateUserByCredentialsAsync(string email, string password) { - var currentTenant = _multiTenantContextAccessor!.MultiTenantContext.TenantInfo; - if (currentTenant == null) throw new UnauthorizedException(); - - if (string.IsNullOrWhiteSpace(currentTenant.Id)) + var user = await _userManager.FindByEmailAsync(email.Trim().Normalize()); + if (user is null || !await _userManager.CheckPasswordAsync(user, password)) { throw new UnauthorizedException(); } + return user; + } + + private async Task FindUserByRefreshTokenAsync(string refreshToken, string tenantId, CancellationToken ct) + { var hashedToken = HashToken(refreshToken); _logger.LogDebug( "Validating refresh token for tenant {TenantId}. Token hash: {TokenHash}", - currentTenant.Id, - hashedToken[..Math.Min(8, hashedToken.Length)] + "..."); + tenantId, hashedToken[..Math.Min(8, hashedToken.Length)] + "..."); var user = await _userManager.Users .FirstOrDefaultAsync(u => u.RefreshToken == hashedToken, ct); if (user is null) { - _logger.LogWarning( - "No user found with matching refresh token hash for tenant {TenantId}", - currentTenant.Id); + _logger.LogWarning("No user found with matching refresh token hash for tenant {TenantId}", tenantId); throw new UnauthorizedException("refresh token is invalid or expired"); } + return user; + } + + private void ValidateRefreshTokenExpiry(FshUser user) + { if (user.RefreshTokenExpiryTime <= DateTime.UtcNow) { _logger.LogWarning( "Refresh token expired for user {UserId}. Expired at: {ExpiryTime}, Current time: {CurrentTime}", - user.Id, - user.RefreshTokenExpiryTime, - DateTime.UtcNow); + user.Id, user.RefreshTokenExpiryTime, DateTime.UtcNow); throw new UnauthorizedException("refresh token is invalid or expired"); } + } + private static void ValidateUserStatus(FshUser user) + { if (!user.IsActive) { throw new UnauthorizedException("user is deactivated"); @@ -143,79 +150,53 @@ public IdentityService( { throw new UnauthorizedException("email not confirmed"); } - - if (currentTenant.Id != MultitenancyConstants.Root.Id) - { - if (!currentTenant.IsActive) - { - throw new UnauthorizedException($"tenant {currentTenant.Id} is deactivated"); - } - - if (DateTime.UtcNow > currentTenant.ValidUpto) - { - throw new UnauthorizedException($"tenant {currentTenant.Id} validity has expired"); - } - } - - var claims = new List - { - new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - new(ClaimTypes.NameIdentifier, user.Id), - new(ClaimTypes.Email, user.Email!), - new(ClaimTypes.Name, user.FirstName ?? string.Empty), - new(ClaimTypes.MobilePhone, user.PhoneNumber ?? string.Empty), - new(ClaimConstants.Fullname, $"{user.FirstName} {user.LastName}"), - new(ClaimTypes.Surname, user.LastName ?? string.Empty), - new(ClaimConstants.Tenant, _multiTenantContextAccessor!.MultiTenantContext.TenantInfo!.Id), - new(ClaimConstants.ImageUrl, user.ImageUrl == null ? string.Empty : user.ImageUrl.ToString()) - }; - - // Add roles as claims (direct roles + group-derived roles) - var directRoles = await _userManager.GetRolesAsync(user); - var groupRoles = await _groupRoleService.GetUserGroupRolesAsync(user.Id, ct); - - // Combine and deduplicate roles - var allRoles = directRoles.Union(groupRoles).Distinct(); - claims.AddRange(allRoles.Select(r => new Claim(ClaimTypes.Role, r))); - - return (user.Id, claims); } - public async Task StoreRefreshTokenAsync(string subject, string refreshToken, DateTime expiresAtUtc, CancellationToken ct = default) + private static void ValidateTenantStatus(AppTenantInfo tenant) { - var currentTenant = _multiTenantContextAccessor!.MultiTenantContext.TenantInfo; - if (currentTenant == null) throw new UnauthorizedException(); - - if (string.IsNullOrWhiteSpace(currentTenant.Id)) + if (tenant.Id == MultitenancyConstants.Root.Id) { - throw new UnauthorizedException(); + return; } - var user = await _userManager.FindByIdAsync(subject); - - if (user is null) + if (!tenant.IsActive) { - throw new UnauthorizedException("user not found"); + throw new UnauthorizedException($"tenant {tenant.Id} is deactivated"); } - var hashedToken = HashToken(refreshToken); - user.RefreshToken = hashedToken; - user.RefreshTokenExpiryTime = expiresAtUtc; + if (DateTime.UtcNow > tenant.ValidUpto) + { + throw new UnauthorizedException($"tenant {tenant.Id} validity has expired"); + } + } - _logger.LogDebug( - "Storing refresh token for user {UserId} in tenant {TenantId}. Token hash: {TokenHash}, Expires: {ExpiresAt}", - subject, - currentTenant.Id, - hashedToken[..Math.Min(8, hashedToken.Length)] + "...", - expiresAtUtc); + private async Task> BuildUserClaimsAsync(FshUser user, string tenantId, CancellationToken ct) + { + var claims = CreateBasicClaims(user, tenantId); + await AddRoleClaimsAsync(claims, user, ct); + return claims; + } - var result = await _userManager.UpdateAsync(user); + private List CreateBasicClaims(FshUser user, string tenantId) => + [ + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Email, user.Email!), + new(ClaimTypes.Name, user.FirstName ?? string.Empty), + new(ClaimTypes.MobilePhone, user.PhoneNumber ?? string.Empty), + new(ClaimConstants.Fullname, $"{user.FirstName} {user.LastName}"), + new(ClaimTypes.Surname, user.LastName ?? string.Empty), + new(ClaimConstants.Tenant, tenantId), + new(ClaimConstants.ImageUrl, user.ImageUrl?.ToString() ?? string.Empty) + ]; + + private async Task AddRoleClaimsAsync(List claims, FshUser user, CancellationToken ct) + { + var directRoles = await _userManager.GetRolesAsync(user); + var groupRoles = await _groupRoleService.GetUserGroupRolesAsync(user.Id, ct); - if (!result.Succeeded) - { - _logger.LogError("Failed to persist refresh token for user {UserId}: {Errors}", subject, string.Join(", ", result.Errors.Select(e => e.Description))); - throw new UnauthorizedException("could not persist refresh token"); - } + var allRoles = directRoles.Union(groupRoles).Distinct(); + claims.AddRange(allRoles.Select(r => new Claim(ClaimTypes.Role, r))); } private static string HashToken(string token) diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Lifecycle.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.Lifecycle.cs index 883902736b..5cf648492f 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.Lifecycle.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserService.Lifecycle.cs @@ -1,6 +1,7 @@ using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Constants; using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Identity.Domain; using Microsoft.EntityFrameworkCore; namespace FSH.Modules.Identity.Services; @@ -27,84 +28,102 @@ public async Task ToggleStatusAsync(bool activateUser, string userId, Cancellati { EnsureValidTenant(); + var context = await BuildToggleContextAsync(userId, activateUser, cancellationToken); + + await ValidateTogglePermissionsAsync(context, cancellationToken); + + ApplyStatusChange(context); + + await SaveAndAuditAsync(context, cancellationToken); + } + + private async Task BuildToggleContextAsync( + string userId, + bool activateUser, + CancellationToken cancellationToken) + { var actorId = _currentUser.GetUserId(); if (actorId == Guid.Empty) { throw new UnauthorizedException("authenticated user required to toggle status"); } - var actor = await userManager.FindByIdAsync(actorId.ToString()); - _ = actor ?? throw new UnauthorizedException("current user not found"); + var actor = await userManager.FindByIdAsync(actorId.ToString()) + ?? throw new UnauthorizedException("current user not found"); - async ValueTask AuditPolicyFailureAsync(string reason, CancellationToken ct) - { - var tenant = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id ?? "unknown"; - var claims = new Dictionary - { - ["actorId"] = actorId.ToString(), - ["targetUserId"] = userId, - ["tenant"] = tenant, - ["action"] = activateUser ? "activate" : "deactivate" - }; - - await _auditClient.WriteSecurityAsync( - SecurityAction.PolicyFailed, - subjectId: actorId.ToString(), - reasonCode: reason, - claims: claims, - severity: AuditSeverity.Warning, - source: "Identity", - ct: ct).ConfigureAwait(false); - } + var targetUser = await userManager.Users + .Where(u => u.Id == userId) + .FirstOrDefaultAsync(cancellationToken) + ?? throw new NotFoundException("User Not Found."); + + return new ToggleStatusContext( + ActorId: actorId, + Actor: actor, + TargetUser: targetUser, + ActivateUser: activateUser, + TenantId: multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id); + } - if (!await userManager.IsInRoleAsync(actor, RoleConstants.Admin)) + private async Task ValidateTogglePermissionsAsync( + ToggleStatusContext context, + CancellationToken cancellationToken) + { + if (!await userManager.IsInRoleAsync(context.Actor, RoleConstants.Admin)) { - await AuditPolicyFailureAsync("ActorNotAdmin", cancellationToken); + await AuditPolicyFailureAsync(context, "ActorNotAdmin", cancellationToken); throw new CustomException("Only administrators can toggle user status."); } - if (!activateUser && string.Equals(actor.Id, userId, StringComparison.Ordinal)) + if (!context.ActivateUser && context.ActorId.ToString() == context.TargetUser.Id) { - await AuditPolicyFailureAsync("SelfDeactivationBlocked", cancellationToken); + await AuditPolicyFailureAsync(context, "SelfDeactivationBlocked", cancellationToken); throw new CustomException("Users cannot deactivate themselves."); } - var user = await userManager.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(cancellationToken); - _ = user ?? throw new NotFoundException("User Not Found."); - - bool targetIsAdmin = await userManager.IsInRoleAsync(user, RoleConstants.Admin); - if (targetIsAdmin) + if (await userManager.IsInRoleAsync(context.TargetUser, RoleConstants.Admin)) { - await AuditPolicyFailureAsync("AdminDeactivationBlocked", cancellationToken); + await AuditPolicyFailureAsync(context, "AdminDeactivationBlocked", cancellationToken); throw new CustomException("Administrators cannot be deactivated."); } - if (!activateUser) + if (!context.ActivateUser) + { + await EnsureMinimumActiveAdminsAsync(context, cancellationToken); + } + } + + private async Task EnsureMinimumActiveAdminsAsync( + ToggleStatusContext context, + CancellationToken cancellationToken) + { + var activeAdmins = await userManager.GetUsersInRoleAsync(RoleConstants.Admin); + if (!activeAdmins.Any(u => u.IsActive)) { - var activeAdmins = await userManager.GetUsersInRoleAsync(RoleConstants.Admin); - int activeAdminCount = activeAdmins.Count(u => u.IsActive); - if (activeAdminCount == 0) - { - await AuditPolicyFailureAsync("NoActiveAdmins", cancellationToken); - throw new CustomException("Tenant must have at least one active administrator."); - } + await AuditPolicyFailureAsync(context, "NoActiveAdmins", cancellationToken); + throw new CustomException("Tenant must have at least one active administrator."); } + } - var tenantId = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; - if (activateUser) + private static void ApplyStatusChange(ToggleStatusContext context) + { + if (context.ActivateUser) { - user.Activate(actorId.ToString(), tenantId); + context.TargetUser.Activate(context.ActorId.ToString(), context.TenantId); } else { - user.Deactivate(actorId.ToString(), "Status toggled by administrator", tenantId); + context.TargetUser.Deactivate(context.ActorId.ToString(), "Status toggled by administrator", context.TenantId); } + } - var result = await userManager.UpdateAsync(user); + private async Task SaveAndAuditAsync( + ToggleStatusContext context, + CancellationToken cancellationToken) + { + var result = await userManager.UpdateAsync(context.TargetUser); if (!result.Succeeded) { - var errors = result.Errors.Select(error => error.Description).ToList(); - throw new CustomException("Toggle status failed", errors); + throw new CustomException("Toggle status failed", result.Errors.Select(e => e.Description).ToList()); } await _auditClient.WriteActivityAsync( @@ -115,10 +134,40 @@ await _auditClient.WriteActivityAsync( captured: BodyCapture.None, requestSize: 0, responseSize: 0, - requestPreview: new { actorId = actorId.ToString(), targetUserId = userId, action = activateUser ? "activate" : "deactivate", tenant = tenantId ?? "unknown" }, + requestPreview: new { actorId = context.ActorId.ToString(), targetUserId = context.TargetUser.Id, action = context.ActivateUser ? "activate" : "deactivate", tenant = context.TenantId ?? "unknown" }, responsePreview: new { outcome = "success" }, severity: AuditSeverity.Information, source: "Identity", ct: cancellationToken).ConfigureAwait(false); } + + private async Task AuditPolicyFailureAsync( + ToggleStatusContext context, + string reason, + CancellationToken cancellationToken) + { + var claims = new Dictionary + { + ["actorId"] = context.ActorId.ToString(), + ["targetUserId"] = context.TargetUser.Id, + ["tenant"] = context.TenantId ?? "unknown", + ["action"] = context.ActivateUser ? "activate" : "deactivate" + }; + + await _auditClient.WriteSecurityAsync( + SecurityAction.PolicyFailed, + subjectId: context.ActorId.ToString(), + reasonCode: reason, + claims: claims, + severity: AuditSeverity.Warning, + source: "Identity", + ct: cancellationToken).ConfigureAwait(false); + } + + private sealed record ToggleStatusContext( + Guid ActorId, + FshUser Actor, + FshUser TargetUser, + bool ActivateUser, + string? TenantId); } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Registration.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.Registration.cs index cb6d3f855e..f60edf4c01 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.Registration.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserService.Registration.cs @@ -20,44 +20,41 @@ public async Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principa EnsureValidTenant(); ArgumentNullException.ThrowIfNull(principal); - var email = principal.FindFirstValue(ClaimTypes.Email) - ?? principal.FindFirstValue("email") - ?? throw new CustomException("Email claim is required for external authentication."); + var email = ExtractEmailFromPrincipal(principal); - // Try to find existing user by email - var user = await userManager.FindByEmailAsync(email); - if (user is not null) + var existingUser = await userManager.FindByEmailAsync(email); + if (existingUser is not null) { - return user.Id; + return existingUser.Id; } - // Extract claims for new user creation - var firstName = principal.FindFirstValue(ClaimTypes.GivenName) - ?? principal.FindFirstValue("given_name") - ?? string.Empty; + var user = await CreateUserFromPrincipalAsync(principal, email); + await AssignDefaultRoleAndGroupsAsync(user, "ExternalAuth"); + await PublishUserRegisteredAsync(user, "Identity.ExternalAuth"); - var lastName = principal.FindFirstValue(ClaimTypes.Surname) - ?? principal.FindFirstValue("family_name") - ?? string.Empty; + return user.Id; + } - var userName = principal.FindFirstValue(ClaimTypes.Name) - ?? principal.FindFirstValue("preferred_username") - ?? email.Split('@')[0]; + private static string ExtractEmailFromPrincipal(ClaimsPrincipal principal) + { + return principal.FindFirstValue(ClaimTypes.Email) + ?? principal.FindFirstValue("email") + ?? throw new CustomException("Email claim is required for external authentication."); + } - // Ensure unique username - if (await userManager.FindByNameAsync(userName) is not null) - { - userName = $"{userName}_{Guid.NewGuid():N}"[..20]; - } + private async Task CreateUserFromPrincipalAsync(ClaimsPrincipal principal, string email) + { + var (firstName, lastName, userName) = ExtractUserInfoFromPrincipal(principal, email); + + userName = await EnsureUniqueUserNameAsync(userName); - // Create new user from external principal - user = new FshUser + var user = new FshUser { Email = email, UserName = userName, FirstName = firstName, LastName = lastName, - EmailConfirmed = true, // External provider has verified the email + EmailConfirmed = true, PhoneNumberConfirmed = false, IsActive = true }; @@ -69,48 +66,73 @@ public async Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principa throw new CustomException("Failed to create user from external principal.", errors); } - // Assign basic role - await userManager.AddToRoleAsync(user, RoleConstants.Basic); + return user; + } - // Add to default groups - var defaultGroups = await db.Groups - .Where(g => g.IsDefault && !g.IsDeleted) - .ToListAsync(); + private static (string firstName, string lastName, string userName) ExtractUserInfoFromPrincipal( + ClaimsPrincipal principal, string email) + { + var firstName = principal.FindFirstValue(ClaimTypes.GivenName) + ?? principal.FindFirstValue("given_name") + ?? string.Empty; - foreach (var group in defaultGroups) - { - db.UserGroups.Add(UserGroup.Create(user.Id, group.Id, "ExternalAuth")); - } + var lastName = principal.FindFirstValue(ClaimTypes.Surname) + ?? principal.FindFirstValue("family_name") + ?? string.Empty; - // Raise domain event for user registration - var tenantId = multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id; - user.RecordRegistered(tenantId); + var userName = principal.FindFirstValue(ClaimTypes.Name) + ?? principal.FindFirstValue("preferred_username") + ?? email.Split('@')[0]; - // Save to dispatch domain event via interceptor - await db.SaveChangesAsync(); + return (firstName, lastName, userName); + } - // Publish integration event - var integrationEvent = new UserRegisteredIntegrationEvent( - Id: Guid.NewGuid(), - OccurredOnUtc: DateTime.UtcNow, - TenantId: tenantId, - CorrelationId: Guid.NewGuid().ToString(), - Source: "Identity.ExternalAuth", - UserId: user.Id, - Email: user.Email ?? string.Empty, - FirstName: user.FirstName ?? string.Empty, - LastName: user.LastName ?? string.Empty); + private async Task EnsureUniqueUserNameAsync(string userName) + { + if (await userManager.FindByNameAsync(userName) is not null) + { + return $"{userName}_{Guid.NewGuid():N}"[..20]; + } + return userName; + } - await outboxStore.AddAsync(integrationEvent).ConfigureAwait(false); + public async Task RegisterAsync( + string firstName, + string lastName, + string email, + string userName, + string password, + string confirmPassword, + string phoneNumber, + string origin, + CancellationToken cancellationToken) + { + ValidatePasswordMatch(password, confirmPassword); + + var user = await CreateUserWithPasswordAsync(firstName, lastName, email, userName, password, phoneNumber); + await AssignDefaultRoleAndGroupsAsync(user, "System", cancellationToken); + await SendConfirmationEmailAsync(user, origin, cancellationToken); + await PublishUserRegisteredAsync(user, "Identity", cancellationToken); return user.Id; } - public async Task RegisterAsync(string firstName, string lastName, string email, string userName, string password, string confirmPassword, string phoneNumber, string origin, CancellationToken cancellationToken) + private static void ValidatePasswordMatch(string password, string confirmPassword) { - if (password != confirmPassword) throw new CustomException("password mismatch."); + if (password != confirmPassword) + { + throw new CustomException("password mismatch."); + } + } - // create user entity + private async Task CreateUserWithPasswordAsync( + string firstName, + string lastName, + string email, + string userName, + string password, + string phoneNumber) + { var user = new FshUser { Email = email, @@ -123,7 +145,6 @@ public async Task RegisterAsync(string firstName, string lastName, strin PhoneNumberConfirmed = false, }; - // register user var result = await userManager.CreateAsync(user, password); if (!result.Succeeded) { @@ -131,59 +152,71 @@ public async Task RegisterAsync(string firstName, string lastName, strin throw new CustomException("error while registering a new user", errors); } - // add basic role + return user; + } + + private async Task AssignDefaultRoleAndGroupsAsync( + FshUser user, + string source, + CancellationToken cancellationToken = default) + { await userManager.AddToRoleAsync(user, RoleConstants.Basic); - // add user to default groups var defaultGroups = await db.Groups .Where(g => g.IsDefault && !g.IsDeleted) .ToListAsync(cancellationToken); foreach (var group in defaultGroups) { - db.UserGroups.Add(UserGroup.Create(user.Id, group.Id, "System")); + db.UserGroups.Add(UserGroup.Create(user.Id, group.Id, source)); } if (defaultGroups.Count > 0) { await db.SaveChangesAsync(cancellationToken); } + } - // send confirmation mail - if (!string.IsNullOrEmpty(user.Email)) + private async Task SendConfirmationEmailAsync(FshUser user, string origin, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(user.Email)) { - string emailVerificationUri = await GetEmailVerificationUriAsync(user, origin); - string emailBody = BuildConfirmationEmailHtml(user.FirstName ?? user.UserName ?? "User", emailVerificationUri); - var mailRequest = new MailRequest( - new Collection { user.Email }, - "Confirm Your Email Address", - emailBody); - jobService.Enqueue("email", () => mailService.SendAsync(mailRequest, cancellationToken)); + return; } - // Raise domain event for user registration + string emailVerificationUri = await GetEmailVerificationUriAsync(user, origin); + string emailBody = BuildConfirmationEmailHtml(user.FirstName ?? user.UserName ?? "User", emailVerificationUri); + + var mailRequest = new MailRequest( + new Collection { user.Email }, + "Confirm Your Email Address", + emailBody); + + jobService.Enqueue("email", () => mailService.SendAsync(mailRequest, cancellationToken)); + } + + private async Task PublishUserRegisteredAsync( + FshUser user, + string source, + CancellationToken cancellationToken = default) + { var tenantId = multiTenantContextAccessor.MultiTenantContext.TenantInfo?.Id; user.RecordRegistered(tenantId); - // Save to dispatch domain event via interceptor await db.SaveChangesAsync(cancellationToken); - // enqueue integration event for user registration - var correlationId = Guid.NewGuid().ToString(); var integrationEvent = new UserRegisteredIntegrationEvent( Id: Guid.NewGuid(), OccurredOnUtc: DateTime.UtcNow, TenantId: tenantId, - CorrelationId: correlationId, - Source: "Identity", + CorrelationId: Guid.NewGuid().ToString(), + Source: source, UserId: user.Id, Email: user.Email ?? string.Empty, FirstName: user.FirstName ?? string.Empty, LastName: user.LastName ?? string.Empty); await outboxStore.AddAsync(integrationEvent, cancellationToken).ConfigureAwait(false); - - return user.Id; } private async Task GetEmailVerificationUriAsync(FshUser user, string origin) @@ -192,13 +225,17 @@ private async Task GetEmailVerificationUriAsync(FshUser user, string ori string code = await userManager.GenerateEmailConfirmationTokenAsync(user); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + const string route = "api/v1/identity/confirm-email"; var endpointUri = new Uri(string.Concat($"{origin}/", route)); + string verificationUri = QueryHelpers.AddQueryString(endpointUri.ToString(), QueryStringKeys.UserId, user.Id); verificationUri = QueryHelpers.AddQueryString(verificationUri, QueryStringKeys.Code, code); - verificationUri = QueryHelpers.AddQueryString(verificationUri, + verificationUri = QueryHelpers.AddQueryString( + verificationUri, MultitenancyConstants.Identifier, multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id!); + return verificationUri; } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Roles.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.Roles.cs index 962302e057..2d80ddaf5a 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.Roles.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserService.Roles.cs @@ -10,74 +10,106 @@ internal sealed partial class UserService { public async Task AssignRolesAsync(string userId, List userRoles, CancellationToken cancellationToken) { - var user = await userManager.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(cancellationToken); + var user = await userManager.Users + .Where(u => u.Id == userId) + .FirstOrDefaultAsync(cancellationToken) + ?? throw new NotFoundException("user not found"); - _ = user ?? throw new NotFoundException("user not found"); + await ValidateAdminRoleChangeAsync(user, userRoles); - // Check if the user is an admin for which the admin role is getting disabled - if (await userManager.IsInRoleAsync(user, RoleConstants.Admin) - && userRoles.Exists(a => !a.Enabled && a.RoleName == RoleConstants.Admin)) + var assignedRoles = await ProcessRoleAssignmentsAsync(user, userRoles); + + await RaiseRolesAssignedEventAsync(user, assignedRoles, cancellationToken); + + return "User Roles Updated Successfully."; + } + + private async Task ValidateAdminRoleChangeAsync(Domain.FshUser user, List userRoles) + { + bool isRemovingAdminRole = userRoles.Exists(a => !a.Enabled && a.RoleName == RoleConstants.Admin); + if (!isRemovingAdminRole) { - // Get count of users in Admin Role - int adminCount = (await userManager.GetUsersInRoleAsync(RoleConstants.Admin)).Count; + return; + } - // Check if user is not Root Tenant Admin - // Edge Case : there are chances for other tenants to have users with the same email as that of Root Tenant Admin. Probably can add a check while User Registration - if (user.Email == MultitenancyConstants.Root.EmailAddress) - { - if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id == MultitenancyConstants.Root.Id) - { - throw new CustomException("action not permitted"); - } - } - else if (adminCount <= 2) - { - throw new CustomException("tenant should have at least 2 admins."); - } + bool userIsAdmin = await userManager.IsInRoleAsync(user, RoleConstants.Admin); + if (!userIsAdmin) + { + return; + } + + if (IsRootTenantAdmin(user)) + { + throw new CustomException("action not permitted"); } + await EnsureMinimumAdminCountAsync(); + } + + private bool IsRootTenantAdmin(Domain.FshUser user) + { + return user.Email == MultitenancyConstants.Root.EmailAddress + && multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id == MultitenancyConstants.Root.Id; + } + + private async Task EnsureMinimumAdminCountAsync() + { + int adminCount = (await userManager.GetUsersInRoleAsync(RoleConstants.Admin)).Count; + if (adminCount <= 2) + { + throw new CustomException("tenant should have at least 2 admins."); + } + } + + private async Task> ProcessRoleAssignmentsAsync(Domain.FshUser user, List userRoles) + { var assignedRoles = new List(); foreach (var userRole in userRoles) { - // Check if Role Exists - if (await roleManager.FindByNameAsync(userRole.RoleName!) is not null) + if (await roleManager.FindByNameAsync(userRole.RoleName!) is null) { - if (userRole.Enabled) - { - if (!await userManager.IsInRoleAsync(user, userRole.RoleName!)) - { - await userManager.AddToRoleAsync(user, userRole.RoleName!); - assignedRoles.Add(userRole.RoleName!); - } - } - else + continue; + } + + if (userRole.Enabled) + { + if (!await userManager.IsInRoleAsync(user, userRole.RoleName!)) { - await userManager.RemoveFromRoleAsync(user, userRole.RoleName!); + await userManager.AddToRoleAsync(user, userRole.RoleName!); + assignedRoles.Add(userRole.RoleName!); } } + else + { + await userManager.RemoveFromRoleAsync(user, userRole.RoleName!); + } } - // Raise domain event for newly assigned roles - if (assignedRoles.Count > 0) + return assignedRoles; + } + + private async Task RaiseRolesAssignedEventAsync(Domain.FshUser user, List assignedRoles, CancellationToken cancellationToken) + { + if (assignedRoles.Count == 0) { - var tenantId = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; - user.RecordRolesAssigned(assignedRoles, tenantId); - await db.SaveChangesAsync(cancellationToken); + return; } - return "User Roles Updated Successfully."; - + var tenantId = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; + user.RecordRolesAssigned(assignedRoles, tenantId); + await db.SaveChangesAsync(cancellationToken); } public async Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken) { - var userRoles = new List(); + var user = await userManager.FindByIdAsync(userId) + ?? throw new NotFoundException("user not found"); - var user = await userManager.FindByIdAsync(userId); - if (user is null) throw new NotFoundException("user not found"); - var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken); - if (roles is null) throw new NotFoundException("roles not found"); + var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken) + ?? throw new NotFoundException("roles not found"); + + var userRoles = new List(); foreach (var role in roles) { userRoles.Add(new UserRoleDto diff --git a/src/Playground/Playground.Blazor/Services/Api/TokenRefreshService.cs b/src/Playground/Playground.Blazor/Services/Api/TokenRefreshService.cs index 84808d3239..085689789c 100644 --- a/src/Playground/Playground.Blazor/Services/Api/TokenRefreshService.cs +++ b/src/Playground/Playground.Blazor/Services/Api/TokenRefreshService.cs @@ -7,14 +7,9 @@ namespace FSH.Playground.Blazor.Services.Api; /// /// Service responsible for refreshing expired access tokens using the refresh token. -/// This service handles the token refresh flow when a 401 response is received. /// internal interface ITokenRefreshService { - /// - /// Attempts to refresh the access token using the stored refresh token. - /// - /// The new access token if refresh succeeded, null otherwise. Task TryRefreshTokenAsync(CancellationToken cancellationToken = default); } @@ -25,24 +20,15 @@ internal sealed class TokenRefreshService : ITokenRefreshService, IDisposable private readonly ICircuitTokenCache _circuitTokenCache; private readonly ILogger _logger; - // Static lock and cache shared across all scoped instances - // This is critical because the service is registered as Scoped, - // but we need to coordinate across all concurrent requests private static readonly SemaphoreSlim RefreshLock = new(1, 1); + private static readonly TimeSpan RefreshCacheDuration = TimeSpan.FromSeconds(30); + private static readonly TimeSpan FailedTokenCacheDuration = TimeSpan.FromMinutes(5); - // Cache the last refreshed token to prevent race conditions - // When multiple concurrent requests detect token expiration, only the first - // should actually refresh - others should use the cached result private static string? _lastRefreshedToken; - private static string? _cachedForRefreshToken; // The refresh token we used to get the cached access token + private static string? _cachedForRefreshToken; private static DateTime _lastRefreshTime = DateTime.MinValue; - private static readonly TimeSpan RefreshCacheDuration = TimeSpan.FromSeconds(30); - - // Track failed refresh tokens to prevent endless retry loops - // When a refresh token fails with 401, we mark it as failed so we don't keep retrying private static string? _failedRefreshToken; private static DateTime _failedRefreshTime = DateTime.MinValue; - private static readonly TimeSpan FailedTokenCacheDuration = TimeSpan.FromMinutes(5); public TokenRefreshService( IHttpContextAccessor httpContextAccessor, @@ -65,41 +51,56 @@ public TokenRefreshService( return null; } - // Get current refresh token - check circuit cache first, then fall back to claims - // Circuit cache is critical because claims are stale after refresh with token rotation - var circuitRefreshToken = _circuitTokenCache.RefreshToken; - var claimsRefreshToken = httpContext.User?.FindFirst("refresh_token")?.Value; - - var currentRefreshToken = !string.IsNullOrEmpty(circuitRefreshToken) - ? circuitRefreshToken - : claimsRefreshToken; - + var currentRefreshToken = GetCurrentRefreshToken(httpContext); if (string.IsNullOrEmpty(currentRefreshToken)) { _logger.LogDebug("No refresh token available"); return null; } - // FAIL-FAST: Check if this refresh token already failed recently - // This prevents endless retry loops when the token is invalid - if (_failedRefreshToken == currentRefreshToken && - DateTime.UtcNow - _failedRefreshTime < FailedTokenCacheDuration) + if (IsTokenRecentlyFailed(currentRefreshToken)) { _logger.LogDebug("Skipping refresh - token already failed recently"); return null; } - // FAST PATH: Check cache BEFORE acquiring lock - // Only use cache if it was created for the SAME refresh token (same session) - // This prevents stale cache from previous login sessions + var cachedToken = TryGetCachedToken(currentRefreshToken); + if (cachedToken is not null) + { + return cachedToken; + } + + return await RefreshWithLockAsync(httpContext, currentRefreshToken, cancellationToken); + } + + private string? GetCurrentRefreshToken(HttpContext httpContext) + { + var circuitRefreshToken = _circuitTokenCache.RefreshToken; + var claimsRefreshToken = httpContext.User?.FindFirst("refresh_token")?.Value; + + return !string.IsNullOrEmpty(circuitRefreshToken) ? circuitRefreshToken : claimsRefreshToken; + } + + private static bool IsTokenRecentlyFailed(string refreshToken) => + _failedRefreshToken == refreshToken && + DateTime.UtcNow - _failedRefreshTime < FailedTokenCacheDuration; + + private static string? TryGetCachedToken(string currentRefreshToken) + { if (_lastRefreshedToken is not null && _cachedForRefreshToken == currentRefreshToken && DateTime.UtcNow - _lastRefreshTime < RefreshCacheDuration) { return _lastRefreshedToken; } + return null; + } - // Prevent concurrent refresh attempts + private async Task RefreshWithLockAsync( + HttpContext httpContext, + string currentRefreshToken, + CancellationToken cancellationToken) + { if (!await RefreshLock.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken)) { _logger.LogWarning("Token refresh lock acquisition timed out"); @@ -108,129 +109,56 @@ public TokenRefreshService( try { - // SLOW PATH: Re-check cache after acquiring lock - // Another caller might have completed refresh while we were waiting - if (_lastRefreshedToken is not null && - _cachedForRefreshToken == currentRefreshToken && - DateTime.UtcNow - _lastRefreshTime < RefreshCacheDuration) + // Re-check cache after acquiring lock + var cachedToken = TryGetCachedToken(currentRefreshToken); + if (cachedToken is not null) { - return _lastRefreshedToken; + return cachedToken; } - var user = httpContext.User; - if (user?.Identity?.IsAuthenticated != true) - { - return null; - } - - // Get tokens - prefer circuit cache over claims (claims are stale in Blazor circuits) - var currentAccessToken = !string.IsNullOrEmpty(_circuitTokenCache.AccessToken) - ? _circuitTokenCache.AccessToken - : user.FindFirst("access_token")?.Value; - - var refreshToken = !string.IsNullOrEmpty(_circuitTokenCache.RefreshToken) - ? _circuitTokenCache.RefreshToken - : user.FindFirst("refresh_token")?.Value; + return await ExecuteRefreshAsync(httpContext, currentRefreshToken, cancellationToken); + } + finally + { + RefreshLock.Release(); + } + } - var tenant = user.FindFirst("tenant")?.Value ?? "root"; + private async Task ExecuteRefreshAsync( + HttpContext httpContext, + string currentRefreshToken, + CancellationToken cancellationToken) + { + var user = httpContext.User; + if (user?.Identity?.IsAuthenticated != true) + { + return null; + } - if (string.IsNullOrEmpty(refreshToken) || string.IsNullOrEmpty(currentAccessToken)) - { - return null; - } + var tokens = GetCurrentTokens(user); + if (tokens is null) + { + return null; + } - // Call the refresh token API - var refreshResponse = await _tokenClient.RefreshAsync( - tenant, - new RefreshTokenCommand - { - Token = currentAccessToken, - RefreshToken = refreshToken - }, - cancellationToken); - - if (refreshResponse is null || string.IsNullOrEmpty(refreshResponse.Token)) + try + { + var refreshResponse = await CallRefreshApiAsync(tokens.Value, cancellationToken); + if (refreshResponse is null) { - _logger.LogWarning("Token refresh returned empty response"); return null; } - // Parse the new JWT to extract claims - var jwtHandler = new JwtSecurityTokenHandler(); - var jwtToken = jwtHandler.ReadJwtToken(refreshResponse.Token); - - // Build new claims list with updated tokens - var newClaims = new List - { - new(ClaimTypes.NameIdentifier, jwtToken.Subject ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? Guid.NewGuid().ToString()), - new(ClaimTypes.Email, user.FindFirst(ClaimTypes.Email)?.Value ?? string.Empty), - new("access_token", refreshResponse.Token), - new("refresh_token", refreshResponse.RefreshToken), - new("tenant", tenant), - }; - - // Preserve name claim - var nameClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "name" || c.Type == ClaimTypes.Name); - if (nameClaim != null) - { - newClaims.Add(new Claim(ClaimTypes.Name, nameClaim.Value)); - } - - // Preserve role claims - var roleClaims = jwtToken.Claims.Where(c => c.Type == "role" || c.Type == ClaimTypes.Role); - newClaims.AddRange(roleClaims.Select(r => new Claim(ClaimTypes.Role, r.Value))); - - // CRITICAL: Update circuit-scoped cache FIRST, before SignInAsync - // SignInAsync will fail in Blazor Server SignalR context ("Headers are read-only") - // but the circuit cache will allow subsequent requests to use the new token - _circuitTokenCache.UpdateTokens(refreshResponse.Token, refreshResponse.RefreshToken); - - // Cache the refreshed token to prevent race conditions across circuits - // Store the OLD refresh token (before rotation) so concurrent callers with the same token can use cache - // Intentionally updating static fields from instance method - coordinating across scoped instances -#pragma warning disable S2696 // Instance members should not write to static fields - _lastRefreshedToken = refreshResponse.Token; - _cachedForRefreshToken = refreshToken; // The refresh token we used (before rotation) - _lastRefreshTime = DateTime.UtcNow; -#pragma warning restore S2696 + var newClaims = BuildNewClaims(user, refreshResponse); + UpdateCaches(refreshResponse, currentRefreshToken); + await TryUpdateCookieAsync(httpContext, newClaims); _logger.LogInformation("Access token refreshed successfully"); - - // Try to update the cookie for future page loads - // This will fail in Blazor Server SignalR context, which is expected - try - { - var identity = new ClaimsIdentity(newClaims, "Cookies"); - var principal = new ClaimsPrincipal(identity); - - await httpContext.SignInAsync("Cookies", principal, new AuthenticationProperties - { - IsPersistent = true, - ExpiresUtc = DateTimeOffset.UtcNow.AddDays(7) - }); - } - catch (InvalidOperationException) - { - // Expected in Blazor Server SignalR context - response has already started - // The circuit cache has the new tokens, so subsequent requests will work - } - return refreshResponse.Token; } catch (ApiException ex) when (ex.StatusCode == 401) { - // Clear circuit cache - _circuitTokenCache.Clear(); - - // Clear static cache and mark this refresh token as failed to prevent retry loops -#pragma warning disable S2696 // Instance members should not write to static fields - _lastRefreshedToken = null; - _cachedForRefreshToken = null; - _lastRefreshTime = DateTime.MinValue; - _failedRefreshToken = currentRefreshToken; // Mark as failed - _failedRefreshTime = DateTime.UtcNow; -#pragma warning restore S2696 - _logger.LogWarning(ex, "Refresh token is invalid or expired, user needs to re-authenticate"); + HandleRefreshFailure(currentRefreshToken, ex); return null; } catch (Exception ex) @@ -238,15 +166,133 @@ public TokenRefreshService( _logger.LogError(ex, "Failed to refresh access token"); return null; } - finally + } + + private (string AccessToken, string RefreshToken, string Tenant)? GetCurrentTokens(ClaimsPrincipal user) + { + var currentAccessToken = !string.IsNullOrEmpty(_circuitTokenCache.AccessToken) + ? _circuitTokenCache.AccessToken + : user.FindFirst("access_token")?.Value; + + var refreshToken = !string.IsNullOrEmpty(_circuitTokenCache.RefreshToken) + ? _circuitTokenCache.RefreshToken + : user.FindFirst("refresh_token")?.Value; + + var tenant = user.FindFirst("tenant")?.Value ?? "root"; + + if (string.IsNullOrEmpty(refreshToken) || string.IsNullOrEmpty(currentAccessToken)) { - RefreshLock.Release(); + return null; } + + return (currentAccessToken, refreshToken, tenant); + } + + private async Task CallRefreshApiAsync( + (string AccessToken, string RefreshToken, string Tenant) tokens, + CancellationToken cancellationToken) + { + var refreshResponse = await _tokenClient.RefreshAsync( + tokens.Tenant, + new RefreshTokenCommand + { + Token = tokens.AccessToken, + RefreshToken = tokens.RefreshToken + }, + cancellationToken); + + if (refreshResponse is null || string.IsNullOrEmpty(refreshResponse.Token)) + { + _logger.LogWarning("Token refresh returned empty response"); + return null; + } + + return refreshResponse; + } + + private static List BuildNewClaims(ClaimsPrincipal user, RefreshTokenCommandResponse response) + { + var jwtHandler = new JwtSecurityTokenHandler(); + var jwtToken = jwtHandler.ReadJwtToken(response.Token); + var tenant = user.FindFirst("tenant")?.Value ?? "root"; + + var newClaims = new List + { + new(ClaimTypes.NameIdentifier, jwtToken.Subject ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? Guid.NewGuid().ToString()), + new(ClaimTypes.Email, user.FindFirst(ClaimTypes.Email)?.Value ?? string.Empty), + new("access_token", response.Token), + new("refresh_token", response.RefreshToken), + new("tenant", tenant), + }; + + AddNameClaim(newClaims, jwtToken); + AddRoleClaims(newClaims, jwtToken); + + return newClaims; + } + + private static void AddNameClaim(List claims, JwtSecurityToken jwtToken) + { + var nameClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "name" || c.Type == ClaimTypes.Name); + if (nameClaim != null) + { + claims.Add(new Claim(ClaimTypes.Name, nameClaim.Value)); + } + } + + private static void AddRoleClaims(List claims, JwtSecurityToken jwtToken) + { + var roleClaims = jwtToken.Claims.Where(c => c.Type == "role" || c.Type == ClaimTypes.Role); + claims.AddRange(roleClaims.Select(r => new Claim(ClaimTypes.Role, r.Value))); + } + + private void UpdateCaches(RefreshTokenCommandResponse response, string oldRefreshToken) + { + _circuitTokenCache.UpdateTokens(response.Token, response.RefreshToken); + +#pragma warning disable S2696 + _lastRefreshedToken = response.Token; + _cachedForRefreshToken = oldRefreshToken; + _lastRefreshTime = DateTime.UtcNow; +#pragma warning restore S2696 + } + + private static async Task TryUpdateCookieAsync(HttpContext httpContext, List newClaims) + { + try + { + var identity = new ClaimsIdentity(newClaims, "Cookies"); + var principal = new ClaimsPrincipal(identity); + + await httpContext.SignInAsync("Cookies", principal, new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddDays(7) + }); + } + catch (InvalidOperationException) + { + // Expected in Blazor Server SignalR context + } + } + + private void HandleRefreshFailure(string currentRefreshToken, ApiException ex) + { + _circuitTokenCache.Clear(); + +#pragma warning disable S2696 + _lastRefreshedToken = null; + _cachedForRefreshToken = null; + _lastRefreshTime = DateTime.MinValue; + _failedRefreshToken = currentRefreshToken; + _failedRefreshTime = DateTime.UtcNow; +#pragma warning restore S2696 + + _logger.LogWarning(ex, "Refresh token is invalid or expired, user needs to re-authenticate"); } public void Dispose() { // Static semaphore should not be disposed by individual instances - // It's shared across all scoped instances for the app lifetime } } diff --git a/src/Playground/Playground.Blazor/Services/TenantThemeState.cs b/src/Playground/Playground.Blazor/Services/TenantThemeState.cs index 3563e96a98..24c2ee5d55 100644 --- a/src/Playground/Playground.Blazor/Services/TenantThemeState.cs +++ b/src/Playground/Playground.Blazor/Services/TenantThemeState.cs @@ -30,7 +30,6 @@ public TenantThemeState(HttpClient httpClient, ILogger logger, } public TenantThemeSettings Current => _current; - public MudTheme Theme => _theme; public bool IsDarkMode @@ -102,140 +101,139 @@ public void UpdateSettings(TenantThemeSettings settings) OnThemeChanged?.Invoke(); } - public void ToggleDarkMode() - { - IsDarkMode = !IsDarkMode; - } + public void ToggleDarkMode() => IsDarkMode = !IsDarkMode; private TenantThemeSettings MapFromDto(TenantThemeApiDto dto) { return new TenantThemeSettings { - LightPalette = new PaletteSettings - { - Primary = dto.LightPalette?.Primary ?? "#2563EB", - Secondary = dto.LightPalette?.Secondary ?? "#0F172A", - Tertiary = dto.LightPalette?.Tertiary ?? "#6366F1", - Background = dto.LightPalette?.Background ?? "#F8FAFC", - Surface = dto.LightPalette?.Surface ?? "#FFFFFF", - Error = dto.LightPalette?.Error ?? "#DC2626", - Warning = dto.LightPalette?.Warning ?? "#F59E0B", - Success = dto.LightPalette?.Success ?? "#16A34A", - Info = dto.LightPalette?.Info ?? "#0284C7" - }, - DarkPalette = new PaletteSettings - { - Primary = dto.DarkPalette?.Primary ?? "#38BDF8", - Secondary = dto.DarkPalette?.Secondary ?? "#94A3B8", - Tertiary = dto.DarkPalette?.Tertiary ?? "#818CF8", - Background = dto.DarkPalette?.Background ?? "#0B1220", - Surface = dto.DarkPalette?.Surface ?? "#111827", - Error = dto.DarkPalette?.Error ?? "#F87171", - Warning = dto.DarkPalette?.Warning ?? "#FBBF24", - Success = dto.DarkPalette?.Success ?? "#22C55E", - Info = dto.DarkPalette?.Info ?? "#38BDF8" - }, - BrandAssets = new BrandAssets - { - LogoUrl = ToAbsoluteUrl(dto.BrandAssets?.LogoUrl), - LogoDarkUrl = ToAbsoluteUrl(dto.BrandAssets?.LogoDarkUrl), - FaviconUrl = ToAbsoluteUrl(dto.BrandAssets?.FaviconUrl) - }, - Typography = new TypographySettings - { - FontFamily = dto.Typography?.FontFamily ?? "Inter, sans-serif", - HeadingFontFamily = dto.Typography?.HeadingFontFamily ?? "Inter, sans-serif", - FontSizeBase = dto.Typography?.FontSizeBase ?? 14, - LineHeightBase = dto.Typography?.LineHeightBase ?? 1.5 - }, - Layout = new LayoutSettings - { - BorderRadius = dto.Layout?.BorderRadius ?? "4px", - DefaultElevation = dto.Layout?.DefaultElevation ?? 1 - }, + LightPalette = MapLightPalette(dto.LightPalette), + DarkPalette = MapDarkPalette(dto.DarkPalette), + BrandAssets = MapBrandAssets(dto.BrandAssets), + Typography = MapTypography(dto.Typography), + Layout = MapLayout(dto.Layout), IsDefault = dto.IsDefault }; } + private static PaletteSettings MapLightPalette(PaletteApiDto? dto) => new() + { + Primary = dto?.Primary ?? "#2563EB", + Secondary = dto?.Secondary ?? "#0F172A", + Tertiary = dto?.Tertiary ?? "#6366F1", + Background = dto?.Background ?? "#F8FAFC", + Surface = dto?.Surface ?? "#FFFFFF", + Error = dto?.Error ?? "#DC2626", + Warning = dto?.Warning ?? "#F59E0B", + Success = dto?.Success ?? "#16A34A", + Info = dto?.Info ?? "#0284C7" + }; + + private static PaletteSettings MapDarkPalette(PaletteApiDto? dto) => new() + { + Primary = dto?.Primary ?? "#38BDF8", + Secondary = dto?.Secondary ?? "#94A3B8", + Tertiary = dto?.Tertiary ?? "#818CF8", + Background = dto?.Background ?? "#0B1220", + Surface = dto?.Surface ?? "#111827", + Error = dto?.Error ?? "#F87171", + Warning = dto?.Warning ?? "#FBBF24", + Success = dto?.Success ?? "#22C55E", + Info = dto?.Info ?? "#38BDF8" + }; + + private BrandAssets MapBrandAssets(BrandAssetsApiDto? dto) => new() + { + LogoUrl = ToAbsoluteUrl(dto?.LogoUrl), + LogoDarkUrl = ToAbsoluteUrl(dto?.LogoDarkUrl), + FaviconUrl = ToAbsoluteUrl(dto?.FaviconUrl) + }; + + private static TypographySettings MapTypography(TypographyApiDto? dto) => new() + { + FontFamily = dto?.FontFamily ?? "Inter, sans-serif", + HeadingFontFamily = dto?.HeadingFontFamily ?? "Inter, sans-serif", + FontSizeBase = dto?.FontSizeBase ?? 14, + LineHeightBase = dto?.LineHeightBase ?? 1.5 + }; + + private static LayoutSettings MapLayout(LayoutApiDto? dto) => new() + { + BorderRadius = dto?.BorderRadius ?? "4px", + DefaultElevation = dto?.DefaultElevation ?? 1 + }; + private string? ToAbsoluteUrl(string? relativeUrl) { if (string.IsNullOrEmpty(relativeUrl)) return null; - // Already absolute URL or data URL - if (relativeUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - relativeUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || - relativeUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) - { + if (IsAbsoluteUrl(relativeUrl)) return relativeUrl; - } - // Prepend API base URL to relative path return $"{_apiBaseUrl}/{relativeUrl}"; } - private static TenantThemeApiDto MapToDto(TenantThemeSettings settings) + private static bool IsAbsoluteUrl(string url) => + url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("data:", StringComparison.OrdinalIgnoreCase); + + private static TenantThemeApiDto MapToDto(TenantThemeSettings settings) => new() { - return new TenantThemeApiDto - { - LightPalette = new PaletteApiDto - { - Primary = settings.LightPalette.Primary, - Secondary = settings.LightPalette.Secondary, - Tertiary = settings.LightPalette.Tertiary, - Background = settings.LightPalette.Background, - Surface = settings.LightPalette.Surface, - Error = settings.LightPalette.Error, - Warning = settings.LightPalette.Warning, - Success = settings.LightPalette.Success, - Info = settings.LightPalette.Info - }, - DarkPalette = new PaletteApiDto - { - Primary = settings.DarkPalette.Primary, - Secondary = settings.DarkPalette.Secondary, - Tertiary = settings.DarkPalette.Tertiary, - Background = settings.DarkPalette.Background, - Surface = settings.DarkPalette.Surface, - Error = settings.DarkPalette.Error, - Warning = settings.DarkPalette.Warning, - Success = settings.DarkPalette.Success, - Info = settings.DarkPalette.Info - }, - BrandAssets = new BrandAssetsApiDto - { - LogoUrl = settings.BrandAssets.LogoUrl, - LogoDarkUrl = settings.BrandAssets.LogoDarkUrl, - FaviconUrl = settings.BrandAssets.FaviconUrl, - Logo = MapFileUpload(settings.BrandAssets.Logo), - LogoDark = MapFileUpload(settings.BrandAssets.LogoDark), - Favicon = MapFileUpload(settings.BrandAssets.Favicon), - DeleteLogo = settings.BrandAssets.DeleteLogo, - DeleteLogoDark = settings.BrandAssets.DeleteLogoDark, - DeleteFavicon = settings.BrandAssets.DeleteFavicon - }, - Typography = new TypographyApiDto - { - FontFamily = settings.Typography.FontFamily, - HeadingFontFamily = settings.Typography.HeadingFontFamily, - FontSizeBase = settings.Typography.FontSizeBase, - LineHeightBase = settings.Typography.LineHeightBase - }, - Layout = new LayoutApiDto - { - BorderRadius = settings.Layout.BorderRadius, - DefaultElevation = settings.Layout.DefaultElevation - }, - IsDefault = settings.IsDefault - }; - } + LightPalette = MapPaletteToDto(settings.LightPalette), + DarkPalette = MapPaletteToDto(settings.DarkPalette), + BrandAssets = MapBrandAssetsToDto(settings.BrandAssets), + Typography = MapTypographyToDto(settings.Typography), + Layout = MapLayoutToDto(settings.Layout), + IsDefault = settings.IsDefault + }; + + private static PaletteApiDto MapPaletteToDto(PaletteSettings palette) => new() + { + Primary = palette.Primary, + Secondary = palette.Secondary, + Tertiary = palette.Tertiary, + Background = palette.Background, + Surface = palette.Surface, + Error = palette.Error, + Warning = palette.Warning, + Success = palette.Success, + Info = palette.Info + }; + + private static BrandAssetsApiDto MapBrandAssetsToDto(BrandAssets assets) => new() + { + LogoUrl = assets.LogoUrl, + LogoDarkUrl = assets.LogoDarkUrl, + FaviconUrl = assets.FaviconUrl, + Logo = MapFileUpload(assets.Logo), + LogoDark = MapFileUpload(assets.LogoDark), + Favicon = MapFileUpload(assets.Favicon), + DeleteLogo = assets.DeleteLogo, + DeleteLogoDark = assets.DeleteLogoDark, + DeleteFavicon = assets.DeleteFavicon + }; + + private static TypographyApiDto MapTypographyToDto(TypographySettings typography) => new() + { + FontFamily = typography.FontFamily, + HeadingFontFamily = typography.HeadingFontFamily, + FontSizeBase = typography.FontSizeBase, + LineHeightBase = typography.LineHeightBase + }; + + private static LayoutApiDto MapLayoutToDto(LayoutSettings layout) => new() + { + BorderRadius = layout.BorderRadius, + DefaultElevation = layout.DefaultElevation + }; private static FileUploadApiDto? MapFileUpload(FileUpload? upload) { if (upload is null || upload.Data.Length == 0) return null; - // Convert byte[] to List for JSON serialization (same as profile picture pattern) return new FileUploadApiDto { FileName = upload.FileName, @@ -308,7 +306,6 @@ internal sealed record BrandAssetsApiDto [JsonPropertyName("faviconUrl")] public string? FaviconUrl { get; init; } - // File upload data (same pattern as profile picture) [JsonPropertyName("logo")] public FileUploadApiDto? Logo { get; init; } @@ -318,7 +315,6 @@ internal sealed record BrandAssetsApiDto [JsonPropertyName("favicon")] public FileUploadApiDto? Favicon { get; init; } - // Delete flags [JsonPropertyName("deleteLogo")] public bool DeleteLogo { get; init; } diff --git a/src/Playground/Playground.Blazor/Services/ThemeStateFactory.cs b/src/Playground/Playground.Blazor/Services/ThemeStateFactory.cs index f7bc7fdaca..83485b9a4b 100644 --- a/src/Playground/Playground.Blazor/Services/ThemeStateFactory.cs +++ b/src/Playground/Playground.Blazor/Services/ThemeStateFactory.cs @@ -14,11 +14,11 @@ internal interface IThemeStateFactory /// /// Redis-cached implementation of theme state factory. -/// Efficient for SSR pages that need theme data without full circuit. /// internal sealed class CachedThemeStateFactory : IThemeStateFactory { private static readonly Uri ThemeEndpoint = new("/api/v1/tenants/theme", UriKind.Relative); + private static readonly TenantThemeSettings DefaultSettings = TenantThemeSettings.Default; private readonly IDistributedCache _cache; private readonly HttpClient _httpClient; @@ -39,64 +39,62 @@ public async Task GetThemeAsync(string tenantId, Cancellati { var cacheKey = $"theme:{tenantId}"; - // Try to get from cache first (with error handling for Redis failures) + var cached = await TryGetFromCacheAsync(cacheKey, tenantId, cancellationToken); + if (cached is not null) + { + return cached; + } + + return await FetchAndCacheThemeAsync(cacheKey, tenantId, cancellationToken); + } + + private async Task TryGetFromCacheAsync( + string cacheKey, + string tenantId, + CancellationToken cancellationToken) + { try { var json = await _cache.GetStringAsync(cacheKey, cancellationToken); - if (json is not null) + if (json is null) { - try - { - var cached = JsonSerializer.Deserialize(json); - if (cached is not null) - { - return cached; - } - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Failed to deserialize cached theme for tenant {TenantId}", tenantId); - } + return null; } + + return DeserializeTheme(json, tenantId); } catch (Exception ex) { _logger.LogWarning(ex, "Cache unavailable, fetching theme directly for tenant {TenantId}", tenantId); + return null; } + } - // Cache miss or deserialization failed - fetch from API + private TenantThemeSettings? DeserializeTheme(string json, string tenantId) + { try { - var response = await _httpClient.GetAsync(ThemeEndpoint, cancellationToken); + return JsonSerializer.Deserialize(json); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to deserialize cached theme for tenant {TenantId}", tenantId); + return null; + } + } - if (response.IsSuccessStatusCode) - { - var dto = await response.Content.ReadFromJsonAsync(cancellationToken); - if (dto is not null) - { - var settings = MapFromDto(dto); - - // Try to cache for 15 minutes (fail silently if cache unavailable) - try - { - var serialized = JsonSerializer.Serialize(settings); - var options = new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = _cacheExpiry - }; - await _cache.SetStringAsync(cacheKey, serialized, options, cancellationToken); - } - catch (Exception cacheEx) - { - _logger.LogWarning(cacheEx, "Failed to cache theme, continuing without cache"); - } - - return settings; - } - } - else + private async Task FetchAndCacheThemeAsync( + string cacheKey, + string tenantId, + CancellationToken cancellationToken) + { + try + { + var settings = await FetchThemeFromApiAsync(cancellationToken); + if (settings is not null) { - _logger.LogWarning("Failed to load tenant theme from API: {StatusCode}", response.StatusCode); + await TryCacheThemeAsync(cacheKey, settings, cancellationToken); + return settings; } } catch (Exception ex) @@ -104,59 +102,97 @@ public async Task GetThemeAsync(string tenantId, Cancellati _logger.LogError(ex, "Error loading tenant theme for {TenantId}", tenantId); } - // Fallback to default theme - return TenantThemeSettings.Default; + return DefaultSettings; } - private static TenantThemeSettings MapFromDto(TenantThemeApiDto dto) + private async Task FetchThemeFromApiAsync(CancellationToken cancellationToken) { - var defaultSettings = TenantThemeSettings.Default; + var response = await _httpClient.GetAsync(ThemeEndpoint, cancellationToken); - return new TenantThemeSettings + if (!response.IsSuccessStatusCode) { - LightPalette = new PaletteSettings - { - Primary = dto.LightPalette?.Primary ?? defaultSettings.LightPalette.Primary, - Secondary = dto.LightPalette?.Secondary ?? defaultSettings.LightPalette.Secondary, - Tertiary = dto.LightPalette?.Tertiary ?? defaultSettings.LightPalette.Tertiary, - Background = dto.LightPalette?.Background ?? defaultSettings.LightPalette.Background, - Surface = dto.LightPalette?.Surface ?? defaultSettings.LightPalette.Surface, - Error = dto.LightPalette?.Error ?? defaultSettings.LightPalette.Error, - Warning = dto.LightPalette?.Warning ?? defaultSettings.LightPalette.Warning, - Success = dto.LightPalette?.Success ?? defaultSettings.LightPalette.Success, - Info = dto.LightPalette?.Info ?? defaultSettings.LightPalette.Info - }, - DarkPalette = new PaletteSettings - { - Primary = dto.DarkPalette?.Primary ?? defaultSettings.DarkPalette.Primary, - Secondary = dto.DarkPalette?.Secondary ?? defaultSettings.DarkPalette.Secondary, - Tertiary = dto.DarkPalette?.Tertiary ?? defaultSettings.DarkPalette.Tertiary, - Background = dto.DarkPalette?.Background ?? defaultSettings.DarkPalette.Background, - Surface = dto.DarkPalette?.Surface ?? defaultSettings.DarkPalette.Surface, - Error = dto.DarkPalette?.Error ?? defaultSettings.DarkPalette.Error, - Warning = dto.DarkPalette?.Warning ?? defaultSettings.DarkPalette.Warning, - Success = dto.DarkPalette?.Success ?? defaultSettings.DarkPalette.Success, - Info = dto.DarkPalette?.Info ?? defaultSettings.DarkPalette.Info - }, - BrandAssets = new BrandAssets - { - LogoUrl = dto.BrandAssets?.LogoUrl, - LogoDarkUrl = dto.BrandAssets?.LogoDarkUrl, - FaviconUrl = dto.BrandAssets?.FaviconUrl - }, - Typography = new TypographySettings - { - FontFamily = dto.Typography?.FontFamily ?? defaultSettings.Typography.FontFamily, - HeadingFontFamily = dto.Typography?.HeadingFontFamily ?? defaultSettings.Typography.HeadingFontFamily, - FontSizeBase = dto.Typography?.FontSizeBase ?? defaultSettings.Typography.FontSizeBase, - LineHeightBase = dto.Typography?.LineHeightBase ?? defaultSettings.Typography.LineHeightBase - }, - Layout = new LayoutSettings + _logger.LogWarning("Failed to load tenant theme from API: {StatusCode}", response.StatusCode); + return null; + } + + var dto = await response.Content.ReadFromJsonAsync(cancellationToken); + return dto is not null ? MapFromDto(dto) : null; + } + + private async Task TryCacheThemeAsync( + string cacheKey, + TenantThemeSettings settings, + CancellationToken cancellationToken) + { + try + { + var serialized = JsonSerializer.Serialize(settings); + var options = new DistributedCacheEntryOptions { - BorderRadius = dto.Layout?.BorderRadius ?? defaultSettings.Layout.BorderRadius, - DefaultElevation = dto.Layout?.DefaultElevation ?? defaultSettings.Layout.DefaultElevation - }, - IsDefault = dto.IsDefault - }; + AbsoluteExpirationRelativeToNow = _cacheExpiry + }; + await _cache.SetStringAsync(cacheKey, serialized, options, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cache theme, continuing without cache"); + } } + + private static TenantThemeSettings MapFromDto(TenantThemeApiDto dto) => new() + { + LightPalette = MapLightPalette(dto.LightPalette), + DarkPalette = MapDarkPalette(dto.DarkPalette), + BrandAssets = MapBrandAssets(dto.BrandAssets), + Typography = MapTypography(dto.Typography), + Layout = MapLayout(dto.Layout), + IsDefault = dto.IsDefault + }; + + private static PaletteSettings MapLightPalette(PaletteApiDto? dto) => new() + { + Primary = dto?.Primary ?? DefaultSettings.LightPalette.Primary, + Secondary = dto?.Secondary ?? DefaultSettings.LightPalette.Secondary, + Tertiary = dto?.Tertiary ?? DefaultSettings.LightPalette.Tertiary, + Background = dto?.Background ?? DefaultSettings.LightPalette.Background, + Surface = dto?.Surface ?? DefaultSettings.LightPalette.Surface, + Error = dto?.Error ?? DefaultSettings.LightPalette.Error, + Warning = dto?.Warning ?? DefaultSettings.LightPalette.Warning, + Success = dto?.Success ?? DefaultSettings.LightPalette.Success, + Info = dto?.Info ?? DefaultSettings.LightPalette.Info + }; + + private static PaletteSettings MapDarkPalette(PaletteApiDto? dto) => new() + { + Primary = dto?.Primary ?? DefaultSettings.DarkPalette.Primary, + Secondary = dto?.Secondary ?? DefaultSettings.DarkPalette.Secondary, + Tertiary = dto?.Tertiary ?? DefaultSettings.DarkPalette.Tertiary, + Background = dto?.Background ?? DefaultSettings.DarkPalette.Background, + Surface = dto?.Surface ?? DefaultSettings.DarkPalette.Surface, + Error = dto?.Error ?? DefaultSettings.DarkPalette.Error, + Warning = dto?.Warning ?? DefaultSettings.DarkPalette.Warning, + Success = dto?.Success ?? DefaultSettings.DarkPalette.Success, + Info = dto?.Info ?? DefaultSettings.DarkPalette.Info + }; + + private static BrandAssets MapBrandAssets(BrandAssetsApiDto? dto) => new() + { + LogoUrl = dto?.LogoUrl, + LogoDarkUrl = dto?.LogoDarkUrl, + FaviconUrl = dto?.FaviconUrl + }; + + private static TypographySettings MapTypography(TypographyApiDto? dto) => new() + { + FontFamily = dto?.FontFamily ?? DefaultSettings.Typography.FontFamily, + HeadingFontFamily = dto?.HeadingFontFamily ?? DefaultSettings.Typography.HeadingFontFamily, + FontSizeBase = dto?.FontSizeBase ?? DefaultSettings.Typography.FontSizeBase, + LineHeightBase = dto?.LineHeightBase ?? DefaultSettings.Typography.LineHeightBase + }; + + private static LayoutSettings MapLayout(LayoutApiDto? dto) => new() + { + BorderRadius = dto?.BorderRadius ?? DefaultSettings.Layout.BorderRadius, + DefaultElevation = dto?.DefaultElevation ?? DefaultSettings.Layout.DefaultElevation + }; }