diff --git a/README.md b/README.md index 94d455f..779098b 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Build the zipped plugin with `jprm --verbosity=debug plugin build .`. - [ ] Admin page - [ ] Automated tests -- [ ] Add role/claims support +- [x] Add role/claims support - [ ] Use canonical usernames instead of preferred usernames ## Examples @@ -46,7 +46,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/auth/realms/test/protocol/saml", "samlClientId": "jellyfin-saml", "samlCertificate": "Very long base64 encoded string here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder1", "folder2"]}' "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/auth/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"` Make sure that the JSON is the same as the configuration you would like. @@ -65,7 +65,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/auth/reapls/test", "oidClientId": "jellyfin-oid", "oidSecret": "short secret here", "enabled": true, "enableAllFolders": true, "enabledFolders": ["folder3", "folder4"]}' "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/auth/reapls/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"` The OpenID provider must have the following configuration (again, I am using Keycloak) @@ -96,7 +96,7 @@ The API is all done from a base URL of `/sso/` #### Configuration -These all require authorization. Append an API key to the end of the request: `curl "http://myjellyfin.example.com/sso/SAML/Get?api_key=9c6e5fae4ae145669e6b7a3942f813b7"` +These all require authorization. Append an API key to the end of the request: `curl "http://myjellyfin.example.com/sso/SAML/Get?api_key=API_KEY_HERE"` - POST `SAML/Add`: This adds a configuration for SAML. It accepts JSON with the following keys and format: - `samlEndpoint`: string. The SAML endpoint. @@ -105,6 +105,8 @@ These all require authorization. Append an API key to the end of the request: `c - `enabled`: boolean. Determines if the provider is enabled or not. - `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). - GET `SAML/Del/clientId`: This removes a configuration for SAML for a given client ID. - GET `SAML/Get`: Lists the configurations currently available. @@ -134,6 +136,8 @@ These all require authorization. Append an API key to the end of the request: `c - `enabled`: boolean. Determines if the provider is enabled or not. - `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). - 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 6dda3b0..a579b92 100644 --- a/SSO-Auth/Api/SSOController.cs +++ b/SSO-Auth/Api/SSOController.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Newtonsoft.Json; namespace Jellyfin.Plugin.SSO_Auth.Api; @@ -41,39 +42,6 @@ public SSOController(ILogger logger, ISessionManager sessionManag _logger.LogInformation("SSO Controller initialized"); } - [HttpPost("SAML/p/{provider}")] - public ActionResult SAMLPost(string provider) - { - foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs) - { - if (config.SamlClientId == provider && config.Enabled) - { - var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]); - return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html); - } - } - - return Content("no active providers found"); // TODO: Return error code as well - } - - [HttpGet("SAML/p/{provider}")] - public RedirectResult SAMLChallenge(string provider) - { - foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs) - { - if (config.SamlClientId == provider && config.Enabled) - { - var request = new AuthRequest( - config.SamlClientId, - GetRequestBase() + "/sso/SAML/p/" + provider); - - return Redirect(request.GetRedirectUrl(config.SamlEndpoint)); - } - } - - throw new ArgumentException("Provider does not exist"); - } - [HttpGet("OID/r/{provider}")] public ActionResult OIDPost(string provider) { @@ -100,16 +68,60 @@ public ActionResult OIDPost(string provider) foreach (var claim in result.User.Claims) { - _logger.LogInformation("{0}: {1}", claim.Type, claim.Value); if (claim.Type == "preferred_username") { - StateManager[Request.Query["state"]].Valid = true; StateManager[Request.Query["state"]].Username = claim.Value; - return Content(WebResponse.OIDGenerator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html); + if (config.Roles.Length == 0) + { + StateManager[Request.Query["state"]].Valid = true; + } } - } - return Content("Does your OpenID provider not support the preferred_username value?", MediaTypeNames.Text.Plain); + // Check if allowed to login based on realm roles + if (config.Roles.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 validRoles in config.Roles) + { + foreach (string role in roles) + { + if (role.Equals(validRoles)) + { + StateManager[Request.Query["state"]].Valid = true; + } + } + } + } + } + // 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) + { + foreach (string role in roles) + { + if (role.Equals(validAdminRoles)) + { + StateManager[Request.Query["state"]].Admin = true; + } + } + } + } + } + } + if (StateManager[Request.Query["state"]].Valid) + { + return Content(WebResponse.OIDGenerator(data: Request.Query["state"], provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html); + } + else + { + return Content("Error. Check permissions"); + } } } @@ -202,7 +214,7 @@ public async Task OIDAuth([FromBody] AuthResponse response) { if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid) { - var authenticationResult = await Authenticate(kvp.Value.Username, false, oidConfig.EnableAllFolders, oidConfig.EnabledFolders, response) + var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, oidConfig.EnableAllFolders, oidConfig.EnabledFolders, response) .ConfigureAwait(false); return Ok(authenticationResult); } @@ -213,6 +225,54 @@ public async Task OIDAuth([FromBody] AuthResponse response) return Problem("Something went wrong"); } + [HttpPost("SAML/p/{provider}")] + 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) + { + if (samlConfig.SamlClientId == provider && samlConfig.Enabled) + { + var samlResponse = new Response(samlConfig.SamlCertificate, Request.Form["SAMLResponse"]); + if (samlConfig.Roles.Length == 0) + { + return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html); + } + foreach (string role in samlResponse.GetCustomAttributes("Role")) + { + foreach (string allowedRole in samlConfig.Roles) + { + if (allowedRole.Equals(role)) + { + return Content(WebResponse.SamlGenerator(xml: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase()), MediaTypeNames.Text.Html); + } + } + } + return Content("401 Forbidden"); + } + } + + return Content("no active providers found"); // TODO: Return error code as well + } + + [HttpGet("SAML/p/{provider}")] + public RedirectResult SAMLChallenge(string provider) + { + foreach (var config in SSOPlugin.Instance.Configuration.SamlConfigs) + { + if (config.SamlClientId == provider && config.Enabled) + { + var request = new AuthRequest( + config.SamlClientId, + GetRequestBase() + "/sso/SAML/p/" + provider); + + return Redirect(request.GetRedirectUrl(config.SamlEndpoint)); + } + } + + throw new ArgumentException("Provider does not exist"); + } + [Authorize(Policy = "RequiresElevation")] [HttpPost("SAML/Add")] public void SamlAdd([FromBody] SamlConfig samlConfig) @@ -262,8 +322,19 @@ public async Task SamlAuth([FromBody] AuthResponse response) { if (samlConfig.SamlClientId == response.Provider && samlConfig.Enabled) { + bool isAdmin = false; var samlResponse = new Response(samlConfig.SamlCertificate, response.Data); - var authenticationResult = await Authenticate(samlResponse.GetNameID(), false, samlConfig.EnableAllFolders, samlConfig.EnabledFolders, response) + foreach (string role in samlResponse.GetCustomAttributes("Role")) + { + foreach (string allowedRole in samlConfig.AdminRoles) + { + if (allowedRole.Equals(role)) + { + isAdmin = true; + } + } + } + var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, samlConfig.EnableAllFolders, samlConfig.EnabledFolders, response) .ConfigureAwait(false); return Ok(authenticationResult); } @@ -274,7 +345,6 @@ public async Task SamlAuth([FromBody] AuthResponse response) private async Task Authenticate(string username, bool isAdmin, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse) { - _logger.LogInformation("Authenticating"); User user = null; user = _userManager.GetUserByName(username); @@ -282,17 +352,17 @@ private async Task Authenticate(string username, bool isAd { _logger.LogInformation("SSO user doesn't exist, creating..."); 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); - } - - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + } + user.AuthenticationProviderId = GetType().FullName; + user.SetPermission(PermissionKind.IsAdministrator, isAdmin); + user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders); + if (!enableAllFolders) + { + user.SetPreference(PreferenceKind.EnabledFolders, enabledFolders); } + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + var authRequest = new AuthenticationRequest(); authRequest.UserId = user.Id; authRequest.Username = user.Username; @@ -344,6 +414,7 @@ public TimedAuthorizeState(AuthorizeState state, DateTime created) State = state; Created = created; Valid = false; + Admin = false; } public AuthorizeState State { get; set; } @@ -353,4 +424,6 @@ public TimedAuthorizeState(AuthorizeState state, DateTime created) public bool Valid { get; set; } public string Username { get; set; } + + public bool Admin { get; set; } } diff --git a/SSO-Auth/Config/PluginConfiguration.cs b/SSO-Auth/Config/PluginConfiguration.cs index c2c0d09..2351734 100644 --- a/SSO-Auth/Config/PluginConfiguration.cs +++ b/SSO-Auth/Config/PluginConfiguration.cs @@ -40,6 +40,10 @@ public class SamlConfig public bool EnableAllFolders { get; set; } public string[] EnabledFolders { get; set; } + + public string[] AdminRoles { get; set; } + + public string[] Roles { get; set; } } [XmlRoot("PluginConfiguration")] @@ -56,4 +60,8 @@ public class OIDConfig public bool EnableAllFolders { get; set; } public string[] EnabledFolders { get; set; } + + public string[] AdminRoles { get; set; } + + public string[] Roles { get; set; } } diff --git a/SSO-Auth/Config/configPage.html b/SSO-Auth/Config/configPage.html index 43892ac..e6b54ec 100644 --- a/SSO-Auth/Config/configPage.html +++ b/SSO-Auth/Config/configPage.html @@ -14,6 +14,12 @@

