Implemented hybrid cache for user sessions, api keys and database provided settings. Cleaned up startup and adjusted caching option models for features

This commit is contained in:
2026-02-12 15:29:35 +01:00
parent dd44e5bb86
commit 741a60adc6
19 changed files with 240 additions and 132 deletions

1
.gitignore vendored
View File

@@ -406,3 +406,4 @@ FodyWeavers.xsd
**/.env
**/appsettings.json
**/appsettings.Development.json
**/storage

View File

@@ -2,5 +2,6 @@ namespace Moonlight.Api.Configuration;
public class ApiOptions
{
public int LookupCacheMinutes { get; set; } = 3;
public TimeSpan LookupCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan LookupCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
}

View File

@@ -0,0 +1,6 @@
namespace Moonlight.Api.Configuration;
public class CacheOptions
{
public bool EnableLayer2 { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class RedisOptions
{
public bool Enable { get; set; }
public string ConnectionString { get; set; }
}

View File

@@ -1,6 +0,0 @@
namespace Moonlight.Api.Configuration;
public class SessionOptions
{
public int ValidationCacheMinutes { get; set; } = 3;
}

View File

@@ -2,5 +2,6 @@
public class SettingsOptions
{
public int CacheMinutes { get; set; } = 3;
public TimeSpan LookupL1CacheTime { get; set; } = TimeSpan.FromMinutes(1);
public TimeSpan LookupL2CacheTime { get; set; } = TimeSpan.FromMinutes(5);
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.Api.Configuration;
public class UserOptions
{
public TimeSpan ValidationCacheL1Expiry { get; set; } = TimeSpan.FromSeconds(30);
public TimeSpan ValidationCacheL2Expiry { get; set; } = TimeSpan.FromMinutes(3);
}

View File

@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Hybrid;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Implementations.ApiKeyScheme;
using Moonlight.Api.Mappers;
using Moonlight.Shared;
using Moonlight.Shared.Http.Requests;
@@ -18,10 +20,12 @@ namespace Moonlight.Api.Http.Controllers.Admin;
public class ApiKeyController : Controller
{
private readonly DatabaseRepository<ApiKey> KeyRepository;
private readonly HybridCache HybridCache;
public ApiKeyController(DatabaseRepository<ApiKey> keyRepository)
public ApiKeyController(DatabaseRepository<ApiKey> keyRepository, HybridCache hybridCache)
{
KeyRepository = keyRepository;
HybridCache = hybridCache;
}
[HttpGet]
@@ -114,6 +118,8 @@ public class ApiKeyController : Controller
ApiKeyMapper.Merge(apiKey, request);
await KeyRepository.UpdateAsync(apiKey);
await HybridCache.RemoveAsync(string.Format(ApiKeySchemeHandler.CacheKeyFormat, apiKey.Key));
return ApiKeyMapper.ToDto(apiKey);
}
@@ -129,6 +135,9 @@ public class ApiKeyController : Controller
return Problem("No API key with this id found", statusCode: 404);
await KeyRepository.RemoveAsync(apiKey);
await HybridCache.RemoveAsync(string.Format(ApiKeySchemeHandler.CacheKeyFormat, apiKey.Key));
return NoContent();
}
}

View File

@@ -2,7 +2,7 @@ using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moonlight.Api.Database;
@@ -14,20 +14,20 @@ namespace Moonlight.Api.Implementations.ApiKeyScheme;
public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
{
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
private readonly IMemoryCache MemoryCache;
private readonly HybridCache HybridCache;
private const string CacheKeyFormat = $"Moonlight.Api.{nameof(ApiKeySchemeHandler)}.{{0}}";
public const string CacheKeyFormat = $"Moonlight.Api.{nameof(ApiKeySchemeHandler)}.{{0}}";
public ApiKeySchemeHandler(
IOptionsMonitor<ApiKeySchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
DatabaseRepository<ApiKey> apiKeyRepository,
IMemoryCache memoryCache
HybridCache hybridCache
) : base(options, logger, encoder)
{
ApiKeyRepository = apiKeyRepository;
MemoryCache = memoryCache;
HybridCache = hybridCache;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
@@ -42,24 +42,29 @@ public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
var cacheKey = string.Format(CacheKeyFormat, authHeaderValue);
if (!MemoryCache.TryGetValue<ApiKeySession>(cacheKey, out var apiKey))
var apiKey = await HybridCache.GetOrCreateAsync<ApiKeySession?>(
cacheKey,
async ct =>
{
apiKey = await ApiKeyRepository
var x = await ApiKeyRepository
.Query()
.Where(x => x.Key == authHeaderValue)
.Select(x => new ApiKeySession(x.Permissions, x.ValidUntil))
.FirstOrDefaultAsync();
.FirstOrDefaultAsync(cancellationToken: ct);
if (apiKey == null)
return AuthenticateResult.Fail("Invalid api key specified");
Console.WriteLine($"API: {x?.ValidUntil}");
MemoryCache.Set(cacheKey, apiKey, Options.LookupCacheTime);
}
else
return x;
},
new HybridCacheEntryOptions()
{
LocalCacheExpiration = Options.LookupL1CacheTime,
Expiration = Options.LookupL2CacheTime
}
);
if (apiKey == null)
return AuthenticateResult.Fail("Invalid api key specified");
}
if (DateTimeOffset.UtcNow > apiKey.ValidUntil)
return AuthenticateResult.Fail("Api key expired");

View File

@@ -4,5 +4,6 @@ namespace Moonlight.Api.Implementations.ApiKeyScheme;
public class ApiKeySchemeOptions : AuthenticationSchemeOptions
{
public TimeSpan LookupCacheTime { get; set; }
public TimeSpan LookupL1CacheTime { get; set; }
public TimeSpan LookupL2CacheTime { get; set; }
}

View File

@@ -26,6 +26,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1"/>
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.3.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0"/>
<PackageReference Include="Riok.Mapperly" Version="4.3.1-next.0"/>
</ItemGroup>

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
using Moonlight.Api.Database;
@@ -12,18 +12,18 @@ public class SettingsService
{
private readonly DatabaseRepository<SettingsOption> Repository;
private readonly IOptions<SettingsOptions> Options;
private readonly IMemoryCache Cache;
private readonly HybridCache HybridCache;
private const string CacheKey = "Moonlight.Api.SettingsService.{0}";
public SettingsService(
DatabaseRepository<SettingsOption> repository,
IOptions<SettingsOptions> options,
IMemoryCache cache
HybridCache hybridCache
)
{
Repository = repository;
Cache = cache;
HybridCache = hybridCache;
Options = options;
}
@@ -31,24 +31,26 @@ public class SettingsService
{
var cacheKey = string.Format(CacheKey, key);
if (Cache.TryGetValue<string>(cacheKey, out var value))
return JsonSerializer.Deserialize<T>(value!);
value = await Repository
var value = await HybridCache.GetOrCreateAsync<string?>(
cacheKey,
async ct =>
{
return await Repository
.Query()
.Where(x => x.Key == key)
.Select(o => o.ValueJson)
.FirstOrDefaultAsync();
if(string.IsNullOrEmpty(value))
return default;
Cache.Set(
cacheKey,
value,
TimeSpan.FromMinutes(Options.Value.CacheMinutes)
.FirstOrDefaultAsync(cancellationToken: ct);
},
new HybridCacheEntryOptions()
{
LocalCacheExpiration = Options.Value.LookupL1CacheTime,
Expiration = Options.Value.LookupL2CacheTime
}
);
if (string.IsNullOrWhiteSpace(value))
return default;
return JsonSerializer.Deserialize<T>(value);
}
@@ -78,6 +80,6 @@ public class SettingsService
await Repository.AddAsync(option);
}
Cache.Remove(cacheKey);
await HybridCache.RemoveAsync(cacheKey);
}
}

