diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index bd5e973c04..8c625539ab 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -110,7 +110,7 @@ using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using ServiceStack; +using Microsoft.OpenApi.Models; using OperatingSystem = MediaBrowser.Common.System.OperatingSystem; namespace Emby.Server.Implementations @@ -230,7 +230,25 @@ namespace Emby.Server.Implementations } } - protected IServiceProvider _serviceProvider; + /// + /// Gets or sets the service provider. + /// + public IServiceProvider ServiceProvider { get; set; } + + /// + /// Gets the http port for the webhost. + /// + public int HttpPort { get; private set; } + + /// + /// Gets the https port for the webhost. + /// + public int HttpsPort { get; private set; } + + /// + /// Gets the content root for the webhost. + /// + public string ContentRoot { get; private set; } /// /// Gets the server configuration manager. @@ -459,7 +477,7 @@ namespace Emby.Server.Implementations /// The type. /// System.Object. public object CreateInstance(Type type) - => ActivatorUtilities.CreateInstance(_serviceProvider, type); + => ActivatorUtilities.CreateInstance(ServiceProvider, type); /// /// Creates an instance of type and resolves all constructor dependencies. @@ -467,7 +485,7 @@ namespace Emby.Server.Implementations /// /// The type. /// T. public T CreateInstance() - => ActivatorUtilities.CreateInstance(_serviceProvider); + => ActivatorUtilities.CreateInstance(ServiceProvider); /// /// Creates the instance safe. @@ -479,7 +497,7 @@ namespace Emby.Server.Implementations try { Logger.LogDebug("Creating instance of {Type}", type); - return ActivatorUtilities.CreateInstance(_serviceProvider, type); + return ActivatorUtilities.CreateInstance(ServiceProvider, type); } catch (Exception ex) { @@ -493,7 +511,7 @@ namespace Emby.Server.Implementations /// /// The type /// ``0. - public T Resolve() => _serviceProvider.GetService(); + public T Resolve() => ServiceProvider.GetService(); /// /// Gets the export types. @@ -610,77 +628,14 @@ namespace Emby.Server.Implementations await RegisterResources(serviceCollection).ConfigureAwait(false); - FindParts(); - - string contentRoot = ServerConfigurationManager.Configuration.DashboardSourcePath; - if (string.IsNullOrEmpty(contentRoot)) + ContentRoot = ServerConfigurationManager.Configuration.DashboardSourcePath; + if (string.IsNullOrEmpty(ContentRoot)) { - contentRoot = ServerConfigurationManager.ApplicationPaths.WebPath; - } - - var host = new WebHostBuilder() - .UseKestrel(options => - { - var addresses = ServerConfigurationManager - .Configuration - .LocalNetworkAddresses - .Select(NormalizeConfiguredLocalAddress) - .Where(i => i != null) - .ToList(); - if (addresses.Any()) - { - foreach (var address in addresses) - { - Logger.LogInformation("Kestrel listening on {ipaddr}", address); - options.Listen(address, HttpPort); - - if (EnableHttps && Certificate != null) - { - options.Listen(address, HttpsPort, listenOptions => listenOptions.UseHttps(Certificate)); - } - } - } - else - { - Logger.LogInformation("Kestrel listening on all interfaces"); - options.ListenAnyIP(HttpPort); - - if (EnableHttps && Certificate != null) - { - options.ListenAnyIP(HttpsPort, listenOptions => listenOptions.UseHttps(Certificate)); - } - } - }) - .UseContentRoot(contentRoot) - .ConfigureServices(services => - { - services.AddResponseCompression(); - services.AddHttpContextAccessor(); - }) - .Configure(app => - { - app.UseWebSockets(); - - app.UseResponseCompression(); - - // TODO app.UseMiddleware(); - app.Use(ExecuteWebsocketHandlerAsync); - app.Use(ExecuteHttpHandlerAsync); - }) - .Build(); - - try - { - await host.StartAsync().ConfigureAwait(false); - } - catch - { - Logger.LogError("Kestrel failed to start! This is most likely due to an invalid address or port bind - correct your bind configuration in system.xml and try again."); - throw; + ContentRoot = ServerConfigurationManager.ApplicationPaths.WebPath; } } - private async Task ExecuteWebsocketHandlerAsync(HttpContext context, Func next) + public async Task ExecuteWebsocketHandlerAsync(HttpContext context, Func next) { if (!context.WebSockets.IsWebSocketRequest) { @@ -691,7 +646,7 @@ namespace Emby.Server.Implementations await HttpServer.ProcessWebSocketRequest(context).ConfigureAwait(false); } - private async Task ExecuteHttpHandlerAsync(HttpContext context, Func next) + public async Task ExecuteHttpHandlerAsync(HttpContext context, Func next) { if (context.WebSockets.IsWebSocketRequest) { @@ -909,7 +864,7 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(authContext); serviceCollection.AddSingleton(new SessionContext(UserManager, authContext, SessionManager)); - AuthService = new AuthService(authContext, ServerConfigurationManager, SessionManager, NetworkManager); + AuthService = new AuthService(LoggerFactory.CreateLogger(), authContext, ServerConfigurationManager, SessionManager, NetworkManager); serviceCollection.AddSingleton(AuthService); SubtitleEncoder = new MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder(LibraryManager, LoggerFactory, ApplicationPaths, FileSystemManager, MediaEncoder, JsonSerializer, HttpClient, MediaSourceManager, ProcessFactory); @@ -928,8 +883,6 @@ namespace Emby.Server.Implementations ((UserDataManager)UserDataManager).Repository = userDataRepo; ItemRepository.Initialize(userDataRepo, UserManager); ((LibraryManager)LibraryManager).ItemRepository = ItemRepository; - - _serviceProvider = serviceCollection.BuildServiceProvider(); } public static void LogEnvironmentInfo(ILogger logger, IApplicationPaths appPaths) @@ -1086,9 +1039,9 @@ namespace Emby.Server.Implementations /// /// Finds the parts. /// - protected void FindParts() + public void FindParts() { - InstallationManager = _serviceProvider.GetService(); + InstallationManager = ServiceProvider.GetService(); InstallationManager.PluginInstalled += PluginInstalled; if (!ServerConfigurationManager.Configuration.IsPortAuthorized) @@ -1217,7 +1170,7 @@ namespace Emby.Server.Implementations private CertificateInfo CertificateInfo { get; set; } - protected X509Certificate2 Certificate { get; private set; } + public X509Certificate2 Certificate { get; private set; } private IEnumerable GetUrlPrefixes() { @@ -1602,7 +1555,7 @@ namespace Emby.Server.Implementations return resultList; } - private IPAddress NormalizeConfiguredLocalAddress(string address) + public IPAddress NormalizeConfiguredLocalAddress(string address) { var index = address.Trim('/').IndexOf('/'); @@ -1678,10 +1631,6 @@ namespace Emby.Server.Implementations ? Environment.MachineName : ServerConfigurationManager.Configuration.ServerName; - public int HttpPort { get; private set; } - - public int HttpsPort { get; private set; } - /// /// Shuts down. /// diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 214ea5aff9..eb9069c444 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -1,8 +1,9 @@ - + + diff --git a/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs b/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs index dc1a56e271..6dd016f8a2 100644 --- a/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs +++ b/Emby.Server.Implementations/HttpServer/HttpListenerHost.cs @@ -18,7 +18,6 @@ using MediaBrowser.Model.Events; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Services; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Internal; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -164,7 +163,7 @@ namespace Emby.Server.Implementations.HttpServer { OnReceive = ProcessWebSocketMessageReceived, Url = e.Url, - QueryString = e.QueryString ?? new QueryCollection() + QueryString = e.QueryString }; connection.Closed += OnConnectionClosed; diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index 93a61fe67a..594f464989 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Emby.Server.Implementations.SocketSharp; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -7,22 +8,27 @@ using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.HttpServer.Security { public class AuthService : IAuthService { + private readonly ILogger _logger; private readonly IAuthorizationContext _authorizationContext; private readonly ISessionManager _sessionManager; private readonly IServerConfigurationManager _config; private readonly INetworkManager _networkManager; public AuthService( + ILogger logger, IAuthorizationContext authorizationContext, IServerConfigurationManager config, ISessionManager sessionManager, INetworkManager networkManager) { + _logger = logger; _authorizationContext = authorizationContext; _config = config; _sessionManager = sessionManager; @@ -34,7 +40,14 @@ namespace Emby.Server.Implementations.HttpServer.Security ValidateUser(request, authAttribtues); } - private void ValidateUser(IRequest request, IAuthenticationAttributes authAttribtues) + public User Authenticate(HttpRequest request, IAuthenticationAttributes authAttributes) + { + var req = new WebSocketSharpRequest(request, null, request.Path, _logger); + var user = ValidateUser(req, authAttributes); + return user; + } + + private User ValidateUser(IRequest request, IAuthenticationAttributes authAttribtues) { // This code is executed before the service var auth = _authorizationContext.GetAuthorizationInfo(request); @@ -81,6 +94,8 @@ namespace Emby.Server.Implementations.HttpServer.Security request.RemoteIp, user); } + + return user; } private void ValidateUserAccess( diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 63ec757626..930f2d35d3 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Events; using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -67,7 +66,7 @@ namespace Emby.Server.Implementations.Session { if (queryString == null) { - throw new ArgumentNullException(nameof(queryString)); + return null; } var token = queryString["api_key"]; @@ -75,6 +74,7 @@ namespace Emby.Server.Implementations.Session { return null; } + var deviceId = queryString["deviceId"]; return _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint); } diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs new file mode 100644 index 0000000000..26f7d9d2dd --- /dev/null +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -0,0 +1,68 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller.Net; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Jellyfin.Api.Auth +{ + /// + /// Custom authentication handler wrapping the legacy authentication. + /// + public class CustomAuthenticationHandler : AuthenticationHandler + { + private readonly IAuthService _authService; + + /// + /// Initializes a new instance of the class. + /// + /// The jellyfin authentication service. + /// Options monitor. + /// The logger. + /// The url encoder. + /// The system clock. + public CustomAuthenticationHandler( + IAuthService authService, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) : base(options, logger, encoder, clock) + { + _authService = authService; + } + + /// + protected override Task HandleAuthenticateAsync() + { + var authenticatedAttribute = new AuthenticatedAttribute(); + try + { + var user = _authService.Authenticate(Request, authenticatedAttribute); + if (user == null) + { + return Task.FromResult(AuthenticateResult.Fail("Invalid user")); + } + + var claims = new[] + { + new Claim(ClaimTypes.Name, user.Name), + new Claim( + ClaimTypes.Role, + value: user.Policy.IsAdministrator ? UserRoles.Administrator : UserRoles.User) + }; + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + catch (SecurityException ex) + { + return Task.FromResult(AuthenticateResult.Fail(ex)); + } + } + } +} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs new file mode 100644 index 0000000000..34aa5d12c8 --- /dev/null +++ b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Configuration; +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy +{ + /// + /// Authorization handler for requiring first time setup or elevated privileges. + /// + public class FirstTimeSetupOrElevatedHandler : AuthorizationHandler + { + private readonly IConfigurationManager _configurationManager; + + /// + /// Initializes a new instance of the class. + /// + /// The jellyfin configuration manager. + public FirstTimeSetupOrElevatedHandler(IConfigurationManager configurationManager) + { + _configurationManager = configurationManager; + } + + /// + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupOrElevatedRequirement firstTimeSetupOrElevatedRequirement) + { + if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) + { + context.Succeed(firstTimeSetupOrElevatedRequirement); + } + else if (context.User.IsInRole(UserRoles.Administrator)) + { + context.Succeed(firstTimeSetupOrElevatedRequirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs new file mode 100644 index 0000000000..51ba637b60 --- /dev/null +++ b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy +{ + /// + /// The authorization requirement, requiring incomplete first time setup or elevated privileges, for the authorization handler. + /// + public class FirstTimeSetupOrElevatedRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs new file mode 100644 index 0000000000..2d3bb1aa48 --- /dev/null +++ b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.RequiresElevationPolicy +{ + /// + /// Authorization handler for requiring elevated privileges. + /// + public class RequiresElevationHandler : AuthorizationHandler + { + /// + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresElevationRequirement requirement) + { + if (context.User.IsInRole(UserRoles.Administrator)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs new file mode 100644 index 0000000000..cfff1cc0c5 --- /dev/null +++ b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.RequiresElevationPolicy +{ + /// + /// The authorization requirement for requiring elevated privileges in the authorization handler. + /// + public class RequiresElevationRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/BaseJellyfinApiController.cs b/Jellyfin.Api/BaseJellyfinApiController.cs new file mode 100644 index 0000000000..1f4508e6cb --- /dev/null +++ b/Jellyfin.Api/BaseJellyfinApiController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api +{ + /// + /// Base api controller for the API setting a default route. + /// + [ApiController] + [Route("[controller]")] + public class BaseJellyfinApiController : ControllerBase + { + } +} diff --git a/Jellyfin.Api/Constants/AuthenticationSchemes.cs b/Jellyfin.Api/Constants/AuthenticationSchemes.cs new file mode 100644 index 0000000000..bac3379e71 --- /dev/null +++ b/Jellyfin.Api/Constants/AuthenticationSchemes.cs @@ -0,0 +1,13 @@ +namespace Jellyfin.Api.Constants +{ + /// + /// Authentication schemes for user authentication in the API. + /// + public static class AuthenticationSchemes + { + /// + /// Scheme name for the custom legacy authentication. + /// + public const string CustomAuthentication = "CustomAuthentication"; + } +} diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs new file mode 100644 index 0000000000..e2b383f75d --- /dev/null +++ b/Jellyfin.Api/Constants/Policies.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Constants +{ + /// + /// Policies for the API authorization. + /// + public static class Policies + { + /// + /// Policy name for requiring first time setup or elevated privileges. + /// + public const string FirstTimeSetupOrElevated = "FirstTimeOrElevated"; + + /// + /// Policy name for requiring elevated privileges. + /// + public const string RequiresElevation = "RequiresElevation"; + } +} diff --git a/Jellyfin.Api/Constants/UserRoles.cs b/Jellyfin.Api/Constants/UserRoles.cs new file mode 100644 index 0000000000..d9a536e7d7 --- /dev/null +++ b/Jellyfin.Api/Constants/UserRoles.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Api.Constants +{ + /// + /// Constants for user roles used in the authentication and authorization for the API. + /// + public static class UserRoles + { + /// + /// Guest user. + /// + public const string Guest = "Guest"; + + /// + /// Regular user with no special privileges. + /// + public const string User = "User"; + + /// + /// Administrator user with elevated privileges. + /// + public const string Administrator = "Administrator"; + } +} diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs new file mode 100644 index 0000000000..1014c8c56b --- /dev/null +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -0,0 +1,127 @@ +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.StartupDtos; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// + /// The startup wizard controller. + /// + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] + public class StartupController : BaseJellyfinApiController + { + private readonly IServerConfigurationManager _config; + private readonly IUserManager _userManager; + + /// + /// Initializes a new instance of the class. + /// + /// The server configuration manager. + /// The user manager. + public StartupController(IServerConfigurationManager config, IUserManager userManager) + { + _config = config; + _userManager = userManager; + } + + /// + /// Api endpoint for completing the startup wizard. + /// + [HttpPost("Complete")] + public void CompleteWizard() + { + _config.Configuration.IsStartupWizardCompleted = true; + _config.SetOptimalValues(); + _config.SaveConfiguration(); + } + + /// + /// Endpoint for getting the initial startup wizard configuration. + /// + /// The initial startup wizard configuration. + [HttpGet("Configuration")] + public StartupConfigurationDto GetStartupConfiguration() + { + var result = new StartupConfigurationDto + { + UICulture = _config.Configuration.UICulture, + MetadataCountryCode = _config.Configuration.MetadataCountryCode, + PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage + }; + + return result; + } + + /// + /// Endpoint for updating the initial startup wizard configuration. + /// + /// The UI language culture. + /// The metadata country code. + /// The preferred language for metadata. + [HttpPost("Configuration")] + public void UpdateInitialConfiguration( + [FromForm] string uiCulture, + [FromForm] string metadataCountryCode, + [FromForm] string preferredMetadataLanguage) + { + _config.Configuration.UICulture = uiCulture; + _config.Configuration.MetadataCountryCode = metadataCountryCode; + _config.Configuration.PreferredMetadataLanguage = preferredMetadataLanguage; + _config.SaveConfiguration(); + } + + /// + /// Endpoint for (dis)allowing remote access and UPnP. + /// + /// Enable remote access. + /// Enable UPnP. + [HttpPost("RemoteAccess")] + public void SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping) + { + _config.Configuration.EnableRemoteAccess = enableRemoteAccess; + _config.Configuration.EnableUPnP = enableAutomaticPortMapping; + _config.SaveConfiguration(); + } + + /// + /// Endpoint for returning the first user. + /// + /// The first user. + [HttpGet("User")] + public StartupUserDto GetFirstUser() + { + var user = _userManager.Users.First(); + + return new StartupUserDto + { + Name = user.Name, + Password = user.Password + }; + } + + /// + /// Endpoint for updating the user name and password. + /// + /// The DTO containing username and password. + /// The async task. + [HttpPost("User")] + public async Task UpdateUser([FromForm] StartupUserDto startupUserDto) + { + var user = _userManager.Users.First(); + + user.Name = startupUserDto.Name; + + _userManager.UpdateUser(user); + + if (!string.IsNullOrEmpty(startupUserDto.Password)) + { + await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); + } + } + } +} diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj new file mode 100644 index 0000000000..a2818b45da --- /dev/null +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -0,0 +1,32 @@ + + + + netstandard2.1 + true + true + + + + + + + + + + + + + + + + + + + + + + + ../jellyfin.ruleset + + + diff --git a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs new file mode 100644 index 0000000000..d048dad0a1 --- /dev/null +++ b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Api.Models.StartupDtos +{ + /// + /// The startup configuration DTO. + /// + public class StartupConfigurationDto + { + /// + /// Gets or sets UI language culture. + /// + public string UICulture { get; set; } + + /// + /// Gets or sets the metadata country code. + /// + public string MetadataCountryCode { get; set; } + + /// + /// Gets or sets the preferred language for the metadata. + /// + public string PreferredMetadataLanguage { get; set; } + } +} diff --git a/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs new file mode 100644 index 0000000000..3a9348037a --- /dev/null +++ b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Models.StartupDtos +{ + /// + /// The startup user DTO. + /// + public class StartupUserDto + { + /// + /// Gets or sets the username. + /// + public string Name { get; set; } + + /// + /// Gets or sets the user's password. + /// + public string Password { get; set; } + } +} diff --git a/Jellyfin.Api/MvcRoutePrefix.cs b/Jellyfin.Api/MvcRoutePrefix.cs new file mode 100644 index 0000000000..e009730947 --- /dev/null +++ b/Jellyfin.Api/MvcRoutePrefix.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace Jellyfin.Api +{ + /// + /// Route prefixing for ASP.NET MVC. + /// + public static class MvcRoutePrefix + { + /// + /// Adds route prefixes to the MVC conventions. + /// + /// The MVC options. + /// The list of prefixes. + public static void UseGeneralRoutePrefix(this MvcOptions opts, params string[] prefixes) + { + opts.Conventions.Insert(0, new RoutePrefixConvention(prefixes)); + } + + private class RoutePrefixConvention : IApplicationModelConvention + { + private readonly AttributeRouteModel[] _routePrefixes; + + public RoutePrefixConvention(IEnumerable prefixes) + { + _routePrefixes = prefixes.Select(p => new AttributeRouteModel(new RouteAttribute(p))).ToArray(); + } + + public void Apply(ApplicationModel application) + { + foreach (var controller in application.Controllers) + { + if (controller.Selectors == null) + { + continue; + } + + var newSelectors = new List(); + foreach (var selector in controller.Selectors) + { + newSelectors.AddRange(_routePrefixes.Select(routePrefix => new SelectorModel(selector) + { + AttributeRouteModel = AttributeRouteModel.CombineAttributeRouteModel(routePrefix, selector.AttributeRouteModel) + })); + } + + controller.Selectors.Clear(); + newSelectors.ForEach(selector => controller.Selectors.Add(selector)); + } + } + } + } +} diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..db06eb4552 --- /dev/null +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Builder; + +namespace Jellyfin.Server.Extensions +{ + /// + /// Extensions for adding API specific functionality to the application pipeline. + /// + public static class ApiApplicationBuilderExtensions + { + /// + /// Adds swagger and swagger UI to the application pipeline. + /// + /// The application builder. + /// The updated application builder. + public static IApplicationBuilder UseJellyfinApiSwagger(this IApplicationBuilder applicationBuilder) + { + applicationBuilder.UseSwagger(); + + // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), + // specifying the Swagger JSON endpoint. + return applicationBuilder.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Jellyfin API V1"); + }); + } + } +} diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs new file mode 100644 index 0000000000..dd4f9cd238 --- /dev/null +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -0,0 +1,90 @@ +using Jellyfin.Api; +using Jellyfin.Api.Auth; +using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; +using Jellyfin.Api.Auth.RequiresElevationPolicy; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Controllers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +namespace Jellyfin.Server.Extensions +{ + /// + /// API specific extensions for the service collection. + /// + public static class ApiServiceCollectionExtensions + { + /// + /// Adds jellyfin API authorization policies to the DI container. + /// + /// The service collection. + /// The updated service collection. + public static IServiceCollection AddJellyfinApiAuthorization(this IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + return serviceCollection.AddAuthorizationCore(options => + { + options.AddPolicy( + Policies.RequiresElevation, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new RequiresElevationRequirement()); + }); + options.AddPolicy( + Policies.FirstTimeSetupOrElevated, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new FirstTimeSetupOrElevatedRequirement()); + }); + }); + } + + /// + /// Adds custom legacy authentication to the service collection. + /// + /// The service collection. + /// The updated service collection. + public static AuthenticationBuilder AddCustomAuthentication(this IServiceCollection serviceCollection) + { + return serviceCollection.AddAuthentication(AuthenticationSchemes.CustomAuthentication) + .AddScheme(AuthenticationSchemes.CustomAuthentication, null); + } + + /// + /// Extension method for adding the jellyfin API to the service collection. + /// + /// The service collection. + /// The base url for the API. + /// The MVC builder. + public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, string baseUrl) + { + return serviceCollection.AddMvc(opts => + { + opts.UseGeneralRoutePrefix(baseUrl); + }) + + // Clear app parts to avoid other assemblies being picked up + .ConfigureApplicationPartManager(a => a.ApplicationParts.Clear()) + .AddApplicationPart(typeof(StartupController).Assembly) + .AddControllersAsServices(); + } + + /// + /// Adds Swagger to the service collection. + /// + /// The service collection. + /// The updated service collection. + public static IServiceCollection AddJellyfinApiSwagger(this IServiceCollection serviceCollection) + { + return serviceCollection.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); + }); + } + } +} diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index f73b5b9274..7d97a1f20f 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -21,6 +21,10 @@ + + + + diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index ed1acb4e66..5ac005b40b 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -19,8 +19,10 @@ using Jellyfin.Drawing.Skia; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Model.Globalization; +using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Serilog; @@ -167,7 +169,24 @@ namespace Jellyfin.Server appConfig); try { - await appHost.InitAsync(new ServiceCollection()).ConfigureAwait(false); + ServiceCollection serviceCollection = new ServiceCollection(); + await appHost.InitAsync(serviceCollection).ConfigureAwait(false); + + var host = CreateWebHostBuilder(appHost, serviceCollection).Build(); + + // A bit hacky to re-use service provider since ASP.NET doesn't allow a custom service collection. + appHost.ServiceProvider = host.Services; + appHost.FindParts(); + + try + { + await host.StartAsync().ConfigureAwait(false); + } + catch + { + _logger.LogError("Kestrel failed to start! This is most likely due to an invalid address or port bind - correct your bind configuration in system.xml and try again."); + throw; + } appHost.ImageProcessor.ImageEncoder = GetImageEncoder(appPaths, appHost.LocalizationManager); @@ -199,6 +218,55 @@ namespace Jellyfin.Server } } + private static IWebHostBuilder CreateWebHostBuilder(ApplicationHost appHost, IServiceCollection serviceCollection) + { + return new WebHostBuilder() + .UseKestrel(options => + { + var addresses = appHost.ServerConfigurationManager + .Configuration + .LocalNetworkAddresses + .Select(appHost.NormalizeConfiguredLocalAddress) + .Where(i => i != null) + .ToList(); + if (addresses.Any()) + { + foreach (var address in addresses) + { + _logger.LogInformation("Kestrel listening on {ipaddr}", address); + options.Listen(address, appHost.HttpPort); + + if (appHost.EnableHttps && appHost.Certificate != null) + { + options.Listen( + address, + appHost.HttpsPort, + listenOptions => listenOptions.UseHttps(appHost.Certificate)); + } + } + } + else + { + _logger.LogInformation("Kestrel listening on all interfaces"); + options.ListenAnyIP(appHost.HttpPort); + + if (appHost.EnableHttps && appHost.Certificate != null) + { + options.ListenAnyIP( + appHost.HttpsPort, + listenOptions => listenOptions.UseHttps(appHost.Certificate)); + } + } + }) + .UseContentRoot(appHost.ContentRoot) + .ConfigureServices(services => + { + // Merge the external ServiceCollection into ASP.NET DI + services.TryAdd(serviceCollection); + }) + .UseStartup(); + } + /// /// Create the data, config and log paths from the variety of inputs(command line args, /// environment variables) or decide on what default to use. For Windows it's %AppPath% diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs new file mode 100644 index 0000000000..3ee5fb8b50 --- /dev/null +++ b/Jellyfin.Server/Startup.cs @@ -0,0 +1,81 @@ +using Jellyfin.Server.Extensions; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Jellyfin.Server +{ + /// + /// Startup configuration for the Kestrel webhost. + /// + public class Startup + { + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// + /// Initializes a new instance of the class. + /// + /// The server configuration manager. + public Startup(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// + /// Configures the service collection for the webhost. + /// + /// The service collection. + public void ConfigureServices(IServiceCollection services) + { + services.AddResponseCompression(); + services.AddHttpContextAccessor(); + services.AddJellyfinApi(_serverConfigurationManager.Configuration.BaseUrl.TrimStart('/')); + + services.AddJellyfinApiSwagger(); + + // configure custom legacy authentication + services.AddCustomAuthentication(); + + services.AddJellyfinApiAuthorization(); + } + + /// + /// Configures the app builder for the webhost. + /// + /// The application builder. + /// The webhost environment. + /// The server application host. + public void Configure( + IApplicationBuilder app, + IWebHostEnvironment env, + IServerApplicationHost serverApplicationHost) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseWebSockets(); + + app.UseResponseCompression(); + + // TODO app.UseMiddleware(); + app.Use(serverApplicationHost.ExecuteWebsocketHandlerAsync); + + // TODO use when old API is removed: app.UseAuthentication(); + app.UseJellyfinApiSwagger(); + app.UseRouting(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + + app.Use(serverApplicationHost.ExecuteHttpHandlerAsync); + } + } +} diff --git a/MediaBrowser.Api/StartupWizardService.cs b/MediaBrowser.Api/StartupWizardService.cs index 3a9eb7a55e..e69de29bb2 100644 --- a/MediaBrowser.Api/StartupWizardService.cs +++ b/MediaBrowser.Api/StartupWizardService.cs @@ -1,135 +0,0 @@ -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Services; - -namespace MediaBrowser.Api -{ - [Route("/Startup/Complete", "POST", Summary = "Reports that the startup wizard has been completed", IsHidden = true)] - public class ReportStartupWizardComplete : IReturnVoid - { - } - - [Route("/Startup/Configuration", "GET", Summary = "Gets initial server configuration", IsHidden = true)] - public class GetStartupConfiguration : IReturn - { - } - - [Route("/Startup/Configuration", "POST", Summary = "Updates initial server configuration", IsHidden = true)] - public class UpdateStartupConfiguration : StartupConfiguration, IReturnVoid - { - } - - [Route("/Startup/RemoteAccess", "POST", Summary = "Updates initial server configuration", IsHidden = true)] - public class UpdateRemoteAccessConfiguration : IReturnVoid - { - public bool EnableRemoteAccess { get; set; } - public bool EnableAutomaticPortMapping { get; set; } - } - - [Route("/Startup/User", "GET", Summary = "Gets initial user info", IsHidden = true)] - public class GetStartupUser : IReturn - { - } - - [Route("/Startup/User", "POST", Summary = "Updates initial user info", IsHidden = true)] - public class UpdateStartupUser : StartupUser - { - } - - [Authenticated(AllowBeforeStartupWizard = true, Roles = "Admin")] - public class StartupWizardService : BaseApiService - { - private readonly IServerConfigurationManager _config; - private readonly IServerApplicationHost _appHost; - private readonly IUserManager _userManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IHttpClient _httpClient; - - public StartupWizardService(IServerConfigurationManager config, IHttpClient httpClient, IServerApplicationHost appHost, IUserManager userManager, IMediaEncoder mediaEncoder) - { - _config = config; - _appHost = appHost; - _userManager = userManager; - _mediaEncoder = mediaEncoder; - _httpClient = httpClient; - } - - public void Post(ReportStartupWizardComplete request) - { - _config.Configuration.IsStartupWizardCompleted = true; - _config.SetOptimalValues(); - _config.SaveConfiguration(); - } - - public object Get(GetStartupConfiguration request) - { - var result = new StartupConfiguration - { - UICulture = _config.Configuration.UICulture, - MetadataCountryCode = _config.Configuration.MetadataCountryCode, - PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage - }; - - return result; - } - - public void Post(UpdateStartupConfiguration request) - { - _config.Configuration.UICulture = request.UICulture; - _config.Configuration.MetadataCountryCode = request.MetadataCountryCode; - _config.Configuration.PreferredMetadataLanguage = request.PreferredMetadataLanguage; - _config.SaveConfiguration(); - } - - public void Post(UpdateRemoteAccessConfiguration request) - { - _config.Configuration.EnableRemoteAccess = request.EnableRemoteAccess; - _config.Configuration.EnableUPnP = request.EnableAutomaticPortMapping; - _config.SaveConfiguration(); - } - - public object Get(GetStartupUser request) - { - var user = _userManager.Users.First(); - - return new StartupUser - { - Name = user.Name, - Password = user.Password - }; - } - - public async Task Post(UpdateStartupUser request) - { - var user = _userManager.Users.First(); - - user.Name = request.Name; - - _userManager.UpdateUser(user); - - if (!string.IsNullOrEmpty(request.Password)) - { - await _userManager.ChangePassword(user, request.Password).ConfigureAwait(false); - } - } - } - - public class StartupConfiguration - { - public string UICulture { get; set; } - public string MetadataCountryCode { get; set; } - public string PreferredMetadataLanguage { get; set; } - } - - public class StartupUser - { - public string Name { get; set; } - public string Password { get; set; } - } -} diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index 61b2c15ae2..b3c56bdd5f 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/MediaBrowser.Controller/IServerApplicationHost.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common; using MediaBrowser.Model.System; +using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller { @@ -87,5 +88,9 @@ namespace MediaBrowser.Controller string ExpandVirtualPath(string path); string ReverseVirtualPath(string path); + + Task ExecuteHttpHandlerAsync(HttpContext context, Func next); + + Task ExecuteWebsocketHandlerAsync(HttpContext context, Func next); } } diff --git a/MediaBrowser.Controller/Net/IAuthService.cs b/MediaBrowser.Controller/Net/IAuthService.cs index 142f1d91c3..4c9120e0c9 100644 --- a/MediaBrowser.Controller/Net/IAuthService.cs +++ b/MediaBrowser.Controller/Net/IAuthService.cs @@ -1,9 +1,12 @@ +using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Services; +using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller.Net { public interface IAuthService { void Authenticate(IRequest request, IAuthenticationAttributes authAttribtues); + User Authenticate(HttpRequest request, IAuthenticationAttributes authAttribtues); } } diff --git a/MediaBrowser.sln b/MediaBrowser.sln index 27c8c1668f..58bfb55f6f 100644 --- a/MediaBrowser.sln +++ b/MediaBrowser.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.26730.3 @@ -51,6 +50,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia", "Jellyfin.Drawing.Skia\Jellyfin.Drawing.Skia.csproj", "{154872D9-6C12-4007-96E3-8F70A58386CE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Api", "Jellyfin.Api\Jellyfin.Api.csproj", "{DFBEFB4C-DA19-4143-98B7-27320C7F7163}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Common.Tests", "tests\Jellyfin.Common.Tests\Jellyfin.Common.Tests.csproj", "{DF194677-DFD3-42AF-9F75-D44D5A416478}" @@ -89,10 +90,6 @@ Global {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Debug|Any CPU.Build.0 = Debug|Any CPU {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Release|Any CPU.ActiveCfg = Release|Any CPU {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Release|Any CPU.Build.0 = Release|Any CPU - {4A4402D4-E910-443B-B8FC-2C18286A2CA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4A4402D4-E910-443B-B8FC-2C18286A2CA0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4A4402D4-E910-443B-B8FC-2C18286A2CA0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4A4402D4-E910-443B-B8FC-2C18286A2CA0}.Release|Any CPU.Build.0 = Release|Any CPU {23499896-B135-4527-8574-C26E926EA99E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {23499896-B135-4527-8574-C26E926EA99E}.Debug|Any CPU.Build.0 = Debug|Any CPU {23499896-B135-4527-8574-C26E926EA99E}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -153,6 +150,10 @@ Global {154872D9-6C12-4007-96E3-8F70A58386CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {154872D9-6C12-4007-96E3-8F70A58386CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {154872D9-6C12-4007-96E3-8F70A58386CE}.Release|Any CPU.Build.0 = Release|Any CPU + {DFBEFB4C-DA19-4143-98B7-27320C7F7163}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DFBEFB4C-DA19-4143-98B7-27320C7F7163}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFBEFB4C-DA19-4143-98B7-27320C7F7163}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFBEFB4C-DA19-4143-98B7-27320C7F7163}.Release|Any CPU.Build.0 = Release|Any CPU {DF194677-DFD3-42AF-9F75-D44D5A416478}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DF194677-DFD3-42AF-9F75-D44D5A416478}.Debug|Any CPU.Build.0 = Debug|Any CPU {DF194677-DFD3-42AF-9F75-D44D5A416478}.Release|Any CPU.ActiveCfg = Release|Any CPU