Implemented api authentication. Removed old secret system

This commit is contained in:
2025-03-14 12:32:13 +01:00
parent 340cf738dc
commit f1c0d3b896
12 changed files with 302 additions and 131 deletions

View File

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

View File

@@ -0,0 +1,89 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PermissionsJson")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Id");
b.ToTable("Core_ApiKeys", (string)null);
});
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PermissionsJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTime>("TokenValidTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Core_Users", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.ApiServer.Database.Migrations
{
/// <inheritdoc />
public partial class ModifiedApiKeyEntity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Secret",
table: "Core_ApiKeys");
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "Core_ApiKeys",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "Core_ApiKeys");
migrationBuilder.AddColumn<string>(
name: "Secret",
table: "Core_ApiKeys",
type: "text",
nullable: false,
defaultValue: "");
}
}
}

View File

@@ -30,6 +30,9 @@ namespace Moonlight.ApiServer.Database.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
@@ -41,10 +44,6 @@ namespace Moonlight.ApiServer.Database.Migrations
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Secret")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Core_ApiKeys", (string)null);

View File

@@ -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<ApiKey, ApiKeyDetailResponse> CrudHelper;
private readonly DatabaseRepository<ApiKey> ApiKeyRepository;
private readonly ApiKeyService ApiKeyService;
public ApiKeysController(CrudHelper<ApiKey, ApiKeyDetailResponse> crudHelper, DatabaseRepository<ApiKey> apiKeyRepository)
public ApiKeysController(CrudHelper<ApiKey, ApiKeyDetailResponse> crudHelper, DatabaseRepository<ApiKey> 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<CreateApiKeyResponse> 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);
return Mapper.Map<CreateApiKeyResponse>(finalApiKey);
var response = Mapper.Map<CreateApiKeyResponse>(finalApiKey);
response.Secret = ApiKeyService.GenerateJwt(finalApiKey);
return response;
}
[HttpPatch("{id}")]

View File

@@ -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 =>
{
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

View File

@@ -25,7 +25,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MoonCore" Version="1.8.5" />
<PackageReference Include="MoonCore.Extended" Version="1.3.1" />
<PackageReference Include="MoonCore.Extended" Version="1.3.2" />
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.5" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>

View File

@@ -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<string[]>(apiKey.PermissionsJson) ?? [];
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
var descriptor = new SecurityTokenDescriptor()
{
Expires = apiKey.ExpiresAt,
IssuedAt = DateTime.Now,
NotBefore = DateTime.Now.AddMinutes(-1),
Claims = new Dictionary<string, object>()
{
{
"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);
}
}

View File

@@ -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<User> UserRepository;
public AuthService(DatabaseRepository<User> userRepository)
{
UserRepository = userRepository;
}
public Task<User> 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<User> 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);
}
}

View File

@@ -54,7 +54,8 @@ public class Startup
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 ?? [];
@@ -282,7 +283,7 @@ public class Startup
{
var startup = ActivatorUtilities.CreateInstance(startupSp, type) as IPluginStartup;
if(startup == null)
if (startup == null)
continue;
startups.Add(startup);
@@ -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()
{
@@ -538,20 +539,40 @@ public class Startup
};
});
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 userIdClaim = principal.Claims.FirstOrDefault(x => x.Type == "userId");
if (userIdClaim != null)
{
var userId = int.Parse(userIdClaim.Value);
var userRepository = provider.GetRequiredService<DatabaseRepository<User>>();
var user = await userRepository.Get().FirstOrDefaultAsync(x => x.Id == userId);
if(user == null)
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<DatabaseRepository<ApiKey>>();
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;
};
});

View File

@@ -88,12 +88,26 @@ public class SysFileSystemProvider : IFileSystemProvider, ICompressFileSystemPro
public async Task Upload(Func<long, Task> updateProgress, string path, Stream stream)
{
var progressStream = new ProgressStream(stream, onReadChanged: new Progress<long>(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)

View File

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