287 lines
11 KiB
C#
287 lines
11 KiB
C#
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;
|
|
using MediaBrowser.Controller.Authentication;
|
|
using MediaBrowser.Controller.Library;
|
|
using MediaBrowser.Controller.Session;
|
|
using Microsoft.Extensions.Logging;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace Jellyfin.Plugin.Keycloak
|
|
{
|
|
/// <summary>
|
|
/// KeyCloak Authentication Provider Plugin.
|
|
/// </summary>
|
|
public class KeyCloakAuthenticationProviderPlugin : IAuthenticationProvider
|
|
{
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly ILogger<KeyCloakAuthenticationProviderPlugin> _logger;
|
|
private readonly IApplicationHost _applicationHost;
|
|
private string _twoFactorPattern = @"(.*)(\d{6})$";
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="KeyCloakAuthenticationProviderPlugin"/> class.
|
|
/// </summary>
|
|
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
|
/// <param name="applicationHost">Instance of the <see cref="IApplicationHost"/> interface.</param>
|
|
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
|
public KeyCloakAuthenticationProviderPlugin(
|
|
IHttpClientFactory httpClientFactory,
|
|
IApplicationHost applicationHost,
|
|
ILogger<KeyCloakAuthenticationProviderPlugin> logger)
|
|
{
|
|
_logger = logger;
|
|
_httpClientFactory = httpClientFactory;
|
|
_applicationHost = applicationHost;
|
|
}
|
|
|
|
private string TokenUri => $"{Cfg().AuthServerUrl}/realms/{Cfg().Realm}/protocol/openid-connect/token";
|
|
|
|
/// <inheritdoc />
|
|
public string Name => "Keycloak-Authentication";
|
|
|
|
/// <inheritdoc />
|
|
public bool IsEnabled => true;
|
|
|
|
private HttpClient GetHttpClient()
|
|
{
|
|
return _httpClientFactory.CreateClient(NamedClient.Default);
|
|
}
|
|
|
|
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<KeyValuePair<string, string?>>
|
|
{
|
|
new KeyValuePair<string, string?>("username", username),
|
|
new KeyValuePair<string, string?>("password", password),
|
|
new KeyValuePair<string, string?>("grant_type", "password"),
|
|
new KeyValuePair<string, string?>("client_id", Cfg().ClientId)
|
|
};
|
|
if (!string.IsNullOrWhiteSpace(Cfg().ClientSecret))
|
|
{
|
|
keyValues.Add(new KeyValuePair<string, string?>("client_secret", Cfg().ClientSecret));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(Cfg().OAuthScope))
|
|
{
|
|
keyValues.Add(new KeyValuePair<string, string?>("scope", Cfg().OAuthScope));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(totp))
|
|
{
|
|
keyValues.Add(new KeyValuePair<string, string?>("totp", totp));
|
|
}
|
|
|
|
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<KeycloakErrorResponse>(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<KeycloakTokenResponse>(responseStream).ConfigureAwait(false);
|
|
if (parsed == null)
|
|
{
|
|
_logger.LogError("Error parsing Keycloak token response");
|
|
return (null, false);
|
|
}
|
|
|
|
try
|
|
{
|
|
var jwtToken = JwtBuilder.Create().Decode<IDictionary<string, object>>(parsed.AccessToken);
|
|
|
|
Collection<string> perms = new Collection<string>();
|
|
var permsObj = GetJwtAttribute(jwtToken, Cfg().RolesTokenAttribute);
|
|
if (permsObj != null)
|
|
{
|
|
perms = ((JArray)permsObj).ToObject<Collection<string>>();
|
|
}
|
|
|
|
var usernameObj = GetJwtAttribute(jwtToken, Cfg().UsernameTokenAttribute);
|
|
if (usernameObj != null)
|
|
{
|
|
username = (string)usernameObj;
|
|
}
|
|
|
|
KeycloakUser user = new KeycloakUser(username, perms);
|
|
|
|
return (user, false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error parsing jwt token");
|
|
}
|
|
|
|
return (null, false);
|
|
}
|
|
|
|
private async Task UpdateUserInfo(KeycloakUser keycloakUser, User? jellyfinUser)
|
|
{
|
|
var userManager = _applicationHost.Resolve<IUserManager>();
|
|
if (jellyfinUser != null)
|
|
{
|
|
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<string>(Cfg().EnabledFolders);
|
|
enabledFolders.UnionWith(keycloakUser.EnabledFolders);
|
|
jellyfinUser.SetPreference(PreferenceKind.EnabledFolders, enabledFolders.ToArray());
|
|
|
|
await userManager.UpdateUserAsync(jellyfinUser).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private object? GetJwtAttribute(IDictionary<string, object> jwt, string tokenAttribute)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(tokenAttribute))
|
|
{
|
|
var parts = tokenAttribute.Split('.');
|
|
try
|
|
{
|
|
IDictionary<string, object> subObj = jwt;
|
|
for (int i = 0; i < parts.Length - 1; i++)
|
|
{
|
|
subObj = (IDictionary<string, object>)subObj[parts[i]];
|
|
}
|
|
|
|
return subObj[parts[^1]];
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Could not extract {TokenAttribute} from JWT", tokenAttribute);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ProviderAuthenticationResult> Authenticate(string username, string password)
|
|
{
|
|
_logger.LogInformation("Keycloak login: {Username}", username);
|
|
var userManager = _applicationHost.Resolve<IUserManager>();
|
|
string password_t = password;
|
|
string? totp = null;
|
|
if (Cfg().Enable2Fa)
|
|
{
|
|
var match = Regex.Match(password, _twoFactorPattern);
|
|
if (match.Success)
|
|
{
|
|
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(keycloakUser.Username);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogWarning("User Manager could not find a user for Keycloak User, this may not be fatal: {E}", e);
|
|
}
|
|
|
|
if (user == null)
|
|
{
|
|
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);
|
|
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);
|
|
throw new AuthenticationException(
|
|
$"Automatic User Creation is disabled and there is no Jellyfin user for authorized Uid: {username}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
await UpdateUserInfo(keycloakUser, user).ConfigureAwait(false);
|
|
}
|
|
|
|
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", username);
|
|
var sessionHandler = _applicationHost.Resolve<ISessionManager>();
|
|
await sessionHandler.RevokeUserTokens(user.Id, null).ConfigureAwait(false);
|
|
}
|
|
|
|
return new ProviderAuthenticationResult { Username = username };
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool HasPassword(User user)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task ChangePassword(User user, string newPassword)
|
|
{
|
|
throw new NotImplementedException();
|
|
}
|
|
}
|
|
}
|