Make UserRefreshToken lifetime configurable via admin (#498)

This commit is contained in:
Leendert de Borst
2024-12-28 16:35:21 +01:00
parent 888054e8ed
commit 32879e09a8
9 changed files with 78 additions and 12 deletions

View File

@@ -20,6 +20,22 @@
</PageHeader>
<div class="px-4">
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Authentication Settings</h3>
<div class="grid gap-4 mb-4 sm:grid-cols-2 sm:gap-6 sm:mb-5">
<div>
<label for="refreshTokenShort" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Short Refresh Token Lifetime (hours)</label>
<input type="number" @bind="Settings.RefreshTokenLifetimeShort" id="refreshTokenShort" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Determines how long the user stays logged in after inactivity. Used when "Remember me" is not checked during login.</p>
</div>
<div>
<label for="refreshTokenLong" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Long Refresh Token Lifetime (hours)</label>
<input type="number" @bind="Settings.RefreshTokenLifetimeLong" id="refreshTokenLong" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Determines how long the user stays logged in after inactivity. Used when "Remember me" is checked during login.</p>
</div>
</div>
</div>
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Data Retention</h3>
<div class="grid gap-4 mb-4 sm:grid-cols-2 sm:gap-6 sm:mb-5">

View File

@@ -34,6 +34,7 @@
<ItemGroup>
<ProjectReference Include="..\Databases\AliasServerDb\AliasServerDb.csproj" />
<ProjectReference Include="..\Shared\AliasVault.Shared.Server\AliasVault.Shared.Server.csproj" />
<ProjectReference Include="..\Shared\AliasVault.Shared\AliasVault.Shared.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.Auth\AliasVault.Auth.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.Logging\AliasVault.Logging.csproj" />

View File

@@ -20,6 +20,7 @@ using AliasVault.Shared.Models.WebApi;
using AliasVault.Shared.Models.WebApi.Auth;
using AliasVault.Shared.Models.WebApi.PasswordChange;
using AliasVault.Shared.Providers.Time;
using AliasVault.Shared.Server.Services;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@@ -40,10 +41,11 @@ using SecureRemotePassword;
/// <param name="timeProvider">ITimeProvider instance. This returns the time which can be mutated for testing.</param>
/// <param name="authLoggingService">AuthLoggingService instance. This is used to log auth attempts to the database.</param>
/// <param name="config">Config instance.</param>
/// <param name="settingsService">ServerSettingsService instance.</param>
[Route("v{version:apiVersion}/[controller]")]
[ApiController]
[ApiVersion("1")]
public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, SignInManager<AliasVaultUser> signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider, AuthLoggingService authLoggingService, Config config) : ControllerBase
public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, SignInManager<AliasVaultUser> signInManager, IConfiguration configuration, IMemoryCache cache, ITimeProvider timeProvider, AuthLoggingService authLoggingService, Config config, ServerSettingsService settingsService) : ControllerBase
{
/// <summary>
/// Error message for invalid username or password.
@@ -688,18 +690,14 @@ public class AuthController(IDbContextFactory<AliasServerDbContext> dbContextFac
private async Task<TokenModel> GenerateNewTokensForUser(AliasVaultUser user, bool extendedLifetime = false)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var settings = await settingsService.GetAllSettingsAsync();
await Semaphore.WaitAsync();
try
{
// Determine the refresh token lifetime.
// - 4 hours by default.
// - 7 days if "remember me" was checked during login.
var refreshTokenLifetime = TimeSpan.FromHours(4);
if (extendedLifetime)
{
refreshTokenLifetime = TimeSpan.FromDays(7);
}
// Use server settings for refresh token lifetime.
var refreshTokenLifetimeHours = extendedLifetime ? settings.RefreshTokenLifetimeLong : settings.RefreshTokenLifetimeShort;
var refreshTokenLifetime = TimeSpan.FromHours(refreshTokenLifetimeHours);
// Return new refresh token.
return await GenerateRefreshToken(user, refreshTokenLifetime);

View File

@@ -15,6 +15,7 @@ using AliasVault.Auth;
using AliasVault.Cryptography.Server;
using AliasVault.Logging;
using AliasVault.Shared.Providers.Time;
using AliasVault.Shared.Server.Services;
using Asp.Versioning;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
@@ -38,6 +39,7 @@ builder.Services.AddAliasVaultDataProtection("AliasVault.Api");
builder.Services.AddSingleton<ITimeProvider, SystemTimeProvider>();
builder.Services.AddScoped<TimeValidationJwtBearerEvents>();
builder.Services.AddScoped<AuthLoggingService>();
builder.Services.AddScoped<ServerSettingsService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddLogging(logging =>

View File

@@ -41,4 +41,16 @@ public class ServerSettingsModel
/// Gets or sets the task runner days. Defaults to all days of the week.
/// </summary>
public List<int> TaskRunnerDays { get; set; } = [1, 2, 3, 4, 5, 6, 7];
/// <summary>
/// Gets or sets the short refresh token lifetime in hours. Defaults to 8 hours.
/// Used when "Remember me" is not checked.
/// </summary>
public int RefreshTokenLifetimeShort { get; set; } = 8;
/// <summary>
/// Gets or sets the long refresh token lifetime in hours. Defaults to 336 hours / 14 days.
/// Used when "Remember me" is checked.
/// </summary>
public int RefreshTokenLifetimeLong { get; set; } = 336;
}

View File

@@ -145,6 +145,16 @@ public class ServerSettingsService(IDbContextFactory<AliasServerDbContext> dbCon
}
}
if (int.TryParse(settings.GetValueOrDefault("RefreshTokenLifetimeShort"), out var shortLifetime))
{
model.RefreshTokenLifetimeShort = shortLifetime;
}
if (int.TryParse(settings.GetValueOrDefault("RefreshTokenLifetimeLong"), out var longLifetime))
{
model.RefreshTokenLifetimeLong = longLifetime;
}
return model;
}
@@ -161,5 +171,7 @@ public class ServerSettingsService(IDbContextFactory<AliasServerDbContext> dbCon
await SetSettingAsync("MaxEmailsPerUser", model.MaxEmailsPerUser.ToString());
await SetSettingAsync("MaintenanceTime", model.MaintenanceTime.ToString("HH:mm", CultureInfo.InvariantCulture));
await SetSettingAsync("TaskRunnerDays", string.Join(",", model.TaskRunnerDays));
await SetSettingAsync("RefreshTokenLifetimeShort", model.RefreshTokenLifetimeShort.ToString());
await SetSettingAsync("RefreshTokenLifetimeLong", model.RefreshTokenLifetimeLong.ToString());
}
}

