Removed old manual access token checking and switched to asp.net jwt handling. Removed old console subscriber handling and switched to full signal r solution + asp.net core auth

This commit is contained in:
2025-04-13 00:09:06 +02:00
parent ec0c336825
commit 36cbc83c63
15 changed files with 181 additions and 380 deletions

View File

@@ -98,7 +98,7 @@ public class ServerFileSystemController : Controller
url += server.Node.UseSsl ? "https://" : "http://";
url += $"{server.Node.Fqdn}:{server.Node.HttpPort}/";
url += $"api/servers/upload?token={accessToken}";
url += $"api/servers/upload?access_token={accessToken}";
return new ServerFilesUploadResponse()
{
@@ -126,7 +126,7 @@ public class ServerFileSystemController : Controller
url += server.Node.UseSsl ? "https://" : "http://";
url += $"{server.Node.Fqdn}:{server.Node.HttpPort}/";
url += $"api/servers/download?token={accessToken}";
url += $"api/servers/download?access_token={accessToken}";
return new ServerFilesDownloadResponse()
{

View File

@@ -132,7 +132,7 @@ public class ServersController : Controller
{
parameters.Add("type", "websocket");
parameters.Add("serverId", server.Id);
}, TimeSpan.FromMinutes(10));
}, TimeSpan.FromSeconds(30));
var url = "";

View File

@@ -0,0 +1,28 @@
namespace MoonlightServers.ApiServer.Services;
public class NodeBootService : IHostedLifecycleService
{
public async Task StartedAsync(CancellationToken cancellationToken)
{
// TODO: Add node boot calls here
}
#region Unused
public Task StartAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task StartingAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task StoppedAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task StoppingAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
#endregion
}

View File

@@ -32,7 +32,8 @@ public class NodeService
node.Token
)),
SecurityAlgorithms.HmacSha256
)
),
Audience = node.TokenId
};
var securityToken = jwtSecurityTokenHandler.CreateJwtSecurityToken(securityTokenDescriptor);

View File

@@ -1,5 +1,6 @@
using System.Text.RegularExpressions;
using Docker.DotNet.Models;
using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Extensions;
using Stateless;
@@ -98,21 +99,28 @@ public partial class Server
Logger.LogInformation("State: {state}", transition.Destination);
});
// Proxy the events so outside subscribes can react to it
// Proxy the events so outside subscribes can react to it and notify websockets
StateMachine.OnTransitionCompletedAsync(async transition =>
{
// Notify all clients interested in the server
await WebSocketHub.Clients
.Group(Id.ToString()) //TODO: Consider saving the string value in memory
.SendAsync("StateChanged", transition.Destination.ToString());
// Notify all external listeners
if (OnStateChanged != null)
{
await OnStateChanged(transition.Destination);
}
});
Console.OnOutput += (async message =>
{
// Notify all clients interested in the server
await WebSocketHub.Clients
.Group(Id.ToString()) //TODO: Consider saving the string value in memory
.SendAsync("ConsoleOutput", message);
if (OnConsoleOutput != null)
{
await OnConsoleOutput(message);
}
});
return Task.CompletedTask;

View File

@@ -1,5 +1,7 @@
using Docker.DotNet.Models;
using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.Models;
using MoonlightServers.Daemon.Models.Cache;
using Stateless;
@@ -20,8 +22,8 @@ public partial class Server
public string? InstallationContainerId { get; private set; }
// Events
public event Func<ServerState, Task> OnStateChanged;
public event Func<string, Task> OnConsoleOutput;
public event Func<ServerState, Task>? OnStateChanged;
public event Func<string, Task>? OnConsoleOutput;
// Private stuff
@@ -29,6 +31,8 @@ public partial class Server
private readonly IServiceProvider ServiceProvider;
private readonly ServerConsole Console;
private readonly IHubContext<ServerWebSocketHub> WebSocketHub;
private StateMachine<ServerState, ServerTrigger> StateMachine;
private ServerConfiguration Configuration;
private CancellationTokenSource Cancellation;
@@ -36,12 +40,14 @@ public partial class Server
public Server(
ILogger logger,
IServiceProvider serviceProvider,
ServerConfiguration configuration
ServerConfiguration configuration,
IHubContext<ServerWebSocketHub> webSocketHub
)
{
Logger = logger;
ServiceProvider = serviceProvider;
Configuration = configuration;
WebSocketHub = webSocketHub;
Console = new();
Cancellation = new();

View File

@@ -1,51 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using MoonCore.Attributes;
using MoonCore.Extended.Helpers;
using MoonlightServers.Daemon.Configuration;
namespace MoonlightServers.Daemon.Helpers;
[Singleton]
public class AccessTokenHelper
{
private readonly AppConfiguration Configuration;
public AccessTokenHelper(AppConfiguration configuration)
{
Configuration = configuration;
}
// TODO: Improve
public bool Process(string accessToken, out Claim[] claims)
{
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
try
{
var data = jwtSecurityTokenHandler.ValidateToken(accessToken, new()
{
ClockSkew = TimeSpan.Zero,
ValidateLifetime = true,
ValidateAudience = false,
ValidateIssuer = false,
ValidateActor = false,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Configuration.Security.Token)
)
}, out var _);
claims = data.Claims.ToArray();
return true;
}
catch (Exception e)
{
claims = [];
return false;
}
}
}