View File

@@ -1,6 +1,6 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moonlight.Api.Configuration;
@@ -14,10 +14,10 @@ namespace Moonlight.Api.Services;
public class UserAuthService
{
private readonly DatabaseRepository<User> UserRepository;
private readonly IMemoryCache Cache;
private readonly ILogger<UserAuthService> Logger;
private readonly IOptions<SessionOptions> Options;
private readonly IOptions<UserOptions> Options;
private readonly IEnumerable<IUserAuthHook> Hooks;
private readonly HybridCache HybridCache;
private const string UserIdClaim = "UserId";
private const string IssuedAtClaim = "IssuedAt";
@@ -27,15 +27,16 @@ public class UserAuthService
public UserAuthService(
DatabaseRepository<User> userRepository,
ILogger<UserAuthService> logger,
IMemoryCache cache, IOptions<SessionOptions> options,
IEnumerable<IUserAuthHook> hooks
IOptions<UserOptions> options,
IEnumerable<IUserAuthHook> hooks,
HybridCache hybridCache
)
{
UserRepository = userRepository;
Logger = logger;
Cache = cache;
Options = options;
Hooks = hooks;
HybridCache = hybridCache;
}
public async Task<bool> SyncAsync(ClaimsPrincipal? principal)
@@ -80,7 +81,7 @@ public class UserAuthService
foreach (var hook in Hooks)
{
// Run every hook and if any returns false we return false as well
// Run every hook, and if any returns false, we return false as well
if (!await hook.SyncAsync(principal, user))
return false;
}
@@ -101,9 +102,11 @@ public class UserAuthService
var cacheKey = string.Format(CacheKeyPattern, userId);
if (!Cache.TryGetValue<UserSession>(cacheKey, out var user))
var user = await HybridCache.GetOrCreateAsync<UserSession?>(
cacheKey,
async ct =>
{
user = await UserRepository
return await UserRepository
.Query()
.AsNoTracking()
.Where(u => u.Id == userId)
@@ -111,22 +114,17 @@ public class UserAuthService
u.InvalidateTimestamp,
u.RoleMemberships.SelectMany(x => x.Role.Permissions).ToArray())
)
.FirstOrDefaultAsync();
if (user == null)
return false;
Cache.Set(
cacheKey,
user,
TimeSpan.FromMinutes(Options.Value.ValidationCacheMinutes)
);
}
else
.FirstOrDefaultAsync(cancellationToken: ct);
},
new HybridCacheEntryOptions()
{
LocalCacheExpiration = Options.Value.ValidationCacheL1Expiry,
Expiration = Options.Value.ValidationCacheL2Expiry
}
);
if (user == null)
return false;
}
var issuedAtString = principal.FindFirstValue(IssuedAtClaim);
@@ -149,7 +147,7 @@ public class UserAuthService
foreach (var hook in Hooks)
{
// Run every hook and if any returns false we return false as well
// Run every hook, and if any returns false we return false as well
if (!await hook.ValidateAsync(principal, userId))
return false;
}

