diff --git a/Moonlight.Api/Database/Entities/ApiKey.cs b/Moonlight.Api/Database/Entities/ApiKey.cs index 5bbbd4f6..3073f0c6 100644 --- a/Moonlight.Api/Database/Entities/ApiKey.cs +++ b/Moonlight.Api/Database/Entities/ApiKey.cs @@ -14,6 +14,7 @@ public class ApiKey : IActionTimestamps public required string Description { get; set; } public string[] Permissions { get; set; } = []; + public DateTimeOffset ValidUntil { get; set; } [MaxLength(32)] public string Key { get; set; } diff --git a/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.Designer.cs b/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.Designer.cs new file mode 100644 index 00000000..03bdc39f --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.Designer.cs @@ -0,0 +1,254 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Moonlight.Api.Database; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Moonlight.Api.Database.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20260209114238_AddedValidUntilToApiKeys")] + partial class AddedValidUntilToApiKeys + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("core") + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ApiKeys", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Roles", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("RoleMembers", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.SettingsOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ValueJson") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("SettingsOptions", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.Theme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CssContent") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.HasKey("Id"); + + b.ToTable("Themes", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("InvalidateTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Users", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b => + { + b.HasOne("Moonlight.Api.Database.Entities.Role", "Role") + .WithMany("Members") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Moonlight.Api.Database.Entities.User", "User") + .WithMany("RoleMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b => + { + b.Navigation("RoleMemberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.cs b/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.cs new file mode 100644 index 00000000..dc7f1857 --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20260209114238_AddedValidUntilToApiKeys.cs @@ -0,0 +1,32 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Moonlight.Api.Database.Migrations +{ + /// + public partial class AddedValidUntilToApiKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ValidUntil", + schema: "core", + table: "ApiKeys", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ValidUntil", + schema: "core", + table: "ApiKeys"); + } + } +} diff --git a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs index 53294dcf..2945a90c 100644 --- a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs +++ b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs @@ -56,6 +56,9 @@ namespace Moonlight.Api.Database.Migrations b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.ToTable("ApiKeys", "core"); diff --git a/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeHandler.cs b/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeHandler.cs index 9292a5ab..dcc9a483 100644 --- a/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeHandler.cs +++ b/Moonlight.Api/Implementations/ApiKeyScheme/ApiKeySchemeHandler.cs @@ -16,6 +16,8 @@ public class ApiKeySchemeHandler : AuthenticationHandler private readonly DatabaseRepository ApiKeyRepository; private readonly IMemoryCache MemoryCache; + private const string CacheKeyFormat = $"Moonlight.Api.{nameof(ApiKeySchemeHandler)}.{{0}}"; + public ApiKeySchemeHandler( IOptionsMonitor options, ILoggerFactory logger, @@ -38,18 +40,20 @@ public class ApiKeySchemeHandler : AuthenticationHandler if (authHeaderValue.Length > 32) return AuthenticateResult.Fail("Invalid api key specified"); - if (!MemoryCache.TryGetValue(authHeaderValue, out var apiKey)) + var cacheKey = string.Format(CacheKeyFormat, authHeaderValue); + + if (!MemoryCache.TryGetValue(cacheKey, out var apiKey)) { apiKey = await ApiKeyRepository .Query() .Where(x => x.Key == authHeaderValue) - .Select(x => new ApiKeySession(x.Permissions)) + .Select(x => new ApiKeySession(x.Permissions, x.ValidUntil)) .FirstOrDefaultAsync(); if (apiKey == null) return AuthenticateResult.Fail("Invalid api key specified"); - MemoryCache.Set(authHeaderValue, apiKey, Options.LookupCacheTime); + MemoryCache.Set(cacheKey, apiKey, Options.LookupCacheTime); } else { @@ -57,6 +61,9 @@ public class ApiKeySchemeHandler : AuthenticationHandler return AuthenticateResult.Fail("Invalid api key specified"); } + if (DateTimeOffset.UtcNow > apiKey.ValidUntil) + return AuthenticateResult.Fail("Api key expired"); + return AuthenticateResult.Success(new AuthenticationTicket( new ClaimsPrincipal( new ClaimsIdentity( @@ -67,5 +74,5 @@ public class ApiKeySchemeHandler : AuthenticationHandler )); } - private record ApiKeySession(string[] Permissions); + private record ApiKeySession(string[] Permissions, DateTimeOffset ValidUntil); } \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Modals/CreateApiKeyDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/CreateApiKeyDialog.razor index 459164c5..37f2b36f 100644 --- a/Moonlight.Frontend/UI/Admin/Modals/CreateApiKeyDialog.razor +++ b/Moonlight.Frontend/UI/Admin/Modals/CreateApiKeyDialog.razor @@ -35,6 +35,10 @@ Description + + Valid until + + Permissions @@ -60,13 +64,15 @@ { Request = new() { - Permissions = [] + Permissions = [], + ValidUntil = DateTimeOffset.UtcNow }; } private async Task OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore) { Request.Permissions = Permissions.ToArray(); + Request.ValidUntil = Request.ValidUntil.ToUniversalTime(); var response = await HttpClient.PostAsJsonAsync( "/api/admin/apiKeys", diff --git a/Moonlight.Frontend/UI/Admin/Modals/UpdateApiKeyDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/UpdateApiKeyDialog.razor index f9e4aef9..75c75115 100644 --- a/Moonlight.Frontend/UI/Admin/Modals/UpdateApiKeyDialog.razor +++ b/Moonlight.Frontend/UI/Admin/Modals/UpdateApiKeyDialog.razor @@ -35,6 +35,10 @@ Description + + Valid until + + Permissions @@ -65,6 +69,7 @@ private async Task OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore) { Request.Permissions = Permissions.ToArray(); + Request.ValidUntil = Request.ValidUntil.ToUniversalTime(); var response = await HttpClient.PatchAsJsonAsync( $"/api/admin/apiKeys/{Key.Id}", diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/ApiKeys.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/ApiKeys.razor index 9b76f917..a4cfda72 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Sys/ApiKeys.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/ApiKeys.razor @@ -48,7 +48,21 @@ + Identifier="@nameof(ApiKeyDto.Description)" Field="k => k.Description" HeadClassName="hidden lg:table-cell" CellClassName="hidden lg:table-cell" /> + + + + @{ + var diff = context.ValidUntil - DateTimeOffset.UtcNow; + var text = string.Format(diff.TotalSeconds < 0 ? "Expired since {0}" : "Expires in {0}", Formatter.FormatDuration(diff)); + } + + + @text + + + + diff --git a/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyDto.cs b/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyDto.cs index 2b60a525..1b58d832 100644 --- a/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyDto.cs +++ b/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyDto.cs @@ -4,13 +4,9 @@ namespace Moonlight.Shared.Http.Requests.Admin.ApiKeys; public class CreateApiKeyDto { - [Required] - [MaxLength(30)] - public string Name { get; set; } - + [Required] [MaxLength(30)] public string Name { get; set; } [MaxLength(300)] public string Description { get; set; } = ""; - - - [Required] - public string[] Permissions { get; set; } + + [Required] public DateTimeOffset ValidUntil { get; set; } + [Required] public string[] Permissions { get; set; } } \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Admin/ApiKeys/UpdateApiKeyDto.cs b/Moonlight.Shared/Http/Requests/Admin/ApiKeys/UpdateApiKeyDto.cs index 3c154ec7..1044904c 100644 --- a/Moonlight.Shared/Http/Requests/Admin/ApiKeys/UpdateApiKeyDto.cs +++ b/Moonlight.Shared/Http/Requests/Admin/ApiKeys/UpdateApiKeyDto.cs @@ -4,12 +4,9 @@ namespace Moonlight.Shared.Http.Requests.Admin.ApiKeys; public class UpdateApiKeyDto { - [Required] - [MaxLength(30)] - public string Name { get; set; } - + [Required] [MaxLength(30)] public string Name { get; set; } [MaxLength(300)] public string Description { get; set; } = ""; - - [Required] - public string[] Permissions { get; set; } + + [Required] public DateTimeOffset ValidUntil { get; set; } + [Required] public string[] Permissions { get; set; } } \ No newline at end of file diff --git a/Moonlight.Shared/Http/Responses/Admin/ApiKeys/ApiKeyDto.cs b/Moonlight.Shared/Http/Responses/Admin/ApiKeys/ApiKeyDto.cs index 85158f42..c3129546 100644 --- a/Moonlight.Shared/Http/Responses/Admin/ApiKeys/ApiKeyDto.cs +++ b/Moonlight.Shared/Http/Responses/Admin/ApiKeys/ApiKeyDto.cs @@ -1,3 +1,3 @@ namespace Moonlight.Shared.Http.Responses.Admin.ApiKeys; -public record ApiKeyDto(int Id, string Name, string Description, string[] Permissions, string Key, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); \ No newline at end of file +public record ApiKeyDto(int Id, string Name, string Description, string[] Permissions, DateTimeOffset ValidUntil, string Key, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); \ No newline at end of file