mirror of
https://github.com/9p4/jellyfin-plugin-sso.git
synced 2026-09-19 13:12:19 +00:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1e157bac2 | ||
|
|
83deeb253c | ||
|
|
ca187a7b5e | ||
|
|
05213dc1ee | ||
|
|
f7a0c94452 | ||
|
|
475048a32e | ||
|
|
781e07b2ce | ||
|
|
fed7e764a8 | ||
|
|
f0def6fd8d | ||
|
|
8e128932c4 | ||
|
|
5b3d70d328 | ||
|
|
be26670e1f | ||
|
|
547eabf55f | ||
|
|
48d75325b5 | ||
|
|
cdce0e583d | ||
|
|
0e897f922f | ||
|
|
afbab1073e | ||
|
|
87425aae36 | ||
|
|
9be9a1fed8 | ||
|
|
3b47851131 | ||
|
|
337ea0ba04 | ||
|
|
c37e3e3a71 | ||
|
|
8f86ce5101 | ||
|
|
d2c77db404 | ||
|
|
fc3976dcc8 | ||
|
|
fc58b8e80f | ||
|
|
da21ebffa3 | ||
|
|
0d18ebae03 | ||
|
|
77e2a229f2 | ||
|
|
71bb9a2f14 | ||
|
|
8baa922eea | ||
|
|
0738ad85f7 | ||
|
|
8d049705c4 | ||
|
|
3459baffdf | ||
|
|
de6cd5d5c2 | ||
|
|
299d3436ec | ||
|
|
5808f34064 | ||
|
|
5ed4c8bbcc | ||
|
|
f78a0f3108 | ||
|
|
aa0e361265 | ||
|
|
efc997c39e | ||
|
|
caab66b653 | ||
|
|
79ac628c4c | ||
|
|
fe3ca6337d | ||
|
|
f761dea356 | ||
|
|
55f58ecedd | ||
|
|
556feba864 | ||
|
|
1f08ccd213 | ||
|
|
f1f57723c3 |
@@ -3,12 +3,12 @@ on:
|
||||
inputs:
|
||||
dotnet-version:
|
||||
required: false
|
||||
default: "8.0.x"
|
||||
default: "9.0.x"
|
||||
description: "The .NET version to setup for the build"
|
||||
type: string
|
||||
dotnet-target:
|
||||
required: false
|
||||
default: "net8.0"
|
||||
default: "net9.0"
|
||||
description: "The .NET target to set for JPRM"
|
||||
type: string
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
dotnet-target: "${{ inputs.dotnet-target }}"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # tag=v4.6.2
|
||||
with:
|
||||
name: build-artifact
|
||||
retention-days: 30
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 9.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 9.0.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
- name: Build Dotnet
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
version: "0.0.0.9000"
|
||||
verbosity: debug
|
||||
path: .
|
||||
dotnet-target: "net8.0"
|
||||
dotnet-target: "net9.0"
|
||||
output: _dist
|
||||
- name: Prepare GitHub Release assets
|
||||
run: |-
|
||||
|
||||
@@ -10,15 +10,15 @@ jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
dotnet-version: "8.0.*"
|
||||
dotnet-target: "net8.0"
|
||||
dotnet-version: "9.0.*"
|
||||
dotnet-target: "net9.0"
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v2.1.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: build-artifact
|
||||
- name: Prepare GitHub Release assets
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Project archived because I'm tired of working on this after all the years.
|
||||
|
||||
This plugin allows users to sign in through an SSO provider (such as Google, Microsoft, or your own provider). This enables one-click signin.
|
||||
|
||||
https://user-images.githubusercontent.com/17993169/149681516-f93b43f5-fa5c-4c1f-a909-e5414878a864.mp4
|
||||
@@ -34,7 +36,7 @@ This is 100% alpha software! PRs are welcome to improve the code.
|
||||
|
||||
~~There is NO admin configuration! You must use the API to configure the program!~~ Added by [strazto](https://github.com/strazto) in PR [#18](https://github.com/9p4/jellyfin-plugin-sso/pull/18) and [#27](https://github.com/9p4/jellyfin-plugin-sso/pull/27).
|
||||
|
||||
**[This is for Jellyfin 10.8](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI and clients supporting [Quick Connect](https://jellyfin.org/docs/general/server/quick-connect)**
|
||||
**[This is for Jellyfin >=10.8](https://github.com/9p4/jellyfin-plugin-sso/issues/3) and only on the Web UI or clients supporting [Quick Connect](https://jellyfin.org/docs/general/server/quick-connect)**
|
||||
|
||||
**This README reflects the branch it is currently on! Switch tags to view version-specific documentation!**
|
||||
|
||||
@@ -46,11 +48,13 @@ This is 100% alpha software! PRs are welcome to improve the code.
|
||||
- authentik
|
||||
- Keycloak
|
||||
- OIDC & SAML
|
||||
- Pocket ID
|
||||
- Kanidm
|
||||
- Google OpenID: Works, but usernames are all numeric
|
||||
|
||||
## Supported Protocols
|
||||
|
||||
- [OpenID](https://openid.net/what-is-openid/)
|
||||
- [OpenID](https://openid.net/developers/how-connect-works/)
|
||||
- [SAML](https://www.cloudflare.com/learning/access-management/what-is-saml/)
|
||||
|
||||
## Security
|
||||
@@ -91,8 +95,6 @@ The nightly build may have new features unavailable in other builds, but **be wa
|
||||
|
||||
## Examples
|
||||
|
||||
**Note that you should add both "/r/" and "/redirect/" paths to your SSO provider's configuration!**
|
||||
|
||||
### Creating A Login Button On The Main Page
|
||||
|
||||
In the Jellyfin administration UI, under "General", there is a "Branding" section. In that section, add the following code in the "Login disclaimer" block (replacing `PROVIDER_NAME` and the domain):
|
||||
@@ -311,7 +313,7 @@ Much thanks to the [Jellyfin LDAP plugin](https://github.com/jellyfin/jellyfin-p
|
||||
|
||||
I use the [AspNet SAML](https://github.com/jitbit/AspNetSaml/) library for the SAML side of things (patched to work with Base64 on non-Windows machines).
|
||||
|
||||
I use the [IdentityModel OIDC Client](https://github.com/IdentityModel/IdentityModel.OidcClient/) library for the OpenID side of things.
|
||||
I use the [Duende IdentityModel OIDC Client](https://github.com/DuendeSoftware/foss) library for the OpenID side of things.
|
||||
|
||||
Thanks to these projects, without which I would have been pulling my hair out implementing these protocols from scratch.
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Please email all security vulnerabilities and issues found to the email "contact at ersei dot net". If using LLMs/AI to find the issues, first verify the issue exists manually. Please do not publicly disclose security vulnerabilities until after a stable release for the fix has been released for 30 days.
|
||||
|
||||
The latest released version is the only supported version.
|
||||
@@ -9,7 +9,8 @@
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
|
||||
+164
-32
@@ -4,12 +4,14 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Mime;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Duende.IdentityModel.OidcClient;
|
||||
using Jellyfin.Data.Entities;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Plugin.SSO_Auth.Config;
|
||||
using Jellyfin.Plugin.SSO_Auth.Helpers;
|
||||
using MediaBrowser.Common.Api;
|
||||
@@ -41,28 +43,34 @@ public class SSOController : ControllerBase
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IAuthorizationContext _authContext;
|
||||
private readonly ILogger<SSOController> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ICryptoProvider _cryptoProvider;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private static readonly IDictionary<string, TimedAuthorizeState> StateManager = new Dictionary<string, TimedAuthorizeState>();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SSOController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{SSOController}"/> interface.</param>
|
||||
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
|
||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
||||
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="cryptoProvider">Instance of the <see cref="ICryptoProvider"/> interface.</param>
|
||||
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
|
||||
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
public SSOController(
|
||||
ILogger<SSOController> logger,
|
||||
ILoggerFactory loggerFactory,
|
||||
ISessionManager sessionManager,
|
||||
IUserManager userManager,
|
||||
IAuthorizationContext authContext,
|
||||
ICryptoProvider cryptoProvider,
|
||||
IProviderManager providerManager,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
@@ -70,8 +78,10 @@ public class SSOController : ControllerBase
|
||||
_authContext = authContext;
|
||||
_cryptoProvider = cryptoProvider;
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
_providerManager = providerManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger.LogInformation("SSO Controller initialized");
|
||||
}
|
||||
|
||||
@@ -100,6 +110,16 @@ public class SSOController : ControllerBase
|
||||
|
||||
if (config.Enabled)
|
||||
{
|
||||
if (string.IsNullOrEmpty(state))
|
||||
{
|
||||
return BadRequest("Missing state");
|
||||
}
|
||||
|
||||
if (!StateManager.TryGetValue(state, out var timedState))
|
||||
{
|
||||
return BadRequest("Invalid or expired state");
|
||||
}
|
||||
|
||||
var scopes = config.OidScopes == null ? new string[2] : config.OidScopes;
|
||||
var options = new OidcClientOptions
|
||||
{
|
||||
@@ -109,6 +129,17 @@ public class SSOController : ControllerBase
|
||||
RedirectUri = GetRequestBase(config.SchemeOverride, config.PortOverride) + $"/sso/OID/{(Request.Path.Value.Contains("/start/", StringComparison.InvariantCultureIgnoreCase) ? "redirect" : "r")}/" + provider,
|
||||
Scope = string.Join(" ", scopes.Prepend("openid profile")),
|
||||
DisablePushedAuthorization = config.DisablePushedAuthorization,
|
||||
LoggerFactory = _loggerFactory,
|
||||
LoadProfile = !config.DoNotLoadProfile,
|
||||
HttpClientFactory = o =>
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
System.Reflection.Assembly assembly = System.Reflection.Assembly.GetExecutingAssembly();
|
||||
System.Diagnostics.FileVersionInfo fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location);
|
||||
string version = fvi.FileVersion;
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd($"Jellyfin-Plugin-SSO-Auth +{version} (https://github.com/9p4/jellyfin-plugin-sso)");
|
||||
return client;
|
||||
}
|
||||
};
|
||||
var oidEndpointUri = new Uri(config.OidEndpoint?.Trim());
|
||||
options.Policy.Discovery.AdditionalEndpointBaseAddresses.Add(oidEndpointUri.GetLeftPart(UriPartial.Authority));
|
||||
@@ -116,7 +147,7 @@ public class SSOController : ControllerBase
|
||||
options.Policy.Discovery.RequireHttps = !config.DisableHttps;
|
||||
options.Policy.Discovery.ValidateIssuerName = !config.DoNotValidateIssuerName;
|
||||
var oidcClient = new OidcClient(options);
|
||||
var currentState = StateManager[state].State;
|
||||
var currentState = timedState.State;
|
||||
var result = await oidcClient.ProcessResponseAsync(Request.QueryString.Value, currentState).ConfigureAwait(false);
|
||||
|
||||
if (result.IsError)
|
||||
@@ -126,19 +157,19 @@ public class SSOController : ControllerBase
|
||||
|
||||
if (!config.EnableFolderRoles && config.EnabledFolders != null)
|
||||
{
|
||||
StateManager[state].Folders = new List<string>(config.EnabledFolders);
|
||||
timedState.Folders = new List<string>(config.EnabledFolders);
|
||||
}
|
||||
else
|
||||
{
|
||||
StateManager[state].Folders = new List<string>();
|
||||
timedState.Folders = new List<string>();
|
||||
}
|
||||
|
||||
StateManager[state].EnableLiveTv = config.EnableLiveTv;
|
||||
StateManager[state].EnableLiveTvManagement = config.EnableLiveTvManagement;
|
||||
timedState.EnableLiveTv = config.EnableLiveTv;
|
||||
timedState.EnableLiveTvManagement = config.EnableLiveTvManagement;
|
||||
|
||||
if (config.AvatarUrlFormat is not null)
|
||||
{
|
||||
StateManager[state].AvatarURL = result.User.Claims.Aggregate(
|
||||
timedState.AvatarURL = result.User.Claims.Aggregate(
|
||||
config.AvatarUrlFormat,
|
||||
(s, claim) => s.Contains($"@{{{claim.Type}}}") ? s.Replace($"@{{{claim.Type}}}", claim.Value) : s);
|
||||
}
|
||||
@@ -147,10 +178,10 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
if (claim.Type == (config.DefaultUsernameClaim?.Trim() ?? "preferred_username"))
|
||||
{
|
||||
StateManager[state].Username = claim.Value;
|
||||
timedState.Username = claim.Value;
|
||||
if (config.Roles == null || config.Roles.Length == 0)
|
||||
{
|
||||
StateManager[state].Valid = true;
|
||||
timedState.Valid = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,14 +207,40 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
// We recursively traverse through the JSON data for the roles and parse it
|
||||
var json = JsonConvert.DeserializeObject<IDictionary<string, object>>(claim.Value);
|
||||
for (int i = 1; i < segments.Length - 1; i++)
|
||||
if (json is null)
|
||||
{
|
||||
var segment = segments[i];
|
||||
json = (json[segment] as JObject).ToObject<IDictionary<string, object>>();
|
||||
roles = new List<string>();
|
||||
}
|
||||
else
|
||||
{
|
||||
bool missingSegment = false;
|
||||
for (int i = 1; i < segments.Length - 1; i++)
|
||||
{
|
||||
var segment = segments[i];
|
||||
if (!json.TryGetValue(segment, out var nextToken) || nextToken is not JObject nextObject)
|
||||
{
|
||||
missingSegment = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// The final step is to take the JSON and turn it from a dictionary into a string
|
||||
roles = (json[segments[^1]] as JArray).ToObject<List<string>>();
|
||||
json = nextObject.ToObject<IDictionary<string, object>>();
|
||||
if (json is null)
|
||||
{
|
||||
missingSegment = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (missingSegment || !json.TryGetValue(segments[^1], out var rolesToken) || rolesToken is not JArray rolesArray)
|
||||
{
|
||||
roles = new List<string>();
|
||||
}
|
||||
else
|
||||
{
|
||||
// The final step is to take the JSON and turn it from a dictionary into a string
|
||||
roles = rolesArray.ToObject<List<string>>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string role in roles)
|
||||
@@ -195,7 +252,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
if (role.Equals(validRoles))
|
||||
{
|
||||
StateManager[state].Valid = true;
|
||||
timedState.Valid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,7 +264,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
if (role.Equals(validAdminRoles))
|
||||
{
|
||||
StateManager[state].Admin = true;
|
||||
timedState.Admin = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,7 +276,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
if (role.Equals(folderRoleMap.Role?.Trim()))
|
||||
{
|
||||
StateManager[state].Folders.AddRange(folderRoleMap.Folders);
|
||||
timedState.Folders.AddRange(folderRoleMap.Folders);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,7 +290,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
if (role.Equals(validLiveTvRoles))
|
||||
{
|
||||
StateManager[state].EnableLiveTv = true;
|
||||
timedState.EnableLiveTv = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,7 +302,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
if (role.Equals(validLiveTvManagementRoles))
|
||||
{
|
||||
StateManager[state].EnableLiveTvManagement = true;
|
||||
timedState.EnableLiveTvManagement = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,24 +313,24 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
|
||||
// If the provider doesn't support the preferred username claim, then use the sub claim
|
||||
if (!StateManager[state].Valid)
|
||||
if (!timedState.Valid)
|
||||
{
|
||||
foreach (var claim in result.User.Claims)
|
||||
{
|
||||
if (claim.Type == "sub")
|
||||
{
|
||||
StateManager[state].Username = claim.Value;
|
||||
timedState.Username = claim.Value;
|
||||
if (config.Roles.Length == 0)
|
||||
{
|
||||
StateManager[state].Valid = true;
|
||||
timedState.Valid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool isLinking = StateManager[state].IsLinking;
|
||||
bool isLinking = timedState.IsLinking;
|
||||
|
||||
if (StateManager[state].Valid)
|
||||
if (timedState.Valid)
|
||||
{
|
||||
_logger.LogInformation($"Is request linking: {isLinking}");
|
||||
return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(config.SchemeOverride, config.PortOverride), mode: "OID", isLinking: isLinking), MediaTypeNames.Text.Html);
|
||||
@@ -282,7 +339,7 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"OpenID user {Username} has one or more incorrect role claims: {@Claims}. Expected any one of: {@ExpectedClaims}",
|
||||
StateManager[state].Username,
|
||||
timedState.Username,
|
||||
result.User.Claims.Select(o => new { o.Type, o.Value }),
|
||||
config.Roles);
|
||||
|
||||
@@ -334,6 +391,18 @@ public class SSOController : ControllerBase
|
||||
RedirectUri = redirectUri,
|
||||
Scope = string.Join(" ", config.OidScopes.Prepend("openid profile")),
|
||||
DisablePushedAuthorization = config.DisablePushedAuthorization,
|
||||
LoggerFactory = _loggerFactory,
|
||||
LoadProfile = !config.DoNotLoadProfile,
|
||||
HttpClientFactory = o =>
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
System.Reflection.Assembly assembly = System.Reflection.Assembly.GetExecutingAssembly();
|
||||
System.Diagnostics.FileVersionInfo fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location);
|
||||
string version = fvi.FileVersion;
|
||||
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd($"Jellyfin-Plugin-SSO-Auth +{version} (https://github.com/9p4/jellyfin-plugin-sso)");
|
||||
return client;
|
||||
}
|
||||
};
|
||||
var oidEndpointUri = new Uri(config.OidEndpoint?.Trim());
|
||||
options.Policy.Discovery.AdditionalEndpointBaseAddresses.Add(oidEndpointUri.GetLeftPart(UriPartial.Authority));
|
||||
@@ -456,8 +525,19 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
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(), kvp.Value.EnableLiveTv, kvp.Value.EnableLiveTvManagement, response, config.DefaultProvider?.Trim(), kvp.Value.AvatarURL)
|
||||
var authenticationResult = await Authenticate(
|
||||
userId,
|
||||
kvp.Value.Admin,
|
||||
config.EnableAuthorization,
|
||||
config.EnableAllFolders,
|
||||
kvp.Value.Folders.ToArray(),
|
||||
kvp.Value.EnableLiveTv,
|
||||
kvp.Value.EnableLiveTvManagement,
|
||||
response,
|
||||
config.DefaultProvider?.Trim(),
|
||||
kvp.Value.AvatarURL)
|
||||
.ConfigureAwait(false);
|
||||
StateManager.Remove(kvp.Key);
|
||||
return Ok(authenticationResult);
|
||||
}
|
||||
}
|
||||
@@ -498,6 +578,11 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
|
||||
|
||||
if (!samlResponse.IsValid())
|
||||
{
|
||||
return Problem("Invalid SAML signature");
|
||||
}
|
||||
|
||||
bool valid = false;
|
||||
|
||||
// If no roles are configured, don't use RBAC
|
||||
@@ -656,6 +741,12 @@ public class SSOController : ControllerBase
|
||||
bool liveTv = config.EnableLiveTv;
|
||||
bool liveTvManagement = config.EnableLiveTvManagement;
|
||||
var samlResponse = new Response(config.SamlCertificate, response.Data);
|
||||
|
||||
if (!samlResponse.IsValid())
|
||||
{
|
||||
return Problem("Invalid SAML signature");
|
||||
}
|
||||
|
||||
List<string> folders;
|
||||
if (!config.EnableFolderRoles && config.EnabledFolders != null)
|
||||
{
|
||||
@@ -721,7 +812,17 @@ public class SSOController : ControllerBase
|
||||
|
||||
Guid userId = await CreateCanonicalLinkAndUserIfNotExist("saml", provider, samlResponse.GetNameID());
|
||||
|
||||
var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), liveTv, liveTvManagement, response, config.DefaultProvider?.Trim(), null)
|
||||
var authenticationResult = await Authenticate(
|
||||
userId,
|
||||
isAdmin,
|
||||
config.EnableAuthorization,
|
||||
config.EnableAllFolders,
|
||||
folders.ToArray(),
|
||||
liveTv,
|
||||
liveTvManagement,
|
||||
response,
|
||||
config.DefaultProvider?.Trim(),
|
||||
null)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(authenticationResult);
|
||||
}
|
||||
@@ -772,7 +873,28 @@ public class SSOController : ControllerBase
|
||||
private async Task<Guid> CreateCanonicalLinkAndUserIfNotExist(string mode, string provider, string canonicalName)
|
||||
{
|
||||
User user = null;
|
||||
user = _userManager.GetUserByName(canonicalName);
|
||||
|
||||
// First try to get the user by its id in case it was already registered before
|
||||
Guid userId = Guid.Empty;
|
||||
try
|
||||
{
|
||||
userId = GetCanonicalLink(mode, provider, canonicalName);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
userId = Guid.Empty;
|
||||
}
|
||||
|
||||
// No userId found? Let's try and find the user by name instead
|
||||
if (userId == Guid.Empty)
|
||||
{
|
||||
user = _userManager.GetUserByName(canonicalName);
|
||||
}
|
||||
else
|
||||
{
|
||||
user = _userManager.GetUserById(userId);
|
||||
}
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogInformation($"SSO user {canonicalName} doesn't exist, creating...");
|
||||
@@ -787,7 +909,7 @@ public class SSOController : ControllerBase
|
||||
UpdateCanonicalLinkConfig(links, mode, provider);
|
||||
}
|
||||
|
||||
Guid userId = Guid.Empty;
|
||||
userId = Guid.Empty;
|
||||
try
|
||||
{
|
||||
userId = GetCanonicalLink(mode, provider, canonicalName);
|
||||
@@ -963,7 +1085,11 @@ public class SSOController : ControllerBase
|
||||
}
|
||||
|
||||
var samlResponse = new Response(config.SamlCertificate, response.Data);
|
||||
// TODO: Does saml response require further validation?
|
||||
|
||||
if (!samlResponse.IsValid())
|
||||
{
|
||||
return Problem("Invalid SAML signature");
|
||||
}
|
||||
|
||||
string providerUserId = samlResponse.GetNameID();
|
||||
|
||||
@@ -1073,7 +1199,13 @@ public class SSOController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
|
||||
System.Reflection.Assembly assembly = System.Reflection.Assembly.GetExecutingAssembly();
|
||||
System.Diagnostics.FileVersionInfo fvi = System.Diagnostics.FileVersionInfo.GetVersionInfo(assembly.Location);
|
||||
string version = fvi.FileVersion;
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd($"Jellyfin-Plugin-SSO-Auth +{version} (https://github.com/9p4/jellyfin-plugin-sso)");
|
||||
|
||||
var avatarResponse = await client.GetAsync(avatarUrl);
|
||||
|
||||
if (!avatarResponse.Content.Headers.TryGetValues("content-type", out var contentTypeList))
|
||||
|
||||
@@ -327,6 +327,11 @@ public class OidConfig
|
||||
/// Gets or sets a value indicating whether the OpenID issuer name is validated.
|
||||
/// </summary>
|
||||
public bool DoNotValidateIssuerName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the UserInfo endpoint is used to get profile data.
|
||||
/// </summary>
|
||||
public bool DoNotLoadProfile { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -480,7 +480,9 @@
|
||||
list of strings from the OIDC server.
|
||||
<br />
|
||||
For Keycloak, it is <code>realm_access.roles</code> by
|
||||
default.
|
||||
default for realm roles. For client roles, it is
|
||||
<code>resource_access.>clientId<.roles</code>
|
||||
(e.g. resource_access.jellyfin.roles)
|
||||
<br />
|
||||
For Authelia, it is <code>groups</code>
|
||||
</div>
|
||||
@@ -600,7 +602,10 @@
|
||||
type="checkbox"
|
||||
class="sso-toggle"
|
||||
/>
|
||||
<span>Disable Pushed Authorization (Insecure)</span>
|
||||
<span
|
||||
>Disable Pushed Authorization (Insecure). May be needed
|
||||
for Authelia.</span
|
||||
>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription"></div>
|
||||
</div>
|
||||
@@ -634,6 +639,23 @@
|
||||
<span>Do Not Validate OpenID Issuer Name (Insecure)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="checkboxContainer checkboxContainer-withDescription"
|
||||
>
|
||||
<label>
|
||||
<input
|
||||
is="emby-checkbox"
|
||||
id="DoNotLoadProfile"
|
||||
name="DoNotLoadProfile"
|
||||
type="checkbox"
|
||||
class="sso-toggle"
|
||||
/>
|
||||
<span>Do Not Load Profile Information</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
May be required for Cloudflare OpenID
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RoleClaim"
|
||||
@@ -651,16 +673,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RoleClaim">Port Override</label>
|
||||
<input is="emby-input"
|
||||
id="PortOverride"
|
||||
type="text"
|
||||
class="sso-text" />
|
||||
<div class="fieldDescription">
|
||||
If the plugin is redirecting to an incorrect port, set this to
|
||||
the appropiate port
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="RoleClaim"
|
||||
>Port Override</label
|
||||
>
|
||||
<input
|
||||
is="emby-input"
|
||||
id="PortOverride"
|
||||
type="text"
|
||||
class="sso-text"
|
||||
/>
|
||||
<div class="fieldDescription">
|
||||
If the plugin is redirecting to an incorrect port, set this
|
||||
to the appropiate port
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -61,14 +61,14 @@
|
||||
is="emby-linkbutton"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso"
|
||||
class="button-link"
|
||||
>help page</a
|
||||
>homepage</a
|
||||
>
|
||||
and
|
||||
<a
|
||||
is="emby-linkbutton"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso/projects/1"
|
||||
href="https://github.com/9p4/jellyfin-plugin-sso/issues"
|
||||
class="button-link"
|
||||
>roadmap
|
||||
>issue tracker
|
||||
</a>
|
||||
for more information.
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>SSO_Auth</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.SSO_Auth</RootNamespace>
|
||||
<AssemblyVersion>3.5.2.4</AssemblyVersion>
|
||||
<FileVersion>3.5.2.4</FileVersion>
|
||||
<AssemblyVersion>4.0.0.4</AssemblyVersion>
|
||||
<FileVersion>4.0.0.4</FileVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
@@ -30,8 +30,8 @@
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="6.0.1" />
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.*-*" />
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.11.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="6.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ public class Response
|
||||
/// <param name="certificateBytes">The certificate formatted as an array of bytes.</param>
|
||||
public Response(byte[] certificateBytes)
|
||||
{
|
||||
_certificate = new X509Certificate2(certificateBytes);
|
||||
_certificate = X509CertificateLoader.LoadCertificate(certificateBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+24
-5
@@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Jellyfin.Plugin.SSO_Auth;
|
||||
|
||||
/// <summary>
|
||||
@@ -9,7 +11,16 @@ public static class WebResponse
|
||||
/// The shared HTML between all of the responses.
|
||||
/// </summary>
|
||||
public static readonly string Base = @"<!DOCTYPE html>
|
||||
<html><head><meta name='viewport' content='width=device-width, initial-scale=1'></head><body>
|
||||
<html><head>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||
<style>
|
||||
body {
|
||||
background: #101010;
|
||||
color: #d1cfce;
|
||||
font-family: Noto Sans, Noto Sans HK, Noto Sans JP, Noto Sans KR, Noto Sans SC, Noto Sans TC, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head><body>
|
||||
<p>Logging in...</p>
|
||||
<noscript>Please enable Javascript to complete the login</noscript>
|
||||
<script>
|
||||
@@ -404,6 +415,14 @@ const sleep = (milliseconds) => {
|
||||
/// <returns>A string with the HTML to serve to the client.</returns>
|
||||
public static string Generator(string data, string provider, string baseUrl, string mode, bool isLinking = false)
|
||||
{
|
||||
// Strip out the protocol (http:// or https://) and convert the domain to Punycode
|
||||
var idnMapping = new IdnMapping();
|
||||
var protocolSeparatorIndex = baseUrl.IndexOf("//");
|
||||
var protocol = baseUrl.Substring(0, protocolSeparatorIndex + 2);
|
||||
var domain = baseUrl.Substring(protocolSeparatorIndex + 2);
|
||||
var punycodeDomain = idnMapping.GetAscii(domain);
|
||||
var punycodeBaseUrl = protocol + punycodeDomain;
|
||||
|
||||
return Base + @"
|
||||
async function link(request) {
|
||||
const jfCredentialsString = localStorage.getItem(""jellyfin_credentials"");
|
||||
@@ -417,7 +436,7 @@ async function link(request) {
|
||||
if (jfUser == null) return;
|
||||
if (jfToken == null) return;
|
||||
|
||||
const url = '" + $"{baseUrl}/sso/{mode}/Link/{provider}/" + @"' + jfUser;
|
||||
const url = '" + $"{punycodeBaseUrl}/sso/{mode}/Link/{provider}/" + @"' + jfUser;
|
||||
|
||||
return new Promise(resolve => {
|
||||
var xhr = new XMLHttpRequest();
|
||||
@@ -442,7 +461,7 @@ async function link(request) {
|
||||
|
||||
async function main() {
|
||||
localStorage.removeItem('jellyfin_credentials');
|
||||
document.getElementById('iframe-main').src = '" + baseUrl + @"/web/index.html';
|
||||
document.getElementById('iframe-main').src = '" + punycodeBaseUrl + @"/web/index.html';
|
||||
|
||||
var data = '" + data + @"';
|
||||
while (localStorage.getItem(""_deviceId2"") == null ||
|
||||
@@ -460,7 +479,7 @@ async function main() {
|
||||
|
||||
if (" + $"{isLinking}".ToLower() + @") await link(request);
|
||||
|
||||
var url = '" + baseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
|
||||
var url = '" + punycodeBaseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
|
||||
|
||||
let response = await new Promise(resolve => {
|
||||
var xhr = new XMLHttpRequest();
|
||||
@@ -484,7 +503,7 @@ async function main() {
|
||||
jfCreds['Servers'][0]['UserId'] = responseJson['User']['Id'];
|
||||
localStorage.setItem('jellyfin_credentials', JSON.stringify(jfCreds));
|
||||
localStorage.setItem('enableAutoLogin', 'true');
|
||||
window.location.replace('" + baseUrl + @"/web/index.html');
|
||||
window.location.replace('" + punycodeBaseUrl + @"/web/index.html');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
+6
-3
@@ -1,9 +1,9 @@
|
||||
name: "SSO Authentication"
|
||||
guid: "505ce9d1-d916-42fa-86ca-673ef241d7df"
|
||||
imageUrl: "https://raw.githubusercontent.com/9p4/jellyfin-plugin-sso/main/img/logo.png"
|
||||
version: "3.5.2.4"
|
||||
targetAbi: "10.9.0.0"
|
||||
framework: "net8.0"
|
||||
version: "4.0.0.4"
|
||||
targetAbi: "10.11.0.0"
|
||||
framework: "net9.0"
|
||||
owner: "9p4"
|
||||
overview: "Authenticate users against an SSO provider."
|
||||
description: |
|
||||
@@ -15,6 +15,9 @@ artifacts:
|
||||
- "Duende.IdentityModel.OidcClient.dll"
|
||||
- "Duende.IdentityModel.dll"
|
||||
changelog: |
|
||||
4.0.0.4: Fix security issue in SAML
|
||||
4.0.0.0: Jellyfin 10.11
|
||||
3.5.3.0: Allow for OID-provided avatars, various bugfixes and workarounds
|
||||
3.5.2.4: Updates for Jellyfin 10.9
|
||||
3.5.2.3: Improve OpenID discovery policy security rules, fix iOS login bugs related to cache
|
||||
3.5.2.2: Fix linking page when using new paths
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1730768919,
|
||||
"narHash": "sha256-8AKquNnnSaJRXZxc5YmF/WfmxiHX6MMZZasRP6RRQkE=",
|
||||
"lastModified": 1760934318,
|
||||
"narHash": "sha256-/oUYsC0lUCBory65VK+UHqCCsCspbL1Vgfcf1KUYqVw=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a04d33c0c3f1a59a2c1cb0c6e34cd24500e5a1dc",
|
||||
"rev": "87848bf0cc4f87717fc813a4575f07330c3e743c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
in {
|
||||
devShell.x86_64-linux =
|
||||
pkgs.mkShell { buildInputs = [ pkgs.nodePackages.prettier pkgs.dotnet-sdk_8 ]; };
|
||||
pkgs.mkShell { buildInputs = [ pkgs.nodePackages.prettier pkgs.dotnet-sdk_9 ]; };
|
||||
};
|
||||
}
|
||||
|
||||
+82
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
This plugin has been tested to work against various providers, though not all providers provide support for all of this plugins' features.
|
||||
|
||||
❗ Before you proceed, make sure you have another admin account if you are going to link SSO provider to the only admin account on the server, permission might get overwritten (see [#212](https://github.com/9p4/jellyfin-plugin-sso/issues/212)).
|
||||
|
||||
## TOC / Tested Providers:
|
||||
|
||||
This section is broken into providers that support Role-Based Access Control (RBAC), and those that do not
|
||||
@@ -10,8 +12,9 @@ This section is broken into providers that support Role-Based Access Control (RB
|
||||
|
||||
- ✅ [Authelia](#authelia)
|
||||
- ✅ [authentik](#authentik)
|
||||
- [✅ Keycloak](#keycloak-oidc)
|
||||
- ✅ [Keycloak](#keycloak-oidc)
|
||||
- Both [OIDC](#keycloak-oidc) & [SAML](#keycloak-saml)
|
||||
- ✅ [Pocket ID](#pocket-id)
|
||||
|
||||
### No RBAC Support
|
||||
|
||||
@@ -88,6 +91,7 @@ authelia:
|
||||
OidSecret: <redacted>
|
||||
RoleClaim: groups
|
||||
OidScopes: ["groups"]
|
||||
DisablePushedAuthorization: true
|
||||
```
|
||||
|
||||
## authentik
|
||||
@@ -222,3 +226,80 @@ keycloak:
|
||||
SamlClientId: <same-as-in-keycloak>
|
||||
SamlCertificate: <copied-from-xml-file>
|
||||
```
|
||||
|
||||
## Pocket ID
|
||||
|
||||
A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.
|
||||
|
||||
### Pocket ID Config
|
||||
|
||||
1. Login to you Pocket ID admin account
|
||||
1. Go to `Administration -> OCID Clients`
|
||||
1. Click `Add OCID Client`
|
||||
1. Give the client a name e.g. `Jellyfin`
|
||||
1. Set the `Clent Launch URL` to your Jellyfin endpoint
|
||||
1. Set the callbak url to `https://jellyfin.example.com/sso/OID/redirect/pocketid`. The `pocketid` part must match the `Name of OpenID Provider` in the Jellyfin SSO provider
|
||||
1. (optional) Enable PKCE if Jellyfin is an https endpoint
|
||||
1. (optional) Set a logo
|
||||
1. (optional) Set `Allowed User Groups`
|
||||
|
||||
### Jellyfin's Config
|
||||
|
||||
```yaml
|
||||
pocketid:
|
||||
OidEndpoint: https://pocketid.example.com/.well-known/openid-configuration
|
||||
OidClientId: <pocket-id-client-id>
|
||||
OidSecret: <pocket-id-secret>
|
||||
EnableAuthorization: true # (optional) If you want Jellyfin to read group permissions from pocket id
|
||||
RoleClaim: groups # (optional) If you want Jellyfin to be able to read group assignments from pocket id
|
||||
AdminRoles: admin # (optional) The pocket id group which will give a user Jellyfin admin privilges
|
||||
Roles: users # (optional) The pocket id group which will give a user Jellyfin access
|
||||
AvatarUrlFormat: @{picture} # (optional) This will pull each users pocket id photo into Jellyfin
|
||||
```
|
||||
|
||||
## Kanidm
|
||||
|
||||
Kanidm is a modern and simple identity management platform written in rust.
|
||||
|
||||
### Kanidm Config
|
||||
|
||||
```shell
|
||||
kanidm system oauth2 create jellyfin "Jellyfin" https://jellyfin.example.com/
|
||||
|
||||
# Set this to drop the trailing @idm.example.com in usernames
|
||||
kanidm system oauth2 prefer-short-username jellyfin
|
||||
|
||||
kanidm system oauth2 add-redirect-url jellyfin https://jellyfin.example.com/sso/OID/redirect/kanidm
|
||||
kanidm system oauth2 add-redirect-url jellyfin https://jellyfin.example.com/sso/OID/r/kanidm
|
||||
|
||||
# Optionally setup groups for Jellyfin
|
||||
kanidm group create jellyfin_admins
|
||||
kanidm group create jellyfin_users
|
||||
|
||||
kanidm system oauth2 update-scope-map jellyfin jellyfin_admins openid profile groups
|
||||
kanidm system oauth2 update-scope-map jellyfin jellyfin_users openid profile groups
|
||||
```
|
||||
|
||||
Get the secret used in the Jellyfin config with `kanidm system oauth2 show-basic-secret jellyfin`.
|
||||
|
||||
### Jellyfin's Config
|
||||
|
||||
```yaml
|
||||
kanidm:
|
||||
OidEndpoint: https://idm.example.com/oauth2/openid/jellyfin/
|
||||
OidClientId: jellyfin
|
||||
OidSecret: <kanidm-secret>
|
||||
# (optional) If you want Jellyfin to read group permissions from kanidm
|
||||
EnableAuthorization: true
|
||||
OidScopes:
|
||||
- groups
|
||||
RoleClaim: groups
|
||||
AdminsRoles:
|
||||
- jellyfin_admins@idm.example.com
|
||||
Roles:
|
||||
- jellyfin_users@idm.example.com
|
||||
# If in your setup admin accounts aren't members of the users group you need to add the admins group to roles as well
|
||||
- jellyfin_admins@idm.example.com
|
||||
# (optional) If you want the name attribute instead of the spn attribute as username
|
||||
DefaultUsernameClaim: preferred_username
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user