View File

@@ -1,5 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Hybrid;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
@@ -10,13 +10,17 @@ public class UserDeletionService
{
private readonly DatabaseRepository<User> Repository;
private readonly IEnumerable<IUserDeletionHook> Hooks;
private readonly IMemoryCache Cache;
private readonly HybridCache HybridCache;
public UserDeletionService(DatabaseRepository<User> repository, IEnumerable<IUserDeletionHook> hooks, IMemoryCache cache)
public UserDeletionService(
DatabaseRepository<User> repository,
IEnumerable<IUserDeletionHook> hooks,
HybridCache hybridCache
)
{
Repository = repository;
Hooks = hooks;
Cache = cache;
HybridCache = hybridCache;
}
public async Task<UserDeletionValidationResult> ValidateAsync(int userId)
@@ -54,7 +58,8 @@ public class UserDeletionService
await hook.ExecuteAsync(user);
await Repository.RemoveAsync(user);
Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, userId));
await HybridCache.RemoveAsync(string.Format(UserAuthService.CacheKeyPattern, user.Id));
}
}

View File

@@ -1,5 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Hybrid;
using Moonlight.Api.Database;
using Moonlight.Api.Database.Entities;
using Moonlight.Api.Interfaces;
@@ -10,17 +10,17 @@ public class UserLogoutService
{
private readonly DatabaseRepository<User> Repository;
private readonly IEnumerable<IUserLogoutHook> Hooks;
private readonly IMemoryCache Cache;
private readonly HybridCache HybridCache;
public UserLogoutService(
DatabaseRepository<User> repository,
IEnumerable<IUserLogoutHook> hooks,
IMemoryCache cache
HybridCache hybridCache
)
{
Repository = repository;
Hooks = hooks;
Cache = cache;
HybridCache = hybridCache;
}
public async Task LogoutAsync(int userId)
@@ -38,6 +38,6 @@ public class UserLogoutService
user.InvalidateTimestamp = DateTimeOffset.UtcNow;
await Repository.UpdateAsync(user);
Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, userId));
await HybridCache.RemoveAsync(string.Format(UserAuthService.CacheKeyPattern, user.Id));
}
}

