diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 3d2d61225e..9227ef61ba 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -47,6 +47,10 @@ using Emby.Server.Implementations.Session; using Emby.Server.Implementations.SocketSharp; using Emby.Server.Implementations.TV; using Emby.Server.Implementations.Updates; +using Jellyfin.Api.Auth; +using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; +using Jellyfin.Api.Auth.RequiresElevationPolicy; +using Jellyfin.Api.Controllers; using MediaBrowser.Api; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; @@ -104,11 +108,14 @@ using MediaBrowser.Providers.Subtitles; using MediaBrowser.Providers.TV.TheTVDB; using MediaBrowser.WebDashboard.Api; using MediaBrowser.XbmcMetadata.Providers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -660,25 +667,45 @@ namespace Emby.Server.Implementations services.AddHttpContextAccessor(); services.AddMvc(opts => { - opts.UseGeneralRoutePrefix("emby", "emby/emby", "api/v{version:apiVersion}"); + var policy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + opts.Filters.Add(new AuthorizeFilter(policy)); + opts.EnableEndpointRouting = false; + opts.UseGeneralRoutePrefix(ServerConfigurationManager.Configuration.BaseUrl.TrimStart('/')); }) .SetCompatibilityVersion(CompatibilityVersion.Version_2_2) - .AddApplicationPart(Assembly.Load("Jellyfin.Api")); - services.AddApiVersioning(opt => opt.ReportApiVersions = true); + .ConfigureApplicationPartManager(a => a.ApplicationParts.Clear()) // Clear app parts to avoid other assemblies being picked up + .AddApplicationPart(typeof(StartupController).Assembly) + .AddControllersAsServices(); services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); - c.DocInclusionPredicate((docName, apiDesc) => - { - if (!apiDesc.TryGetMethodInfo(out var methodInfo)) - { - return false; - } + }); - // A bit of a hack to make Swagger pick the versioned endpoints instead of the legacy emby endpoints - return methodInfo.DeclaringType?.BaseType == typeof(ControllerBase) && - apiDesc.RelativePath.Contains("api/v"); - }); + services.AddSingleton(); + services.AddSingleton(); + + // configure custom legacy authentication + services.AddAuthentication("CustomAuthentication") + .AddScheme("CustomAuthentication", null); + + services.AddAuthorizationCore(options => + { + options.AddPolicy( + "RequiresElevation", + policy => + { + policy.AddAuthenticationSchemes("CustomAuthentication"); + policy.AddRequirements(new RequiresElevationRequirement()); + }); + options.AddPolicy( + "FirstTimeSetupOrElevated", + policy => + { + policy.AddAuthenticationSchemes("CustomAuthentication"); + policy.AddRequirements(new FirstTimeSetupOrElevatedRequirement()); + }); }); // Merge the external ServiceCollection into ASP.NET DI @@ -686,6 +713,7 @@ namespace Emby.Server.Implementations }) .Configure(app => { + app.UseDeveloperExceptionPage(); app.UseSwagger(); // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), @@ -698,9 +726,9 @@ namespace Emby.Server.Implementations app.UseWebSockets(); app.UseResponseCompression(); - // TODO app.UseMiddleware(); app.Use(ExecuteWebsocketHandlerAsync); + //app.UseAuthentication(); app.UseMvc(); app.Use(ExecuteHttpHandlerAsync); }) @@ -938,7 +966,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, authContext, ServerConfigurationManager, SessionManager, NetworkManager); serviceCollection.AddSingleton(AuthService); SubtitleEncoder = new MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder(LibraryManager, LoggerFactory, ApplicationPaths, FileSystemManager, MediaEncoder, JsonSerializer, HttpClient, MediaSourceManager, ProcessFactory); diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 26301b379e..e7164342c3 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -21,6 +21,9 @@ + + + @@ -38,7 +41,7 @@ - + diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index 93a61fe67a..81dab83d5e 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( + ILoggerFactory loggerFactory, IAuthorizationContext authorizationContext, IServerConfigurationManager config, ISessionManager sessionManager, INetworkManager networkManager) { + _logger = loggerFactory.CreateLogger(); _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/MvcRoutePrefix.cs b/Emby.Server.Implementations/MvcRoutePrefix.cs index fb26ae09da..974a2a8852 100644 --- a/Emby.Server.Implementations/MvcRoutePrefix.cs +++ b/Emby.Server.Implementations/MvcRoutePrefix.cs @@ -12,7 +12,7 @@ namespace Emby.Server.Implementations opts.Conventions.Insert(0, new RoutePrefixConvention(prefixes)); } - internal class RoutePrefixConvention : IApplicationModelConvention + private class RoutePrefixConvention : IApplicationModelConvention { private readonly AttributeRouteModel[] _routePrefixes; diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs new file mode 100644 index 0000000000..bb6192b03d --- /dev/null +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using MediaBrowser.Controller.Net; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Jellyfin.Api.Auth +{ + public class CustomAuthenticationHandler : AuthenticationHandler + { + private readonly IAuthService _authService; + + 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, user.Policy.IsAdministrator ? "Administrator" : "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..73925cd616 --- /dev/null +++ b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy +{ + public class FirstTimeSetupOrElevatedHandler : AuthorizationHandler + { + private readonly IConfigurationManager _configurationManager; + + 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("Administrator")) + { + // TODO user role enum + 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..42436c870d --- /dev/null +++ b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedRequirement.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy +{ + 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..6948274582 --- /dev/null +++ b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.RequiresElevationPolicy +{ + public class RequiresElevationHandler : AuthorizationHandler + { + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresElevationRequirement requirement) + { + if (context.User.IsInRole("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..dd51cd3c20 --- /dev/null +++ b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationRequirement.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.RequiresElevationPolicy +{ + public class RequiresElevationRequirement : IAuthorizationRequirement + { + + } +} diff --git a/Jellyfin.Api/BaseJellyfinApiController.cs b/Jellyfin.Api/BaseJellyfinApiController.cs new file mode 100644 index 0000000000..796a8039af --- /dev/null +++ b/Jellyfin.Api/BaseJellyfinApiController.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api +{ + [ApiController] + [Route("[controller]")] + public class BaseJellyfinApiController : ControllerBase + { + + } +} diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 45e4cd5ac8..fb61b8d0b3 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -3,12 +3,13 @@ using System.Threading.Tasks; using Jellyfin.Api.Models.Startup; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Api.Controllers { - [ApiVersion("1")] - public class StartupController : ControllerBase + [Authorize(Policy = "FirstTimeSetupOrElevated")] + public class StartupController : BaseJellyfinApiController { private readonly IServerConfigurationManager _config; private readonly IUserManager _userManager; @@ -28,9 +29,9 @@ namespace Jellyfin.Api.Controllers } [HttpGet("Configuration")] - public StartupConfiguration Get() + public StartupConfigurationDto GetStartupConfiguration() { - var result = new StartupConfiguration + var result = new StartupConfigurationDto { UICulture = _config.Configuration.UICulture, MetadataCountryCode = _config.Configuration.MetadataCountryCode, @@ -41,7 +42,7 @@ namespace Jellyfin.Api.Controllers } [HttpPost("Configuration")] - public void UpdateInitial([FromForm] string uiCulture, [FromForm] string metadataCountryCode, [FromForm] string preferredMetadataLanguage) + public void UpdateInitialConfiguration([FromForm] string uiCulture, [FromForm] string metadataCountryCode, [FromForm] string preferredMetadataLanguage) { _config.Configuration.UICulture = uiCulture; _config.Configuration.MetadataCountryCode = metadataCountryCode; @@ -50,7 +51,7 @@ namespace Jellyfin.Api.Controllers } [HttpPost("RemoteAccess")] - public void Post([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping) + public void SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping) { _config.Configuration.EnableRemoteAccess = enableRemoteAccess; _config.Configuration.EnableUPnP = enableAutomaticPortMapping; @@ -58,11 +59,11 @@ namespace Jellyfin.Api.Controllers } [HttpGet("User")] - public StartupUser GetUser() + public StartupUserDto GetUser() { var user = _userManager.Users.First(); - return new StartupUser + return new StartupUserDto { Name = user.Name, Password = user.Password @@ -70,17 +71,17 @@ namespace Jellyfin.Api.Controllers } [HttpPost("User")] - public async Task UpdateUser([FromForm] StartupUser startupUser) + public async Task UpdateUser([FromForm] StartupUserDto startupUserDto) { var user = _userManager.Users.First(); - user.Name = startupUser.Name; + user.Name = startupUserDto.Name; _userManager.UpdateUser(user); - if (!string.IsNullOrEmpty(startupUser.Password)) + if (!string.IsNullOrEmpty(startupUserDto.Password)) { - await _userManager.ChangePassword(user, startupUser.Password).ConfigureAwait(false); + await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); } } } diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index 7a7e49e302..647004cb68 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -1,18 +1,16 @@ - - - - netstandard2.0 - Library - - - - - - - - - - - - - + + + + netstandard2.1 + + + + + + + + + + + + diff --git a/Jellyfin.Api/Models/Startup/StartupConfiguration.cs b/Jellyfin.Api/Models/Startup/StartupConfigurationDto.cs similarity index 84% rename from Jellyfin.Api/Models/Startup/StartupConfiguration.cs rename to Jellyfin.Api/Models/Startup/StartupConfigurationDto.cs index 08dd59a177..769d2e1bb6 100644 --- a/Jellyfin.Api/Models/Startup/StartupConfiguration.cs +++ b/Jellyfin.Api/Models/Startup/StartupConfigurationDto.cs @@ -1,6 +1,6 @@ namespace Jellyfin.Api.Models.Startup { - public class StartupConfiguration + public class StartupConfigurationDto { public string UICulture { get; set; } public string MetadataCountryCode { get; set; } diff --git a/Jellyfin.Api/Models/Startup/StartupUser.cs b/Jellyfin.Api/Models/Startup/StartupUserDto.cs similarity index 81% rename from Jellyfin.Api/Models/Startup/StartupUser.cs rename to Jellyfin.Api/Models/Startup/StartupUserDto.cs index 93a09e865b..c7c2e8cb04 100644 --- a/Jellyfin.Api/Models/Startup/StartupUser.cs +++ b/Jellyfin.Api/Models/Startup/StartupUserDto.cs @@ -1,6 +1,6 @@ namespace Jellyfin.Api.Models.Startup { - public class StartupUser + public class StartupUserDto { public string Name { get; set; } public string Password { get; set; } 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); } }