diff options
5 files changed, 192 insertions, 13 deletions
diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs index 161479e4ca..2fa7b649af 100644 --- a/Jellyfin.Api/Controllers/ApiKeyController.cs +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.ApiKeyDtos; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Security; using MediaBrowser.Model.Querying; @@ -32,31 +34,31 @@ public class ApiKeyController : BaseJellyfinApiController /// Get all keys. /// </summary> /// <response code="200">Api keys retrieved.</response> - /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns> + /// <returns>A <see cref="QueryResult{AuthenticationInfoDto}"/> with all keys.</returns> [HttpGet("Keys")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys() + public async Task<ActionResult<QueryResult<AuthenticationInfoDto>>> GetKeys() { var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false); - return new QueryResult<AuthenticationInfo>(keys); + return new QueryResult<AuthenticationInfoDto>(keys.Select(ToDto).ToList()); } /// <summary> /// Create a new api key. /// </summary> /// <param name="app">Name of the app using the authentication key.</param> - /// <response code="204">Api key created.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="200">Api key created.</response> + /// <returns>The created api key.</returns> [HttpPost("Keys")] [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CreateKey([FromQuery, Required] string app) + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<AuthenticationInfoDto>> CreateKey([FromQuery, Required] string app) { - await _authenticationManager.CreateApiKey(app).ConfigureAwait(false); + var key = await _authenticationManager.CreateApiKey(app).ConfigureAwait(false); - return NoContent(); + return ToDto(key); } /// <summary> @@ -74,4 +76,21 @@ public class ApiKeyController : BaseJellyfinApiController return NoContent(); } + + private static AuthenticationInfoDto ToDto(AuthenticationInfo info) + => new() + { + Id = info.Id, + AccessToken = info.AccessToken, + DeviceId = info.DeviceId, + AppName = info.AppName, + AppVersion = info.AppVersion, + DeviceName = info.DeviceName, + UserId = info.UserId, + IsActive = info.IsActive, + DateCreated = info.DateCreated, + DateRevoked = info.DateRevoked, + DateLastActivity = info.DateLastActivity, + UserName = info.UserName + }; } diff --git a/Jellyfin.Api/Models/ApiKeyDtos/AuthenticationInfoDto.cs b/Jellyfin.Api/Models/ApiKeyDtos/AuthenticationInfoDto.cs new file mode 100644 index 0000000000..945fded532 --- /dev/null +++ b/Jellyfin.Api/Models/ApiKeyDtos/AuthenticationInfoDto.cs @@ -0,0 +1,69 @@ +using System; + +namespace Jellyfin.Api.Models.ApiKeyDtos; + +/// <summary> +/// An API key. +/// </summary> +public class AuthenticationInfoDto +{ + /// <summary> + /// Gets or sets the identifier. + /// </summary> + public long Id { get; set; } + + /// <summary> + /// Gets or sets the access token. + /// </summary> + public string? AccessToken { get; set; } + + /// <summary> + /// Gets or sets the device identifier. + /// </summary> + public string? DeviceId { get; set; } + + /// <summary> + /// Gets or sets the name of the app using the key. + /// </summary> + public string? AppName { get; set; } + + /// <summary> + /// Gets or sets the application version. + /// </summary> + public string? AppVersion { get; set; } + + /// <summary> + /// Gets or sets the name of the device. + /// </summary> + public string? DeviceName { get; set; } + + /// <summary> + /// Gets or sets the user identifier. + /// </summary> + public Guid UserId { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this instance is active. + /// </summary> + public bool IsActive { get; set; } + + /// <summary> + /// Gets or sets the date the key was created. + /// </summary> + public DateTime DateCreated { get; set; } + + /// <summary> + /// Gets or sets the date the key was revoked. + /// </summary> + public DateTime? DateRevoked { get; set; } + + /// <summary> + /// Gets or sets the date of the last activity. + /// </summary> + public DateTime DateLastActivity { get; set; } + + /// <summary> + /// Gets or sets the user name. + /// </summary> + public string? UserName { get; set; } +} diff --git a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs index cf0293463f..2bc7b9f915 100644 --- a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs +++ b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs @@ -23,14 +23,25 @@ namespace Jellyfin.Server.Implementations.Security } /// <inheritdoc /> - public async Task CreateApiKey(string name) + public async Task<AuthenticationInfo> CreateApiKey(string name) { var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - dbContext.ApiKeys.Add(new ApiKey(name)); + var apiKey = new ApiKey(name); + dbContext.ApiKeys.Add(apiKey); await dbContext.SaveChangesAsync().ConfigureAwait(false); + + return new AuthenticationInfo + { + AppName = apiKey.Name, + AccessToken = apiKey.AccessToken, + DateCreated = apiKey.DateCreated, + DeviceId = string.Empty, + DeviceName = string.Empty, + AppVersion = string.Empty + }; } } diff --git a/MediaBrowser.Controller/Security/IAuthenticationManager.cs b/MediaBrowser.Controller/Security/IAuthenticationManager.cs index 79b2a2444e..aebb229e79 100644 --- a/MediaBrowser.Controller/Security/IAuthenticationManager.cs +++ b/MediaBrowser.Controller/Security/IAuthenticationManager.cs @@ -12,8 +12,8 @@ namespace MediaBrowser.Controller.Security /// Creates an API key. /// </summary> /// <param name="name">The name of the key.</param> - /// <returns>A task representing the creation of the key.</returns> - Task CreateApiKey(string name); + /// <returns>A task representing the creation of the key, containing the created key.</returns> + Task<AuthenticationInfo> CreateApiKey(string name); /// <summary> /// Gets the API keys. diff --git a/tests/Jellyfin.Server.Implementations.Tests/Security/AuthenticationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Security/AuthenticationManagerTests.cs new file mode 100644 index 0000000000..e46b88c67e --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Security/AuthenticationManagerTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Security; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Security +{ + public sealed class AuthenticationManagerTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly AuthenticationManager _authenticationManager; + + public AuthenticationManagerTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + // Create the schema + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())) + .ReturnsAsync(CreateDbContext); + + _authenticationManager = new AuthenticationManager(factory.Object); + } + + public void Dispose() + { + _connection.Dispose(); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } + + [Fact] + public async Task CreateApiKey_ReturnsInfoMatchingStoredKey() + { + var created = await _authenticationManager.CreateApiKey("test-app"); + + Assert.Equal("test-app", created.AppName); + Assert.False(string.IsNullOrEmpty(created.AccessToken)); + + var keys = await _authenticationManager.GetApiKeys(); + var stored = Assert.Single(keys); + Assert.Equal(created.AppName, stored.AppName); + Assert.Equal(created.AccessToken, stored.AccessToken); + } + + [Fact] + public async Task CreateApiKey_WithMultipleKeys_ReturnsDistinctTokens() + { + var first = await _authenticationManager.CreateApiKey("app1"); + var second = await _authenticationManager.CreateApiKey("app2"); + + Assert.NotEqual(first.AccessToken, second.AccessToken); + } + } +} |