View File

@@ -17,19 +17,25 @@ public partial class Startup
{
private static void AddAuth(WebApplicationBuilder builder)
{
// OIDC
var oidcOptions = new OidcOptions();
builder.Configuration.GetSection("Moonlight:Oidc").Bind(oidcOptions);
// API Key
var apiKeyOptions = new ApiOptions();
builder.Configuration.GetSection("Moonlight:Api").Bind(apiKeyOptions);
builder.Services.AddOptions<ApiOptions>().BindConfiguration("Moonlight:Api");
builder.Services.AddScoped<UserAuthService>();
// Session
builder.Services.AddOptions<UserOptions>().BindConfiguration("Moonlight:User");
// Authentication
builder.Services.AddAuthentication("Main")
.AddPolicyScheme("Main", null, options =>
.AddPolicyScheme("Main", null,
options =>
{
options.ForwardDefaultSelector += context => context.Request.Headers.Authorization.Count > 0 ? "ApiKey" : "Session";
options.ForwardDefaultSelector += context =>
context.Request.Headers.Authorization.Count > 0 ? "ApiKey" : "Session";
})
.AddCookie("Session", null, options =>
{
@@ -97,18 +103,26 @@ public partial class Startup
options.GetClaimsFromUserInfoEndpoint = true;
})
.AddScheme<ApiKeySchemeOptions, ApiKeySchemeHandler>("ApiKey", null, options =>
.AddScheme<ApiKeySchemeOptions, ApiKeySchemeHandler>("ApiKey", null,
options =>
{
options.LookupCacheTime = TimeSpan.FromMinutes(apiKeyOptions.LookupCacheMinutes);
options.LookupL1CacheTime = apiKeyOptions.LookupCacheL1Expiry;
options.LookupL2CacheTime = apiKeyOptions.LookupCacheL2Expiry;
});
// Authorization
builder.Services.AddAuthorization();
// Reduce log noise
builder.Logging.AddFilter("Moonlight.Api.Implementations.ApiKeyScheme.ApiKeySchemeHandler", LogLevel.Warning);
// Custom permission handling using named policies
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddOptions<SettingsOptions>().BindConfiguration("Moonlight:Settings");
builder.Services.AddScoped<SettingsService>();
builder.Services.AddScoped<UserDeletionService>();
builder.Services.AddScoped<UserLogoutService>();
builder.Services.AddScoped<UserAuthService>();
}
private static void UseAuth(WebApplication application)

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;
@@ -9,7 +11,6 @@ using Moonlight.Api.Helpers;
using Moonlight.Api.Implementations;
using Moonlight.Api.Interfaces;
using Moonlight.Api.Services;
using SessionOptions = Moonlight.Api.Configuration.SessionOptions;
namespace Moonlight.Api.Startup;
@@ -17,44 +18,63 @@ public partial class Startup
{
private static void AddBase(WebApplicationBuilder builder)
{
// Create the base directory
Directory.CreateDirectory("storage");
// Hook up source-generated serialization
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.TypeInfoResolverChain.Add(SerializationContext.Default);
});
// Configure logging
builder.Logging.ClearProviders();
builder.Logging.AddConsole(options => { options.FormatterName = nameof(AppConsoleFormatter); });
builder.Logging.AddConsoleFormatter<AppConsoleFormatter, ConsoleFormatterOptions>();
// Application service
builder.Services.AddSingleton<ApplicationService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ApplicationService>());
// Diagnose
builder.Services.AddSingleton<DiagnoseService>();
builder.Services.AddSingleton<IDiagnoseProvider, UpdateDiagnoseProvider>();
builder.Services.AddMemoryCache();
builder.Services.AddOptions<SessionOptions>().BindConfiguration("Moonlight:Session");
// Frontend
builder.Services.AddOptions<FrontendOptions>().BindConfiguration("Moonlight:Frontend");
builder.Services.AddScoped<FrontendService>();
// HTTP Client
builder.Services.AddHttpClient();
// Version fetching configuration
builder.Services.AddOptions<VersionOptions>().BindConfiguration("Moonlight:Version");
builder.Services.AddSingleton<VersionService>();
// Container Helper Options
builder.Configuration.GetSection("Moonlight:ContainerHelper").Bind(builder.Configuration);
builder.Services.AddOptions<ContainerHelperOptions>().BindConfiguration("Moonlight:ContainerHelper");
builder.Services.AddSingleton<ContainerHelperService>();
builder.Services.AddHttpClient("ContainerHelper", (provider, client) =>
{
var options = provider.GetRequiredService<IOptions<ContainerHelperOptions>>();
client.BaseAddress = new Uri(options.Value.IsEnabled ? options.Value.Url : "http://you-should-fail.invalid");
client.BaseAddress =
new Uri(options.Value.IsEnabled ? options.Value.Url : "http://you-should-fail.invalid");
});
// User management services
builder.Services.AddScoped<UserDeletionService>();
builder.Services.AddScoped<UserLogoutService>();
// Settings options
builder.Services.AddOptions<SettingsOptions>().BindConfiguration("Moonlight:Settings");
builder.Services.AddScoped<SettingsService>();
// Setup key loading
var keysDirectory = new DirectoryInfo(Path.Combine("storage", "keys"));
builder.Services.AddDataProtection().PersistKeysToFileSystem(keysDirectory);
}
private static void UseBase(WebApplication application)

View File

@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Moonlight.Api.Configuration;
namespace Moonlight.Api.Startup;
public partial class Startup
{
private static void AddCache(WebApplicationBuilder builder)
{
// Load cache options
var cacheOptions = new CacheOptions();
builder.Configuration.GetSection("Moonlight:Cache").Bind(cacheOptions);
builder.Services.AddMemoryCache();
builder.Services.AddHybridCache();
if (!cacheOptions.EnableLayer2)
return;
var redisOptions = new RedisOptions();
builder.Configuration.GetSection("Moonlight:Redis").Bind(redisOptions);
if(!redisOptions.Enable)
return;
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = redisOptions.ConnectionString;
options.InstanceName = "Moonlight:";
});
}
}

View File

@@ -9,6 +9,7 @@ public partial class Startup : IAppStartup
AddBase(builder);
AddAuth(builder);
AddDatabase(builder);
AddCache(builder);
}
public void PostBuild(WebApplication application)