From f3dc02ae325652afbd05775f7c02d6b756d99aff Mon Sep 17 00:00:00 2001 From: Ugrend <1233321+Ugrend@users.noreply.github.com> Date: Mon, 29 Mar 2021 19:03:53 +1100 Subject: [PATCH 1/9] Add 2FA support --- .../Configuration/PluginConfiguration.cs | 2 ++ .../Configuration/configPage.html | 10 +++++++++ .../Jellyfin.Plugin.Keycloak.csproj | 4 ++-- .../KeyCloakAuthenticationProviderPlugin.cs | 22 +++++++++++++++++-- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs index 12a92e3..a5b5c09 100644 --- a/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs @@ -6,6 +6,7 @@ namespace Jellyfin.Plugin.Keycloak.Configuration public class PluginConfiguration : BasePluginConfiguration { public bool CreateUser { get; set; } + public bool Enable2FA { get; set; } public string AuthServerUrl { get; set; } public string Realm { get; set; } public string Resource { get; set; } @@ -16,6 +17,7 @@ namespace Jellyfin.Plugin.Keycloak.Configuration { // set default options here CreateUser = true; + Enable2FA = false; AuthServerUrl = ""; Realm = ""; Resource = ""; diff --git a/Jellyfin.Plugin.Keycloak/Configuration/configPage.html b/Jellyfin.Plugin.Keycloak/Configuration/configPage.html index c23d4e7..d8185c0 100644 --- a/Jellyfin.Plugin.Keycloak/Configuration/configPage.html +++ b/Jellyfin.Plugin.Keycloak/Configuration/configPage.html @@ -9,6 +9,13 @@
+
+ +
BIGHACK: add _2FA=CODEHERE to end of password when loging in
+
- +
Keycloak Realm
- +
Keycloak Resource/Client
diff --git a/Jellyfin.Plugin.Keycloak/KeycloakAccessToken.cs b/Jellyfin.Plugin.Keycloak/KeycloakAccessToken.cs deleted file mode 100644 index dcc8b45..0000000 --- a/Jellyfin.Plugin.Keycloak/KeycloakAccessToken.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Collections.Generic; - -namespace Jellyfin.Plugin.Keycloak -{ - -} From b33f1118c421915edb59ce2014dbf7a5e96753c2 Mon Sep 17 00:00:00 2001 From: Buhbbl Date: Sat, 1 Jan 2022 08:07:42 +0100 Subject: [PATCH 4/9] Cleaned code & Added documentation --- .../Configuration/PluginConfiguration.cs | 68 +++++++++++++------ Jellyfin.Plugin.Keycloak/KeycloakUser.cs | 25 ++++++- Jellyfin.Plugin.Keycloak/Plugin.cs | 26 +++++-- 3 files changed, 90 insertions(+), 29 deletions(-) diff --git a/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs index a5b5c09..c8fe0ac 100644 --- a/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs @@ -1,27 +1,53 @@ using MediaBrowser.Model.Plugins; -namespace Jellyfin.Plugin.Keycloak.Configuration +namespace Jellyfin.Plugin.Keycloak.Configuration; + +/// +/// The main plugin. +/// +public class PluginConfiguration : BasePluginConfiguration { - - public class PluginConfiguration : BasePluginConfiguration + /// + /// Initializes a new instance of the class. + /// + public PluginConfiguration() { - public bool CreateUser { get; set; } - public bool Enable2FA { get; set; } - public string AuthServerUrl { get; set; } - public string Realm { get; set; } - public string Resource { get; set; } - public string ClientSecret { get; set; } - - - public PluginConfiguration() - { - // set default options here - CreateUser = true; - Enable2FA = false; - AuthServerUrl = ""; - Realm = ""; - Resource = ""; - ClientSecret = ""; - } + // set default options here + this.CreateUser = true; + this.Enable2Fa = false; + this.AuthServerUrl = string.Empty; + this.Realm = string.Empty; + this.Resource = string.Empty; + this.ClientSecret = string.Empty; } + + /// + /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// + public bool CreateUser { get; set; } + + /// + /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// + public bool Enable2Fa { get; set; } + + /// + /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// + public string AuthServerUrl { get; set; } + + /// + /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// + public string Realm { get; set; } + + /// + /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// + public string Resource { get; set; } + + /// + /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// + public string ClientSecret { get; set; } } diff --git a/Jellyfin.Plugin.Keycloak/KeycloakUser.cs b/Jellyfin.Plugin.Keycloak/KeycloakUser.cs index 65bf82d..e3d1d01 100644 --- a/Jellyfin.Plugin.Keycloak/KeycloakUser.cs +++ b/Jellyfin.Plugin.Keycloak/KeycloakUser.cs @@ -1,10 +1,31 @@ -using System.Collections.Generic; +using System.Collections.ObjectModel; +using Microsoft.VisualBasic; namespace Jellyfin.Plugin.Keycloak { + /// + /// User Model for Keycloak Users. + /// public class KeycloakUser { + /// + /// Initializes a new instance of the class. + /// + /// Instance of the username of the user. + public KeycloakUser(string username) + { + Username = username; + Permissions = new Collection(); + } + + /// + /// Gets or sets the value of the username of an user. + /// public string Username { get; set; } - public List Permissions { get; set; } + + /// + /// Gets the value of permissions of an user. + /// + public Collection Permissions { get; } } } diff --git a/Jellyfin.Plugin.Keycloak/Plugin.cs b/Jellyfin.Plugin.Keycloak/Plugin.cs index 6fb1811..cf529d0 100644 --- a/Jellyfin.Plugin.Keycloak/Plugin.cs +++ b/Jellyfin.Plugin.Keycloak/Plugin.cs @@ -8,19 +8,33 @@ using MediaBrowser.Model.Serialization; namespace Jellyfin.Plugin.Keycloak { + /// + /// Keycloak Plugin. + /// public class Plugin : BasePlugin, IHasWebPages { - public override string Name => "Keycloak-Auth"; - - public override Guid Id => Guid.Parse("40886866-b3dd-4d6a-bf9b-25c83e6c3d10"); - + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) { Instance = this; } - public static Plugin Instance { get; private set; } + /// + /// Gets the plugin instance. + /// + public static Plugin Instance { get; private set; } = null!; + /// + public override string Name => "Keycloak-Auth"; + + /// + public override Guid Id => Guid.Parse("40886866-b3dd-4d6a-bf9b-25c83e6c3d10"); + + /// public IEnumerable GetPages() { return new[] @@ -28,7 +42,7 @@ namespace Jellyfin.Plugin.Keycloak new PluginPageInfo { Name = this.Name, - EmbeddedResourcePath = string.Format("{0}.Configuration.configPage.html", GetType().Namespace) + EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html" } }; } From 1a5e64edf7e8fbee94fe252cadfa5f4ac6edeed9 Mon Sep 17 00:00:00 2001 From: Buhbbl Date: Sat, 1 Jan 2022 08:08:04 +0100 Subject: [PATCH 5/9] Added jellyfin default ruleset for stylecop --- jellyfin.ruleset | 118 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 jellyfin.ruleset diff --git a/jellyfin.ruleset b/jellyfin.ruleset new file mode 100644 index 0000000..e7bc7ec --- /dev/null +++ b/jellyfin.ruleset @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 061445801103b12548856f1954dfda1cfe4c8ef5 Mon Sep 17 00:00:00 2001 From: Buhbbl Date: Sat, 1 Jan 2022 08:08:45 +0100 Subject: [PATCH 6/9] Updated for jellyfin 10.8.0 & Added documentation --- .../Jellyfin.Plugin.Keycloak.csproj | 15 +- .../KeyCloakAuthenticationProviderPlugin.cs | 189 +++++++++++------- .../KeycloakTokenResponse.cs | 53 ++++- 3 files changed, 176 insertions(+), 81 deletions(-) diff --git a/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj b/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj index 3cd0138..a2b9965 100644 --- a/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj +++ b/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj @@ -1,9 +1,15 @@ - net5.0 + net6.0 1.1.0.0 1.1.0.0 + true + true + enable + AllEnabledByDefault + ../jellyfin.ruleset + CA1819 @@ -16,6 +22,13 @@ + + + + + + + diff --git a/Jellyfin.Plugin.Keycloak/KeyCloakAuthenticationProviderPlugin.cs b/Jellyfin.Plugin.Keycloak/KeyCloakAuthenticationProviderPlugin.cs index b32ec71..2b848c7 100644 --- a/Jellyfin.Plugin.Keycloak/KeyCloakAuthenticationProviderPlugin.cs +++ b/Jellyfin.Plugin.Keycloak/KeyCloakAuthenticationProviderPlugin.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Net.Http; using System.Text.Json; using System.Text.RegularExpressions; @@ -17,67 +18,105 @@ using Newtonsoft.Json.Linq; namespace Jellyfin.Plugin.Keycloak { + /// + /// KeyCloak Authentication Provider Plugin. + /// public class KeyCloakAuthenticationProviderPlugin : IAuthenticationProvider { private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly IApplicationHost _applicationHost; - private IUserManager _userManager; - private String TwoFactorPattern = @"(.*)_2FA=(.*)$"; + private string _twoFactorPattern = @"(.*)_2FA=(.*)$"; - private bool CreateUser => Plugin.Instance.Configuration.CreateUser; - private String AuthServerUrl => Plugin.Instance.Configuration.AuthServerUrl; - private String Realm => Plugin.Instance.Configuration.Realm; - private String Resource => Plugin.Instance.Configuration.Resource; - private String ClientSecret => Plugin.Instance.Configuration.ClientSecret; - private bool Enable2FA => Plugin.Instance.Configuration.Enable2FA; + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public KeyCloakAuthenticationProviderPlugin( + IHttpClientFactory httpClientFactory, + IApplicationHost applicationHost, + ILogger logger) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _applicationHost = applicationHost; + } + + private static bool CreateUser => Plugin.Instance.Configuration.CreateUser; + + private static string AuthServerUrl => Plugin.Instance.Configuration.AuthServerUrl; + + private static string Realm => Plugin.Instance.Configuration.Realm; + + private static string Resource => Plugin.Instance.Configuration.Resource; + + private static string ClientSecret => Plugin.Instance.Configuration.ClientSecret; + + private static bool Enable2Fa => Plugin.Instance.Configuration.Enable2Fa; + + private string TokenUri => $"{AuthServerUrl}/realms/{Realm}/protocol/openid-connect/token"; + + /// + public string Name => "Keycloak-Authentication"; + + /// + public bool IsEnabled => true; private HttpClient GetHttpClient() { return _httpClientFactory.CreateClient(NamedClient.Default); } - private String TokenURI => $"{AuthServerUrl}/realms/{Realm}/protocol/openid-connect/token"; - - private async Task GetKeycloakUser(string username, string password, string totp) + private async Task GetKeycloakUser(string username, string password, string? totp) { var httpClient = GetHttpClient(); - var keyValues = new List>(); - keyValues.Add( new KeyValuePair("username", username)); - keyValues.Add( new KeyValuePair("password", password)); - keyValues.Add( new KeyValuePair("grant_type", "password")); - keyValues.Add( new KeyValuePair("client_id", Resource)); - if (!String.IsNullOrWhiteSpace(ClientSecret)) + var keyValues = new List>(); + keyValues.Add(new KeyValuePair("username", username)); + keyValues.Add(new KeyValuePair("password", password)); + keyValues.Add(new KeyValuePair("grant_type", "password")); + keyValues.Add(new KeyValuePair("client_id", Resource)); + if (!string.IsNullOrWhiteSpace(ClientSecret)) { - keyValues.Add(new KeyValuePair("client_secret", ClientSecret)); + keyValues.Add(new KeyValuePair("client_secret", ClientSecret)); } - if (!String.IsNullOrWhiteSpace(totp)) + if (!string.IsNullOrWhiteSpace(totp)) { - keyValues.Add(new KeyValuePair("totp", totp)); + keyValues.Add(new KeyValuePair("totp", totp)); } var content = new FormUrlEncodedContent(keyValues); - var response = await httpClient.PostAsync(TokenURI, content).ConfigureAwait(false); + var response = await httpClient.PostAsync(TokenUri, content).ConfigureAwait(false); var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var parsed = await JsonSerializer.DeserializeAsync(responseStream).ConfigureAwait(false); + KeycloakTokenResponse? parsed = await JsonSerializer.DeserializeAsync(responseStream).ConfigureAwait(false); if (parsed == null) + { return null; + } + try { - var jwtToken = JwtBuilder.Create().Decode>(parsed.access_token); - List perms = new List(); + var jwtToken = JwtBuilder.Create().Decode>(parsed.AccessToken); + Collection perms = new Collection(); try { var resourceAccess = (JObject)jwtToken["resource_access"]; - perms = ((JArray)(((JObject)resourceAccess[Resource])["roles"])).ToObject>(); + perms = ((JArray)((JObject)resourceAccess[Resource])["roles"]).ToObject>(); } catch (Exception ex) { - _logger.LogError(ex, $"Could not parse permissions for resource {Resource}"); + _logger.LogError(ex, "Could not parse permissions for resource: {Resource}", Resource); } - return new KeycloakUser {Username = username, Permissions = perms}; + KeycloakUser user = new KeycloakUser(username); + foreach (var perm in perms) + { + user.Permissions.Add(perm); + } + + return user; } catch (Exception ex) { @@ -87,51 +126,60 @@ namespace Jellyfin.Plugin.Keycloak return null; } - private async Task UpdateUserInfo(KeycloakUser keycloakUser, User jellyfinUser) + private async Task UpdateUserInfo(KeycloakUser? keycloakUser, User? jellyfinUser) { - jellyfinUser.SetPermission(PermissionKind.IsDisabled, true); - jellyfinUser.SetPermission(PermissionKind.IsAdministrator, false); - jellyfinUser.SetPermission(PermissionKind.EnableContentDownloading, false); - foreach (string permission in keycloakUser.Permissions) + var userManager = _applicationHost.Resolve(); + if (jellyfinUser != null) { - switch (permission) + jellyfinUser.SetPermission(PermissionKind.IsDisabled, true); + jellyfinUser.SetPermission(PermissionKind.IsAdministrator, false); + jellyfinUser.SetPermission(PermissionKind.EnableContentDownloading, false); + if (keycloakUser != null) { - case "administrator": - jellyfinUser.SetPermission(PermissionKind.IsAdministrator, true); - break; - case "allowed_access": - jellyfinUser.SetPermission(PermissionKind.IsDisabled, false); - break; + foreach (string permission in keycloakUser.Permissions) + { + switch (permission) + { + case "administrator": + jellyfinUser.SetPermission(PermissionKind.IsAdministrator, true); + break; + case "allowed_access": + jellyfinUser.SetPermission(PermissionKind.IsDisabled, false); + break; + } + } } + + await userManager.UpdateUserAsync(jellyfinUser).ConfigureAwait(false); } - await _userManager.UpdateUserAsync(jellyfinUser).ConfigureAwait(false); - return jellyfinUser; } + /// public async Task Authenticate(string username, string password) { - _userManager ??= _applicationHost.Resolve(); - string totp = null; - if (Enable2FA) + var userManager = _applicationHost.Resolve(); + string? totp = null; + if (Enable2Fa) { - var match = Regex.Match(password, TwoFactorPattern); + var match = Regex.Match(password, _twoFactorPattern); if (match.Success) { password = match.Groups[1].Value; totp = match.Groups[2].Value; } } - User user = null; + + User? user = null; try { - user = _userManager.GetUserByName(username); + user = userManager.GetUserByName(username); } catch (Exception e) { - _logger.LogWarning("User Manager could not find a user for Keycloak User, this may not be fatal", e); + _logger.LogWarning("User Manager could not find a user for Keycloak User, this may not be fatal: {E}", e); } - KeycloakUser keycloakUser = await GetKeycloakUser(username, password, totp); + KeycloakUser? keycloakUser = await GetKeycloakUser(username, password, totp).ConfigureAwait(false); if (keycloakUser == null) { throw new AuthenticationException("Error completing Keycloak login. Invalid username or password."); @@ -141,52 +189,49 @@ namespace Jellyfin.Plugin.Keycloak { if (CreateUser) { - _logger.LogInformation($"Creating user {username}"); - user = await _userManager.CreateUserAsync(username).ConfigureAwait(false); - user.AuthenticationProviderId = GetType().FullName; - await UpdateUserInfo(keycloakUser, user); + _logger.LogInformation("Creating user: {Username}", username); + user = await userManager.CreateUserAsync(username).ConfigureAwait(false); + var userAuthenticationProviderId = GetType().FullName; + if (userAuthenticationProviderId != null) + { + user.AuthenticationProviderId = userAuthenticationProviderId; + } + + await UpdateUserInfo(keycloakUser, user).ConfigureAwait(false); } else { - _logger.LogError("Keycloak User not configured for Jellyfin: {username}", username); + _logger.LogError("Keycloak User not configured for Jellyfin: {Username}", username); throw new AuthenticationException( $"Automatic User Creation is disabled and there is no Jellyfin user for authorized Uid: {username}"); } - } else { - await UpdateUserInfo(keycloakUser, user); + await UpdateUserInfo(keycloakUser, user).ConfigureAwait(false); } - if (user.HasPermission(PermissionKind.IsDisabled)) + + if (user != null && user.HasPermission(PermissionKind.IsDisabled)) { // If the user no longer has permission to access revoke all sessions for this user - _logger.LogInformation($"{username} is disabled, revoking all sessions"); + _logger.LogInformation("{Username} is disabled, revoking all sessions", username); var sessionHandler = _applicationHost.Resolve(); - sessionHandler.RevokeUserTokens(user.Id, null); + await sessionHandler.RevokeUserTokens(user.Id, null).ConfigureAwait(false); } - return new ProviderAuthenticationResult { Username = username}; + + return new ProviderAuthenticationResult { Username = username }; } + /// public bool HasPassword(User user) { return true; } + /// public Task ChangePassword(User user, string newPassword) { - throw new System.NotImplementedException(); + throw new NotImplementedException(); } - public KeyCloakAuthenticationProviderPlugin(IHttpClientFactory httpClientFactory, - IApplicationHost applicationHost, - ILogger logger) - { - _logger = logger; - _httpClientFactory = httpClientFactory; - _applicationHost = applicationHost; - } - - public string Name => "Keycloak-Authentication"; - public bool IsEnabled => true; } } diff --git a/Jellyfin.Plugin.Keycloak/KeycloakTokenResponse.cs b/Jellyfin.Plugin.Keycloak/KeycloakTokenResponse.cs index 89e586b..63fcd82 100644 --- a/Jellyfin.Plugin.Keycloak/KeycloakTokenResponse.cs +++ b/Jellyfin.Plugin.Keycloak/KeycloakTokenResponse.cs @@ -1,17 +1,54 @@ using System.Runtime.Serialization; +using System.Text.Json.Serialization; namespace Jellyfin.Plugin.Keycloak { + /// + /// Response model for the keycloak token. + /// [DataContract] public class KeycloakTokenResponse { - public string access_token { get; set; } - public int expires_in { get; set; } - [DataMember(Name = "not-before-policy")] - public int not_before_policy { get; set; } - public string refresh_token { get; set; } - public string scope { get; set; } - public string session_state { get; set; } - public string token_type { get; set; } + /// + /// Gets or sets the access token instance. + /// + [JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + + /// + /// Gets or sets the expiry time instance. + /// + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + /// + /// Gets or sets the not-before-policy instance. + /// + [JsonPropertyName("not-before-policy")] + public int NotBeforePolicy { get; set; } + + /// + /// Gets or sets the refresh token instance. + /// + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + + /// + /// Gets or sets the scope instance. + /// + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + /// + /// Gets or sets the session state instance. + /// + [JsonPropertyName("session_state")] + public string? SessionState { get; set; } + + /// + /// Gets or sets the token type instance. + /// + [JsonPropertyName("token_type")] + public string? TokenType { get; set; } } } From ff9427b78c6a1a03eb9995683e3f3f177fcff8f2 Mon Sep 17 00:00:00 2001 From: Buhbbl Date: Sat, 1 Jan 2022 08:16:14 +0100 Subject: [PATCH 7/9] Updated plugin version to 2.0.0.0 --- Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj b/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj index a2b9965..e8d3a14 100644 --- a/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj +++ b/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj @@ -2,8 +2,8 @@ net6.0 - 1.1.0.0 - 1.1.0.0 + 2.0.0.0 + 2.0.0.0 true true enable From a931bc41469b890b9fc69c10f2718204ae8e6a81 Mon Sep 17 00:00:00 2001 From: Buhbbl Date: Sat, 1 Jan 2022 08:34:57 +0100 Subject: [PATCH 8/9] Updated github workflows --- .github/workflows/build-dotnet.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-dotnet.yml b/.github/workflows/build-dotnet.yml index 1395b38..b21c5b6 100644 --- a/.github/workflows/build-dotnet.yml +++ b/.github/workflows/build-dotnet.yml @@ -15,7 +15,7 @@ jobs: - name: "Setup .NET Core" uses: actions/setup-dotnet@v1 with: - dotnet-version: "5.0.x" + dotnet-version: "6.0.x" - name: "Install dependencies" run: dotnet restore diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c6ee551..960af96 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 5.0.x + dotnet-version: 6.0.x - name: Initialize CodeQL uses: github/codeql-action/init@v1 From c33c61d69e95e3c5fa87e29f6d594d52f7b69a6d Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Mon, 31 Mar 2025 00:08:54 +0200 Subject: [PATCH 9/9] add improvements --- .github/workflows/build-dotnet.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .../Configuration/PluginConfiguration.cs | 59 +++++- .../Configuration/configPage.html | 138 ++++++++++++-- .../Jellyfin.Plugin.Keycloak.csproj | 6 +- .../KeyCloakAuthenticationProviderPlugin.cs | 178 +++++++++++------- .../KeycloakErrorResponse.cs | 24 +++ Jellyfin.Plugin.Keycloak/KeycloakUser.cs | 55 +++++- .../ServiceRegistrator.cs | 18 ++ 9 files changed, 378 insertions(+), 104 deletions(-) create mode 100644 Jellyfin.Plugin.Keycloak/KeycloakErrorResponse.cs create mode 100644 Jellyfin.Plugin.Keycloak/ServiceRegistrator.cs diff --git a/.github/workflows/build-dotnet.yml b/.github/workflows/build-dotnet.yml index b21c5b6..ba99431 100644 --- a/.github/workflows/build-dotnet.yml +++ b/.github/workflows/build-dotnet.yml @@ -15,7 +15,7 @@ jobs: - name: "Setup .NET Core" uses: actions/setup-dotnet@v1 with: - dotnet-version: "6.0.x" + dotnet-version: "8.0.x" - name: "Install dependencies" run: dotnet restore diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 960af96..6696a94 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Initialize CodeQL uses: github/codeql-action/init@v1 diff --git a/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs index c8fe0ac..bc018d6 100644 --- a/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.Keycloak/Configuration/PluginConfiguration.cs @@ -13,41 +13,82 @@ public class PluginConfiguration : BasePluginConfiguration public PluginConfiguration() { // set default options here + this.Enabled = false; this.CreateUser = true; this.Enable2Fa = false; this.AuthServerUrl = string.Empty; - this.Realm = string.Empty; - this.Resource = string.Empty; + this.Realm = "master"; + this.ClientId = string.Empty; this.ClientSecret = string.Empty; + this.OAuthScope = string.Empty; + this.RolesTokenAttribute = string.Empty; + this.UsernameTokenAttribute = "preferred_username"; + this.EnableAllFolders = false; + this.EnabledFolders = System.Array.Empty(); } /// - /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// Gets or sets a value indicating whether Keycloak authentication is enabled. + /// + public bool Enabled { get; set; } + + /// + /// Gets or sets a value indicating whether creation of new users that dont exist in Jellyfin is enabled. /// public bool CreateUser { get; set; } /// - /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// Gets or sets a value indicating whether 2-factor authentication (Password+TOTP). /// public bool Enable2Fa { get; set; } /// - /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// Gets or sets Keycloak server URL. /// public string AuthServerUrl { get; set; } /// - /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// Gets or sets Keycloak server realm. /// public string Realm { get; set; } /// - /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// Gets or sets Keycloak client ID. /// - public string Resource { get; set; } + public string ClientId { get; set; } /// - /// Gets or sets a value indicating whether an user from keycloak exists Jellyfin. + /// Gets or sets Keycloak client secret. /// public string ClientSecret { get; set; } + + /// + /// Gets or sets Keycloak OAuth scope. + /// + public string OAuthScope { get; set; } + + /// + /// Gets or sets Keycloak username token attribute. + /// + public string UsernameTokenAttribute { get; set; } + + /// + /// Gets or sets Keycloak roles token attribute. + /// + public string RolesTokenAttribute { get; set; } + + /// + /// Gets or sets a value indicating whether users without a role are allowed to log in. + /// + public bool AllowUsersWithoutRole { get; set; } + + /// + /// Gets or sets a value indicating whether to enable access to all library folders. + /// + public bool EnableAllFolders { get; set; } + + /// + /// Gets or sets a list of folder Ids which are enabled for access by default. + /// + public string[] EnabledFolders { get; set; } } diff --git a/Jellyfin.Plugin.Keycloak/Configuration/configPage.html b/Jellyfin.Plugin.Keycloak/Configuration/configPage.html index c36f007..c11d588 100644 --- a/Jellyfin.Plugin.Keycloak/Configuration/configPage.html +++ b/Jellyfin.Plugin.Keycloak/Configuration/configPage.html @@ -8,40 +8,75 @@
+

Keycloak Authentication

-
BIGHACK: add _2FA=CODEHERE to end of password when loging in
+
+ +
You need to add the TOTP (6 digits) to the password when logging in
+
- + -
Base Keycloak auth URI
- + -
Keycloak Realm
- - -
Keycloak Resource/Client
+ +
-
Client Secret
+
+ + +
+
+ + +
Access token attribute with the list of roles. Seperate keys with a '.' if the role list is part of a nested object.
+
+
+ + +
Access token attribute with the username
+
+
+ +
+ + +
+
+
Enable access to certain libraries by default
+
Add library access to certain users by giving them the 'lib-<ID>' role
+

+
+
diff --git a/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj b/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj index e8d3a14..7204b0d 100644 --- a/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj +++ b/Jellyfin.Plugin.Keycloak/Jellyfin.Plugin.Keycloak.csproj @@ -1,9 +1,9 @@ - net6.0 - 2.0.0.0 - 2.0.0.0 + net8.0 + 2.0.0.1 + 2.0.0.1 true true enable diff --git a/Jellyfin.Plugin.Keycloak/KeyCloakAuthenticationProviderPlugin.cs b/Jellyfin.Plugin.Keycloak/KeyCloakAuthenticationProviderPlugin.cs index 2b848c7..8e2cc7a 100644 --- a/Jellyfin.Plugin.Keycloak/KeyCloakAuthenticationProviderPlugin.cs +++ b/Jellyfin.Plugin.Keycloak/KeyCloakAuthenticationProviderPlugin.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using System.Net.Http; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using Jellyfin.Plugin.Keycloak.Configuration; using JWT.Builder; using MediaBrowser.Common; using MediaBrowser.Common.Net; @@ -26,7 +28,7 @@ namespace Jellyfin.Plugin.Keycloak private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly IApplicationHost _applicationHost; - private string _twoFactorPattern = @"(.*)_2FA=(.*)$"; + private string _twoFactorPattern = @"(.*)(\d{6})$"; /// /// Initializes a new instance of the class. @@ -44,19 +46,7 @@ namespace Jellyfin.Plugin.Keycloak _applicationHost = applicationHost; } - private static bool CreateUser => Plugin.Instance.Configuration.CreateUser; - - private static string AuthServerUrl => Plugin.Instance.Configuration.AuthServerUrl; - - private static string Realm => Plugin.Instance.Configuration.Realm; - - private static string Resource => Plugin.Instance.Configuration.Resource; - - private static string ClientSecret => Plugin.Instance.Configuration.ClientSecret; - - private static bool Enable2Fa => Plugin.Instance.Configuration.Enable2Fa; - - private string TokenUri => $"{AuthServerUrl}/realms/{Realm}/protocol/openid-connect/token"; + private string TokenUri => $"{Cfg().AuthServerUrl}/realms/{Cfg().Realm}/protocol/openid-connect/token"; /// public string Name => "Keycloak-Authentication"; @@ -69,17 +59,29 @@ namespace Jellyfin.Plugin.Keycloak return _httpClientFactory.CreateClient(NamedClient.Default); } - private async Task GetKeycloakUser(string username, string password, string? totp) + private PluginConfiguration Cfg() + { + return Plugin.Instance.Configuration; + } + + private async Task<(KeycloakUser?, bool)> GetKeycloakUser(string username, string password, string? totp) { var httpClient = GetHttpClient(); - var keyValues = new List>(); - keyValues.Add(new KeyValuePair("username", username)); - keyValues.Add(new KeyValuePair("password", password)); - keyValues.Add(new KeyValuePair("grant_type", "password")); - keyValues.Add(new KeyValuePair("client_id", Resource)); - if (!string.IsNullOrWhiteSpace(ClientSecret)) + var keyValues = new List> { - keyValues.Add(new KeyValuePair("client_secret", ClientSecret)); + new KeyValuePair("username", username), + new KeyValuePair("password", password), + new KeyValuePair("grant_type", "password"), + new KeyValuePair("client_id", Cfg().ClientId) + }; + if (!string.IsNullOrWhiteSpace(Cfg().ClientSecret)) + { + keyValues.Add(new KeyValuePair("client_secret", Cfg().ClientSecret)); + } + + if (!string.IsNullOrWhiteSpace(Cfg().OAuthScope)) + { + keyValues.Add(new KeyValuePair("scope", Cfg().OAuthScope)); } if (!string.IsNullOrWhiteSpace(totp)) @@ -90,104 +92,152 @@ namespace Jellyfin.Plugin.Keycloak var content = new FormUrlEncodedContent(keyValues); var response = await httpClient.PostAsync(TokenUri, content).ConfigureAwait(false); var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + KeycloakErrorResponse? err = await JsonSerializer.DeserializeAsync(responseStream).ConfigureAwait(false); + if (err == null) + { + return (null, false); + } + else if (err.Error == "invalid_grant") + { + return (null, true); + } + else + { + _logger.LogError("Keycloak error {0}: {1}", err.Error, err.ErrorDescription); + return (null, err.Error == "invalid_grant"); + } + } + KeycloakTokenResponse? parsed = await JsonSerializer.DeserializeAsync(responseStream).ConfigureAwait(false); if (parsed == null) { - return null; + _logger.LogError("Error parsing Keycloak token response"); + return (null, false); } try { var jwtToken = JwtBuilder.Create().Decode>(parsed.AccessToken); + Collection perms = new Collection(); - try + var permsObj = GetJwtAttribute(jwtToken, Cfg().RolesTokenAttribute); + if (permsObj != null) { - var resourceAccess = (JObject)jwtToken["resource_access"]; - perms = ((JArray)((JObject)resourceAccess[Resource])["roles"]).ToObject>(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not parse permissions for resource: {Resource}", Resource); + perms = ((JArray)permsObj).ToObject>(); } - KeycloakUser user = new KeycloakUser(username); - foreach (var perm in perms) + var usernameObj = GetJwtAttribute(jwtToken, Cfg().UsernameTokenAttribute); + if (usernameObj != null) { - user.Permissions.Add(perm); + username = (string)usernameObj; } - return user; + KeycloakUser user = new KeycloakUser(username, perms); + + return (user, false); } catch (Exception ex) { _logger.LogError(ex, "Error parsing jwt token"); } - return null; + return (null, false); } - private async Task UpdateUserInfo(KeycloakUser? keycloakUser, User? jellyfinUser) + private async Task UpdateUserInfo(KeycloakUser keycloakUser, User? jellyfinUser) { var userManager = _applicationHost.Resolve(); if (jellyfinUser != null) { - jellyfinUser.SetPermission(PermissionKind.IsDisabled, true); - jellyfinUser.SetPermission(PermissionKind.IsAdministrator, false); - jellyfinUser.SetPermission(PermissionKind.EnableContentDownloading, false); - if (keycloakUser != null) - { - foreach (string permission in keycloakUser.Permissions) - { - switch (permission) - { - case "administrator": - jellyfinUser.SetPermission(PermissionKind.IsAdministrator, true); - break; - case "allowed_access": - jellyfinUser.SetPermission(PermissionKind.IsDisabled, false); - break; - } - } - } + jellyfinUser.SetPermission(PermissionKind.IsDisabled, !keycloakUser.IsEnabled); + jellyfinUser.SetPermission(PermissionKind.IsAdministrator, keycloakUser.IsAdmin); + jellyfinUser.SetPermission(PermissionKind.EnableCollectionManagement, keycloakUser.IsEditor); + jellyfinUser.SetPermission(PermissionKind.EnableSubtitleManagement, keycloakUser.IsEditor); + jellyfinUser.SetPermission(PermissionKind.EnableAllFolders, Cfg().EnableAllFolders || keycloakUser.IsAdmin); + + var enabledFolders = new HashSet(Cfg().EnabledFolders); + enabledFolders.UnionWith(keycloakUser.EnabledFolders); + jellyfinUser.SetPreference(PreferenceKind.EnabledFolders, enabledFolders.ToArray()); await userManager.UpdateUserAsync(jellyfinUser).ConfigureAwait(false); } } + private object? GetJwtAttribute(IDictionary jwt, string tokenAttribute) + { + if (!string.IsNullOrWhiteSpace(tokenAttribute)) + { + var parts = tokenAttribute.Split('.'); + try + { + IDictionary subObj = jwt; + for (int i = 0; i < parts.Length - 1; i++) + { + subObj = (IDictionary)subObj[parts[i]]; + } + + return subObj[parts[^1]]; + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not extract {TokenAttribute} from JWT", tokenAttribute); + } + } + + return null; + } + /// public async Task Authenticate(string username, string password) { + _logger.LogInformation("Keycloak login: {Username}", username); var userManager = _applicationHost.Resolve(); + string password_t = password; string? totp = null; - if (Enable2Fa) + if (Cfg().Enable2Fa) { var match = Regex.Match(password, _twoFactorPattern); if (match.Success) { - password = match.Groups[1].Value; + password_t = match.Groups[1].Value; totp = match.Groups[2].Value; } } + (KeycloakUser? keycloakUser, bool retryNoOtp) = await GetKeycloakUser(username, password_t, totp).ConfigureAwait(false); + if (keycloakUser == null && totp != null && retryNoOtp) + { + (keycloakUser, _) = await GetKeycloakUser(username, password, null).ConfigureAwait(false); + } + + if (keycloakUser == null) + { + throw new AuthenticationException("Error completing Keycloak login. Invalid username or password."); + } + + keycloakUser.IsEnabled |= Cfg().AllowUsersWithoutRole; + User? user = null; try { - user = userManager.GetUserByName(username); + user = userManager.GetUserByName(keycloakUser.Username); } catch (Exception e) { _logger.LogWarning("User Manager could not find a user for Keycloak User, this may not be fatal: {E}", e); } - KeycloakUser? keycloakUser = await GetKeycloakUser(username, password, totp).ConfigureAwait(false); - if (keycloakUser == null) - { - throw new AuthenticationException("Error completing Keycloak login. Invalid username or password."); - } - if (user == null) { - if (CreateUser) + if (!keycloakUser.IsEnabled) + { + _logger.LogError("Keycloak User not allowed to use Jellyfin: {Username}", username); + throw new AuthenticationException("User is not allowed to use Jellyfin"); + } + else if (Cfg().CreateUser) { _logger.LogInformation("Creating user: {Username}", username); user = await userManager.CreateUserAsync(username).ConfigureAwait(false); diff --git a/Jellyfin.Plugin.Keycloak/KeycloakErrorResponse.cs b/Jellyfin.Plugin.Keycloak/KeycloakErrorResponse.cs new file mode 100644 index 0000000..b810ff1 --- /dev/null +++ b/Jellyfin.Plugin.Keycloak/KeycloakErrorResponse.cs @@ -0,0 +1,24 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.Keycloak +{ + /// + /// Response model for Keycloak errors. + /// + [DataContract] + public class KeycloakErrorResponse + { + /// + /// Gets or sets error code. + /// + [JsonPropertyName("error")] + public string? Error { get; set; } + + /// + /// Gets or sets error description. + /// + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; set; } + } +} diff --git a/Jellyfin.Plugin.Keycloak/KeycloakUser.cs b/Jellyfin.Plugin.Keycloak/KeycloakUser.cs index e3d1d01..0c7b578 100644 --- a/Jellyfin.Plugin.Keycloak/KeycloakUser.cs +++ b/Jellyfin.Plugin.Keycloak/KeycloakUser.cs @@ -1,5 +1,5 @@ +using System.Collections.Generic; using System.Collections.ObjectModel; -using Microsoft.VisualBasic; namespace Jellyfin.Plugin.Keycloak { @@ -12,20 +12,61 @@ namespace Jellyfin.Plugin.Keycloak /// Initializes a new instance of the class. /// /// Instance of the username of the user. - public KeycloakUser(string username) + /// User roles. + public KeycloakUser(string username, Collection roles) { Username = username; - Permissions = new Collection(); + Roles = roles; + + IsAdmin = false; + IsEditor = false; + IsEnabled = false; + var enabledFolders = new List(); + + foreach (string permission in Roles) + { + IsAdmin |= permission == "admin"; + IsEditor |= permission == "editor"; + IsEnabled |= permission == "user"; + + if (permission.StartsWith("lib-", System.StringComparison.Ordinal)) + { + enabledFolders.Add(permission[4..]); + } + } + + IsEnabled |= IsAdmin || IsEditor; + EnabledFolders = enabledFolders.ToArray(); } /// - /// Gets or sets the value of the username of an user. + /// Gets the value of the username of an user. /// - public string Username { get; set; } + public string Username { get; } /// - /// Gets the value of permissions of an user. + /// Gets the value of roles of an user. /// - public Collection Permissions { get; } + public Collection Roles { get; } + + /// + /// Gets a list of the enabled media libraries of an user. + /// + public string[] EnabledFolders { get; } + + /// + /// Gets a value indicating whether the user is an administrator. + /// + public bool IsAdmin { get; } + + /// + /// Gets a value indicating whether the user is an editor. + /// + public bool IsEditor { get; } + + /// + /// Gets or sets a value indicating whether the user should be enabled. + /// + public bool IsEnabled { get; set; } } } diff --git a/Jellyfin.Plugin.Keycloak/ServiceRegistrator.cs b/Jellyfin.Plugin.Keycloak/ServiceRegistrator.cs new file mode 100644 index 0000000..50afcff --- /dev/null +++ b/Jellyfin.Plugin.Keycloak/ServiceRegistrator.cs @@ -0,0 +1,18 @@ +using MediaBrowser.Controller; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.Keycloak; + +/// +/// Register LDAP services. +/// +public class ServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + serviceCollection.AddSingleton(); + } +}