Skip to content

Commit

Permalink
Merge pull request #427 from lanedirt/426-client-logs-out-unexpectedl…
Browse files Browse the repository at this point in the history
…y-when-kept-open-in-background-tab
  • Loading branch information
lanedirt authored Nov 26, 2024
2 parents 7c7f754 + 0d8143c commit 1baea18
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 60 deletions.
31 changes: 14 additions & 17 deletions src/AliasVault.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -262,16 +262,14 @@ public async Task<IActionResult> 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);
Expand Down Expand Up @@ -345,7 +343,7 @@ public async Task<IActionResult> 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
Expand Down Expand Up @@ -684,9 +682,9 @@ private async Task<TokenModel> GenerateNewTokensForUser(AliasVaultUser user, boo
/// to the database.
/// </summary>
/// <param name="user">The user to generate the tokens for.</param>
/// <param name="existingToken">The existing token that is being replaced (optional).</param>
/// <returns>TokenModel which includes new access and refresh token.</returns>
private async Task<TokenModel> GenerateNewTokensForUser(AliasVaultUser user, AliasVaultUserRefreshToken existingToken)
/// <param name="existingTokenValue">The existing token value that is being replaced (optional).</param>
/// <returns>TokenModel which includes new access and refresh token. Returns null if provided refresh token is invalid.</returns>
private async Task<TokenModel?> GenerateNewTokensForUser(AliasVaultUser user, string existingTokenValue)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
await Semaphore.WaitAsync();
Expand All @@ -699,7 +697,7 @@ private async Task<TokenModel> 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)
Expand All @@ -710,15 +708,14 @@ private async Task<TokenModel> GenerateNewTokensForUser(AliasVaultUser user, Ali
return new TokenModel { Token = accessToken, RefreshToken = existingTokenReuse.Value };
}

// Remove the existing refresh token.
var tokenToDelete = await context.AliasVaultUserRefreshTokens.FirstOrDefaultAsync(t => t.Id == existingToken.Id);
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)
{
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.TokenRefresh, AuthFailureReason.InvalidRefreshToken);
throw new InvalidOperationException("Refresh token does not exist (anymore).");
return null;
}

context.AliasVaultUserRefreshTokens.Remove(tokenToDelete);
context.AliasVaultUserRefreshTokens.Remove(existingToken);

// New refresh token lifetime is the same as the existing one.
var existingTokenLifetime = existingToken.ExpireDate - existingToken.CreatedAt;
Expand Down
182 changes: 139 additions & 43 deletions src/AliasVault.Client/Main/Components/Email/RecentEmails.razor
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<h3 class="mb-4 text-xl font-semibold dark:text-white">Email</h3>
</div>
<div class="flex justify-end items-center space-x-2">
@if (RefreshTimer is not null)
@if (DbService.Settings.AutoEmailRefresh)
{
<div class="w-3 h-3 mr-2 rounded-full bg-primary-300 border-2 border-primary-100 animate-pulse" title="Auto-refresh enabled"></div>
}
Expand Down Expand Up @@ -56,27 +56,27 @@
<div class="overflow-hidden shadow sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Subject
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Date &amp; Time
</th>
</tr>
<tr>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Subject
</th>
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
Date &amp; Time
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800">
@foreach (var mail in MailboxEmails)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@(mail.Subject.Substring(0, mail.Subject.Length > 30 ? 30 : mail.Subject.Length))...</span>
</td>
<td class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400">
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@mail.DateSystem</span>
</td>
</tr>
}
@foreach (var mail in MailboxEmails)
{
<tr class="hover:bg-gray-50 dark:hover:bg-gray-600">
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@(mail.Subject.Substring(0, mail.Subject.Length > 30 ? 30 : mail.Subject.Length))...</span>
</td>
<td class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400">
<span class="cursor-pointer" @onclick="() => OpenEmail(mail.Id)">@mail.DateSystem</span>
</td>
</tr>
}
</tbody>
</table>
</div>
Expand All @@ -99,13 +99,56 @@
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;

/// <summary>
/// Callback invoked by JavaScript when the page visibility changes.
/// </summary>
/// <param name="isVisible">Boolean whether the page is visible or not.</param>
/// <returns>Task.</returns>
[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.
if (PollingCancellationTokenSource is not null)
{
await PollingCancellationTokenSource.CancelAsync();
}
}
StateHasChanged();
}

/// <inheritdoc />
public void Dispose()
{
PollingCancellationTokenSource?.Cancel();
PollingCancellationTokenSource?.Dispose();
RefreshSemaphore.Dispose();
}

/// <inheritdoc />
protected override async Task OnInitializedAsync()
Expand All @@ -124,12 +167,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();
}
}

/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);

if (!ShowComponent)
{
return;
}

if (firstRender)
{
await ManualRefresh();
}
}

Expand All @@ -146,25 +206,62 @@
IsSpamOk = IsSpamOkDomain(EmailAddress);
}

/// <inheritdoc />
public void Dispose()
/// <summary>
/// Start the polling for new emails.
/// </summary>
/// <returns>Task.</returns>
private async Task StartPolling()
{
RefreshTimer?.Dispose();
if (PollingCancellationTokenSource is not null)
{
await PollingCancellationTokenSource.CancelAsync();
}

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
}
}

/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
/// <summary>
/// Refresh the emails with throttling to prevent multiple refreshes at the same time.
/// </summary>
/// <returns></returns>
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();
}
}

Expand All @@ -184,15 +281,10 @@
return Config.PrivateEmailDomains.Exists(x => email.EndsWith(x));
}

private async Task TimerRefresh()
{
IsRefreshing = true;
StateHasChanged();
await LoadRecentEmailsAsync();
IsRefreshing = false;
StateHasChanged();
}

/// <summary>
/// Manually refresh the emails.
/// </summary>
/// <returns></returns>
private async Task ManualRefresh()
{
IsLoading = true;
Expand All @@ -202,6 +294,10 @@
StateHasChanged();
}

/// <summary>
/// (Re)load recent emails by making an API call to the server.
/// </summary>
/// <returns>Task.</returns>
private async Task LoadRecentEmailsAsync()
{
if (!ShowComponent || EmailAddress is null)
Expand Down
11 changes: 11 additions & 0 deletions src/AliasVault.Client/Services/JsInteropService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace AliasVault.Client.Services;

using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.JSInterop;

/// <summary>
Expand Down Expand Up @@ -237,6 +238,16 @@ public async Task ScrollToTop()
await jsRuntime.InvokeVoidAsync("window.scrollTo", 0, 0);
}

/// <summary>
/// Registers a visibility callback which is invoked when the visibility of component changes in client.
/// </summary>
/// <typeparam name="TComponent">Component type.</typeparam>
/// <param name="objRef">DotNetObjectReference.</param>
/// <returns>Task.</returns>
public async Task RegisterVisibilityCallback<TComponent>(DotNetObjectReference<TComponent> objRef)
where TComponent : class =>
await jsRuntime.InvokeVoidAsync("window.registerVisibilityCallback", objRef);

/// <summary>
/// Represents the result of a WebAuthn get credential operation.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions src/AliasVault.Client/wwwroot/js/utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
};

0 comments on commit 1baea18

Please sign in to comment.