diff --git a/Moonlight.Api/Configuration/ApiOptions.cs b/Moonlight.Api/Configuration/ApiOptions.cs new file mode 100644 index 00000000..134b1813 --- /dev/null +++ b/Moonlight.Api/Configuration/ApiOptions.cs @@ -0,0 +1,6 @@ +namespace Moonlight.Api.Configuration; + +public class ApiOptions +{ + public int LookupCacheMinutes { get; set; } = 3; +} \ No newline at end of file diff --git a/Moonlight.Api/Http/Controllers/Admin/UserActionsController.cs b/Moonlight.Api/Http/Controllers/Admin/UserActionsController.cs index 006e201d..0fbe7f00 100644 --- a/Moonlight.Api/Http/Controllers/Admin/UserActionsController.cs +++ b/Moonlight.Api/Http/Controllers/Admin/UserActionsController.cs @@ -38,7 +38,7 @@ public class UserActionsController : Controller user.InvalidateTimestamp = DateTimeOffset.UtcNow; await UsersRepository.UpdateAsync(user); - Cache.Remove(string.Format(UserAuthService.ValidationCacheKeyPattern, id)); + Cache.Remove(string.Format(UserAuthService.CacheKeyPattern, id)); return NoContent(); } diff --git a/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeHandler.cs b/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeHandler.cs new file mode 100644 index 00000000..9292a5ab --- /dev/null +++ b/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeHandler.cs @@ -0,0 +1,71 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moonlight.Api.Database; +using Moonlight.Api.Database.Entities; +using Moonlight.Shared; + +namespace Moonlight.Api.Implementations.ApiKeyScheme; + +public class ApiKeySchemeHandler : AuthenticationHandler +{ + private readonly DatabaseRepository ApiKeyRepository; + private readonly IMemoryCache MemoryCache; + + public ApiKeySchemeHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + DatabaseRepository apiKeyRepository, + IMemoryCache memoryCache + ) : base(options, logger, encoder) + { + ApiKeyRepository = apiKeyRepository; + MemoryCache = memoryCache; + } + + protected override async Task HandleAuthenticateAsync() + { + var authHeaderValue = Request.Headers.Authorization.FirstOrDefault() ?? null; + + if (string.IsNullOrWhiteSpace(authHeaderValue)) + return AuthenticateResult.NoResult(); + + if (authHeaderValue.Length > 32) + return AuthenticateResult.Fail("Invalid api key specified"); + + if (!MemoryCache.TryGetValue(authHeaderValue, out var apiKey)) + { + apiKey = await ApiKeyRepository + .Query() + .Where(x => x.Key == authHeaderValue) + .Select(x => new ApiKeySession(x.Permissions)) + .FirstOrDefaultAsync(); + + if (apiKey == null) + return AuthenticateResult.Fail("Invalid api key specified"); + + MemoryCache.Set(authHeaderValue, apiKey, Options.LookupCacheTime); + } + else + { + if (apiKey == null) + return AuthenticateResult.Fail("Invalid api key specified"); + } + + return AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal( + new ClaimsIdentity( + apiKey.Permissions.Select(x => new Claim(Permissions.ClaimType, x)).ToArray() + ) + ), + Scheme.Name + )); + } + + private record ApiKeySession(string[] Permissions); +} \ No newline at end of file diff --git a/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeOptions.cs b/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeOptions.cs new file mode 100644 index 00000000..5041e344 --- /dev/null +++ b/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeOptions.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Authentication; + +namespace Moonlight.Api.Implementations.ApiKeyScheme; + +public class ApiKeySchemeOptions : AuthenticationSchemeOptions +{ + public TimeSpan LookupCacheTime { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Api/Services/UserAuthService.cs b/Moonlight.Api/Services/UserAuthService.cs index 4a3211a9..f44b1d77 100644 --- a/Moonlight.Api/Services/UserAuthService.cs +++ b/Moonlight.Api/Services/UserAuthService.cs @@ -20,7 +20,7 @@ public class UserAuthService private const string UserIdClaim = "UserId"; private const string IssuedAtClaim = "IssuedAt"; - public const string ValidationCacheKeyPattern = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{{0}}"; + public const string CacheKeyPattern = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{{0}}"; public UserAuthService( DatabaseRepository userRepository, @@ -88,8 +88,8 @@ public class UserAuthService if (!int.TryParse(userIdString, out var userId)) return false; - var cacheKey = $"Moonlight.{nameof(UserAuthService)}.{nameof(ValidateAsync)}-{userId}"; - + var cacheKey = string.Format(CacheKeyPattern, userId); + if (!Cache.TryGetValue(cacheKey, out var user)) { user = await UserRepository diff --git a/Moonlight.Api/Startup/Startup.Auth.cs b/Moonlight.Api/Startup/Startup.Auth.cs index 35a53b10..fba19bd3 100644 --- a/Moonlight.Api/Startup/Startup.Auth.cs +++ b/Moonlight.Api/Startup/Startup.Auth.cs @@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Moonlight.Api.Configuration; using Moonlight.Api.Implementations; +using Moonlight.Api.Implementations.ApiKeyScheme; using Moonlight.Api.Services; namespace Moonlight.Api.Startup; @@ -18,9 +20,17 @@ public partial class Startup var oidcOptions = new OidcOptions(); builder.Configuration.GetSection("Moonlight:Oidc").Bind(oidcOptions); + var apiKeyOptions = new ApiOptions(); + builder.Configuration.GetSection("Moonlight:Api").Bind(apiKeyOptions); + builder.Services.AddOptions().BindConfiguration("Moonlight:Api"); + builder.Services.AddScoped(); - builder.Services.AddAuthentication("Session") + builder.Services.AddAuthentication("Main") + .AddPolicyScheme("Main", null, options => + { + options.ForwardDefaultSelector += context => context.Request.Headers.Authorization.Count > 0 ? "ApiKey" : "Session"; + }) .AddCookie("Session", null, options => { options.Events.OnSigningIn += async context => @@ -80,8 +90,14 @@ public partial class Startup options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email"); options.GetClaimsFromUserInfoEndpoint = true; + }) + .AddScheme("ApiKey", null, options => + { + options.LookupCacheTime = TimeSpan.FromMinutes(apiKeyOptions.LookupCacheMinutes); }); + builder.Logging.AddFilter("Moonlight.Api.Implementations.ApiKeyScheme.ApiKeySchemeHandler", LogLevel.Warning); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); } diff --git a/Moonlight.Shared/Http/Requests/ApiKeys/CreateApiKeyDto.cs b/Moonlight.Shared/Http/Requests/ApiKeys/CreateApiKeyDto.cs index c222b3dc..12b28620 100644 --- a/Moonlight.Shared/Http/Requests/ApiKeys/CreateApiKeyDto.cs +++ b/Moonlight.Shared/Http/Requests/ApiKeys/CreateApiKeyDto.cs @@ -6,9 +6,8 @@ public class CreateApiKeyDto { [MaxLength(30)] public string Name { get; set; } - - [MaxLength(300)] - public string Description { get; set; } + + [MaxLength(300)] public string Description { get; set; } = ""; public string[] Permissions { get; set; } } \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/ApiKeys/UpdateApiKeyDto.cs b/Moonlight.Shared/Http/Requests/ApiKeys/UpdateApiKeyDto.cs index 633017ad..20f64b56 100644 --- a/Moonlight.Shared/Http/Requests/ApiKeys/UpdateApiKeyDto.cs +++ b/Moonlight.Shared/Http/Requests/ApiKeys/UpdateApiKeyDto.cs @@ -6,9 +6,8 @@ public class UpdateApiKeyDto { [MaxLength(30)] public string Name { get; set; } - - [MaxLength(300)] - public string Description { get; set; } + + [MaxLength(300)] public string Description { get; set; } = ""; public string[] Permissions { get; set; } } \ No newline at end of file