From 052e78b44dcdc841b15e7264faadef569be1cc72 Mon Sep 17 00:00:00 2001 From: Sambhav Saggi <17993169+9p4@users.noreply.github.com> Date: Tue, 15 Feb 2022 20:20:15 -0500 Subject: [PATCH] Add more RBAC --- README.md | 17 +++- SSO-Auth/Api/SSOController.cs | 119 ++++++++++++++++--------- SSO-Auth/Config/PluginConfiguration.cs | 25 ++++++ 3 files changed, 114 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 65d245b..da35592 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ There is NO admin configuration! You must use the API to configure the program! **This is for Jellyfin 10.8** +**This README reflects the __main__ branch! Switch tags to view version-specific documentation!** + ## Tested Providers - Google OpenID: Works, but usernames are all numeric @@ -53,7 +55,7 @@ Build the zipped plugin with `jprm --verbosity=debug plugin build .`. Example for adding a SAML configuration with the API using [curl](https://curl.se/): -`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder1", "folder2"], "adminRoles": [], "roles": []}' "https://myjellyfin.example.com/sso/SAML/Add?api_key=API_KEY_HERE"` +`curl -v -X POST -H "Content-Type: application/json" -d '{"samlEndpoint": "https://keycloak.example.com/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAuthorization": true, "enableAllFolders": true, "enabledFolders": ["folder1", "folder2"], "adminRoles": [], "roles": [], "enableFolderRoles": false, "folderRoleMapping": [{"role": "Movies", "folders": ["Movie1", "Movie2"]}]}' "https://myjellyfin.example.com/sso/SAML/Add?api_key=API_KEY_HERE"` Make sure that the JSON is the same as the configuration you would like. @@ -72,7 +74,7 @@ Make sure that `clientid` is replaced with the actual client ID! Example for adding an OpenID configuration with the API using [curl](https://curl.se/) -`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/realms/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder3", "folder4"], "adminRoles": [], "roles": []}' "https://myjellyfin.example.com/sso/OID/Add?api_key=API_KEY_HERE"` +`curl -v -X POST -H "Content-Type: application/json" -d '{"oidEndpoint": "https://keycloak.example.com/realms/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder3", "folder4"], "enableAuthorization": true, "adminRoles": [], "roles": [], "enableFolderRoles": false, roleClaim: "realm_roles", "folderRoleMapping": [{"role": "Movies", "folders": ["Movie1", "Movie2"]}]}' "https://myjellyfin.example.com/sso/OID/Add?api_key=API_KEY_HERE"` The OpenID provider must have the following configuration (again, I am using Keycloak) @@ -110,10 +112,13 @@ These all require authorization. Append an API key to the end of the request: `c - `samlClientId`: string. The SAML client ID. - `samlCertificate`: string. The base64 encoded SAML certificate. - `enabled`: boolean. Determines if the provider is enabled or not. + - `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. The permissions of existing users will not be rewritten on subsequent logins. - `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders. - `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use. - `roles`: array of strings. This validates the SAML response against the `Role` attribute. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking. - `adminRoles`: array of strings. This uses SAML response's `Role` attributes. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions). + - `enableFolderRoles`: boolean. Determines if role-based folder access should be used. + - `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute. - GET `SAML/Del/clientId`: This removes a configuration for SAML for a given client ID. - GET `SAML/Get`: Lists the configurations currently available. @@ -141,10 +146,14 @@ These all require authorization. Append an API key to the end of the request: `c - `oidClientId`: string. The OpenID client ID. - `oidSecret`: string. The OpenID secret. - `enabled`: boolean. Determines if the provider is enabled or not. + - `enableAuthorization`: boolean: Determines if the plugin sets permissions for the user. If false, the user will start with no permissions and an administrator will add permissions. The permissions of existing users will not be rewritten on subsequent logins. - `enableAllFolders`: boolean. Determines if the client logging in is allowed access to all folders. - `enabledFolders`: array of strings. If `enableAllFolders` is set to false, then this will be used to determine what folders the users who log in through this provider are allowed to use. - - `roles`: array of strings. This validates the OpenID response against the `realm_access` claim. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking. This currently only works for Keycloak (to my knowledge). - - `adminRoles`: array of strings. This uses the OpenID response against the `realm_access` claim. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions). + - `roles`: array of strings. This validates the OpenID response against the claim set in `roleClaim`. If a user has any of these roles, then the user is authenticated. Leave blank to disable role checking. This currently only works for Keycloak (to my knowledge). + - `adminRoles`: array of strings. This uses the OpenID response against the claim set in `roleClaim`. If a user has any of these roles, then the user is an admin. Leave blank to disable (default is to not enable admin permissions). + - `enableFolderRoles`: boolean. Determines if role-based folder access should be used. + - `folderRoleMapping`: object in the format "role": string and "folders": array of strings. The user with this role will have access to the following folders if `enableFolderRoles` is enabled. To get the IDs of the folders, GET the `/Library/MediaFolders` URL with an API key. Look for the `Id` attribute. + - `roleClaim`: string. This is the value in the OpenID response to check for roles. For Keycloak, it is `realm_roles` by default. - GET `OID/Del/clientId`: This removes a configuration for OpenID for a given client ID. - GET `OID/Get`: Lists the configurations currently available. - GET `OID/States`: Lists currently active OpenID flows in progress. diff --git a/SSO-Auth/Api/SSOController.cs b/SSO-Auth/Api/SSOController.cs index 1f25716..8793114 100644 --- a/SSO-Auth/Api/SSOController.cs +++ b/SSO-Auth/Api/SSOController.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Net.Mime; using System.Threading.Tasks; -using IdentityModel.Client; using IdentityModel.OidcClient; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; @@ -68,6 +67,13 @@ public class SSOController : ControllerBase return Content("Something went wrong...", MediaTypeNames.Text.Plain); } + if (!config.EnableFolderRoles) + { + StateManager[Request.Query["state"]].Folders = new List(config.EnabledFolders); + } else { + StateManager[Request.Query["state"]].Folders = new List(); + } + foreach (var claim in result.User.Claims) { if (claim.Type == "preferred_username") @@ -79,15 +85,16 @@ public class SSOController : ControllerBase } } - // Check if allowed to login based on realm roles - if (config.Roles.Length != 0) + // Role processing + if (claim.Type == config.RoleClaim) { - if (claim.Type == "realm_access") // This is specific to Keycloak. Don't use roles without Keycloak, I guess + List roles = JsonConvert.DeserializeObject>>(claim.Value)["roles"]; // Might need error handling here + foreach (string role in roles) { - List roles = JsonConvert.DeserializeObject>>(claim.Value)["roles"]; // Might need error handling here - foreach (string validRoles in config.Roles) + // Check if allowed to login based on roles + if (config.Roles.Length != 0) { - foreach (string role in roles) + foreach (string validRoles in config.Roles) { if (role.Equals(validRoles)) { @@ -95,17 +102,10 @@ public class SSOController : ControllerBase } } } - } - } - // Check if admin - if (config.AdminRoles.Length != 0) - { - if (claim.Type == "realm_access") // This is specific to Keycloak. Don't use roles without Keycloak, I guess - { - List roles = JsonConvert.DeserializeObject>>(claim.Value)["roles"]; // Might need error handling here - foreach (string validAdminRoles in config.AdminRoles) + // Check if admin based on roles + if (config.AdminRoles.Length != 0) { - foreach (string role in roles) + foreach (string validAdminRoles in config.AdminRoles) { if (role.Equals(validAdminRoles)) { @@ -113,6 +113,17 @@ public class SSOController : ControllerBase } } } + // Get allowed folders from roles + if (config.EnableFolderRoles) + { + foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping) + { + if (role.Equals(folderRoleMap.Role)) + { + StateManager[Request.Query["state"]].Folders.AddRange(folderRoleMap.Folders); + } + } + } } } } @@ -175,18 +186,18 @@ public class SSOController : ControllerBase [Authorize(Policy = "RequiresElevation")] [HttpPost("OID/Add")] - public void OIDAdd([FromBody] OIDConfig oidConfig) + public void OIDAdd([FromBody] OIDConfig config) { var configuration = SSOPlugin.Instance.Configuration; for (var i = 0; i < configuration.OIDConfigs.Count; i++) { - if (configuration.OIDConfigs[i].OIDClientId.Equals(oidConfig.OIDClientId)) + if (configuration.OIDConfigs[i].OIDClientId.Equals(config.OIDClientId)) { configuration.OIDConfigs.RemoveAt(i); } } - configuration.OIDConfigs.Add(oidConfig); + configuration.OIDConfigs.Add(config); SSOPlugin.Instance.UpdateConfiguration(configuration); } @@ -225,15 +236,15 @@ public class SSOController : ControllerBase [Produces(MediaTypeNames.Application.Json)] public async Task OIDAuth([FromBody] AuthResponse response) { - foreach (var oidConfig in SSOPlugin.Instance.Configuration.OIDConfigs) + foreach (var config in SSOPlugin.Instance.Configuration.OIDConfigs) { - if (oidConfig.OIDClientId == response.Provider && oidConfig.Enabled) + if (config.OIDClientId == response.Provider && config.Enabled) { foreach (var kvp in StateManager) { if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid) { - var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, oidConfig.EnableAllFolders, oidConfig.EnabledFolders, response) + var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response) .ConfigureAwait(false); return Ok(authenticationResult); } @@ -248,18 +259,20 @@ public class SSOController : ControllerBase public ActionResult SAMLPost(string provider) { // I'm sure there's a better way than using nested for loops but eh whatever - foreach (var samlConfig in SSOPlugin.Instance.Configuration.SamlConfigs) + foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs) { - if (samlConfig.SamlClientId == provider && samlConfig.Enabled) + if (config.SamlClientId == provider && config.Enabled) { - var samlResponse = new Response(samlConfig.SamlCertificate, Request.Form["SAMLResponse"]); - if (samlConfig.Roles.Length == 0) + var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]); + // If no roles are configured, don't use RBAC + if (config.Roles.Length == 0) { return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html); } + // Check if user is allowed to log in based on roles foreach (string role in samlResponse.GetCustomAttributes("Role")) { - foreach (string allowedRole in samlConfig.Roles) + foreach (string allowedRole in config.Roles) { if (allowedRole.Equals(role)) { @@ -267,7 +280,7 @@ public class SSOController : ControllerBase } } } - return Content("401 Forbidden"); + return Content("401 Forbidden"); // TODO: Return error code as well } } @@ -294,18 +307,18 @@ public class SSOController : ControllerBase [Authorize(Policy = "RequiresElevation")] [HttpPost("SAML/Add")] - public void SamlAdd([FromBody] SamlConfig samlConfig) + public void SamlAdd([FromBody] SamlConfig config) { var configuration = SSOPlugin.Instance.Configuration; for (var i = 0; i < configuration.SamlConfigs.Count; i++) { - if (configuration.SamlConfigs[i].SamlClientId.Equals(samlConfig.SamlClientId)) + if (configuration.SamlConfigs[i].SamlClientId.Equals(config.SamlClientId)) { configuration.SamlConfigs.RemoveAt(i); } } - configuration.SamlConfigs.Add(samlConfig); + configuration.SamlConfigs.Add(config); SSOPlugin.Instance.UpdateConfiguration(configuration); } @@ -337,23 +350,39 @@ public class SSOController : ControllerBase [Produces(MediaTypeNames.Application.Json)] public async Task SamlAuth([FromBody] AuthResponse response) { - foreach (var samlConfig in SSOPlugin.Instance.Configuration.SamlConfigs) + foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs) { - if (samlConfig.SamlClientId == response.Provider && samlConfig.Enabled) + if (config.SamlClientId == response.Provider && config.Enabled) { bool isAdmin = false; - var samlResponse = new Response(samlConfig.SamlCertificate, response.Data); + var samlResponse = new Response(config.SamlCertificate, response.Data); + List folders; + if (!config.EnableFolderRoles) + { + folders = new List(config.EnabledFolders); + } else { + folders = new List(); + } foreach (string role in samlResponse.GetCustomAttributes("Role")) { - foreach (string allowedRole in samlConfig.AdminRoles) + foreach (string allowedRole in config.AdminRoles) { if (allowedRole.Equals(role)) { isAdmin = true; } } + + if (config.EnableFolderRoles) { + foreach (FolderRoleMap folderRoleMap in config.FolderRoleMapping) + { + if (folderRoleMap.Role.Equals(role)) { + folders.AddRange(folderRoleMap.Folders); + } + } + } } - var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, samlConfig.EnableAllFolders, samlConfig.EnabledFolders, response) + var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response) .ConfigureAwait(false); return Ok(authenticationResult); } @@ -362,7 +391,7 @@ public class SSOController : ControllerBase return Problem("Something went wrong"); } - private async Task Authenticate(string username, bool isAdmin, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse) + private async Task Authenticate(string username, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse) { User user = null; user = _userManager.GetUserByName(username); @@ -373,11 +402,13 @@ public class SSOController : ControllerBase user = await _userManager.CreateUserAsync(username).ConfigureAwait(false); } user.AuthenticationProviderId = GetType().FullName; - user.SetPermission(PermissionKind.IsAdministrator, isAdmin); - user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders); - if (!enableAllFolders) - { - user.SetPreference(PreferenceKind.EnabledFolders, enabledFolders); + if (enableAuthorization) { + user.SetPermission(PermissionKind.IsAdministrator, isAdmin); + user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders); + if (!enableAllFolders) + { + user.SetPreference(PreferenceKind.EnabledFolders, enabledFolders); + } } await _userManager.UpdateUserAsync(user).ConfigureAwait(false); @@ -447,4 +478,6 @@ public class TimedAuthorizeState public bool Admin { get; set; } public string Email { get; set; } + + public List Folders { get; set; } } diff --git a/SSO-Auth/Config/PluginConfiguration.cs b/SSO-Auth/Config/PluginConfiguration.cs index 2351734..c2953f9 100644 --- a/SSO-Auth/Config/PluginConfiguration.cs +++ b/SSO-Auth/Config/PluginConfiguration.cs @@ -37,6 +37,8 @@ public class SamlConfig public bool Enabled { get; set; } + public bool EnableAuthorization { get; set; } + public bool EnableAllFolders { get; set; } public string[] EnabledFolders { get; set; } @@ -44,6 +46,12 @@ public class SamlConfig public string[] AdminRoles { get; set; } public string[] Roles { get; set; } + + public bool EnableFolderRoles { get; set; } + + [XmlArray("FolderRoleMappings")] + [XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")] + public List FolderRoleMapping { get; set; } } [XmlRoot("PluginConfiguration")] @@ -57,6 +65,8 @@ public class OIDConfig public bool Enabled { get; set; } + public bool EnableAuthorization { get; set; } + public bool EnableAllFolders { get; set; } public string[] EnabledFolders { get; set; } @@ -64,4 +74,19 @@ public class OIDConfig public string[] AdminRoles { get; set; } public string[] Roles { get; set; } + + public bool EnableFolderRoles { get; set; } + + [XmlArray("FolderRoleMappings")] + [XmlArrayItem(typeof(FolderRoleMap), ElementName = "FolderRoleMappings")] + public List FolderRoleMapping { get; set; } + + public string RoleClaim { get; set; } +} + +public class FolderRoleMap +{ + public string Role { get; set; } + + public List Folders { get; set; } }