Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ public async Task PropagateAuthState(bool firstRun, Task<AuthenticationState> ta
if (lastPropagatedUserId == userId)
return;
await Abort(); // Cancels ongoing user id propagation, because the new authentication state is available.
lastPropagatedUserId = userId;
TelemetryContext.UserId = userId;
TelemetryContext.UserSessionId = isAuthenticated ? user.GetSessionId() : null;

Expand Down Expand Up @@ -161,6 +160,8 @@ public async Task PropagateAuthState(bool firstRun, Task<AuthenticationState> ta
{
await UpdateUserSession();
}

lastPropagatedUserId = userId;
}
catch (Exception exp)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,6 @@
Color="BitColor.SecondaryBackground"
IconName="@BitIconName.DeveloperTools" />

@*#if (signalR == true)*@
<AuthorizeView Policy="@AppFeatures.System.ManageLogs">
<BitButton IconOnly AutoLoading
OnClick="ReadAnotherUserLogs"
Title="Read another user logs"
Color="BitColor.SecondaryBackground"
IconName="@BitIconName.Download" />
</AuthorizeView>
@*#endif*@

@if (AppPlatform.IsBrowser is false)
{
<BitButton IconOnly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,13 @@
//#if (signalR == true)
using Microsoft.AspNetCore.SignalR.Client;
//#endif
using Boilerplate.Shared.Dtos.Diagnostic;
using Boilerplate.Client.Core.Services.DiagnosticLog;

namespace Boilerplate.Client.Core.Components.Layout;

public partial class AppDiagnosticModal
{
[AutoInject] private Cookie cookie = default!;
[AutoInject] private AuthManager authManager = default!;
//#if (signalR == true)
[AutoInject] private PromptService promptService = default!;
//#endif
[AutoInject] private IStorageService storageService = default!;
[AutoInject] private IUserController userController = default!;
[AutoInject] private IAppUpdateService appUpdateService = default!;
Expand Down Expand Up @@ -152,20 +147,4 @@ private async Task UpdateApp()
{
await appUpdateService.ForceUpdate();
}

//#if (signalR == true)
/// <summary>
/// <inheritdoc cref="SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE"/>
/// </summary>
private async Task ReadAnotherUserLogs()
{
var userQuery = await promptService.Show("Enter `UserId`, `UserSessionId`, `Email` or `PhoneNumber`:", "Get other user logs");
var logs = await hubConnection.InvokeAsync<DiagnosticLogDto[]>("GetUserDiagnosticLogs", userQuery, CurrentCancellationToken);

filterCategoryValues = null;
filterLogLevelValues = [LogLevel.Information, LogLevel.Warning, LogLevel.Error, LogLevel.Critical];

LoadLogs(logs);
}
//#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ public partial class MainLayout : IAsyncDisposable
private AppThemeType? currentTheme;
private RouteData? currentRouteData;
private List<Action> unsubscribers = [];
private Guid? userIdForUpdateAuthRelatedUI;
private CancellationTokenSource? getCurrentUserCts;
private CancellationTokenSource getCurrentUserCts = new();

[AutoInject] private Keyboard keyboard = default!;
[AutoInject] private IJSRuntime jsRuntime = default!;
Expand Down Expand Up @@ -169,21 +168,16 @@ private async Task SetCurrentUser(Task<AuthenticationState> task)

await SetNavPanelItems(authUser);

if (getCurrentUserCts is not null)
{
using var currentCts = getCurrentUserCts;
await currentCts.CancelAsync();
}
using var currentCts = getCurrentUserCts;
getCurrentUserCts = new();
await currentCts.CancelAsync();

if (authUser.IsAuthenticated() is false)
{
currentUser = null;
userIdForUpdateAuthRelatedUI = null;
}
else if (authUser.GetUserId() != userIdForUpdateAuthRelatedUI)
else if (authUser.GetUserId() != currentUser?.Id)
{
userIdForUpdateAuthRelatedUI = authUser.GetUserId();
currentUser = await userController.GetCurrentUser(getCurrentUserCts.Token);
}
}
Expand Down Expand Up @@ -242,7 +236,7 @@ private async Task UpdateApp()
{
await appUpdateService.ForceUpdate();
}
catch(Exception exp)
catch (Exception exp)
{
exceptionHandler.Handle(exp);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@
@if (string.IsNullOrWhiteSpace(session.SignalRConnectionId) is false)
{
<BitTag Color="BitColor.SecondaryBackground" Size="BitSize.Large" Class="selectable">@session.SignalRConnectionId</BitTag>
<BitButton IconOnly AutoLoading
OnClick="WrapHandled(() => ReadUserSessionLogs(session.Id))"
Title="Read user session logs"
Color="BitColor.SecondaryBackground"
IconName="@BitIconName.Download" />
}
</BitText>
@*#endif*@
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
//+:cnd:noEmit
using Boilerplate.Shared.Dtos.Identity;
using Boilerplate.Shared.Controllers.Identity;
using Boilerplate.Shared.Dtos.Identity;
//#if (signalR == true)
using Boilerplate.Shared.Dtos.Diagnostic;
using Boilerplate.Client.Core.Services.DiagnosticLog;
using Microsoft.AspNetCore.SignalR.Client;
//#endif

namespace Boilerplate.Client.Core.Components.Pages.Authorized.Management;

Expand All @@ -26,7 +31,9 @@ public partial class UsersPage


[AutoInject] IUserManagementController userManagementController = default!;

//#if (signalR == true)
[AutoInject] HubConnection hubConnection = default!;
//#endif

protected override async Task OnInitAsync()
{
Expand Down Expand Up @@ -177,4 +184,22 @@ private void SearchSessions()
filteredUserSessions = [.. allUserSessions.Where(us => ((us.IP + us.Address + us.DeviceInfo + us.RenewedOnDateTimeOffset + us.Id) ?? string.Empty).Contains(t, StringComparison.InvariantCultureIgnoreCase))];
}
}

//#if (signalR == true)
/// <summary>
/// <inheritdoc cref="SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE"/>
/// </summary>
private async Task ReadUserSessionLogs(Guid userSessionId)
{
var logs = await hubConnection.InvokeAsync<DiagnosticLogDto[]>("GetUserSessionLogs", userSessionId);

DiagnosticLogger.Store.Clear();
foreach (var log in logs)
{
DiagnosticLogger.Store.Enqueue(log);
}

PubSubService.Publish(ClientPubSubMessages.SHOW_DIAGNOSTIC_MODAL);
}
//#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ function handleLoad() {
// the following code ensures that the original window is notified.
// If IExternalNavigationService fails to navigate to the new window (Typically on iOS/Safari), the window.opener will be null and the page normally loads.
window.opener.postMessage({ key: 'PUBLISH_MESSAGE', message: 'SOCIAL_SIGN_IN', payload: window.location.href });
setTimeout(() => window.close(), 100);
window.close();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@
<PackageVersion Condition=" '$(signalR)' == 'true' OR '$(signalR)' == ''" Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.5" />
<PackageVersion Condition=" '$(signalR)' == 'true' OR '$(signalR)' == ''" Include="Microsoft.Azure.SignalR" Version="1.30.3" />
<PackageVersion Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') " Include="Microsoft.Extensions.AI" Version="9.5.0" />
<PackageVersion Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') " Include="Microsoft.Extensions.AI.OpenAI" Version="9.4.0-preview.1.25207.5" />
<PackageVersion Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') " Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.4.0-preview.1.25207.5" />
<PackageVersion Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') " Include="Microsoft.Extensions.AI.OpenAI" Version="9.5.0-preview.1.25265.7" />
<PackageVersion Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') " Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.5.0-preview.1.25265.7" />
<PackageVersion Condition=" ('$(database)' == 'PostgreSQL' OR '$(database)' == '') " Include="Pgvector.EntityFrameworkCore" Version="0.2.2" />
<PackageVersion Condition="'$(module)' == 'Admin' OR '$(module)' == ''" Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="Microsoft.Extensions.Logging.ApplicationInsights" Version="2.23.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ private async Task UpdateUserSessionPrivilegeStatus(UserSession userSession, Can
{
var userId = userSession.UserId;

var maxPrivilegedSessionsClaimValues = await userClaimsService.GetUserClaimValues<int?>(userId, AppClaimTypes.MAX_PRIVILEGED_SESSIONS, cancellationToken);
var maxPrivilegedSessionsClaimValues = await userClaimsService.GetClaimValues<int?>(userId, AppClaimTypes.MAX_PRIVILEGED_SESSIONS, cancellationToken);

var hasUnlimitedPrivilegedSessions = maxPrivilegedSessionsClaimValues.Any(v => v == -1); // -1 means no limit

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace Boilerplate.Server.Api.Services.Identity;

public partial class AppUserClaimsPrincipalFactory(UserManager<User> userManager, RoleManager<Role> roleManager, IOptions<IdentityOptions> optionsAccessor)
public partial class AppUserClaimsPrincipalFactory(UserClaimsService userClaimsService, UserManager<User> userManager, RoleManager<Role> roleManager, IOptions<IdentityOptions> optionsAccessor)
: UserClaimsPrincipalFactory<User, Role>(userManager, roleManager, optionsAccessor)
{
/// <summary>
Expand All @@ -12,7 +12,7 @@ public partial class AppUserClaimsPrincipalFactory(UserManager<User> userManager

protected override async Task<ClaimsIdentity> GenerateClaimsAsync(User user)
{
var result = await base.GenerateClaimsAsync(user);
var result = await GenerateClaims(user);

foreach (var sessionClaim in SessionClaims)
{
Expand All @@ -22,4 +22,35 @@ protected override async Task<ClaimsIdentity> GenerateClaimsAsync(User user)

return result;
}

/// <summary>
/// aspnetcore identity's code to retrieve claims is not performant enough,
/// because it doesn't have access to navigation properties and has to query the database for user claims, user roles and role claims separately,
/// while we use <see cref="UserClaimsService.GetClaims(Guid,CancellationToken)"/> to retrieve all claims in a single query.
/// The original code borrowed from https://github.com/dotnet/aspnetcore/blob/main/src/Identity/Extensions.Core/src/UserClaimsPrincipalFactory.cs#L71
/// </summary>
private async Task<ClaimsIdentity> GenerateClaims(User user)
{
var userId = user.Id.ToString();
var userName = user.UserName;
var id = new ClaimsIdentity("Identity.Application", // REVIEW: Used to match Application scheme
Options.ClaimsIdentity.UserNameClaimType,
Options.ClaimsIdentity.RoleClaimType);
id.AddClaim(new Claim(Options.ClaimsIdentity.UserIdClaimType, userId));
id.AddClaim(new Claim(Options.ClaimsIdentity.UserNameClaimType, userName!));
var email = user.Email;
if (string.IsNullOrEmpty(email) is false)
{
id.AddClaim(new Claim(Options.ClaimsIdentity.EmailClaimType, email));
}
id.AddClaim(new Claim(Options.ClaimsIdentity.SecurityStampClaimType, user.SecurityStamp!));

foreach (var claim in await userClaimsService.GetClaims(user.Id, default))
{
if (id.HasClaim(claim.Type, claim.Value) is false)
id.AddClaim(new(claim.Type, claim.Value));
}

return id;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,20 @@

public partial class UserClaimsService
{
private Dictionary<Guid, Claim[]> _cachedClaims = [];

[AutoInject]
private AppDbContext dbContext = default!;

/// <summary>
/// Returns all claim values of a specific type for a user, including those inherited from roles.
/// </summary>
public async Task<T?[]> GetUserClaimValues<T>(Guid userId, string claimType, CancellationToken cancellationToken)
public async Task<T?[]> GetClaimValues<T>(Guid userId, string claimType, CancellationToken cancellationToken)
{
var userClaimsQuery = dbContext.UserClaims.Where(uc => uc.UserId == userId).Select(uc => new { uc.ClaimType, uc.ClaimValue });
var userRoleClaimsQuery = dbContext.UserRoles.Where(ur => ur.UserId == userId).SelectMany(ur => ur.Role!.Claims).Select(uc => new { uc.ClaimType, uc.ClaimValue });
var allUserClaimsQuery = userClaimsQuery.Union(userRoleClaimsQuery).TagWith($"Finding {claimType} claim for {userId}");

var results = await allUserClaimsQuery
.Where(uc => uc.ClaimType == claimType)
.Select(uc => uc.ClaimValue)
.ToArrayAsync(cancellationToken);
var results = (await GetClaims(userId, cancellationToken))
.Where(uc => uc.Type == claimType)
.Select(uc => uc.Value)
.ToArray();

if (results.Any() is false)
return [];
Expand All @@ -37,8 +35,32 @@ public partial class UserClaimsService
/// Returns claim value of a specific type for a user, including those inherited from roles.
/// User might have multiple claims of the same type because of her roles or directly assigned user claims, so we return the maximum value
/// </summary>
public async Task<T?> GetUserClaimValue<T>(Guid userId, string claimType, CancellationToken cancellationToken)
public async Task<T?> GetClaimValue<T>(Guid userId, string claimType, CancellationToken cancellationToken)
{
return (await GetClaimValues<T>(userId, claimType, cancellationToken)).Max();
}

/// <summary>
/// Loads all user claims, role claims and role names for a user in a single query that gets cached in-memory for current request lifetime.
/// There's no need for complex caching here as the service is not being called in parallel.
/// </summary>
public async Task<Claim[]> GetClaims(Guid userId, CancellationToken cancellationToken)
{
return (await GetUserClaimValues<T>(userId, claimType, cancellationToken)).Max();
if (_cachedClaims.TryGetValue(userId, out var cachedClaims))
return cachedClaims;

var userClaimsQuery = dbContext.UserClaims.Where(uc => uc.UserId == userId).Select(uc => new { ClaimType = uc.ClaimType!, ClaimValue = uc.ClaimValue! });

var userRolesQuery = dbContext.Roles.Where(role => role.Users.Any(ur => ur.UserId == userId));

var userRoleClaimsQuery = userRolesQuery.SelectMany(r => r.Claims.Select(rc => new { ClaimType = rc.ClaimType!, ClaimValue = rc.ClaimValue! }));

var roleClaimQuery = userRolesQuery.Select(role => new { ClaimType = ClaimTypes.Role, ClaimValue = role.Name! });

var allUserClaimsQuery = userClaimsQuery.Union(userRoleClaimsQuery).Union(roleClaimQuery).TagWith("Get claims");

_cachedClaims.Add(userId, await allUserClaimsQuery.Select(uc => new Claim(uc.ClaimType!, uc.ClaimValue!)).ToArrayAsync(cancellationToken));

return _cachedClaims[userId];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,30 +71,21 @@ public Task ChangeAuthenticationState(string? accessToken)
/// <summary>
/// <inheritdoc cref="SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE"/>
/// </summary>
/// <param name="userQuery">`UserId`, `UserSessionId`, `Email` or `PhoneNumber`</param>
/// <returns></returns>
[Authorize(Policy = AppFeatures.System.ManageLogs)]
public async Task<DiagnosticLogDto[]> GetUserDiagnosticLogs(string? userQuery)
public async Task<DiagnosticLogDto[]> GetUserSessionLogs(Guid userSessionId)
{
if (string.IsNullOrEmpty(userQuery))
return [];

userQuery = userQuery.Trim().ToUpperInvariant();

await using var scope = serviceProvider.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();

var isGuidId = Guid.TryParse(userQuery, out var id);

var userSessionSignalRConnectionIds = await dbContext.UserSessions
.WhereIf(isGuidId, us => us.Id == id || us.UserId == id)
.WhereIf(isGuidId is false, us => us.User!.NormalizedEmail == userQuery || us.User.PhoneNumber == userQuery || us.User.UserName == userQuery)
.Where(us => us.SignalRConnectionId != null)
var userSessionSignalRConnectionId = await dbContext.UserSessions
.Where(us => us.Id == userSessionId)
.Select(us => us.SignalRConnectionId)
.ToArrayAsync(Context.ConnectionAborted);
.FirstOrDefaultAsync(Context.ConnectionAborted);

if (string.IsNullOrEmpty(userSessionSignalRConnectionId))
return [];

return [.. (await Task.WhenAll(userSessionSignalRConnectionIds.Select(id => Clients.Client(id!).InvokeAsync<DiagnosticLogDto[]>(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, Context.ConnectionAborted))))
.SelectMany(_ => _)];
return await Clients.Client(userSessionSignalRConnectionId).InvokeAsync<DiagnosticLogDto[]>(SignalRMethods.UPLOAD_DIAGNOSTIC_LOGGER_STORE, Context.ConnectionAborted);
}

private async Task ChangeAuthenticationStateImplementation(ClaimsPrincipal? user)
Expand Down
Loading