using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Controller.Security;
using MediaBrowser.Model.QuickConnect;
using Microsoft.AspNetCore.Http;
using MediaBrowser.Common;
using Microsoft.Extensions.Logging;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
namespace Emby.Server.Implementations.QuickConnect
{
///
/// Quick connect implementation.
///
public class QuickConnectManager : IQuickConnect, IDisposable
{
private readonly RNGCryptoServiceProvider _rng = new RNGCryptoServiceProvider();
private readonly ConcurrentDictionary _currentRequests = new ConcurrentDictionary();
private readonly IServerConfigurationManager _config;
private readonly ILogger _logger;
private readonly IAuthenticationRepository _authenticationRepository;
private readonly IAuthorizationContext _authContext;
private readonly IServerApplicationHost _appHost;
///
/// Initializes a new instance of the class.
/// Should only be called at server startup when a singleton is created.
///
/// Configuration.
/// Logger.
/// Application host.
/// Authentication context.
/// Authentication repository.
public QuickConnectManager(
IServerConfigurationManager config,
ILogger logger,
IServerApplicationHost appHost,
IAuthorizationContext authContext,
IAuthenticationRepository authenticationRepository)
{
_config = config;
_logger = logger;
_appHost = appHost;
_authContext = authContext;
_authenticationRepository = authenticationRepository;
ReloadConfiguration();
}
///
public int CodeLength { get; set; } = 6;
///
public string TokenNamePrefix { get; set; } = "QuickConnect-";
///
public QuickConnectState State { get; private set; } = QuickConnectState.Unavailable;
///
public int Timeout { get; set; } = 5;
private DateTime DateActivated { get; set; }
///
public void AssertActive()
{
if (State != QuickConnectState.Active)
{
throw new ArgumentException("Quick connect is not active on this server");
}
}
///
public void Activate()
{
DateActivated = DateTime.Now;
SetState(QuickConnectState.Active);
}
///
public void SetState(QuickConnectState newState)
{
_logger.LogDebug("Changed quick connect state from {State} to {newState}", State, newState);
ExpireRequests(true);
State = newState;
_config.Configuration.QuickConnectAvailable = newState == QuickConnectState.Available || newState == QuickConnectState.Active;
_config.SaveConfiguration();
_logger.LogDebug("Configuration saved");
}
///
public QuickConnectResult TryConnect(string friendlyName)
{
ExpireRequests();
if (State != QuickConnectState.Active)
{
_logger.LogDebug("Refusing quick connect initiation request, current state is {State}", State);
throw new AuthenticationException("Quick connect is not active on this server");
}
_logger.LogDebug("Got new quick connect request from {friendlyName}", friendlyName);
var code = GenerateCode();
var result = new QuickConnectResult()
{
Secret = GenerateSecureRandom(),
FriendlyName = friendlyName,
DateAdded = DateTime.Now,
Code = code
};
_currentRequests[code] = result;
return result;
}
///
public QuickConnectResult CheckRequestStatus(string secret)
{
ExpireRequests();
AssertActive();
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;
}
///
public 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)
{
_rng.GetBytes(raw);
scale = BitConverter.ToUInt32(raw);
}
int code = (int)(min + ((max - min) * (scale / (double)uint.MaxValue)));
return code.ToString(CultureInfo.InvariantCulture);
}
///
public bool AuthorizeRequest(HttpRequest request, string code)
{
ExpireRequests();
AssertActive();
var auth = _authContext.GetAuthorizationInfo(request);
if (!_currentRequests.TryGetValue(code, out QuickConnectResult result))
{
throw new ResourceNotFoundException("Unable to find request");
}
if (result.Authenticated)
{
throw new InvalidOperationException("Request is already authorized");
}
result.Authentication = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
// 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.
var added = result.DateAdded ?? DateTime.Now.Subtract(new TimeSpan(0, Timeout, 0));
result.DateAdded = added.Subtract(new TimeSpan(0, Timeout - 1, 0));
_authenticationRepository.Create(new AuthenticationInfo
{
AppName = TokenNamePrefix + result.FriendlyName,
AccessToken = result.Authentication,
DateCreated = DateTime.UtcNow,
DeviceId = _appHost.SystemId,
DeviceName = _appHost.FriendlyName,
AppVersion = _appHost.ApplicationVersionString,
UserId = auth.UserId
});
_logger.LogInformation("Allowing device {FriendlyName} to login as user {Username} with quick connect code {Code}", result.FriendlyName, auth.User.Username, result.Code);
return true;
}
///
public int DeleteAllDevices(Guid user)
{
var raw = _authenticationRepository.Get(new AuthenticationInfoQuery()
{
DeviceId = _appHost.SystemId,
UserId = user
});
var tokens = raw.Items.Where(x => x.AppName.StartsWith(TokenNamePrefix, StringComparison.CurrentCulture));
var removed = 0;
foreach (var token in tokens)
{
_authenticationRepository.Delete(token);
_logger.LogDebug("Deleted token {AccessToken}", token.AccessToken);
removed++;
}
return removed;
}
///
/// Dispose.
///
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
///
/// Dispose.
///
/// Dispose unmanaged resources.
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_rng?.Dispose();
}
}
private string GenerateSecureRandom(int length = 32)
{
Span bytes = stackalloc byte[length];
_rng.GetBytes(bytes);
return Hex.Encode(bytes);
}
///
public void ExpireRequests(bool expireAll = false)
{
// Check if quick connect should be deactivated
if (State == QuickConnectState.Active && DateTime.Now > DateActivated.AddMinutes(Timeout) && !expireAll)
{
_logger.LogDebug("Quick connect time expired, deactivating");
SetState(QuickConnectState.Available);
expireAll = true;
}
// Expire stale connection requests
var code = string.Empty;
var values = _currentRequests.Values.ToList();
for (int i = 0; i < values.Count; i++)
{
var added = values[i].DateAdded ?? DateTime.UnixEpoch;
if (DateTime.Now > added.AddMinutes(Timeout) || expireAll)
{
code = values[i].Code;
_logger.LogDebug("Removing expired request {code}", code);
if (!_currentRequests.TryRemove(code, out _))
{
_logger.LogWarning("Request {code} already expired", code);
}
}
}
}
private void ReloadConfiguration()
{
State = _config.Configuration.QuickConnectAvailable ? QuickConnectState.Available : QuickConnectState.Unavailable;
}
}
}