From 7ede38d0abdb5cfc408b68c76197fc91ee8ae912 Mon Sep 17 00:00:00 2001 From: Matthew Strasiotto <39424834+matthewstrasiotto@users.noreply.github.com> Date: Fri, 27 May 2022 01:41:43 +1000 Subject: [PATCH] Implement canonical linking --- SSO-Auth/Api/RequestHelpers.cs | 62 ++++++ SSO-Auth/Api/SSOController.cs | 280 +++++++++++++++++++++++-- SSO-Auth/Config/PluginConfiguration.cs | 41 ++++ SSO-Auth/WebResponse.cs | 40 +++- 4 files changed, 400 insertions(+), 23 deletions(-) create mode 100644 SSO-Auth/Api/RequestHelpers.cs diff --git a/SSO-Auth/Api/RequestHelpers.cs b/SSO-Auth/Api/RequestHelpers.cs new file mode 100644 index 0000000..af29df9 --- /dev/null +++ b/SSO-Auth/Api/RequestHelpers.cs @@ -0,0 +1,62 @@ +// The following code is a derivative work of the code from the Jellyfin project, +// which is licensed GPLv2. This code therefore is also licensed under the terms +// of the GNU Public License, verison 2. +// https://github.com/jellyfin/jellyfin/blob/a60cb280a3d31ba19ffb3a94cf83ef300a7473b7/Jellyfin.Api/Helpers/RequestHelpers.cs#L63-L77 + +// Use of this relatively small snippet complies with fair use +// See https://www.gnu.org/licenses/gpl-faq.en.html#SourceCodeInDocumentation +// These helpers were not published within a Nuget package, so it was neccessary to re-implement. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Mime; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using IdentityModel.OidcClient; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using Jellyfin.Plugin.SSO_Auth.Config; +using Jellyfin.Plugin.SSO_Auth.Helpers; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Jellyfin.Plugin.SSO_Auth.Helpers; + +/// +/// Request Extensions. +/// +public static class RequestHelpers +{ + /// + /// Checks if the user can update an entry. + /// + /// Instance of the interface. + /// The . + /// The user id. + /// Whether to restrict the user preferences. + /// A whether the user can update the entry. + internal static async Task AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences) + { + var auth = await authContext.GetAuthorizationInfo(requestContext).ConfigureAwait(false); + + var authenticatedUser = auth.User; + + // If they're going to update the record of another user, they must be an administrator + if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator)) + || (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess)) + { + return false; + } + + return true; + } +} diff --git a/SSO-Auth/Api/SSOController.cs b/SSO-Auth/Api/SSOController.cs index ad974cb..4baacfa 100644 --- a/SSO-Auth/Api/SSOController.cs +++ b/SSO-Auth/Api/SSOController.cs @@ -8,12 +8,15 @@ using IdentityModel.OidcClient; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Plugin.SSO_Auth.Config; +using Jellyfin.Plugin.SSO_Auth.Helpers; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -29,6 +32,7 @@ public class SSOController : ControllerBase { private readonly IUserManager _userManager; private readonly ISessionManager _sessionManager; + private readonly IAuthorizationContext _authContext; private readonly ILogger _logger; private static readonly IDictionary StateManager = new Dictionary(); @@ -37,11 +41,13 @@ public class SSOController : ControllerBase /// /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. /// Instance of the interface. - public SSOController(ILogger logger, ISessionManager sessionManager, IUserManager userManager) + public SSOController(ILogger logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext) { _sessionManager = sessionManager; _userManager = userManager; + _authContext = authContext; _logger = logger; _logger.LogInformation("SSO Controller initialized"); } @@ -196,9 +202,12 @@ public class SSOController : ControllerBase } } + bool isLinking = StateManager[state].IsLinking; + if (StateManager[state].Valid) { - return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html); + _logger.LogInformation($"Is request linking: {isLinking}"); + return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(), mode: "OID", isLinking: isLinking), MediaTypeNames.Text.Html); } else { @@ -220,9 +229,10 @@ public class SSOController : ControllerBase /// Initiates the login flow for OpenID. This redirects the user to the auth provider. /// /// The name of the provider. + /// Whether or not this request is to link accounts (Rather than authenticate). /// An asynchronous result for the authentication. [HttpGet("OID/p/{provider}")] - public async Task OidChallenge(string provider) + public async Task OidChallenge(string provider, [FromQuery] bool isLinking = false) { Invalidate(); OidConfig config; @@ -249,6 +259,9 @@ public class SSOController : ControllerBase var oidcClient = new OidcClient(options); var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false); StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now)); + + // Track whether this is a linking request or not. + StateManager[state.State].IsLinking = isLinking; return Redirect(state.StartUrl); } @@ -331,7 +344,9 @@ public class SSOController : ControllerBase { if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid) { - var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response, config.DefaultProvider) + Guid userId = await CreateCanonicalLinkAndUserIfNotExist("oid", provider, kvp.Value.Username); + + var authenticationResult = await Authenticate(userId, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response, config.DefaultProvider) .ConfigureAwait(false); return Ok(authenticationResult); } @@ -345,9 +360,13 @@ public class SSOController : ControllerBase /// This is the callback for the SAML flow. This creates a webpage to complete auth. /// /// The provider that is calling back. + /// + /// RelayState given in the original saml request. If it is equal to "linking", + /// We consider this to be a linking request. + /// /// A webpage that will complete the client-side flow. [HttpPost("SAML/p/{provider}")] - public ActionResult SamlPost(string provider) + public ActionResult SamlPost(string provider, [FromQuery] string relayState = null) { SamlConfig config; try @@ -359,13 +378,19 @@ public class SSOController : ControllerBase return BadRequest("No matching provider found"); } + bool isLinking = relayState == "linking"; + + _logger.LogInformation( + $"SAML request has relayState of {relayState}"); + if (config.Enabled) { 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.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html); + return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML", isLinking: isLinking), MediaTypeNames.Text.Html); } // Check if user is allowed to log in based on roles @@ -375,7 +400,7 @@ public class SSOController : ControllerBase { if (allowedRole.Equals(role)) { - return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html); + return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML", isLinking: isLinking), MediaTypeNames.Text.Html); } } } @@ -395,9 +420,10 @@ public class SSOController : ControllerBase /// Initializes the SAML flow. This will redirect the user to the SAML provider. /// /// The provider to being the flow with. + /// Whether this flow intends to link an account, or initiate auth. /// A redirect to the SAML provider's auth page. [HttpGet("SAML/p/{provider}")] - public RedirectResult SamlChallenge(string provider) + public RedirectResult SamlChallenge(string provider, [FromQuery] bool isLinking = false) { SamlConfig config; try @@ -411,11 +437,17 @@ public class SSOController : ControllerBase if (config.Enabled) { + string relayState = null; + if (isLinking) + { + relayState = "linking"; + } + var request = new AuthRequest( config.SamlClientId, GetRequestBase() + "/sso/SAML/p/" + provider); - return Redirect(request.GetRedirectUrl(config.SamlEndpoint)); + return Redirect(request.GetRedirectUrl(config.SamlEndpoint, relayState)); } throw new ArgumentException("Provider does not exist"); @@ -520,7 +552,9 @@ public class SSOController : ControllerBase } } - var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response, config.DefaultProvider) + Guid userId = await CreateCanonicalLinkAndUserIfNotExist("saml", provider, samlResponse.GetNameID()); + + var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response, config.DefaultProvider) .ConfigureAwait(false); return Ok(authenticationResult); } @@ -544,28 +578,223 @@ public class SSOController : ControllerBase return Ok(); } + private SerializableDictionary GetCanonicalLinks(string mode, string provider) + { + SerializableDictionary links = null; + + switch (mode.ToLower()) + { + case "saml": + links = SSOPlugin.Instance.Configuration.SamlConfigs[provider].CanonicalLinks; + break; + case "oid": + links = SSOPlugin.Instance.Configuration.OidConfigs[provider].CanonicalLinks; + break; + default: + throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'"); + } + + if (links == null) + { + links = new SerializableDictionary(); + } + + return links; + } + + private async Task CreateCanonicalLinkAndUserIfNotExist(string mode, string provider, string canonicalName) + { + Guid userId = Guid.Empty; + try + { + userId = GetCanonicalLink(mode, provider, canonicalName); + } + catch (KeyNotFoundException) + { + userId = Guid.Empty; + } + + if (userId == Guid.Empty) + { + _logger.LogInformation("SSO user link doesn't exist, creating..."); + User user = null; + user = _userManager.GetUserByName(canonicalName); + + if (user == null) + { + _logger.LogInformation($"SSO user {canonicalName} doesn't exist, creating..."); + user = await _userManager.CreateUserAsync(canonicalName).ConfigureAwait(false); + user.AuthenticationProviderId = GetType().FullName; + } + + userId = user.Id; + + CreateCanonicalLink(mode, provider, userId, canonicalName); + } + + return userId; + } + + private Guid GetCanonicalLink(string mode, string provider, string canonicalName) + { + SerializableDictionary links = null; + Guid userId = Guid.Empty; + + links = GetCanonicalLinks(mode, provider); + + userId = links[canonicalName]; + + return userId; + } + + /// + /// Removes a user from SSO auth and switches it back to another auth provider. Requires administrator privileges. + /// + /// The mode of the function; SAML or OID. + /// The name of the provider to link to a jellyfin account. + /// The user ID within jellyfin to link to the provider. + /// The client information to authenticate the user with. + /// Whether this API endpoint succeeded. + [Authorize(Policy = "DefaultAuthorization")] + [HttpPost("{mode}/Link/{provider}/{jellyfinUserId}")] + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + public async Task AddCanonicalLink([FromRoute] string mode, [FromRoute] string provider, [FromRoute] Guid jellyfinUserId, [FromBody] AuthResponse authResponse) + { + if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to link SSO providers."); + } + + switch (mode.ToLower()) + { + case "saml": + return SamlLink(provider, jellyfinUserId, authResponse); + case "oid": + return OidLink(provider, jellyfinUserId, authResponse); + default: + throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'"); + } + } + + /// + /// Validate a saml link request and create the link if it is valid. + /// + /// The provider to authenticate against. + /// + /// The ID of the account to be linked to the provider. + /// Must be performed by this user, or an admin. + /// + /// The data passed to the client to ensure it is the right one. + /// JSON for the client to populate information with. + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + private ActionResult SamlLink(string provider, Guid jellyfinUserId, AuthResponse response) + { + SamlConfig config; + try + { + config = SSOPlugin.Instance.Configuration.SamlConfigs[provider]; + } + catch (KeyNotFoundException) + { + return BadRequest("No matching provider found"); + } + + var samlResponse = new Response(config.SamlCertificate, response.Data); + // TODO: Does saml response require further validation? + + string providerUserId = samlResponse.GetNameID(); + + return CreateCanonicalLink("saml", provider, jellyfinUserId, providerUserId); + } + + /// + /// Validate an OIDC link request and create the link if it is valid. + /// + /// The provider to authenticate against. + /// + /// The ID of the account to be linked to the provider. + /// Must be performed by this user, or an admin. + /// + /// The data passed to the client to ensure it is the right one. + /// JSON for the client to populate information with. + [Consumes(MediaTypeNames.Application.Json)] + [Produces(MediaTypeNames.Application.Json)] + private ActionResult OidLink(string provider, Guid jellyfinUserId, AuthResponse response) + { + OidConfig config; + try + { + config = SSOPlugin.Instance.Configuration.OidConfigs[provider]; + } + catch (KeyNotFoundException) + { + return BadRequest("No matching provider found"); + } + + foreach (var kvp in StateManager) + { + if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid) + { + string providerUserId = kvp.Value.Username; + return CreateCanonicalLink("oid", provider, jellyfinUserId, providerUserId); + } + } + + return Problem("Something went wrong!"); + } + + private ActionResult CreateCanonicalLink(string mode, string provider, [FromRoute] Guid jellyfinUserId, string providerUserId) + { + SerializableDictionary links = null; + try + { + links = GetCanonicalLinks(mode, provider); + } + catch (KeyNotFoundException) + { + return BadRequest("No matching provider found"); + } + + links[providerUserId] = jellyfinUserId; + UpdateCanonicalLinkConfig(links, mode, provider); + + return NoContent(); + } + + private OkResult UpdateCanonicalLinkConfig(SerializableDictionary links, string mode, string provider) + { + var configuration = SSOPlugin.Instance.Configuration; + switch (mode.ToLower()) + { + case "saml": + configuration.SamlConfigs[provider].CanonicalLinks = links; + break; + case "oid": + configuration.OidConfigs[provider].CanonicalLinks = links; + break; + default: + throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'"); + } + + SSOPlugin.Instance.UpdateConfiguration(configuration); + return Ok(); + } + /// /// Authenticates the user with the given information. /// - /// The username of the user to authenticate. + /// The user id of the user to authenticate. /// Determines whether this user is an administrator. /// Determines whether RBAC is used for this user. /// Determines whether all folders are enabled. /// Determines which folders should be enabled for this client. /// The client information to authenticate the user with. /// The default provider of the user to be set after logging in. - private async Task Authenticate(string username, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse, string defaultProvider) + private async Task Authenticate(Guid userId, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse, string defaultProvider) { - User user = null; - user = _userManager.GetUserByName(username); - - if (user == null) - { - _logger.LogInformation("SSO user doesn't exist, creating..."); - user = await _userManager.CreateUserAsync(username).ConfigureAwait(false); - user.AuthenticationProviderId = GetType().FullName; - } - + User user = _userManager.GetUserById(userId); if (enableAuthorization) { user.SetPermission(PermissionKind.IsAdministrator, isAdmin); @@ -670,6 +899,7 @@ public class TimedAuthorizeState Created = created; Valid = false; Admin = false; + IsLinking = false; } /// @@ -697,6 +927,12 @@ public class TimedAuthorizeState /// public bool Admin { get; set; } + /// + /// Gets or sets a value indicating whether the state is + /// tied to a linking flow (instead of a login flow). + /// + public bool IsLinking { get; set; } + /// /// Gets or sets the folders the user is allowed access to. /// diff --git a/SSO-Auth/Config/PluginConfiguration.cs b/SSO-Auth/Config/PluginConfiguration.cs index 6c7bd79..bf9a43b 100644 --- a/SSO-Auth/Config/PluginConfiguration.cs +++ b/SSO-Auth/Config/PluginConfiguration.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Xml.Serialization; @@ -36,6 +37,8 @@ public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfigur [XmlRoot("PluginConfiguration")] public class SamlConfig { + private SerializableDictionary _canonicalLinks; + /// /// Gets or sets the SAML information endpoint. /// @@ -97,6 +100,24 @@ public class SamlConfig /// Gets or sets the default provider the user after logging in with SSO. /// public string DefaultProvider { get; set; } + + /// + /// Gets or sets a mapping of canonical names from the provider to jellyfin user ids. + /// + [XmlElement("CanonicalLinks")] + public SerializableDictionary CanonicalLinks + { + get + { + if (_canonicalLinks == null) + { + return new SerializableDictionary(); + } + + return _canonicalLinks; + } + set => _canonicalLinks = value; + } } /// @@ -105,6 +126,8 @@ public class SamlConfig [XmlRoot("PluginConfiguration")] public class OidConfig { + private SerializableDictionary _canonicalLinks; + /// /// Gets or sets the OpenID well-known information endpoint. /// @@ -176,6 +199,24 @@ public class OidConfig /// Gets or sets the default provider the user after logging in with SSO. /// public string DefaultProvider { get; set; } + + /// + /// Gets or sets a mapping of canonical names from the provider to jellyfin user ids. + /// + [XmlElement("CanonicalLinks")] + public SerializableDictionary CanonicalLinks + { + get + { + if (_canonicalLinks == null) + { + return new SerializableDictionary(); + } + + return _canonicalLinks; + } + set => _canonicalLinks = value; + } } /// diff --git a/SSO-Auth/WebResponse.cs b/SSO-Auth/WebResponse.cs index 10520a8..8d5611f 100644 --- a/SSO-Auth/WebResponse.cs +++ b/SSO-Auth/WebResponse.cs @@ -412,10 +412,46 @@ const sleep = (milliseconds) => { /// The name of the provider to callback to. /// The base URL of the Jellyfin installation. /// The mode of the function; SAML or OID. + /// Whether or not this request is to link accounts (Rather than authenticate). /// A string with the HTML to serve to the client. - public static string Generator(string data, string provider, string baseUrl, string mode) + public static string Generator(string data, string provider, string baseUrl, string mode, bool isLinking = false) { return Base + @" +async function link(request) { + const jfCredentialsString = localStorage.getItem(""jellyfin_credentials""); + + if (jfCredentialsString == null) return; + + const jfCredentials = JSON.parse(jfCredentialsString); + const jfUser = jfCredentials['Servers'][0]['UserId']; + const jfToken = jfCredentials['Servers'][0]['AccessToken']; + + if (jfUser == null) return; + if (jfToken == null) return; + + const url = '" + $"{baseUrl}/sso/{mode}/Link/{provider}/" + @"' + jfUser; + + return new Promise(resolve => { + var xhr = new XMLHttpRequest(); + xhr.open('POST', url, true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Accept', 'application/json'); + + xhr.setRequestHeader( + 'X-Emby-Authorization', + `MediaBrowser Client=""${request.appName}"",Device=""${request.deviceName}"",DeviceId=""${request.deviceId}"",Version=""${request.appVersion}"",Token=""${jfToken}""`) + + xhr.onload = function(e) { + resolve(xhr.response); + }; + xhr.onerror = function (e) { + console.log(e); + resolve(undefined); + }; + xhr.send(JSON.stringify(request)); + }) +} + async function main() { var data = '" + data + @"'; while (localStorage.getItem(""_deviceId2"") == null || @@ -431,6 +467,8 @@ async function main() { var request = {deviceId, appName, appVersion, deviceName, data}; + if (" + $"{isLinking}".ToLower() + @") await link(request); + var url = '" + baseUrl + "/sso/" + mode + "/Auth/" + provider + @"'; let response = await new Promise(resolve => {