Merge remote-tracking branch 'origin/main' into benvin/session-directory
# Conflicts: # .woodpecker/ci.yaml # Jellyfin.Server/CoreAppHost.cs # tests/Jellyfin.Server.Tests/HighAvailability/RedisTestServer.cs
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
@@ -30,12 +28,10 @@ namespace Emby.Server.Implementations.QuickConnect
|
||||
/// </summary>
|
||||
private const int Timeout = 10;
|
||||
|
||||
private readonly ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new();
|
||||
private readonly ConcurrentDictionary<string, (DateTime Timestamp, AuthenticationResult AuthenticationResult)> _authorizedSecrets = new();
|
||||
|
||||
private readonly IServerConfigurationManager _config;
|
||||
private readonly ILogger<QuickConnectManager> _logger;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IQuickConnectStore _store;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QuickConnectManager"/> class.
|
||||
@@ -44,14 +40,17 @@ namespace Emby.Server.Implementations.QuickConnect
|
||||
/// <param name="config">Configuration.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="sessionManager">Session Manager.</param>
|
||||
/// <param name="store">Quick connect store.</param>
|
||||
public QuickConnectManager(
|
||||
IServerConfigurationManager config,
|
||||
ILogger<QuickConnectManager> logger,
|
||||
ISessionManager sessionManager)
|
||||
ISessionManager sessionManager,
|
||||
IQuickConnectStore store)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
_sessionManager = sessionManager;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -69,7 +68,7 @@ namespace Emby.Server.Implementations.QuickConnect
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public QuickConnectResult TryConnect(AuthorizationInfo authorizationInfo)
|
||||
public async Task<QuickConnectResult> TryConnect(AuthorizationInfo authorizationInfo)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.DeviceId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Device);
|
||||
@@ -77,7 +76,6 @@ namespace Emby.Server.Implementations.QuickConnect
|
||||
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Version);
|
||||
|
||||
AssertActive();
|
||||
ExpireRequests();
|
||||
|
||||
var secret = GenerateSecureRandom();
|
||||
var code = GenerateCode();
|
||||
@@ -90,19 +88,17 @@ namespace Emby.Server.Implementations.QuickConnect
|
||||
authorizationInfo.Client,
|
||||
authorizationInfo.Version);
|
||||
|
||||
_currentRequests[code] = result;
|
||||
await _store.SetRequestAsync(result, ExpiryOf(result)).ConfigureAwait(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public QuickConnectResult CheckRequestStatus(string secret)
|
||||
public async Task<QuickConnectResult> CheckRequestStatus(string secret)
|
||||
{
|
||||
AssertActive();
|
||||
ExpireRequests();
|
||||
|
||||
string code = _currentRequests.Where(x => x.Value.Secret == secret).Select(x => x.Value.Code).DefaultIfEmpty(string.Empty).First();
|
||||
|
||||
if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result))
|
||||
var result = await _store.GetRequestBySecretAsync(secret).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
throw new ResourceNotFoundException("Unable to find request with provided secret");
|
||||
}
|
||||
@@ -136,21 +132,27 @@ namespace Emby.Server.Implementations.QuickConnect
|
||||
public async Task<bool> AuthorizeRequest(Guid userId, string code)
|
||||
{
|
||||
AssertActive();
|
||||
ExpireRequests();
|
||||
|
||||
if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result))
|
||||
var result = await _store.GetRequestByCodeAsync(code).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
throw new ResourceNotFoundException("Unable to find request");
|
||||
}
|
||||
|
||||
if (result.Authenticated)
|
||||
{
|
||||
throw new InvalidOperationException("Request is already authorized");
|
||||
throw new ConflictException("Request is already authorized");
|
||||
}
|
||||
|
||||
// Change the time on the request so it expires one minute into the future. It can't expire immediately as otherwise some clients wouldn't ever see that they have been authenticated.
|
||||
result.DateAdded = DateTime.UtcNow.Add(TimeSpan.FromMinutes(1));
|
||||
|
||||
// The guard above is a read on shared state, so it cannot settle a race between instances; the claim can.
|
||||
if (!await _store.TryClaimAuthorizationAsync(result.Secret, ExpiryOf(result)).ConfigureAwait(false))
|
||||
{
|
||||
throw await RefusedClaimAsync(result.Secret).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var authenticationResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest
|
||||
{
|
||||
UserId = userId,
|
||||
@@ -160,9 +162,10 @@ namespace Emby.Server.Implementations.QuickConnect
|
||||
AppVersion = result.AppVersion
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_authorizedSecrets[result.Secret] = (DateTime.UtcNow, authenticationResult);
|
||||
result.Authenticated = true;
|
||||
_currentRequests[code] = result;
|
||||
|
||||
await _store.SetAuthorizationAsync(result.Secret, authenticationResult, DateTime.UtcNow.AddMinutes(Timeout)).ConfigureAwait(false);
|
||||
await _store.SetRequestAsync(result, ExpiryOf(result)).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Authorizing device with code {Code} to login as user {UserId}", code, userId);
|
||||
|
||||
@@ -170,17 +173,33 @@ namespace Emby.Server.Implementations.QuickConnect
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public AuthenticationResult GetAuthorizedRequest(string secret)
|
||||
public async Task<AuthenticationResult> GetAuthorizedRequest(string secret)
|
||||
{
|
||||
AssertActive();
|
||||
ExpireRequests();
|
||||
|
||||
if (!_authorizedSecrets.TryGetValue(secret, out var result))
|
||||
var result = await _store.GetAuthorizationAsync(secret).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
throw new ResourceNotFoundException("Unable to find request");
|
||||
}
|
||||
|
||||
return result.AuthenticationResult;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static DateTime ExpiryOf(QuickConnectResult request) => request.DateAdded.AddMinutes(Timeout);
|
||||
|
||||
/// <summary>
|
||||
/// Explains a refused claim. The claim outlives a failed mint on purpose, so it can mean either
|
||||
/// that the request is authorized or that authorizing it did not finish; the two are told apart
|
||||
/// by re-reading the request rather than reported as the same thing.
|
||||
/// </summary>
|
||||
private async Task<ConflictException> RefusedClaimAsync(string secret)
|
||||
{
|
||||
var current = await _store.GetRequestBySecretAsync(secret).ConfigureAwait(false);
|
||||
|
||||
return current?.Authenticated == true
|
||||
? new ConflictException("Request is already authorized")
|
||||
: new ConflictException("Request is being authorized elsewhere, or an earlier attempt to authorize it did not complete. Start quick connect again for a new code.");
|
||||
}
|
||||
|
||||
private string GenerateSecureRandom(int length = 32)
|
||||
@@ -190,42 +209,5 @@ namespace Emby.Server.Implementations.QuickConnect
|
||||
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expire quick connect requests that are over the time limit. If <paramref name="expireAll"/> is true, all requests are unconditionally expired.
|
||||
/// </summary>
|
||||
/// <param name="expireAll">If true, all requests will be expired.</param>
|
||||
private void ExpireRequests(bool expireAll = false)
|
||||
{
|
||||
// All requests before this timestamp have expired
|
||||
var minTime = DateTime.UtcNow.AddMinutes(-Timeout);
|
||||
|
||||
// Expire stale connection requests
|
||||
foreach (var (_, currentRequest) in _currentRequests)
|
||||
{
|
||||
if (expireAll || currentRequest.DateAdded < minTime)
|
||||
{
|
||||
var code = currentRequest.Code;
|
||||
_logger.LogDebug("Removing expired request {Code}", code);
|
||||
|
||||
if (!_currentRequests.TryRemove(code, out _))
|
||||
{
|
||||
_logger.LogWarning("Request {Code} already expired", code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (secret, (timestamp, _)) in _authorizedSecrets)
|
||||
{
|
||||
if (expireAll || timestamp < minTime)
|
||||
{
|
||||
_logger.LogDebug("Removing expired secret {Secret}", secret);
|
||||
if (!_authorizedSecrets.TryRemove(secret, out _))
|
||||
{
|
||||
_logger.LogWarning("Secret {Secret} already expired", secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.QuickConnect;
|
||||
using MediaBrowser.Model.QuickConnect;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Emby.Server.Implementations.QuickConnect;
|
||||
|
||||
/// <summary>
|
||||
/// A Redis-backed <see cref="IQuickConnectStore"/> that lets the initiate, authorize and exchange legs
|
||||
/// of a quick connect flow land on different instances. Expiry is the key TTL and an authorization is
|
||||
/// claimed with a Lua check-and-set, so only one instance can ever mint a given secret's access token.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// There is no local fallback: a call Redis did not answer is inconclusive, and reporting it as a miss
|
||||
/// would tell a polling client its secret is invalid. Quick connect is unavailable for as long as Redis
|
||||
/// is, which password login is not.
|
||||
/// </remarks>
|
||||
public sealed class RedisQuickConnectStore : IQuickConnectStore
|
||||
{
|
||||
private const string KeyPrefix = "jellyfin:quickconnect:";
|
||||
|
||||
/// <summary>
|
||||
/// Lua script writing the two keys a request is resolvable by in one step, so it can never be
|
||||
/// reachable by its secret while the code the user is reading off the screen resolves to nothing.
|
||||
/// </summary>
|
||||
private const string SetRequestScript = @"
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3])
|
||||
redis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[3])
|
||||
return 1";
|
||||
|
||||
/// <summary>
|
||||
/// Lua script for the atomic claim of the sole right to authorize a request: the request has to
|
||||
/// exist and not already be authorized, and the claim marker is taken with <c>SET NX</c>, so of two
|
||||
/// instances racing on one code exactly one goes on to mint an access token.
|
||||
/// </summary>
|
||||
private const string ClaimAuthorizationScript = @"
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then return 0 end
|
||||
if cjson.decode(raw)['Authenticated'] then return 0 end
|
||||
if redis.call('SET', KEYS[2], '1', 'NX', 'PX', ARGV[1]) then return 1 end
|
||||
return 0";
|
||||
|
||||
private readonly IDatabase _db;
|
||||
private readonly ILogger<RedisQuickConnectStore> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedisQuickConnectStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public RedisQuickConnectStore(IConnectionMultiplexer redis, ILogger<RedisQuickConnectStore> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(redis);
|
||||
|
||||
_db = redis.GetDatabase();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<QuickConnectResult?> GetRequestBySecretAsync(string secret, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var raw = await CallAsync(() => _db.StringGetAsync(RequestKey(secret))).ConfigureAwait(false);
|
||||
|
||||
// Deserialization is outside the guard: a malformed stored value is a fault of its own, not Redis
|
||||
// being unavailable.
|
||||
return raw.HasValue ? JsonSerializer.Deserialize<QuickConnectResult>(raw.ToString(), JsonDefaults.Options) : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<QuickConnectResult?> GetRequestByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var secret = await CallAsync(() => _db.StringGetAsync(CodeKey(code))).ConfigureAwait(false);
|
||||
|
||||
return secret.HasValue
|
||||
? await GetRequestBySecretAsync(secret.ToString(), cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetRequestAsync(QuickConnectResult request, DateTime expiresUtc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var ttl = expiresUtc - DateTime.UtcNow;
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(request, JsonDefaults.Options);
|
||||
await CallAsync(() => _db.ScriptEvaluateAsync(
|
||||
SetRequestScript,
|
||||
keys: new RedisKey[] { RequestKey(request.Secret), CodeKey(request.Code) },
|
||||
values: new RedisValue[] { json, request.Secret, (long)ttl.TotalMilliseconds })).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TryClaimAuthorizationAsync(string secret, DateTime expiresUtc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ttl = expiresUtc - DateTime.UtcNow;
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var claimed = (long?)await CallAsync(() => _db.ScriptEvaluateAsync(
|
||||
ClaimAuthorizationScript,
|
||||
keys: new RedisKey[] { RequestKey(secret), ClaimKey(secret) },
|
||||
values: new RedisValue[] { (long)ttl.TotalMilliseconds })).ConfigureAwait(false);
|
||||
|
||||
return claimed == 1;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAuthorizationAsync(string secret, AuthenticationResult authenticationResult, DateTime expiresUtc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ttl = expiresUtc - DateTime.UtcNow;
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(authenticationResult, JsonDefaults.Options);
|
||||
await CallAsync(() => _db.StringSetAsync(AuthorizationKey(secret), json, ttl)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AuthenticationResult?> GetAuthorizationAsync(string secret, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var raw = await CallAsync(() => _db.StringGetAsync(AuthorizationKey(secret))).ConfigureAwait(false);
|
||||
|
||||
return raw.HasValue
|
||||
? JsonSerializer.Deserialize<AuthenticationResult>(raw.ToString(), JsonDefaults.Options)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string RequestKey(string secret) => KeyPrefix + "request:" + secret;
|
||||
|
||||
private static string CodeKey(string code) => KeyPrefix + "code:" + code;
|
||||
|
||||
private static string ClaimKey(string secret) => KeyPrefix + "claim:" + secret;
|
||||
|
||||
private static string AuthorizationKey(string secret) => KeyPrefix + "auth:" + secret;
|
||||
|
||||
private async Task<T> CallAsync<T>(Func<Task<T>> call)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await call().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is RedisException or RedisCommandException or TimeoutException)
|
||||
{
|
||||
_logger.LogError(exception, "Quick connect state could not be reached in Redis.");
|
||||
throw new ServiceUnavailableException("Quick connect is temporarily unavailable.", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user