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