View File

@@ -9,6 +9,7 @@ namespace AliasVault.E2ETests.Common;
using AliasServerDb;
using AliasVault.Shared.Providers.Time;
using AliasVault.Shared.Server.Services;
using Microsoft.Playwright;
/// <summary>
@@ -45,6 +46,11 @@ public class ClientPlaywrightTest : PlaywrightTest
/// </summary>
protected AliasServerDbContext ApiDbContext => _apiFactory.GetDbContext();
/// <summary>
/// Gets the server settings service for the WebAPI project.
/// </summary>
protected ServerSettingsService ApiServerSettings => _apiFactory.GetServerSettings();
/// <summary>
/// Gets or sets the base URL where the WebAPI project runs on including random port.
/// </summary>

View File

@@ -10,6 +10,7 @@ namespace AliasVault.E2ETests.Infrastructure;
using System.Data.Common;
using AliasServerDb;
using AliasVault.Shared.Providers.Time;
using AliasVault.Shared.Server.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Mvc.Testing;
@@ -35,6 +36,11 @@ public class WebApplicationApiFactoryFixture<TEntryPoint> : WebApplicationFactor
/// </summary>
private IDbContextFactory<AliasServerDbContext> _dbContextFactory = null!;
/// <summary>
/// The ServerSettingsService instance that is created for the test.
/// </summary>
private IServiceScope? _scope;
/// <summary>
/// The cached DbContext instance that can be used during the test.
/// </summary>
@@ -74,12 +80,24 @@ public class WebApplicationApiFactoryFixture<TEntryPoint> : WebApplicationFactor
return _dbContext;
}
/// <summary>
/// Gets the ServerSettingsService instance for mutating server settings in tests.
/// </summary>
/// <returns>ServerSettingsService instance.</returns>
public ServerSettingsService GetServerSettings()
{
_scope?.Dispose();
_scope = Services.CreateScope();
return _scope.ServiceProvider.GetRequiredService<ServerSettingsService>();
}
/// <summary>
/// Disposes the DbConnection instance.
/// </summary>
/// <returns>ValueTask.</returns>
public override ValueTask DisposeAsync()
{
_scope?.Dispose();
_dbConnection.Dispose();
GC.SuppressFinalize(this);
return base.DisposeAsync();

View File

@@ -31,12 +31,13 @@ public class AuthTests : ClientPlaywrightTest
var authLogEntry = await ApiDbContext.AuthLogs.FirstOrDefaultAsync(x => x.Username == TestUserUsername && x.EventType == AuthEventType.Register);
Assert.That(authLogEntry, Is.Not.Null, "Auth log entry not found in database after registration.");
// Check if the refresh token is stored in the database and its expiration date is set 7 days in the future
// Check if the refresh token is stored in the database and its expiration date is set to the long lifetime
// after registration. The registration page does not have a "Remember me" checkbox, but it is assumed that
// the device is trusted so the refresh token will be valid for the extended duration: 7 days.
// the device is trusted so the refresh token will be valid for the extended duration.
var settings = await ApiServerSettings.GetAllSettingsAsync();
var refreshToken = await ApiDbContext.AliasVaultUserRefreshTokens.FirstOrDefaultAsync();
Assert.That(refreshToken, Is.Not.Null, "Refresh token not found in database after login.");
Assert.That(refreshToken.ExpireDate, Is.EqualTo(refreshToken.CreatedAt.AddDays(7)), "Refresh token expiration date is not 7 days in the future while rememberMe checkbox was checked.");
Assert.That(refreshToken.ExpireDate, Is.EqualTo(refreshToken.CreatedAt.AddHours(settings.RefreshTokenLifetimeLong)), "Refresh token expiration date does not match the configured long lifetime while rememberMe was checked.");
}
/// <summary>