View File

@@ -1,146 +0,0 @@
using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Abstractions;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.Models;
using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.Helpers;
public class ServerWebSocketConnection
{
private readonly ServerService ServerService;
private readonly ILogger<ServerWebSocketConnection> Logger;
private readonly AccessTokenHelper AccessTokenHelper;
private readonly IHubContext<ServerWebSocketHub> HubContext;
private int ServerId = -1;
private Server Server;
private bool IsInitialized = false;
private string ConnectionId;
public ServerWebSocketConnection(
ServerService serverService,
ILogger<ServerWebSocketConnection> logger,
AccessTokenHelper accessTokenHelper,
IHubContext<ServerWebSocketHub> hubContext
)
{
ServerService = serverService;
Logger = logger;
AccessTokenHelper = accessTokenHelper;
HubContext = hubContext;
}
public Task Initialize(HubCallerContext context) => Task.CompletedTask;
public async Task Authenticate(HubCallerContext context, string accessToken)
{
// Validate access token
if (!AccessTokenHelper.Process(accessToken, out var accessData))
{
Logger.LogDebug("Received invalid or expired access token");
await HubContext.Clients.Client(context.ConnectionId).SendAsync(
"Error",
"Received invalid or expired access token"
);
return;
}
// Validate access token data
if (accessData.All(x => x.Type != "type") || accessData.All(x => x.Type != "serverId"))
{
Logger.LogDebug("Received invalid access token: Required parameters are missing");
await HubContext.Clients.Client(context.ConnectionId).SendAsync(
"Error",
"Received invalid access token: Required parameters are missing"
);
return;
}
// Validate access token type
var type = accessData.First(x => x.Type == "type").Value;
if (type != "websocket")
{
Logger.LogDebug("Received invalid access token: Invalid type '{type}'", type);
await HubContext.Clients.Client(context.ConnectionId).SendAsync(
"Error",
$"Received invalid access token: Invalid type '{type}'"
);
return;
}
var serverId = int.Parse(accessData.First(x => x.Type == "serverId").Value);
// Check that the access token isn't for another server
if (ServerId != -1 && ServerId == serverId)
{
Logger.LogDebug("Received invalid access token: Server id not valid for this session. Current server id: {serverId}", ServerId);
await HubContext.Clients.Client(context.ConnectionId).SendAsync(
"Error",
$"Received invalid access token: Server id not valid for this session. Current server id: {ServerId}"
);
return;
}
var server = ServerService.GetServer(serverId);
// Check i the server actually exists
if (server == null)
{
Logger.LogDebug("Received invalid access token: No server found with the requested id");
await HubContext.Clients.Client(context.ConnectionId).SendAsync(
"Error",
"Received invalid access token: No server found with the requested id"
);
return;
}
// Set values
Server = server;
ServerId = serverId;
ConnectionId = context.ConnectionId;
if(IsInitialized)
return;
IsInitialized = true;
// Setup event handlers
Server.OnConsoleOutput += HandleConsoleOutput;
Server.OnStateChanged += HandleStateChange;
Logger.LogTrace("Authenticated and initialized server console connection '{id}'", context.ConnectionId);
}
public Task Destroy(HubCallerContext context)
{
Logger.LogTrace("Destroyed server console connection '{id}'", context.ConnectionId);
Server.OnConsoleOutput -= HandleConsoleOutput;
Server.OnStateChanged -= HandleStateChange;
return Task.CompletedTask;
}
#region Event Handlers
private async Task HandleStateChange(ServerState state)
=> await HubContext.Clients.Client(ConnectionId).SendAsync("StateChanged", state.ToString());
private async Task HandleConsoleOutput(string line)
=> await HubContext.Clients.Client(ConnectionId).SendAsync("ConsoleOutput", line);
#endregion
}

