Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client logs out unexpectedly when kept open in background tab #427

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

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 @@
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 All @@ -245,17 +256,17 @@
/// <summary>
/// Gets the derived key.
/// </summary>
public string? DerivedKey { get; init; }

Check warning on line 259 in src/AliasVault.Client/Services/JsInteropService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Remove unassigned auto-property 'DerivedKey', or set its value. (https://rules.sonarsource.com/csharp/RSPEC-3459)

/// <summary>
/// Gets the optional error message.
/// </summary>
public string? Error { get; init; }

Check warning on line 264 in src/AliasVault.Client/Services/JsInteropService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Remove unassigned auto-property 'Error', or set its value. (https://rules.sonarsource.com/csharp/RSPEC-3459)

/// <summary>
/// Gets the optional additional error details.
/// </summary>
public string? Message { get; init; }

Check warning on line 269 in src/AliasVault.Client/Services/JsInteropService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Remove unassigned auto-property 'Message', or set its value. (https://rules.sonarsource.com/csharp/RSPEC-3459)
}

/// <summary>
Expand All @@ -281,11 +292,11 @@
/// <summary>
/// Gets the optional error message.
/// </summary>
public string? Error { get; init; }

Check warning on line 295 in src/AliasVault.Client/Services/JsInteropService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Remove unassigned auto-property 'Error', or set its value. (https://rules.sonarsource.com/csharp/RSPEC-3459)

/// <summary>
/// Gets the optional additional error details.
/// </summary>
public string? Message { get; init; }

Check warning on line 300 in src/AliasVault.Client/Services/JsInteropService.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Remove unassigned auto-property 'Message', or set its value. (https://rules.sonarsource.com/csharp/RSPEC-3459)
}
}
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);
});
};
Loading