share quick connect state between instances
Move the pending requests and authorized secrets out of process so the initiate, authorize and exchange legs can land on different replicas.
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,9 +132,9 @@ 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");
|
||||
}
|
||||
@@ -160,9 +156,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,19 +167,21 @@ 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.TryConsumeAuthorizationAsync(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);
|
||||
|
||||
private string GenerateSecureRandom(int length = 32)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[length];
|
||||
@@ -190,42 +189,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,151 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Extensions.Json;
|
||||
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
|
||||
/// consumed with <c>GETDEL</c> so only one instance can ever hand out a given secret's access token.
|
||||
/// </summary>
|
||||
public sealed class RedisQuickConnectStore : IQuickConnectStore
|
||||
{
|
||||
private const string KeyPrefix = "jellyfin:quickconnect:";
|
||||
|
||||
private readonly IDatabase _db;
|
||||
private readonly InMemoryQuickConnectStore _fallback;
|
||||
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();
|
||||
_fallback = new InMemoryQuickConnectStore();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<QuickConnectResult?> GetRequestBySecretAsync(string secret, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = await _db.StringGetAsync(RequestKey(secret)).ConfigureAwait(false);
|
||||
if (raw.HasValue)
|
||||
{
|
||||
return JsonSerializer.Deserialize<QuickConnectResult>(raw.ToString(), JsonDefaults.Options);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogDegraded(ex);
|
||||
}
|
||||
|
||||
return await _fallback.GetRequestBySecretAsync(secret, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<QuickConnectResult?> GetRequestByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var secret = await _db.StringGetAsync(CodeKey(code)).ConfigureAwait(false);
|
||||
if (secret.HasValue)
|
||||
{
|
||||
return await GetRequestBySecretAsync(secret.ToString(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogDegraded(ex);
|
||||
}
|
||||
|
||||
return await _fallback.GetRequestByCodeAsync(code, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(request, JsonDefaults.Options);
|
||||
await _db.StringSetAsync(RequestKey(request.Secret), json, ttl).ConfigureAwait(false);
|
||||
await _db.StringSetAsync(CodeKey(request.Code), request.Secret, ttl).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogDegraded(ex);
|
||||
await _fallback.SetRequestAsync(request, expiresUtc, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAuthorizationAsync(string secret, AuthenticationResult authenticationResult, DateTime expiresUtc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ttl = expiresUtc - DateTime.UtcNow;
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(authenticationResult, JsonDefaults.Options);
|
||||
await _db.StringSetAsync(AuthorizationKey(secret), json, ttl).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogDegraded(ex);
|
||||
await _fallback.SetAuthorizationAsync(secret, authenticationResult, expiresUtc, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AuthenticationResult?> TryConsumeAuthorizationAsync(string secret, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = await _db.StringGetDeleteAsync(AuthorizationKey(secret)).ConfigureAwait(false);
|
||||
if (raw.HasValue)
|
||||
{
|
||||
return JsonSerializer.Deserialize<AuthenticationResult>(raw.ToString(), JsonDefaults.Options);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogDegraded(ex);
|
||||
}
|
||||
|
||||
return await _fallback.TryConsumeAuthorizationAsync(secret, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string RequestKey(string secret) => KeyPrefix + "request:" + secret;
|
||||
|
||||
private static string CodeKey(string code) => KeyPrefix + "code:" + code;
|
||||
|
||||
private static string AuthorizationKey(string secret) => KeyPrefix + "auth:" + secret;
|
||||
|
||||
private void LogDegraded(Exception exception)
|
||||
=> _logger.LogWarning(exception, "Quick connect state could not be shared through Redis; falling back to this instance only.");
|
||||
}
|
||||
Reference in New Issue
Block a user