diff --git a/Moonlight.ApiServer/Database/Entities/ApiKey.cs b/Moonlight.ApiServer/Database/Entities/ApiKey.cs
index efe77b7f..afb869f4 100644
--- a/Moonlight.ApiServer/Database/Entities/ApiKey.cs
+++ b/Moonlight.ApiServer/Database/Entities/ApiKey.cs
@@ -6,7 +6,6 @@ public class ApiKey
{
public int Id { get; set; }
- public string Secret { get; set; }
public string Description { get; set; }
[Column(TypeName="jsonb")]
@@ -14,4 +13,7 @@ public class ApiKey
[Column(TypeName = "timestamp with time zone")]
public DateTime ExpiresAt { get; set; }
+
+ [Column(TypeName = "timestamp with time zone")]
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
\ No newline at end of file
diff --git a/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.Designer.cs b/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.Designer.cs
new file mode 100644
index 00000000..6e3d6105
--- /dev/null
+++ b/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.Designer.cs
@@ -0,0 +1,89 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Moonlight.ApiServer.Database;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Moonlight.ApiServer.Database.Migrations
+{
+ [DbContext(typeof(CoreDataContext))]
+ [Migration("20250314095412_ModifiedApiKeyEntity")]
+ partial class ModifiedApiKeyEntity
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.11")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Moonlight.ApiServer.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()
+ .HasColumnType("text");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PermissionsJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.HasKey("Id");
+
+ b.ToTable("Core_ApiKeys", (string)null);
+ });
+
+ modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Password")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PermissionsJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("TokenValidTimestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Core_Users", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.cs b/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.cs
new file mode 100644
index 00000000..b7d47632
--- /dev/null
+++ b/Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.cs
@@ -0,0 +1,41 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Moonlight.ApiServer.Database.Migrations
+{
+ ///
+ public partial class ModifiedApiKeyEntity : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Secret",
+ table: "Core_ApiKeys");
+
+ migrationBuilder.AddColumn(
+ name: "CreatedAt",
+ table: "Core_ApiKeys",
+ type: "timestamp with time zone",
+ nullable: false,
+ defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "CreatedAt",
+ table: "Core_ApiKeys");
+
+ migrationBuilder.AddColumn(
+ name: "Secret",
+ table: "Core_ApiKeys",
+ type: "text",
+ nullable: false,
+ defaultValue: "");
+ }
+ }
+}
diff --git a/Moonlight.ApiServer/Database/Migrations/CoreDataContextModelSnapshot.cs b/Moonlight.ApiServer/Database/Migrations/CoreDataContextModelSnapshot.cs
index 3728cc73..e0017152 100644
--- a/Moonlight.ApiServer/Database/Migrations/CoreDataContextModelSnapshot.cs
+++ b/Moonlight.ApiServer/Database/Migrations/CoreDataContextModelSnapshot.cs
@@ -30,6 +30,9 @@ namespace Moonlight.ApiServer.Database.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
b.Property("Description")
.IsRequired()
.HasColumnType("text");
@@ -41,10 +44,6 @@ namespace Moonlight.ApiServer.Database.Migrations
.IsRequired()
.HasColumnType("jsonb");
- b.Property("Secret")
- .IsRequired()
- .HasColumnType("text");
-
b.HasKey("Id");
b.ToTable("Core_ApiKeys", (string)null);
diff --git a/Moonlight.ApiServer/Http/Controllers/Admin/ApiKeys/ApiKeysController.cs b/Moonlight.ApiServer/Http/Controllers/Admin/ApiKeys/ApiKeysController.cs
index d65e8f1f..2b09577d 100644
--- a/Moonlight.ApiServer/Http/Controllers/Admin/ApiKeys/ApiKeysController.cs
+++ b/Moonlight.ApiServer/Http/Controllers/Admin/ApiKeys/ApiKeysController.cs
@@ -5,6 +5,7 @@ using MoonCore.Extended.PermFilter;
using MoonCore.Helpers;
using MoonCore.Models;
using Moonlight.ApiServer.Database.Entities;
+using Moonlight.ApiServer.Services;
using Moonlight.Shared.Http.Requests.Admin.ApiKeys;
using Moonlight.Shared.Http.Responses.Admin.ApiKeys;
@@ -16,11 +17,13 @@ public class ApiKeysController : Controller
{
private readonly CrudHelper CrudHelper;
private readonly DatabaseRepository ApiKeyRepository;
+ private readonly ApiKeyService ApiKeyService;
- public ApiKeysController(CrudHelper crudHelper, DatabaseRepository apiKeyRepository)
+ public ApiKeysController(CrudHelper crudHelper, DatabaseRepository apiKeyRepository, ApiKeyService apiKeyService)
{
CrudHelper = crudHelper;
ApiKeyRepository = apiKeyRepository;
+ ApiKeyService = apiKeyService;
}
[HttpGet]
@@ -37,19 +40,20 @@ public class ApiKeysController : Controller
[RequirePermission("admin.apikeys.create")]
public async Task Create([FromBody] CreateApiKeyRequest request)
{
- var secret = "api_" + Formatter.GenerateString(32);
-
var apiKey = new ApiKey()
{
Description = request.Description,
PermissionsJson = request.PermissionsJson,
- ExpiresAt = request.ExpiresAt,
- Secret = secret
+ ExpiresAt = request.ExpiresAt
};
var finalApiKey = await ApiKeyRepository.Add(apiKey);
+
+ var response = Mapper.Map(finalApiKey);
+
+ response.Secret = ApiKeyService.GenerateJwt(finalApiKey);
- return Mapper.Map(finalApiKey);
+ return response;
}
[HttpPatch("{id}")]
diff --git a/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs b/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs
index dcc6531d..6ca274db 100644
--- a/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs
+++ b/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs
@@ -1,9 +1,7 @@
-using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models;
using Moonlight.ApiServer.Configuration;
using Moonlight.ApiServer.Database;
using Moonlight.ApiServer.Interfaces.Startup;
-using Moonlight.ApiServer.Services;
namespace Moonlight.ApiServer.Implementations.Startup;
@@ -25,10 +23,23 @@ public class CoreStartup : IPluginStartup
builder.Services.AddEndpointsApiExplorer();
// Configure swagger api specification generator and set the document title for the api docs to use
- builder.Services.AddSwaggerGen(options => options.SwaggerDoc("main", new OpenApiInfo()
+ builder.Services.AddSwaggerGen(options =>
{
- Title = "Moonlight API"
- }));
+ options.SwaggerDoc("main", new OpenApiInfo()
+ {
+ Title = "Moonlight API"
+ });
+
+ options.CustomSchemaIds(x => x.FullName);
+
+ options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
+ {
+ Name = "Authorization",
+ In = ParameterLocation.Header,
+ Type = SecuritySchemeType.ApiKey,
+ Scheme = "Bearer"
+ });
+ });
}
#endregion
diff --git a/Moonlight.ApiServer/Moonlight.ApiServer.csproj b/Moonlight.ApiServer/Moonlight.ApiServer.csproj
index 5703a8c3..725dd32f 100644
--- a/Moonlight.ApiServer/Moonlight.ApiServer.csproj
+++ b/Moonlight.ApiServer/Moonlight.ApiServer.csproj
@@ -25,7 +25,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/Moonlight.ApiServer/Services/ApiKeyService.cs b/Moonlight.ApiServer/Services/ApiKeyService.cs
new file mode 100644
index 00000000..83adc200
--- /dev/null
+++ b/Moonlight.ApiServer/Services/ApiKeyService.cs
@@ -0,0 +1,56 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Text;
+using System.Text.Json;
+using Microsoft.IdentityModel.Tokens;
+using MoonCore.Attributes;
+using Moonlight.ApiServer.Configuration;
+using Moonlight.ApiServer.Database.Entities;
+
+namespace Moonlight.ApiServer.Services;
+
+[Singleton]
+public class ApiKeyService
+{
+ private readonly AppConfiguration Configuration;
+
+ public ApiKeyService(AppConfiguration configuration)
+ {
+ Configuration = configuration;
+ }
+
+ public string GenerateJwt(ApiKey apiKey)
+ {
+ var permissions = JsonSerializer.Deserialize(apiKey.PermissionsJson) ?? [];
+
+ var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
+
+ var descriptor = new SecurityTokenDescriptor()
+ {
+ Expires = apiKey.ExpiresAt,
+ IssuedAt = DateTime.Now,
+ NotBefore = DateTime.Now.AddMinutes(-1),
+ Claims = new Dictionary()
+ {
+ {
+ "apiKeyId",
+ apiKey.Id
+ },
+ {
+ "permissions",
+ string.Join(";", permissions)
+ }
+ },
+ SigningCredentials = new SigningCredentials(
+ new SymmetricSecurityKey(
+ Encoding.UTF8.GetBytes(Configuration.Authentication.Secret)
+ ),
+ SecurityAlgorithms.HmacSha256
+ ),
+ Issuer = Configuration.PublicUrl,
+ Audience = Configuration.PublicUrl
+ };
+
+ var securityToken = jwtSecurityTokenHandler.CreateJwtSecurityToken(descriptor);
+ return jwtSecurityTokenHandler.WriteToken(securityToken);
+ }
+}
\ No newline at end of file
diff --git a/Moonlight.ApiServer/Services/AuthService.cs b/Moonlight.ApiServer/Services/AuthService.cs
deleted file mode 100644
index fe5323f6..00000000
--- a/Moonlight.ApiServer/Services/AuthService.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using MoonCore.Attributes;
-using MoonCore.Exceptions;
-using MoonCore.Extended.Abstractions;
-using MoonCore.Extended.Helpers;
-using MoonCore.Services;
-using Moonlight.ApiServer.Configuration;
-using Moonlight.ApiServer.Database.Entities;
-
-namespace Moonlight.ApiServer.Services;
-
-[Scoped]
-public class AuthService
-{
- private readonly DatabaseRepository UserRepository;
-
- public AuthService(DatabaseRepository userRepository)
- {
- UserRepository = userRepository;
- }
-
- public Task Register(string username, string email, string password)
- {
- // Reformat values
- username = username.ToLower().Trim();
- email = email.ToLower().Trim();
-
- // Check for users with the same values
- if (UserRepository.Get().Any(x => x.Username == username))
- throw new HttpApiException("A user with that username already exists", 400);
-
- if (UserRepository.Get().Any(x => x.Email == email))
- throw new HttpApiException("A user with that email address already exists", 400);
-
- // Build model and add it to the database
- var user = new User()
- {
- Username = username,
- Email = email,
- Password = HashHelper.Hash(password),
- PermissionsJson = "[]",
- TokenValidTimestamp = DateTime.UtcNow
- };
-
- UserRepository.Add(user);
-
- return Task.FromResult(user);
- }
-
- public Task Login(string email, string password)
- {
- // Reformat values
- email = email.ToLower().Trim();
-
- var user = UserRepository
- .Get()
- .FirstOrDefault(x => x.Email == email);
-
- if (user == null)
- throw new HttpApiException("Invalid email or password", 400);
-
- if(!HashHelper.Verify(password, user.Password))
- throw new HttpApiException("Invalid email or password", 400);
-
- return Task.FromResult(user);
- }
-}
\ No newline at end of file
diff --git a/Moonlight.ApiServer/Startup.cs b/Moonlight.ApiServer/Startup.cs
index 10235a7b..2fd3096e 100644
--- a/Moonlight.ApiServer/Startup.cs
+++ b/Moonlight.ApiServer/Startup.cs
@@ -48,13 +48,14 @@ public class Startup
// Plugin Loading
private PluginService PluginService;
private AssemblyLoadContext PluginLoadContext;
-
+
// Asset bundling
private BundleService BundleService = new();
private IPluginStartup[] PluginStartups;
- public async Task Run(string[] args, Assembly[]? additionalAssemblies = null, PluginManifest[]? additionalManifests = null)
+ public async Task Run(string[] args, Assembly[]? additionalAssemblies = null,
+ PluginManifest[]? additionalManifests = null)
{
Args = args;
AdditionalAssemblies = additionalAssemblies ?? [];
@@ -125,9 +126,9 @@ public class Startup
private Task SetupBundling()
{
BundleService = new();
-
+
BundleService.BundleCss("css/core.min.css");
-
+
return Task.CompletedTask;
}
@@ -137,7 +138,7 @@ public class Startup
{
WebApplicationBuilder.Services.AutoAddServices();
WebApplicationBuilder.Services.AddHttpClient();
-
+
WebApplicationBuilder.Services.AddApiExceptionHandler();
// Add pre-existing services
@@ -191,10 +192,10 @@ public class Startup
var maxUploadInBytes = ByteConverter
.FromMegaBytes(Configuration.Kestrel.UploadLimit)
.Bytes;
-
+
kestrelOptions.Limits.MaxRequestBodySize = maxUploadInBytes;
});
-
+
return Task.CompletedTask;
}
@@ -208,7 +209,7 @@ public class Startup
PluginService = new PluginService(
LoggerFactory.CreateLogger()
);
-
+
// Add plugins manually if specified in the startup
foreach (var manifest in AdditionalPluginManifests)
PluginService.LoadedPlugins.Add(manifest, Directory.GetCurrentDirectory());
@@ -247,30 +248,30 @@ public class Startup
// Add bundle service so plugins can do additional bundling if required
startupSc.AddSingleton(BundleService);
-
+
// Auto add all files specified in the bundledStyles section to the bundle job
foreach (var plugin in PluginService.LoadedPlugins.Keys)
BundleService.BundleCssRange(plugin.BundledStyles);
-
+
startupSc.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddProviders(LoggerProviders);
});
-
+
//
var startupSp = startupSc.BuildServiceProvider();
-
+
// Initialize plugin startups
var startups = new List();
var startupType = typeof(IPluginStartup);
var assembliesToScan = new List();
-
+
assembliesToScan.Add(typeof(Startup).Assembly);
assembliesToScan.AddRange(PluginLoadContext.Assemblies);
assembliesToScan.AddRange(AdditionalAssemblies);
-
+
foreach (var pluginAssembly in assembliesToScan)
{
var startupTypes = pluginAssembly
@@ -281,10 +282,10 @@ public class Startup
foreach (var type in startupTypes)
{
var startup = ActivatorUtilities.CreateInstance(startupSp, type) as IPluginStartup;
-
- if(startup == null)
+
+ if (startup == null)
continue;
-
+
startups.Add(startup);
}
}
@@ -299,7 +300,7 @@ public class Startup
WebApplicationBuilder.Services.AddHostedService(sp => sp.GetRequiredService());
WebApplicationBuilder.Services.AddSingleton();
WebApplicationBuilder.Services.AddSingleton(BundleService);
-
+
return Task.CompletedTask;
}
@@ -309,12 +310,12 @@ public class Startup
{
FileProvider = new BundleAssetFileProvider()
});
-
+
WebApplication.UseStaticFiles(new StaticFileOptions()
{
FileProvider = PluginService.WwwRootFileProvider
});
-
+
return Task.CompletedTask;
}
@@ -387,7 +388,7 @@ public class Startup
{
// Configure configuration (wow)
var configurationBuilder = new ConfigurationBuilder();
-
+
// Ensure configuration file exists
var jsonFilePath = PathBuilder.File(Directory.GetCurrentDirectory(), "storage", "app.json");
@@ -484,7 +485,7 @@ public class Startup
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware",
LogLevel.Critical
);
-
+
WebApplicationBuilder.Logging.AddFilter(
"Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware",
LogLevel.Critical
@@ -499,17 +500,17 @@ public class Startup
{
WebApplicationBuilder.Services.AddDatabaseMappings();
WebApplicationBuilder.Services.AddServiceCollectionAccessor();
-
+
WebApplicationBuilder.Services.AddScoped(typeof(DatabaseRepository<>));
WebApplicationBuilder.Services.AddScoped(typeof(CrudHelper<,>));
-
+
return Task.CompletedTask;
}
private async Task PrepareDatabase()
{
await WebApplication.Services.EnsureDatabaseMigrated();
-
+
WebApplication.Services.GenerateDatabaseMappings();
}
@@ -520,8 +521,8 @@ public class Startup
private Task RegisterAuth()
{
WebApplicationBuilder.Services
- .AddAuthentication("userAuthentication")
- .AddJwtBearer("userAuthentication",options =>
+ .AddAuthentication("coreAuthentication")
+ .AddJwtBearer("coreAuthentication", options =>
{
options.TokenValidationParameters = new()
{
@@ -537,37 +538,57 @@ public class Startup
ValidIssuer = Configuration.PublicUrl
};
});
-
- WebApplicationBuilder.Services.AddJwtInvalidation("userAuthentication",options =>
+
+ WebApplicationBuilder.Services.AddJwtInvalidation("coreAuthentication", options =>
{
options.InvalidateTimeProvider = async (provider, principal) =>
{
- var userIdClaim = principal.Claims.First(x => x.Type == "userId");
- var userId = int.Parse(userIdClaim.Value);
-
- var userRepository = provider.GetRequiredService>();
- var user = await userRepository.Get().FirstOrDefaultAsync(x => x.Id == userId);
-
- if(user == null)
- return DateTime.MaxValue;
+ var userIdClaim = principal.Claims.FirstOrDefault(x => x.Type == "userId");
- return user.TokenValidTimestamp;
+ if (userIdClaim != null)
+ {
+ var userId = int.Parse(userIdClaim.Value);
+
+ var userRepository = provider.GetRequiredService>();
+ var user = await userRepository.Get().FirstOrDefaultAsync(x => x.Id == userId);
+
+ if (user == null)
+ return DateTime.MaxValue;
+
+ return user.TokenValidTimestamp;
+ }
+
+ var apiKeyIdClaim = principal.Claims.FirstOrDefault(x => x.Type == "apiKeyId");
+
+ if (apiKeyIdClaim != null)
+ {
+ var apiKeyId = int.Parse(apiKeyIdClaim.Value);
+
+ var apiKeyRepository = provider.GetRequiredService>();
+ var apiKey = await apiKeyRepository.Get().FirstOrDefaultAsync(x => x.Id == apiKeyId);
+
+ // If the api key exists, we don't want to invalidate the request.
+ // If it doesn't exist we want to invalidate the request
+ return apiKey == null ? DateTime.MaxValue : DateTime.MinValue;
+ }
+
+ return DateTime.MaxValue;
};
});
WebApplicationBuilder.Services.AddAuthorization();
-
+
return Task.CompletedTask;
}
private Task UseAuth()
{
WebApplication.UseAuthentication();
-
+
WebApplication.UseJwtInvalidation();
-
+
WebApplication.UseAuthorization();
-
+
return Task.CompletedTask;
}
diff --git a/Moonlight.Client/Implementations/SysFileSystemProvider.cs b/Moonlight.Client/Implementations/SysFileSystemProvider.cs
index 8a7c565a..3f4c87f8 100644
--- a/Moonlight.Client/Implementations/SysFileSystemProvider.cs
+++ b/Moonlight.Client/Implementations/SysFileSystemProvider.cs
@@ -88,12 +88,26 @@ public class SysFileSystemProvider : IFileSystemProvider, ICompressFileSystemPro
public async Task Upload(Func updateProgress, string path, Stream stream)
{
- var progressStream = new ProgressStream(stream, onReadChanged: new Progress(async bytes =>
- {
- await updateProgress.Invoke(bytes);
- }));
+ var cts = new CancellationTokenSource();
- await Create(path, progressStream);
+ Task.Run(async () =>
+ {
+ while (!cts.IsCancellationRequested)
+ {
+ await updateProgress.Invoke(stream.Position);
+ await Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token);
+ }
+ });
+
+ try
+ {
+ await Create(path, stream);
+ }
+ finally
+ {
+ // Ensure we aren't creating an endless loop ^^
+ await cts.CancelAsync();
+ }
}
public async Task Compress(CompressType type, string path, string[] itemsToCompress)
diff --git a/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyRequest.cs b/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyRequest.cs
index 33c520a3..28c65e0c 100644
--- a/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyRequest.cs
+++ b/Moonlight.Shared/Http/Requests/Admin/ApiKeys/CreateApiKeyRequest.cs
@@ -11,5 +11,5 @@ public class CreateApiKeyRequest
public string PermissionsJson { get; set; } = "[]";
[Required(ErrorMessage = "You need to specify an expire date")]
- public DateTime ExpiresAt { get; set; } = DateTime.Now.AddDays(30);
+ public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddDays(30);
}
\ No newline at end of file