Implemented api authentication. Removed old secret system
This commit is contained in:
@@ -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;
|
||||
}
|
||||
89
Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.Designer.cs
generated
Normal file
89
Moonlight.ApiServer/Database/Migrations/20250314095412_ModifiedApiKeyEntity.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
56
Moonlight.ApiServer/Services/ApiKeyService.cs
Normal file
56
Moonlight.ApiServer/Services/ApiKeyService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 userId = int.Parse(userIdClaim.Value);
|
||||
var userIdClaim = principal.Claims.FirstOrDefault(x => x.Type == "userId");
|
||||
|
||||
var userRepository = provider.GetRequiredService<DatabaseRepository<User>>();
|
||||
var user = await userRepository.Get().FirstOrDefaultAsync(x => x.Id == userId);
|
||||
if (userIdClaim != null)
|
||||
{
|
||||
var userId = int.Parse(userIdClaim.Value);
|
||||
|
||||
if(user == null)
|
||||
return DateTime.MaxValue;
|
||||
var userRepository = provider.GetRequiredService<DatabaseRepository<User>>();
|
||||
var user = await userRepository.Get().FirstOrDefaultAsync(x => x.Id == userId);
|
||||
|
||||
return user.TokenValidTimestamp;
|
||||
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;
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user