View File

@@ -2,66 +2,34 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.Http.Controllers.Servers;
[AllowAnonymous]
[ApiController]
[Route("api/servers/download")]
[Authorize(AuthenticationSchemes = "accessToken", Policy = "serverDownload")]
public class DownloadController : Controller
{
private readonly AccessTokenHelper AccessTokenHelper;
private readonly AppConfiguration Configuration;
private readonly ServerService ServerService;
public DownloadController(
AccessTokenHelper accessTokenHelper,
ServerService serverService,
AppConfiguration configuration
)
public DownloadController(ServerService serverService)
{
AccessTokenHelper = accessTokenHelper;
ServerService = serverService;
Configuration = configuration;
}
[HttpGet]
public async Task Download([FromQuery] string token)
public async Task Download()
{
#region Token validation
if (!AccessTokenHelper.Process(token, out var claims))
throw new HttpApiException("Invalid access token provided", 401);
var typeClaim = claims.FirstOrDefault(x => x.Type == "type");
if (typeClaim == null || typeClaim.Value != "download")
throw new HttpApiException("Invalid access token provided: Missing or invalid type", 401);
var serverIdClaim = claims.FirstOrDefault(x => x.Type == "serverId");
if (serverIdClaim == null || !int.TryParse(serverIdClaim.Value, out var serverId))
throw new HttpApiException("Invalid access token provided: Missing or invalid server id", 401);
var pathClaim = claims.FirstOrDefault(x => x.Type == "path");
if(pathClaim == null || string.IsNullOrEmpty(pathClaim.Value))
throw new HttpApiException("Invalid access token provided: Missing or invalid path", 401);
#endregion
var serverId = int.Parse(User.Claims.First(x => x.Type == "serverId").Value);
var path = User.Claims.First(x => x.Type == "path").Value;
var server = ServerService.GetServer(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
var path = pathClaim.Value;
await server.FileSystem.Read(path, async dataStream =>
{
await Results.File(dataStream).ExecuteAsync(HttpContext);
});
await server.FileSystem.Read(path,
async dataStream => { await Results.File(dataStream).ExecuteAsync(HttpContext); });
}
}

View File

@@ -9,30 +9,26 @@ using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.Http.Controllers.Servers;
[ApiController]
[AllowAnonymous]
[Route("api/servers/upload")]
[Authorize(AuthenticationSchemes = "accessToken", Policy = "serverUpload")]
public class UploadController : Controller
{
private readonly AccessTokenHelper AccessTokenHelper;
private readonly AppConfiguration Configuration;
private readonly ServerService ServerService;
private readonly long ChunkSize = ByteConverter.FromMegaBytes(20).Bytes; // TODO config
public UploadController(
AccessTokenHelper accessTokenHelper,
ServerService serverService,
AppConfiguration configuration
)
{
AccessTokenHelper = accessTokenHelper;
ServerService = serverService;
Configuration = configuration;
}
[HttpPost]
public async Task Upload(
[FromQuery] string token,
[FromQuery] long totalSize, // TODO: Add limit in config
[FromQuery] int chunkId,
[FromQuery] string path
@@ -50,22 +46,7 @@ public class UploadController : Controller
#endregion
#region Token validation
if (!AccessTokenHelper.Process(token, out var claims))
throw new HttpApiException("Invalid access token provided", 401);
var typeClaim = claims.FirstOrDefault(x => x.Type == "type");
if (typeClaim == null || typeClaim.Value != "upload")
throw new HttpApiException("Invalid access token provided: Missing or invalid type", 401);
var serverIdClaim = claims.FirstOrDefault(x => x.Type == "serverId");
if (serverIdClaim == null || !int.TryParse(serverIdClaim.Value, out var serverId))
throw new HttpApiException("Invalid access token provided: Missing or invalid server id", 401);
#endregion
var serverId = int.Parse(User.Claims.First(x => x.Type == "serverId").Value);
#region Chunk calculation and validation

View File

@@ -1,43 +1,28 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.Http.Hubs;
[Authorize(AuthenticationSchemes = "accessToken", Policy = "serverWebsocket")]
public class ServerWebSocketHub : Hub
{
private readonly ILogger<ServerWebSocketHub> Logger;
private readonly ServerWebSocketService WebSocketService;
public ServerWebSocketHub(ILogger<ServerWebSocketHub> logger, ServerWebSocketService webSocketService)
public ServerWebSocketHub(ILogger<ServerWebSocketHub> logger)
{
Logger = logger;
WebSocketService = webSocketService;
}
#region Connection Handlers
public override async Task OnConnectedAsync()
=> await WebSocketService.InitializeClient(Context);
public override async Task OnDisconnectedAsync(Exception? exception)
=> await WebSocketService.DestroyClient(Context);
#endregion
#region Methods
[HubMethodName("Authenticate")]
public async Task Authenticate(string accessToken)
{
try
{
await WebSocketService.AuthenticateClient(Context, accessToken);
}
catch (Exception e)
{
Logger.LogError("An unhandled error occured in the Authenticate method: {e}", e);
}
}
// The policies validated already the type and the token so we can assume we are authenticated
// and just start adding ourselves into the desired group
#endregion
var serverId = Context.User!.Claims.First(x => x.Type == "serverId").Value;
await Groups.AddToGroupAsync(
Context.ConnectionId,
serverId
);
}
}

View File

@@ -1,11 +1,13 @@
using Docker.DotNet;
using Docker.DotNet.Models;
using Microsoft.AspNetCore.SignalR;
using MoonCore.Attributes;
using MoonCore.Exceptions;
using MoonCore.Models;
using MoonlightServers.Daemon.Abstractions;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Extensions;
using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.DaemonShared.PanelSide.Http.Responses;
@@ -19,6 +21,7 @@ public class ServerService : IHostedLifecycleService
private readonly RemoteService RemoteService;
private readonly IServiceProvider ServiceProvider;
private readonly ILoggerFactory LoggerFactory;
private readonly IHubContext<ServerWebSocketHub> WebSocketHub;
private CancellationTokenSource Cancellation = new();
private bool IsInitialized = false;
@@ -26,13 +29,15 @@ public class ServerService : IHostedLifecycleService
RemoteService remoteService,
ILogger<ServerService> logger,
IServiceProvider serviceProvider,
ILoggerFactory loggerFactory
ILoggerFactory loggerFactory,
IHubContext<ServerWebSocketHub> webSocketHub
)
{
RemoteService = remoteService;
Logger = logger;
ServiceProvider = serviceProvider;
LoggerFactory = loggerFactory;
WebSocketHub = webSocketHub;
}
public async Task Initialize() //TODO: Add initialize call from panel
@@ -190,7 +195,8 @@ public class ServerService : IHostedLifecycleService
var server = new Server(
LoggerFactory.CreateLogger($"Server {serverConfiguration.Id}"),
ServiceProvider,
serverConfiguration
serverConfiguration,
WebSocketHub
);
await server.Initialize(existingContainers);

View File

@@ -1,68 +0,0 @@
using Microsoft.AspNetCore.SignalR;
using MoonCore.Attributes;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.Http.Hubs;
namespace MoonlightServers.Daemon.Services;
[Singleton]
public class ServerWebSocketService
{
private readonly ILogger<ServerWebSocketService> Logger;
private readonly IServiceProvider ServiceProvider;
private readonly Dictionary<string, ServerWebSocketConnection> Connections = new();
public ServerWebSocketService(
ILogger<ServerWebSocketService> logger,
IServiceProvider serviceProvider
)
{
Logger = logger;
ServiceProvider = serviceProvider;
}
public async Task InitializeClient(HubCallerContext context)
{
var connection = new ServerWebSocketConnection(
ServiceProvider.GetRequiredService<ServerService>(),
ServiceProvider.GetRequiredService<ILogger<ServerWebSocketConnection>>(),
ServiceProvider.GetRequiredService<AccessTokenHelper>(),
ServiceProvider.GetRequiredService<IHubContext<ServerWebSocketHub>>()
);
lock (Connections)
Connections[context.ConnectionId] = connection;
await connection.Initialize(context);
}
public async Task AuthenticateClient(HubCallerContext context, string accessToken)
{
ServerWebSocketConnection? connection;
lock (Connections)
connection = Connections.GetValueOrDefault(context.ConnectionId);
if(connection == null)
return;
await connection.Authenticate(context, accessToken);
}
public async Task DestroyClient(HubCallerContext context)
{
ServerWebSocketConnection? connection;
lock (Connections)
connection = Connections.GetValueOrDefault(context.ConnectionId);
if(connection == null)
return;
await connection.Destroy(context);
lock (Connections)
Connections.Remove(context.ConnectionId);
}
}

View File

@@ -1,5 +1,9 @@
using System.Text;
using System.Text.Json;
using Docker.DotNet;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.IdentityModel.Tokens;
using MoonCore.EnvConfiguration;
using MoonCore.Extended.Extensions;
using MoonCore.Extensions;
@@ -50,8 +54,8 @@ public class Startup
await BuildWebApplication();
await UseBase();
await UseAuth();
await UseCors();
await UseAuth();
await UseBaseMiddleware();
await MapBase();
@@ -273,7 +277,11 @@ public class Startup
private Task MapHubs()
{
WebApplication.MapHub<ServerWebSocketHub>("api/servers/ws");
WebApplication.MapHub<ServerWebSocketHub>("api/servers/ws", options =>
{
options.AllowStatefulReconnects = false;
options.CloseOnAuthenticationExpiration = true;
});
return Task.CompletedTask;
}
@@ -286,8 +294,11 @@ public class Startup
{
//TODO: IMPORTANT: CHANGE !!!
WebApplicationBuilder.Services.AddCors(x =>
x.AddDefaultPolicy(builder =>
builder.AllowAnyHeader().AllowAnyOrigin().AllowAnyMethod().Build()
x.AddDefaultPolicy(builder => builder
.SetIsOriginAllowed(_ => true)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
)
);
@@ -308,12 +319,78 @@ public class Startup
{
WebApplicationBuilder.Services
.AddAuthentication("token")
.AddScheme<TokenAuthOptions, TokenAuthScheme>("token", options =>
.AddScheme<TokenAuthOptions, TokenAuthScheme>("token",
options => { options.Token = Configuration.Security.Token; })
.AddJwtBearer("accessToken", options =>
{
options.Token = Configuration.Security.Token;
options.TokenValidationParameters = new TokenValidationParameters()
{
ClockSkew = TimeSpan.Zero,
ValidateAudience = true,
ValidateIssuer = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidateActor = false,
IssuerSigningKeys =
[
new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Configuration.Security.Token)
)
],
AudienceValidator = (audiences, _, _)
=> audiences.Contains(Configuration.Security.TokenId)
};
// Some requests like the signal r websockets or the upload and download endpoints require the user to provide
// the access token via the query instead of the header field due to client limitations. To reuse the authentication
// on all these requests as we do for all other endpoints which require an access token, we are defining
// the custom behaviour to load the access token from the query for these specific endpoints below
options.Events = new JwtBearerEvents()
{
OnMessageReceived = context =>
{
if (
!context.HttpContext.Request.Path.StartsWithSegments("/api/servers/ws") &&
!context.HttpContext.Request.Path.StartsWithSegments("/api/servers/upload") &&
!context.HttpContext.Request.Path.StartsWithSegments("/api/servers/download")
)
{
return Task.CompletedTask;
}
var accessToken = context.Request.Query["access_token"];
if (string.IsNullOrEmpty(accessToken))
return Task.CompletedTask;
context.Token = accessToken;
return Task.CompletedTask;
}
};
});
WebApplicationBuilder.Services.AddAuthorization();
WebApplicationBuilder.Services.AddAuthorization(options =>
{
// We are defining the access token policies here. Because the same jwt secret is used by the panel
// to generate jwt access tokens for all sorts of daemon related stuff we need to separate
// the type of access token using the type parameter provided in the claims.
options.AddPolicy("serverWebsocket", builder =>
{
builder.RequireClaim("type", "websocket");
});
options.AddPolicy("serverUpload", builder =>
{
builder.RequireClaim("type", "upload");
});
options.AddPolicy("serverDownload", builder =>
{
builder.RequireClaim("type", "download");
});
});
return Task.CompletedTask;
}

View File

@@ -1,6 +1,7 @@
@page "/servers/{ServerId:int}"
@page "/servers/{ServerId:int}/{TabPath:alpha}"
@using Microsoft.AspNetCore.Http.Connections
@using Microsoft.AspNetCore.SignalR.Client
@using MoonlightServers.Shared.Http.Responses.Users.Servers
@using MoonCore.Blazor.Tailwind.Components
@@ -224,7 +225,15 @@
// Build signal r
HubConnection = new HubConnectionBuilder()
.WithUrl(websocketDetails.Target)
.WithUrl(websocketDetails.Target, options =>
{
options.AccessTokenProvider = async () =>
{
var details = await ServerService.GetWebSocket(ServerId);
return details.AccessToken;
};
})
.WithAutomaticReconnect()
.Build();
// Define handlers
@@ -246,9 +255,6 @@
// Connect
await HubConnection.StartAsync();
// Authenticate
await HubConnection.SendAsync("Authenticate", websocketDetails.AccessToken);
}
catch (HttpApiException e)
{