From 5b58418e57864e39e27b681ad5c47d6f07086768 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 26 Nov 2024 18:24:33 +0100 Subject: [PATCH 1/4] Fix refresh token grace period check (#426) --- .../Controllers/AuthController.cs | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/AliasVault.Api/Controllers/AuthController.cs b/src/AliasVault.Api/Controllers/AuthController.cs index 91203e1f..db776a64 100644 --- a/src/AliasVault.Api/Controllers/AuthController.cs +++ b/src/AliasVault.Api/Controllers/AuthController.cs @@ -262,16 +262,14 @@ public async Task Refresh([FromBody] TokenModel tokenModel) return Unauthorized("User not found (name-2)"); } - // Check if the refresh token is valid. - var existingToken = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.UserId == user.Id && t.Value == tokenModel.RefreshToken); - if (existingToken == null || existingToken.ExpireDate < timeProvider.UtcNow) + // Generate new tokens for the user. + var token = await GenerateNewTokensForUser(user, tokenModel.RefreshToken); + if (token == null) { await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TokenRefresh, AuthFailureReason.InvalidRefreshToken); - return Unauthorized("Refresh token expired"); + return Unauthorized("Invalid refresh token"); } - // Generate new tokens for the user. - var token = await GenerateNewTokensForUser(user, existingToken); await context.SaveChangesAsync(); await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.TokenRefresh); @@ -684,9 +682,9 @@ private async Task GenerateNewTokensForUser(AliasVaultUser user, boo /// to the database. /// /// The user to generate the tokens for. - /// The existing token that is being replaced (optional). - /// TokenModel which includes new access and refresh token. - private async Task GenerateNewTokensForUser(AliasVaultUser user, AliasVaultUserRefreshToken existingToken) + /// The existing token value that is being replaced (optional). + /// TokenModel which includes new access and refresh token. Returns null if provided refresh token is invalid. + private async Task GenerateNewTokensForUser(AliasVaultUser user, string existingTokenValue) { await using var context = await dbContextFactory.CreateDbContextAsync(); await Semaphore.WaitAsync(); @@ -699,7 +697,7 @@ private async Task GenerateNewTokensForUser(AliasVaultUser user, Ali var existingTokenReuseWindow = timeProvider.UtcNow.AddSeconds(-30); var existingTokenReuse = await context.AliasVaultUserRefreshTokens .FirstOrDefaultAsync(t => t.UserId == user.Id && - t.PreviousTokenValue == existingToken.Value && + t.PreviousTokenValue == existingTokenValue && t.CreatedAt > existingTokenReuseWindow); if (existingTokenReuse is not null) @@ -711,20 +709,19 @@ private async Task GenerateNewTokensForUser(AliasVaultUser user, Ali } // Remove the existing refresh token. - var tokenToDelete = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.Id == existingToken.Id); + var tokenToDelete = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.Value == existingTokenValue); if (tokenToDelete is null) { - await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TokenRefresh, AuthFailureReason.InvalidRefreshToken); - throw new InvalidOperationException("Refresh token does not exist (anymore)."); + return null; } context.AliasVaultUserRefreshTokens.Remove(tokenToDelete); // New refresh token lifetime is the same as the existing one. - var existingTokenLifetime = existingToken.ExpireDate - existingToken.CreatedAt; + var existingTokenLifetime = tokenToDelete.ExpireDate - tokenToDelete.CreatedAt; // Retrieve new refresh token. - var newRefreshToken = await GenerateRefreshToken(user, existingTokenLifetime, existingToken.Value); + var newRefreshToken = await GenerateRefreshToken(user, existingTokenLifetime, tokenToDelete.Value); // After successfully retrieving new refresh token, remove the existing one by saving changes. await context.SaveChangesAsync(); From c73c41ca060f6dd78d27142d8ae20f80d52ceec0 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 26 Nov 2024 18:33:35 +0100 Subject: [PATCH 2/4] Refactor RecentEmails component to only load emails when app is visible (#426) --- .../Main/Components/Email/RecentEmails.razor | 175 +++++++++++++----- .../Services/JsInteropService.cs | 11 ++ src/AliasVault.Client/wwwroot/js/utilities.js | 6 + 3 files changed, 149 insertions(+), 43 deletions(-) diff --git a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor index 6ff06473..781affe3 100644 --- a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor +++ b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor @@ -26,7 +26,7 @@

Email

- @if (RefreshTimer is not null) + @if (DbService.Settings.AutoEmailRefresh) {
} @@ -56,27 +56,27 @@
- - - - + + + + - @foreach (var mail in MailboxEmails) - { - - - - - } + @foreach (var mail in MailboxEmails) + { + + + + + }
- Subject - - Date & Time -
+ Subject + + Date & Time +
- @(mail.Subject.Substring(0, mail.Subject.Length > 30 ? 30 : mail.Subject.Length))... - - @mail.DateSystem -
+ @(mail.Subject.Substring(0, mail.Subject.Length > 30 ? 30 : mail.Subject.Length))... + + @mail.DateSystem +
@@ -99,13 +99,53 @@ private EmailApiModel Email { get; set; } = new(); private bool EmailModalVisible { get; set; } private string Error { get; set; } = string.Empty; - private Timer? RefreshTimer { get; set; } private bool IsRefreshing { get; set; } = true; private bool IsLoading { get; set; } = true; private bool IsSpamOk { get; set; } = false; + private bool IsPageVisible { get; set; } = true; + private CancellationTokenSource? PollingCancellationTokenSource { get; set; } + private const int ACTIVE_TAB_REFRESH_INTERVAL = 2000; // 2 seconds + private readonly SemaphoreSlim RefreshSemaphore = new(1, 1); + private DateTime LastRefreshTime = DateTime.MinValue; + + /// + /// Callback invoked by JavaScript when the page visibility changes. + /// + /// Boolean whether the page is visible or not. + /// Task. + [JSInvokable] + public async Task OnVisibilityChange(bool isVisible) + { + IsPageVisible = isVisible; + if (isVisible) + { + // Only enable auto-refresh if the setting is enabled. + if (DbService.Settings.AutoEmailRefresh) + { + await StartPolling(); + } + + // Refresh immediately when tab becomes visible + await ManualRefresh(); + } + else + { + // Cancel polling. + PollingCancellationTokenSource?.Cancel(); + } + StateHasChanged(); + } + + /// + public void Dispose() + { + PollingCancellationTokenSource?.Cancel(); + PollingCancellationTokenSource?.Dispose(); + RefreshSemaphore.Dispose(); + } /// protected override async Task OnInitializedAsync() @@ -124,12 +164,29 @@ } IsSpamOk = IsSpamOkDomain(EmailAddress); + // Set up visibility change detection + await JsInteropService.RegisterVisibilityCallback(DotNetObjectReference.Create(this)); + // Only enable auto-refresh if the setting is enabled. if (DbService.Settings.AutoEmailRefresh) { - RefreshTimer = new Timer(2000); - RefreshTimer.Elapsed += async (sender, e) => await TimerRefresh(); - RefreshTimer.Start(); + await StartPolling(); + } + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (!ShowComponent) + { + return; + } + + if (firstRender) + { + await ManualRefresh(); } } @@ -146,25 +203,58 @@ IsSpamOk = IsSpamOkDomain(EmailAddress); } - /// - public void Dispose() + /// + /// Start the polling for new emails. + /// + /// Task. + private async Task StartPolling() { - RefreshTimer?.Dispose(); + PollingCancellationTokenSource?.Cancel(); + PollingCancellationTokenSource = new CancellationTokenSource(); + + try + { + while (!PollingCancellationTokenSource.Token.IsCancellationRequested) + { + if (IsPageVisible) + { + // Only auto refresh when the tab is visible. + await RefreshWithThrottling(); + await Task.Delay(ACTIVE_TAB_REFRESH_INTERVAL, PollingCancellationTokenSource.Token); + } + } + } + catch (OperationCanceledException) + { + // Normal cancellation, ignore + } } - /// - protected override async Task OnAfterRenderAsync(bool firstRender) + /// + /// Refresh the emails with throttling to prevent multiple refreshes at the same time. + /// + /// + private async Task RefreshWithThrottling() { - await base.OnAfterRenderAsync(firstRender); - - if (!ShowComponent) + if (!await RefreshSemaphore.WaitAsync(0)) // Don't wait if a refresh is in progress { return; } - if (firstRender) + try { - await ManualRefresh(); + var timeSinceLastRefresh = DateTime.UtcNow - LastRefreshTime; + if (timeSinceLastRefresh.TotalMilliseconds < ACTIVE_TAB_REFRESH_INTERVAL) + { + return; + } + + await LoadRecentEmailsAsync(); + LastRefreshTime = DateTime.UtcNow; + } + finally + { + RefreshSemaphore.Release(); } } @@ -184,15 +274,10 @@ return Config.PrivateEmailDomains.Exists(x => email.EndsWith(x)); } - private async Task TimerRefresh() - { - IsRefreshing = true; - StateHasChanged(); - await LoadRecentEmailsAsync(); - IsRefreshing = false; - StateHasChanged(); - } - + /// + /// Manually refresh the emails. + /// + /// private async Task ManualRefresh() { IsLoading = true; @@ -202,6 +287,10 @@ StateHasChanged(); } + /// + /// (Re)load recent emails by making an API call to the server. + /// + /// Task. private async Task LoadRecentEmailsAsync() { if (!ShowComponent || EmailAddress is null) diff --git a/src/AliasVault.Client/Services/JsInteropService.cs b/src/AliasVault.Client/Services/JsInteropService.cs index d3264513..4fb16aa6 100644 --- a/src/AliasVault.Client/Services/JsInteropService.cs +++ b/src/AliasVault.Client/Services/JsInteropService.cs @@ -9,6 +9,7 @@ namespace AliasVault.Client.Services; using System.Security.Cryptography; using System.Text.Json; +using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.JSInterop; /// @@ -237,6 +238,16 @@ public async Task ScrollToTop() await jsRuntime.InvokeVoidAsync("window.scrollTo", 0, 0); } + /// + /// Registers a visibility callback which is invoked when the visibility of component changes in client. + /// + /// Component type. + /// DotNetObjectReference. + /// Task. + public async Task RegisterVisibilityCallback(DotNetObjectReference objRef) + where TComponent : class => + await jsRuntime.InvokeVoidAsync("window.registerVisibilityCallback", objRef); + /// /// Represents the result of a WebAuthn get credential operation. /// diff --git a/src/AliasVault.Client/wwwroot/js/utilities.js b/src/AliasVault.Client/wwwroot/js/utilities.js index 4c424612..e9ee0d83 100644 --- a/src/AliasVault.Client/wwwroot/js/utilities.js +++ b/src/AliasVault.Client/wwwroot/js/utilities.js @@ -298,3 +298,9 @@ async function createWebAuthnCredentialAndDeriveKey(username) { return { Error: "WEBAUTHN_CREATE_ERROR", Message: createError.message }; } } + +window.registerVisibilityCallback = function (dotnetHelper) { + document.addEventListener("visibilitychange", function () { + dotnetHelper.invokeMethodAsync('OnVisibilityChange', !document.hidden); + }); +}; From 4ae84052e822c8e7175e7f9e53fc9c7bc8b50189 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 26 Nov 2024 19:03:03 +0100 Subject: [PATCH 3/4] Refactor RecentEmails.razor (#426) --- .../Main/Components/Email/RecentEmails.razor | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor index 781affe3..184bab19 100644 --- a/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor +++ b/src/AliasVault.Client/Main/Components/Email/RecentEmails.razor @@ -134,7 +134,10 @@ else { // Cancel polling. - PollingCancellationTokenSource?.Cancel(); + if (PollingCancellationTokenSource is not null) + { + await PollingCancellationTokenSource.CancelAsync(); + } } StateHasChanged(); } @@ -209,7 +212,11 @@ /// Task. private async Task StartPolling() { - PollingCancellationTokenSource?.Cancel(); + if (PollingCancellationTokenSource is not null) + { + await PollingCancellationTokenSource.CancelAsync(); + } + PollingCancellationTokenSource = new CancellationTokenSource(); try From 0d8143c62e4d588a829f3ebab41bf67adb03ee25 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 26 Nov 2024 19:08:41 +0100 Subject: [PATCH 4/4] Fix refresh token expired check (#426) --- src/AliasVault.Api/Controllers/AuthController.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/AliasVault.Api/Controllers/AuthController.cs b/src/AliasVault.Api/Controllers/AuthController.cs index db776a64..c7143e9d 100644 --- a/src/AliasVault.Api/Controllers/AuthController.cs +++ b/src/AliasVault.Api/Controllers/AuthController.cs @@ -343,7 +343,7 @@ public async Task Register([FromBody] RegisterRequest model) UserName = model.Username, CreatedAt = timeProvider.UtcNow, UpdatedAt = timeProvider.UtcNow, - PasswordChangedAt = DateTime.UtcNow, + PasswordChangedAt = timeProvider.UtcNow, }; user.Vaults.Add(new AliasServerDb.Vault @@ -708,20 +708,20 @@ private async Task GenerateNewTokensForUser(AliasVaultUser user, boo return new TokenModel { Token = accessToken, RefreshToken = existingTokenReuse.Value }; } - // Remove the existing refresh token. - var tokenToDelete = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.Value == existingTokenValue); - if (tokenToDelete is null) + // Check if the refresh token still exists and is not expired. + var existingToken = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.UserId == user.Id && t.Value == existingTokenValue); + if (existingToken == null || existingToken.ExpireDate < timeProvider.UtcNow) { return null; } - context.AliasVaultUserRefreshTokens.Remove(tokenToDelete); + context.AliasVaultUserRefreshTokens.Remove(existingToken); // New refresh token lifetime is the same as the existing one. - var existingTokenLifetime = tokenToDelete.ExpireDate - tokenToDelete.CreatedAt; + var existingTokenLifetime = existingToken.ExpireDate - existingToken.CreatedAt; // Retrieve new refresh token. - var newRefreshToken = await GenerateRefreshToken(user, existingTokenLifetime, tokenToDelete.Value); + var newRefreshToken = await GenerateRefreshToken(user, existingTokenLifetime, existingToken.Value); // After successfully retrieving new refresh token, remove the existing one by saving changes. await context.SaveChangesAsync();