using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq;
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
{
///
/// Quick connect implementation.
///
public class QuickConnectManager : IQuickConnect
{
///
/// The length of user facing codes.
///
private const int CodeLength = 6;
///
/// The time (in minutes) that the quick connect token is valid.
///
private const int Timeout = 10;
private readonly ConcurrentDictionary _currentRequests = new ();
private readonly ConcurrentDictionary _authorizedSecrets = new ();
private readonly IServerConfigurationManager _config;
private readonly ILogger _logger;
private readonly ISessionManager _sessionManager;
///
/// Initializes a new instance of the class.
/// Should only be called at server startup when a singleton is created.
///
/// Configuration.
/// Logger.
/// Session Manager.
public QuickConnectManager(
IServerConfigurationManager config,
ILogger logger,
ISessionManager sessionManager)
{
_config = config;
_logger = logger;
_sessionManager = sessionManager;
}
///
public bool IsEnabled => _config.Configuration.QuickConnectAvailable;
///
/// Assert that quick connect is currently active and throws an exception if it is not.
///
private void AssertActive()
{
if (!IsEnabled)
{
throw new AuthenticationException("Quick connect is not active on this server");
}
}
///
public QuickConnectResult TryConnect(AuthorizationInfo authorizationInfo)
{
if (string.IsNullOrEmpty(authorizationInfo.DeviceId))
{
throw new ArgumentException(nameof(authorizationInfo.DeviceId) + " is required");
}
if (string.IsNullOrEmpty(authorizationInfo.Device))
{
throw new ArgumentException(nameof(authorizationInfo.Device) + " is required");
}
if (string.IsNullOrEmpty(authorizationInfo.Client))
{
throw new ArgumentException(nameof(authorizationInfo.Client) + " is required");
}
if (string.IsNullOrEmpty(authorizationInfo.Version))
{
throw new ArgumentException(nameof(authorizationInfo.Version) + "is required");
}
AssertActive();
ExpireRequests();
var secret = GenerateSecureRandom();
var code = GenerateCode();
var result = new QuickConnectResult(
secret,
code,
DateTime.UtcNow,
authorizationInfo.DeviceId,
authorizationInfo.Device,
authorizationInfo.Client,
authorizationInfo.Version);
_currentRequests[code] = result;
return result;
}
///
public 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))
{
throw new ResourceNotFoundException("Unable to find request with provided secret");
}
return result;
}
///
/// Generates a short code to display to the user to uniquely identify this request.
///
/// A short, unique alphanumeric string.
private string GenerateCode()
{
Span 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);
}
///
public async Task AuthorizeRequest(Guid userId, string code)
{
AssertActive();
ExpireRequests();
if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result))
{
throw new ResourceNotFoundException("Unable to find request");
}
if (result.Authenticated)
{
throw new InvalidOperationException("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));
var authenticationResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest
{
UserId = userId,
DeviceId = result.DeviceId,
DeviceName = result.DeviceName,
App = result.AppName,
AppVersion = result.AppVersion
}).ConfigureAwait(false);
_authorizedSecrets[result.Secret] = (DateTime.UtcNow, authenticationResult);
result.Authenticated = true;
_currentRequests[code] = result;
_logger.LogDebug("Authorizing device with code {Code} to login as user {UserId}", code, userId);
return true;
}
///
public AuthenticationResult GetAuthorizedRequest(string secret)
{
AssertActive();
ExpireRequests();
if (!_authorizedSecrets.TryGetValue(secret, out var result))
{
throw new ResourceNotFoundException("Unable to find request");
}
return result.AuthenticationResult;
}
private string GenerateSecureRandom(int length = 32)
{
Span bytes = stackalloc byte[length];
RandomNumberGenerator.Fill(bytes);
return Convert.ToHexString(bytes);
}
///
/// Expire quick connect requests that are over the time limit. If is true, all requests are unconditionally expired.
///
/// If true, all requests will be expired.
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);
}
}
}
}
}
}