SSO Settings:

${Help}
+ +
@@ -36,7 +42,7 @@

SSO Settings:

@@ -49,7 +55,6 @@

SSO Settings:

diff --git a/SSO-Auth/SSO-Auth.csproj b/SSO-Auth/SSO-Auth.csproj index a3b33d1..a48a325 100644 --- a/SSO-Auth/SSO-Auth.csproj +++ b/SSO-Auth/SSO-Auth.csproj @@ -19,6 +19,7 @@ + diff --git a/SSO-Auth/Saml.cs b/SSO-Auth/Saml.cs index 8e4321d..0494791 100644 --- a/SSO-Auth/Saml.cs +++ b/SSO-Auth/Saml.cs @@ -1,5 +1,5 @@ /* - Jitbit's simple SAML 2.0 component for ASP.NET + Was Jitbit's simple SAML 2.0 component for ASP.NET https://github.com/jitbit/AspNetSaml/ (c) Jitbit LP, 2016 Use this freely under the Apache license (see https://choosealicense.com/licenses/apache-2.0/) @@ -7,6 +7,7 @@ version 1.2.3 */ using System; +using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Security.Cryptography.X509Certificates; @@ -188,6 +189,17 @@ public string GetCustomAttribute(string attr) return node?.InnerText; } + public List GetCustomAttributes(string attr) + { + var node = _xmlDoc.SelectNodes("/samlp:Response/saml:Assertion[1]/saml:AttributeStatement/saml:Attribute[@Name='" + attr + "']/saml:AttributeValue", _xmlNameSpaceManager); + List output = new List(); + foreach (XmlNode item in node) + { + output.Add(item?.InnerText); + } + return output; + } + // returns namespace manager, we need one b/c MS says so... Otherwise XPath doesnt work in an XML doc with namespaces // see https://stackoverflow.com/questions/7178111/why-is-xmlnamespacemanager-necessary private XmlNamespaceManager GetNamespaceManager()