Merge pull request 'Added api key expiry' (#13) from feat/ApiKeys into v2.1

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-02-12 09:11:45 +00:00
11 changed files with 337 additions and 22 deletions

View File

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

View File

@@ -0,0 +1,254 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ValidUntil")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.PrimitiveCollection<string[]>("Permissions")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Roles", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ValueJson")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("jsonb");
b.HasKey("Id");
b.ToTable("SettingsOptions", "core");
});
modelBuilder.Entity("Moonlight.Api.Database.Entities.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Author")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("CssContent")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTimeOffset>("InvalidateTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,32 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.Api.Database.Migrations
{
/// <inheritdoc />
public partial class AddedValidUntilToApiKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
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)));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ValidUntil",
schema: "core",
table: "ApiKeys");
}
}
}

View File

@@ -56,6 +56,9 @@ namespace Moonlight.Api.Database.Migrations
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ValidUntil")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("ApiKeys", "core");

View File

@@ -16,6 +16,8 @@ public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
private readonly IMemoryCache MemoryCache;
private const string CacheKeyFormat = $"Moonlight.Api.{nameof(ApiKeySchemeHandler)}.{{0}}";
public ApiKeySchemeHandler(
IOptionsMonitor<ApiKeySchemeOptions> options,
ILoggerFactory logger,
@@ -38,18 +40,20 @@ public class ApiKeySchemeHandler : AuthenticationHandler<ApiKeySchemeOptions>
if (authHeaderValue.Length > 32)
return AuthenticateResult.Fail("Invalid api key specified");
if (!MemoryCache.TryGetValue<ApiKeySession>(authHeaderValue, out var apiKey))
var cacheKey = string.Format(CacheKeyFormat, authHeaderValue);
if (!MemoryCache.TryGetValue<ApiKeySession>(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<ApiKeySchemeOptions>
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<ApiKeySchemeOptions>
));
}
private record ApiKeySession(string[] Permissions);
private record ApiKeySession(string[] Permissions, DateTimeOffset ValidUntil);
}

View File

@@ -35,6 +35,10 @@
<FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription" placeholder="What this key is for"/>
</Field>
<Field>
<FieldLabel for="keyValidUntil">Valid until</FieldLabel>
<DateTimeInputField @bind-Value="Request.ValidUntil" id="keyValidUntil" />
</Field>
<Field>
<FieldLabel>Permissions</FieldLabel>
<FieldContent>
@@ -60,13 +64,15 @@
{
Request = new()
{
Permissions = []
Permissions = [],
ValidUntil = DateTimeOffset.UtcNow
};
}
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
Request.Permissions = Permissions.ToArray();
Request.ValidUntil = Request.ValidUntil.ToUniversalTime();
var response = await HttpClient.PostAsJsonAsync(
"/api/admin/apiKeys",

View File

@@ -35,6 +35,10 @@
<FieldLabel for="keyDescription">Description</FieldLabel>
<TextareaInputField @bind-Value="Request.Description" id="keyDescription" placeholder="What this key is for"/>
</Field>
<Field>
<FieldLabel for="keyValidUntil">Valid until</FieldLabel>
<DateTimeInputField @bind-Value="Request.ValidUntil" id="keyValidUntil" />
</Field>
<Field>
<FieldLabel>Permissions</FieldLabel>
<FieldContent>
@@ -65,6 +69,7 @@
private async Task<bool> OnSubmitAsync(EditContext editContext, ValidationMessageStore validationMessageStore)
{
Request.Permissions = Permissions.ToArray();
Request.ValidUntil = Request.ValidUntil.ToUniversalTime();
var response = await HttpClient.PatchAsJsonAsync(
$"/api/admin/apiKeys/{Key.Id}",

View File

@@ -48,7 +48,21 @@
</CellTemplate>
</TemplateColumn>
<PropertyColumn IsFilterable="true"
Identifier="@nameof(ApiKeyDto.Description)" Field="k => k.Description"/>
Identifier="@nameof(ApiKeyDto.Description)" Field="k => k.Description" HeadClassName="hidden lg:table-cell" CellClassName="hidden lg:table-cell" />
<TemplateColumn Identifier="@nameof(ApiKeyDto.ValidUntil)" Title="Valid until" HeadClassName="hidden lg:table-cell">
<CellTemplate>
<TableCell ClassName="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));
}
<span>
@text
</span>
</TableCell>
</CellTemplate>
</TemplateColumn>
<TemplateColumn>
<CellTemplate>
<TableCell>

View File

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

View File

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

View File

@@ -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);
public record ApiKeyDto(int Id, string Name, string Description, string[] Permissions, DateTimeOffset ValidUntil, string Key, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);