Files
jellyfin-ha-src/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs
T
unkin-agent e1ca272d6c
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
fail quick connect closed on an unreachable valkey and restore the idempotent exchange
2026-09-26 14:28:45 +10:00

214 lines
8.0 KiB
C#

using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.QuickConnect;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.QuickConnect
{
/// <summary>
/// Quick connect implementation.
/// </summary>
public class QuickConnectManager : IQuickConnect
{
/// <summary>
/// The length of user facing codes.
/// </summary>
private const int CodeLength = 6;
/// <summary>
/// The time (in minutes) that the quick connect token is valid.
/// </summary>
private const int Timeout = 10;
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.
/// Should only be called at server startup when a singleton is created.
/// </summary>
/// <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,
IQuickConnectStore store)
{
_config = config;
_logger = logger;
_sessionManager = sessionManager;
_store = store;
}
/// <inheritdoc />
public bool IsEnabled => _config.Configuration.QuickConnectAvailable;
/// <summary>
/// Assert that quick connect is currently active and throws an exception if it is not.
/// </summary>
private void AssertActive()
{
if (!IsEnabled)
{
throw new AuthenticationException("Quick connect is not active on this server");
}
}
/// <inheritdoc/>
public async Task<QuickConnectResult> TryConnect(AuthorizationInfo authorizationInfo)
{
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.DeviceId);
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Device);
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Client);
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Version);
AssertActive();
var secret = GenerateSecureRandom();
var code = GenerateCode();
var result = new QuickConnectResult(
secret,
code,
DateTime.UtcNow,
authorizationInfo.DeviceId,
authorizationInfo.Device,
authorizationInfo.Client,
authorizationInfo.Version);
await _store.SetRequestAsync(result, ExpiryOf(result)).ConfigureAwait(false);
return result;
}
/// <inheritdoc/>
public async Task<QuickConnectResult> CheckRequestStatus(string secret)
{
AssertActive();
var result = await _store.GetRequestBySecretAsync(secret).ConfigureAwait(false);
if (result is null)
{
throw new ResourceNotFoundException("Unable to find request with provided secret");
}
return result;
}
/// <summary>
/// Generates a short code to display to the user to uniquely identify this request.
/// </summary>
/// <returns>A short, unique alphanumeric string.</returns>
private string GenerateCode()
{
Span<byte> raw = stackalloc byte[4];
int min = (int)Math.Pow(10, CodeLength - 1);
int max = (int)Math.Pow(10, CodeLength);
uint scale = uint.MaxValue;
while (scale == uint.MaxValue)
{
RandomNumberGenerator.Fill(raw);
scale = BitConverter.ToUInt32(raw);
}
int code = (int)(min + ((max - min) * (scale / (double)uint.MaxValue)));
return code.ToString(CultureInfo.InvariantCulture);
}
/// <inheritdoc/>
public async Task<bool> AuthorizeRequest(Guid userId, string code)
{
AssertActive();
var result = await _store.GetRequestByCodeAsync(code).ConfigureAwait(false);
if (result is null)
{
throw new ResourceNotFoundException("Unable to find request");
}
if (result.Authenticated)
{
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,
DeviceId = result.DeviceId,
DeviceName = result.DeviceName,
App = result.AppName,
AppVersion = result.AppVersion
}).ConfigureAwait(false);
result.Authenticated = true;
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);
return true;
}
/// <inheritdoc/>
public async Task<AuthenticationResult> GetAuthorizedRequest(string secret)
{
AssertActive();
var result = await _store.GetAuthorizationAsync(secret).ConfigureAwait(false);
if (result is null)
{
throw new ResourceNotFoundException("Unable to find request");
}
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)
{
Span<byte> bytes = stackalloc byte[length];
RandomNumberGenerator.Fill(bytes);
return Convert.ToHexString(bytes);
}
}
}