diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs
index c65d4694a..6a2d8fdbb 100644
--- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs
+++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs
@@ -39,9 +39,9 @@ namespace Emby.Server.Implementations.HttpServer.Security
_networkManager = networkManager;
}
- public void Authenticate(IRequest request, IAuthenticationAttributes authAttribtues)
+ public void Authenticate(IRequest request, IAuthenticationAttributes authAttributes)
{
- ValidateUser(request, authAttribtues);
+ ValidateUser(request, authAttributes);
}
public User Authenticate(HttpRequest request, IAuthenticationAttributes authAttributes)
@@ -51,17 +51,33 @@ namespace Emby.Server.Implementations.HttpServer.Security
return user;
}
- private User ValidateUser(IRequest request, IAuthenticationAttributes authAttribtues)
+ public AuthorizationInfo Authenticate(HttpRequest request)
+ {
+ var auth = _authorizationContext.GetAuthorizationInfo(request);
+ if (auth?.User == null)
+ {
+ return null;
+ }
+
+ if (auth.User.HasPermission(PermissionKind.IsDisabled))
+ {
+ throw new SecurityException("User account has been disabled.");
+ }
+
+ return auth;
+ }
+
+ private User ValidateUser(IRequest request, IAuthenticationAttributes authAttributes)
{
// This code is executed before the service
var auth = _authorizationContext.GetAuthorizationInfo(request);
- if (!IsExemptFromAuthenticationToken(authAttribtues, request))
+ if (!IsExemptFromAuthenticationToken(authAttributes, request))
{
ValidateSecurityToken(request, auth.Token);
}
- if (authAttribtues.AllowLocalOnly && !request.IsLocal)
+ if (authAttributes.AllowLocalOnly && !request.IsLocal)
{
throw new SecurityException("Operation not found.");
}
@@ -75,14 +91,14 @@ namespace Emby.Server.Implementations.HttpServer.Security
if (user != null)
{
- ValidateUserAccess(user, request, authAttribtues, auth);
+ ValidateUserAccess(user, request, authAttributes);
}
var info = GetTokenInfo(request);
- if (!IsExemptFromRoles(auth, authAttribtues, request, info))
+ if (!IsExemptFromRoles(auth, authAttributes, request, info))
{
- var roles = authAttribtues.GetRoles();
+ var roles = authAttributes.GetRoles();
ValidateRoles(roles, user);
}
@@ -106,8 +122,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
private void ValidateUserAccess(
User user,
IRequest request,
- IAuthenticationAttributes authAttributes,
- AuthorizationInfo auth)
+ IAuthenticationAttributes authAttributes)
{
if (user.HasPermission(PermissionKind.IsDisabled))
{
@@ -230,16 +245,6 @@ namespace Emby.Server.Implementations.HttpServer.Security
{
throw new AuthenticationException("Access token is invalid or expired.");
}
-
- //if (!string.IsNullOrEmpty(info.UserId))
- //{
- // var user = _userManager.GetUserById(info.UserId);
-
- // if (user == null || user.Configuration.IsDisabled)
- // {
- // throw new SecurityException("User account has been disabled.");
- // }
- //}
}
}
}
diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
index 9558cb4c6..fb93fae3e 100644
--- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
+++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
@@ -8,6 +8,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Security;
using MediaBrowser.Model.Services;
+using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;
namespace Emby.Server.Implementations.HttpServer.Security
@@ -38,6 +39,14 @@ namespace Emby.Server.Implementations.HttpServer.Security
return GetAuthorization(requestContext);
}
+ public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext)
+ {
+ var auth = GetAuthorizationDictionary(requestContext);
+ var (authInfo, _) =
+ GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query);
+ return authInfo;
+ }
+
///
/// Gets the authorization.
///
@@ -46,7 +55,23 @@ namespace Emby.Server.Implementations.HttpServer.Security
private AuthorizationInfo GetAuthorization(IRequest httpReq)
{
var auth = GetAuthorizationDictionary(httpReq);
+ var (authInfo, originalAuthInfo) =
+ GetAuthorizationInfoFromDictionary(auth, httpReq.Headers, httpReq.QueryString);
+ if (originalAuthInfo != null)
+ {
+ httpReq.Items["OriginalAuthenticationInfo"] = originalAuthInfo;
+ }
+
+ httpReq.Items["AuthorizationInfo"] = authInfo;
+ return authInfo;
+ }
+
+ private (AuthorizationInfo authInfo, AuthenticationInfo originalAuthenticationInfo) GetAuthorizationInfoFromDictionary(
+ in Dictionary auth,
+ in IHeaderDictionary headers,
+ in IQueryCollection queryString)
+ {
string deviceId = null;
string device = null;
string client = null;
@@ -64,19 +89,26 @@ namespace Emby.Server.Implementations.HttpServer.Security
if (string.IsNullOrEmpty(token))
{
- token = httpReq.Headers["X-Emby-Token"];
+ token = headers["X-Emby-Token"];
}
if (string.IsNullOrEmpty(token))
{
- token = httpReq.Headers["X-MediaBrowser-Token"];
- }
- if (string.IsNullOrEmpty(token))
- {
- token = httpReq.QueryString["api_key"];
+ token = headers["X-MediaBrowser-Token"];
}
- var info = new AuthorizationInfo
+ if (string.IsNullOrEmpty(token))
+ {
+ token = queryString["ApiKey"];
+ }
+
+ // TODO deprecate this query parameter.
+ if (string.IsNullOrEmpty(token))
+ {
+ token = queryString["api_key"];
+ }
+
+ var authInfo = new AuthorizationInfo
{
Client = client,
Device = device,
@@ -85,6 +117,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
Token = token
};
+ AuthenticationInfo originalAuthenticationInfo = null;
if (!string.IsNullOrWhiteSpace(token))
{
var result = _authRepo.Get(new AuthenticationInfoQuery
@@ -92,81 +125,77 @@ namespace Emby.Server.Implementations.HttpServer.Security
AccessToken = token
});
- var tokenInfo = result.Items.Count > 0 ? result.Items[0] : null;
+ originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null;
- if (tokenInfo != null)
+ if (originalAuthenticationInfo != null)
{
var updateToken = false;
// TODO: Remove these checks for IsNullOrWhiteSpace
- if (string.IsNullOrWhiteSpace(info.Client))
+ if (string.IsNullOrWhiteSpace(authInfo.Client))
{
- info.Client = tokenInfo.AppName;
+ authInfo.Client = originalAuthenticationInfo.AppName;
}
- if (string.IsNullOrWhiteSpace(info.DeviceId))
+ if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
{
- info.DeviceId = tokenInfo.DeviceId;
+ authInfo.DeviceId = originalAuthenticationInfo.DeviceId;
}
// Temporary. TODO - allow clients to specify that the token has been shared with a casting device
- var allowTokenInfoUpdate = info.Client == null || info.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1;
+ var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1;
- if (string.IsNullOrWhiteSpace(info.Device))
+ if (string.IsNullOrWhiteSpace(authInfo.Device))
{
- info.Device = tokenInfo.DeviceName;
+ authInfo.Device = originalAuthenticationInfo.DeviceName;
}
-
- else if (!string.Equals(info.Device, tokenInfo.DeviceName, StringComparison.OrdinalIgnoreCase))
+ else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
- tokenInfo.DeviceName = info.Device;
+ originalAuthenticationInfo.DeviceName = authInfo.Device;
}
}
- if (string.IsNullOrWhiteSpace(info.Version))
+ if (string.IsNullOrWhiteSpace(authInfo.Version))
{
- info.Version = tokenInfo.AppVersion;
+ authInfo.Version = originalAuthenticationInfo.AppVersion;
}
- else if (!string.Equals(info.Version, tokenInfo.AppVersion, StringComparison.OrdinalIgnoreCase))
+ else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
- tokenInfo.AppVersion = info.Version;
+ originalAuthenticationInfo.AppVersion = authInfo.Version;
}
}
- if ((DateTime.UtcNow - tokenInfo.DateLastActivity).TotalMinutes > 3)
+ if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3)
{
- tokenInfo.DateLastActivity = DateTime.UtcNow;
+ originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow;
updateToken = true;
}
- if (!tokenInfo.UserId.Equals(Guid.Empty))
+ if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty))
{
- info.User = _userManager.GetUserById(tokenInfo.UserId);
+ authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId);
- if (info.User != null && !string.Equals(info.User.Username, tokenInfo.UserName, StringComparison.OrdinalIgnoreCase))
+ if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase))
{
- tokenInfo.UserName = info.User.Username;
+ originalAuthenticationInfo.UserName = authInfo.User.Username;
updateToken = true;
}
}
if (updateToken)
{
- _authRepo.Update(tokenInfo);
+ _authRepo.Update(originalAuthenticationInfo);
}
}
- httpReq.Items["OriginalAuthenticationInfo"] = tokenInfo;
}
- httpReq.Items["AuthorizationInfo"] = info;
-
- return info;
+ return (authInfo, originalAuthenticationInfo);
}
///
@@ -186,6 +215,23 @@ namespace Emby.Server.Implementations.HttpServer.Security
return GetAuthorization(auth);
}
+ ///
+ /// Gets the auth.
+ ///
+ /// The HTTP req.
+ /// Dictionary{System.StringSystem.String}.
+ private Dictionary GetAuthorizationDictionary(HttpRequest httpReq)
+ {
+ var auth = httpReq.Headers["X-Emby-Authorization"];
+
+ if (string.IsNullOrEmpty(auth))
+ {
+ auth = httpReq.Headers[HeaderNames.Authorization];
+ }
+
+ return GetAuthorization(auth);
+ }
+
///
/// Gets the authorization.
///
@@ -236,12 +282,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
private static string NormalizeValue(string value)
{
- if (string.IsNullOrEmpty(value))
- {
- return value;
- }
-
- return WebUtility.HtmlEncode(value);
+ return string.IsNullOrEmpty(value) ? value : WebUtility.HtmlEncode(value);
}
}
}
diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
new file mode 100644
index 000000000..b5b9d8904
--- /dev/null
+++ b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
@@ -0,0 +1,102 @@
+#nullable enable
+
+using System.Net;
+using System.Security.Claims;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth
+{
+ ///
+ /// Base authorization handler.
+ ///
+ /// Type of Authorization Requirement.
+ public abstract class BaseAuthorizationHandler : AuthorizationHandler
+ where T : IAuthorizationRequirement
+ {
+ private readonly IUserManager _userManager;
+ private readonly INetworkManager _networkManager;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ /// Instance of the interface.
+ protected BaseAuthorizationHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ {
+ _userManager = userManager;
+ _networkManager = networkManager;
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ ///
+ /// Validate authenticated claims.
+ ///
+ /// Request claims.
+ /// Whether to ignore parental control.
+ /// Whether access is to be allowed locally only.
+ /// Validated claim status.
+ protected bool ValidateClaims(
+ ClaimsPrincipal claimsPrincipal,
+ bool ignoreSchedule = false,
+ bool localAccessOnly = false)
+ {
+ // Ensure claim has userId.
+ var userId = ClaimHelpers.GetUserId(claimsPrincipal);
+ if (userId == null)
+ {
+ return false;
+ }
+
+ // Ensure userId links to a valid user.
+ var user = _userManager.GetUserById(userId.Value);
+ if (user == null)
+ {
+ return false;
+ }
+
+ // Ensure user is not disabled.
+ if (user.HasPermission(PermissionKind.IsDisabled))
+ {
+ return false;
+ }
+
+ var ip = NormalizeIp(_httpContextAccessor.HttpContext.Connection.RemoteIpAddress).ToString();
+ var isInLocalNetwork = _networkManager.IsInLocalNetwork(ip);
+ // User cannot access remotely and user is remote
+ if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !isInLocalNetwork)
+ {
+ return false;
+ }
+
+ if (localAccessOnly && !isInLocalNetwork)
+ {
+ return false;
+ }
+
+ // User attempting to access out of parental control hours.
+ if (!ignoreSchedule
+ && !user.HasPermission(PermissionKind.IsAdministrator)
+ && !user.IsParentalScheduleAllowed())
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static IPAddress NormalizeIp(IPAddress ip)
+ {
+ return ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4() : ip;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
index a5c4e9974..5e5e25e84 100644
--- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
+++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
@@ -1,3 +1,6 @@
+#nullable enable
+
+using System.Globalization;
using System.Security.Authentication;
using System.Security.Claims;
using System.Text.Encodings.Web;
@@ -39,15 +42,10 @@ namespace Jellyfin.Api.Auth
///
protected override Task HandleAuthenticateAsync()
{
- var authenticatedAttribute = new AuthenticatedAttribute
- {
- IgnoreLegacyAuth = true
- };
-
try
{
- var user = _authService.Authenticate(Request, authenticatedAttribute);
- if (user == null)
+ var authorizationInfo = _authService.Authenticate(Request);
+ if (authorizationInfo == null)
{
return Task.FromResult(AuthenticateResult.NoResult());
// TODO return when legacy API is removed.
@@ -57,11 +55,16 @@ namespace Jellyfin.Api.Auth
var claims = new[]
{
- new Claim(ClaimTypes.Name, user.Username),
- new Claim(
- ClaimTypes.Role,
- value: user.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User)
+ new Claim(ClaimTypes.Name, authorizationInfo.User.Username),
+ new Claim(ClaimTypes.Role, value: authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User),
+ new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
+ new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId),
+ new Claim(InternalClaimTypes.Device, authorizationInfo.Device),
+ new Claim(InternalClaimTypes.Client, authorizationInfo.Client),
+ new Claim(InternalClaimTypes.Version, authorizationInfo.Version),
+ new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
};
+
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
new file mode 100644
index 000000000..b5913daab
--- /dev/null
+++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
+{
+ ///
+ /// Default authorization handler.
+ ///
+ public class DefaultAuthorizationHandler : BaseAuthorizationHandler
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public DefaultAuthorizationHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
+ {
+ }
+
+ ///
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement)
+ {
+ var validated = ValidateClaims(context.User);
+ if (!validated)
+ {
+ context.Fail();
+ return Task.CompletedTask;
+ }
+
+ context.Succeed(requirement);
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs
new file mode 100644
index 000000000..7cea00b69
--- /dev/null
+++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
+{
+ ///
+ /// The default authorization requirement.
+ ///
+ public class DefaultAuthorizationRequirement : IAuthorizationRequirement
+ {
+ }
+}
diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs
index 34aa5d12c..decbe0c03 100644
--- a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs
+++ b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs
@@ -1,22 +1,33 @@
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy
{
///
/// Authorization handler for requiring first time setup or elevated privileges.
///
- public class FirstTimeSetupOrElevatedHandler : AuthorizationHandler
+ public class FirstTimeSetupOrElevatedHandler : BaseAuthorizationHandler
{
private readonly IConfigurationManager _configurationManager;
///
/// Initializes a new instance of the class.
///
- /// The jellyfin configuration manager.
- public FirstTimeSetupOrElevatedHandler(IConfigurationManager configurationManager)
+ /// Instance of the interface.
+ /// Instance of the interface.
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public FirstTimeSetupOrElevatedHandler(
+ IConfigurationManager configurationManager,
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
{
_configurationManager = configurationManager;
}
@@ -27,8 +38,11 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy
if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
{
context.Succeed(firstTimeSetupOrElevatedRequirement);
+ return Task.CompletedTask;
}
- else if (context.User.IsInRole(UserRoles.Administrator))
+
+ var validated = ValidateClaims(context.User);
+ if (validated && context.User.IsInRole(UserRoles.Administrator))
{
context.Succeed(firstTimeSetupOrElevatedRequirement);
}
diff --git a/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs
new file mode 100644
index 000000000..9afa0b28f
--- /dev/null
+++ b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs
@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.IgnoreSchedulePolicy
+{
+ ///
+ /// Escape schedule controls handler.
+ ///
+ public class IgnoreScheduleHandler : BaseAuthorizationHandler
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public IgnoreScheduleHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
+ {
+ }
+
+ ///
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreScheduleRequirement requirement)
+ {
+ var validated = ValidateClaims(context.User, ignoreSchedule: true);
+ if (!validated)
+ {
+ context.Fail();
+ return Task.CompletedTask;
+ }
+
+ context.Succeed(requirement);
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs
new file mode 100644
index 000000000..d5bb61ce6
--- /dev/null
+++ b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.IgnoreSchedulePolicy
+{
+ ///
+ /// Escape schedule controls requirement.
+ ///
+ public class IgnoreScheduleRequirement : IAuthorizationRequirement
+ {
+ }
+}
diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
new file mode 100644
index 000000000..af73352bc
--- /dev/null
+++ b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
@@ -0,0 +1,44 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.LocalAccessPolicy
+{
+ ///
+ /// Local access handler.
+ ///
+ public class LocalAccessHandler : BaseAuthorizationHandler
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public LocalAccessHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
+ {
+ }
+
+ ///
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement)
+ {
+ var validated = ValidateClaims(context.User, localAccessOnly: true);
+ if (!validated)
+ {
+ context.Fail();
+ }
+ else
+ {
+ context.Succeed(requirement);
+ }
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs
new file mode 100644
index 000000000..761127fa4
--- /dev/null
+++ b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.LocalAccessPolicy
+{
+ ///
+ /// The local access authorization requirement.
+ ///
+ public class LocalAccessRequirement : IAuthorizationRequirement
+ {
+ }
+}
diff --git a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs
index 2d3bb1aa4..b235c4b63 100644
--- a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs
+++ b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs
@@ -1,21 +1,43 @@
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
namespace Jellyfin.Api.Auth.RequiresElevationPolicy
{
///
/// Authorization handler for requiring elevated privileges.
///
- public class RequiresElevationHandler : AuthorizationHandler
+ public class RequiresElevationHandler : BaseAuthorizationHandler
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ /// Instance of the interface.
+ /// Instance of the interface.
+ public RequiresElevationHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
+ {
+ }
+
///
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresElevationRequirement requirement)
{
- if (context.User.IsInRole(UserRoles.Administrator))
+ var validated = ValidateClaims(context.User);
+ if (validated && context.User.IsInRole(UserRoles.Administrator))
{
context.Succeed(requirement);
}
+ else
+ {
+ context.Fail();
+ }
return Task.CompletedTask;
}
diff --git a/Jellyfin.Api/Constants/InternalClaimTypes.cs b/Jellyfin.Api/Constants/InternalClaimTypes.cs
new file mode 100644
index 000000000..4d7c7135d
--- /dev/null
+++ b/Jellyfin.Api/Constants/InternalClaimTypes.cs
@@ -0,0 +1,38 @@
+namespace Jellyfin.Api.Constants
+{
+ ///
+ /// Internal claim types for authorization.
+ ///
+ public static class InternalClaimTypes
+ {
+ ///
+ /// User Id.
+ ///
+ public const string UserId = "Jellyfin-UserId";
+
+ ///
+ /// Device Id.
+ ///
+ public const string DeviceId = "Jellyfin-DeviceId";
+
+ ///
+ /// Device.
+ ///
+ public const string Device = "Jellyfin-Device";
+
+ ///
+ /// Client.
+ ///
+ public const string Client = "Jellyfin-Client";
+
+ ///
+ /// Version.
+ ///
+ public const string Version = "Jellyfin-Version";
+
+ ///
+ /// Token.
+ ///
+ public const string Token = "Jellyfin-Token";
+ }
+}
diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs
index e2b383f75..cf574e43d 100644
--- a/Jellyfin.Api/Constants/Policies.cs
+++ b/Jellyfin.Api/Constants/Policies.cs
@@ -5,6 +5,11 @@ namespace Jellyfin.Api.Constants
///
public static class Policies
{
+ ///
+ /// Policy name for default authorization.
+ ///
+ public const string DefaultAuthorization = "DefaultAuthorization";
+
///
/// Policy name for requiring first time setup or elevated privileges.
///
@@ -14,5 +19,15 @@ namespace Jellyfin.Api.Constants
/// Policy name for requiring elevated privileges.
///
public const string RequiresElevation = "RequiresElevation";
+
+ ///
+ /// Policy name for allowing local access only.
+ ///
+ public const string LocalAccessOnly = "LocalAccessOnly";
+
+ ///
+ /// Policy name for escaping schedule controls.
+ ///
+ public const string IgnoreSchedule = "IgnoreSchedule";
}
}
diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs
index ae5685156..74f1677bd 100644
--- a/Jellyfin.Api/Controllers/ConfigurationController.cs
+++ b/Jellyfin.Api/Controllers/ConfigurationController.cs
@@ -16,7 +16,7 @@ namespace Jellyfin.Api.Controllers
/// Configuration Controller.
///
[Route("System")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
public class ConfigurationController : BaseJellyfinApiController
{
private readonly IServerConfigurationManager _configurationManager;
diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs
index 1575307c5..78368eed6 100644
--- a/Jellyfin.Api/Controllers/DevicesController.cs
+++ b/Jellyfin.Api/Controllers/DevicesController.cs
@@ -15,7 +15,7 @@ namespace Jellyfin.Api.Controllers
///
/// Devices Controller.
///
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
public class DevicesController : BaseJellyfinApiController
{
private readonly IDeviceManager _deviceManager;
diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs
index 4f125f16b..943c23f8e 100644
--- a/Jellyfin.Api/Controllers/PackageController.cs
+++ b/Jellyfin.Api/Controllers/PackageController.cs
@@ -16,7 +16,7 @@ namespace Jellyfin.Api.Controllers
/// Package Controller.
///
[Route("Packages")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
public class PackageController : BaseJellyfinApiController
{
private readonly IInstallationManager _installationManager;
diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs
index ec05e4fb4..d971889db 100644
--- a/Jellyfin.Api/Controllers/SearchController.cs
+++ b/Jellyfin.Api/Controllers/SearchController.cs
@@ -3,6 +3,7 @@ using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
+using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
@@ -23,7 +24,7 @@ namespace Jellyfin.Api.Controllers
/// Search controller.
///
[Route("/Search/Hints")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
public class SearchController : BaseJellyfinApiController
{
private readonly ISearchEngine _searchEngine;
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index caf30031b..69b83379d 100644
--- a/Jellyfin.Api/Controllers/SubtitleController.cs
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -109,7 +109,7 @@ namespace Jellyfin.Api.Controllers
/// Subtitles retrieved.
/// An array of .
[HttpGet("/Items/{id}/RemoteSearch/Subtitles/{language}")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task>> SearchRemoteSubtitles(
[FromRoute] Guid id,
@@ -129,7 +129,7 @@ namespace Jellyfin.Api.Controllers
/// Subtitle downloaded.
/// A .
[HttpPost("/Items/{id}/RemoteSearch/Subtitles/{subtitleId}")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task DownloadRemoteSubtitles(
[FromRoute] Guid id,
@@ -159,7 +159,7 @@ namespace Jellyfin.Api.Controllers
/// File returned.
/// A with the subtitle file.
[HttpGet("/Providers/Subtitles/Subtitles/{id}")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Application.Octet)]
public async Task GetRemoteSubtitles([FromRoute] string id)
@@ -249,7 +249,7 @@ namespace Jellyfin.Api.Controllers
/// Subtitle playlist retrieved.
/// A with the HLS subtitle playlist.
[HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task GetSubtitlePlaylist(
[FromRoute] Guid id,
diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
index 268aecad8..2528fd75d 100644
--- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
+++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
@@ -2,6 +2,7 @@ using System;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
@@ -15,7 +16,7 @@ namespace Jellyfin.Api.Controllers
/// Attachments controller.
///
[Route("Videos")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
public class VideoAttachmentsController : BaseJellyfinApiController
{
private readonly ILibraryManager _libraryManager;
diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs
new file mode 100644
index 000000000..a07d4ed82
--- /dev/null
+++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs
@@ -0,0 +1,77 @@
+#nullable enable
+
+using System;
+using System.Linq;
+using System.Security.Claims;
+using Jellyfin.Api.Constants;
+
+namespace Jellyfin.Api.Helpers
+{
+ ///
+ /// Claim Helpers.
+ ///
+ public static class ClaimHelpers
+ {
+ ///
+ /// Get user id from claims.
+ ///
+ /// Current claims principal.
+ /// User id.
+ public static Guid? GetUserId(in ClaimsPrincipal user)
+ {
+ var value = GetClaimValue(user, InternalClaimTypes.UserId);
+ return string.IsNullOrEmpty(value)
+ ? null
+ : (Guid?)Guid.Parse(value);
+ }
+
+ ///
+ /// Get device id from claims.
+ ///
+ /// Current claims principal.
+ /// Device id.
+ public static string? GetDeviceId(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.DeviceId);
+
+ ///
+ /// Get device from claims.
+ ///
+ /// Current claims principal.
+ /// Device.
+ public static string? GetDevice(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Device);
+
+ ///
+ /// Get client from claims.
+ ///
+ /// Current claims principal.
+ /// Client.
+ public static string? GetClient(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Client);
+
+ ///
+ /// Get version from claims.
+ ///
+ /// Current claims principal.
+ /// Version.
+ public static string? GetVersion(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Version);
+
+ ///
+ /// Get token from claims.
+ ///
+ /// Current claims principal.
+ /// Token.
+ public static string? GetToken(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Token);
+
+ private static string? GetClaimValue(in ClaimsPrincipal user, string name)
+ {
+ return user?.Identities
+ .SelectMany(c => c.Claims)
+ .Where(claim => claim.Type.Equals(name, StringComparison.OrdinalIgnoreCase))
+ .Select(claim => claim.Value)
+ .FirstOrDefault();
+ }
+ }
+}
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index 9cdaa0eb1..dbd5ba416 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -5,7 +5,10 @@ using System.Linq;
using System.Reflection;
using Jellyfin.Api;
using Jellyfin.Api.Auth;
+using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy;
+using Jellyfin.Api.Auth.IgnoreSchedulePolicy;
+using Jellyfin.Api.Auth.LocalAccessPolicy;
using Jellyfin.Api.Auth.RequiresElevationPolicy;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Controllers;
@@ -15,6 +18,8 @@ using MediaBrowser.Common.Json;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
@@ -33,16 +38,19 @@ namespace Jellyfin.Server.Extensions
/// The updated service collection.
public static IServiceCollection AddJellyfinApiAuthorization(this IServiceCollection serviceCollection)
{
+ serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
return serviceCollection.AddAuthorizationCore(options =>
{
options.AddPolicy(
- Policies.RequiresElevation,
+ Policies.DefaultAuthorization,
policy =>
{
policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
- policy.AddRequirements(new RequiresElevationRequirement());
+ policy.AddRequirements(new DefaultAuthorizationRequirement());
});
options.AddPolicy(
Policies.FirstTimeSetupOrElevated,
@@ -51,6 +59,27 @@ namespace Jellyfin.Server.Extensions
policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
policy.AddRequirements(new FirstTimeSetupOrElevatedRequirement());
});
+ options.AddPolicy(
+ Policies.IgnoreSchedule,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new IgnoreScheduleRequirement());
+ });
+ options.AddPolicy(
+ Policies.LocalAccessOnly,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new LocalAccessRequirement());
+ });
+ options.AddPolicy(
+ Policies.RequiresElevation,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new RequiresElevationRequirement());
+ });
});
}
@@ -78,6 +107,10 @@ namespace Jellyfin.Server.Extensions
{
options.AddPolicy(ServerCorsPolicy.DefaultPolicyName, ServerCorsPolicy.DefaultPolicy);
})
+ .Configure(options =>
+ {
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
+ })
.AddMvc(opts =>
{
opts.UseGeneralRoutePrefix(baseUrl);
diff --git a/MediaBrowser.Controller/Net/IAuthService.cs b/MediaBrowser.Controller/Net/IAuthService.cs
index d8f6d19da..2055a656a 100644
--- a/MediaBrowser.Controller/Net/IAuthService.cs
+++ b/MediaBrowser.Controller/Net/IAuthService.cs
@@ -6,10 +6,31 @@ using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Controller.Net
{
+ ///
+ /// IAuthService.
+ ///
public interface IAuthService
{
- void Authenticate(IRequest request, IAuthenticationAttributes authAttribtues);
+ ///
+ /// Authenticate and authorize request.
+ ///
+ /// Request.
+ /// Authorization attributes.
+ void Authenticate(IRequest request, IAuthenticationAttributes authAttribtutes);
- User? Authenticate(HttpRequest request, IAuthenticationAttributes authAttribtues);
+ ///
+ /// Authenticate and authorize request.
+ ///
+ /// Request.
+ /// Authorization attributes.
+ /// Authenticated user.
+ User? Authenticate(HttpRequest request, IAuthenticationAttributes authAttribtutes);
+
+ ///
+ /// Authenticate request.
+ ///
+ /// The request.
+ /// Authorization information. Null if unauthenticated.
+ AuthorizationInfo Authenticate(HttpRequest request);
}
}
diff --git a/MediaBrowser.Controller/Net/IAuthorizationContext.cs b/MediaBrowser.Controller/Net/IAuthorizationContext.cs
index 61598391f..37a7425b9 100644
--- a/MediaBrowser.Controller/Net/IAuthorizationContext.cs
+++ b/MediaBrowser.Controller/Net/IAuthorizationContext.cs
@@ -1,7 +1,11 @@
using MediaBrowser.Model.Services;
+using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Controller.Net
{
+ ///
+ /// IAuthorization context.
+ ///
public interface IAuthorizationContext
{
///
@@ -17,5 +21,12 @@ namespace MediaBrowser.Controller.Net
/// The request context.
/// AuthorizationInfo.
AuthorizationInfo GetAuthorizationInfo(IRequest requestContext);
+
+ ///
+ /// Gets the authorization information.
+ ///
+ /// The request context.
+ /// AuthorizationInfo.
+ AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext);
}
}
diff --git a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs
index 362d41b01..4ea5094b6 100644
--- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs
+++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs
@@ -1,7 +1,6 @@
using System;
using System.Linq;
using System.Security.Claims;
-using System.Text.Encodings.Web;
using System.Threading.Tasks;
using AutoFixture;
using AutoFixture.AutoMoq;
@@ -9,7 +8,6 @@ using Jellyfin.Api.Auth;
using Jellyfin.Api.Constants;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
-using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
@@ -26,12 +24,6 @@ namespace Jellyfin.Api.Tests.Auth
private readonly IFixture _fixture;
private readonly Mock _jellyfinAuthServiceMock;
- private readonly Mock> _optionsMonitorMock;
- private readonly Mock _clockMock;
- private readonly Mock _serviceProviderMock;
- private readonly Mock _authenticationServiceMock;
- private readonly UrlEncoder _urlEncoder;
- private readonly HttpContext _context;
private readonly CustomAuthenticationHandler _sut;
private readonly AuthenticationScheme _scheme;
@@ -47,26 +39,23 @@ namespace Jellyfin.Api.Tests.Auth
AllowFixtureCircularDependencies();
_jellyfinAuthServiceMock = _fixture.Freeze>();
- _optionsMonitorMock = _fixture.Freeze>>();
- _clockMock = _fixture.Freeze>();
- _serviceProviderMock = _fixture.Freeze>();
- _authenticationServiceMock = _fixture.Freeze>();
+ var optionsMonitorMock = _fixture.Freeze>>();
+ var serviceProviderMock = _fixture.Freeze>();
+ var authenticationServiceMock = _fixture.Freeze>();
_fixture.Register(() => new NullLoggerFactory());
- _urlEncoder = UrlEncoder.Default;
+ serviceProviderMock.Setup(s => s.GetService(typeof(IAuthenticationService)))
+ .Returns(authenticationServiceMock.Object);
- _serviceProviderMock.Setup(s => s.GetService(typeof(IAuthenticationService)))
- .Returns(_authenticationServiceMock.Object);
-
- _optionsMonitorMock.Setup(o => o.Get(It.IsAny()))
+ optionsMonitorMock.Setup(o => o.Get(It.IsAny()))
.Returns(new AuthenticationSchemeOptions
{
ForwardAuthenticate = null
});
- _context = new DefaultHttpContext
+ HttpContext context = new DefaultHttpContext
{
- RequestServices = _serviceProviderMock.Object
+ RequestServices = serviceProviderMock.Object
};
_scheme = new AuthenticationScheme(
@@ -75,24 +64,7 @@ namespace Jellyfin.Api.Tests.Auth
typeof(CustomAuthenticationHandler));
_sut = _fixture.Create();
- _sut.InitializeAsync(_scheme, _context).Wait();
- }
-
- [Fact]
- public async Task HandleAuthenticateAsyncShouldFailWithNullUser()
- {
- _jellyfinAuthServiceMock.Setup(
- a => a.Authenticate(
- It.IsAny(),
- It.IsAny()))
- .Returns((User?)null);
-
- var authenticateResult = await _sut.AuthenticateAsync();
-
- Assert.False(authenticateResult.Succeeded);
- Assert.True(authenticateResult.None);
- // TODO return when legacy API is removed.
- // Assert.Equal("Invalid user", authenticateResult.Failure.Message);
+ _sut.InitializeAsync(_scheme, context).Wait();
}
[Fact]
@@ -102,8 +74,7 @@ namespace Jellyfin.Api.Tests.Auth
_jellyfinAuthServiceMock.Setup(
a => a.Authenticate(
- It.IsAny(),
- It.IsAny()))
+ It.IsAny()))
.Throws(new SecurityException(errorMessage));
var authenticateResult = await _sut.AuthenticateAsync();
@@ -125,10 +96,10 @@ namespace Jellyfin.Api.Tests.Auth
[Fact]
public async Task HandleAuthenticateAsyncShouldAssignNameClaim()
{
- var user = SetupUser();
+ var authorizationInfo = SetupUser();
var authenticateResult = await _sut.AuthenticateAsync();
- Assert.True(authenticateResult.Principal.HasClaim(ClaimTypes.Name, user.Username));
+ Assert.True(authenticateResult.Principal.HasClaim(ClaimTypes.Name, authorizationInfo.User.Username));
}
[Theory]
@@ -136,10 +107,10 @@ namespace Jellyfin.Api.Tests.Auth
[InlineData(false)]
public async Task HandleAuthenticateAsyncShouldAssignRoleClaim(bool isAdmin)
{
- var user = SetupUser(isAdmin);
+ var authorizationInfo = SetupUser(isAdmin);
var authenticateResult = await _sut.AuthenticateAsync();
- var expectedRole = user.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User;
+ var expectedRole = authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User;
Assert.True(authenticateResult.Principal.HasClaim(ClaimTypes.Role, expectedRole));
}
@@ -152,18 +123,18 @@ namespace Jellyfin.Api.Tests.Auth
Assert.Equal(_scheme.Name, authenticatedResult.Ticket.AuthenticationScheme);
}
- private User SetupUser(bool isAdmin = false)
+ private AuthorizationInfo SetupUser(bool isAdmin = false)
{
- var user = _fixture.Create();
- user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
+ var authorizationInfo = _fixture.Create();
+ authorizationInfo.User = _fixture.Create();
+ authorizationInfo.User.SetPermission(PermissionKind.IsAdministrator, isAdmin);
_jellyfinAuthServiceMock.Setup(
a => a.Authenticate(
- It.IsAny(),
- It.IsAny()))
- .Returns(user);
+ It.IsAny()))
+ .Returns(authorizationInfo);
- return user;
+ return authorizationInfo;
}
private void AllowFixtureCircularDependencies()
diff --git a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs
new file mode 100644
index 000000000..a62fd8d5a
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Auth.DefaultAuthorizationPolicy
+{
+ public class DefaultAuthorizationHandlerTests
+ {
+ private readonly Mock _configurationManagerMock;
+ private readonly List _requirements;
+ private readonly DefaultAuthorizationHandler _sut;
+ private readonly Mock _userManagerMock;
+ private readonly Mock _httpContextAccessor;
+
+ public DefaultAuthorizationHandlerTests()
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ _configurationManagerMock = fixture.Freeze>();
+ _requirements = new List { new DefaultAuthorizationRequirement() };
+ _userManagerMock = fixture.Freeze>();
+ _httpContextAccessor = fixture.Freeze>();
+
+ _sut = fixture.Create();
+ }
+
+ [Theory]
+ [InlineData(UserRoles.Administrator)]
+ [InlineData(UserRoles.Guest)]
+ [InlineData(UserRoles.User)]
+ public async Task ShouldSucceedOnUser(string userRole)
+ {
+ TestHelpers.SetupConfigurationManager(_configurationManagerMock, true);
+ var claims = TestHelpers.SetupUser(
+ _userManagerMock,
+ _httpContextAccessor,
+ userRole);
+
+ var context = new AuthorizationHandlerContext(_requirements, claims, null);
+
+ await _sut.HandleAsync(context);
+ Assert.True(context.HasSucceeded);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs
index e40af703f..ee42216e4 100644
--- a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs
+++ b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandlerTests.cs
@@ -1,13 +1,13 @@
using System.Collections.Generic;
-using System.Security.Claims;
using System.Threading.Tasks;
using AutoFixture;
using AutoFixture.AutoMoq;
using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy;
using Jellyfin.Api.Constants;
using MediaBrowser.Common.Configuration;
-using MediaBrowser.Model.Configuration;
+using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
using Moq;
using Xunit;
@@ -18,12 +18,16 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupOrElevatedPolicy
private readonly Mock _configurationManagerMock;
private readonly List _requirements;
private readonly FirstTimeSetupOrElevatedHandler _sut;
+ private readonly Mock _userManagerMock;
+ private readonly Mock _httpContextAccessor;
public FirstTimeSetupOrElevatedHandlerTests()
{
var fixture = new Fixture().Customize(new AutoMoqCustomization());
_configurationManagerMock = fixture.Freeze>();
_requirements = new List { new FirstTimeSetupOrElevatedRequirement() };
+ _userManagerMock = fixture.Freeze>();
+ _httpContextAccessor = fixture.Freeze>();
_sut = fixture.Create();
}
@@ -34,9 +38,13 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupOrElevatedPolicy
[InlineData(UserRoles.User)]
public async Task ShouldSucceedIfStartupWizardIncomplete(string userRole)
{
- SetupConfigurationManager(false);
- var user = SetupUser(userRole);
- var context = new AuthorizationHandlerContext(_requirements, user, null);
+ TestHelpers.SetupConfigurationManager(_configurationManagerMock, false);
+ var claims = TestHelpers.SetupUser(
+ _userManagerMock,
+ _httpContextAccessor,
+ userRole);
+
+ var context = new AuthorizationHandlerContext(_requirements, claims, null);
await _sut.HandleAsync(context);
Assert.True(context.HasSucceeded);
@@ -48,30 +56,16 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupOrElevatedPolicy
[InlineData(UserRoles.User, false)]
public async Task ShouldRequireAdministratorIfStartupWizardComplete(string userRole, bool shouldSucceed)
{
- SetupConfigurationManager(true);
- var user = SetupUser(userRole);
- var context = new AuthorizationHandlerContext(_requirements, user, null);
+ TestHelpers.SetupConfigurationManager(_configurationManagerMock, true);
+ var claims = TestHelpers.SetupUser(
+ _userManagerMock,
+ _httpContextAccessor,
+ userRole);
+
+ var context = new AuthorizationHandlerContext(_requirements, claims, null);
await _sut.HandleAsync(context);
Assert.Equal(shouldSucceed, context.HasSucceeded);
}
-
- private static ClaimsPrincipal SetupUser(string role)
- {
- var claims = new[] { new Claim(ClaimTypes.Role, role) };
- var identity = new ClaimsIdentity(claims);
- return new ClaimsPrincipal(identity);
- }
-
- private void SetupConfigurationManager(bool startupWizardCompleted)
- {
- var commonConfiguration = new BaseApplicationConfiguration
- {
- IsStartupWizardCompleted = startupWizardCompleted
- };
-
- _configurationManagerMock.Setup(c => c.CommonConfiguration)
- .Returns(commonConfiguration);
- }
}
}
diff --git a/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs
new file mode 100644
index 000000000..b65d45aa0
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandlerTests.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Jellyfin.Api.Auth.IgnoreSchedulePolicy;
+using Jellyfin.Api.Constants;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Auth.IgnoreSchedulePolicy
+{
+ public class IgnoreScheduleHandlerTests
+ {
+ private readonly Mock _configurationManagerMock;
+ private readonly List _requirements;
+ private readonly IgnoreScheduleHandler _sut;
+ private readonly Mock _userManagerMock;
+ private readonly Mock _httpContextAccessor;
+
+ ///
+ /// Globally disallow access.
+ ///
+ private readonly AccessSchedule[] _accessSchedules = { new AccessSchedule(DynamicDayOfWeek.Everyday, 0, 0, Guid.Empty) };
+
+ public IgnoreScheduleHandlerTests()
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ _configurationManagerMock = fixture.Freeze>();
+ _requirements = new List { new IgnoreScheduleRequirement() };
+ _userManagerMock = fixture.Freeze>();
+ _httpContextAccessor = fixture.Freeze>();
+
+ _sut = fixture.Create();
+ }
+
+ [Theory]
+ [InlineData(UserRoles.Administrator, true)]
+ [InlineData(UserRoles.User, true)]
+ [InlineData(UserRoles.Guest, true)]
+ public async Task ShouldAllowScheduleCorrectly(string role, bool shouldSucceed)
+ {
+ TestHelpers.SetupConfigurationManager(_configurationManagerMock, true);
+ var claims = TestHelpers.SetupUser(
+ _userManagerMock,
+ _httpContextAccessor,
+ role,
+ _accessSchedules);
+
+ var context = new AuthorizationHandlerContext(_requirements, claims, null);
+
+ await _sut.HandleAsync(context);
+ Assert.Equal(shouldSucceed, context.HasSucceeded);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs
new file mode 100644
index 000000000..09ffa8468
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Auth/LocalAccessPolicy/LocalAccessHandlerTests.cs
@@ -0,0 +1,58 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Jellyfin.Api.Auth.LocalAccessPolicy;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Auth.LocalAccessPolicy
+{
+ public class LocalAccessHandlerTests
+ {
+ private readonly Mock _configurationManagerMock;
+ private readonly List _requirements;
+ private readonly LocalAccessHandler _sut;
+ private readonly Mock _userManagerMock;
+ private readonly Mock _httpContextAccessor;
+ private readonly Mock _networkManagerMock;
+
+ public LocalAccessHandlerTests()
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ _configurationManagerMock = fixture.Freeze>();
+ _requirements = new List { new LocalAccessRequirement() };
+ _userManagerMock = fixture.Freeze>();
+ _httpContextAccessor = fixture.Freeze>();
+ _networkManagerMock = fixture.Freeze>();
+
+ _sut = fixture.Create();
+ }
+
+ [Theory]
+ [InlineData(true, true)]
+ [InlineData(false, false)]
+ public async Task LocalAccessOnly(bool isInLocalNetwork, bool shouldSucceed)
+ {
+ _networkManagerMock
+ .Setup(n => n.IsInLocalNetwork(It.IsAny()))
+ .Returns(isInLocalNetwork);
+
+ TestHelpers.SetupConfigurationManager(_configurationManagerMock, true);
+ var claims = TestHelpers.SetupUser(
+ _userManagerMock,
+ _httpContextAccessor,
+ UserRoles.User);
+
+ var context = new AuthorizationHandlerContext(_requirements, claims, null);
+ await _sut.HandleAsync(context);
+ Assert.Equal(shouldSucceed, context.HasSucceeded);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs
index cd05a8328..ffe88fcde 100644
--- a/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs
+++ b/tests/Jellyfin.Api.Tests/Auth/RequiresElevationPolicy/RequiresElevationHandlerTests.cs
@@ -1,20 +1,35 @@
using System.Collections.Generic;
-using System.Security.Claims;
using System.Threading.Tasks;
+using AutoFixture;
+using AutoFixture.AutoMoq;
using Jellyfin.Api.Auth.RequiresElevationPolicy;
using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Moq;
using Xunit;
namespace Jellyfin.Api.Tests.Auth.RequiresElevationPolicy
{
public class RequiresElevationHandlerTests
{
+ private readonly Mock _configurationManagerMock;
+ private readonly List _requirements;
private readonly RequiresElevationHandler _sut;
+ private readonly Mock _userManagerMock;
+ private readonly Mock _httpContextAccessor;
public RequiresElevationHandlerTests()
{
- _sut = new RequiresElevationHandler();
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ _configurationManagerMock = fixture.Freeze>();
+ _requirements = new List { new RequiresElevationRequirement() };
+ _userManagerMock = fixture.Freeze>();
+ _httpContextAccessor = fixture.Freeze>();
+
+ _sut = fixture.Create();
}
[Theory]
@@ -23,13 +38,13 @@ namespace Jellyfin.Api.Tests.Auth.RequiresElevationPolicy
[InlineData(UserRoles.Guest, false)]
public async Task ShouldHandleRolesCorrectly(string role, bool shouldSucceed)
{
- var requirements = new List { new RequiresElevationRequirement() };
+ TestHelpers.SetupConfigurationManager(_configurationManagerMock, true);
+ var claims = TestHelpers.SetupUser(
+ _userManagerMock,
+ _httpContextAccessor,
+ role);
- var claims = new[] { new Claim(ClaimTypes.Role, role) };
- var identity = new ClaimsIdentity(claims);
- var user = new ClaimsPrincipal(identity);
-
- var context = new AuthorizationHandlerContext(requirements, user, null);
+ var context = new AuthorizationHandlerContext(_requirements, claims, null);
await _sut.HandleAsync(context);
Assert.Equal(shouldSucceed, context.HasSucceeded);
diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
index aedcc7c42..010fad520 100644
--- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
+++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
@@ -35,6 +35,7 @@
+
diff --git a/tests/Jellyfin.Api.Tests/TestHelpers.cs b/tests/Jellyfin.Api.Tests/TestHelpers.cs
new file mode 100644
index 000000000..a4dd4e409
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/TestHelpers.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Net;
+using System.Security.Claims;
+using Jellyfin.Api.Constants;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using Microsoft.AspNetCore.Http;
+using Moq;
+using AccessSchedule = Jellyfin.Data.Entities.AccessSchedule;
+
+namespace Jellyfin.Api.Tests
+{
+ public static class TestHelpers
+ {
+ public static ClaimsPrincipal SetupUser(
+ Mock userManagerMock,
+ Mock httpContextAccessorMock,
+ string role,
+ IEnumerable? accessSchedules = null)
+ {
+ var user = new User(
+ "jellyfin",
+ typeof(DefaultAuthenticationProvider).FullName,
+ typeof(DefaultPasswordResetProvider).FullName);
+
+ // Set administrator flag.
+ user.SetPermission(PermissionKind.IsAdministrator, role.Equals(UserRoles.Administrator, StringComparison.OrdinalIgnoreCase));
+
+ // Add access schedules if set.
+ if (accessSchedules != null)
+ {
+ foreach (var accessSchedule in accessSchedules)
+ {
+ user.AccessSchedules.Add(accessSchedule);
+ }
+ }
+
+ var claims = new[]
+ {
+ new Claim(ClaimTypes.Role, role),
+ new Claim(ClaimTypes.Name, "jellyfin"),
+ new Claim(InternalClaimTypes.UserId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)),
+ new Claim(InternalClaimTypes.DeviceId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)),
+ new Claim(InternalClaimTypes.Device, "test"),
+ new Claim(InternalClaimTypes.Client, "test"),
+ new Claim(InternalClaimTypes.Version, "test"),
+ new Claim(InternalClaimTypes.Token, "test"),
+ };
+
+ var identity = new ClaimsIdentity(claims);
+
+ userManagerMock
+ .Setup(u => u.GetUserById(It.IsAny()))
+ .Returns(user);
+
+ httpContextAccessorMock
+ .Setup(h => h.HttpContext.Connection.RemoteIpAddress)
+ .Returns(new IPAddress(0));
+
+ return new ClaimsPrincipal(identity);
+ }
+
+ public static void SetupConfigurationManager(in Mock configurationManagerMock, bool startupWizardCompleted)
+ {
+ var commonConfiguration = new BaseApplicationConfiguration
+ {
+ IsStartupWizardCompleted = startupWizardCompleted
+ };
+
+ configurationManagerMock
+ .Setup(c => c.CommonConfiguration)
+ .Returns(commonConfiguration);
+ }
+ }
+}