Merge branch 'v2_ChangeArchitecture' of https://github.com/Moonlight-Panel/Moonlight into v2_ChangeArchitecture
This commit is contained in:
11
Moonlight.ApiServer/Attributes/RequirePermissionAttribute.cs
Normal file
11
Moonlight.ApiServer/Attributes/RequirePermissionAttribute.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Moonlight.ApiServer.Attributes;
|
||||
|
||||
public class RequirePermissionAttribute : Attribute
|
||||
{
|
||||
public string Permission { get; set; }
|
||||
|
||||
public RequirePermissionAttribute(string permission)
|
||||
{
|
||||
Permission = permission;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
public class AppConfiguration
|
||||
{
|
||||
public DatabaseConfig Database { get; set; } = new();
|
||||
public AuthenticationConfig Authentication { get; set; } = new();
|
||||
public DevelopmentConfig Development { get; set; } = new();
|
||||
|
||||
public class DatabaseConfig
|
||||
{
|
||||
@@ -14,4 +16,19 @@ public class AppConfiguration
|
||||
|
||||
public string Database { get; set; } = "db_name";
|
||||
}
|
||||
|
||||
public class AuthenticationConfig
|
||||
{
|
||||
public string Secret { get; set; } = Guid
|
||||
.NewGuid()
|
||||
.ToString()
|
||||
.Replace("-", "");
|
||||
|
||||
public int TokenDuration { get; set; } = 10;
|
||||
}
|
||||
|
||||
public class DevelopmentConfig
|
||||
{
|
||||
public bool EnableApiDocs { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
using Moonlight.ApiServer.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.ApiServer.Database.Entities;
|
||||
using Moonlight.ApiServer.Helpers;
|
||||
|
||||
namespace Moonlight.ApiServer.Database;
|
||||
|
||||
public class CoreDataContext : DatabaseContext
|
||||
{
|
||||
public override string Prefix { get; } = "Core";
|
||||
|
||||
public DbSet<User> Users { get; set; }
|
||||
}
|
||||
13
Moonlight.ApiServer/Database/Entities/User.cs
Normal file
13
Moonlight.ApiServer/Database/Entities/User.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Moonlight.ApiServer.Database.Entities;
|
||||
|
||||
public class User
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Username { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Password { get; set; }
|
||||
|
||||
public DateTime TokenValidTimestamp { get; set; } = DateTime.UtcNow;
|
||||
public string PermissionsJson { get; set; } = "[]";
|
||||
}
|
||||
63
Moonlight.ApiServer/Database/Migrations/20241001090841_AddedBasicUserModel.Designer.cs
generated
Normal file
63
Moonlight.ApiServer/Database/Migrations/20241001090841_AddedBasicUserModel.Designer.cs
generated
Normal file
@@ -0,0 +1,63 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Moonlight.ApiServer.Database;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.ApiServer.Database.Migrations
|
||||
{
|
||||
[DbContext(typeof(CoreDataContext))]
|
||||
[Migration("20241001090841_AddedBasicUserModel")]
|
||||
partial class AddedBasicUserModel
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Core")
|
||||
.HasAnnotation("ProductVersion", "8.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||
|
||||
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("PermissionsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("TokenValidTimestamp")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users", "Core");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.ApiServer.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddedBasicUserModel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "Core");
|
||||
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
schema: "Core",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||
Username = table.Column<string>(type: "longtext", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Email = table.Column<string>(type: "longtext", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
Password = table.Column<string>(type: "longtext", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||
TokenValidTimestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
PermissionsJson = table.Column<string>(type: "longtext", nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
})
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users",
|
||||
schema: "Core");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Moonlight.ApiServer.Database;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.ApiServer.Database.Migrations
|
||||
{
|
||||
[DbContext(typeof(CoreDataContext))]
|
||||
partial class CoreDataContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("Core")
|
||||
.HasAnnotation("ProductVersion", "8.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||
|
||||
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Moonlight.ApiServer.Database.Entities.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("PermissionsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime>("TokenValidTimestamp")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users", "Core");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Moonlight.ApiServer/Exceptions/MissingPermissionException.cs
Normal file
11
Moonlight.ApiServer/Exceptions/MissingPermissionException.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Moonlight.ApiServer.Exceptions;
|
||||
|
||||
public class MissingPermissionException : Exception
|
||||
{
|
||||
public string Permission { get; set; }
|
||||
|
||||
public MissingPermissionException(string permission)
|
||||
{
|
||||
Permission = permission;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Security.Claims;
|
||||
using Moonlight.ApiServer.Database.Entities;
|
||||
|
||||
namespace Moonlight.ApiServer.Helpers.Authentication;
|
||||
|
||||
public class PermClaimsPrinciple : ClaimsPrincipal
|
||||
{
|
||||
public string[] Permissions { get; private set; }
|
||||
public User? CurrentModelNullable { get; private set; }
|
||||
public User CurrentModel => CurrentModelNullable!;
|
||||
|
||||
public PermClaimsPrinciple(string[] permissions, User? currentModelNullable)
|
||||
{
|
||||
Permissions = permissions;
|
||||
CurrentModelNullable = currentModelNullable;
|
||||
}
|
||||
|
||||
public bool HasPermission(string requiredPermission)
|
||||
{
|
||||
// Check for wildcard permission
|
||||
if (Permissions.Contains("*"))
|
||||
return true;
|
||||
|
||||
var requiredSegments = requiredPermission.Split('.');
|
||||
|
||||
// Check if the user has the exact permission or a wildcard match
|
||||
foreach (var permission in Permissions)
|
||||
{
|
||||
var permissionSegments = permission.Split('.');
|
||||
|
||||
// Iterate over the segments of the required permission
|
||||
for (var i = 0; i < requiredSegments.Length; i++)
|
||||
{
|
||||
// If the current segment matches or is a wildcard, continue to the next segment
|
||||
if (i < permissionSegments.Length && requiredSegments[i] == permissionSegments[i] ||
|
||||
permissionSegments[i] == "*")
|
||||
{
|
||||
// If we've reached the end of the permissionSegments array, it means we've found a match
|
||||
if (i == permissionSegments.Length - 1)
|
||||
return true; // Found an exact match or a wildcard match
|
||||
}
|
||||
else
|
||||
{
|
||||
// If we reach here, it means the segments don't match and we break out of the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No matching permission found
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Moonlight.ApiServer.Helpers.Authentication;
|
||||
|
||||
public class SyncedClaimsPrinciple : ClaimsPrincipal
|
||||
{
|
||||
|
||||
}
|
||||
61
Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs
Normal file
61
Moonlight.ApiServer/Http/Controllers/Auth/AuthController.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moonlight.ApiServer.Attributes;
|
||||
using Moonlight.ApiServer.Helpers.Authentication;
|
||||
using Moonlight.ApiServer.Services;
|
||||
using Moonlight.Shared.Http.Requests.Auth;
|
||||
using Moonlight.Shared.Http.Responses.Auth;
|
||||
|
||||
namespace Moonlight.ApiServer.Http.Controllers.Auth;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController : Controller
|
||||
{
|
||||
private readonly AuthService AuthService;
|
||||
|
||||
public AuthController(AuthService authService)
|
||||
{
|
||||
AuthService = authService;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<LoginResponse> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var user = await AuthService.Login(request.Email, request.Password);
|
||||
|
||||
return new LoginResponse()
|
||||
{
|
||||
Token = await AuthService.GenerateToken(user)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<RegisterResponse> Register([FromBody] RegisterRequest request)
|
||||
{
|
||||
var user = await AuthService.Register(
|
||||
request.Username,
|
||||
request.Email,
|
||||
request.Password
|
||||
);
|
||||
|
||||
return new RegisterResponse()
|
||||
{
|
||||
Token = await AuthService.GenerateToken(user)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("check")]
|
||||
[RequirePermission("meta.authenticated")]
|
||||
public async Task<CheckResponse> Check()
|
||||
{
|
||||
var perm = HttpContext.User as PermClaimsPrinciple;
|
||||
var user = perm!.CurrentModel;
|
||||
|
||||
return new CheckResponse()
|
||||
{
|
||||
Email = user.Email,
|
||||
Username = user.Username,
|
||||
Permissions = perm.Permissions
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MoonCore.Services;
|
||||
using Moonlight.ApiServer.Configuration;
|
||||
using Moonlight.ApiServer.Models;
|
||||
|
||||
namespace Moonlight.ApiServer.Http.Controllers.Swagger;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/swagger")]
|
||||
public class SwaggerController : Controller
|
||||
{
|
||||
private readonly ConfigService<AppConfiguration> ConfigService;
|
||||
|
||||
public SwaggerController(ConfigService<AppConfiguration> configService)
|
||||
{
|
||||
ConfigService = configService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> Get()
|
||||
{
|
||||
if (!ConfigService.Get().Development.EnableApiDocs)
|
||||
return BadRequest("Api docs are disabled");
|
||||
|
||||
var options = new ApiDocsOptions();
|
||||
var optionsJson = JsonSerializer.Serialize(options);
|
||||
|
||||
//TODO: Replace the css link with a better one
|
||||
|
||||
var html = "<!doctype html>\n" +
|
||||
"<html>\n" +
|
||||
"<head>\n" +
|
||||
"<title>Moonlight Api Reference</title>\n" +
|
||||
"<meta charset=\"utf-8\" />\n" +
|
||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n" +
|
||||
"</head>\n" +
|
||||
"<body>\n" +
|
||||
"<script id=\"api-reference\" data-url=\"/api/swagger/main\"></script>\n" +
|
||||
"<script>\n" +
|
||||
"var configuration =\n" +
|
||||
$"{optionsJson}\n" +
|
||||
"\n" +
|
||||
"document.getElementById('api-reference').dataset.configuration =\n" +
|
||||
"JSON.stringify(configuration)\n" +
|
||||
"</script>\n" +
|
||||
"<script src=\"https://cdn.jsdelivr.net/npm/@scalar/api-reference\"></script>\n" +
|
||||
"<style>.light-mode {\n --scalar-background-1: #fff;\n --scalar-background-2: #f8fafc;\n --scalar-background-3: #e7e7e7;\n --scalar-background-accent: #8ab4f81f;\n --scalar-color-1: #000;\n --scalar-color-2: #6b7280;\n --scalar-color-3: #9ca3af;\n --scalar-color-accent: #00c16a;\n --scalar-border-color: #e5e7eb;\n --scalar-color-green: #069061;\n --scalar-color-red: #ef4444;\n --scalar-color-yellow: #f59e0b;\n --scalar-color-blue: #1d4ed8;\n --scalar-color-orange: #fb892c;\n --scalar-color-purple: #6d28d9;\n --scalar-button-1: #000;\n --scalar-button-1-hover: rgba(0, 0, 0, 0.9);\n --scalar-button-1-color: #fff;\n}\n.dark-mode {\n --scalar-background-1: #020420;\n --scalar-background-2: #121a31;\n --scalar-background-3: #1e293b;\n --scalar-background-accent: #8ab4f81f;\n --scalar-color-1: #fff;\n --scalar-color-2: #cbd5e1;\n --scalar-color-3: #94a3b8;\n --scalar-color-accent: #00dc82;\n --scalar-border-color: #1e293b;\n --scalar-color-green: #069061;\n --scalar-color-red: #f87171;\n --scalar-color-yellow: #fde68a;\n --scalar-color-blue: #60a5fa;\n --scalar-color-orange: #fb892c;\n --scalar-color-purple: #ddd6fe;\n --scalar-button-1: hsla(0, 0%, 100%, 0.9);\n --scalar-button-1-hover: hsla(0, 0%, 100%, 0.8);\n --scalar-button-1-color: #000;\n}\n.dark-mode .t-doc__sidebar,\n.light-mode .t-doc__sidebar {\n --scalar-sidebar-background-1: var(--scalar-background-1);\n --scalar-sidebar-color-1: var(--scalar-color-1);\n --scalar-sidebar-color-2: var(--scalar-color-3);\n --scalar-sidebar-border-color: var(--scalar-border-color);\n --scalar-sidebar-item-hover-background: transparent;\n --scalar-sidebar-item-hover-color: var(--scalar-color-1);\n --scalar-sidebar-item-active-background: transparent;\n --scalar-sidebar-color-active: var(--scalar-color-accent);\n --scalar-sidebar-search-background: transparent;\n --scalar-sidebar-search-color: var(--scalar-color-3);\n --scalar-sidebar-search-border-color: var(--scalar-border-color);\n --scalar-sidebar-indent-border: var(--scalar-border-color);\n --scalar-sidebar-indent-border-hover: var(--scalar-color-1);\n --scalar-sidebar-indent-border-active: var(--scalar-color-accent);\n}\n.scalar-card .request-card-footer {\n --scalar-background-3: var(--scalar-background-2);\n --scalar-button-1: #0f172a;\n --scalar-button-1-hover: rgba(30, 41, 59, 0.5);\n --scalar-button-1-color: #fff;\n}\n.scalar-card .show-api-client-button {\n border: 1px solid #334155 !important;\n}</style>\n" +
|
||||
"</body>\n" +
|
||||
"</html>";
|
||||
|
||||
return Content(html, "text/html");
|
||||
}
|
||||
}
|
||||
72
Moonlight.ApiServer/Http/Middleware/ApiErrorMiddleware.cs
Normal file
72
Moonlight.ApiServer/Http/Middleware/ApiErrorMiddleware.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using MoonCore.Exceptions;
|
||||
|
||||
namespace Moonlight.ApiServer.Http.Middleware;
|
||||
|
||||
public class ApiErrorMiddleware
|
||||
{
|
||||
private readonly RequestDelegate Next;
|
||||
|
||||
public ApiErrorMiddleware(RequestDelegate next)
|
||||
{
|
||||
Next = next;
|
||||
}
|
||||
|
||||
public async Task Invoke(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Next(context);
|
||||
}
|
||||
catch (HttpApiException httpApiException)
|
||||
{
|
||||
await Results.Problem(
|
||||
title: httpApiException.Title,
|
||||
detail: httpApiException.Detail,
|
||||
statusCode: httpApiException.Status,
|
||||
type: "moonlight/general-api-error"
|
||||
).ExecuteAsync(context);
|
||||
}
|
||||
catch (HttpRequestException e)
|
||||
{
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<ApiErrorMiddleware>>();
|
||||
|
||||
if (e.InnerException is SocketException)
|
||||
{
|
||||
logger.LogCritical("An unhandled socket exception occured. [{method}] {path}: {e}", context.Request.Method, context.Request.Path, e);
|
||||
|
||||
await Results.Problem(
|
||||
title: "An socket exception occured on the api server",
|
||||
detail: "Check the api server logs for more details",
|
||||
statusCode: 502,
|
||||
type: "moonlight/remote-api-connection-error"
|
||||
).ExecuteAsync(context);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogCritical("An unhandled exception occured. [{method}] {path}: {e}", context.Request.Method, context.Request.Path, e.Demystify());
|
||||
|
||||
await Results.Problem(
|
||||
title: "An http request exception occured on the api server",
|
||||
detail: "Check the api server logs for more details",
|
||||
statusCode: 500,
|
||||
type: "moonlight/remote-api-request-error"
|
||||
).ExecuteAsync(context);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<ApiErrorMiddleware>>();
|
||||
|
||||
logger.LogCritical("An unhandled exception occured. [{method}] {path}: {e}", context.Request.Method, context.Request.Path, e);
|
||||
|
||||
await Results.Problem(
|
||||
title: "An unhanded exception occured on the api server",
|
||||
detail: "Check the api server logs for more details",
|
||||
statusCode: 500,
|
||||
type: "moonlight/critical-api-error"
|
||||
).ExecuteAsync(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,107 @@
|
||||
namespace Moonlight.ApiServer.Http.Middleware;
|
||||
using System.Text.Json;
|
||||
using MoonCore.Extended.Abstractions;
|
||||
using MoonCore.Extended.Helpers;
|
||||
using MoonCore.Services;
|
||||
using Moonlight.ApiServer.Configuration;
|
||||
using Moonlight.ApiServer.Database.Entities;
|
||||
using Moonlight.ApiServer.Helpers.Authentication;
|
||||
|
||||
namespace Moonlight.ApiServer.Http.Middleware;
|
||||
|
||||
public class AuthenticationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate Next;
|
||||
private readonly ILogger<AuthenticationMiddleware> Logger;
|
||||
|
||||
public AuthenticationMiddleware(RequestDelegate next)
|
||||
public AuthenticationMiddleware(RequestDelegate next, ILogger<AuthenticationMiddleware> logger)
|
||||
{
|
||||
Next = next;
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
await Authenticate(context);
|
||||
await Next(context);
|
||||
}
|
||||
|
||||
private async Task Authenticate(HttpContext context)
|
||||
{
|
||||
var request = context.Request;
|
||||
string? token = null;
|
||||
|
||||
// Cookie for Moonlight.Client
|
||||
if (request.Cookies.ContainsKey("token") && !string.IsNullOrEmpty(request.Cookies["token"]))
|
||||
token = request.Cookies["token"];
|
||||
|
||||
// Header for api clients
|
||||
if (request.Headers.ContainsKey("Authorization") && !string.IsNullOrEmpty(request.Cookies["Authorization"]))
|
||||
{
|
||||
var headerValue = request.Cookies["Authorization"] ?? "";
|
||||
|
||||
if (headerValue.StartsWith("Bearer"))
|
||||
{
|
||||
var headerParts = headerValue.Split(" ");
|
||||
|
||||
if (headerParts.Length > 1 && !string.IsNullOrEmpty(headerParts[1]))
|
||||
token = headerParts[1];
|
||||
}
|
||||
}
|
||||
|
||||
if(token == null)
|
||||
return;
|
||||
|
||||
// Validate token
|
||||
if (token.Length > 300)
|
||||
{
|
||||
Logger.LogWarning("Received token bigger than 300 characters, Length: {length}", token.Length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decide which authentication method we need for the token
|
||||
if (token.Count(x => x == '.') == 2) // JWT only has two dots
|
||||
await AuthenticateUser(context, token);
|
||||
else
|
||||
await AuthenticateApiKey(context, token);
|
||||
}
|
||||
|
||||
private async Task AuthenticateUser(HttpContext context, string jwt)
|
||||
{
|
||||
var jwtHelper = context.RequestServices.GetRequiredService<JwtHelper>();
|
||||
var configService = context.RequestServices.GetRequiredService<ConfigService<AppConfiguration>>();
|
||||
var secret = configService.Get().Authentication.Secret;
|
||||
|
||||
if(!await jwtHelper.Validate(secret, jwt, "login"))
|
||||
return;
|
||||
|
||||
var data = await jwtHelper.Decode(secret, jwt);
|
||||
|
||||
if(!data.TryGetValue("iat", out var issuedAtString) || !data.TryGetValue("userId", out var userIdString))
|
||||
return;
|
||||
|
||||
var userId = int.Parse(userIdString);
|
||||
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(long.Parse(issuedAtString)).DateTime;
|
||||
|
||||
var userRepo = context.RequestServices.GetRequiredService<DatabaseRepository<User>>();
|
||||
var user = userRepo.Get().FirstOrDefault(x => x.Id == userId);
|
||||
|
||||
if(user == null)
|
||||
return;
|
||||
|
||||
// Check if token is in the past
|
||||
if(user.TokenValidTimestamp > issuedAt)
|
||||
return;
|
||||
|
||||
// Load permissions, handle empty values
|
||||
var permissions = JsonSerializer.Deserialize<string[]>(
|
||||
string.IsNullOrEmpty(user.PermissionsJson) ? "[]" : user.PermissionsJson
|
||||
) ?? [];
|
||||
|
||||
// Save permission state
|
||||
context.User = new PermClaimsPrinciple(permissions, user);
|
||||
}
|
||||
|
||||
private async Task AuthenticateApiKey(HttpContext context, string apiKey)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
144
Moonlight.ApiServer/Http/Middleware/AuthorizationMiddleware.cs
Normal file
144
Moonlight.ApiServer/Http/Middleware/AuthorizationMiddleware.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Moonlight.ApiServer.Attributes;
|
||||
using Moonlight.ApiServer.Exceptions;
|
||||
using Moonlight.ApiServer.Helpers.Authentication;
|
||||
|
||||
namespace Moonlight.ApiServer.Http.Middleware;
|
||||
|
||||
public class AuthorizationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate Next;
|
||||
private readonly ILogger<AuthorizationMiddleware> Logger;
|
||||
|
||||
public AuthorizationMiddleware(RequestDelegate next, ILogger<AuthorizationMiddleware> logger)
|
||||
{
|
||||
Next = next;
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (await Authorize(context))
|
||||
{
|
||||
try
|
||||
{
|
||||
await Next(context);
|
||||
}
|
||||
catch (MissingPermissionException e)
|
||||
{
|
||||
if (e.Permission == "meta.authenticated")
|
||||
{
|
||||
await Results.Problem(
|
||||
title: "This endpoint requires a user authenticated token",
|
||||
statusCode: 401
|
||||
).ExecuteAsync(context);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Results.Problem(
|
||||
title: "You dont have the required permission",
|
||||
detail: e.Permission,
|
||||
statusCode: 403
|
||||
).ExecuteAsync(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> Authorize(HttpContext context)
|
||||
{
|
||||
var requiredPermissions = ResolveRequiredPermissions(context);
|
||||
|
||||
if (requiredPermissions.Length == 0)
|
||||
return true;
|
||||
|
||||
// Check if no context => permissions have been loaded
|
||||
if (context.User is not PermClaimsPrinciple permClaimsPrinciple)
|
||||
{
|
||||
await Results.Problem(
|
||||
title: "An unauthenticated request is not allowed to use this endpoint",
|
||||
statusCode: 401
|
||||
).ExecuteAsync(context);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if one of the required permissions is to be logged in
|
||||
if (requiredPermissions.Any(x => x == "meta.authenticated") && permClaimsPrinciple.CurrentModelNullable == null)
|
||||
{
|
||||
await Results.Problem(
|
||||
title: "This endpoint requires a user authenticated token",
|
||||
statusCode: 401
|
||||
).ExecuteAsync(context);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var permission in requiredPermissions)
|
||||
{
|
||||
if(permission == "meta.authenticated") // We already verified that
|
||||
continue;
|
||||
|
||||
if (!permClaimsPrinciple.HasPermission(permission))
|
||||
{
|
||||
await Results.Problem(
|
||||
title: "You dont have the required permission",
|
||||
detail: permission,
|
||||
statusCode: 403
|
||||
).ExecuteAsync(context);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private string[] ResolveRequiredPermissions(HttpContext context)
|
||||
{
|
||||
// Basic handling
|
||||
var endpoint = context.GetEndpoint();
|
||||
|
||||
if (endpoint == null)
|
||||
return [];
|
||||
|
||||
var metadata = endpoint
|
||||
.Metadata
|
||||
.GetMetadata<ControllerActionDescriptor>();
|
||||
|
||||
if (metadata == null)
|
||||
return [];
|
||||
|
||||
// Retrieve attribute infos
|
||||
var controllerAttrInfo = metadata
|
||||
.ControllerTypeInfo
|
||||
.CustomAttributes
|
||||
.FirstOrDefault(x => x.AttributeType == typeof(RequirePermissionAttribute));
|
||||
|
||||
var methodAttrInfo = metadata
|
||||
.MethodInfo
|
||||
.CustomAttributes
|
||||
.FirstOrDefault(x => x.AttributeType == typeof(RequirePermissionAttribute));
|
||||
|
||||
// Retrieve permissions from attribute infos
|
||||
var controllerPermission = controllerAttrInfo != null
|
||||
? controllerAttrInfo.ConstructorArguments.First().Value as string
|
||||
: null;
|
||||
|
||||
var methodPermission = methodAttrInfo != null
|
||||
? methodAttrInfo.ConstructorArguments.First().Value as string
|
||||
: null;
|
||||
|
||||
// If both have a permission flag, return both
|
||||
if (controllerPermission != null && methodPermission != null)
|
||||
return [controllerPermission, methodPermission];
|
||||
|
||||
// If either of them have a permission set, return it
|
||||
if (controllerPermission != null)
|
||||
return [controllerPermission];
|
||||
|
||||
if (methodPermission != null)
|
||||
return [methodPermission];
|
||||
|
||||
// If both have no permission set, allow everyone to access it
|
||||
return [];
|
||||
}
|
||||
}
|
||||
43
Moonlight.ApiServer/Models/ApiDocsOptions.cs
Normal file
43
Moonlight.ApiServer/Models/ApiDocsOptions.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace Moonlight.ApiServer.Models;
|
||||
|
||||
// From https://github.com/scalar/scalar/blob/main/packages/scalar.aspnetcore/ScalarOptions.cs
|
||||
|
||||
public class ApiDocsOptions
|
||||
{
|
||||
public string Theme { get; set; } = "purple";
|
||||
|
||||
public bool? DarkMode { get; set; }
|
||||
public bool? HideDownloadButton { get; set; }
|
||||
public bool? ShowSideBar { get; set; }
|
||||
|
||||
public bool? WithDefaultFonts { get; set; }
|
||||
|
||||
public string? Layout { get; set; }
|
||||
|
||||
public string? CustomCss { get; set; }
|
||||
|
||||
public string? SearchHotkey { get; set; }
|
||||
|
||||
public Dictionary<string, string>? Metadata { get; set; }
|
||||
|
||||
public ScalarAuthenticationOptions? Authentication { get; set; }
|
||||
}
|
||||
|
||||
public class ScalarAuthenticationOptions
|
||||
{
|
||||
public string? PreferredSecurityScheme { get; set; }
|
||||
|
||||
public ScalarAuthenticationApiKey? ApiKey { get; set; }
|
||||
}
|
||||
|
||||
public class ScalarAuthenticationoAuth2
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
public List<string>? Scopes { get; set; }
|
||||
}
|
||||
|
||||
public class ScalarAuthenticationApiKey
|
||||
{
|
||||
public string? Token { get; set; }
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MoonCore" Version="1.5.7" />
|
||||
<PackageReference Include="MoonCore" Version="1.5.8" />
|
||||
<PackageReference Include="MoonCore.Extended" Version="1.0.2" />
|
||||
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.0" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
||||
@@ -25,11 +25,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Database\Entities\" />
|
||||
<Folder Include="Database\Migrations\" />
|
||||
<Folder Include="Http\Controllers\" />
|
||||
<Folder Include="Models\" />
|
||||
<Folder Include="Services\" />
|
||||
<Folder Include="Implementations\" />
|
||||
<Folder Include="Interfaces\" />
|
||||
<Folder Include="storage\" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.OpenApi.Models;
|
||||
using MoonCore.Extended.Abstractions;
|
||||
using MoonCore.Extended.Helpers;
|
||||
using MoonCore.Extensions;
|
||||
using MoonCore.Helpers;
|
||||
@@ -5,6 +7,7 @@ using MoonCore.Services;
|
||||
using Moonlight.ApiServer.Configuration;
|
||||
using Moonlight.ApiServer.Database;
|
||||
using Moonlight.ApiServer.Helpers;
|
||||
using Moonlight.ApiServer.Http.Middleware;
|
||||
|
||||
// Prepare file system
|
||||
Directory.CreateDirectory(PathBuilder.Dir("storage"));
|
||||
@@ -73,18 +76,32 @@ builder.Services.AddSwaggerGen();
|
||||
|
||||
builder.Services.AddSingleton(configService);
|
||||
|
||||
builder.Services.AddSingleton<JwtHelper>();
|
||||
builder.Services.AutoAddServices<Program>();
|
||||
|
||||
// Database
|
||||
var databaseHelper = new DatabaseHelper(
|
||||
loggerFactory.CreateLogger<DatabaseHelper>()
|
||||
);
|
||||
|
||||
builder.Services.AddSingleton(databaseHelper);
|
||||
builder.Services.AddScoped(typeof(DatabaseRepository<>));
|
||||
|
||||
builder.Services.AddDbContext<CoreDataContext>();
|
||||
databaseHelper.AddDbContext<CoreDataContext>();
|
||||
|
||||
databaseHelper.GenerateMappings();
|
||||
|
||||
// API Docs
|
||||
if (configService.Get().Development.EnableApiDocs)
|
||||
{
|
||||
// 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()
|
||||
{
|
||||
Title = "Moonlight API"
|
||||
}));
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
@@ -92,19 +109,24 @@ using (var scope = app.Services.CreateScope())
|
||||
await databaseHelper.EnsureMigrated(scope.ServiceProvider);
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
if(app.Environment.IsDevelopment())
|
||||
app.UseWebAssemblyDebugging();
|
||||
}
|
||||
|
||||
app.UseBlazorFrameworkFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
app.UseMiddleware<ApiErrorMiddleware>();
|
||||
app.UseMiddleware<AuthenticationMiddleware>();
|
||||
app.UseMiddleware<AuthorizationMiddleware>();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
// API Docs
|
||||
if (configService.Get().Development.EnableApiDocs)
|
||||
app.MapSwagger("/api/swagger/{documentName}");
|
||||
|
||||
app.Run();
|
||||
83
Moonlight.ApiServer/Services/AuthService.cs
Normal file
83
Moonlight.ApiServer/Services/AuthService.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
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;
|
||||
private readonly ConfigService<AppConfiguration> ConfigService;
|
||||
private readonly JwtHelper JwtHelper;
|
||||
|
||||
public AuthService(
|
||||
DatabaseRepository<User> userRepository,
|
||||
ConfigService<AppConfiguration> configService,
|
||||
JwtHelper jwtHelper)
|
||||
{
|
||||
UserRepository = userRepository;
|
||||
ConfigService = configService;
|
||||
JwtHelper = jwtHelper;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateToken(User user)
|
||||
{
|
||||
var authConfig = ConfigService.Get().Authentication;
|
||||
|
||||
return await JwtHelper.Create(authConfig.Secret, data =>
|
||||
{
|
||||
data.Add("userId", user.Id.ToString());
|
||||
}, "login", TimeSpan.FromDays(authConfig.TokenDuration));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.6"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.6" PrivateAssets="all"/>
|
||||
<PackageReference Include="MoonCore" Version="1.5.7" />
|
||||
<PackageReference Include="MoonCore" Version="1.5.8" />
|
||||
<PackageReference Include="MoonCore.Blazor.Tailwind" Version="1.0.1" />
|
||||
<PackageReference Include="MoonCore.PluginFramework" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
13
Moonlight.Shared/Http/Requests/Auth/LoginRequest.cs
Normal file
13
Moonlight.Shared/Http/Requests/Auth/LoginRequest.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.Shared.Http.Requests.Auth;
|
||||
|
||||
public class LoginRequest
|
||||
{
|
||||
[Required(ErrorMessage = "You need to provide an email address")]
|
||||
[EmailAddress(ErrorMessage = "You need to provide a valid email address")]
|
||||
public string Email { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "You need to provide a password")]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
19
Moonlight.Shared/Http/Requests/Auth/RegisterRequest.cs
Normal file
19
Moonlight.Shared/Http/Requests/Auth/RegisterRequest.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Moonlight.Shared.Http.Requests.Auth;
|
||||
|
||||
public class RegisterRequest
|
||||
{
|
||||
[Required(ErrorMessage = "You need to provide an email address")]
|
||||
[EmailAddress(ErrorMessage = "You need to provide a valid email address")]
|
||||
public string Email { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "You need to provide a username")]
|
||||
[RegularExpression("^[a-z][a-z0-9]*$", ErrorMessage = "Usernames can only contain lowercase characters and numbers and should not start with a number")]
|
||||
public string Username { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "You need to provide a password")]
|
||||
[MinLength(8, ErrorMessage = "Your password needs to be at least 8 characters long")]
|
||||
[MaxLength(256, ErrorMessage = "Your password should not exceed the length of 256 characters")]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
8
Moonlight.Shared/Http/Responses/Auth/CheckResponse.cs
Normal file
8
Moonlight.Shared/Http/Responses/Auth/CheckResponse.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Moonlight.Shared.Http.Responses.Auth;
|
||||
|
||||
public class CheckResponse
|
||||
{
|
||||
public string Username { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string[] Permissions { get; set; }
|
||||
}
|
||||
6
Moonlight.Shared/Http/Responses/Auth/LoginResponse.cs
Normal file
6
Moonlight.Shared/Http/Responses/Auth/LoginResponse.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Moonlight.Shared.Http.Responses.Auth;
|
||||
|
||||
public class LoginResponse
|
||||
{
|
||||
public string Token { get; set; }
|
||||
}
|
||||
6
Moonlight.Shared/Http/Responses/Auth/RegisterResponse.cs
Normal file
6
Moonlight.Shared/Http/Responses/Auth/RegisterResponse.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Moonlight.Shared.Http.Responses.Auth;
|
||||
|
||||
public class RegisterResponse
|
||||
{
|
||||
public string Token { get; set; }
|
||||
}
|
||||
@@ -6,9 +6,4 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Http\Requests\" />
|
||||
<Folder Include="Http\Responses\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user